mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-08 09:42:55 -04:00
union for all
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
-- pokeyellow engine/events/pokecenter_chansey.asm:1
|
||||
local function chansey()
|
||||
return {
|
||||
{ "play_cry", "CHANSEY", true },
|
||||
{ "show_text", "_NurseChanseyText" },
|
||||
}
|
||||
end
|
||||
|
||||
local TEXTS = {
|
||||
VIRIDIAN_POKECENTER = "TEXT_VIRIDIANPOKECENTER_CHANSEY", -- scripts/ViridianPokecenter.asm:29
|
||||
PEWTER_POKECENTER = "TEXT_PEWTERPOKECENTER_CHANSEY", -- scripts/PewterPokecenter.asm:39
|
||||
MT_MOON_POKECENTER = "TEXT_MTMOONPOKECENTER_CHANSEY", -- scripts/MtMoonPokecenter.asm:40
|
||||
CERULEAN_POKECENTER = "TEXT_CERULEANPOKECENTER_CHANSEY", -- scripts/CeruleanPokecenter.asm:29
|
||||
ROCK_TUNNEL_POKECENTER = "TEXT_ROCKTUNNELPOKECENTER_CHANSEY", -- scripts/RockTunnelPokecenter.asm:29
|
||||
VERMILION_POKECENTER = "TEXT_VERMILIONPOKECENTER_CHANSEY", -- scripts/VermilionPokecenter.asm:29
|
||||
LAVENDER_POKECENTER = "TEXT_LAVENDERPOKECENTER_CHANSEY", -- scripts/LavenderPokecenter.asm:29
|
||||
CELADON_POKECENTER = "TEXT_CELADONPOKECENTER_CHANSEY", -- scripts/CeladonPokecenter.asm:29
|
||||
SAFFRON_POKECENTER = "TEXT_SAFFRONPOKECENTER_CHANSEY", -- scripts/SaffronPokecenter.asm:29
|
||||
FUCHSIA_POKECENTER = "TEXT_FUCHSIAPOKECENTER_CHANSEY", -- scripts/FuchsiaPokecenter.asm:29
|
||||
CINNABAR_POKECENTER = "TEXT_CINNABARPOKECENTER_CHANSEY", -- scripts/CinnabarPokecenter.asm:29
|
||||
INDIGO_PLATEAU_LOBBY = "TEXT_INDIGOPLATEAULOBBY_CHANSEY", -- scripts/IndigoPlateauLobby.asm:42
|
||||
}
|
||||
|
||||
local M = {}
|
||||
for mapId, text in pairs(TEXTS) do
|
||||
M[mapId] = { talk = { [text] = chansey() } }
|
||||
end
|
||||
return M
|
||||
@@ -37,7 +37,8 @@ for _, file in ipairs({ "data.scripts.story", "data.scripts.story2",
|
||||
"data.scripts.story5", "data.scripts.story6",
|
||||
"data.scripts.story7", "data.scripts.flavor_all",
|
||||
"data.scripts.safari", "data.scripts.seafoam",
|
||||
"data.scripts.gyms" }) do
|
||||
"data.scripts.gyms",
|
||||
"data.scripts.pokecenter_upstairs" }) do
|
||||
for mapId, mod in pairs(require(file)) do
|
||||
MapScripts.attachBase(mapId, mod)
|
||||
end
|
||||
@@ -49,7 +50,8 @@ if GameVersion.isYellow() then
|
||||
for _, file in ipairs({ "data.scripts.yellow_gifts",
|
||||
"data.scripts.yellow_jessie_james",
|
||||
"data.scripts.yellow_beach_house",
|
||||
"data.scripts.yellow_viridian_old_man" }) do
|
||||
"data.scripts.yellow_viridian_old_man",
|
||||
"data.scripts.flavor.pokecenter_chansey" }) do
|
||||
for mapId, mod in pairs(require(file)) do
|
||||
MapScripts.attachBase(mapId, mod)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
local UnionCenters = require("src.world.gen1.UnionCenters")
|
||||
local UnionRoomMap = require("src.world.gen1.UnionRoomMap")
|
||||
local Origin = require("src.online.union.Origin")
|
||||
local Presence = require("src.world.gen1.UnionRoomPresence")
|
||||
local Strings = require("src.core.Strings")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
local GATE = UnionCenters.GATE_2F
|
||||
local STAIRS = UnionCenters.STAIRS_2F
|
||||
|
||||
local WELCOME = Strings("This is the UNION\nROOM. TRAINERS\vfrom near and far\vmeet up inside.\fWould you like to\ngo in?")
|
||||
local ENJOY = Strings("Step right in!\nHave fun!")
|
||||
|
||||
local function say(game, text, after, opts)
|
||||
game.stack:push(TextBox.new(game, text, after, opts))
|
||||
end
|
||||
|
||||
local function gateIds(game)
|
||||
local r = UnionCenters.forData(game.data)
|
||||
return r and r.gateClosed, r and r.gateOpen
|
||||
end
|
||||
|
||||
local function walkIn(game)
|
||||
local _, open = gateIds(game)
|
||||
local ex, ey, facing = UnionRoomMap.entry()
|
||||
return {
|
||||
{ "replace_block", GATE.bx, GATE.by, open },
|
||||
{ "play_sound", "Switch" },
|
||||
{ "wait", 20 },
|
||||
{ "move_player", "left", 1 },
|
||||
{ "move_player", "up", 3 },
|
||||
{ "warp", UnionCenters.UNION_ROOM, ex, ey, facing },
|
||||
}
|
||||
end
|
||||
|
||||
local function walkOut(game)
|
||||
local closed = gateIds(game)
|
||||
return {
|
||||
{ "move_player", "down", 3 },
|
||||
{ "replace_block", GATE.bx, GATE.by, closed },
|
||||
{ "play_sound", "Switch" },
|
||||
{ "face_player_dir", "right" },
|
||||
{ "face_object", UnionCenters.UNION_RECEPTIONIST, "left" },
|
||||
{ "show_text", "_CableClubNPCPleaseComeAgainText" },
|
||||
{ "face_object", UnionCenters.UNION_RECEPTIONIST, "down" },
|
||||
}
|
||||
end
|
||||
|
||||
local function unionReceptionist(game, ow, npc, done)
|
||||
local t = game.data.text
|
||||
if not game.save.flags.EVENT_GOT_POKEDEX then
|
||||
say(game, t._CableClubNPCMakingPreparationsText, done)
|
||||
return
|
||||
end
|
||||
local function decline() say(game, t._CableClubNPCPleaseComeAgainText, done) end
|
||||
say(game, WELCOME, nil, { choice = function(yes)
|
||||
if not yes then decline() return end
|
||||
say(game, t._WouldYouLikeToSaveText, nil, { choice = function(save)
|
||||
if not save then decline() return end
|
||||
game:writeSave()
|
||||
say(game, t._GameSavedText, function()
|
||||
say(game, ENJOY, function()
|
||||
ow.runner:run(walkIn(game), { npc = npc, onDone = done })
|
||||
end)
|
||||
end, { auto = {
|
||||
sound = function() return require("src.core.Sound").play(game.data, "Save") end,
|
||||
delay = 30,
|
||||
} })
|
||||
end })
|
||||
end })
|
||||
end
|
||||
|
||||
local function recordOrigin(game, fromMapId)
|
||||
local plan = UnionCenters.planFor(game.data, fromMapId)
|
||||
if not plan then return end
|
||||
Origin.record(game.save, {
|
||||
gen = 1, version = require("src.core.GameVersion").get(),
|
||||
map = plan.map, warp = plan.warp, x = plan.stairs.x, y = plan.stairs.y,
|
||||
facing = "down",
|
||||
})
|
||||
end
|
||||
|
||||
local function returnDown(game, ow, x, y)
|
||||
local o = Origin.get(game.save)
|
||||
local plan = o and o.gen == 1 and UnionCenters.planFor(game.data, o.map)
|
||||
Origin.clear(game.save)
|
||||
if plan and plan.warp == o.warp then
|
||||
ow:takeWarp({ x = x, y = y, destMap = plan.map, destWarp = plan.warp })
|
||||
else
|
||||
ow:warpToHealPoint()
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
[UnionCenters.FLOOR_2F] = {
|
||||
onEnter = function(game, ow, fromMapId)
|
||||
local closed, open = gateIds(game)
|
||||
if fromMapId == UnionCenters.UNION_ROOM then
|
||||
ow:replaceBlock(GATE.bx, GATE.by, open)
|
||||
ow:queueScript(walkOut(game))
|
||||
return
|
||||
end
|
||||
if ow.map:blockAt(GATE.bx, GATE.by) ~= closed then
|
||||
ow:replaceBlock(GATE.bx, GATE.by, closed)
|
||||
end
|
||||
recordOrigin(game, fromMapId)
|
||||
end,
|
||||
onStep = function(game, ow, x, y)
|
||||
if x ~= STAIRS.x or y ~= STAIRS.y then return false end
|
||||
returnDown(game, ow, x, y)
|
||||
return true
|
||||
end,
|
||||
talk = {
|
||||
[UnionCenters.TEXT_UNION] = unionReceptionist,
|
||||
},
|
||||
},
|
||||
[UnionCenters.UNION_ROOM] = {
|
||||
onEnter = function(game, ow) Presence.enter(game, ow) end,
|
||||
talk = {
|
||||
[Presence.TEXT] = Presence.talk,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -19,6 +19,11 @@ Features intentionally added to the games themselves, beyond what the original c
|
||||
* **Name badges over trainers** in Gen 3 link rooms (Union Room, Trade Center, Colosseum, Record Corner, Berry Blender), your own included
|
||||
* **Chat, trade and battle icons** on those badges, so a busy trainer is visible at a glance
|
||||
* **Idle Union Room trainers wander**, a few steps around their spot and back
|
||||
* **Cross-generation Union Room** online for every game: Gen 1, 2 and 3 players share one 40-player room, each shown with their own game's sprite and a circled 1/2/3 badge
|
||||
* **Union Room in Red, Blue, Yellow, Gold, Silver and Crystal** upstairs in every Pokémon Center, on by default; Launcher Options -> Union Room OFF restores the original Centers
|
||||
* **Union Room in Ruby and Sapphire**, a door added to every Pokémon Center 2F
|
||||
* **Cross-generation battles** under one Gen 3 ruleset limited to the older game's Pokémon and moves, with substitutes, move fixes and type-covering rentals that never touch the save
|
||||
* **Cross-generation trades** with both players shown every permanent change before confirming, journaled so an interrupted trade finishes exactly once
|
||||
* **Translation mods stay online** on Gen 3: mods that only change text, fonts, names or visuals can still link, trade and battle
|
||||
* **Button remapping** on every game: OPTION -> CONTROLS (keys, pads, L/R on FireRed/LeafGreen/Emerald, fast-forward buttons)
|
||||
* **Full controller support**: both thumbsticks move and navigate menus, and stick directions, triggers, stick clicks and extra SDL buttons can be bound in CONTROLS
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
# Cross-generation Union Room
|
||||
|
||||
Gen 1, 2 and 3 players meet in one Union Room over the relay, battle and
|
||||
trade. Cross-generation battles and trades are a feature of this app, not of
|
||||
the original games.
|
||||
|
||||
## Setting
|
||||
|
||||
Launcher Options -> Union Room (default ON; a missing preference counts as
|
||||
ON, an explicit OFF is kept). OFF restores the original Gen 1 and Gen 2
|
||||
Pokemon Centers: no stairs, no 2F additions, the Gen 1 cable club desk back on
|
||||
1F. Gen 3 Union Rooms always work. Map changes are applied to a fresh copy of
|
||||
the map data at load, so toggling never accumulates edits.
|
||||
|
||||
## Saves
|
||||
|
||||
A save written anywhere the original game has no such cell (Gen 1 2F, any
|
||||
added Union Room, Gen 2 2F added columns, the Ruby/Sapphire added door and
|
||||
room, the FireRed/LeafGreen/Emerald 40-player room) records the player in
|
||||
front of the origin Pokemon Center's 1F nurse desk, facing up. Loading such a
|
||||
save from an older build moves the player there too, with the setting ON or
|
||||
OFF. Cart-format exports never contain an added map or cell.
|
||||
|
||||
## Imports
|
||||
|
||||
- Entering the room, battling and trading need only the game being played.
|
||||
- Another player's real sprite needs their game family imported (Gen 1: Red,
|
||||
Blue or Yellow; Gen 2 male: Gold, Silver or Crystal; Gen 2 female: Crystal;
|
||||
FireRed/LeafGreen classes: FireRed or LeafGreen; Emerald classes: Emerald;
|
||||
Ruby/Sapphire player: Ruby, Sapphire or Emerald). Otherwise they appear as an
|
||||
ordinary trainer from your own game, chosen the same way every time, with
|
||||
their generation badge; talking to them names the import that shows their
|
||||
real look.
|
||||
|
||||
## Battles
|
||||
|
||||
- Same generation (Gen 1 vs Gen 1, Gen 2 vs Gen 2, Gen 3 vs Gen 3): the
|
||||
existing link battle for that generation.
|
||||
- Cross-generation: ruleset `g3u`. Gen 3 battle mechanics, run on every
|
||||
client from one move and species-type table taken from the lower-generation
|
||||
player's own game (Gen 1 table against any Gen 2/3 player, Gen 2 table
|
||||
against a Gen 3 player) and sent once at battle start (numbers only).
|
||||
Species and moves are limited to that table (151/165 with a Gen 1 player,
|
||||
251/251 with Gen 2). With a Gen 1/2 player present: abilities and held items
|
||||
off, natures neutral. Both clients run the same lockstep core
|
||||
(`src/battle/g3u/`) and compare a state hash every turn.
|
||||
- Preparation (temporary, never written to the save): rules, team problems,
|
||||
substitutes from your party and PC, rentals, move fixes, team size,
|
||||
confirm. Any change after confirming clears both confirmations. The
|
||||
opponent's team is never shown.
|
||||
|
||||
Source of truth: `src/online/xgen/Policy.lua` (`Policy.BATTLE`, `Policy.TRADE`
|
||||
field tables, constants).
|
||||
|
||||
## Identity
|
||||
|
||||
- Species: national dex number. Gen 1/2 caches give it as `dex` on each
|
||||
`pokemon.lua` row; Gen 3 via `national.lua` (`toSpecies[national]` is the
|
||||
internal species id).
|
||||
- Moves: canonical move number. Gen 1/2 rows carry `index`; Gen 3 ids are
|
||||
already canonical (1..354). Gen 1 = 1..165, Gen 2 = 1..251.
|
||||
- Names are normalized (upper case, ♀/♂ -> F/M, é -> E, only A-Z0-9 kept)
|
||||
and `Identity.agree(a, b)` asserts that every shared species and move id
|
||||
has the same normalized name in both caches (a truncated name that is a
|
||||
subsequence of the other also counts as the same). Checked over all 55
|
||||
pairs of the 11 local caches: zero mismatches.
|
||||
- Types: canonical names NORMAL..DARK plus MYSTERY. Gen 1/2 `PSYCHIC_TYPE`
|
||||
-> PSYCHIC, `CURSE_TYPE` -> MYSTERY. Gen 3 type ids map through the pret
|
||||
`TYPE_*` constants; the cache's truncated display names (ELECTR, PSYCHC) are
|
||||
checked as subsequences.
|
||||
- Items: canonical key = normalized display name. Gen 1 has no held items.
|
||||
- Category (for move suggestions only): status if power 0, else physical for
|
||||
types below MYSTERY, special above (Gen 3 `IS_TYPE_PHYSICAL`).
|
||||
|
||||
## Learnability (owner's own game data)
|
||||
|
||||
`Datasets.learnSources(data, national, level)` unions over the species and
|
||||
every pre-evolution: level-up moves at or below the level (level 1 moves
|
||||
included), TM/HM, tutor (Crystal `tutorMoves`; Emerald/FRLG tutor bitfield;
|
||||
FRLG Cape Brink for the Kanto starters), egg moves (Gen 2/3). Both Gen 2
|
||||
cache layouts (`levelMoves`/`eggMoves`/`tmhm`/`tutorMoves` and
|
||||
`learnset`/`level1Moves`) are read.
|
||||
|
||||
## Battle projection (g3u, temporary, never written)
|
||||
|
||||
| field | rule |
|
||||
|---|---|
|
||||
| species | national dex kept; must be <= ruleset dexMax (`species_not_in_ruleset` otherwise) |
|
||||
| level | kept |
|
||||
| ivs | Gen 3 kept. Gen 1/2: IV = 2*DV+1 for Atk/Def/Spe; HP IV = 2*HPDV+1 where HPDV is derived from the four DV low bits; SpA = SpD = 2*Special+1 |
|
||||
| evs | Gen 3 kept. Gen 1/2: EV = min(255, floor(sqrt(StatExp))); Special Stat Exp feeds both SpA and SpD; 510 total cap applied in HP, Atk, Def, Spe, SpA, SpD order |
|
||||
| base stats | owner's own game; Gen 1 base Special used for SpA and SpD |
|
||||
| stats | Gen 3 formula (`CalculateMonStats`, Shedinja HP 1) from the above and nature |
|
||||
| nature | neutral (0) when a Gen 1/2 player is present; Gen 3 nature only when not |
|
||||
| ability, item | 0 when a Gen 1/2 player is present |
|
||||
| types | not in the record: typing comes from the match table (owner decision 2) |
|
||||
| moves | each must be id <= moveMax, not in the match table's unsupported list, and learnable per the owner's data. Codes: `move_not_in_ruleset`, `move_unsupported`, `move_not_legal`. Player picks a legal replacement or empties the slot; order of the remaining moves kept; at least one move (`no_legal_moves`) |
|
||||
| pp | owner's base PP + PP Ups * floor(base/5), full |
|
||||
| gender | Gen 2 DV rule, Gen 3 personality rule, none for Gen 1 |
|
||||
| shiny | Gen 1/2 DV rule, Gen 3 personality rule |
|
||||
| hp / status | full / clear |
|
||||
|
||||
Record shape: `{national, species, nickname, level, hp, maxHp, atk, def,
|
||||
spAtk, spDef, speed, moves={{id,pp,ppUps}}, ability, item, ivs, evs, nature,
|
||||
gender, shiny, unownLetter, sourceGen, rental}`. Output never aliases input.
|
||||
|
||||
Team size: default min(roster sizes) (or a smaller size both agree); a larger
|
||||
roster gets `choose_sit_out` with the number to bench and is never trimmed
|
||||
automatically. Replacement candidates (party then PC order): shares a type,
|
||||
then same primary type, then level closeness, then source order; only
|
||||
species in the ruleset with at least one legal move. Move suggestions: same
|
||||
type, then same category and power band (0, 1-40, 41-70, 71-100, 101+), then
|
||||
name.
|
||||
|
||||
## Permanent trade conversion (TradeConvert, policy v1)
|
||||
|
||||
Deterministic, no rerolls. Move legality policy: a kept move must exist in
|
||||
the destination and be learnable by the species in the destination game OR
|
||||
in the source game (so a legally learned Gen 2 egg move or a FRLG tutor move
|
||||
survives). Staged replacements must be learnable in the destination game.
|
||||
|
||||
| field | rule |
|
||||
|---|---|
|
||||
| species | national kept; `species_missing` if the destination lacks it |
|
||||
| level | kept |
|
||||
| exp | kept, clamped into the destination curve's range for that level |
|
||||
| Gen 1/2 -> Gen 3 IVs/EVs | as in the battle projection |
|
||||
| Gen 3 -> Gen 1/2 DVs | target floor(IV/2) for Atk/Def/Spe and Special from SpA; the nearest DV set (L1 distance, first in attack-major scan order on ties) that keeps shininess, Gen 2 gender and the Unown letter; HP DV derived; HP IV lost. Unown !/? refused (`traits_unrepresentable`) |
|
||||
| Gen 3 -> Gen 1/2 Stat Exp | min(65535, EV^2); Special from SpA EV; SpD EV lost when it differs |
|
||||
| personality (Gen 1/2 -> Gen 3) | seed = FNV-1a of canonical {national, OT ID, DVs, exp, level}; high half starts at seed % 65536 and walks upward; for each high half the low halves are scanned for a personality with pid % 25 == 0 (Hardy), even (ability slot 0), the Gen 2 DV gender under the destination gender ratio, the DV Unown letter, and shiny iff the DVs are shiny with Secret ID 0 |
|
||||
| nature | from personality |
|
||||
| ability | Gen 1/2 -> 3: slot 0; Gen 3 -> 3: kept; Gen 3 -> 1/2: lost |
|
||||
| moves / pp | see legality above; PP at destination max with the same PP Ups |
|
||||
| nickname | kept when the destination charset encodes it within 10 characters, else refused; a nickname equal to the species name becomes the destination default |
|
||||
| OT | name (7) and ID (0..65535) kept when encodable, else refused |
|
||||
| item | Gen 2/3 by canonical name, else refused until removed. Mail refused. Gen 2 -> Gen 1 stored as the catch-rate byte; Gen 1 -> Gen 2 from the catch rate (Time Capsule); Gen 3 item -> Gen 1 refused; Gen 1 catch rate -> Gen 3 lost |
|
||||
| friendship | kept Gen 2 <-> 3; Gen 1 -> Gen 2 is 70; Gen 1 -> Gen 3 is the species base; lost into Gen 1 |
|
||||
| pokerus | kept Gen 2 <-> 3; lost into Gen 1 |
|
||||
| origin | Gen 3 target from Gen 1/2: met location 0xFE (in a trade), met level = level, Poke Ball, destination game id, English, OT gender from Crystal caught gender else 0, no markings/ribbons/contest; Mew gets the modern fateful flag so it obeys. Gen 2 target from another gen: caught data 0 (Time Capsule). Same-gen: kept |
|
||||
| ribbons / contest / markings / met data | kept Gen 3 -> 3, lost otherwise |
|
||||
| eggs | refused |
|
||||
| hp / status | full / clear |
|
||||
| mod `extra` | kept same-gen, lost cross-gen |
|
||||
| evolution | not run here |
|
||||
|
||||
Every top-level field of the source record is classified as carried,
|
||||
changed, derived or lost (`report.accounting`); every changed field has a
|
||||
`kind='change'` row and every lost field a `kind='loss'` row in `changes`.
|
||||
Unknown fields default to lost.
|
||||
|
||||
`TradeConvert.canonical(rec)` is a sorted-key JSON-like serialization
|
||||
(integers as `%.0f`); `TradeConvert.digest(rec)` is two FNV-1a 32 words over it.
|
||||
|
||||
## Rentals v1
|
||||
|
||||
Per ruleset one rental per ordinary type (g3u-gen1: 15, g3u-gen2/3: 17),
|
||||
level 50, IVs 20, EVs 0, personality 150 (Hardy, not shiny). Species and
|
||||
moves were chosen so they are learnable at level 50 in every local cache of
|
||||
that ruleset's generation and above (all 11 for g3u-gen1). `Rentals.build`
|
||||
validates each against the providing game's data and excludes and reports
|
||||
any that fail.
|
||||
|
||||
## Trades: transaction
|
||||
|
||||
- Each offer carries the sender's source record (its game's own link
|
||||
encoding), the destination game and the converted preview. The receiver
|
||||
re-converts the source record itself and refuses any mismatch.
|
||||
- Agreed digest = digest over both offer revisions, both source records,
|
||||
both converted records and the policy version. It is the readiness digest
|
||||
and the `trade_confirm` digest; the relay refuses any confirm with another
|
||||
digest, so changing either offer invalidates both approvals.
|
||||
- Before confirming, each client rechecks its outgoing record at its slot and
|
||||
writes a journal entry (`<save>_xtrade.lua`, atomic) holding the outgoing
|
||||
and incoming records. On the relay's `trade_commit` it applies the swap in
|
||||
place, runs the destination game's own trade evolution, saves atomically
|
||||
and drops the journal. `trade_abort` drops the journal; both originals stay.
|
||||
- Recovery: at boot, committed entries apply without the network; confirming
|
||||
entries ask `GET /trade/outcome` and apply, drop or wait. Applying is keyed
|
||||
by room, round and digest and guarded by an applied marker (Gen 1/2) and the
|
||||
record identity check (all gens), so a commit applies exactly once. No copy
|
||||
of a traded-away Pokemon is kept for the sender.
|
||||
|
||||
## What the relay verifies
|
||||
|
||||
Room membership, seat, revision freshness, capability shapes, team size
|
||||
bounds, message size caps, that only the lower-generation seat sends the
|
||||
battle table, digest equality for trades, the commit barrier and the outcome
|
||||
ledger. It holds no game data: a matching digest proves both players agreed
|
||||
on the same exchange, not that a Pokemon is legally owned or legal. Battle
|
||||
tables and parties are bounds-checked by the receiving client.
|
||||
|
||||
## Deployment order
|
||||
|
||||
Relay first (additive, relay protocol stays 3; older clients keep their own
|
||||
rooms). Then clients. A new Gen 1/2 client on an old relay is refused at join
|
||||
and shows an update message; a new Gen 3 client on an old relay detects the
|
||||
missing generation field and shows the same message.
|
||||
@@ -0,0 +1,147 @@
|
||||
local E = require("src.core.game3.battle.effect_ids")
|
||||
|
||||
local M = {}
|
||||
|
||||
M.GEN = 1
|
||||
|
||||
local function e(effect, chance) return { effect = effect, chance = chance or 0 } end
|
||||
|
||||
-- pokered/constants/move_effect_constants.asm:7
|
||||
M.EFFECTS = {
|
||||
NO_ADDITIONAL_EFFECT = e(E.HIT),
|
||||
-- pokered/engine/battle/effects.asm:101
|
||||
POISON_SIDE_EFFECT1 = e(E.POISON_HIT, 20),
|
||||
-- pokered/engine/battle/effects.asm:104
|
||||
POISON_SIDE_EFFECT2 = e(E.POISON_HIT, 40),
|
||||
DRAIN_HP_EFFECT = e(E.ABSORB),
|
||||
-- pokered/engine/battle/effects.asm:215
|
||||
BURN_SIDE_EFFECT1 = e(E.BURN_HIT, 10),
|
||||
FREEZE_SIDE_EFFECT1 = e(E.FREEZE_HIT, 10),
|
||||
PARALYZE_SIDE_EFFECT1 = e(E.PARALYZE_HIT, 10),
|
||||
-- pokered/engine/battle/effects.asm:218
|
||||
BURN_SIDE_EFFECT2 = e(E.BURN_HIT, 30),
|
||||
FREEZE_SIDE_EFFECT2 = e(E.FREEZE_HIT, 30),
|
||||
PARALYZE_SIDE_EFFECT2 = e(E.PARALYZE_HIT, 30),
|
||||
-- pokered/engine/battle/effects.asm:984
|
||||
FLINCH_SIDE_EFFECT1 = e(E.FLINCH_HIT, 10),
|
||||
-- pokered/engine/battle/effects.asm:986
|
||||
FLINCH_SIDE_EFFECT2 = e(E.FLINCH_HIT, 30),
|
||||
-- pokered/engine/battle/effects.asm:1116
|
||||
CONFUSION_SIDE_EFFECT = e(E.CONFUSE_HIT, 10),
|
||||
-- pokered/engine/battle/effects.asm:562
|
||||
ATTACK_DOWN_SIDE_EFFECT = e(E.ATTACK_DOWN_HIT, 33),
|
||||
DEFENSE_DOWN_SIDE_EFFECT = e(E.DEFENSE_DOWN_HIT, 33),
|
||||
SPEED_DOWN_SIDE_EFFECT = e(E.SPEED_DOWN_HIT, 33),
|
||||
SPECIAL_DOWN_SIDE_EFFECT = e(E.SPECIAL_DEFENSE_DOWN_HIT, 33),
|
||||
-- pokered/engine/battle/effects.asm:967
|
||||
TWINEEDLE_EFFECT = e(E.TWINEEDLE, 20),
|
||||
-- pokered/constants/move_effect_constants.asm:14
|
||||
EXPLODE_EFFECT = e(E.EXPLOSION),
|
||||
DREAM_EATER_EFFECT = e(E.DREAM_EATER),
|
||||
MIRROR_MOVE_EFFECT = e(E.MIRROR_MOVE),
|
||||
ATTACK_UP1_EFFECT = e(E.ATTACK_UP),
|
||||
DEFENSE_UP1_EFFECT = e(E.DEFENSE_UP),
|
||||
SPECIAL_UP1_EFFECT = e(E.SPECIAL_ATTACK_UP),
|
||||
EVASION_UP1_EFFECT = e(E.EVASION_UP),
|
||||
PAY_DAY_EFFECT = e(E.PAY_DAY),
|
||||
SWIFT_EFFECT = e(E.ALWAYS_HIT),
|
||||
ATTACK_DOWN1_EFFECT = e(E.ATTACK_DOWN),
|
||||
DEFENSE_DOWN1_EFFECT = e(E.DEFENSE_DOWN),
|
||||
SPEED_DOWN1_EFFECT = e(E.SPEED_DOWN),
|
||||
ACCURACY_DOWN1_EFFECT = e(E.ACCURACY_DOWN),
|
||||
EVASION_DOWN1_EFFECT = e(E.EVASION_DOWN),
|
||||
-- pokered/constants/move_effect_constants.asm:31
|
||||
CONVERSION_EFFECT = e(E.CONVERSION),
|
||||
HAZE_EFFECT = e(E.HAZE),
|
||||
BIDE_EFFECT = e(E.BIDE),
|
||||
THRASH_PETAL_DANCE_EFFECT = e(E.RAMPAGE),
|
||||
SWITCH_AND_TELEPORT_EFFECT = e(E.ROAR),
|
||||
TWO_TO_FIVE_ATTACKS_EFFECT = e(E.MULTI_HIT),
|
||||
SLEEP_EFFECT = e(E.SLEEP),
|
||||
OHKO_EFFECT = e(E.OHKO),
|
||||
CHARGE_EFFECT = e(E.RAZOR_WIND),
|
||||
SUPER_FANG_EFFECT = e(E.SUPER_FANG),
|
||||
SPECIAL_DAMAGE_EFFECT = e(E.LEVEL_DAMAGE),
|
||||
TRAPPING_EFFECT = e(E.TRAP),
|
||||
FLY_EFFECT = e(E.SEMI_INVULNERABLE),
|
||||
ATTACK_TWICE_EFFECT = e(E.DOUBLE_HIT),
|
||||
JUMP_KICK_EFFECT = e(E.RECOIL_IF_MISS),
|
||||
MIST_EFFECT = e(E.MIST),
|
||||
FOCUS_ENERGY_EFFECT = e(E.FOCUS_ENERGY),
|
||||
RECOIL_EFFECT = e(E.RECOIL),
|
||||
CONFUSION_EFFECT = e(E.CONFUSE),
|
||||
-- pokered/constants/move_effect_constants.asm:57
|
||||
ATTACK_UP2_EFFECT = e(E.ATTACK_UP_2),
|
||||
DEFENSE_UP2_EFFECT = e(E.DEFENSE_UP_2),
|
||||
SPEED_UP2_EFFECT = e(E.SPEED_UP_2),
|
||||
SPECIAL_UP2_EFFECT = e(E.SPECIAL_DEFENSE_UP_2),
|
||||
HEAL_EFFECT = e(E.RESTORE_HP),
|
||||
TRANSFORM_EFFECT = e(E.TRANSFORM),
|
||||
ATTACK_DOWN2_EFFECT = e(E.ATTACK_DOWN_2),
|
||||
DEFENSE_DOWN2_EFFECT = e(E.DEFENSE_DOWN_2),
|
||||
SPEED_DOWN2_EFFECT = e(E.SPEED_DOWN_2),
|
||||
SPECIAL_DOWN2_EFFECT = e(E.SPECIAL_DEFENSE_DOWN_2),
|
||||
LIGHT_SCREEN_EFFECT = e(E.LIGHT_SCREEN),
|
||||
REFLECT_EFFECT = e(E.REFLECT),
|
||||
POISON_EFFECT = e(E.POISON),
|
||||
PARALYZE_EFFECT = e(E.PARALYZE),
|
||||
-- pokered/constants/move_effect_constants.asm:86
|
||||
SUBSTITUTE_EFFECT = e(E.SUBSTITUTE),
|
||||
HYPER_BEAM_EFFECT = e(E.RECHARGE),
|
||||
RAGE_EFFECT = e(E.RAGE),
|
||||
MIMIC_EFFECT = e(E.MIMIC),
|
||||
METRONOME_EFFECT = e(E.METRONOME),
|
||||
LEECH_SEED_EFFECT = e(E.LEECH_SEED),
|
||||
SPLASH_EFFECT = e(E.SPLASH),
|
||||
DISABLE_EFFECT = e(E.DISABLE),
|
||||
}
|
||||
|
||||
-- pokered/constants/move_effect_constants.asm:8
|
||||
M.UNSUPPORTED = {
|
||||
EFFECT_01 = "unused",
|
||||
EFFECT_1E = "unused",
|
||||
SPEED_UP1_EFFECT = "no_engine_handler",
|
||||
ACCURACY_UP1_EFFECT = "no_engine_handler",
|
||||
SPECIAL_DOWN1_EFFECT = "no_engine_handler",
|
||||
ACCURACY_UP2_EFFECT = "no_engine_handler",
|
||||
EVASION_UP2_EFFECT = "no_engine_handler",
|
||||
ACCURACY_DOWN2_EFFECT = "no_engine_handler",
|
||||
EVASION_DOWN2_EFFECT = "no_engine_handler",
|
||||
}
|
||||
|
||||
M.MOVES = {
|
||||
-- pokered/data/battle/critical_hit_moves.asm:1
|
||||
[2] = { effect = E.HIGH_CRITICAL },
|
||||
[75] = { effect = E.HIGH_CRITICAL },
|
||||
[152] = { effect = E.HIGH_CRITICAL },
|
||||
[163] = { effect = E.HIGH_CRITICAL },
|
||||
-- pokered/engine/battle/core.asm:4566
|
||||
[68] = { effect = E.COUNTER },
|
||||
-- pokered/engine/battle/core.asm:4647
|
||||
[69] = { effect = E.LEVEL_DAMAGE },
|
||||
[101] = { effect = E.LEVEL_DAMAGE },
|
||||
[49] = { effect = E.SONICBOOM },
|
||||
[82] = { effect = E.DRAGON_RAGE },
|
||||
[149] = { effect = E.PSYWAVE },
|
||||
-- pokered/engine/battle/effects.asm:1036
|
||||
[13] = { effect = E.RAZOR_WIND },
|
||||
[76] = { effect = E.SOLAR_BEAM },
|
||||
[130] = { effect = E.SKULL_BASH },
|
||||
[143] = { effect = E.SKY_ATTACK },
|
||||
[91] = { effect = E.SEMI_INVULNERABLE },
|
||||
-- pokered/engine/battle/effects.asm:836
|
||||
[100] = { effect = E.TELEPORT },
|
||||
-- pokered/engine/battle/effects.asm:135
|
||||
[92] = { effect = E.TOXIC },
|
||||
-- pokered/engine/battle/move_effects/heal.asm:22
|
||||
[156] = { effect = E.REST },
|
||||
}
|
||||
|
||||
-- pokered/engine/battle/core.asm:371
|
||||
M.PRIORITY = {
|
||||
[98] = 1,
|
||||
-- pokered/engine/battle/core.asm:382
|
||||
[68] = -1,
|
||||
}
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,214 @@
|
||||
local E = require("src.core.game3.battle.effect_ids")
|
||||
|
||||
local M = {}
|
||||
|
||||
M.GEN = 2
|
||||
|
||||
local function e(effect) return { effect = effect } end
|
||||
|
||||
-- pokecrystal/constants/move_effect_constants.asm:3
|
||||
M.EFFECTS = {
|
||||
EFFECT_NORMAL_HIT = e(E.HIT),
|
||||
EFFECT_SLEEP = e(E.SLEEP),
|
||||
EFFECT_POISON_HIT = e(E.POISON_HIT),
|
||||
EFFECT_LEECH_HIT = e(E.ABSORB),
|
||||
EFFECT_BURN_HIT = e(E.BURN_HIT),
|
||||
EFFECT_FREEZE_HIT = e(E.FREEZE_HIT),
|
||||
EFFECT_PARALYZE_HIT = e(E.PARALYZE_HIT),
|
||||
EFFECT_SELFDESTRUCT = e(E.EXPLOSION),
|
||||
EFFECT_DREAM_EATER = e(E.DREAM_EATER),
|
||||
EFFECT_MIRROR_MOVE = e(E.MIRROR_MOVE),
|
||||
EFFECT_ATTACK_UP = e(E.ATTACK_UP),
|
||||
EFFECT_DEFENSE_UP = e(E.DEFENSE_UP),
|
||||
EFFECT_SP_ATK_UP = e(E.SPECIAL_ATTACK_UP),
|
||||
EFFECT_EVASION_UP = e(E.EVASION_UP),
|
||||
EFFECT_ALWAYS_HIT = e(E.ALWAYS_HIT),
|
||||
EFFECT_ATTACK_DOWN = e(E.ATTACK_DOWN),
|
||||
EFFECT_DEFENSE_DOWN = e(E.DEFENSE_DOWN),
|
||||
EFFECT_SPEED_DOWN = e(E.SPEED_DOWN),
|
||||
EFFECT_ACCURACY_DOWN = e(E.ACCURACY_DOWN),
|
||||
EFFECT_EVASION_DOWN = e(E.EVASION_DOWN),
|
||||
-- pokecrystal/constants/move_effect_constants.asm:28
|
||||
EFFECT_RESET_STATS = e(E.HAZE),
|
||||
EFFECT_BIDE = e(E.BIDE),
|
||||
EFFECT_RAMPAGE = e(E.RAMPAGE),
|
||||
EFFECT_FORCE_SWITCH = e(E.ROAR),
|
||||
EFFECT_MULTI_HIT = e(E.MULTI_HIT),
|
||||
EFFECT_CONVERSION = e(E.CONVERSION),
|
||||
EFFECT_FLINCH_HIT = e(E.FLINCH_HIT),
|
||||
EFFECT_HEAL = e(E.RESTORE_HP),
|
||||
EFFECT_TOXIC = e(E.TOXIC),
|
||||
EFFECT_PAY_DAY = e(E.PAY_DAY),
|
||||
EFFECT_LIGHT_SCREEN = e(E.LIGHT_SCREEN),
|
||||
EFFECT_TRI_ATTACK = e(E.TRI_ATTACK),
|
||||
EFFECT_OHKO = e(E.OHKO),
|
||||
EFFECT_RAZOR_WIND = e(E.RAZOR_WIND),
|
||||
EFFECT_SUPER_FANG = e(E.SUPER_FANG),
|
||||
EFFECT_STATIC_DAMAGE = e(E.DRAGON_RAGE),
|
||||
EFFECT_TRAP_TARGET = e(E.TRAP),
|
||||
EFFECT_DOUBLE_HIT = e(E.DOUBLE_HIT),
|
||||
EFFECT_JUMP_KICK = e(E.RECOIL_IF_MISS),
|
||||
EFFECT_MIST = e(E.MIST),
|
||||
EFFECT_FOCUS_ENERGY = e(E.FOCUS_ENERGY),
|
||||
EFFECT_RECOIL_HIT = e(E.RECOIL),
|
||||
EFFECT_CONFUSE = e(E.CONFUSE),
|
||||
-- pokecrystal/constants/move_effect_constants.asm:53
|
||||
EFFECT_ATTACK_UP_2 = e(E.ATTACK_UP_2),
|
||||
EFFECT_DEFENSE_UP_2 = e(E.DEFENSE_UP_2),
|
||||
EFFECT_SPEED_UP_2 = e(E.SPEED_UP_2),
|
||||
EFFECT_SP_ATK_UP_2 = e(E.SPECIAL_ATTACK_UP_2),
|
||||
EFFECT_SP_DEF_UP_2 = e(E.SPECIAL_DEFENSE_UP_2),
|
||||
EFFECT_TRANSFORM = e(E.TRANSFORM),
|
||||
EFFECT_ATTACK_DOWN_2 = e(E.ATTACK_DOWN_2),
|
||||
EFFECT_DEFENSE_DOWN_2 = e(E.DEFENSE_DOWN_2),
|
||||
EFFECT_SPEED_DOWN_2 = e(E.SPEED_DOWN_2),
|
||||
EFFECT_SP_DEF_DOWN_2 = e(E.SPECIAL_DEFENSE_DOWN_2),
|
||||
EFFECT_REFLECT = e(E.REFLECT),
|
||||
EFFECT_POISON = e(E.POISON),
|
||||
EFFECT_PARALYZE = e(E.PARALYZE),
|
||||
-- pokecrystal/constants/move_effect_constants.asm:71
|
||||
EFFECT_ATTACK_DOWN_HIT = e(E.ATTACK_DOWN_HIT),
|
||||
EFFECT_DEFENSE_DOWN_HIT = e(E.DEFENSE_DOWN_HIT),
|
||||
EFFECT_SPEED_DOWN_HIT = e(E.SPEED_DOWN_HIT),
|
||||
EFFECT_SP_ATK_DOWN_HIT = e(E.SPECIAL_ATTACK_DOWN_HIT),
|
||||
EFFECT_SP_DEF_DOWN_HIT = e(E.SPECIAL_DEFENSE_DOWN_HIT),
|
||||
EFFECT_ACCURACY_DOWN_HIT = e(E.ACCURACY_DOWN_HIT),
|
||||
EFFECT_SKY_ATTACK = e(E.SKY_ATTACK),
|
||||
EFFECT_CONFUSE_HIT = e(E.CONFUSE_HIT),
|
||||
EFFECT_POISON_MULTI_HIT = e(E.TWINEEDLE),
|
||||
EFFECT_SUBSTITUTE = e(E.SUBSTITUTE),
|
||||
EFFECT_HYPER_BEAM = e(E.RECHARGE),
|
||||
EFFECT_RAGE = e(E.RAGE),
|
||||
EFFECT_MIMIC = e(E.MIMIC),
|
||||
EFFECT_METRONOME = e(E.METRONOME),
|
||||
EFFECT_LEECH_SEED = e(E.LEECH_SEED),
|
||||
EFFECT_SPLASH = e(E.SPLASH),
|
||||
EFFECT_DISABLE = e(E.DISABLE),
|
||||
-- pokecrystal/constants/move_effect_constants.asm:90
|
||||
EFFECT_LEVEL_DAMAGE = e(E.LEVEL_DAMAGE),
|
||||
EFFECT_PSYWAVE = e(E.PSYWAVE),
|
||||
EFFECT_COUNTER = e(E.COUNTER),
|
||||
EFFECT_ENCORE = e(E.ENCORE),
|
||||
EFFECT_PAIN_SPLIT = e(E.PAIN_SPLIT),
|
||||
EFFECT_SNORE = e(E.SNORE),
|
||||
EFFECT_CONVERSION2 = e(E.CONVERSION_2),
|
||||
EFFECT_LOCK_ON = e(E.LOCK_ON),
|
||||
EFFECT_SKETCH = e(E.SKETCH),
|
||||
EFFECT_SLEEP_TALK = e(E.SLEEP_TALK),
|
||||
EFFECT_DESTINY_BOND = e(E.DESTINY_BOND),
|
||||
EFFECT_REVERSAL = e(E.FLAIL),
|
||||
EFFECT_SPITE = e(E.SPITE),
|
||||
EFFECT_FALSE_SWIPE = e(E.FALSE_SWIPE),
|
||||
EFFECT_HEAL_BELL = e(E.HEAL_BELL),
|
||||
EFFECT_PRIORITY_HIT = e(E.QUICK_ATTACK),
|
||||
EFFECT_TRIPLE_KICK = e(E.TRIPLE_KICK),
|
||||
EFFECT_THIEF = e(E.THIEF),
|
||||
EFFECT_MEAN_LOOK = e(E.MEAN_LOOK),
|
||||
EFFECT_NIGHTMARE = e(E.NIGHTMARE),
|
||||
EFFECT_FLAME_WHEEL = e(E.THAW_HIT),
|
||||
EFFECT_CURSE = e(E.CURSE),
|
||||
-- pokecrystal/constants/move_effect_constants.asm:114
|
||||
EFFECT_PROTECT = e(E.PROTECT),
|
||||
EFFECT_SPIKES = e(E.SPIKES),
|
||||
EFFECT_FORESIGHT = e(E.FORESIGHT),
|
||||
EFFECT_PERISH_SONG = e(E.PERISH_SONG),
|
||||
EFFECT_SANDSTORM = e(E.SANDSTORM),
|
||||
EFFECT_ENDURE = e(E.ENDURE),
|
||||
EFFECT_ROLLOUT = e(E.ROLLOUT),
|
||||
EFFECT_SWAGGER = e(E.SWAGGER),
|
||||
EFFECT_FURY_CUTTER = e(E.FURY_CUTTER),
|
||||
EFFECT_ATTRACT = e(E.ATTRACT),
|
||||
EFFECT_RETURN = e(E.RETURN),
|
||||
EFFECT_PRESENT = e(E.PRESENT),
|
||||
EFFECT_FRUSTRATION = e(E.FRUSTRATION),
|
||||
EFFECT_SAFEGUARD = e(E.SAFEGUARD),
|
||||
EFFECT_SACRED_FIRE = e(E.THAW_HIT),
|
||||
EFFECT_MAGNITUDE = e(E.MAGNITUDE),
|
||||
EFFECT_BATON_PASS = e(E.BATON_PASS),
|
||||
EFFECT_PURSUIT = e(E.PURSUIT),
|
||||
EFFECT_RAPID_SPIN = e(E.RAPID_SPIN),
|
||||
-- pokecrystal/constants/move_effect_constants.asm:135
|
||||
EFFECT_MORNING_SUN = e(E.MORNING_SUN),
|
||||
EFFECT_SYNTHESIS = e(E.SYNTHESIS),
|
||||
EFFECT_MOONLIGHT = e(E.MOONLIGHT),
|
||||
EFFECT_HIDDEN_POWER = e(E.HIDDEN_POWER),
|
||||
EFFECT_RAIN_DANCE = e(E.RAIN_DANCE),
|
||||
EFFECT_SUNNY_DAY = e(E.SUNNY_DAY),
|
||||
EFFECT_DEFENSE_UP_HIT = e(E.DEFENSE_UP_HIT),
|
||||
EFFECT_ATTACK_UP_HIT = e(E.ATTACK_UP_HIT),
|
||||
EFFECT_ALL_UP_HIT = e(E.ALL_STATS_UP_HIT),
|
||||
EFFECT_FAKE_OUT = e(E.FAKE_OUT),
|
||||
EFFECT_BELLY_DRUM = e(E.BELLY_DRUM),
|
||||
EFFECT_PSYCH_UP = e(E.PSYCH_UP),
|
||||
EFFECT_MIRROR_COAT = e(E.MIRROR_COAT),
|
||||
EFFECT_SKULL_BASH = e(E.SKULL_BASH),
|
||||
EFFECT_TWISTER = e(E.TWISTER),
|
||||
EFFECT_EARTHQUAKE = e(E.EARTHQUAKE),
|
||||
EFFECT_FUTURE_SIGHT = e(E.FUTURE_SIGHT),
|
||||
EFFECT_GUST = e(E.GUST),
|
||||
EFFECT_STOMP = e(E.FLINCH_MINIMIZE_HIT),
|
||||
EFFECT_SOLARBEAM = e(E.SOLAR_BEAM),
|
||||
EFFECT_THUNDER = e(E.THUNDER),
|
||||
EFFECT_TELEPORT = e(E.TELEPORT),
|
||||
EFFECT_BEAT_UP = e(E.BEAT_UP),
|
||||
EFFECT_FLY = e(E.SEMI_INVULNERABLE),
|
||||
EFFECT_DEFENSE_CURL = e(E.DEFENSE_CURL),
|
||||
}
|
||||
|
||||
-- pokecrystal/constants/move_effect_constants.asm:15
|
||||
M.UNSUPPORTED = {
|
||||
EFFECT_SPEED_UP = "no_engine_handler",
|
||||
EFFECT_SP_DEF_UP = "no_engine_handler",
|
||||
EFFECT_ACCURACY_UP = "no_engine_handler",
|
||||
EFFECT_SP_ATK_DOWN = "no_engine_handler",
|
||||
EFFECT_SP_DEF_DOWN = "no_engine_handler",
|
||||
EFFECT_UNUSED_25 = "unused",
|
||||
EFFECT_UNUSED_2B = "unused",
|
||||
EFFECT_ACCURACY_UP_2 = "no_engine_handler",
|
||||
EFFECT_EVASION_UP_2 = "no_engine_handler",
|
||||
EFFECT_SP_ATK_DOWN_2 = "no_engine_handler",
|
||||
EFFECT_ACCURACY_DOWN_2 = "no_engine_handler",
|
||||
EFFECT_EVASION_DOWN_2 = "no_engine_handler",
|
||||
EFFECT_EVASION_DOWN_HIT = "no_engine_handler",
|
||||
EFFECT_UNUSED_4E = "unused",
|
||||
EFFECT_DEFROST_OPPONENT = "no_engine_handler",
|
||||
EFFECT_UNUSED_6E = "unused",
|
||||
EFFECT_UNUSED_82 = "unused",
|
||||
EFFECT_UNUSED_83 = "unused",
|
||||
}
|
||||
|
||||
M.MOVES = {
|
||||
-- pokecrystal/data/moves/critical_hit_moves.asm:1
|
||||
[2] = { effect = E.HIGH_CRITICAL },
|
||||
[75] = { effect = E.HIGH_CRITICAL },
|
||||
[152] = { effect = E.HIGH_CRITICAL },
|
||||
[163] = { effect = E.HIGH_CRITICAL },
|
||||
[177] = { effect = E.HIGH_CRITICAL },
|
||||
[238] = { effect = E.HIGH_CRITICAL },
|
||||
-- pokecrystal/data/moves/moves.asm:65
|
||||
[49] = { effect = E.SONICBOOM },
|
||||
-- pokecrystal/engine/battle/effect_commands.asm:6055
|
||||
[156] = { effect = E.REST },
|
||||
-- pokecrystal/engine/battle/effect_commands.asm:4251
|
||||
[107] = { effect = E.MINIMIZE },
|
||||
}
|
||||
|
||||
-- pokecrystal/data/moves/effects_priorities.asm:1
|
||||
M.EFFECT_PRIORITY = {
|
||||
EFFECT_PROTECT = 3,
|
||||
EFFECT_ENDURE = 3,
|
||||
EFFECT_PRIORITY_HIT = 2,
|
||||
EFFECT_FORCE_SWITCH = 0,
|
||||
EFFECT_COUNTER = 0,
|
||||
EFFECT_MIRROR_COAT = 0,
|
||||
}
|
||||
|
||||
-- pokecrystal/engine/battle/core.asm:835
|
||||
M.MOVE_PRIORITY = {
|
||||
[233] = 0,
|
||||
}
|
||||
|
||||
-- pokecrystal/constants/battle_constants.asm:18
|
||||
M.BASE_PRIORITY = 1
|
||||
|
||||
return M
|
||||
@@ -0,0 +1,82 @@
|
||||
local Events = {}
|
||||
|
||||
Events.KINDS = {
|
||||
"msg", "move", "hp", "status", "stage", "faint", "withdraw", "sendout", "weather",
|
||||
"anim", "need_replacement", "end",
|
||||
}
|
||||
|
||||
local SEAT = { player = 0, enemy = 1 }
|
||||
|
||||
function Events.seatOf(side)
|
||||
if side == nil then return nil end
|
||||
if type(side) == "number" then return side % 2 end
|
||||
return SEAT[side]
|
||||
end
|
||||
|
||||
local seatOf = Events.seatOf
|
||||
|
||||
local function isBattler(v)
|
||||
return type(v) == "table" and (v.side == "player" or v.side == "enemy") and v.mon ~= nil
|
||||
end
|
||||
|
||||
local function value(v, depth)
|
||||
local t = type(v)
|
||||
if t == "string" then
|
||||
local kind, n = v:match("^{(%a+):(%-?%d+)}$")
|
||||
if kind then return { [kind] = tonumber(n) } end
|
||||
return v
|
||||
end
|
||||
if t == "number" or t == "boolean" then return v end
|
||||
if t == "table" then
|
||||
if isBattler(v) then return { side = seatOf(v.side), index = v.partyIndex } end
|
||||
if (depth or 0) > 2 then return nil end
|
||||
local out = {}
|
||||
for k, x in pairs(v) do
|
||||
if type(k) == "string" or type(k) == "number" then out[k] = value(x, (depth or 0) + 1) end
|
||||
end
|
||||
return out
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Events.fill(fill)
|
||||
local out = {}
|
||||
for k, v in pairs(fill or {}) do
|
||||
if type(k) == "string" then out[k] = value(v, 0) end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function Events.normalize(raw, out)
|
||||
out = out or {}
|
||||
for _, ev in ipairs(raw or {}) do
|
||||
local k = ev.kind
|
||||
if k == "msg" then
|
||||
out[#out + 1] = { kind = "msg", id = ev.id, fill = Events.fill(ev.fill) }
|
||||
elseif k == "move" then
|
||||
out[#out + 1] = { kind = "move", moveId = tonumber(ev.moveId) or ev.moveId,
|
||||
user = seatOf(ev.attacker), target = seatOf(ev.target), turn = ev.turn }
|
||||
elseif k == "hp" or k == "hit" then
|
||||
out[#out + 1] = { kind = "hp", side = seatOf(ev.side), from = ev.from, to = ev.to, max = ev.maxHp,
|
||||
hit = k == "hit" or nil }
|
||||
elseif k == "status_apply" then
|
||||
out[#out + 1] = { kind = "status", side = seatOf(ev.side), status = ev.status }
|
||||
elseif k == "status_clear" then
|
||||
out[#out + 1] = { kind = "status", side = seatOf(ev.side), status = "NONE" }
|
||||
elseif k == "stage" then
|
||||
out[#out + 1] = { kind = "stage", side = seatOf(ev.side), stat = ev.stat, delta = ev.delta }
|
||||
elseif k == "faint" then
|
||||
out[#out + 1] = { kind = "faint", side = seatOf(ev.side) }
|
||||
elseif k == "switch" then
|
||||
local seat = seatOf(ev.side)
|
||||
out[#out + 1] = { kind = "withdraw", side = seat, index = ev.from, reason = ev.reason }
|
||||
out[#out + 1] = { kind = "sendout", side = seat, index = ev.to, reason = ev.reason }
|
||||
elseif k == "anim" then
|
||||
out[#out + 1] = { kind = "anim", anim = ev.anim, name = ev.name, user = seatOf(ev.attacker),
|
||||
target = seatOf(ev.target), arg = ev.arg }
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
return Events
|
||||
@@ -0,0 +1,156 @@
|
||||
local bit = require("bit")
|
||||
|
||||
local Hash = {}
|
||||
|
||||
Hash.PARTS = { "actives", "volatile", "bench", "field", "rng" }
|
||||
|
||||
local function fnv(text)
|
||||
local h = 0x811C9DC5
|
||||
for i = 1, #text do
|
||||
h = bit.bxor(h, text:byte(i)) % 4294967296
|
||||
h = ((bit.lshift(h, 24) % 4294967296) + h * 403) % 4294967296
|
||||
end
|
||||
return string.format("%08x", h)
|
||||
end
|
||||
|
||||
Hash.fnv = fnv
|
||||
|
||||
local function scalar(v)
|
||||
local t = type(v)
|
||||
if t == "number" then
|
||||
if v == math.floor(v) then return string.format("%d", v) end
|
||||
return string.format("%.6f", v)
|
||||
end
|
||||
if t == "boolean" then return v and "T" or "F" end
|
||||
if t == "string" then return v end
|
||||
if v == nil then return "-" end
|
||||
return "t"
|
||||
end
|
||||
|
||||
local STAGES = { "attack", "defense", "speed", "spAtk", "spDef", "accuracy", "evasion" }
|
||||
|
||||
local VOLATILE_KEYS = {
|
||||
"confusion", "substitute", "toxicCounter", "focusEnergy", "perishSong", "seeded", "trapped",
|
||||
"attracted", "disabled", "encore", "taunt", "bide", "rage", "endure", "protect", "destinyBond",
|
||||
"transformed", "isFirstTurn",
|
||||
"expCharged", "expCursed", "expDisableTurns", "expDisabledMove", "expEncoreMove", "expEncoreSlot",
|
||||
"expEncoreTurns", "expFocusEnergy", "expFuryCutter", "expInfatuated", "expIngrain", "expLockedMove",
|
||||
"expLockedSlot", "expMustRecharge", "expNightmare", "expPerishTurns", "expRampageTurns",
|
||||
"expRechargeTurns", "expRolloutTimer", "expSeeded", "expTauntedTurns", "expTormented",
|
||||
"expTransform", "expTrapTurns", "expTrapped", "expTruantCounter", "expUproarTurns", "expYawnTurns",
|
||||
"expCastformForm",
|
||||
}
|
||||
|
||||
local function perspective(st)
|
||||
if st.linkMaster == false then
|
||||
return { 1, 0, 3, 2 }, { "enemy", "player" }
|
||||
end
|
||||
return { 0, 1, 2, 3 }, { "player", "enemy" }
|
||||
end
|
||||
|
||||
local function battlerOf(st, id)
|
||||
local State = require("src.core.game3.battle.state")
|
||||
if State.isAbsent(st, id) then return nil end
|
||||
return State.battler(st, id)
|
||||
end
|
||||
|
||||
local function sortedScalars(t, skip, st)
|
||||
if type(t) ~= "table" then return scalar(t) end
|
||||
local flip = st and st.linkMaster == false
|
||||
local keys = {}
|
||||
for k, v in pairs(t) do
|
||||
if (type(k) == "string" or type(k) == "number") and not (skip and skip[k])
|
||||
and type(v) ~= "table" and type(v) ~= "function" then
|
||||
keys[#keys + 1] = tostring(k)
|
||||
end
|
||||
end
|
||||
table.sort(keys)
|
||||
local out = {}
|
||||
for _, k in ipairs(keys) do
|
||||
local v = t[k]
|
||||
if v == nil then v = t[tonumber(k)] end
|
||||
if flip and type(v) == "number" and k:sub(-2) == "Id" and v >= 0 and v <= 3 then
|
||||
v = (v % 2 == 0) and (v + 1) or (v - 1)
|
||||
end
|
||||
out[#out + 1] = k .. "=" .. scalar(v)
|
||||
end
|
||||
return table.concat(out, ",")
|
||||
end
|
||||
|
||||
local function monPp(mon)
|
||||
local pp = {}
|
||||
for i = 1, 4 do pp[i] = scalar(mon and mon.pp and mon.pp[i]) end
|
||||
return table.concat(pp, "/")
|
||||
end
|
||||
|
||||
local SIDE_SKIP = { id = true }
|
||||
|
||||
function Hash.parts(st, draws)
|
||||
local order, sides = perspective(st)
|
||||
local actives, volatile = {}, {}
|
||||
for _, id in ipairs(order) do
|
||||
local b = battlerOf(st, id)
|
||||
if not b then
|
||||
actives[#actives + 1] = "-"
|
||||
volatile[#volatile + 1] = "-"
|
||||
else
|
||||
local mon = b.mon or {}
|
||||
local stages = {}
|
||||
for i, key in ipairs(STAGES) do stages[i] = scalar(b.stages and b.stages[key] or 0) end
|
||||
actives[#actives + 1] = table.concat({
|
||||
scalar(tonumber(b.species or mon.species)), scalar(tonumber(mon.hp)), scalar(tonumber(mon.maxHp)),
|
||||
scalar(b.status or mon.status), scalar(mon.sleep or b.sleepTurns), table.concat(stages, "/"),
|
||||
scalar(b.ability), scalar(tonumber(b.item) or 0), monPp(mon),
|
||||
}, ":")
|
||||
local vol = {}
|
||||
for _, key in ipairs(VOLATILE_KEYS) do vol[#vol + 1] = scalar(b[key]) end
|
||||
vol[#vol + 1] = sortedScalars(b.volatiles)
|
||||
volatile[#volatile + 1] = table.concat(vol, ":")
|
||||
end
|
||||
end
|
||||
local bench, field = {}, {}
|
||||
for _, side in ipairs(sides) do
|
||||
local party = (side == "player") and st.playerParty or st.foeParty
|
||||
local active = {}
|
||||
for _, id in ipairs((side == "player") and { 0, 2 } or { 1, 3 }) do
|
||||
local b = battlerOf(st, id)
|
||||
if b and b.partyIndex then active[b.partyIndex] = true end
|
||||
end
|
||||
local rows = {}
|
||||
for i, mon in ipairs(party or {}) do
|
||||
if active[i] then
|
||||
rows[#rows + 1] = "*"
|
||||
else
|
||||
rows[#rows + 1] = table.concat({
|
||||
scalar(tonumber(mon.species or mon.speciesId)), scalar(tonumber(mon.hp)), scalar(mon.status),
|
||||
scalar(tonumber(mon.item or mon.heldItem) or 0), monPp(mon),
|
||||
}, ":")
|
||||
end
|
||||
end
|
||||
bench[#bench + 1] = table.concat(rows, ";")
|
||||
local sideState = (side == "player") and st.playerSide or st.enemySide
|
||||
field[#field + 1] = sortedScalars(sideState, SIDE_SKIP, st) .. "|" .. sortedScalars(sideState and sideState.hazards, nil, st)
|
||||
end
|
||||
table.insert(field, 1, scalar(st.weather) .. ":" .. scalar(st.weatherTurns))
|
||||
local raw = {
|
||||
actives = table.concat(actives, "#"),
|
||||
volatile = table.concat(volatile, "#"),
|
||||
bench = table.concat(bench, "#"),
|
||||
field = table.concat(field, "#"),
|
||||
}
|
||||
return {
|
||||
actives = fnv(raw.actives),
|
||||
volatile = fnv(raw.volatile),
|
||||
bench = fnv(raw.bench),
|
||||
field = fnv(raw.field),
|
||||
rng = string.format("%d", draws and draws.n or 0),
|
||||
}, raw
|
||||
end
|
||||
|
||||
function Hash.value(parts)
|
||||
local list = {}
|
||||
for i, key in ipairs(Hash.PARTS) do list[i] = tostring(parts[key] or "") end
|
||||
return fnv(table.concat(list, "|"))
|
||||
end
|
||||
|
||||
return Hash
|
||||
@@ -0,0 +1,452 @@
|
||||
local Table = require("src.battle.g3u.Table")
|
||||
local Scope = require("src.battle.g3u.Scope")
|
||||
local Rng = require("src.battle.g3u.Rng")
|
||||
local Hash = require("src.battle.g3u.Hash")
|
||||
local Events = require("src.battle.g3u.Events")
|
||||
|
||||
local Match = {}
|
||||
Match.__index = Match
|
||||
|
||||
local STAGES = { "attack", "defense", "speed", "spAtk", "spDef", "accuracy", "evasion" }
|
||||
local GENDER = { [0] = "M", [1] = "F", [2] = "U" }
|
||||
local MOVE_STRUGGLE = 165
|
||||
local SIDE = { [0] = "player", [1] = "enemy" }
|
||||
|
||||
local function mods() return Scope.modules() end
|
||||
|
||||
local function engineMon(rec)
|
||||
local moves, pp, ups = {}, {}, {}
|
||||
for i, m in ipairs(rec.moves or {}) do
|
||||
moves[i] = m.id
|
||||
pp[i] = m.pp
|
||||
ups[i] = m.ppUps or 0
|
||||
end
|
||||
local iv = rec.ivs or {}
|
||||
return {
|
||||
species = rec.species, level = rec.level, hp = rec.hp, maxHp = rec.maxHp,
|
||||
attack = rec.atk, defense = rec.def, spAtk = rec.spAtk, spDef = rec.spDef, speed = rec.speed,
|
||||
moves = moves, pp = pp, ppUps = ups, nickname = rec.nickname,
|
||||
ivs = { hp = iv.hp or 0, atk = iv.atk or 0, def = iv.def or 0, spe = iv.spe or 0, spa = iv.spa or 0,
|
||||
spd = iv.spd or 0 },
|
||||
evs = { hp = 0, atk = 0, def = 0, spe = 0, spa = 0, spd = 0 },
|
||||
personality = 0, ability = 0, abilityId = 0, item = 0, heldItem = 0,
|
||||
gender = GENDER[rec.gender] or "U", friendship = rec.friendship or 0,
|
||||
}
|
||||
end
|
||||
|
||||
function Match.new(opts)
|
||||
local t = opts.table
|
||||
local ok, why = Table.validate(t)
|
||||
if not ok then error("g3u table rejected: " .. tostring(why), 2) end
|
||||
local m = setmetatable({
|
||||
t = t, seed = math.floor(tonumber(opts.seed) or 0) % 4294967296, draws = { n = 0 },
|
||||
turn = 0, phase = "init", illegal = Table.illegalSet(t), hashes = {}, out = {},
|
||||
names = opts.names or {}, stageOwner = {}, stageSnap = {},
|
||||
}, Match)
|
||||
m.parties = { [0] = {}, [1] = {} }
|
||||
for seat = 0, 1 do
|
||||
for i, rec in ipairs(opts.parties[seat]) do m.parties[seat][i] = engineMon(rec) end
|
||||
end
|
||||
m.stack = Scope.newStack()
|
||||
m.co = coroutine.create(function() return m:_main() end)
|
||||
return m
|
||||
end
|
||||
|
||||
function Match:_resume(arg)
|
||||
self.legalCache = nil
|
||||
self.out = {}
|
||||
local co = self.co
|
||||
Scope.run(self.t, function()
|
||||
local ok, err = coroutine.resume(co, arg)
|
||||
if not ok then error(debug.traceback(co, err), 0) end
|
||||
end, self.stack)
|
||||
local out = self.out
|
||||
self.out = {}
|
||||
return out
|
||||
end
|
||||
|
||||
function Match:start()
|
||||
if self.phase ~= "init" then error("g3u match already started", 2) end
|
||||
return self:_resume()
|
||||
end
|
||||
|
||||
function Match:_setup()
|
||||
local M = mods()
|
||||
local State = M["src.core.game3.battle.state"]
|
||||
local Adapter = M["src.core.game3.battle.adapter"]
|
||||
self.rng = Rng.make(self.seed, self.draws)
|
||||
local st = State.new({ playerParty = self.parties[0], foeParty = self.parties[1], rng = self.rng })
|
||||
st.link = true
|
||||
st.linkMaster = true
|
||||
st.terrain = 8
|
||||
st.g3u = true
|
||||
st.moveMax = self.t.moveMax
|
||||
st.moveExcluded = self.illegal
|
||||
st.playerName = self.names[0]
|
||||
st.peerName = self.names[1]
|
||||
self.st = st
|
||||
self.ad = Adapter.new(st, function() end)
|
||||
self.lastWeather = st.weather
|
||||
end
|
||||
|
||||
function Match:_emit(ev)
|
||||
self.out[#self.out + 1] = ev
|
||||
end
|
||||
|
||||
function Match:_drain()
|
||||
local ad, st = self.ad, self.st
|
||||
local raw = ad._events
|
||||
ad._events = {}
|
||||
local first = #self.out + 1
|
||||
Events.normalize(raw, self.out)
|
||||
local reported = { [0] = {}, [1] = {} }
|
||||
for i = first, #self.out do
|
||||
local ev = self.out[i]
|
||||
if ev.kind == "stage" and ev.side ~= nil then
|
||||
local r = reported[ev.side]
|
||||
r[ev.stat] = (r[ev.stat] or 0) + (ev.delta or 0)
|
||||
end
|
||||
end
|
||||
local State = mods()["src.core.game3.battle.state"]
|
||||
for seat = 0, 1 do
|
||||
local b = State.battler(st, seat)
|
||||
if b and b.stages then
|
||||
local snap = self.stageSnap[seat]
|
||||
if self.stageOwner[seat] == b and snap then
|
||||
for _, k in ipairs(STAGES) do
|
||||
local want = (snap[k] or 0) + (reported[seat][k] or 0)
|
||||
local cur = b.stages[k] or 0
|
||||
if cur ~= want then
|
||||
self:_emit({ kind = "stage", side = seat, stat = k, delta = cur - want, sync = true })
|
||||
end
|
||||
end
|
||||
end
|
||||
local copy = {}
|
||||
for _, k in ipairs(STAGES) do copy[k] = b.stages[k] or 0 end
|
||||
self.stageOwner[seat], self.stageSnap[seat] = b, copy
|
||||
end
|
||||
end
|
||||
if st.weather ~= self.lastWeather then
|
||||
self.lastWeather = st.weather
|
||||
self:_emit({ kind = "weather", weather = st.weather or "NONE", turns = st.weatherTurns })
|
||||
end
|
||||
end
|
||||
|
||||
function Match:_hashNow()
|
||||
return Hash.value((Hash.parts(self.st, self.draws)))
|
||||
end
|
||||
|
||||
function Match:hash(turn)
|
||||
if turn == nil then turn = self.turn end
|
||||
return self.hashes[turn]
|
||||
end
|
||||
|
||||
function Match:_ask(need)
|
||||
self.phase = "replace"
|
||||
self.need = need
|
||||
for seat = 0, 1 do
|
||||
if need[seat] then self:_emit({ kind = "need_replacement", side = seat, reason = need[seat].kind }) end
|
||||
end
|
||||
local picks = coroutine.yield("replace")
|
||||
self.need = nil
|
||||
self.phase = "running"
|
||||
return picks
|
||||
end
|
||||
|
||||
function Match:_finish(winner, why)
|
||||
local st = self.st
|
||||
st.over = true
|
||||
local result
|
||||
if winner == "draw" then
|
||||
result = { draw = true, why = why }
|
||||
st.result = "draw"
|
||||
else
|
||||
result = { winner = winner, why = why }
|
||||
st.result = (winner == 0) and "win" or "lose"
|
||||
end
|
||||
self.result = result
|
||||
self.phase = "over"
|
||||
self:_emit({ kind = "end", result = result })
|
||||
end
|
||||
|
||||
local function anyFainted(st, State)
|
||||
return State.isFainted(st.player) or State.isFainted(st.enemy)
|
||||
end
|
||||
|
||||
-- pokefirered/src/battle_util.c:1144
|
||||
function Match:_handleFaints()
|
||||
local M = mods()
|
||||
local State, Engine = M["src.core.game3.battle.state"], M["src.core.game3.battle.engine"]
|
||||
local st, ad = self.st, self.ad
|
||||
while true do
|
||||
if st.over then return true end
|
||||
local pF, eF = State.isFainted(st.player), State.isFainted(st.enemy)
|
||||
if not pF and not eF then
|
||||
local r = Engine.checkEnd(st, ad)
|
||||
if r == "win" then self:_finish(0, "faint") return true end
|
||||
if r == "lose" then self:_finish(1, "faint") return true end
|
||||
if r == "draw" then self:_finish("draw", "faint") return true end
|
||||
return false
|
||||
end
|
||||
State.syncBattlerToParty(st.player, st.playerParty)
|
||||
State.syncBattlerToParty(st.enemy, st.foeParty)
|
||||
local pL, eL = Engine.hasLivingMons(st.playerParty), Engine.hasLivingMons(st.foeParty)
|
||||
-- pokefirered/src/battle_script_commands.c:3413
|
||||
if not pL and not eL then self:_finish("draw", "faint") return true end
|
||||
if not pL then self:_finish(1, "faint") return true end
|
||||
if not eL then self:_finish(0, "faint") return true end
|
||||
local need = {}
|
||||
if pF then need[0] = { kind = "faint", candidates = Engine.replacementCandidates(st, 0) } end
|
||||
if eF then need[1] = { kind = "faint", candidates = Engine.replacementCandidates(st, 1) } end
|
||||
local picks = self:_ask(need)
|
||||
for seat = 0, 1 do
|
||||
if need[seat] then
|
||||
Engine.performSwitch(st, ad, seat, picks[seat], { reason = "switch", nativeSwitchKind = "replace" })
|
||||
Engine.switchInEffects(st, ad, State.battler(st, seat), { spikes = true })
|
||||
self:_drain()
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- pokefirered/src/battle_script_commands.c:8337
|
||||
function Match:_pursuitRow(pid, targetId)
|
||||
local M = mods()
|
||||
local State, Engine = M["src.core.game3.battle.state"], M["src.core.game3.battle.engine"]
|
||||
local st, ad = self.st, self.ad
|
||||
local user, target = State.battler(st, pid), State.battler(st, targetId)
|
||||
if not user or State.isFainted(user) or not target or State.isFainted(target) then return nil end
|
||||
for _, row in ipairs(st.turnActions or {}) do
|
||||
if row.battler == pid and row.kind == "move" and not row.done and not row.finished
|
||||
and Engine.isPursuit(row.move) and not ad:hasStatus(user, "SLP") and not ad:hasStatus(user, "FRZ")
|
||||
and (tonumber(user.expTruantCounter) or 0) == 0 then
|
||||
return row
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Match:_switchAction(id, slot)
|
||||
local M = mods()
|
||||
local State, Engine = M["src.core.game3.battle.state"], M["src.core.game3.battle.engine"]
|
||||
local st, ad = self.st, self.ad
|
||||
local prow = self:_pursuitRow(1 - id, id)
|
||||
if prow then
|
||||
prow.done = true
|
||||
st.interactiveChoices = true
|
||||
Engine.resolveMove(State.battler(st, 1 - id), State.battler(st, id), prow.move, prow.slot, ad, st, {},
|
||||
{ pursuitSwitch = true })
|
||||
st.interactiveChoices = nil
|
||||
self:_drain()
|
||||
end
|
||||
if State.isFainted(State.battler(st, id)) then
|
||||
if st.monToSwitchInto then st.monToSwitchInto[id] = nil end
|
||||
return true
|
||||
end
|
||||
Engine.performSwitch(st, ad, id, slot, { reason = "switch", nativeSwitchKind = "switch" })
|
||||
Engine.switchInEffects(st, ad, State.battler(st, id), { spikes = true })
|
||||
self:_drain()
|
||||
return anyFainted(st, State)
|
||||
end
|
||||
|
||||
function Match:_moveAction(act)
|
||||
local M = mods()
|
||||
local State, Engine = M["src.core.game3.battle.state"], M["src.core.game3.battle.engine"]
|
||||
local st, ad = self.st, self.ad
|
||||
local u, tg = State.occupant(st, act.user), State.occupant(st, act.target)
|
||||
if State.isFainted(u) or State.isFainted(tg) then return false end
|
||||
act.done = true
|
||||
st.interactiveChoices = true
|
||||
local out = Engine.resolveMove(act.user, act.target, act.move, act.slot, ad, st, {})
|
||||
self:_drain()
|
||||
while out and out.pendingChoice do
|
||||
local req = out.pendingChoice
|
||||
local seat = Events.seatOf(req.side)
|
||||
local picks = self:_ask({ [seat] = { kind = "baton_pass", candidates = req.candidates } })
|
||||
st.interactiveChoices = true
|
||||
out = Engine.resumeChoice(st, ad, picks[seat])
|
||||
self:_drain()
|
||||
end
|
||||
st.interactiveChoices = nil
|
||||
return anyFainted(st, State)
|
||||
end
|
||||
|
||||
function Match:_engineAction(seat, act)
|
||||
local State = mods()["src.core.game3.battle.state"]
|
||||
local b = State.battler(self.st, seat)
|
||||
local side = SIDE[seat]
|
||||
if act.kind == "switch" then return { kind = "switch", slot = act.index, user = side } end
|
||||
local mon = b.mon or {}
|
||||
if b.expLockedMove or b.expMustRecharge then
|
||||
-- pokefirered/src/battle_main.c:3125
|
||||
local slot = b.expLockedSlot
|
||||
return { kind = "move", user = side, locked = true, slot = slot,
|
||||
move = b.expLockedMove or b.lastMoveId or b.lastMove or (mon.moves and mon.moves[slot or 1]) }
|
||||
end
|
||||
if act.slot == 0 then return { kind = "move", move = MOVE_STRUGGLE, slot = nil, user = side } end
|
||||
return { kind = "move", move = mon.moves[act.slot], slot = act.slot, user = side }
|
||||
end
|
||||
|
||||
function Match:_turn(acts)
|
||||
local Engine = mods()["src.core.game3.battle.engine"]
|
||||
local st, ad = self.st, self.ad
|
||||
st.turn = st.turn + 1
|
||||
self.turn = st.turn
|
||||
local f0, f1 = acts[0].kind == "forfeit", acts[1].kind == "forfeit"
|
||||
if f0 or f1 then
|
||||
-- pokeemerald/src/battle_main.c:5061
|
||||
self:_finish((f0 and f1) and "draw" or (f0 and 1 or 0), "forfeit")
|
||||
return
|
||||
end
|
||||
local pAct, eAct = self:_engineAction(0, acts[0]), self:_engineAction(1, acts[1])
|
||||
st.monToSwitchInto = {}
|
||||
if pAct.kind == "switch" then st.monToSwitchInto[0] = pAct.slot end
|
||||
if eAct.kind == "switch" then st.monToSwitchInto[1] = eAct.slot end
|
||||
local actions, meta = Engine.planTurnFromActions(st, ad, pAct, eAct)
|
||||
self:_drain()
|
||||
local midFaint = false
|
||||
if meta and meta.kind == "switch" then midFaint = self:_switchAction(0, meta.slot) end
|
||||
if not midFaint then
|
||||
for _, act in ipairs(actions or {}) do
|
||||
if st.over then break end
|
||||
if Engine.actionRunnable(st, act) then
|
||||
if act.kind == "switch" then
|
||||
midFaint = self:_switchAction(act.battler, act.slot)
|
||||
elseif act.kind == "move" then
|
||||
midFaint = self:_moveAction(act)
|
||||
end
|
||||
if midFaint then break end
|
||||
end
|
||||
end
|
||||
end
|
||||
if midFaint and self:_handleFaints() then return end
|
||||
-- pokefirered/src/battle_main.c:2953
|
||||
Engine.collectResidualEvents(st, ad)
|
||||
self:_drain()
|
||||
self:_handleFaints()
|
||||
end
|
||||
|
||||
function Match:_main()
|
||||
local M = mods()
|
||||
local Engine = M["src.core.game3.battle.engine"]
|
||||
local State = M["src.core.game3.battle.state"]
|
||||
self:_setup()
|
||||
for seat = 0, 1 do
|
||||
local b = State.battler(self.st, seat)
|
||||
self:_emit({ kind = "sendout", side = seat, index = b.partyIndex, reason = "start" })
|
||||
end
|
||||
-- pokefirered/src/battle_main.c:2856
|
||||
Engine.battleStartEffects(self.st, self.ad)
|
||||
self:_drain()
|
||||
while true do
|
||||
self.hashes[self.turn] = self:_hashNow()
|
||||
if self.phase == "over" then return end
|
||||
self.phase = "choose"
|
||||
local acts = coroutine.yield("choose")
|
||||
self.phase = "running"
|
||||
self:_turn(acts)
|
||||
end
|
||||
end
|
||||
|
||||
local function canSwitch(b)
|
||||
-- pokefirered/src/battle_main.c:3196
|
||||
return not (b.expTrapped or b.escapePrevention or (b.expTrapTurns or 0) > 0 or b.expIngrain)
|
||||
end
|
||||
|
||||
function Match:_legal(seat)
|
||||
local M = mods()
|
||||
local State, Engine = M["src.core.game3.battle.state"], M["src.core.game3.battle.engine"]
|
||||
local st, ad = self.st, self.ad
|
||||
local b = State.battler(st, seat)
|
||||
local out = {}
|
||||
if b.expLockedMove or b.expMustRecharge then
|
||||
out[1] = { kind = "move", slot = b.expLockedSlot or 0, locked = true }
|
||||
out[2] = { kind = "forfeit" }
|
||||
return out
|
||||
end
|
||||
local bad = Engine.moveLimitations(b, ad)
|
||||
local moves = b.mon and b.mon.moves or {}
|
||||
for i = 1, 4 do
|
||||
local mv = tonumber(moves[i])
|
||||
if mv and not bad[i] and not self.illegal[mv] then out[#out + 1] = { kind = "move", slot = i } end
|
||||
end
|
||||
if #out == 0 then out[1] = { kind = "move", slot = 0 } end
|
||||
if canSwitch(b) then
|
||||
for _, i in ipairs(Engine.switchCandidates(st, seat)) do out[#out + 1] = { kind = "switch", index = i } end
|
||||
end
|
||||
out[#out + 1] = { kind = "forfeit" }
|
||||
return out
|
||||
end
|
||||
|
||||
function Match:legalActions(seat)
|
||||
if self.phase == "replace" then
|
||||
local need = self.need and self.need[seat]
|
||||
local out = {}
|
||||
for _, i in ipairs(need and need.candidates or {}) do out[#out + 1] = { kind = "switch", index = i } end
|
||||
return out
|
||||
end
|
||||
if self.phase ~= "choose" then return {} end
|
||||
self.legalCache = self.legalCache or {}
|
||||
local cached = self.legalCache[seat]
|
||||
if not cached then
|
||||
cached = Scope.run(self.t, function() return self:_legal(seat) end, self.stack)
|
||||
self.legalCache[seat] = cached
|
||||
end
|
||||
local out = {}
|
||||
for i, a in ipairs(cached) do out[i] = { kind = a.kind, slot = a.slot, index = a.index, locked = a.locked } end
|
||||
return out
|
||||
end
|
||||
|
||||
local function same(a, b)
|
||||
if a.kind ~= b.kind then return false end
|
||||
if a.kind == "move" then return a.slot == b.slot end
|
||||
if a.kind == "switch" then return a.index == b.index end
|
||||
return true
|
||||
end
|
||||
|
||||
function Match:isLegal(seat, act)
|
||||
if type(act) ~= "table" then return false end
|
||||
for _, l in ipairs(self:legalActions(seat)) do
|
||||
if same(l, act) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function Match:submit(acts)
|
||||
if self.phase ~= "choose" then error("g3u match is not choosing actions", 2) end
|
||||
for seat = 0, 1 do
|
||||
if not self:isLegal(seat, acts[seat]) then error("g3u illegal action from seat " .. seat, 2) end
|
||||
end
|
||||
return self:_resume({ [0] = acts[0], [1] = acts[1] })
|
||||
end
|
||||
|
||||
function Match:replace(picks)
|
||||
if self.phase ~= "replace" then error("g3u match is not waiting for a replacement", 2) end
|
||||
local clean = {}
|
||||
for seat = 0, 1 do
|
||||
if self.need[seat] then
|
||||
local i = picks and picks[seat]
|
||||
if not self:isLegal(seat, { kind = "switch", index = i }) then
|
||||
error("g3u illegal replacement from seat " .. seat, 2)
|
||||
end
|
||||
clean[seat] = i
|
||||
end
|
||||
end
|
||||
return self:_resume(clean)
|
||||
end
|
||||
|
||||
function Match:needs(seat)
|
||||
return self.phase == "replace" and self.need and self.need[seat] ~= nil
|
||||
end
|
||||
|
||||
function Match:active(seat)
|
||||
local State = mods()["src.core.game3.battle.state"]
|
||||
local b = self.st and State.battler(self.st, seat)
|
||||
return b and b.partyIndex or nil
|
||||
end
|
||||
|
||||
function Match:party(seat)
|
||||
return self.st and ((seat == 0) and self.st.playerParty or self.st.foeParty) or nil
|
||||
end
|
||||
|
||||
return Match
|
||||
@@ -0,0 +1,29 @@
|
||||
local Rng32 = require("src.core.game3.rng")
|
||||
|
||||
local Rng = {}
|
||||
|
||||
-- pokefirered/src/random.c:15 ISO_RANDOMIZE1
|
||||
function Rng.make(seed, counter)
|
||||
local value = math.floor(tonumber(seed) or 0) % 4294967296
|
||||
local function word()
|
||||
if counter then counter.n = counter.n + 1 end
|
||||
value = (Rng32.mulU32(value, 1103515245) + 24691) % 4294967296
|
||||
return math.floor(value / 65536) % 65536
|
||||
end
|
||||
return function(lo, hi)
|
||||
if lo == nil and hi == nil then return word() / 65536 end
|
||||
if hi == nil then
|
||||
lo = math.floor(tonumber(lo) or 1)
|
||||
if lo <= 0 then return 0 end
|
||||
return 1 + (word() % lo)
|
||||
end
|
||||
lo = math.floor(tonumber(lo) or 0)
|
||||
hi = math.floor(tonumber(hi) or lo)
|
||||
if hi < lo then lo, hi = hi, lo end
|
||||
local span = hi - lo + 1
|
||||
if span <= 0 then return lo end
|
||||
return lo + (word() % span)
|
||||
end
|
||||
end
|
||||
|
||||
return Rng
|
||||
@@ -0,0 +1,230 @@
|
||||
local Table = require("src.battle.g3u.Table")
|
||||
|
||||
local Scope = {}
|
||||
|
||||
Scope.trips = {}
|
||||
|
||||
local PRELOAD = {
|
||||
"src.core.game3.rng", "src.core.game3.rom_text", "src.import.CacheFs", "src.core.game3.dataset",
|
||||
"src.core.game3.pokemon", "src.mods.Runtime", "src.core.game3.battle.link_guard",
|
||||
"src.core.game3.battle.profile", "src.core.game3.battle.battle_text", "src.core.game3.battle.types",
|
||||
"src.core.game3.battle.moves", "src.core.game3.battle.damage", "src.core.game3.battle.state",
|
||||
"src.core.game3.battle.adapter", "src.core.game3.battle.rules", "src.core.game3.battle.effect_ids",
|
||||
"src.core.game3.battle.effects", "src.core.game3.battle.effects.hit",
|
||||
"src.core.game3.battle.effects._helpers", "src.core.game3.battle.effects.secondary",
|
||||
"src.core.game3.battle.effects.hazards", "src.core.game3.battle.effects.special",
|
||||
"src.core.game3.battle.residuals", "src.core.game3.battle.residual_handlers",
|
||||
"src.core.game3.battle.held_items", "src.core.game3.battle.abilities", "src.core.game3.battle.kinds",
|
||||
"src.core.game3.battle.oak_advice", "src.core.game3.battle.commands",
|
||||
"src.core.game3.battle.effect_ctx", "src.core.game3.battle.switch_seq", "src.core.game3.battle.engine",
|
||||
"src.core.game3.rs.enigma", "src.mods.Gen3Compat",
|
||||
}
|
||||
|
||||
local M = {}
|
||||
local loaded = false
|
||||
|
||||
local function load()
|
||||
if loaded then return end
|
||||
for _, name in ipairs(PRELOAD) do M[name] = require(name) end
|
||||
loaded = true
|
||||
end
|
||||
|
||||
function Scope.modules()
|
||||
load()
|
||||
return M
|
||||
end
|
||||
|
||||
local SPECIAL_KEYS = {
|
||||
[0] = "STRINGID_INTROMSG", [1] = "STRINGID_INTROSENDOUT", [2] = "STRINGID_RETURNMON",
|
||||
[3] = "STRINGID_SWITCHINMON", [4] = "STRINGID_USEDMOVE", [5] = "STRINGID_BATTLEEND",
|
||||
}
|
||||
|
||||
function Scope.textKey(id)
|
||||
if type(id) == "number" then return SPECIAL_KEYS[id] or ("STRINGID_" .. id) end
|
||||
return tostring(id)
|
||||
end
|
||||
|
||||
local function token(kind)
|
||||
return setmetatable({}, { __index = function(_, k)
|
||||
local n = tonumber(k)
|
||||
if not n then return nil end
|
||||
return "{" .. kind .. ":" .. n .. "}"
|
||||
end })
|
||||
end
|
||||
|
||||
local function identity()
|
||||
return setmetatable({}, { __index = function(_, k) return tonumber(k) end })
|
||||
end
|
||||
|
||||
local function trip(where)
|
||||
return function()
|
||||
Scope.trips[#Scope.trips + 1] = where
|
||||
error("g3u scope: " .. where .. " during a match", 2)
|
||||
end
|
||||
end
|
||||
|
||||
local prepared = setmetatable({}, { __mode = "k" })
|
||||
|
||||
local function prepare(t)
|
||||
local p = prepared[t]
|
||||
if p then return p end
|
||||
p = { rows = {}, types = {}, stats = {}, byNum = {}, byName = {} }
|
||||
for id = 1, t.moveMax do
|
||||
p.rows[id] = Table.engineRow(t, id)
|
||||
local key = "G3U_" .. id
|
||||
p.byNum[id] = key
|
||||
p.byName[key] = id
|
||||
end
|
||||
p.byName.STRUGGLE = 165
|
||||
for n = 1, t.dexMax do
|
||||
p.types[n] = Table.speciesTypes(t, n)
|
||||
p.stats[n] = Table.baseStats(t, n)
|
||||
end
|
||||
prepared[t] = p
|
||||
return p
|
||||
end
|
||||
|
||||
local function guardModule(list, mod, name, keep)
|
||||
for k, v in pairs(mod) do
|
||||
if type(v) == "function" and not (keep and keep[k]) then
|
||||
list[#list + 1] = { mod, k, trip(name .. "." .. k) }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function swaps(t)
|
||||
load()
|
||||
local p = prepare(t)
|
||||
if p.list then return p.list end
|
||||
local Pokemon = M["src.core.game3.pokemon"]
|
||||
local Moves = M["src.core.game3.battle.moves"]
|
||||
local Runtime = M["src.mods.Runtime"]
|
||||
local BattleText = M["src.core.game3.battle.battle_text"]
|
||||
local Types = M["src.core.game3.battle.types"]
|
||||
local Profile = M["src.core.game3.battle.profile"]
|
||||
local Adapter = M["src.core.game3.battle.adapter"]
|
||||
local SwitchSeq = M["src.core.game3.battle.switch_seq"]
|
||||
local Rng32 = M["src.core.game3.rng"]
|
||||
local guardCache = { read = trip("cache:read") }
|
||||
local function noop() end
|
||||
local function defaults() return Profile.DEFAULTS end
|
||||
local list = {
|
||||
{ Pokemon, "_cache", guardCache },
|
||||
{ Pokemon, "_names", token("species") },
|
||||
{ Pokemon, "_types", p.types },
|
||||
{ Pokemon, "_stats", p.stats },
|
||||
{ Pokemon, "_abilities", {} },
|
||||
{ Pokemon, "_abilityNames", token("ability") },
|
||||
{ Pokemon, "_romAbilityNames", token("ability") },
|
||||
{ Pokemon, "_moveNames", token("move") },
|
||||
{ Pokemon, "_romMoveNames", token("move") },
|
||||
{ Pokemon, "_speciesMeta", {} },
|
||||
{ Pokemon, "_national", { toNational = identity(), toSpecies = identity() } },
|
||||
{ Pokemon, "_byName", {} },
|
||||
{ Pokemon, "_manifest", {} },
|
||||
{ Pokemon, "_learnsets", {} },
|
||||
{ Pokemon, "_eggMoves", {} },
|
||||
{ Pokemon, "_evolutions", {} },
|
||||
{ Pokemon, "_tmhm", {} },
|
||||
{ Pokemon, "_dex", {} },
|
||||
{ Pokemon, "_battleMoves", p.rows },
|
||||
{ Pokemon, "install", trip("Pokemon.install") },
|
||||
{ Pokemon, "adjustFriendshipOnBattleFaint", noop },
|
||||
{ Pokemon, "currentMapSec", noop },
|
||||
{ Moves, "_rom", p.rows },
|
||||
{ Moves, "_romLoaded", true },
|
||||
{ Moves, "_linkRows", false },
|
||||
{ Moves, "BY_NUM", p.byNum },
|
||||
{ Moves, "_numByName", p.byName },
|
||||
{ Moves, "loadRomPack", trip("Moves.loadRomPack") },
|
||||
{ Runtime, "wants", function() return false end },
|
||||
{ Runtime, "wantsHook", function() return false end },
|
||||
{ Runtime, "emit", noop },
|
||||
{ Runtime, "call", function(_, vanilla, ...) return vanilla(...) end },
|
||||
{ BattleText, "get", function(id) return Scope.textKey(id) end },
|
||||
{ BattleText, "key", function(id, fill) return Scope.textKey(id), fill end },
|
||||
{ BattleText, "ir", trip("BattleText.ir") },
|
||||
{ BattleText, "context", trip("BattleText.context") },
|
||||
{ Types, "name", function(id) return "{type:" .. tostring(tonumber(id)) .. "}" end },
|
||||
{ Types, "get", function(id) return "{type:" .. tostring(tonumber(id)) .. "}" end },
|
||||
{ Profile, "get", defaults },
|
||||
{ Profile, "of", defaults },
|
||||
{ Adapter, "textSink", Scope.textKey },
|
||||
{ SwitchSeq, "switchInFill", function(_, b) return { side = b and b.side, switchBattler = b and b.id } end },
|
||||
{ math, "random", trip("math.random") },
|
||||
{ math, "randomseed", trip("math.randomseed") },
|
||||
}
|
||||
for _, k in ipairs({ "Random", "Random32", "Random2", "compat", "step", "mod", "WildEncounterRandom" }) do
|
||||
list[#list + 1] = { Rng32, k, trip("rng." .. k) }
|
||||
end
|
||||
guardModule(list, M["src.core.game3.rom_text"], "RomText", { key = true })
|
||||
guardModule(list, M["src.import.CacheFs"], "CacheFs")
|
||||
list[#list + 1] = { M["src.core.game3.dataset"], "cache", trip("Dataset.cache") }
|
||||
list[#list + 1] = { M["src.core.game3.dataset"], "hydrate", trip("Dataset.hydrate") }
|
||||
p.list = list
|
||||
return list
|
||||
end
|
||||
|
||||
function Scope.newStack()
|
||||
local pool, depth = {}, 0
|
||||
local S = {}
|
||||
function S.push(adapter, user, target, move, moveId, rng, opts)
|
||||
depth = depth + 1
|
||||
local ctx = pool[depth] or {}
|
||||
pool[depth] = ctx
|
||||
ctx.adapter, ctx.user, ctx.target, ctx.move, ctx.moveId, ctx.rng, ctx.opts =
|
||||
adapter, user, target, move, moveId, rng, opts
|
||||
return ctx
|
||||
end
|
||||
function S.pop()
|
||||
assert(depth > 0, "effect context stack underflow")
|
||||
local ctx = pool[depth]
|
||||
ctx.adapter, ctx.user, ctx.target, ctx.move, ctx.moveId, ctx.rng, ctx.opts = nil, nil, nil, nil, nil, nil, nil
|
||||
pool[depth] = nil
|
||||
depth = depth - 1
|
||||
end
|
||||
function S.current() return depth > 0 and pool[depth] or nil end
|
||||
function S.depth() return depth end
|
||||
function S.reset() while depth > 0 do S.pop() end end
|
||||
return S
|
||||
end
|
||||
|
||||
local depth = 0
|
||||
|
||||
function Scope.run(t, fn, stack)
|
||||
if depth > 0 then return fn() end
|
||||
local list = swaps(t)
|
||||
if stack then
|
||||
local all = {}
|
||||
for i, s in ipairs(list) do all[i] = s end
|
||||
local EffectCtx = M["src.core.game3.battle.effect_ctx"]
|
||||
for _, k in ipairs({ "push", "pop", "current", "depth", "reset" }) do all[#all + 1] = { EffectCtx, k, stack[k] } end
|
||||
list = all
|
||||
end
|
||||
local saved = {}
|
||||
for i, s in ipairs(list) do
|
||||
saved[i] = { s[1], s[2], rawget(s[1], s[2]) }
|
||||
rawset(s[1], s[2], s[3])
|
||||
end
|
||||
local Guard = M["src.core.game3.battle.link_guard"]
|
||||
local wasActive, wasTripped = Guard.active, Guard.tripped
|
||||
Guard.arm()
|
||||
depth = depth + 1
|
||||
local res = { pcall(fn) }
|
||||
depth = depth - 1
|
||||
local guardTrip = Guard.tripped
|
||||
Guard.active, Guard.tripped = wasActive, wasTripped
|
||||
for i = #saved, 1, -1 do
|
||||
local s = saved[i]
|
||||
rawset(s[1], s[2], s[3])
|
||||
end
|
||||
if guardTrip then Scope.trips[#Scope.trips + 1] = "link_guard:" .. tostring(guardTrip) end
|
||||
if not res[1] then error(res[2], 0) end
|
||||
return unpack(res, 2, table.maxn(res))
|
||||
end
|
||||
|
||||
function Scope.resetTrips()
|
||||
Scope.trips = {}
|
||||
end
|
||||
|
||||
return Scope
|
||||
@@ -0,0 +1,257 @@
|
||||
local E = require("src.core.game3.battle.effect_ids")
|
||||
local Identity = require("src.online.xgen.Identity")
|
||||
local Map1 = require("src.battle.g3u.EffectMap1")
|
||||
local Map2 = require("src.battle.g3u.EffectMap2")
|
||||
|
||||
local Table = {}
|
||||
|
||||
Table.VERSION = 1
|
||||
|
||||
Table.MOVE_COLS = { "power", "type", "accuracy", "pp", "effect", "chance", "target", "priority", "flags" }
|
||||
Table.SPECIES_COLS = { "type1", "type2", "hp", "atk", "def", "spe", "spa", "spd" }
|
||||
|
||||
Table.LIMITS = {
|
||||
[1] = { moveMax = 165, dexMax = 151 },
|
||||
[2] = { moveMax = 251, dexMax = 251 },
|
||||
}
|
||||
|
||||
local MAPS = { [1] = Map1, [2] = Map2 }
|
||||
Table.MAPS = MAPS
|
||||
|
||||
-- pokefirered/include/constants/pokemon.h:239
|
||||
local FLAG_PROTECT_AFFECTED = 2
|
||||
local FLAG_MIRROR_MOVE_AFFECTED = 16
|
||||
|
||||
-- pokefirered/include/battle.h:59
|
||||
local TARGET_SELECTED, TARGET_DEPENDS, TARGET_USER, TARGET_OPPONENTS_FIELD = 0, 1, 16, 64
|
||||
|
||||
local USER_EFFECTS = {}
|
||||
for _, id in ipairs({
|
||||
E.ATTACK_UP, E.DEFENSE_UP, E.SPEED_UP, E.SPECIAL_ATTACK_UP, E.SPECIAL_DEFENSE_UP, E.ACCURACY_UP,
|
||||
E.EVASION_UP, E.HAZE, E.BIDE, E.CONVERSION, E.RESTORE_HP, E.LIGHT_SCREEN, E.REST, E.MIST,
|
||||
E.FOCUS_ENERGY, E.ATTACK_UP_2, E.DEFENSE_UP_2, E.SPEED_UP_2, E.SPECIAL_ATTACK_UP_2,
|
||||
E.SPECIAL_DEFENSE_UP_2, E.ACCURACY_UP_2, E.EVASION_UP_2, E.REFLECT, E.SUBSTITUTE, E.METRONOME,
|
||||
E.SPLASH, E.CONVERSION_2, E.SLEEP_TALK, E.DESTINY_BOND, E.HEAL_BELL, E.MINIMIZE, E.CURSE,
|
||||
E.PROTECT, E.SANDSTORM, E.ENDURE, E.SAFEGUARD, E.BATON_PASS, E.MORNING_SUN, E.SYNTHESIS,
|
||||
E.MOONLIGHT, E.RAIN_DANCE, E.SUNNY_DAY, E.BELLY_DRUM, E.TELEPORT, E.DEFENSE_CURL, E.SOFTBOILED,
|
||||
E.MIRROR_MOVE,
|
||||
}) do USER_EFFECTS[id] = true end
|
||||
|
||||
local NO_MIRROR = { [E.MIRROR_MOVE] = true, [E.METRONOME] = true, [E.SLEEP_TALK] = true }
|
||||
|
||||
-- pokefirered/src/data/battle_moves.h:465
|
||||
local CERTAIN_SECONDARY = { [E.TRAP] = true, [E.PAY_DAY] = true }
|
||||
|
||||
local function targetOf(effect)
|
||||
if effect == E.COUNTER or effect == E.MIRROR_COAT then return TARGET_DEPENDS end
|
||||
if effect == E.SPIKES then return TARGET_OPPONENTS_FIELD end
|
||||
if USER_EFFECTS[effect] then return TARGET_USER end
|
||||
return TARGET_SELECTED
|
||||
end
|
||||
|
||||
local function flagsOf(effect, target)
|
||||
local f = 0
|
||||
if target == TARGET_SELECTED or target == TARGET_DEPENDS then f = f + FLAG_PROTECT_AFFECTED end
|
||||
if target == TARGET_SELECTED and not NO_MIRROR[effect] then f = f + FLAG_MIRROR_MOVE_AFFECTED end
|
||||
return f
|
||||
end
|
||||
|
||||
local SUPPORTED = {}
|
||||
for _, map in pairs(MAPS) do
|
||||
for _, row in pairs(map.EFFECTS) do SUPPORTED[row.effect] = true end
|
||||
for _, row in pairs(map.MOVES) do SUPPORTED[row.effect] = true end
|
||||
end
|
||||
Table.SUPPORTED_EFFECTS = SUPPORTED
|
||||
|
||||
local function gen3Type(name)
|
||||
return name and Identity.GEN3_TYPE_ID[name] or nil
|
||||
end
|
||||
|
||||
function Table.mapMove(gen, id, mv)
|
||||
local map = MAPS[gen]
|
||||
if not map then return nil, "bad_gen" end
|
||||
if type(mv) ~= "table" then return nil, "missing_move" end
|
||||
local name = mv.effect
|
||||
local base = map.EFFECTS[name]
|
||||
if not base then
|
||||
if map.UNSUPPORTED[name] then return nil, "unsupported_effect", name end
|
||||
return nil, "unknown_effect", name
|
||||
end
|
||||
local over = map.MOVES[id] or {}
|
||||
local effect = over.effect or base.effect
|
||||
local chance
|
||||
if gen == 1 then
|
||||
chance = base.chance or 0
|
||||
else
|
||||
chance = math.floor(tonumber(mv.effectChance) or 0)
|
||||
end
|
||||
if over.chance then chance = over.chance end
|
||||
if chance == 0 and CERTAIN_SECONDARY[effect] then chance = 100 end
|
||||
local priority
|
||||
if gen == 1 then
|
||||
priority = map.PRIORITY[id] or 0
|
||||
else
|
||||
local raw = map.MOVE_PRIORITY[id] or map.EFFECT_PRIORITY[name] or map.BASE_PRIORITY
|
||||
priority = raw - map.BASE_PRIORITY
|
||||
end
|
||||
local typeId = gen3Type(mv.type)
|
||||
if not typeId then return nil, "unknown_type", mv.type end
|
||||
local target = targetOf(effect)
|
||||
return {
|
||||
math.floor(tonumber(mv.power) or 0), typeId, math.floor(tonumber(mv.accuracy) or 0),
|
||||
math.floor(tonumber(mv.pp) or 0), effect, chance, target, priority, flagsOf(effect, target),
|
||||
}
|
||||
end
|
||||
|
||||
function Table.unsupportedMoves(data)
|
||||
local gen = data and data.generation
|
||||
local lim = Table.LIMITS[gen]
|
||||
local out = {}
|
||||
if not lim then return out end
|
||||
for id = 1, lim.moveMax do
|
||||
local mv = data.moves[id]
|
||||
if mv then
|
||||
local row, why, detail = Table.mapMove(gen, id, mv)
|
||||
if not row then out[#out + 1] = { id = id, why = why, effect = detail } end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function speciesRow(sp)
|
||||
local t1 = gen3Type(sp.types and sp.types[1])
|
||||
local t2 = gen3Type(sp.types and (sp.types[2] or sp.types[1]))
|
||||
if not t1 or not t2 then return nil end
|
||||
local b = sp.base or {}
|
||||
local row = { t1, t2 }
|
||||
for _, k in ipairs({ "hp", "atk", "def", "spe", "spa", "spd" }) do
|
||||
local v = math.floor(tonumber(b[k]) or 0)
|
||||
if v < 1 then return nil end
|
||||
row[#row + 1] = v
|
||||
end
|
||||
return row
|
||||
end
|
||||
|
||||
function Table.build(data, gen)
|
||||
if type(data) ~= "table" then return nil, "no_data" end
|
||||
gen = gen or data.generation
|
||||
local lim = Table.LIMITS[gen]
|
||||
if not lim or data.generation ~= gen then return nil, "bad_gen" end
|
||||
local t = { v = Table.VERSION, gen = gen, moveMax = lim.moveMax, dexMax = lim.dexMax,
|
||||
moves = {}, species = {}, illegal = {} }
|
||||
for id = 1, lim.moveMax do
|
||||
local mv = data.moves[id]
|
||||
if not mv then return nil, "missing_move", id end
|
||||
local row, why, detail = Table.mapMove(gen, id, mv)
|
||||
if not row then
|
||||
if why ~= "unsupported_effect" then return nil, why, { id = id, detail = detail } end
|
||||
row = { math.floor(tonumber(mv.power) or 0), gen3Type(mv.type) or 0, 0, 1, E.HIT, 0, TARGET_SELECTED, 0, 0 }
|
||||
t.illegal[#t.illegal + 1] = id
|
||||
end
|
||||
t.moves[id] = row
|
||||
end
|
||||
for n = 1, lim.dexMax do
|
||||
local sp = data.species[n]
|
||||
if not sp then return nil, "missing_species", n end
|
||||
local row = speciesRow(sp)
|
||||
if not row then return nil, "bad_species", n end
|
||||
t.species[n] = row
|
||||
end
|
||||
return t
|
||||
end
|
||||
|
||||
local GEN1_TYPES, GEN2_TYPES = {}, {}
|
||||
for _, name in ipairs(Identity.GEN1_TYPES) do GEN1_TYPES[Identity.GEN3_TYPE_ID[name]] = true end
|
||||
for _, name in ipairs(Identity.TYPES) do GEN2_TYPES[Identity.GEN3_TYPE_ID[name]] = true end
|
||||
GEN2_TYPES[Identity.GEN3_TYPE_ID.MYSTERY] = true
|
||||
local TYPE_SETS = { [1] = GEN1_TYPES, [2] = GEN2_TYPES }
|
||||
|
||||
local VALID_TARGETS = { [TARGET_SELECTED] = true, [TARGET_DEPENDS] = true, [TARGET_USER] = true,
|
||||
[TARGET_OPPONENTS_FIELD] = true }
|
||||
|
||||
local function int(v, lo, hi)
|
||||
return type(v) == "number" and v == v and v == math.floor(v) and v >= lo and v <= hi
|
||||
end
|
||||
|
||||
local function exact(row, n)
|
||||
if type(row) ~= "table" or #row ~= n then return false end
|
||||
local count = 0
|
||||
for _ in pairs(row) do count = count + 1 end
|
||||
return count == n
|
||||
end
|
||||
|
||||
local MOVE_KEYS = { v = true, gen = true, moveMax = true, dexMax = true, moves = true, species = true, illegal = true }
|
||||
|
||||
function Table.validate(t, gen)
|
||||
if type(t) ~= "table" then return nil, "not_a_table" end
|
||||
for k in pairs(t) do
|
||||
if not MOVE_KEYS[k] then return nil, "unknown_key" end
|
||||
end
|
||||
if t.v ~= Table.VERSION then return nil, "bad_version" end
|
||||
if gen ~= nil and t.gen ~= gen then return nil, "gen_mismatch" end
|
||||
local lim = Table.LIMITS[t.gen]
|
||||
if not lim then return nil, "bad_gen" end
|
||||
if t.moveMax ~= lim.moveMax or t.dexMax ~= lim.dexMax then return nil, "bad_limits" end
|
||||
local types = TYPE_SETS[t.gen]
|
||||
if not exact(t.moves, lim.moveMax) then return nil, "bad_move_count" end
|
||||
for id = 1, lim.moveMax do
|
||||
local r = t.moves[id]
|
||||
if not exact(r, #Table.MOVE_COLS) then return nil, "bad_move_row", id end
|
||||
if not int(r[1], 0, 255) then return nil, "bad_power", id end
|
||||
if not int(r[2], 0, 17) or not types[r[2]] then return nil, "bad_type", id end
|
||||
if not int(r[3], 0, 100) then return nil, "bad_accuracy", id end
|
||||
if not int(r[4], 1, 64) then return nil, "bad_pp", id end
|
||||
if not int(r[5], 0, 255) or not SUPPORTED[r[5]] then return nil, "bad_effect", id end
|
||||
if not int(r[6], 0, 100) then return nil, "bad_chance", id end
|
||||
if not int(r[7], 0, 255) or not VALID_TARGETS[r[7]] then return nil, "bad_target", id end
|
||||
if not int(r[8], -6, 6) then return nil, "bad_priority", id end
|
||||
if not int(r[9], 0, 63) then return nil, "bad_flags", id end
|
||||
end
|
||||
if not exact(t.species, lim.dexMax) then return nil, "bad_species_count" end
|
||||
for n = 1, lim.dexMax do
|
||||
local r = t.species[n]
|
||||
if not exact(r, #Table.SPECIES_COLS) then return nil, "bad_species_row", n end
|
||||
if not int(r[1], 0, 17) or not types[r[1]] or not int(r[2], 0, 17) or not types[r[2]] then
|
||||
return nil, "bad_species_type", n
|
||||
end
|
||||
for i = 3, 8 do
|
||||
if not int(r[i], 1, 255) then return nil, "bad_base_stat", n end
|
||||
end
|
||||
end
|
||||
if type(t.illegal) ~= "table" or #t.illegal > lim.moveMax then return nil, "bad_illegal" end
|
||||
local count, prev = 0, 0
|
||||
for _ in pairs(t.illegal) do count = count + 1 end
|
||||
if count ~= #t.illegal then return nil, "bad_illegal" end
|
||||
for _, id in ipairs(t.illegal) do
|
||||
if not int(id, prev + 1, lim.moveMax) then return nil, "bad_illegal" end
|
||||
prev = id
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function Table.illegalSet(t)
|
||||
local out = {}
|
||||
for _, id in ipairs(t.illegal or {}) do out[id] = true end
|
||||
return out
|
||||
end
|
||||
|
||||
function Table.engineRow(t, id)
|
||||
local r = t.moves[id]
|
||||
if not r then return nil end
|
||||
return { power = r[1], type = r[2], accuracy = r[3], pp = r[4], effect = r[5],
|
||||
secondaryChance = r[6], target = r[7], priority = r[8], flags = r[9] }
|
||||
end
|
||||
|
||||
function Table.speciesTypes(t, n)
|
||||
local r = t.species[n]
|
||||
if not r then return nil end
|
||||
return { r[1], r[2] }
|
||||
end
|
||||
|
||||
function Table.baseStats(t, n)
|
||||
local r = t.species[n]
|
||||
if not r then return nil end
|
||||
return { hp = r[3], atk = r[4], def = r[5], spe = r[6], spa = r[7], spd = r[8] }
|
||||
end
|
||||
|
||||
return Table
|
||||
@@ -0,0 +1,192 @@
|
||||
local Table = require("src.battle.g3u.Table")
|
||||
|
||||
local Wire = {}
|
||||
|
||||
Wire.TYPES = { "g3u_table", "g3u_party", "g3u_action", "g3u_replace", "g3u_hash", "g3u_bye" }
|
||||
|
||||
Wire.MAX_BYTES = {
|
||||
g3u_table = 16384, g3u_party = 4096, g3u_action = 128, g3u_replace = 96, g3u_hash = 96, g3u_bye = 128,
|
||||
}
|
||||
|
||||
Wire.BYE = { forfeit = true, desync = true, illegal = true, timeout = true, quit = true, error = true,
|
||||
bad_table = true, bad_party = true }
|
||||
|
||||
Wire.MAX_TURN = 4096
|
||||
Wire.MAX_NICK_CHARS = 10
|
||||
|
||||
local function int(v, lo, hi)
|
||||
return type(v) == "number" and v == v and v == math.floor(v) and v >= lo and v <= hi
|
||||
end
|
||||
|
||||
local function keys(t, allowed)
|
||||
for k in pairs(t) do
|
||||
if not allowed[k] then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function chars(s)
|
||||
local n = 0
|
||||
for i = 1, #s do
|
||||
local b = s:byte(i)
|
||||
if b < 0x80 or b >= 0xC0 then n = n + 1 end
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
function Wire.size(msg)
|
||||
return #require("src.link.Json").encode(msg)
|
||||
end
|
||||
|
||||
function Wire.table(t)
|
||||
return { type = "g3u_table", table = t }
|
||||
end
|
||||
|
||||
function Wire.party(records)
|
||||
return { type = "g3u_party", records = records }
|
||||
end
|
||||
|
||||
function Wire.action(turn, act)
|
||||
local m = { type = "g3u_action", turn = turn, kind = act.kind }
|
||||
if act.kind == "move" then m.slot = act.slot end
|
||||
if act.kind == "switch" then m.index = act.index end
|
||||
return m
|
||||
end
|
||||
|
||||
function Wire.replace(turn, index)
|
||||
return { type = "g3u_replace", turn = turn, index = index }
|
||||
end
|
||||
|
||||
function Wire.hash(turn, hash)
|
||||
return { type = "g3u_hash", turn = turn, hash = hash }
|
||||
end
|
||||
|
||||
function Wire.bye(why)
|
||||
return { type = "g3u_bye", why = why }
|
||||
end
|
||||
|
||||
function Wire.toAction(m)
|
||||
if m.kind == "move" then return { kind = "move", slot = m.slot } end
|
||||
if m.kind == "switch" then return { kind = "switch", index = m.index } end
|
||||
return { kind = "forfeit" }
|
||||
end
|
||||
|
||||
local V = {}
|
||||
|
||||
function V.g3u_table(m, ctx)
|
||||
if not keys(m, { type = true, table = true }) then return nil, "unknown_key" end
|
||||
local ok, why = Table.validate(m.table, ctx and ctx.gen)
|
||||
if not ok then return nil, "bad_table:" .. tostring(why) end
|
||||
return m
|
||||
end
|
||||
|
||||
local REC_KEYS = { species = true, level = true, hp = true, maxHp = true, atk = true, def = true,
|
||||
spAtk = true, spDef = true, speed = true, moves = true, nickname = true, gender = true,
|
||||
friendship = true, ivs = true }
|
||||
local IV_KEYS = { hp = true, atk = true, def = true, spe = true, spa = true, spd = true }
|
||||
local MOVE_KEYS = { id = true, pp = true, ppUps = true }
|
||||
|
||||
-- pokefirered/src/pokemon.c:2093
|
||||
local function statCap(level) return math.floor((2 * 255 + 31 + 63) * level / 100) + 5 end
|
||||
local function hpCap(level) return math.floor((2 * 255 + 31 + 63) * level / 100) + level + 10 end
|
||||
|
||||
function Wire.record(r, t)
|
||||
if type(r) ~= "table" or not keys(r, REC_KEYS) then return nil, "record_shape" end
|
||||
if not int(r.species, 1, t.dexMax) then return nil, "species" end
|
||||
if not int(r.level, 1, 100) then return nil, "level" end
|
||||
if not int(r.maxHp, 1, hpCap(r.level)) or not int(r.hp, 1, r.maxHp) then return nil, "hp" end
|
||||
for _, k in ipairs({ "atk", "def", "spAtk", "spDef", "speed" }) do
|
||||
if not int(r[k], 1, statCap(r.level)) then return nil, "stat_" .. k end
|
||||
end
|
||||
if type(r.moves) ~= "table" or #r.moves < 1 or #r.moves > 4 then return nil, "moves" end
|
||||
local count, seen = 0, {}
|
||||
for _ in pairs(r.moves) do count = count + 1 end
|
||||
if count ~= #r.moves then return nil, "moves" end
|
||||
local illegal = Table.illegalSet(t)
|
||||
for _, mv in ipairs(r.moves) do
|
||||
if type(mv) ~= "table" or not keys(mv, MOVE_KEYS) then return nil, "move_shape" end
|
||||
if not int(mv.id, 1, t.moveMax) or seen[mv.id] then return nil, "move_id" end
|
||||
if illegal[mv.id] then return nil, "move_unsupported" end
|
||||
seen[mv.id] = true
|
||||
local base = t.moves[mv.id][4]
|
||||
if not int(mv.ppUps or 0, 0, 3) then return nil, "pp_ups" end
|
||||
if not int(mv.pp, 0, math.min(64, base + math.floor(base * 3 / 5))) then return nil, "pp" end
|
||||
end
|
||||
if r.nickname ~= nil and (type(r.nickname) ~= "string" or #r.nickname > Wire.MAX_NICK_CHARS * 4
|
||||
or chars(r.nickname) > Wire.MAX_NICK_CHARS) then
|
||||
return nil, "nickname"
|
||||
end
|
||||
if r.gender ~= nil and not int(r.gender, 0, 2) then return nil, "gender" end
|
||||
if r.friendship ~= nil and not int(r.friendship, 0, 255) then return nil, "friendship" end
|
||||
if r.ivs ~= nil then
|
||||
if type(r.ivs) ~= "table" or not keys(r.ivs, IV_KEYS) then return nil, "ivs" end
|
||||
for _, v in pairs(r.ivs) do
|
||||
if not int(v, 0, 31) then return nil, "ivs" end
|
||||
end
|
||||
end
|
||||
return r
|
||||
end
|
||||
|
||||
function V.g3u_party(m, ctx)
|
||||
if not keys(m, { type = true, records = true }) then return nil, "unknown_key" end
|
||||
local t = ctx and ctx.table
|
||||
if type(t) ~= "table" then return nil, "no_table" end
|
||||
local recs = m.records
|
||||
if type(recs) ~= "table" or #recs < 1 or #recs > 6 then return nil, "party_size" end
|
||||
local count = 0
|
||||
for _ in pairs(recs) do count = count + 1 end
|
||||
if count ~= #recs then return nil, "party_size" end
|
||||
for i, r in ipairs(recs) do
|
||||
local ok, why = Wire.record(r, t)
|
||||
if not ok then return nil, "record_" .. i .. ":" .. why end
|
||||
end
|
||||
return m
|
||||
end
|
||||
|
||||
function V.g3u_action(m)
|
||||
if not int(m.turn, 1, Wire.MAX_TURN) then return nil, "turn" end
|
||||
if m.kind == "move" then
|
||||
if not keys(m, { type = true, turn = true, kind = true, slot = true }) then return nil, "unknown_key" end
|
||||
if not int(m.slot, 0, 4) then return nil, "slot" end
|
||||
elseif m.kind == "switch" then
|
||||
if not keys(m, { type = true, turn = true, kind = true, index = true }) then return nil, "unknown_key" end
|
||||
if not int(m.index, 1, 6) then return nil, "index" end
|
||||
elseif m.kind == "forfeit" then
|
||||
if not keys(m, { type = true, turn = true, kind = true }) then return nil, "unknown_key" end
|
||||
else
|
||||
return nil, "kind"
|
||||
end
|
||||
return m
|
||||
end
|
||||
|
||||
function V.g3u_replace(m)
|
||||
if not keys(m, { type = true, turn = true, index = true }) then return nil, "unknown_key" end
|
||||
if not int(m.turn, 0, Wire.MAX_TURN) then return nil, "turn" end
|
||||
if not int(m.index, 1, 6) then return nil, "index" end
|
||||
return m
|
||||
end
|
||||
|
||||
function V.g3u_hash(m)
|
||||
if not keys(m, { type = true, turn = true, hash = true }) then return nil, "unknown_key" end
|
||||
if not int(m.turn, 0, Wire.MAX_TURN) then return nil, "turn" end
|
||||
if type(m.hash) ~= "string" or not m.hash:match("^%x%x%x%x%x%x%x%x$") then return nil, "hash" end
|
||||
return m
|
||||
end
|
||||
|
||||
function V.g3u_bye(m)
|
||||
if not keys(m, { type = true, why = true }) then return nil, "unknown_key" end
|
||||
if type(m.why) ~= "string" or not Wire.BYE[m.why] then return nil, "why" end
|
||||
return m
|
||||
end
|
||||
|
||||
Wire.VALIDATORS = V
|
||||
|
||||
function Wire.validate(m, ctx)
|
||||
if type(m) ~= "table" or type(m.type) ~= "string" then return nil, "not_a_message" end
|
||||
local fn = V[m.type]
|
||||
if not fn then return nil, "unknown_type" end
|
||||
if ctx and ctx.bytes and ctx.bytes > Wire.MAX_BYTES[m.type] then return nil, "too_big" end
|
||||
return fn(m, ctx)
|
||||
end
|
||||
|
||||
return Wire
|
||||
@@ -182,6 +182,7 @@ function Data:seedDefaults(version)
|
||||
Data.seedMtMoonB2FSuperNerd(self)
|
||||
-- #189: 1F cabin door order vs rooms map (survey zoom)
|
||||
require("src.world.SsAnneLayout").apply(self.maps)
|
||||
if gen == 1 then require("src.world.gen1.UnionCenters").seed(self) end
|
||||
end
|
||||
|
||||
-- The Karate Master (FightingDojo.asm) is a text_asm object: his object has
|
||||
|
||||
+4
-1
@@ -356,6 +356,7 @@ function Game:step(dt)
|
||||
else
|
||||
self.stack:update(dt)
|
||||
end
|
||||
if self.unionPresence then self.unionPresence:tick() end
|
||||
-- play time for the trainer card / save screen
|
||||
self.save.playTime = (self.save.playTime or 0) + dt
|
||||
-- Music.update is NOT serviced here: it decrements fade counters and
|
||||
@@ -1363,7 +1364,7 @@ function Game:writeSave()
|
||||
if ModRuntime.wants("save.writing") then
|
||||
ModRuntime.emit("save.writing", { save = self.save, meta = self.save.meta })
|
||||
end
|
||||
local written = SaveData.save(self.save)
|
||||
local written = require("src.world.gen1.UnionSafety").write(self.save, self.data, SaveData.save)
|
||||
if written then
|
||||
local eng = self:syncEngine()
|
||||
if eng then pcall(eng.noteSaveWritten, eng) end
|
||||
@@ -1510,6 +1511,7 @@ function Game:restoreSave(loaded, recovered, opts)
|
||||
local Pokemon = require("src.pokemon.Pokemon")
|
||||
for _, mon in ipairs(loaded.party or {}) do Pokemon.heal(mon) end
|
||||
end
|
||||
require("src.world.gen1.UnionSafety").settle(loaded, self.data)
|
||||
local modsDiff = SaveData.modsDiff(loaded, activeMods)
|
||||
local report = SaveData.validate(loaded, self.data)
|
||||
report.recovered = recovered
|
||||
@@ -1537,6 +1539,7 @@ function Game:restoreSave(loaded, recovered, opts)
|
||||
self.stack:push(self.overworld, loaded.player.map,
|
||||
loaded.player.x, loaded.player.y, facing,
|
||||
{ via = "boot", freshBoot = opts and opts.freshBoot })
|
||||
require("src.online.union.TradeTxn").resumePending(self)
|
||||
self.saveReport = report
|
||||
if not SaveData.emptyReport(report) then
|
||||
-- the report screen is a Screens id so mods (or the ui milestone) own
|
||||
|
||||
@@ -342,7 +342,9 @@ function Game2:continueGame(save)
|
||||
self:applyOptions()
|
||||
self.stack:clear()
|
||||
self.world = nil
|
||||
require("src.world.gen2.UnionSafety").settle(save, self.data)
|
||||
self:startWorld()
|
||||
require("src.online.union.TradeTxn").resumePending(self)
|
||||
-- After the adopt and after the world is standing, which is where Gen 1
|
||||
-- emits it (src/core/Game.lua:1127, once the stack has been rebuilt).
|
||||
if modsDiff then
|
||||
@@ -1002,6 +1004,7 @@ function Game2:snapshotSave()
|
||||
-- WRAM on the cart, so a save made on POKECENTER_2F must still know which
|
||||
-- centre's stairs lead back down -- World:loadPlayerData reads it back.
|
||||
self.save.backupWarp = world.backupWarp or self.save.backupWarp
|
||||
require("src.world.gen2.UnionSafety").seal(self.save, self.data)
|
||||
end
|
||||
self.save.options = self.options
|
||||
return self.save
|
||||
@@ -1137,6 +1140,7 @@ function Game2:load(opts)
|
||||
self.data.gen2Scripts = loadGenerated("data/generated/scripts.lua")
|
||||
self.data.gen2StdScripts = loadGenerated("data/generated/std_scripts.lua")
|
||||
self.data.gen2Text = loadGenerated("data/generated/text.lua")
|
||||
require("src.world.gen2.UnionCenter2F").apply(self.data)
|
||||
-- The engine's own strings, keyed by the disassembly's label. gen2Text
|
||||
-- above is the script text and is keyed by bank:address for the overworld
|
||||
-- VM, so the two are different tables and both are loaded. This one is
|
||||
@@ -1302,6 +1306,7 @@ function Game2:load(opts)
|
||||
-- open the same frame), rather than once per render frame in World:draw.
|
||||
local world = self.world
|
||||
if world and world.tickFrameClocks then world:tickFrameClocks() end
|
||||
if world then require("src.world.gen2.UnionRoomPresence").tick(self) end
|
||||
-- Not the audio tick: _UpdateSound runs once per frame off VBlank
|
||||
-- (audio/engine.asm:84, home/vblank.asm:141-143), never off the logic clock.
|
||||
local top = self.stack:top()
|
||||
|
||||
@@ -1045,6 +1045,11 @@ function Game3:saveGame()
|
||||
local P = package.loaded["src.core.game3.player"]
|
||||
if P and P.facing and not P.moving and P.cellX == self.session.x and P.cellY == self.session.y then self.session.facing = P.facing end
|
||||
local save = Schema.toSaveTable(self.session)
|
||||
local rules = Schema.rulesFor(self.session.version)
|
||||
if rules.saveLocation then
|
||||
local map, x, y, facing = rules.saveLocation(self.session, self)
|
||||
if map then save.map, save.x, save.y, save.facing = map, x, y, facing end
|
||||
end
|
||||
if SaveData.buildMeta then
|
||||
save.meta = SaveData.buildMeta(
|
||||
self.modStatus and self.modStatus.loaded, save.meta, self.sessionStartedAt)
|
||||
|
||||
+24
-6
@@ -1385,6 +1385,18 @@ local function tryMigrateLegacy(key, fs)
|
||||
-- refuse to delete the originals unless the new slot is loadable (from
|
||||
-- the main copy or, failing that, the backup)
|
||||
if not decodeSlot(fs, key, id) then return nil end
|
||||
local lbase, dbase = lmain:gsub("%.lua$", ""), dmain:gsub("%.lua$", "")
|
||||
for _, suffix in ipairs({ "_trade.lua", "_xtrade.lua", "_xtrade.lua.tmp" }) do
|
||||
local from = lbase .. suffix
|
||||
local body = fs.getInfo(from) and fs.read(from)
|
||||
if body then
|
||||
fs.write(dbase .. suffix, body)
|
||||
if not fs.getInfo(dbase .. suffix) then return nil end
|
||||
end
|
||||
end
|
||||
for _, suffix in ipairs({ "_trade.lua", "_xtrade.lua", "_xtrade.lua.tmp" }) do
|
||||
if fs.getInfo(lbase .. suffix) then remove(fs, lbase .. suffix) end
|
||||
end
|
||||
remove(fs, lmain)
|
||||
remove(fs, lbak)
|
||||
remove(fs, ltmp)
|
||||
@@ -1905,12 +1917,18 @@ local function copyTree(fs, from, to)
|
||||
end
|
||||
|
||||
local function pendingTradeSent(fs, main)
|
||||
local journal = main:gsub("%.lua$", "") .. "_trade.lua"
|
||||
if not fs.getInfo(journal) then return false end
|
||||
local data = SaveSerializer.decode(fs.read(journal) or "")
|
||||
if type(data) ~= "table" or type(data.entries) ~= "table" then return false end
|
||||
for _, e in ipairs(data.entries) do
|
||||
if type(e) == "table" and type(e.sent) == "table" then return true end
|
||||
local base = main:gsub("%.lua$", "")
|
||||
for _, journal in ipairs({ base .. "_trade.lua", base .. "_xtrade.lua" }) do
|
||||
if fs.getInfo(journal) then
|
||||
local data = SaveSerializer.decode(fs.read(journal) or "")
|
||||
if type(data) == "table" and type(data.entries) == "table" then
|
||||
for _, e in ipairs(data.entries) do
|
||||
if type(e) == "table" and (type(e.sent) == "table" or type(e.out) == "table") then
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
@@ -423,6 +423,12 @@ function Adapter.new(battleState, sayFn)
|
||||
if faintPolicy and fill and (id == "STRINGID_ATTACKERFAINTED" or id == "STRINGID_TARGETFAINTED") then
|
||||
self:prepareFaintAnnouncement(fill.atk or fill.def)
|
||||
end
|
||||
if Adapter.textSink then
|
||||
local key = Adapter.textSink(id, fill)
|
||||
self:pushEvent({ kind = "msg", text = key, id = key, fill = fill or {} })
|
||||
self._say(key)
|
||||
return key
|
||||
end
|
||||
local BattleText = require("src.core.game3.battle.battle_text")
|
||||
fill = Adapter.fill(self._st, fill)
|
||||
local text = BattleText.get(id, fill)
|
||||
|
||||
@@ -29,6 +29,7 @@ end
|
||||
|
||||
-- src/battle_message.c:437
|
||||
function Secondary.statName(stat)
|
||||
if require("src.core.game3.battle.adapter").textSink then return stat end
|
||||
return RomText.at("gStatNamesTable", Secondary.STAT_ID[stat])
|
||||
end
|
||||
|
||||
@@ -48,6 +49,15 @@ end
|
||||
|
||||
-- src/battle_script_commands.c:6758
|
||||
local function stat_text(ad, battler, stat, delta, isUser)
|
||||
if require("src.core.game3.battle.adapter").textSink then
|
||||
local id
|
||||
if delta > 0 then
|
||||
id = isUser and "STRINGID_ATTACKERSSTATROSE" or "STRINGID_DEFENDERSSTATROSE"
|
||||
else
|
||||
id = isUser and "STRINGID_ATTACKERSSTATFELL" or "STRINGID_DEFENDERSSTATFELL"
|
||||
end
|
||||
return ad:sayText(id, { atk = battler, def = battler, stat = stat, delta = delta })
|
||||
end
|
||||
local change
|
||||
if delta >= 2 then change = Secondary.sharpChange("STRINGID_STATSHARPLY", "STRINGID_STATROSE")
|
||||
elseif delta >= 1 then change = RomText.plain("STRINGID_STATROSE")
|
||||
@@ -112,6 +122,9 @@ function Secondary.changeStat(ad, battler, stat, delta, flags)
|
||||
local nxt = cur + delta
|
||||
if nxt < -6 then nxt = -6 elseif nxt > 6 then nxt = 6 end
|
||||
battler.stages[stat] = nxt
|
||||
if require("src.core.game3.battle.adapter").textSink then
|
||||
ad:pushEvent({ kind = "stage", side = battler.side, battler = battler.id, stat = stat, delta = nxt - cur })
|
||||
end
|
||||
if not flags.noAnim then
|
||||
ad:playAnim("general", "STATS_CHANGE", battler, battler, Secondary.statAnimArg(stat, delta))
|
||||
end
|
||||
|
||||
@@ -802,6 +802,14 @@ end
|
||||
|
||||
-- pokefirered/src/battle_script_commands.c:7519
|
||||
local function pick_metronome(M)
|
||||
local cap = tonumber(M.st and M.st.moveMax)
|
||||
if cap then
|
||||
local pool, skip = {}, M.st.moveExcluded or {}
|
||||
for m = 1, cap do
|
||||
if not forbidden(m, false) and not skip[m] then pool[#pool + 1] = m end
|
||||
end
|
||||
return pool[roll(M.adapter, 1, #pool)]
|
||||
end
|
||||
for _ = 1, 64 do
|
||||
local m = roll(M.adapter, 1, 511)
|
||||
if m < 355 and not forbidden(m, false) then return m end
|
||||
|
||||
@@ -206,6 +206,7 @@ end
|
||||
local function healthbox_gender(mon)
|
||||
if not mon then return nil end
|
||||
local g = mon.gender
|
||||
if g == "U" then return nil end
|
||||
if g ~= "M" and g ~= "F" then
|
||||
local Pokemon = require("src.core.game3.pokemon")
|
||||
local species = tonumber(mon.species or mon.speciesId)
|
||||
|
||||
@@ -463,6 +463,7 @@ end
|
||||
function State.prefixedName(st, battler, name)
|
||||
name = name or State.displayName(battler)
|
||||
if battler and battler.side == "player" then return name end
|
||||
if require("src.core.game3.battle.adapter").textSink then return name end
|
||||
local RomText = require("src.core.game3.rom_text")
|
||||
local prefix = (st ~= nil and not st.wild) and "sText_FoePkmnPrefix" or "sText_WildPkmnPrefix"
|
||||
local ok, pre = pcall(RomText.plain, prefix)
|
||||
|
||||
@@ -1758,6 +1758,12 @@ local PP_STATE_TO_COLOR_INDEX = {
|
||||
[3] = 0,
|
||||
}
|
||||
|
||||
function Ui.moveTypeOf(mon, slot, def)
|
||||
local t = mon and mon.moveTypes and mon.moveTypes[slot]
|
||||
if t ~= nil then return t end
|
||||
return def.type
|
||||
end
|
||||
|
||||
function Ui.ppColorState(currentPp, maxPp)
|
||||
currentPp = tonumber(currentPp) or 0
|
||||
maxPp = tonumber(maxPp) or 0
|
||||
@@ -2335,7 +2341,7 @@ local function draw_move_menu_rs(st)
|
||||
F.draw(RomText.plain("gText_MoveInterfacePP"), 184, 120, { colors = colors })
|
||||
F.draw(string.char(0xfc, 0x11, 2, 0xfc, 0x14, 6) .. string.format("%2d/%2d", pp, maxPp),
|
||||
200, 120, { colors = colors })
|
||||
F.draw(Types.name(def.type), 184, 136, { colors = colors })
|
||||
F.draw(Types.name(Ui.moveTypeOf(mon, slot, def)), 184, 136, { colors = colors })
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2373,7 +2379,7 @@ local function draw_move_menu_rse(st)
|
||||
rse_text(W.MOVE_TYPE, typeLabel)
|
||||
local F = battle_font()
|
||||
local tw = F.measure(typeLabel, { font = "narrow" })
|
||||
rse_text(W.MOVE_TYPE, Types.name(def.type), tw, { narrow = false })
|
||||
rse_text(W.MOVE_TYPE, Types.name(Ui.moveTypeOf(mon, slot, def)), tw, { narrow = false })
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2446,7 +2452,7 @@ local function draw_move_menu(st)
|
||||
-- pokefirered/src/battle_controller_player.c:1402
|
||||
draw_menu_text(string.format("%2d/%2d", pp, maxPp), 202, 122, { small = false, colors = ppColors })
|
||||
-- pokefirered/src/battle_controller_player.c:1413
|
||||
draw_menu_text(RomText.plain("gText_MoveInterfaceType") .. Types.name(def.type), 168, 138,
|
||||
draw_menu_text(RomText.plain("gText_MoveInterfaceType") .. Types.name(Ui.moveTypeOf(mon, slot, def)), 168, 138,
|
||||
{ small = true, colors = FrlgFont.COLOR.NORMAL })
|
||||
end
|
||||
end
|
||||
|
||||
@@ -648,6 +648,7 @@ local function collectGame3Actors(game, mapDef, camX, camY, px, py, facing, walk
|
||||
a.sprite = eo.sprite or spriteNameForObj(eo.def or {})
|
||||
a.graphicsId = eo.graphicsId or (eo.def and (eo.def.graphicsId or eo.def.graphics))
|
||||
a.alpha = Objects.fadeAlpha(eo)
|
||||
a.draw = eo.draw
|
||||
a.priority = nil
|
||||
a.subpriority = nil
|
||||
actors[#actors + 1] = a
|
||||
|
||||
+29
-171
@@ -1,4 +1,6 @@
|
||||
local Rng = require("src.core.game3.rng")
|
||||
local G3uRng = require("src.battle.g3u.Rng")
|
||||
local G3uHash = require("src.battle.g3u.Hash")
|
||||
|
||||
local LB = {}
|
||||
|
||||
@@ -47,7 +49,7 @@ LB.MSG = {
|
||||
FORFEIT = "forfeit",
|
||||
}
|
||||
|
||||
LB.HASH_PARTS = { "actives", "volatile", "bench", "field", "rng" }
|
||||
LB.HASH_PARTS = G3uHash.PARTS
|
||||
|
||||
LB.MODE_OF = { single = 1, double = 2, multi = 5 }
|
||||
|
||||
@@ -114,29 +116,7 @@ end
|
||||
|
||||
LB.copy = copyTable
|
||||
|
||||
-- pokefirered/src/random.c:15 ISO_RANDOMIZE1
|
||||
function LB.makeRng(seed, counter)
|
||||
local value = math.floor(tonumber(seed) or 0) % 4294967296
|
||||
local function word()
|
||||
if counter then counter.n = counter.n + 1 end
|
||||
value = (Rng.mulU32(value, 1103515245) + 24691) % 4294967296
|
||||
return math.floor(value / 65536) % 65536
|
||||
end
|
||||
return function(lo, hi)
|
||||
if lo == nil and hi == nil then return word() / 65536 end
|
||||
if hi == nil then
|
||||
lo = math.floor(tonumber(lo) or 1)
|
||||
if lo <= 0 then return 0 end
|
||||
return 1 + (word() % lo)
|
||||
end
|
||||
lo = math.floor(tonumber(lo) or 0)
|
||||
hi = math.floor(tonumber(hi) or lo)
|
||||
if hi < lo then lo, hi = hi, lo end
|
||||
local span = hi - lo + 1
|
||||
if span <= 0 then return lo end
|
||||
return lo + (word() % span)
|
||||
end
|
||||
end
|
||||
LB.makeRng = G3uRng.make
|
||||
|
||||
function LB.dealSeed()
|
||||
return Rng.Random32() % 4294967296
|
||||
@@ -336,6 +316,14 @@ end
|
||||
function LB.battleParty(s)
|
||||
s = s or session()
|
||||
local party = partyOf(s)
|
||||
if type(LB._team) == "table" and #LB._team > 0 then
|
||||
local out = {}
|
||||
for _, slot in ipairs(LB._team) do
|
||||
slot = tonumber(slot)
|
||||
if slot and party[slot] then out[#out + 1] = copyTable(party[slot]) end
|
||||
end
|
||||
if #out > 0 then return out end
|
||||
end
|
||||
local okT, Tower = pcall(require, "src.core.game3.trainer_tower")
|
||||
local order = okT and Tower and Tower.selectedOrder and Tower.selectedOrder(s) or nil
|
||||
if type(order) == "table" and (tonumber(order[1]) or 0) ~= 0 then
|
||||
@@ -478,6 +466,14 @@ LB.TRAINER_PIC_LEAF = 136
|
||||
-- pokefirered/include/constants/union_room.h:19
|
||||
LB.NUM_UNION_ROOM_CLASSES = 8
|
||||
|
||||
function LB.hasUnionRoomClasses()
|
||||
if LB._unionRoomClasses then return true end
|
||||
local ok, src = pcall(function()
|
||||
return require("src.core.game3.dataset").cache():read("data/generated/gba/trainers/union_room_classes.lua")
|
||||
end)
|
||||
return ok and src ~= nil
|
||||
end
|
||||
|
||||
function LB.unionRoomClasses()
|
||||
if not LB._unionRoomClasses then
|
||||
local rel = "data/generated/gba/trainers/union_room_classes.lua"
|
||||
@@ -506,7 +502,7 @@ end
|
||||
|
||||
-- pokefirered/src/battle_controller_link_opponent.c:1172
|
||||
function LB.peerPicId(setup)
|
||||
if LB.unionRoom then return LB.unionRoomTrainerPic(setup) end
|
||||
if LB.unionRoom and LB.hasUnionRoomClasses() then return LB.unionRoomTrainerPic(setup) end
|
||||
if tonumber(setup and setup.gender) == 1 then return LB.TRAINER_PIC_LEAF end
|
||||
return LB.TRAINER_PIC_RED
|
||||
end
|
||||
@@ -627,7 +623,7 @@ function LB.beginBattle(setup, onDone)
|
||||
[2] = {enigmaBerry = ownEnigmaPacket()}, [3] = setup}),
|
||||
hostRules = hostVersion,
|
||||
double = double,
|
||||
unionRoom = LB.unionRoom,
|
||||
unionRoom = LB.unionRoom and LB.hasUnionRoomClasses(),
|
||||
trainerId = nil,
|
||||
rng = LB.makeRng(LB.seed, LB._draws),
|
||||
peerName = setup.name,
|
||||
@@ -822,155 +818,14 @@ local function battleState()
|
||||
return Battle and Battle.getState and Battle.getState() or nil, Battle
|
||||
end
|
||||
|
||||
local bit = require("bit")
|
||||
|
||||
local function fnv(text)
|
||||
local h = 0x811C9DC5
|
||||
for i = 1, #text do
|
||||
h = bit.bxor(h, text:byte(i)) % 4294967296
|
||||
h = ((bit.lshift(h, 24) % 4294967296) + h * 403) % 4294967296
|
||||
end
|
||||
return string.format("%08x", h)
|
||||
end
|
||||
|
||||
LB.fnv = fnv
|
||||
|
||||
local function scalar(v)
|
||||
local t = type(v)
|
||||
if t == "number" then
|
||||
if v == math.floor(v) then return string.format("%d", v) end
|
||||
return string.format("%.6f", v)
|
||||
end
|
||||
if t == "boolean" then return v and "T" or "F" end
|
||||
if t == "string" then return v end
|
||||
if v == nil then return "-" end
|
||||
return "t"
|
||||
end
|
||||
|
||||
local STAGES = { "attack", "defense", "speed", "spAtk", "spDef", "accuracy", "evasion" }
|
||||
|
||||
local VOLATILE_KEYS = {
|
||||
"confusion", "substitute", "toxicCounter", "focusEnergy", "perishSong", "seeded", "trapped",
|
||||
"attracted", "disabled", "encore", "taunt", "bide", "rage", "endure", "protect", "destinyBond",
|
||||
"transformed", "isFirstTurn",
|
||||
"expCharged", "expCursed", "expDisableTurns", "expDisabledMove", "expEncoreMove", "expEncoreSlot",
|
||||
"expEncoreTurns", "expFocusEnergy", "expFuryCutter", "expInfatuated", "expIngrain", "expLockedMove",
|
||||
"expLockedSlot", "expMustRecharge", "expNightmare", "expPerishTurns", "expRampageTurns",
|
||||
"expRechargeTurns", "expRolloutTimer", "expSeeded", "expTauntedTurns", "expTormented",
|
||||
"expTransform", "expTrapTurns", "expTrapped", "expTruantCounter", "expUproarTurns", "expYawnTurns",
|
||||
"expCastformForm",
|
||||
}
|
||||
|
||||
local function perspective(st)
|
||||
if st.linkMaster == false then
|
||||
return { 1, 0, 3, 2 }, { "enemy", "player" }
|
||||
end
|
||||
return { 0, 1, 2, 3 }, { "player", "enemy" }
|
||||
end
|
||||
|
||||
local function battlerOf(st, id)
|
||||
local State = require("src.core.game3.battle.state")
|
||||
if State.isAbsent(st, id) then return nil end
|
||||
return State.battler(st, id)
|
||||
end
|
||||
|
||||
local function sortedScalars(t, skip, st)
|
||||
if type(t) ~= "table" then return scalar(t) end
|
||||
local flip = st and st.linkMaster == false
|
||||
local keys = {}
|
||||
for k, v in pairs(t) do
|
||||
if (type(k) == "string" or type(k) == "number") and not (skip and skip[k])
|
||||
and type(v) ~= "table" and type(v) ~= "function" then
|
||||
keys[#keys + 1] = tostring(k)
|
||||
end
|
||||
end
|
||||
table.sort(keys)
|
||||
local out = {}
|
||||
for _, k in ipairs(keys) do
|
||||
local v = t[k]
|
||||
if v == nil then v = t[tonumber(k)] end
|
||||
if flip and type(v) == "number" and k:sub(-2) == "Id" and v >= 0 and v <= 3 then
|
||||
v = (v % 2 == 0) and (v + 1) or (v - 1)
|
||||
end
|
||||
out[#out + 1] = k .. "=" .. scalar(v)
|
||||
end
|
||||
return table.concat(out, ",")
|
||||
end
|
||||
|
||||
local function monPp(mon)
|
||||
local pp = {}
|
||||
for i = 1, 4 do pp[i] = scalar(mon and mon.pp and mon.pp[i]) end
|
||||
return table.concat(pp, "/")
|
||||
end
|
||||
|
||||
local SIDE_SKIP = { id = true }
|
||||
LB.fnv = G3uHash.fnv
|
||||
|
||||
function LB.hashParts(st)
|
||||
local order, sides = perspective(st)
|
||||
local actives, volatile = {}, {}
|
||||
for _, id in ipairs(order) do
|
||||
local b = battlerOf(st, id)
|
||||
if not b then
|
||||
actives[#actives + 1] = "-"
|
||||
volatile[#volatile + 1] = "-"
|
||||
else
|
||||
local mon = b.mon or {}
|
||||
local stages = {}
|
||||
for i, key in ipairs(STAGES) do stages[i] = scalar(b.stages and b.stages[key] or 0) end
|
||||
actives[#actives + 1] = table.concat({
|
||||
scalar(tonumber(b.species or mon.species)), scalar(tonumber(mon.hp)), scalar(tonumber(mon.maxHp)),
|
||||
scalar(b.status or mon.status), scalar(mon.sleep or b.sleepTurns), table.concat(stages, "/"),
|
||||
scalar(b.ability), scalar(tonumber(b.item) or 0), monPp(mon),
|
||||
}, ":")
|
||||
local vol = {}
|
||||
for _, key in ipairs(VOLATILE_KEYS) do vol[#vol + 1] = scalar(b[key]) end
|
||||
vol[#vol + 1] = sortedScalars(b.volatiles)
|
||||
volatile[#volatile + 1] = table.concat(vol, ":")
|
||||
end
|
||||
end
|
||||
local bench, field = {}, {}
|
||||
for _, side in ipairs(sides) do
|
||||
local party = (side == "player") and st.playerParty or st.foeParty
|
||||
local active = {}
|
||||
for _, id in ipairs((side == "player") and { 0, 2 } or { 1, 3 }) do
|
||||
local b = battlerOf(st, id)
|
||||
if b and b.partyIndex then active[b.partyIndex] = true end
|
||||
end
|
||||
local rows = {}
|
||||
for i, mon in ipairs(party or {}) do
|
||||
if active[i] then
|
||||
rows[#rows + 1] = "*"
|
||||
else
|
||||
rows[#rows + 1] = table.concat({
|
||||
scalar(tonumber(mon.species or mon.speciesId)), scalar(tonumber(mon.hp)), scalar(mon.status),
|
||||
scalar(tonumber(mon.item or mon.heldItem) or 0), monPp(mon),
|
||||
}, ":")
|
||||
end
|
||||
end
|
||||
bench[#bench + 1] = table.concat(rows, ";")
|
||||
local sideState = (side == "player") and st.playerSide or st.enemySide
|
||||
field[#field + 1] = sortedScalars(sideState, SIDE_SKIP, st) .. "|" .. sortedScalars(sideState and sideState.hazards, nil, st)
|
||||
end
|
||||
table.insert(field, 1, scalar(st.weather) .. ":" .. scalar(st.weatherTurns))
|
||||
local raw = {
|
||||
actives = table.concat(actives, "#"),
|
||||
volatile = table.concat(volatile, "#"),
|
||||
bench = table.concat(bench, "#"),
|
||||
field = table.concat(field, "#"),
|
||||
}
|
||||
return {
|
||||
actives = fnv(raw.actives),
|
||||
volatile = fnv(raw.volatile),
|
||||
bench = fnv(raw.bench),
|
||||
field = fnv(raw.field),
|
||||
rng = string.format("%d", LB._draws and LB._draws.n or 0),
|
||||
}, raw
|
||||
return G3uHash.parts(st, LB._draws)
|
||||
end
|
||||
|
||||
function LB.hashValue(parts)
|
||||
local list = {}
|
||||
for i, key in ipairs(LB.HASH_PARTS) do list[i] = tostring(parts[key] or "") end
|
||||
return fnv(table.concat(list, "|"))
|
||||
return G3uHash.value(parts)
|
||||
end
|
||||
|
||||
local function firstDiff(mine, theirs)
|
||||
@@ -1834,7 +1689,8 @@ function LB.enterColosseumPlayerSpot(ctx, adapters)
|
||||
end
|
||||
|
||||
-- pokefirered/src/union_room.c:1811 StartUnionRoomBattle
|
||||
function LB.startUnionRoomBattle(onDone)
|
||||
function LB.startUnionRoomBattle(onDone, opts)
|
||||
opts = opts or {}
|
||||
local L = link()
|
||||
local lk = L.link
|
||||
if not (lk and lk:isOpen()) then return false, "no_link" end
|
||||
@@ -1844,6 +1700,7 @@ function LB.startUnionRoomBattle(onDone)
|
||||
LB.unionRoom = true
|
||||
LB.state = "setup"
|
||||
LB.freshBattle()
|
||||
LB._team = type(opts.team) == "table" and opts.team or nil
|
||||
if LB.onRelay() then
|
||||
LB.seed = LB.seedFromRelay()
|
||||
elseif lk.role == "host" then
|
||||
@@ -1897,6 +1754,7 @@ function LB.freshBattle()
|
||||
LB._drained = nil
|
||||
LB._myPacked = nil
|
||||
LB._myParty = nil
|
||||
LB._team = nil
|
||||
LB._reported = false
|
||||
LB._relay = nil
|
||||
LB._turn = nil
|
||||
|
||||
@@ -622,13 +622,21 @@ function Link.adapterConnected()
|
||||
return false
|
||||
end
|
||||
|
||||
function Link.avatarStyle(version, trainerId)
|
||||
if Family.isRubySapphire(version) then return "player" end
|
||||
-- pokefirered/src/union_room_player_avatar.c:129
|
||||
return "g3:" .. ((tonumber(trainerId) or 0) % 8)
|
||||
end
|
||||
|
||||
function Link.avatar()
|
||||
local s = Link.session() or {}
|
||||
local trainerId = (tonumber(s.trainerId or s.id) or 0) % 65536
|
||||
return {
|
||||
name = tostring(s.name or s.playerName or ""):sub(1, 7),
|
||||
trainerId = (tonumber(s.trainerId or s.id) or 0) % 65536,
|
||||
trainerId = trainerId,
|
||||
gender = (s.gender == 1 or s.gender == "female") and 1 or 0,
|
||||
version = Link.version(),
|
||||
style = Link.avatarStyle(Link.version(), trainerId),
|
||||
-- pokeemerald/src/link_rfu_3.c:679
|
||||
canLinkNationally = Family.canLinkNationally(s, Link.version()) and true or false,
|
||||
}
|
||||
|
||||
@@ -1,10 +1,34 @@
|
||||
local Family = require("src.core.game3.link.family")
|
||||
|
||||
local function rsActive()
|
||||
return Family.isRubySapphire(Family.activeVersion())
|
||||
end
|
||||
|
||||
local function enginePrefix()
|
||||
local row = require("src.core.game3.profile").of(Family.activeVersion())
|
||||
return row and row.map and row.map.enginePrefix or "FR_"
|
||||
end
|
||||
|
||||
local Plaza = setmetatable({}, {
|
||||
__index = function(_, k)
|
||||
if k == "SOURCE_ID" then return Family.mapId(nil, "unionRoom") end
|
||||
if k == "MAP_ID" then return Family.mapId(nil, "unionRoom") .. "_PLAZA" end
|
||||
if k == "EXITS" then local t = rawget(_, "FAMILY_EXITS"); return t[Family.of()] or t.frlg end
|
||||
__index = function(t, k)
|
||||
if k == "KIND" then return rsActive() and "rs" or "cart" end
|
||||
if k == "SOURCE_ID" then
|
||||
if rsActive() then return nil end
|
||||
return Family.mapId(nil, "unionRoom")
|
||||
end
|
||||
if k == "TILE_SOURCE_ID" then
|
||||
if rsActive() then return Family.mapId(nil, "recordCorner") end
|
||||
return Family.mapId(nil, "unionRoom")
|
||||
end
|
||||
if k == "MAP_ID" then
|
||||
if rsActive() then return enginePrefix() .. "UNION_ROOM" end
|
||||
return Family.mapId(nil, "unionRoom") .. "_PLAZA"
|
||||
end
|
||||
if k == "EXITS" then
|
||||
if rsActive() then return rawget(t, "RS").EXITS end
|
||||
local ex = rawget(t, "FAMILY_EXITS")
|
||||
return ex[Family.of()] or ex.frlg
|
||||
end
|
||||
return nil
|
||||
end,
|
||||
})
|
||||
@@ -69,6 +93,57 @@ Plaza.BLOCKED = {
|
||||
["#"] = true,
|
||||
}
|
||||
|
||||
Plaza.RS = {
|
||||
-- pokeruby/data/maps/RecordCorner/map.json warp_events
|
||||
EXITS = { { x = 11, y = 24, source = 1 }, { x = 12, y = 24, source = 2 }, { x = 13, y = 24, source = 3 } },
|
||||
ROLES = {
|
||||
"ABTTTTTTTTTEFFGTTTTTTTTHI",
|
||||
"abttttttttteffgtttttttthi",
|
||||
"JKLLLLLLLLLMNNOLLLLLLLLPQ",
|
||||
"RS.....................UV",
|
||||
"RS.....................UV",
|
||||
"RS.....................UV",
|
||||
"RS.....................UV",
|
||||
"RS.....................UV",
|
||||
"RS.....................UV",
|
||||
"RS.....................UV",
|
||||
"RS.....................UV",
|
||||
"RS.....................UV",
|
||||
"RS.....................UV",
|
||||
"RS.....................UV",
|
||||
"RS.....................UV",
|
||||
"RS.....................UV",
|
||||
"RS.....................UV",
|
||||
"RS.....................UV",
|
||||
"RS.....................UV",
|
||||
"RS.....................UV",
|
||||
"RS.....................UV",
|
||||
"RS.....................UV",
|
||||
"RS.....................UV",
|
||||
"WX.....................YZ",
|
||||
"###########ppp###########",
|
||||
},
|
||||
-- pokeruby/data/layouts/RecordCorner/map.bin
|
||||
SOURCE = {
|
||||
A = { 3, 0 }, B = { 4, 0 }, T = { 5, 0 }, E = { 8, 0 }, F = { 9, 0 }, G = { 11, 0 }, H = { 15, 0 }, I = { 16, 0 },
|
||||
a = { 3, 1 }, b = { 4, 1 }, t = { 5, 1 }, e = { 8, 1 }, f = { 9, 1 }, g = { 11, 1 }, h = { 15, 1 }, i = { 16, 1 },
|
||||
J = { 3, 2 }, K = { 4, 2 }, L = { 5, 2 }, M = { 8, 2 }, N = { 9, 2 }, O = { 11, 2 }, P = { 15, 2 }, Q = { 16, 2 },
|
||||
R = { 3, 3 }, S = { 4, 3 }, ["."] = { 5, 3 }, U = { 15, 3 }, V = { 16, 3 },
|
||||
W = { 3, 8 }, X = { 4, 8 }, Y = { 15, 8 }, Z = { 16, 8 },
|
||||
["#"] = { 0, 9 }, p = { 8, 9 },
|
||||
},
|
||||
BLOCKED = {
|
||||
A = true, B = true, T = true, E = true, F = true, G = true, H = true, I = true,
|
||||
a = true, b = true, t = true, e = true, f = true, g = true, h = true, i = true,
|
||||
J = true, Q = true, R = true, V = true, W = true, Z = true, ["#"] = true,
|
||||
},
|
||||
}
|
||||
|
||||
function Plaza.tables(kind)
|
||||
if (kind or Plaza.KIND) == "rs" then return Plaza.RS.ROLES, Plaza.RS.SOURCE, Plaza.RS.BLOCKED end
|
||||
return Plaza.ROLES, Plaza.SOURCE, Plaza.BLOCKED
|
||||
end
|
||||
|
||||
local CELL_XS = { 3, 6, 9, 12, 15, 18, 21 }
|
||||
local CELL_YS = { 5, 8, 11, 14, 17, 20 }
|
||||
local DOOR_X, DOOR_Y = 12, 23
|
||||
@@ -114,8 +189,9 @@ function Plaza.entry()
|
||||
return Plaza.EXIT_X, Plaza.EXIT_Y, "up"
|
||||
end
|
||||
|
||||
function Plaza.roleAt(x, y)
|
||||
local row = Plaza.ROLES[y + 1]
|
||||
function Plaza.roleAt(x, y, kind)
|
||||
local roles = Plaza.tables(kind)
|
||||
local row = roles[y + 1]
|
||||
if not row or x < 0 or x >= #row then return nil end
|
||||
return row:sub(x + 1, x + 1)
|
||||
end
|
||||
@@ -126,13 +202,14 @@ local function copyRow(row)
|
||||
return out
|
||||
end
|
||||
|
||||
function Plaza.buildDecoded(src)
|
||||
function Plaza.buildDecoded(src, kind)
|
||||
local _, source = Plaza.tables(kind)
|
||||
local cellsOut = {}
|
||||
for y = 0, Plaza.HEIGHT - 1 do
|
||||
for x = 0, Plaza.WIDTH - 1 do
|
||||
local s = Plaza.SOURCE[Plaza.roleAt(x, y)]
|
||||
local s = source[Plaza.roleAt(x, y, kind)]
|
||||
if s[1] >= (src.trueWidth or src.width or 0) or s[2] >= (src.trueHeight or src.height or 0) then
|
||||
error(("union plaza: %s layout has no cell %d,%d; re-import the ROM"):format(Plaza.SOURCE_ID, s[1], s[2]))
|
||||
error(("union plaza: %s layout has no cell %d,%d; re-import the ROM"):format(Plaza.TILE_SOURCE_ID, s[1], s[2]))
|
||||
end
|
||||
local c = src:cellAt(s[1], s[2])
|
||||
cellsOut[y * Plaza.WIDTH + x + 1] = { mid = c.mid, coll = c.coll, elev = c.elev }
|
||||
@@ -172,31 +249,34 @@ end
|
||||
function Plaza.ensure(game)
|
||||
local maps = game and game.data and game.data.maps
|
||||
if type(maps) ~= "table" then error("union plaza: no map table to build " .. Plaza.MAP_ID .. " into", 2) end
|
||||
local srcDef = maps[Plaza.SOURCE_ID]
|
||||
local kind = Plaza.KIND
|
||||
local sourceId = Plaza.TILE_SOURCE_ID
|
||||
local srcDef = maps[sourceId]
|
||||
if not srcDef then
|
||||
error("union plaza: " .. Plaza.SOURCE_ID .. " is missing from the cache; re-import the ROM", 2)
|
||||
error("union plaza: " .. sourceId .. " is missing from the cache; re-import the ROM", 2)
|
||||
end
|
||||
if not srcDef.midLayout then
|
||||
local okM, Map = pcall(require, "src.core.game3.map")
|
||||
if okM and Map and Map.ensureMidLayout then Map.ensureMidLayout(game, Plaza.SOURCE_ID, srcDef) end
|
||||
if okM and Map and Map.ensureMidLayout then Map.ensureMidLayout(game, sourceId, srcDef) end
|
||||
end
|
||||
local src = srcDef.midLayout
|
||||
if not src then
|
||||
error("union plaza: " .. Plaza.SOURCE_ID .. " has no layout in the cache; re-import the ROM", 2)
|
||||
error("union plaza: " .. sourceId .. " has no layout in the cache; re-import the ROM", 2)
|
||||
end
|
||||
|
||||
local def = maps[Plaza.MAP_ID]
|
||||
local mapId = Plaza.MAP_ID
|
||||
local def = maps[mapId]
|
||||
if not (def and def._plazaSource == src) then
|
||||
local LayoutNative = require("src.core.game3.layout_native")
|
||||
local pair = src.pair or srcDef.pair
|
||||
def = {}
|
||||
for k, v in pairs(srcDef) do def[k] = v end
|
||||
def.id = Plaza.MAP_ID
|
||||
def.name = Plaza.MAP_ID
|
||||
def.id = mapId
|
||||
def.name = mapId
|
||||
def.width = Plaza.WIDTH
|
||||
def.height = Plaza.HEIGHT
|
||||
def.connections = {}
|
||||
def.midLayout = LayoutNative.fromDecoded(Plaza.buildDecoded(src), Plaza.MAP_ID, pair)
|
||||
def.midLayout = LayoutNative.fromDecoded(Plaza.buildDecoded(src, kind), mapId, pair)
|
||||
def.pair = pair
|
||||
def.warps = {}
|
||||
for i, exit in ipairs(Plaza.EXITS) do
|
||||
@@ -205,24 +285,30 @@ function Plaza.ensure(game)
|
||||
warp.x, warp.y = exit.x, exit.y
|
||||
def.warps[i] = warp
|
||||
end
|
||||
def.objects = nil
|
||||
def.objects = kind == "rs" and {} or nil
|
||||
def.objectEvents = nil
|
||||
def.bgEvents = {}
|
||||
def.coordEvents = {}
|
||||
if kind == "rs" then def.mapScripts = nil end
|
||||
def._plazaSource = src
|
||||
maps[Plaza.MAP_ID] = def
|
||||
maps[mapId] = def
|
||||
end
|
||||
|
||||
if kind == "rs" then
|
||||
require("src.core.game3.rse.union_rs").install(game)
|
||||
return def
|
||||
end
|
||||
|
||||
local Space = package.loaded["src.core.game3.scripting.space"]
|
||||
local bundle = Space and Space.bundle
|
||||
local events = bundle and bundle.events
|
||||
if type(events) == "table" and events[Plaza.SOURCE_ID]
|
||||
and events[Plaza.MAP_ID] == nil then
|
||||
events[Plaza.MAP_ID] = buildEvents(events[Plaza.SOURCE_ID])
|
||||
if type(events) == "table" and events[sourceId]
|
||||
and events[mapId] == nil then
|
||||
events[mapId] = buildEvents(events[sourceId])
|
||||
end
|
||||
local ev = events and events[Plaza.MAP_ID]
|
||||
local ev = events and events[mapId]
|
||||
if ev and def.objects == nil and Space.attachEventsToMaps then
|
||||
Space.attachEventsToMaps({ [Plaza.MAP_ID] = def }, bundle)
|
||||
Space.attachEventsToMaps({ [mapId] = def }, bundle)
|
||||
end
|
||||
return def
|
||||
end
|
||||
|
||||
@@ -215,9 +215,14 @@ end
|
||||
|
||||
Union.plazaMap = plazaMap
|
||||
|
||||
function Union.isRs()
|
||||
return Family.isRubySapphire(Family.activeVersion())
|
||||
end
|
||||
|
||||
function Union.isUnionMap(id)
|
||||
if not Family.hasWireless() then return false end
|
||||
if type(id) ~= "string" then return false end
|
||||
if Union.isRs() then return id == plazaMap().MAP_ID end
|
||||
if not Family.hasWireless() then return false end
|
||||
return id == Union.MAP or id == plazaMap().MAP_ID
|
||||
end
|
||||
|
||||
@@ -271,6 +276,28 @@ function Union.graphicsIdFor(gender, trainerId)
|
||||
return row[((tonumber(trainerId) or 0) % 8) + 1]
|
||||
end
|
||||
|
||||
function Union.gfxFor(p)
|
||||
if Union.isRs() then
|
||||
-- pokeruby/src/overworld.c:2726
|
||||
return (Family.linkPlayerGfx(nil, p.game, p.gender))
|
||||
end
|
||||
return Union.graphicsIdFor(p.gender, p.trainerId)
|
||||
end
|
||||
|
||||
function Union.foreignEntry(p)
|
||||
local f = Union.foreignAvatar(p)
|
||||
return require("src.online.union.Avatars").resolve(f, f.host)
|
||||
end
|
||||
|
||||
function Union.isForeign(p)
|
||||
return type(p) == "table" and (p.sourceGen == 1 or p.sourceGen == 2)
|
||||
end
|
||||
|
||||
function Union.foreignAvatar(p)
|
||||
return { game = p.game, gen = p.sourceGen, gender = p.gender, style = p.style,
|
||||
trainerId = p.trainerId, name = p.name, id = p.id, host = { version = Family.activeVersion() } }
|
||||
end
|
||||
|
||||
-- pokefirered/src/union_room_player_avatar.c:448
|
||||
function Union.memberFacing(member, activity)
|
||||
if (member or 0) ~= 0 then return Union.avatarData().member_facing[member + 1] end
|
||||
@@ -362,7 +389,13 @@ function Union.showAvatar(slot, p)
|
||||
v.y2 = -Union.FLY_HEIGHT
|
||||
end
|
||||
v.waiting = nil
|
||||
v.gfx = Union.graphicsIdFor(p.gender, p.trainerId)
|
||||
if Union.isForeign(p) then
|
||||
v.foreign = Union.foreignAvatar(p)
|
||||
v.gfx = nil
|
||||
else
|
||||
v.foreign = nil
|
||||
v.gfx = Union.gfxFor(p)
|
||||
end
|
||||
v.x, v.y = x, y
|
||||
if Union._talkSlot ~= slot then v.dir = Union.cellFacing(slot, p) end
|
||||
Union._vobjDirty = true
|
||||
@@ -539,6 +572,7 @@ function Union.animateVobjs()
|
||||
if rec then
|
||||
local w = v.wander
|
||||
rec.graphicsId = v.gfx
|
||||
rec.foreign = v.foreign
|
||||
rec.x, rec.y = v.x, v.y
|
||||
rec.direction = v.dir
|
||||
rec.prevX, rec.prevY, rec.px, rec.py, rec.moving = nil, nil, nil, nil, nil
|
||||
@@ -593,7 +627,11 @@ function Union.tryInteractWithMember()
|
||||
local p = Union.players[slot]
|
||||
local v = Union.vobj(slot)
|
||||
if not (v and v.visible and v.anim == nil and p and not p.gone) then return nil end
|
||||
v.dir = Union.avatarData().opposite_facing[(Union.FACE_DIR[P.facing] or 1) + 1]
|
||||
if Union.isRs() then
|
||||
v.dir = Union.FACE_DIR[wanderMod().OPPOSITE[P.facing or "down"]] or Union.DIR.SOUTH
|
||||
else
|
||||
v.dir = Union.avatarData().opposite_facing[(Union.FACE_DIR[P.facing] or 1) + 1]
|
||||
end
|
||||
Union._talkSlot = slot
|
||||
Union._vobjDirty = true
|
||||
return slot, 0
|
||||
@@ -711,6 +749,7 @@ function Union.run(ctx, adapters)
|
||||
Union._plazaInstance, Union._plazaRev = nil, nil
|
||||
Union._offlineSince, Union._offlineWiped = nil, nil
|
||||
Union._upgradeShown = nil
|
||||
Union._serverOutdated = nil
|
||||
Union.flow = nil
|
||||
Union.memberIds = {}
|
||||
Union.partnerId = nil
|
||||
@@ -720,10 +759,15 @@ function Union.run(ctx, adapters)
|
||||
Union.invite = nil
|
||||
Union.incoming = nil
|
||||
Union._await = nil
|
||||
Union._xg = nil
|
||||
Union._xgSession = nil
|
||||
Union.state = "init"
|
||||
local L = link()
|
||||
Union.relay = true
|
||||
L.clientCall("joinPlaza", "union", L.liveProfile(), L.avatar(), plazaMap().CAP)
|
||||
Union.hookJoinErrors()
|
||||
local Participant = require("src.online.union.Participant")
|
||||
L.clientCall("joinPlaza", "union", L.liveProfile(), Participant.wireAvatar(L.avatar()), plazaMap().CAP,
|
||||
{ xgen = require("src.online.Protocol2").XGEN, caps = Union.caps() })
|
||||
L.setStatus("idle")
|
||||
Union._refresh = 0
|
||||
Union._synced = false
|
||||
@@ -732,9 +776,40 @@ function Union.run(ctx, adapters)
|
||||
return false
|
||||
end
|
||||
|
||||
function Union.caps()
|
||||
local L = link()
|
||||
local game = L.game()
|
||||
local memo = Union._capsMemo
|
||||
if memo and memo.game == game and memo.version == L.version() then return memo.caps end
|
||||
local Caps = require("src.online.union.Caps")
|
||||
local ok, caps = pcall(Caps.compute, { version = L.version(), game = game })
|
||||
if not ok or type(caps) ~= "table" then caps = Caps.compute({ version = L.version(), gameplayMods = true }) end
|
||||
Union._capsMemo = { game = game, version = L.version(), caps = caps }
|
||||
return caps
|
||||
end
|
||||
|
||||
Union.JOIN_ERRORS = { bad_profile = true, bad_avatar = true, bad_caps = true }
|
||||
|
||||
function Union.hookJoinErrors()
|
||||
if Union._onJoinError then return end
|
||||
Union._onJoinError = function(e)
|
||||
if type(e) ~= "table" or e.scope ~= "join" or not Union.JOIN_ERRORS[e.reason] then return end
|
||||
if Union.state ~= "off" then Union._serverOutdated = e.reason end
|
||||
end
|
||||
link().clientCall("on", "error", Union._onJoinError)
|
||||
end
|
||||
|
||||
function Union.unhookJoinErrors()
|
||||
if not Union._onJoinError then return end
|
||||
link().clientCall("off", "error", Union._onJoinError)
|
||||
Union._onJoinError = nil
|
||||
end
|
||||
|
||||
function Union.stop(reason)
|
||||
if Union.state == "off" then return false end
|
||||
local L = link()
|
||||
if Union._xgSession then Union.endXg(Union._xgSession, "left") end
|
||||
Union.unhookJoinErrors()
|
||||
if Union.incoming then L.clientCall("replyInvite", Union.incoming.id, false) end
|
||||
L.clientCall("leavePlaza", "union")
|
||||
L.setStatus("busy")
|
||||
@@ -798,6 +873,13 @@ function Union.cancelActivity()
|
||||
return true
|
||||
end
|
||||
|
||||
local function pressedB()
|
||||
local game = link().game()
|
||||
local input = game and game.input
|
||||
if not (input and input.wasPressed) then return false end
|
||||
return input:wasPressed("b") and true or false
|
||||
end
|
||||
|
||||
local function pressedA()
|
||||
local game = link().game()
|
||||
local input = game and game.input
|
||||
@@ -1379,6 +1461,7 @@ function Union.answerRequest(accept)
|
||||
Union.beginAwaitRoom(nil)
|
||||
else
|
||||
Union.activity = nil
|
||||
if shown and Union._xg then return Union.printAndExit(Union.xgText("you_declined")) end
|
||||
if shown then
|
||||
-- pokefirered/src/union_room.c:887
|
||||
local key = (raw == Union.ACTIVITY.CHAT or raw == Union.ACTIVITY.CARD)
|
||||
@@ -1554,6 +1637,8 @@ function Union.activityForInvite(wire, detail)
|
||||
if wire == "battle_double" then return A.BATTLE_DOUBLE end
|
||||
if wire == "chat" then return A.CHAT + U end
|
||||
if wire == "card" then return A.CARD end
|
||||
if wire == "xg_battle" then return A.BATTLE_SINGLE + U end
|
||||
if wire == "xg_trade" then return A.TRADE + U end
|
||||
if wire == "trade" then
|
||||
if type(detail) == "table" and type(detail.board) == "table" then return A.TRADE + U end
|
||||
return A.TRADE
|
||||
@@ -1606,14 +1691,21 @@ end
|
||||
|
||||
local function avatarRow(m)
|
||||
local av = type(m.avatar) == "table" and m.avatar or {}
|
||||
local part = require("src.online.union.Participant").fromMember(m)
|
||||
return {
|
||||
id = m.id,
|
||||
name = av.name or m.name,
|
||||
gender = tonumber(av.gender) or 0,
|
||||
trainerId = tonumber(av.trainerId) or 0,
|
||||
game = part and part.game or av.version,
|
||||
sourceGen = part and part.gen or 3,
|
||||
legacy = part == nil or part.legacy,
|
||||
style = part and part.style or nil,
|
||||
}
|
||||
end
|
||||
|
||||
Union.avatarRow = avatarRow
|
||||
|
||||
local NO_MEMBERS = {}
|
||||
|
||||
-- pokefirered/src/union_room.c:2781
|
||||
@@ -1638,6 +1730,10 @@ function Union.syncPlaza()
|
||||
for i = 1, #members do
|
||||
local m = members[i]
|
||||
local slot = type(m) == "table" and tonumber(m.slot) or nil
|
||||
if slot and m.id ~= nil and (m.id == me or slot == mySlot) then
|
||||
local av = type(m.avatar) == "table" and m.avatar or nil
|
||||
if not (av and av.gen ~= nil) and not Union._serverOutdated then Union._serverOutdated = "legacy_shard" end
|
||||
end
|
||||
if slot and slot >= 1 and slot <= cap and m.id ~= nil and m.id ~= me and slot ~= mySlot then
|
||||
local p = Union.players[slot]
|
||||
if p and p.id ~= m.id then
|
||||
@@ -1687,12 +1783,17 @@ end
|
||||
function Union.checkUpgrade()
|
||||
if Union._upgradeShown or Union.state ~= "main" or Union.flow then return end
|
||||
local up = link().clientCall("upgradeRequired")
|
||||
if not up then return end
|
||||
local text
|
||||
if up then
|
||||
text = require("src.online.Protocol2").upgradeText(up)
|
||||
elseif Union._serverOutdated then
|
||||
text = Union.xgText("server_outdated")
|
||||
else
|
||||
return
|
||||
end
|
||||
Union._upgradeShown = true
|
||||
local M = message()
|
||||
if M and M.show then
|
||||
Union.printAndExit(require("src.online.Protocol2").upgradeText(up))
|
||||
end
|
||||
if M and M.show then Union.printAndExit(text) end
|
||||
end
|
||||
|
||||
-- pokefirered/src/union_room.c:3106
|
||||
@@ -1704,13 +1805,19 @@ function Union.pollIncoming()
|
||||
if id ~= nil and not Union._answered[id] then
|
||||
Union._answered[id] = true
|
||||
local activity = Union.activityForInvite(inv.activity, inv.detail)
|
||||
local xg = require("src.online.Protocol2").XG_ACTIVITIES[inv.activity or ""] and inv.activity or nil
|
||||
if Union.isRs() and not xg then activity = nil end
|
||||
if Union.state == "main" and not Union.incoming and activity then
|
||||
local from = type(inv.from) == "table" and inv.from or {}
|
||||
Union.incoming = inv
|
||||
Union._lastIncoming = inv
|
||||
Union.activity = activity
|
||||
Union._xg = xg
|
||||
Union._requestName = type(from.avatar) == "table" and from.avatar.name or from.name
|
||||
Union.partnerId = Union.slotForId(from.id)
|
||||
if xg and not Union.partnerId then
|
||||
Union._partner = avatarRow({ id = from.id, name = from.name, slot = 1, avatar = from.avatar })
|
||||
end
|
||||
playSe("SE_DING_DONG")
|
||||
Union.state = "player_contacted_you"
|
||||
else
|
||||
@@ -1732,6 +1839,7 @@ function Union.relayTick(_dt)
|
||||
Union._offlineSince, Union._offlineWiped = nil, nil
|
||||
Union.syncPlaza()
|
||||
Union.animateAll()
|
||||
if Union._serverOutdated and not Union._upgradeShown then Union.checkUpgrade() end
|
||||
Union.pollIncoming()
|
||||
if Union.state == "main" and L.link then
|
||||
Union.noteLeftRoom()
|
||||
@@ -1847,6 +1955,7 @@ end
|
||||
function Union.toMain()
|
||||
Union.flow = nil
|
||||
Union.activity = nil
|
||||
Union._xg = nil
|
||||
Union.state = "main"
|
||||
if Union._talkSlot then Union.updateMemberFacing(Union._talkSlot) end
|
||||
Union.releaseScript()
|
||||
@@ -1991,6 +2100,8 @@ end
|
||||
-- pokefirered/src/union_room.c:2805
|
||||
function Union.talkTo(slot)
|
||||
local p = Union.players[slot]
|
||||
if p and not p.gone and Union.usesXg(p) then return Union.xgTalk(slot) end
|
||||
if Union.isRs() then return Union.printAndExit(Union.xgText("busy", p), slot) end
|
||||
if not p or p.gone then
|
||||
return Union.printAndExit(RomText.ascii("gText_UR_TrainerAppearsBusy"), slot)
|
||||
end
|
||||
@@ -2013,6 +2124,296 @@ function Union.talkTo(slot)
|
||||
return Union.printAndExit(Union.reactionText(p.activity, g, p.name), slot)
|
||||
end
|
||||
|
||||
Union.XG_ITEMS = {
|
||||
{ key = "XG_BATTLE", wire = "xg_battle", activity = "BATTLE_SINGLE" },
|
||||
{ key = "XG_TRADE", wire = "xg_trade", activity = "TRADE" },
|
||||
{ key = "EXIT" },
|
||||
}
|
||||
|
||||
function Union.gameName(version)
|
||||
local info = require("src.core.GameVersion").VERSIONS[version or ""]
|
||||
return info and tostring(info.label or version):upper() or ""
|
||||
end
|
||||
|
||||
local function nameList(versions)
|
||||
local names = {}
|
||||
for _, v in ipairs(versions or {}) do names[#names + 1] = Union.gameName(v) end
|
||||
local Strings = require("src.core.Strings")
|
||||
if #names <= 1 then return names[1] or "" end
|
||||
return Strings("%s or %s", table.concat(names, ", ", 1, #names - 1), names[#names])
|
||||
end
|
||||
|
||||
function Union.xgText(key, p, extra)
|
||||
local Strings = require("src.core.Strings")
|
||||
p = type(p) == "table" and p or {}
|
||||
local name, game = tostring(p.name or ""), Union.gameName(p.game)
|
||||
if key == "talk" then return Strings("%s from POKéMON %s is here.\nWhat would you like to do?", name, game) end
|
||||
if key == "standin" then
|
||||
return Strings("Import POKéMON %s to see how %s really looks.", nameList(extra), name)
|
||||
end
|
||||
if key == "wait_battle" then return Strings("Waiting for %s to answer\nthe battle request...", name) end
|
||||
if key == "wait_trade" then return Strings("Waiting for %s to answer\nthe trade request...", name) end
|
||||
if key == "declined_battle" then return Strings("%s turned down the battle.", name) end
|
||||
if key == "declined_trade" then return Strings("%s turned down the trade.", name) end
|
||||
if key == "busy" then return Strings("%s seems to be busy right now.", name) end
|
||||
if key == "link_lost" then return Strings("The link with %s was lost.", name) end
|
||||
if key == "ask_battle" then return Strings("%s from POKéMON %s wants to battle!\nWill you accept?", name, game) end
|
||||
if key == "ask_trade" then return Strings("%s from POKéMON %s wants to trade!\nWill you accept?", name, game) end
|
||||
if key == "you_declined" then return Strings("You turned down the request.") end
|
||||
if key == "preparing_battle" then return Strings("Getting ready to battle %s...", name) end
|
||||
if key == "preparing_trade" then return Strings("Getting ready to trade with %s...", name) end
|
||||
if key == "unavailable" then
|
||||
return Strings("Battles and trades between different games aren't open yet. The request was canceled.")
|
||||
end
|
||||
if key == "peer_canceled" then return Strings("%s canceled the request.", name) end
|
||||
if key == "canceled" then return Strings("The request was canceled.") end
|
||||
if key == "blocked" then return Strings("Your games can't link up for this. The request was canceled.") end
|
||||
if key == "server_outdated" then
|
||||
return Strings("The UNION ROOM server needs an update before trainers from other games can join you.")
|
||||
end
|
||||
if key == "goodbye" then return Strings("See you around!") end
|
||||
if key == "label_battle" then return Strings("BATTLE") end
|
||||
if key == "label_trade" then return Strings("TRADE") end
|
||||
if key == "label_exit" then return Strings("EXIT") end
|
||||
return ""
|
||||
end
|
||||
|
||||
function Union.xgItems()
|
||||
local s = screen()
|
||||
local out = {}
|
||||
for i, item in ipairs(Union.XG_ITEMS) do
|
||||
local label
|
||||
if item.wire == "xg_trade" then
|
||||
label = Union.xgText("label_trade")
|
||||
elseif not Union.isRs() and s and s.labelFor then
|
||||
label = s.labelFor({ key = item.wire and "BATTLE" or "EXIT" })
|
||||
else
|
||||
label = Union.xgText(item.wire and "label_battle" or "label_exit")
|
||||
end
|
||||
out[i] = { key = item.key, wire = item.wire, activity = item.activity, label = label }
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function Union.usesXg(p)
|
||||
if type(p) ~= "table" then return false end
|
||||
if Union.isRs() then return true end
|
||||
return Union.isForeign(p) or Family.isRubySapphire(p.game)
|
||||
end
|
||||
|
||||
function Union.xgTalk(slot)
|
||||
local p = Union.players[slot]
|
||||
Union.partnerId = slot
|
||||
Union._partner = p
|
||||
if not Union.memberIdle(p) then return Union.printAndExit(Union.xgText("busy", p), slot) end
|
||||
local steps = {}
|
||||
if Union.isForeign(p) then
|
||||
local entry = Union.foreignEntry(p)
|
||||
if entry.standin or entry.hostStandin then steps[#steps + 1] = Union.sayStep(Union.xgText("standin", p, entry.need)) end
|
||||
end
|
||||
steps[#steps + 1] = Union.stayStep(Union.xgText("talk", p))
|
||||
steps[#steps + 1] = Union.doStep(function()
|
||||
local s = screen()
|
||||
if not s then return Union.toMain() end
|
||||
Union.state = "handle_do_something_prompt_input"
|
||||
s.showActivities(Union.xgItems(), {
|
||||
partner = p,
|
||||
onChoose = function(index) Union.chooseXg(index) end,
|
||||
onCancel = function() Union.chooseXg(#Union.XG_ITEMS) end,
|
||||
})
|
||||
end)
|
||||
return Union.runFlow(steps, "do_something_prompt")
|
||||
end
|
||||
|
||||
function Union.chooseXg(index)
|
||||
local item = Union.XG_ITEMS[tonumber(index) or 0]
|
||||
local p = Union.partnerRow() or {}
|
||||
local M = message()
|
||||
if not (item and item.wire) then
|
||||
Union.activity = nil
|
||||
Union.partnerId = nil
|
||||
if Union.isRs() then return Union.printAndExit(Union.xgText("goodbye", p)) end
|
||||
-- pokefirered/src/union_room.c:2916
|
||||
local text = RomText.ascii(RomText.key("gTexts_UR_IfYouWantToDoSomething", tonumber(p.gender) == 1 and 1 or 0))
|
||||
return Union.printAndExit(text)
|
||||
end
|
||||
local L = link()
|
||||
Union.activity = Union.ACTIVITY[item.activity] + Union.IN_UNION_ROOM
|
||||
Union._xg = item.wire
|
||||
Union._role = "child"
|
||||
Union.invite = p.id ~= nil and L.clientCall("invite", p.id, item.wire, {}, L.liveProfile()) or nil
|
||||
if not Union.invite then return Union.printAndExit(Union.xgText("busy", p)) end
|
||||
Union.state = "send_activity_request"
|
||||
if M and M.show then
|
||||
M.show(Union.xgText(item.wire == "xg_trade" and "wait_trade" or "wait_battle", p), { stay = true })
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function Union.xgMode()
|
||||
return require("src.online.Protocol2").XG_ACTIVITIES[Union._xg or ""]
|
||||
end
|
||||
|
||||
Union.xgScreens = { battle = nil, trade = nil }
|
||||
|
||||
local XgActivity = {}
|
||||
XgActivity.__index = XgActivity
|
||||
Union.XgActivity = XgActivity
|
||||
|
||||
local function xgPeer(roomInfo, mySeat, partner)
|
||||
for _, row in ipairs(type(roomInfo.players) == "table" and roomInfo.players or {}) do
|
||||
if type(row) == "table" and row.seat ~= nil and row.seat ~= mySeat then
|
||||
local av = type(row.avatar) == "table" and row.avatar or {}
|
||||
return { id = row.id, name = av.name or row.name or "?", gen = row.gen, game = av.version, seat = row.seat }
|
||||
end
|
||||
end
|
||||
partner = type(partner) == "table" and partner or {}
|
||||
return { id = partner.id, name = partner.name or "?", gen = partner.sourceGen, game = partner.game }
|
||||
end
|
||||
|
||||
function XgActivity:release()
|
||||
if Union._xgSession == self then Union._xgSession = nil end
|
||||
Union.noteLeftRoom(self.roomId)
|
||||
link().setStatus("idle")
|
||||
end
|
||||
|
||||
function XgActivity:finish(why, text)
|
||||
if self.done or self.state == "done" then return end
|
||||
local Flow = require("src.ui.union.Flow")
|
||||
if why == "go" and not self.launched then
|
||||
self.state = "battle"
|
||||
if Flow.launch(self, 3) then return end
|
||||
why, text = "error", require("src.ui.g3u.Launch").resultText(3, "error")
|
||||
end
|
||||
self.why = why
|
||||
self.state = "done"
|
||||
Flow.leaveRoom(self)
|
||||
self:release()
|
||||
local M = message()
|
||||
if M and M.isOpen and M.isOpen() then M.close() end
|
||||
Union.partnerId = nil
|
||||
self.done = true
|
||||
if text and M and M.show then return Union.printAndExit(text) end
|
||||
Union.toMain()
|
||||
end
|
||||
|
||||
function XgActivity:cancel(why)
|
||||
local prep = self.prep
|
||||
if prep then
|
||||
if prep:open() then prep:cancel(why or "cancel") end
|
||||
prep:leave()
|
||||
end
|
||||
self:finish(why or "cancel", Union.xgText("canceled", self.peer))
|
||||
end
|
||||
|
||||
function XgActivity:abort(why)
|
||||
if self.done then return end
|
||||
local prep = self.prep
|
||||
if prep and prep:open() then
|
||||
prep:cancel(why or "left")
|
||||
prep:leave()
|
||||
end
|
||||
self.state = "done"
|
||||
self.why = why or "left"
|
||||
self.done = true
|
||||
self:release()
|
||||
end
|
||||
|
||||
function XgActivity:handle(e)
|
||||
if e.kind == "closed" then
|
||||
local mine = e.seat ~= nil and e.seat == self.prep:seat()
|
||||
if mine then return self:finish("closed", Union.xgText("canceled", self.peer)) or true end
|
||||
playSe("SE_FAILURE")
|
||||
self:finish("peer", Union.xgText("peer_canceled", self.peer))
|
||||
return true
|
||||
end
|
||||
if e.kind == "blocked" then
|
||||
self.prep:cancel("blocked")
|
||||
self.prep:leave()
|
||||
self:finish("blocked", Union.xgText("blocked", self.peer))
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function XgActivity:tick(dt)
|
||||
if self.done or self.state == "done" or self.state == "screen" or self.state == "battle" then return end
|
||||
local prep = self.prep
|
||||
if not prep then return self:finish("gone", Union.xgText("link_lost", self.peer)) end
|
||||
for _, e in ipairs(prep:poll()) do
|
||||
if self:handle(e) then return end
|
||||
end
|
||||
local screen = Union.xgScreens[self.mode]
|
||||
if not screen then
|
||||
self.t = (self.t or 0) + (tonumber(dt) or 0)
|
||||
if self.t >= Union.XG_NOTICE_SECONDS then
|
||||
if prep:open() then prep:cancel("unavailable") end
|
||||
prep:leave()
|
||||
return self:finish("unavailable", Union.xgText("unavailable", self.peer))
|
||||
end
|
||||
return
|
||||
end
|
||||
if prep.state == "prep" and self.state == "preparing" then
|
||||
self.state = "screen"
|
||||
local M = message()
|
||||
if M and M.isOpen and M.isOpen() then M.close() end
|
||||
screen(self)
|
||||
return
|
||||
end
|
||||
if pressedB() then self:cancel("cancel") end
|
||||
end
|
||||
|
||||
Union.XG_NOTICE_SECONDS = 1
|
||||
|
||||
function Union.beginXg(roomInfo, mode)
|
||||
local L = link()
|
||||
if not Union.xgInstalled then
|
||||
Union.xgInstalled = true
|
||||
require("src.ui.union.Flow").install({ screens = Union.xgScreens })
|
||||
end
|
||||
Union._await = nil
|
||||
roomInfo = type(roomInfo) == "table" and roomInfo or {}
|
||||
mode = mode or roomInfo.mode or (type(roomInfo.xg) == "table" and roomInfo.xg.mode) or "battle"
|
||||
mode = mode == "trade" and "trade" or "battle"
|
||||
local client = L.client()
|
||||
local prep
|
||||
if client and roomInfo.room ~= nil then
|
||||
local Room = require("src.online.union.Room")
|
||||
prep = require("src.online.union.Prep").new(Room.adapter(client, roomInfo.room), { mode = mode })
|
||||
end
|
||||
local mySeat = client and client.seat and client.seat() or nil
|
||||
local act = setmetatable({
|
||||
game = L.game(), room = roomInfo, roomId = roomInfo.room, mode = mode, prep = prep,
|
||||
peer = xgPeer(roomInfo, mySeat, Union.partnerRow()), state = "preparing", done = false, why = nil,
|
||||
role = Union._role, wire = Union._xg,
|
||||
}, XgActivity)
|
||||
Union._xgSession = act
|
||||
Union.state = "xg_prep"
|
||||
local M = message()
|
||||
if M and M.show then
|
||||
M.show(Union.xgText(mode == "trade" and "preparing_trade" or "preparing_battle", act.peer), { stay = true })
|
||||
end
|
||||
return act
|
||||
end
|
||||
|
||||
function Union.endXg(act, why)
|
||||
act = act or Union._xgSession
|
||||
if not act then return false end
|
||||
act:abort(why)
|
||||
return true
|
||||
end
|
||||
|
||||
function Union.xgFail(key)
|
||||
local p = Union.partnerRow() or {}
|
||||
Union._await = nil
|
||||
Union.invite = nil
|
||||
local M = message()
|
||||
if M and M.isOpen and M.isOpen() then M.close() end
|
||||
if M and M.show then return Union.printAndExit(Union.xgText(key, p)) end
|
||||
Union.state = "print_and_exit"
|
||||
end
|
||||
|
||||
|
||||
-- pokefirered/src/union_room.c:3027
|
||||
function Union.joinChat(slot)
|
||||
local L = link()
|
||||
@@ -2059,6 +2460,15 @@ end
|
||||
|
||||
function Union.pollMain(ctx)
|
||||
local L = link()
|
||||
if Union.isRs() then
|
||||
local Hud = package.loaded["src.ui.game3.hud"]
|
||||
if Hud and Hud.busy and Hud.busy() then return end
|
||||
if not pressedA() then return end
|
||||
local slot = Union.tryInteractWithMember()
|
||||
if not slot then return end
|
||||
playSe("SE_SELECT")
|
||||
return Union.talkTo(slot)
|
||||
end
|
||||
Union.armScriptWait(ctx)
|
||||
local result = Union._scriptResult or L.getVar(ctx, L.VAR_RESULT)
|
||||
Union._scriptResult = nil
|
||||
@@ -2108,6 +2518,13 @@ function Union.contactedYou()
|
||||
local raw = math.floor(tonumber(Union.activity) or 0) % Union.IN_UNION_ROOM
|
||||
Union.state = "handle_activity_request"
|
||||
Union._asked = true
|
||||
if Union._xg then
|
||||
local p = Union.partnerRow() or { name = name }
|
||||
return Union.runFlow(stepsOf(
|
||||
Union.stayStep(Union.xgText(Union._xg == "xg_trade" and "ask_trade" or "ask_battle", p)),
|
||||
Union.yesNoStep(function(yes) Union.answerRequest(yes) end)
|
||||
), "handle_activity_request")
|
||||
end
|
||||
local named = Union.namedRequest(name)
|
||||
local steps
|
||||
if named then
|
||||
@@ -2189,6 +2606,8 @@ function Union.relayUpdate(dt, ctx)
|
||||
Union.startActivity()
|
||||
elseif st == "in_activity" then
|
||||
Union.pollInActivity()
|
||||
elseif st == "xg_prep" then
|
||||
if Union._xgSession then Union._xgSession:tick(dt) else Union.toMain() end
|
||||
elseif st == "print_and_exit" then
|
||||
local Mo = message()
|
||||
if not (Mo and Mo.isOpen and Mo.isOpen()) then Union.toMain() end
|
||||
@@ -2259,6 +2678,13 @@ function Union.pollInvite()
|
||||
local M = message()
|
||||
local text
|
||||
local p = Union.partnerRow()
|
||||
if Union._xg then
|
||||
Union._joining = nil
|
||||
Union.lastResult = (not dropped and h.why == "declined") and "declined" or "busy"
|
||||
if dropped then return Union.xgFail("link_lost") end
|
||||
if h.why == "declined" then return Union.xgFail(Union._xg == "xg_trade" and "declined_trade" or "declined_battle") end
|
||||
return Union.xgFail("busy")
|
||||
end
|
||||
if dropped then
|
||||
Union._joining = nil
|
||||
Union.lastResult = "busy"
|
||||
@@ -2308,6 +2734,13 @@ function Union.pollAwaitRoom(dt)
|
||||
local room = L.clientCall("room")
|
||||
local id = type(room) == "table" and room.room or nil
|
||||
local stale = id ~= nil and Union._leftRooms and Union._leftRooms[id]
|
||||
if Union._xg then
|
||||
if room and not stale and room.intent == "xg" and (a.room == nil or id == a.room) then
|
||||
return Union.beginXg(room, Union.xgMode())
|
||||
end
|
||||
if not L.online() or a.t >= Union.AWAIT_ROOM_SECONDS then return Union.xgFail("busy") end
|
||||
return
|
||||
end
|
||||
if room and not stale and (a.room == nil or id == a.room) and Union.matchStarted(room) then
|
||||
local M = message()
|
||||
if M and M.isOpen and M.isOpen() then M.close() end
|
||||
@@ -3408,6 +3841,10 @@ function Union.reset()
|
||||
Union._held = nil
|
||||
Union._scriptResult = nil
|
||||
Union._leftRooms = nil
|
||||
Union._xg = nil
|
||||
Union._xgSession = nil
|
||||
Union._serverOutdated = nil
|
||||
Union._capsMemo = nil
|
||||
local Screen = package.loaded["src.ui.game3.union_room"]
|
||||
if Screen and Screen.reset then Screen.reset() end
|
||||
local LinkMenu = package.loaded["src.ui.game3.link_menu"]
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
local Spot = {}
|
||||
|
||||
local DOOR = { x = 2, y = 1 }
|
||||
-- pokeruby/data/maps/OldaleTown_PokemonCenter_2F/map.json:63
|
||||
local BAY_DOOR = { x = 5, y = 1 }
|
||||
|
||||
local function profileOf(version)
|
||||
return require("src.core.game3.profile").of(version)
|
||||
end
|
||||
|
||||
local function isRs(version)
|
||||
return require("src.core.game3.link.family").isRubySapphire(version)
|
||||
end
|
||||
|
||||
function Spot.prefix(version)
|
||||
local row = profileOf(version)
|
||||
return row and row.map and row.map.enginePrefix or "FR_"
|
||||
end
|
||||
|
||||
function Spot.roomId(version)
|
||||
if isRs(version) then return Spot.prefix(version) .. "UNION_ROOM" end
|
||||
return require("src.core.game3.link.family").mapId(version, "unionRoom") .. "_PLAZA"
|
||||
end
|
||||
|
||||
function Spot.colosseumId(version)
|
||||
return require("src.core.game3.link.family").mapId(version, "colosseum2P")
|
||||
end
|
||||
|
||||
function Spot.nurseGfx(version)
|
||||
return require("src.core.game3.constants").of(version):require("event_objects", "OBJ_EVENT_GFX_NURSE")
|
||||
end
|
||||
|
||||
function Spot.isRsCenter(def, colosseum)
|
||||
for _, w in ipairs(type(def) == "table" and def.warps or {}) do
|
||||
if tonumber(w.x) == BAY_DOOR.x and tonumber(w.y) == BAY_DOOR.y and w.destMap == colosseum then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function Spot.isAdded(version, lookup, map, x, y)
|
||||
if type(map) ~= "string" then return false end
|
||||
if map == Spot.roomId(version) then return true end
|
||||
if not isRs(version) or tonumber(x) ~= DOOR.x or tonumber(y) ~= DOOR.y then return false end
|
||||
return Spot.isRsCenter(lookup(map), Spot.colosseumId(version))
|
||||
end
|
||||
|
||||
-- pokefirered/data/maps/ViridianCity_PokemonCenter_1F/map.json:21
|
||||
function Spot.nurseFront(def, nurse)
|
||||
if type(def) ~= "table" or type(def.collAt) ~= "function" then return nil end
|
||||
for _, o in ipairs(def.objects or {}) do
|
||||
if tonumber(o.graphicsId or o.graphics) == nurse then
|
||||
local x, y = tonumber(o.x), tonumber(o.y)
|
||||
for dy = 1, 3 do
|
||||
if def.collAt(x, y + dy) == 0 then return x, y + dy end
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Spot.oneFOf(lookup, twoF, nurse)
|
||||
local def = lookup(twoF)
|
||||
for _, w in ipairs(type(def) == "table" and def.warps or {}) do
|
||||
local dest = type(w.destMap) == "string" and lookup(w.destMap) or nil
|
||||
if dest and Spot.nurseFront(dest, nurse) then
|
||||
for _, back in ipairs(dest.warps or {}) do
|
||||
if back.destMap == twoF then return w.destMap end
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Spot.resolve(version, lookup, save)
|
||||
if type(save) ~= "table" or not Spot.isAdded(version, lookup, save.map, save.x, save.y) then return nil end
|
||||
local nurse = Spot.nurseGfx(version)
|
||||
local tries = {}
|
||||
if save.map ~= Spot.roomId(version) then tries[#tries + 1] = save.map end
|
||||
local dw = save.dynamicWarp
|
||||
if type(dw) == "table" and type(dw.map) == "string" then tries[#tries + 1] = dw.map end
|
||||
for _, twoF in ipairs(tries) do
|
||||
local oneF = Spot.oneFOf(lookup, twoF, nurse)
|
||||
local x, y
|
||||
if oneF then x, y = Spot.nurseFront(lookup(oneF), nurse) end
|
||||
if x then return { map = oneF, x = x, y = y, facing = "up" } end
|
||||
end
|
||||
local heal = type(save.healMap) == "string" and save.healMap or nil
|
||||
local x, y = Spot.nurseFront(heal and lookup(heal), nurse)
|
||||
if x then return { map = heal, x = x, y = y, facing = "up" } end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Spot.liveLookup(game)
|
||||
local maps = game and game.data and game.data.maps or {}
|
||||
local memo = {}
|
||||
return function(mapId)
|
||||
if memo[mapId] ~= nil then return memo[mapId] or nil end
|
||||
local def = maps[mapId]
|
||||
if type(def) ~= "table" then
|
||||
memo[mapId] = false
|
||||
return nil
|
||||
end
|
||||
if not def.midLayout then
|
||||
local okM, Map = pcall(require, "src.core.game3.map")
|
||||
if okM and Map and Map.ensureMidLayout then pcall(Map.ensureMidLayout, game, mapId, def) end
|
||||
end
|
||||
local objects = def.objects
|
||||
if objects == nil then
|
||||
local Space = package.loaded["src.core.game3.scripting.space"]
|
||||
local ev = Space and Space.bundle and Space.bundle.events and Space.bundle.events[mapId]
|
||||
objects = ev and (ev.objects or ev.objectEvents) or {}
|
||||
end
|
||||
local L = def.midLayout
|
||||
local row = {
|
||||
warps = def.warps or {}, objects = objects,
|
||||
collAt = L and function(x, y) return L:collAt(x, y) end or nil,
|
||||
}
|
||||
memo[mapId] = row
|
||||
return row
|
||||
end
|
||||
end
|
||||
|
||||
function Spot.live(session, game)
|
||||
if type(session) ~= "table" then return nil end
|
||||
local version = session.version or require("src.core.game3.link.family").activeVersion()
|
||||
if not game then
|
||||
local rt = package.loaded["src.core.game3.runtime"]
|
||||
game = rt and rt._game or nil
|
||||
end
|
||||
if not (game and game.data and game.data.maps) then return nil end
|
||||
return Spot.resolve(version, Spot.liveLookup(game), session)
|
||||
end
|
||||
|
||||
local function u16(s, i)
|
||||
local a, b = s:byte(i, i + 1)
|
||||
if not b then return nil end
|
||||
return a + b * 256
|
||||
end
|
||||
|
||||
-- pokefirered/include/global.fieldmap.h:8
|
||||
local COLLISION_SHIFT, COLLISION_MASK = 10, 3
|
||||
|
||||
function Spot.cacheLookup(read, slotOf, mapOf)
|
||||
local Json = require("src.link.Json")
|
||||
local memo = {}
|
||||
return function(mapId)
|
||||
if memo[mapId] ~= nil then return memo[mapId] or nil end
|
||||
memo[mapId] = false
|
||||
local slot = slotOf(mapId)
|
||||
local header = slot and read("map_tree/maps/" .. slot .. "/header.json")
|
||||
local events = slot and read("map_tree/maps/" .. slot .. "/events.json")
|
||||
local grid = slot and read("map_tree/maps/" .. slot .. "/grid.bin")
|
||||
if not (header and events and grid) then return nil end
|
||||
local okH, h = pcall(Json.decode, header)
|
||||
local okE, e = pcall(Json.decode, events)
|
||||
if not (okH and okE and type(h) == "table" and type(e) == "table") then return nil end
|
||||
local w, hgt = tonumber(h.width) or 0, tonumber(h.height) or 0
|
||||
local warps = {}
|
||||
for i, wp in ipairs(e.warps or {}) do
|
||||
warps[i] = { x = wp.x, y = wp.y, destMap = mapOf(wp.mapGroup, wp.mapNum) }
|
||||
end
|
||||
local row = {
|
||||
warps = warps, objects = e.objects or {},
|
||||
collAt = function(x, y)
|
||||
if x < 0 or y < 0 or x >= w or y >= hgt then return 1 end
|
||||
local v = u16(grid, (y * w + x) * 2 + 1)
|
||||
return v and math.floor(v / 2 ^ COLLISION_SHIFT) % (COLLISION_MASK + 1) or 1
|
||||
end,
|
||||
}
|
||||
memo[mapId] = row
|
||||
return row
|
||||
end
|
||||
end
|
||||
|
||||
return Spot
|
||||
@@ -947,12 +947,15 @@ function Objects.forDraw()
|
||||
vrecs[vo.id] = vrec
|
||||
end
|
||||
local gid = tonumber(vo.graphicsId) or 0
|
||||
local foreign = vo.foreign
|
||||
vrec.cellX = tonumber(vo.x) or 0
|
||||
vrec.cellY = tonumber(vo.y) or 0
|
||||
vrec.elevation = tonumber(vo.elevation) or 3
|
||||
vrec.facing = VIRT_DIR_FACE[tonumber(vo.direction)] or "down"
|
||||
vrec.sprite = GfxIds.spriteFor(gid)
|
||||
vrec.graphicsId = gid
|
||||
vrec.foreign = foreign
|
||||
vrec.draw = foreign and Objects.drawForeign or nil
|
||||
vrec.sprite = not foreign and GfxIds.spriteFor(gid) or nil
|
||||
vrec.graphicsId = not foreign and gid or nil
|
||||
vrec.raiseY = tonumber(vo.y2) or 0
|
||||
vrec.px, vrec.py, vrec.moving = vo.px, vo.py, vo.moving == true
|
||||
vrec.targetX, vrec.targetY = vo.targetX, vo.targetY
|
||||
@@ -967,6 +970,15 @@ function Objects.forDraw()
|
||||
return list
|
||||
end
|
||||
|
||||
function Objects.drawForeign(a, camX, camY)
|
||||
local eo = a and a.eventObject
|
||||
local p = eo and eo.foreign
|
||||
if not p then return false end
|
||||
local Avatars = require("src.online.union.Avatars")
|
||||
return Avatars.draw(Avatars.resolve(p, p.host), math.floor(a.x - camX + CELL / 2), math.floor(a.y - camY + CELL),
|
||||
a.facing, a.walkPhase, a.stepFlip, 1)
|
||||
end
|
||||
|
||||
--- First visible EventObject standing on (tx, ty), or nil if moving onto it.
|
||||
function Objects.at(tx, ty)
|
||||
tx, ty = tonumber(tx), tonumber(ty)
|
||||
|
||||
@@ -131,6 +131,9 @@ function Rules.saveWarpFields(session)
|
||||
local f = tonumber(session.specialSaveWarpFlags) or 0
|
||||
local w = session.continueGameWarp
|
||||
local dw = session.dynamicWarp
|
||||
if require("src.core.game3.link.union_save_spot").live(session) then
|
||||
return require("bit").band(f, require("bit").bnot(CONTINUE_GAME_WARP)), w
|
||||
end
|
||||
if LINK_ROOMS[session.map] and type(dw) == "table" and type(dw.map) == "string"
|
||||
and tonumber(dw.x) and tonumber(dw.y) then
|
||||
-- pokeemerald/src/overworld.c:735 SetContinueGameWarpToDynamicWarp
|
||||
@@ -139,4 +142,10 @@ function Rules.saveWarpFields(session)
|
||||
return f, w
|
||||
end
|
||||
|
||||
function Rules.saveLocation(session, game)
|
||||
local o = require("src.core.game3.link.union_save_spot").live(session, game)
|
||||
if o then return o.map, o.x, o.y, o.facing end
|
||||
return nil
|
||||
end
|
||||
|
||||
return Rules
|
||||
|
||||
@@ -98,6 +98,9 @@ function Rules.saveWarpFields(session)
|
||||
local f = tonumber(session.specialSaveWarpFlags) or 0
|
||||
local w = session.continueGameWarp
|
||||
local dw = session.dynamicWarp
|
||||
if require("src.core.game3.link.union_save_spot").live(session) then
|
||||
return require("bit").band(f, require("bit").bnot(CONTINUE_GAME_WARP)), w
|
||||
end
|
||||
if LINK_ROOMS[session.map] and type(dw) == "table" and type(dw.map) == "string"
|
||||
and tonumber(dw.x) and tonumber(dw.y) then
|
||||
-- pokefirered/src/overworld.c:701 SetContinueGameWarpToDynamicWarp
|
||||
@@ -106,6 +109,12 @@ function Rules.saveWarpFields(session)
|
||||
return f, w
|
||||
end
|
||||
|
||||
function Rules.saveLocation(session, game)
|
||||
local o = require("src.core.game3.link.union_save_spot").live(session, game)
|
||||
if o then return o.map, o.x, o.y, o.facing end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- pokefirered/include/constants/region_map_sections.h:211 KANTO_MAPSEC_START
|
||||
Rules.OWN_MON_MET_LOCATION = 88
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ function Rs.new(game, label, prefix)
|
||||
scriptConnections = true, scriptStepEvents = false,
|
||||
-- pokeruby/src/field_fadetransition.c:195
|
||||
onFrameAfterWarpExit = true,
|
||||
fieldModules = {},
|
||||
fieldModules = { unionPlaza = true },
|
||||
-- pokeruby/src/overworld.c:496
|
||||
escapeWarp = { delta = 1 },
|
||||
semantics = {
|
||||
@@ -74,7 +74,7 @@ function Rs.new(game, label, prefix)
|
||||
daycare = require("src.core.game3.rs.daycare"),
|
||||
audio = require("src.core.game3.profiles.rs.audio").new(game),
|
||||
save = require("src.core.game3.profiles.rs.save"),
|
||||
saveRules = require("src.core.game3.profiles.rs.saveRules"),
|
||||
saveRules = "src.core.game3.rse.union_rs_rules",
|
||||
mail = {
|
||||
exportEmpty = true,
|
||||
newRecord = function(session, record)
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
local faces = {}
|
||||
for id = 0, 6 do faces["native_" .. id] = { id = id } end
|
||||
-- pokeruby/src/text.c:622
|
||||
faces.normal, faces.small = faces.native_3, faces.native_1
|
||||
faces.short, faces.narrow, faces.small_narrow = faces.native_0, faces.native_4, faces.native_1
|
||||
faces.normal = faces.native_3
|
||||
faces.short, faces.narrow = faces.native_0, faces.native_4
|
||||
-- pokeruby/src/contest_2.c:883
|
||||
faces.small, faces.small_narrow = faces.native_4, faces.native_4
|
||||
|
||||
return {
|
||||
module = "src.ui.game3.frlg_font",
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
local Family = require("src.core.game3.link.family")
|
||||
|
||||
local UnionRs = {}
|
||||
|
||||
-- pokeruby/data/maps/OldaleTown_PokemonCenter_2F/map.json:63
|
||||
UnionRs.BAY_DOOR = { x = 5, y = 1 }
|
||||
-- pokeruby/data/maps/OldaleTown_PokemonCenter_2F/map.json:16
|
||||
UnionRs.BAY_ATTENDANT = { x = 4, y = 2 }
|
||||
UnionRs.DOOR = { x = 2, y = 1 }
|
||||
UnionRs.ATTENDANT = { x = 1, y = 2 }
|
||||
UnionRs.FRONT = { x = 2, y = 2 }
|
||||
UnionRs.VOBJ_ID = 0x7D01
|
||||
UnionRs.MIN_MONS = 2
|
||||
UnionRs.LOCK = "union_rs"
|
||||
|
||||
UnionRs.centers = {}
|
||||
UnionRs.flow = nil
|
||||
UnionRs._task = nil
|
||||
|
||||
local DIR = { down = 1, up = 2, left = 3, right = 4 }
|
||||
local OPPOSITE = { down = "up", up = "down", left = "right", right = "left" }
|
||||
local DELTA = { down = { 0, 1 }, up = { 0, -1 }, left = { -1, 0 }, right = { 1, 0 } }
|
||||
|
||||
local function Strings(...) return require("src.core.Strings")(...) end
|
||||
local function link() return require("src.core.game3.link") end
|
||||
local function message() return require("src.ui.game3.message") end
|
||||
local function plaza() return require("src.core.game3.link.union_plaza_map") end
|
||||
|
||||
function UnionRs.active()
|
||||
return Family.isRubySapphire(Family.activeVersion())
|
||||
end
|
||||
|
||||
function UnionRs.text(key)
|
||||
if key == "welcome" then
|
||||
return Strings("Welcome to the UNION ROOM!\fTrainers from every POKéMON game meet here to battle and trade.\fWould you like to go in?")
|
||||
end
|
||||
if key == "come_again" then return Strings("Please come again!") end
|
||||
if key == "need_two" then return Strings("You need at least two POKéMON that aren't EGGS to go in.") end
|
||||
if key == "connect" then return Strings("The UNION ROOM is online.\nWould you like to connect?") end
|
||||
if key == "connecting" then return Strings("Connecting...") end
|
||||
if key == "save_first" then return Strings("Your progress will be saved before you go in.") end
|
||||
if key == "enjoy" then return Strings("Please enjoy the UNION ROOM!") end
|
||||
return ""
|
||||
end
|
||||
|
||||
local function backLink(dest, mapId)
|
||||
for _, w in ipairs(type(dest) == "table" and dest.warps or {}) do
|
||||
if w.destMap == mapId then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function UnionRs.discover(maps)
|
||||
local colosseum = Family.mapId(nil, "colosseum2P")
|
||||
local out = {}
|
||||
for mapId, def in pairs(maps or {}) do
|
||||
local warps = type(def) == "table" and def.warps or nil
|
||||
local bay = false
|
||||
for _, w in ipairs(warps or {}) do
|
||||
if tonumber(w.x) == UnionRs.BAY_DOOR.x and tonumber(w.y) == UnionRs.BAY_DOOR.y and w.destMap == colosseum then
|
||||
bay = true
|
||||
end
|
||||
end
|
||||
if bay then
|
||||
local oneF
|
||||
for _, w in ipairs(warps) do
|
||||
local destId = w.destMap
|
||||
if type(destId) == "string" and destId ~= colosseum and backLink(maps[destId], mapId) then oneF = destId end
|
||||
end
|
||||
out[mapId] = { id = mapId, oneF = oneF }
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function copyCells(L)
|
||||
local cells = {}
|
||||
local w, h = L.width, L.height
|
||||
for y = 0, h - 1 do
|
||||
for x = 0, w - 1 do
|
||||
local c = L:cellAt(x, y)
|
||||
cells[y * w + x + 1] = { mid = c.mid, coll = c.coll, elev = c.elev }
|
||||
end
|
||||
end
|
||||
return cells
|
||||
end
|
||||
|
||||
function UnionRs.patchLayout(def, mapId)
|
||||
if def._unionRsDoor then return def.midLayout end
|
||||
local L = def.midLayout
|
||||
if not L then error("union room: " .. tostring(mapId) .. " has no layout in the cache; re-import the ROM", 2) end
|
||||
local cells = copyCells(L)
|
||||
local w = L.width
|
||||
local function put(dx, dy, sx, sy)
|
||||
local c = cells[sy * w + sx + 1]
|
||||
cells[dy * w + dx + 1] = { mid = c.mid, coll = c.coll, elev = c.elev }
|
||||
end
|
||||
put(UnionRs.DOOR.x, UnionRs.DOOR.y, UnionRs.BAY_DOOR.x, UnionRs.BAY_DOOR.y)
|
||||
put(UnionRs.DOOR.x, UnionRs.DOOR.y - 1, UnionRs.BAY_DOOR.x, UnionRs.BAY_DOOR.y - 1)
|
||||
local border = {}
|
||||
for i, m in ipairs(L.borderMids or { 0 }) do border[i] = m end
|
||||
local layout = require("src.core.game3.layout_native").fromDecoded({
|
||||
width = L.width, height = L.height, trueWidth = L.trueWidth, trueHeight = L.trueHeight,
|
||||
borderWidth = L.borderWidth, borderHeight = L.borderHeight, borderMids = border, cells = cells,
|
||||
}, mapId, L.pair or def.pair)
|
||||
def.midLayout = layout
|
||||
def._unionRsDoor = true
|
||||
return layout
|
||||
end
|
||||
|
||||
function UnionRs.install(game)
|
||||
local maps = game and game.data and game.data.maps
|
||||
if type(maps) ~= "table" then return 0 end
|
||||
UnionRs._game = game
|
||||
UnionRs.centers = UnionRs.discover(maps)
|
||||
local n = 0
|
||||
for mapId in pairs(UnionRs.centers) do
|
||||
UnionRs.patchLayout(maps[mapId], mapId)
|
||||
n = n + 1
|
||||
end
|
||||
UnionRs.arm()
|
||||
return n
|
||||
end
|
||||
|
||||
function UnionRs.isCenter(mapId)
|
||||
return type(mapId) == "string" and UnionRs.centers[mapId] ~= nil
|
||||
end
|
||||
|
||||
local function maps()
|
||||
local game = link().game() or UnionRs._game
|
||||
return game and game.data and game.data.maps or {}
|
||||
end
|
||||
|
||||
function UnionRs.attendantGfx(mapId)
|
||||
local def = maps()[mapId]
|
||||
for _, o in ipairs(def and def.objects or {}) do
|
||||
if tonumber(o.x) == UnionRs.BAY_ATTENDANT.x and tonumber(o.y) == UnionRs.BAY_ATTENDANT.y then
|
||||
return tonumber(o.graphicsId or o.graphics)
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function UnionRs.nurseFront(oneF)
|
||||
local def = maps()[oneF]
|
||||
if not def then return nil end
|
||||
local nurse = require("src.core.game3.constants").of(Family.activeVersion()):require("event_objects", "OBJ_EVENT_GFX_NURSE")
|
||||
local L = def.midLayout
|
||||
for _, o in ipairs(def.objects or {}) do
|
||||
if tonumber(o.graphicsId or o.graphics) == nurse then
|
||||
local x, y = tonumber(o.x), tonumber(o.y)
|
||||
for dy = 1, 3 do
|
||||
if L and L:collAt(x, y + dy) == 0 then return x, y + dy end
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function UnionRs.originFor(centerId)
|
||||
local c = UnionRs.centers[centerId]
|
||||
if not (c and c.oneF) then return nil end
|
||||
local x, y = UnionRs.nurseFront(c.oneF)
|
||||
if not x then return nil end
|
||||
return { map = c.oneF, x = x, y = y, facing = "up" }
|
||||
end
|
||||
|
||||
local function currentMap()
|
||||
local Map = package.loaded["src.core.game3.map"]
|
||||
return Map and Map.current or nil
|
||||
end
|
||||
|
||||
local function player() return package.loaded["src.core.game3.player"] end
|
||||
|
||||
function UnionRs.spawnAttendant(mapId)
|
||||
local V = require("src.core.game3.virtual_objects")
|
||||
local vo = V.get(UnionRs.VOBJ_ID)
|
||||
if vo and vo.map == mapId then return vo end
|
||||
local gfx = UnionRs.attendantGfx(mapId)
|
||||
if not gfx then return nil end
|
||||
vo = V.spawn(UnionRs.VOBJ_ID, gfx, UnionRs.ATTENDANT.x, UnionRs.ATTENDANT.y, 3, DIR.down)
|
||||
vo.solid = true
|
||||
vo.map = mapId
|
||||
return vo
|
||||
end
|
||||
|
||||
local function busy()
|
||||
local Space = package.loaded["src.core.game3.scripting.space"]
|
||||
if Space and Space.vm and Space.vm.isRunning and Space.vm:isRunning() then return true end
|
||||
local Field = package.loaded["src.core.game3.field"]
|
||||
if Field and Field.locked then return true end
|
||||
local Hud = package.loaded["src.ui.game3.hud"]
|
||||
if Hud and Hud.busy and Hud.busy() then return true end
|
||||
local Warp = package.loaded["src.core.game3.warp"]
|
||||
return (Warp and Warp.isBusy and Warp.isBusy()) and true or false
|
||||
end
|
||||
|
||||
local function pressedA()
|
||||
local game = link().game()
|
||||
local input = game and game.input
|
||||
return input and input.wasPressed and input:wasPressed("a") and true or false
|
||||
end
|
||||
|
||||
function UnionRs.facingAttendant()
|
||||
local P = player()
|
||||
if not P or P.moving then return false end
|
||||
local d = DELTA[P.facing or "down"]
|
||||
return d ~= nil and (tonumber(P.cellX) or 0) + d[1] == UnionRs.ATTENDANT.x
|
||||
and (tonumber(P.cellY) or 0) + d[2] == UnionRs.ATTENDANT.y
|
||||
end
|
||||
|
||||
function UnionRs.tick()
|
||||
if not UnionRs.active() then return end
|
||||
if UnionRs.flow then return UnionRs.stepFlow() end
|
||||
local mapId = currentMap()
|
||||
if not UnionRs.isCenter(mapId) then return end
|
||||
local Warp = package.loaded["src.core.game3.warp"]
|
||||
if Warp and Warp.isBusy and Warp.isBusy() then return end
|
||||
UnionRs.spawnAttendant(mapId)
|
||||
if busy() or not pressedA() or not UnionRs.facingAttendant() then return end
|
||||
UnionRs.talk(mapId)
|
||||
end
|
||||
|
||||
function UnionRs.arm()
|
||||
if UnionRs._task and not UnionRs._task.done then return end
|
||||
UnionRs._task = require("src.core.game3.task").spawn(function()
|
||||
local ok, err = pcall(UnionRs.tick)
|
||||
if not ok then
|
||||
print("[union_rs] " .. tostring(err))
|
||||
UnionRs.endFlow()
|
||||
end
|
||||
return false
|
||||
end)
|
||||
end
|
||||
|
||||
function UnionRs.runFlow(steps)
|
||||
UnionRs.flow = { steps = steps, i = 1, started = false }
|
||||
UnionRs.stepFlow()
|
||||
end
|
||||
|
||||
function UnionRs.stepFlow()
|
||||
for _ = 1, 16 do
|
||||
local f = UnionRs.flow
|
||||
if not f then return end
|
||||
local step = f.steps[f.i]
|
||||
if not step then
|
||||
if UnionRs.flow == f then UnionRs.endFlow() end
|
||||
return
|
||||
end
|
||||
if not f.started then
|
||||
f.started = true
|
||||
if step.start then step.start() end
|
||||
if UnionRs.flow ~= f then return end
|
||||
end
|
||||
if step.poll and not step.poll() then return end
|
||||
if UnionRs.flow ~= f then return end
|
||||
f.i = f.i + 1
|
||||
f.started = false
|
||||
end
|
||||
end
|
||||
|
||||
function UnionRs.endFlow()
|
||||
UnionRs.flow = nil
|
||||
local Field = package.loaded["src.core.game3.field"]
|
||||
if Field and Field.unlock then Field.unlock(UnionRs.LOCK) end
|
||||
local vo = require("src.core.game3.virtual_objects").get(UnionRs.VOBJ_ID)
|
||||
if vo then vo.direction = DIR.down end
|
||||
end
|
||||
|
||||
local function say(text)
|
||||
return {
|
||||
start = function() message().show(text) end,
|
||||
poll = function() return not message().isOpen() end,
|
||||
}
|
||||
end
|
||||
|
||||
local function stay(text)
|
||||
return {
|
||||
start = function() message().show(text, { stay = true }) end,
|
||||
poll = function()
|
||||
local M = message()
|
||||
return M.isOpen() and M.isWaiting() and (M._page or 1) >= #(M._pages or {})
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
local function yesNo(cb)
|
||||
local asked, answered = false, false
|
||||
return {
|
||||
poll = function()
|
||||
if answered then return true end
|
||||
local Choice = require("src.ui.game3.choice")
|
||||
if not asked and not Choice.isOpen() then
|
||||
asked = true
|
||||
Choice.yesNo(function(yes)
|
||||
answered = true
|
||||
message().close()
|
||||
cb(yes and true or false)
|
||||
end)
|
||||
end
|
||||
return answered
|
||||
end,
|
||||
}
|
||||
end
|
||||
|
||||
local function act(fn) return { start = fn } end
|
||||
|
||||
local function bye()
|
||||
UnionRs.runFlow({ say(UnionRs.text("come_again")) })
|
||||
end
|
||||
|
||||
function UnionRs.countMons(session)
|
||||
local n = 0
|
||||
for _, mon in ipairs(type(session) == "table" and session.party or {}) do
|
||||
if type(mon) == "table" and not (mon.isEgg or mon.egg) and (tonumber(mon.species) or 0) > 0 then n = n + 1 end
|
||||
end
|
||||
return n
|
||||
end
|
||||
|
||||
function UnionRs.talk(mapId)
|
||||
local Field = require("src.core.game3.field")
|
||||
Field.lock(UnionRs.LOCK)
|
||||
local P = player()
|
||||
local vo = require("src.core.game3.virtual_objects").get(UnionRs.VOBJ_ID)
|
||||
if vo and P then vo.direction = DIR[OPPOSITE[P.facing or "down"]] or DIR.down end
|
||||
UnionRs.runFlow({
|
||||
stay(UnionRs.text("welcome")),
|
||||
yesNo(function(yes)
|
||||
if not yes then return bye() end
|
||||
UnionRs.checkParty(mapId)
|
||||
end),
|
||||
})
|
||||
end
|
||||
|
||||
function UnionRs.checkParty(mapId)
|
||||
local session = link().session()
|
||||
if UnionRs.countMons(session) < UnionRs.MIN_MONS then
|
||||
return UnionRs.runFlow({ say(UnionRs.text("need_two")) })
|
||||
end
|
||||
if link().adapterConnected() then return UnionRs.askSave(mapId) end
|
||||
UnionRs.runFlow({
|
||||
stay(UnionRs.text("connect")),
|
||||
yesNo(function(yes)
|
||||
if not yes then return bye() end
|
||||
UnionRs.connect(mapId)
|
||||
end),
|
||||
})
|
||||
end
|
||||
|
||||
function UnionRs.connect(mapId)
|
||||
local L = link()
|
||||
local ok, err = L.connect()
|
||||
if not ok then return UnionRs.runFlow({ say(L.reasonText(err)) }) end
|
||||
local failed
|
||||
UnionRs.runFlow({
|
||||
act(function() message().show(UnionRs.text("connecting"), { stay = true }) end),
|
||||
{
|
||||
poll = function()
|
||||
if L.adapterConnected() then return true end
|
||||
local state = L.connectState()
|
||||
if state == "error" or state == "offline" then
|
||||
failed = L.connectError() or state
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end,
|
||||
},
|
||||
act(function()
|
||||
message().close()
|
||||
if failed then return UnionRs.runFlow({ say(L.reasonText(failed)) }) end
|
||||
UnionRs.askSave(mapId)
|
||||
end),
|
||||
})
|
||||
end
|
||||
|
||||
function UnionRs.askSave(mapId)
|
||||
local saved
|
||||
UnionRs.runFlow({
|
||||
say(UnionRs.text("save_first")),
|
||||
act(function()
|
||||
local SaveMenu = require("src.ui.game3.save_menu")
|
||||
SaveMenu.show({
|
||||
session = link().session(), game = link().game(),
|
||||
onClose = function() saved = SaveMenu._phase == "saved" end,
|
||||
})
|
||||
end),
|
||||
{ poll = function() return saved ~= nil end },
|
||||
act(function()
|
||||
if not saved then return bye() end
|
||||
UnionRs.runFlow({
|
||||
say(UnionRs.text("enjoy")),
|
||||
act(function() UnionRs.enter(mapId) end),
|
||||
})
|
||||
end),
|
||||
})
|
||||
end
|
||||
|
||||
local ENTER = { surfing = false }
|
||||
|
||||
local function pathTo(fx, fy, tx, ty)
|
||||
local Coll = package.loaded["src.core.game3.collision"]
|
||||
local Objects = package.loaded["src.core.game3.objects"]
|
||||
local function free(x, y)
|
||||
if x == UnionRs.ATTENDANT.x and y == UnionRs.ATTENDANT.y then return false end
|
||||
if Objects and Objects.blocks and Objects.blocks(x, y) then return false end
|
||||
return Coll ~= nil and Coll.canEnter(link().game(), x, y, ENTER) == true
|
||||
end
|
||||
local key = function(x, y) return y * 1024 + x end
|
||||
local prev = { [key(fx, fy)] = false }
|
||||
local queue, head = { { fx, fy } }, 1
|
||||
while queue[head] do
|
||||
local c = queue[head]
|
||||
head = head + 1
|
||||
if c[1] == tx and c[2] == ty then
|
||||
local dirs = {}
|
||||
local k = key(tx, ty)
|
||||
while prev[k] do
|
||||
local p = prev[k]
|
||||
table.insert(dirs, 1, p.dir)
|
||||
k = key(p.x, p.y)
|
||||
end
|
||||
return dirs
|
||||
end
|
||||
for _, dir in ipairs({ "up", "left", "right", "down" }) do
|
||||
local d = DELTA[dir]
|
||||
local nx, ny = c[1] + d[1], c[2] + d[2]
|
||||
local nk = key(nx, ny)
|
||||
if prev[nk] == nil and math.abs(nx - fx) <= 4 and math.abs(ny - fy) <= 4 and free(nx, ny) then
|
||||
prev[nk] = { x = c[1], y = c[2], dir = dir }
|
||||
queue[#queue + 1] = { nx, ny }
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function UnionRs.walkSteps(mapId)
|
||||
local P = player()
|
||||
local dirs = pathTo(tonumber(P.cellX) or 0, tonumber(P.cellY) or 0, UnionRs.FRONT.x, UnionRs.FRONT.y) or {}
|
||||
local steps = {}
|
||||
for _, dir in ipairs(dirs) do
|
||||
local done = false
|
||||
steps[#steps + 1] = {
|
||||
start = function()
|
||||
if not P.forceStep(dir, function() done = true end) then done = true end
|
||||
end,
|
||||
poll = function() return done end,
|
||||
}
|
||||
end
|
||||
steps[#steps + 1] = act(function()
|
||||
if P.scriptFace then P.scriptFace("up") else P.facing = "up" end
|
||||
end)
|
||||
return steps
|
||||
end
|
||||
|
||||
function UnionRs.enter(mapId)
|
||||
local L = link()
|
||||
local session = L.session()
|
||||
require("src.core.game3.party").healAll(session.party)
|
||||
local origin = UnionRs.originFor(mapId)
|
||||
if origin then
|
||||
require("src.online.union.Origin").record(session, {
|
||||
gen = 3, version = Family.activeVersion(), map = origin.map, x = origin.x, y = origin.y,
|
||||
facing = origin.facing, warp = mapId,
|
||||
})
|
||||
end
|
||||
session.dynamicWarp = { map = mapId, warpId = 0xFF, x = UnionRs.DOOR.x, y = UnionRs.DOOR.y }
|
||||
local steps = UnionRs.walkSteps(mapId)
|
||||
local arrived = false
|
||||
steps[#steps + 1] = act(function()
|
||||
local rt = package.loaded["src.core.game3.runtime"]
|
||||
local P = plaza()
|
||||
local x, y = P.entry()
|
||||
require("src.core.game3.warp").scripted(rt and rt._mod, L.game(), "warpdoor", P.MAP_ID, x, y, "up",
|
||||
function() arrived = true end)
|
||||
end)
|
||||
steps[#steps + 1] = { poll = function() return arrived end }
|
||||
steps[#steps + 1] = act(function()
|
||||
UnionRs.endFlow()
|
||||
require("src.core.game3.field").unlock()
|
||||
L.union().run(nil)
|
||||
end)
|
||||
UnionRs.runFlow(steps)
|
||||
end
|
||||
|
||||
function UnionRs.inRoom(session)
|
||||
return type(session) == "table" and session.map ~= nil and session.map == plaza().MAP_ID
|
||||
end
|
||||
|
||||
function UnionRs.saveOrigin(session)
|
||||
local Origin = require("src.online.union.Origin")
|
||||
local o = Origin.get(session)
|
||||
if o and o.gen == 3 then return { map = o.map, x = o.x, y = o.y, facing = o.facing or "up" } end
|
||||
return nil
|
||||
end
|
||||
|
||||
function UnionRs.reset()
|
||||
UnionRs.flow = nil
|
||||
end
|
||||
|
||||
return UnionRs
|
||||
@@ -0,0 +1,51 @@
|
||||
local Base = require("src.core.game3.profiles.rs_rules")
|
||||
|
||||
local Rules = setmetatable({}, { __index = Base })
|
||||
|
||||
local function union() return require("src.core.game3.rse.union_rs") end
|
||||
|
||||
local function healCenterFront(session)
|
||||
local heal = type(session.healMap) == "string" and session.healMap or nil
|
||||
if not heal then return nil end
|
||||
local U = union()
|
||||
for id, c in pairs(U.centers) do
|
||||
if c.oneF == heal then return U.originFor(id) end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Rules.unionReturn(session, game)
|
||||
if not union().inRoom(session) then
|
||||
return require("src.core.game3.link.union_save_spot").live(session, game)
|
||||
end
|
||||
return union().saveOrigin(session) or healCenterFront(session)
|
||||
end
|
||||
|
||||
function Rules.saveLocation(session, game)
|
||||
local o = Rules.unionReturn(session, game)
|
||||
if not o then return nil end
|
||||
return o.map, o.x, o.y, o.facing
|
||||
end
|
||||
|
||||
-- pokeruby/src/load_save.c:38
|
||||
function Rules.saveWarpFields(session)
|
||||
local o = Rules.unionReturn(session)
|
||||
if o then return 1, { map = o.map, warpId = -1, x = o.x, y = o.y } end
|
||||
return Base.saveWarpFields(session)
|
||||
end
|
||||
|
||||
-- pokeruby/src/load_save.c:38
|
||||
function Rules.useContinueGameWarp(session, mounted)
|
||||
local fromRoom = union().inRoom(session)
|
||||
local map, x, y, facing = session.map, tonumber(session.x), tonumber(session.y), session.facing
|
||||
Base.useContinueGameWarp(session, mounted)
|
||||
if session.map == map and tonumber(session.x) == x and tonumber(session.y) == y then session.facing = facing end
|
||||
local o = Rules.unionReturn(session)
|
||||
if o then
|
||||
session.map, session.x, session.y, session.facing = o.map, o.x, o.y, o.facing
|
||||
elseif fromRoom then
|
||||
session.facing = "up"
|
||||
end
|
||||
end
|
||||
|
||||
return Rules
|
||||
@@ -897,6 +897,18 @@ function LauncherSettings.open(hooks, version)
|
||||
action = function() hooks.openExtras(); return false end,
|
||||
})
|
||||
end
|
||||
local UnionSetting = require("src.online.union.Setting")
|
||||
launcher.rows[#launcher.rows + 1] = {
|
||||
label = Strings("Union Room"),
|
||||
note = Strings("Adds a Union Room upstairs in Gen 1 and Gen 2 Pokemon Centers. OFF restores the original Centers. Gen 3 Union Rooms always work."),
|
||||
value = function()
|
||||
return UnionSetting.enabledIn(opts) and Strings("ON") or Strings("OFF")
|
||||
end,
|
||||
step = function()
|
||||
opts[UnionSetting.KEY] = not UnionSetting.enabledIn(opts)
|
||||
return true
|
||||
end,
|
||||
}
|
||||
local RomSources = require("src.import.RomSources")
|
||||
launcher.rows[#launcher.rows + 1] = {
|
||||
label = Strings("Auto Re-import"),
|
||||
|
||||
@@ -5765,7 +5765,8 @@ local function buildSettingsModal(imp, m)
|
||||
item.stacked = item.row.choices ~= nil or Kit.textWidth("small", item.label)
|
||||
+ math.floor(210 * m.s) > inner
|
||||
item.labelH = item.stacked and Kit.wrapHeight("small", item.label, inner) or Kit.textHeight("small")
|
||||
item.h = 2 * inset + m.btnH + (item.stacked and item.labelH + gap or 0)
|
||||
item.noteH = item.row.note and Kit.wrapHeight("micro", item.row.note, inner) + gap or 0
|
||||
item.h = 2 * inset + m.btnH + (item.stacked and item.labelH + gap or 0) + item.noteH
|
||||
end
|
||||
total = total + item.h + gap
|
||||
end
|
||||
@@ -5809,6 +5810,9 @@ local function buildSettingsModal(imp, m)
|
||||
if visible then
|
||||
Kit.card(x, ry, rowW, item.h, "row")
|
||||
Kit.textWrapped("small", item.label, ix, labelY, inner, PAL.text)
|
||||
if row.note then
|
||||
Kit.textWrapped("micro", row.note, ix, ctlY + m.btnH + gap, inner, PAL.muted)
|
||||
end
|
||||
end
|
||||
if row.choices then
|
||||
local cw = (inner - gap) / 2
|
||||
|
||||
@@ -332,6 +332,9 @@ local MAX_PLAZA = 40
|
||||
local MAX_DIRECT = 64
|
||||
local MAX_GROUPS = 64
|
||||
local MAX_AVATAR_NAME = 16
|
||||
local MAX_AVATAR_STYLE = 16
|
||||
local MAX_CAPS_FP = 64
|
||||
local MAX_CAPS_ENTRIES = 11
|
||||
local MAX_MON_TEXT = 32
|
||||
local MAX_CHAT_NAME = 7
|
||||
local MAX_CHAT_TEXT = 15
|
||||
@@ -447,6 +450,15 @@ end
|
||||
|
||||
Wire.party3 = party3
|
||||
|
||||
local function avatarStyle(v)
|
||||
if type(v) ~= "string" or not v:match("^[a-z0-9:_%-]+$") or #v > MAX_AVATAR_STYLE then
|
||||
return nil
|
||||
end
|
||||
return v
|
||||
end
|
||||
|
||||
Wire.avatarStyle = avatarStyle
|
||||
|
||||
function Wire.avatar(v)
|
||||
if type(v) ~= "table" then return nil end
|
||||
return {
|
||||
@@ -454,6 +466,46 @@ function Wire.avatar(v)
|
||||
trainerId = Wire.num(v.trainerId, 0, 0, 65535),
|
||||
gender = Wire.num(v.gender, 0, 0, 1),
|
||||
version = Wire.str(v.version, nil, MAX_AVATAR_NAME),
|
||||
style = avatarStyle(v.style),
|
||||
gen = Wire.num(v.gen, nil, 1, 3),
|
||||
canLinkNationally = Wire.bool(v.canLinkNationally, nil),
|
||||
}
|
||||
end
|
||||
|
||||
local function capsEntry(v)
|
||||
if type(v) ~= "table" then return nil end
|
||||
local version = Wire.str(v.version, nil, MAX_AVATAR_NAME)
|
||||
local fp = type(v.fp) == "string" and v.fp:lower() or nil
|
||||
if not version or not version:match("^[a-z]+$") or not fp
|
||||
or #fp > MAX_CAPS_FP or not fp:match("^[0-9a-f]+$") then
|
||||
return nil
|
||||
end
|
||||
return { version = version, fp = fp }
|
||||
end
|
||||
|
||||
function Wire.caps(v)
|
||||
if type(v) ~= "table" then return nil end
|
||||
local gens, total = {}, 0
|
||||
local src = type(v.gens) == "table" and v.gens or {}
|
||||
for gen = 1, 3 do
|
||||
local list = src[tostring(gen)]
|
||||
if list == nil then list = src[gen] end
|
||||
if type(list) == "table" then
|
||||
local out = {}
|
||||
for i = 1, math.min(#list, MAX_CAPS_ENTRIES) do
|
||||
local entry = capsEntry(list[i])
|
||||
if entry and total < MAX_CAPS_ENTRIES then
|
||||
out[#out + 1] = entry
|
||||
total = total + 1
|
||||
end
|
||||
end
|
||||
gens[tostring(gen)] = out
|
||||
end
|
||||
end
|
||||
return {
|
||||
proto = Wire.num(v.proto, 1, 1, 1000),
|
||||
policy = Wire.num(v.policy, 0, 0, 1000000),
|
||||
gens = gens,
|
||||
}
|
||||
end
|
||||
|
||||
@@ -501,6 +553,7 @@ function Wire.member(v)
|
||||
recruiting = recruiting(v.recruiting),
|
||||
board = board(v.board),
|
||||
group = memberGroup(v.group),
|
||||
caps = Wire.caps(v.caps),
|
||||
}
|
||||
end
|
||||
|
||||
@@ -635,9 +688,85 @@ local function playerEntry(v)
|
||||
seat = Wire.num(v.seat, nil, 0, MAX_SEAT),
|
||||
party = Wire.list(v.party, MAX_TEAM, mon),
|
||||
partyDigest = Wire.str(v.partyDigest, nil, MAX_STRING),
|
||||
gen = Wire.num(v.gen, nil, 1, 3),
|
||||
avatar = Wire.avatar(v.avatar),
|
||||
}
|
||||
end
|
||||
|
||||
local XG_MODES = { battle = true, trade = true }
|
||||
|
||||
local function xgMode(v)
|
||||
return XG_MODES[v] and v or nil
|
||||
end
|
||||
|
||||
local function xgGens(v)
|
||||
if type(v) ~= "table" then return nil end
|
||||
local a, b = Wire.num(v[1], nil, 1, 3), Wire.num(v[2], nil, 1, 3)
|
||||
if not a or not b then return nil end
|
||||
return { a, b }
|
||||
end
|
||||
|
||||
local function xgRules(v)
|
||||
if type(v) ~= "table" then return nil end
|
||||
return {
|
||||
mode = xgMode(v.mode),
|
||||
ruleset = Wire.str(v.ruleset, nil, MAX_NAME),
|
||||
gen = Wire.num(v.gen, nil, 1, 3),
|
||||
dexMax = Wire.num(v.dexMax, nil, 1, 65535),
|
||||
moveMax = Wire.num(v.moveMax, nil, 1, 65535),
|
||||
moveGen = Wire.num(v.moveGen, nil, 1, 3),
|
||||
policy = Wire.num(v.policy, nil, 0, 1000000),
|
||||
gens = xgGens(v.gens),
|
||||
}
|
||||
end
|
||||
|
||||
Wire.xgRules = xgRules
|
||||
|
||||
local function xgPair(v, fn)
|
||||
local out = {}
|
||||
if type(v) ~= "table" then return out end
|
||||
for i = 1, 2 do
|
||||
if v[i] ~= nil then out[i] = fn(v[i]) end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function xgRoster(v)
|
||||
if type(v) ~= "table" then return nil end
|
||||
local size, digest = Wire.num(v.size, nil, 1, MAX_TEAM), Wire.digest(v.digest16)
|
||||
if not size or not digest then return nil end
|
||||
return { size = size, digest16 = digest }
|
||||
end
|
||||
|
||||
local function xgOfferMeta(v)
|
||||
if type(v) ~= "table" then return nil end
|
||||
local offerRev, digest = Wire.num(v.offerRev, nil, 0, MAX_INT), Wire.digest(v.digest16)
|
||||
if not offerRev or not digest then return nil end
|
||||
return { offerRev = offerRev, digest16 = digest }
|
||||
end
|
||||
|
||||
local function xgSnapshot(v)
|
||||
if type(v) ~= "table" then return nil end
|
||||
local mode = xgMode(v.mode)
|
||||
local rev = Wire.num(v.rev, nil, 0, MAX_INT)
|
||||
if not mode or not rev then return nil end
|
||||
return {
|
||||
mode = mode,
|
||||
rev = rev,
|
||||
gens = xgGens(v.gens),
|
||||
rules = xgRules(v.rules),
|
||||
blocked = Wire.str(v.blocked, nil, MAX_NAME),
|
||||
size = Wire.num(v.size, nil, 1, MAX_TEAM),
|
||||
caps = xgPair(v.caps, function(x) return x == true end),
|
||||
rosters = xgPair(v.rosters, xgRoster),
|
||||
sizeReq = xgPair(v.sizeReq, function(x) return Wire.num(x, nil, 1, MAX_TEAM) end),
|
||||
offers = xgPair(v.offers, xgOfferMeta),
|
||||
ready = xgPair(v.ready, function(x) return x == true end),
|
||||
}
|
||||
end
|
||||
|
||||
Wire.xgSnapshot = xgSnapshot
|
||||
|
||||
local function spectatorEntry(v)
|
||||
if type(v) ~= "table" then return nil end
|
||||
return {
|
||||
@@ -704,6 +833,7 @@ SCHEMAS.lobby_hello = function(m)
|
||||
platform = Wire.str(m.platform, nil, MAX_NAME),
|
||||
profiles = Wire.list(m.profiles, MAX_PROFILES, profile),
|
||||
presence = presenceFields(m.presence),
|
||||
xgen = Wire.num(m.xgen, nil, 0, 1),
|
||||
}
|
||||
end
|
||||
|
||||
@@ -875,6 +1005,8 @@ SCHEMAS.room_state = function(m)
|
||||
maxSpectators = Wire.num(m.maxSpectators, nil, 0, MAX_SPECTATORS),
|
||||
leader = Wire.num(m.leader, nil, 0, MAX_SEAT),
|
||||
deadlines = Wire.list(m.deadlines, MAX_DEADLINES, deadlineEntry),
|
||||
mode = xgMode(m.mode),
|
||||
xg = xgSnapshot(m.xg),
|
||||
}
|
||||
end
|
||||
|
||||
@@ -991,9 +1123,17 @@ SCHEMAS.plaza_join = function(m)
|
||||
cap = Wire.num(m.cap, nil, 1, MAX_PLAZA),
|
||||
profile = profile(m.profile),
|
||||
avatar = Wire.avatar(m.avatar),
|
||||
xgen = Wire.num(m.xgen, nil, 0, 1),
|
||||
caps = Wire.caps(m.caps),
|
||||
}
|
||||
end
|
||||
|
||||
SCHEMAS.set_caps = function(m)
|
||||
local caps = Wire.caps(m.caps)
|
||||
if not caps then return nil end
|
||||
return { caps = caps }
|
||||
end
|
||||
|
||||
SCHEMAS.plaza_leave = function(m)
|
||||
return { kind = Wire.str(m.kind, nil, MAX_NAME) }
|
||||
end
|
||||
@@ -1756,6 +1896,114 @@ SCHEMAS.game3_mg_leader = inner3(function(m)
|
||||
}
|
||||
end)
|
||||
|
||||
local XG_PAYLOAD_DEPTH = 8
|
||||
local XG_PAYLOAD_STRING = 256
|
||||
local XG_WHY = 40
|
||||
|
||||
local function xgPayload(v, depth)
|
||||
if type(v) ~= "table" or depth > XG_PAYLOAD_DEPTH then return nil end
|
||||
local out = {}
|
||||
for k, val in pairs(v) do
|
||||
local kt, vt = type(k), type(val)
|
||||
if (kt == "string" and #k <= MAX_STRING) or (kt == "number" and k == k) then
|
||||
if vt == "string" then out[k] = Wire.str(val, nil, XG_PAYLOAD_STRING)
|
||||
elseif vt == "number" then
|
||||
if val == val then out[k] = val end
|
||||
elseif vt == "boolean" then out[k] = val
|
||||
elseif vt == "table" then out[k] = xgPayload(val, depth + 1) end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function xgInner(fn)
|
||||
return function(m)
|
||||
local out = fn(m)
|
||||
if not out then return nil end
|
||||
out.seat = seatNum(m.seat)
|
||||
out.relay = Wire.bool(m.relay, nil)
|
||||
return out
|
||||
end
|
||||
end
|
||||
|
||||
local function xgRev(v)
|
||||
return Wire.num(v, nil, 0, MAX_INT)
|
||||
end
|
||||
|
||||
SCHEMAS.xg_caps = xgInner(function(m)
|
||||
return { caps = Wire.caps(m.caps) }
|
||||
end)
|
||||
|
||||
SCHEMAS.xg_rules = xgInner(function(m)
|
||||
local out = xgRules(m)
|
||||
out.rev = xgRev(m.rev)
|
||||
return out
|
||||
end)
|
||||
|
||||
SCHEMAS.xg_blocked = xgInner(function(m)
|
||||
return { rev = xgRev(m.rev), why = Wire.str(m.why, nil, MAX_NAME) }
|
||||
end)
|
||||
|
||||
SCHEMAS.xg_counter = xgInner(function(m)
|
||||
return { rev = xgRev(m.rev), gen = Wire.num(m.gen, nil, 1, 3) }
|
||||
end)
|
||||
|
||||
SCHEMAS.xg_roster = xgInner(function(m)
|
||||
return { rev = xgRev(m.rev), size = Wire.num(m.size, nil, 1, MAX_TEAM),
|
||||
digest16 = Wire.digest(m.digest16) }
|
||||
end)
|
||||
|
||||
SCHEMAS.xg_size = xgInner(function(m)
|
||||
return { rev = xgRev(m.rev), size = Wire.num(m.size, nil, 1, MAX_TEAM) }
|
||||
end)
|
||||
|
||||
SCHEMAS.xg_size_req = SCHEMAS.xg_size
|
||||
|
||||
SCHEMAS.xg_offer = xgInner(function(m)
|
||||
return { rev = xgRev(m.rev), offerRev = Wire.num(m.offerRev, nil, 0, MAX_INT),
|
||||
payload = xgPayload(m.payload, 0), digest16 = Wire.digest(m.digest16) }
|
||||
end)
|
||||
|
||||
SCHEMAS.xg_rev = xgInner(function(m)
|
||||
return { rev = xgRev(m.rev), cause = Wire.str(m.cause, nil, MAX_NAME) }
|
||||
end)
|
||||
|
||||
SCHEMAS.xg_ready = xgInner(function(m)
|
||||
return { rev = xgRev(m.rev), digest16 = Wire.digest(m.digest16) }
|
||||
end)
|
||||
|
||||
SCHEMAS.xg_go = xgInner(function(m)
|
||||
return {
|
||||
rev = xgRev(m.rev),
|
||||
seed = Wire.num(m.seed, nil, 0, MAX_INT),
|
||||
match = Wire.str(m.match, nil, MAX_NAME),
|
||||
mode = xgMode(m.mode),
|
||||
ruleset = Wire.str(m.ruleset, nil, MAX_NAME),
|
||||
size = Wire.num(m.size, nil, 1, MAX_TEAM),
|
||||
gen = Wire.num(m.gen, nil, 1, 3),
|
||||
dexMax = Wire.num(m.dexMax, nil, 1, 65535),
|
||||
moveMax = Wire.num(m.moveMax, nil, 1, 65535),
|
||||
moveGen = Wire.num(m.moveGen, nil, 1, 3),
|
||||
}
|
||||
end)
|
||||
|
||||
SCHEMAS.xg_cancel = xgInner(function(m)
|
||||
return { why = Wire.str(m.why, nil, XG_WHY) }
|
||||
end)
|
||||
|
||||
SCHEMAS.xg_closed = xgInner(function(m)
|
||||
return { why = Wire.str(m.why, nil, MAX_NAME), detail = Wire.str(m.detail, nil, XG_WHY) }
|
||||
end)
|
||||
|
||||
SCHEMAS.xg_nack = xgInner(function(m)
|
||||
return {
|
||||
of = Wire.str(m.of, nil, MAX_NAME),
|
||||
why = Wire.str(m.why, nil, MAX_NAME),
|
||||
rev = xgRev(m.rev),
|
||||
current = xgRev(m.current),
|
||||
}
|
||||
end)
|
||||
|
||||
Wire.SCHEMAS = SCHEMAS
|
||||
|
||||
local function passthrough(m)
|
||||
|
||||
+39
-6
@@ -9,7 +9,7 @@ local BACKOFF = { 1, 2, 4, 8, 15 }
|
||||
local MAX_ATTEMPTS = 12
|
||||
local MATCH_STAGES = { battling = true }
|
||||
local ROOM_STAGES = { waiting = true, ready = true, battling = true,
|
||||
ended = true }
|
||||
ended = true, prep = true, trading = true }
|
||||
local UNACKED_MAX = 512
|
||||
|
||||
local function now()
|
||||
@@ -326,6 +326,21 @@ function RoomSession:take(messageType, predicate)
|
||||
return nil
|
||||
end
|
||||
|
||||
function RoomSession:takeWhere(predicate)
|
||||
local rest = leftover(self)
|
||||
local inbox = rest or S.roomInbox
|
||||
for index = 1, #inbox do
|
||||
local entry = inbox[index]
|
||||
local msg = shaped(entry)
|
||||
if predicate(msg) == true then
|
||||
table.remove(inbox, index)
|
||||
if rest then return msg end
|
||||
return deliver({ entry })[1]
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function RoomSession:unread(messages)
|
||||
if type(messages) ~= "table" then return end
|
||||
local inbox = leftover(self) or S.roomInbox
|
||||
@@ -532,6 +547,8 @@ local function applyRoomState(msg)
|
||||
maxSpectators = msg.maxSpectators,
|
||||
leader = msg.leader,
|
||||
deadlines = deadlines,
|
||||
mode = msg.mode,
|
||||
xg = msg.xg,
|
||||
}
|
||||
S.match = msg.match or S.match
|
||||
S.role, S.seat = myRole(S.room)
|
||||
@@ -565,7 +582,8 @@ clearRoom = function()
|
||||
for i = 1, #inbox do
|
||||
local entry = inbox[i]
|
||||
local kind = entry.msg and entry.msg.type
|
||||
if entry.relay and (kind == "trade_commit" or kind == "trade_abort") then
|
||||
if entry.relay and (kind == "trade_commit" or kind == "trade_abort"
|
||||
or Protocol2.XG_RELAY_TYPES[kind]) then
|
||||
keep[#keep + 1] = entry
|
||||
end
|
||||
end
|
||||
@@ -837,7 +855,7 @@ local function resendState()
|
||||
if S.presence then sendRaw(Protocol2.presence(S.presence)) end
|
||||
for _, kind in ipairs({ "union", "wireless" }) do
|
||||
local j = S.plazaJoins[kind]
|
||||
if j then sendRaw(Protocol2.plazaJoin(kind, j.profile, j.avatar, j.cap)) end
|
||||
if j then sendRaw(Protocol2.plazaJoin(kind, j.profile, j.avatar, j.cap, j.opts)) end
|
||||
end
|
||||
local board = S.presence and S.presence.board
|
||||
if S.plazaJoins.union and type(board) == "table" then
|
||||
@@ -1126,6 +1144,7 @@ helloMessage = function()
|
||||
platform = S.platform,
|
||||
profiles = opts.profiles,
|
||||
presence = S.presence,
|
||||
xgen = Protocol2.XGEN,
|
||||
})
|
||||
end
|
||||
|
||||
@@ -1404,11 +1423,25 @@ function Client.replyInvite(id, accept)
|
||||
return sendRaw(Protocol2.inviteReply(id, accept == true))
|
||||
end
|
||||
|
||||
function Client.joinPlaza(kind, profile, avatar, cap)
|
||||
function Client.joinPlaza(kind, profile, avatar, cap, opts)
|
||||
profile = profile or defaultProfile(3)
|
||||
if cap == nil and kind == "union" then cap = Protocol2.PLAZA_CAP end
|
||||
S.plazaJoins[kind] = { profile = profile, avatar = avatar, cap = cap }
|
||||
return sendRaw(Protocol2.plazaJoin(kind, profile, avatar, cap))
|
||||
local xopts = nil
|
||||
if type(opts) == "table" and opts.xgen then
|
||||
xopts = { xgen = Protocol2.XGEN, caps = opts.caps }
|
||||
end
|
||||
S.plazaJoins[kind] = { profile = profile, avatar = avatar, cap = cap, opts = xopts }
|
||||
return sendRaw(Protocol2.plazaJoin(kind, profile, avatar, cap, xopts))
|
||||
end
|
||||
|
||||
function Client.setCaps(caps)
|
||||
local j = S.plazaJoins.union
|
||||
if j and j.opts then j.opts.caps = caps end
|
||||
return sendRaw(Protocol2.setCaps(caps))
|
||||
end
|
||||
|
||||
function Client.plazaJoinInfo(kind)
|
||||
return S.plazaJoins[kind or "union"]
|
||||
end
|
||||
|
||||
function Client.leavePlaza(kind)
|
||||
|
||||
@@ -87,6 +87,7 @@ function Protocol2.lobbyHello(opts)
|
||||
platform = opts.platform,
|
||||
profiles = opts.profiles or {},
|
||||
presence = opts.presence,
|
||||
xgen = opts.xgen,
|
||||
})
|
||||
end
|
||||
|
||||
@@ -116,9 +117,57 @@ end
|
||||
|
||||
Protocol2.PLAZA_CAP = 40
|
||||
|
||||
function Protocol2.plazaJoin(kind, profile, avatar, cap)
|
||||
Protocol2.XGEN = 1
|
||||
|
||||
function Protocol2.plazaJoin(kind, profile, avatar, cap, opts)
|
||||
opts = opts or {}
|
||||
return build({ type = "plaza_join", kind = kind, profile = profile,
|
||||
avatar = avatar, cap = cap })
|
||||
avatar = avatar, cap = cap, xgen = opts.xgen, caps = opts.caps })
|
||||
end
|
||||
|
||||
function Protocol2.setCaps(caps)
|
||||
return build({ type = "set_caps", caps = caps })
|
||||
end
|
||||
|
||||
Protocol2.XG_ACTIVITIES = { xg_battle = "battle", xg_trade = "trade" }
|
||||
Protocol2.XG_STAGES = { prep = true, trading = true, battling = true, ended = true }
|
||||
Protocol2.XG_RELAY_TYPES = { xg_rules = true, xg_blocked = true, xg_size = true, xg_rev = true,
|
||||
xg_go = true, xg_closed = true, xg_nack = true }
|
||||
Protocol2.XG_SEAT_TYPES = { xg_caps = true, xg_counter = true, xg_roster = true,
|
||||
xg_size_req = true, xg_offer = true, xg_ready = true, xg_cancel = true }
|
||||
|
||||
function Protocol2.isXg(msg)
|
||||
local t = type(msg) == "table" and msg.type or nil
|
||||
return Protocol2.XG_RELAY_TYPES[t] == true or Protocol2.XG_SEAT_TYPES[t] == true
|
||||
end
|
||||
|
||||
function Protocol2.xgCaps(caps)
|
||||
return build({ type = "xg_caps", caps = caps })
|
||||
end
|
||||
|
||||
function Protocol2.xgCounter(rev, gen)
|
||||
return build({ type = "xg_counter", rev = rev, gen = gen })
|
||||
end
|
||||
|
||||
function Protocol2.xgRoster(rev, size, digest16)
|
||||
return build({ type = "xg_roster", rev = rev, size = size, digest16 = digest16 })
|
||||
end
|
||||
|
||||
function Protocol2.xgSizeReq(rev, size)
|
||||
return build({ type = "xg_size_req", rev = rev, size = size })
|
||||
end
|
||||
|
||||
function Protocol2.xgOffer(rev, offerRev, payload, digest16)
|
||||
return build({ type = "xg_offer", rev = rev, offerRev = offerRev, payload = payload,
|
||||
digest16 = digest16 })
|
||||
end
|
||||
|
||||
function Protocol2.xgReady(rev, digest16)
|
||||
return build({ type = "xg_ready", rev = rev, digest16 = digest16 })
|
||||
end
|
||||
|
||||
function Protocol2.xgCancel(why)
|
||||
return build({ type = "xg_cancel", why = why })
|
||||
end
|
||||
|
||||
function Protocol2.plazaLeave(kind)
|
||||
@@ -323,7 +372,7 @@ Protocol2.CLIENT_TYPES = {
|
||||
invite_reply = true, plaza_join = true, plaza_leave = true,
|
||||
group_open = true, group_list = true, group_join = true, group_accept = true,
|
||||
group_leave = true, group_start = true, direct_queue = true,
|
||||
direct_list = true, direct_leave = true,
|
||||
direct_list = true, direct_leave = true, set_caps = true,
|
||||
}
|
||||
|
||||
Protocol2.SERVER_TYPES = {
|
||||
@@ -344,6 +393,7 @@ Protocol2.SERVER_TYPES = {
|
||||
|
||||
Protocol2.RELAY_INNER = { trade_commit = true, trade_abort = true,
|
||||
game3_mg_leader = true }
|
||||
for t in pairs(Protocol2.XG_RELAY_TYPES) do Protocol2.RELAY_INNER[t] = true end
|
||||
|
||||
Protocol2.RESULTS = { win = true, lose = true, draw = true }
|
||||
|
||||
@@ -618,6 +668,8 @@ local REASONS = {
|
||||
bad_activity = Strings.source("That activity isn't available."),
|
||||
already_queued = Strings.source("You're already waiting for a partner."),
|
||||
rate_limited = Strings.source("Too many requests. Please wait a moment."),
|
||||
bad_avatar = Strings.source("The relay couldn't read your trainer."),
|
||||
bad_caps = Strings.source("The relay couldn't read your game list."),
|
||||
}
|
||||
|
||||
local CLOSED_REASONS = {
|
||||
@@ -649,6 +701,7 @@ local INVITE_CLOSED = {
|
||||
no_room = Strings.source("That room is gone."),
|
||||
no_group = Strings.source("That group is gone."),
|
||||
group_full = Strings.source("That group is full."),
|
||||
not_plaza = Strings.source("That trainer isn't in this Union Room."),
|
||||
}
|
||||
|
||||
Protocol2.UPGRADE_TEXT = Strings.source("This build is too old for online play. Please update.")
|
||||
|
||||
@@ -974,6 +974,13 @@ function Trade.pendingSentAt(savePath)
|
||||
for _, e in ipairs(readJournal(Trade.journalPath(savePath))) do
|
||||
if type(e) == "table" and type(e.sent) == "table" then out[#out + 1] = e.sent end
|
||||
end
|
||||
local xpath = type(savePath) == "string" and savePath ~= ""
|
||||
and (savePath:gsub("%.lua$", "")) .. "_xtrade.lua" or nil
|
||||
for _, e in ipairs(readJournal(xpath)) do
|
||||
if type(e) == "table" and type(e.out) == "table" and type(e.out.record) == "table" then
|
||||
out[#out + 1] = e.out.record
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
|
||||
local Avatars = {}
|
||||
|
||||
local readerOverride = nil
|
||||
local resolved = {}
|
||||
local tables = {}
|
||||
local hostBases = {}
|
||||
local stats = { reads = 0, resolves = 0, images = 0 }
|
||||
|
||||
Avatars.GB_FRAME = 16
|
||||
-- pokered/data/sprites/facings.asm:1
|
||||
Avatars.GB_STAND = { down = 0, up = 1, left = 2, right = 2 }
|
||||
Avatars.GB_WALK = { down = 3, up = 4, left = 5, right = 5 }
|
||||
-- pokeemerald/src/data/object_events/object_event_anims.h:825
|
||||
Avatars.GBA_STAND = { down = 0, up = 1, left = 2, right = 2 }
|
||||
Avatars.GBA_WALK_A = { down = 3, up = 5, left = 7, right = 7 }
|
||||
Avatars.GBA_WALK_B = { down = 4, up = 6, left = 8, right = 8 }
|
||||
|
||||
local GB1 = { "red", "blue", "yellow" }
|
||||
local GB2 = { "gold", "silver", "crystal" }
|
||||
local GB2_FEMALE = { "crystal" }
|
||||
local FRLG = { "firered", "leafgreen" }
|
||||
local RSE = { "ruby", "sapphire", "emerald" }
|
||||
local RSE_CLASSES = { "emerald" }
|
||||
|
||||
local SPRITE_GB1 = "assets/generated/sprites/red.png"
|
||||
-- pokecrystal/data/sprites/player_sprites.asm:1
|
||||
local SPRITE_GB2 = { [0] = { png = "assets/generated/sprites/chris.png", id = "SPRITE_CHRIS" },
|
||||
[1] = { png = "assets/generated/sprites/kris.png", id = "SPRITE_KRIS" } }
|
||||
local GB2_SPRITES = "data/generated/sprites.lua"
|
||||
local GB2_PALETTES = "data/generated/palettes.lua"
|
||||
local GB2_DAYTIME = "DAY"
|
||||
local OW_ROOT = "data/generated/gba/ow/"
|
||||
local UNION_AVATARS = "data/generated/gba/union_room/avatars.lua"
|
||||
|
||||
local function defaultReader(version, rel)
|
||||
local prefix = GameVersion.cachePrefix(version)
|
||||
return require("src.import.CacheFs").readAt(prefix .. rel)
|
||||
end
|
||||
|
||||
function Avatars.directoryReader(roots)
|
||||
local CacheBlob = require("src.import.CacheBlob")
|
||||
return function(version, rel)
|
||||
local root = type(roots) == "table" and roots[version] or roots
|
||||
if type(root) ~= "string" then return nil end
|
||||
local path = root .. "/" .. GameVersion.cachePrefix(version) .. rel
|
||||
local f = io.open(path, "rb")
|
||||
if not f then return nil end
|
||||
local body = f:read("*a")
|
||||
f:close()
|
||||
return CacheBlob.decode(path, body)
|
||||
end
|
||||
end
|
||||
|
||||
function Avatars.setReader(fn)
|
||||
readerOverride = fn
|
||||
Avatars.reset()
|
||||
end
|
||||
|
||||
function Avatars.reset()
|
||||
resolved = {}
|
||||
tables = {}
|
||||
hostBases = {}
|
||||
stats = { reads = 0, resolves = 0, images = 0 }
|
||||
end
|
||||
|
||||
function Avatars.refresh()
|
||||
resolved = {}
|
||||
tables = {}
|
||||
hostBases = {}
|
||||
end
|
||||
|
||||
function Avatars.stats()
|
||||
return { reads = stats.reads, resolves = stats.resolves, images = stats.images }
|
||||
end
|
||||
|
||||
local function read(version, rel)
|
||||
stats.reads = stats.reads + 1
|
||||
local fn = readerOverride or defaultReader
|
||||
local ok, bytes = pcall(fn, version, rel)
|
||||
if ok and type(bytes) == "string" and #bytes > 0 then return bytes end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function evaluate(body, label)
|
||||
if type(body) ~= "string" then return nil end
|
||||
local chunk = (loadstring or load)(body, "@" .. label)
|
||||
if not chunk then return nil end
|
||||
if setfenv then setfenv(chunk, {}) end
|
||||
local ok, value = pcall(chunk)
|
||||
return ok and type(value) == "table" and value or nil
|
||||
end
|
||||
|
||||
local function tableAt(version, rel)
|
||||
local key = version .. "|" .. rel
|
||||
local hit = tables[key]
|
||||
if hit ~= nil then return hit or nil end
|
||||
local t = evaluate(read(version, rel), version .. "/" .. rel)
|
||||
tables[key] = t or false
|
||||
return t
|
||||
end
|
||||
|
||||
function Avatars.pngSize(bytes)
|
||||
if type(bytes) ~= "string" or #bytes < 24 or bytes:sub(1, 8) ~= "\137PNG\r\n\26\n" then
|
||||
return nil
|
||||
end
|
||||
local function u32(i)
|
||||
local a, b, c, d = bytes:byte(i, i + 3)
|
||||
return ((a * 256 + b) * 256 + c) * 256 + d
|
||||
end
|
||||
return u32(17), u32(21)
|
||||
end
|
||||
|
||||
local function ordered(first, family)
|
||||
local out, seen = {}, {}
|
||||
if first then
|
||||
for _, v in ipairs(family) do
|
||||
if v == first then out[1], seen[v] = v, true end
|
||||
end
|
||||
end
|
||||
for _, v in ipairs(family) do
|
||||
if not seen[v] then out[#out + 1] = v end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function classIndex(style)
|
||||
local n = type(style) == "string" and tonumber(style:match("^g3:(%d+)$")) or nil
|
||||
if n and n >= 0 and n <= 7 then return math.floor(n) end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Avatars.family(p)
|
||||
local version = p and p.game
|
||||
local gen = GameVersion.VERSIONS[version or ""] and GameVersion.generation(version) or tonumber(p and p.gen)
|
||||
local female = p and p.gender == 1
|
||||
if gen == 1 then return "gb1", ordered(version, GB1) end
|
||||
if gen == 2 then
|
||||
if female and version == "crystal" then return "gb2f", ordered(version, GB2_FEMALE) end
|
||||
return "gb2m", ordered(version, GB2)
|
||||
end
|
||||
if gen == 3 then
|
||||
local cls = classIndex(p.style)
|
||||
local frlg = version == "firered" or version == "leafgreen"
|
||||
if frlg then return cls and "frlg:class" or "frlg:player", ordered(version, FRLG) end
|
||||
if cls then return "rse:class", ordered(version, RSE_CLASSES) end
|
||||
return "rse:player", ordered(version, RSE)
|
||||
end
|
||||
return nil, {}
|
||||
end
|
||||
|
||||
local function rects(w, h, count)
|
||||
local out = {}
|
||||
for i = 0, count - 1 do out[i] = { x = 0, y = i * h, w = w, h = h } end
|
||||
return out
|
||||
end
|
||||
|
||||
local function gb2PaletteFor(version, spriteId)
|
||||
local sprites = tableAt(version, GB2_SPRITES)
|
||||
local palettes = tableAt(version, GB2_PALETTES)
|
||||
local def = sprites and sprites[spriteId]
|
||||
local id = def and tonumber(def.paletteId)
|
||||
local objects = palettes and palettes.objects
|
||||
local day = type(objects) == "table" and objects[GB2_DAYTIME] or nil
|
||||
local colors = day and id and day[id + 1]
|
||||
if type(colors) ~= "table" or #colors ~= 4 then return nil end
|
||||
return { mode = "gbc", colors = colors, name = def.palette }
|
||||
end
|
||||
|
||||
local function gb2Palette(version, gender)
|
||||
return gb2PaletteFor(version, SPRITE_GB2[gender].id)
|
||||
end
|
||||
|
||||
local function gbEntry(version, gen, path, palette)
|
||||
local bytes = read(version, path)
|
||||
local w, h = Avatars.pngSize(bytes)
|
||||
if not w or w ~= Avatars.GB_FRAME or h < Avatars.GB_FRAME * 6 then return nil end
|
||||
local frames = math.floor(h / Avatars.GB_FRAME)
|
||||
return {
|
||||
version = version, gen = gen, layout = "gb",
|
||||
source = { kind = "png", rel = path, bytes = bytes },
|
||||
w = w, h = Avatars.GB_FRAME, frames = frames, sheetW = w, sheetH = h,
|
||||
rects = rects(w, Avatars.GB_FRAME, frames),
|
||||
anchor = { x = w / 2, y = Avatars.GB_FRAME },
|
||||
palette = palette,
|
||||
}
|
||||
end
|
||||
|
||||
local function frlgPlayer(gender)
|
||||
local V = require("src.import.gba.versions_frlg")
|
||||
return gender == 1 and V.OW_PLAYER_FEMALE or V.OW_PLAYER_MALE
|
||||
end
|
||||
|
||||
local function gbaGid(version, kind, p)
|
||||
local gender = p.gender == 1 and 1 or 0
|
||||
if kind == "frlg:player" then return frlgPlayer(gender) end
|
||||
if kind == "rse:player" then
|
||||
local manifest = tableAt(version, OW_ROOT .. "manifest.lua")
|
||||
local rows = manifest and manifest.avatars and manifest.avatars.player
|
||||
for _, row in ipairs(type(rows) == "table" and rows or {}) do
|
||||
if row.state == "NORMAL" then return gender == 1 and row.female or row.male end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
local av = tableAt(version, UNION_AVATARS)
|
||||
local ids = av and av.gfx_ids
|
||||
local row = ids and (gender == 1 and ids.female or ids.male)
|
||||
local n = classIndex(p.style)
|
||||
return row and n and row[n + 1] or nil
|
||||
end
|
||||
|
||||
local function gbaEntry(version, kind, p)
|
||||
local gid = kind == "host" and p.gid or gbaGid(version, kind, p)
|
||||
if not gid then return nil end
|
||||
local manifest = tableAt(version, OW_ROOT .. "manifest.lua")
|
||||
local info = manifest and manifest.sprites and manifest.sprites[gid]
|
||||
if type(info) ~= "table" then return nil end
|
||||
local w, h, n = tonumber(info.width), tonumber(info.height), tonumber(info.frameCount)
|
||||
if not (w and h and n) or n < 9 then return nil end
|
||||
local rel = OW_ROOT .. gid .. ".rgba"
|
||||
local bytes = read(version, rel)
|
||||
if not bytes or #bytes ~= w * h * n * 4 then return nil end
|
||||
return {
|
||||
version = version, gen = 3, layout = "gba", gid = gid,
|
||||
source = { kind = "rgba", rel = rel, bytes = bytes },
|
||||
w = w, h = h, frames = n, sheetW = w, sheetH = h * n,
|
||||
rects = rects(w, h, n),
|
||||
anchor = { x = w / 2, y = h },
|
||||
palette = { mode = "rgba" },
|
||||
}
|
||||
end
|
||||
|
||||
local function candidate(kind, version, p)
|
||||
if kind == "gb1" then
|
||||
return gbEntry(version, 1, SPRITE_GB1, { mode = "dmg" })
|
||||
end
|
||||
if kind == "gb2m" or kind == "gb2f" then
|
||||
local gender = kind == "gb2f" and 1 or 0
|
||||
local palette = gb2Palette(version, gender)
|
||||
if not palette then return nil end
|
||||
return gbEntry(version, 2, SPRITE_GB2[gender].png, palette)
|
||||
end
|
||||
return gbaEntry(version, kind, p)
|
||||
end
|
||||
|
||||
function Avatars.key(p)
|
||||
if type(p) ~= "table" then return "?" end
|
||||
return table.concat({ tostring(p.game), tostring(p.style or "player"),
|
||||
tostring(p.gender == 1 and 1 or 0) }, "|")
|
||||
end
|
||||
|
||||
-- pokered/constants/sprite_constants.asm:4
|
||||
Avatars.HOST_GB1 = {
|
||||
[0] = { "SPRITE_YOUNGSTER", "SPRITE_COOLTRAINER_M", "SPRITE_HIKER", "SPRITE_BIKER",
|
||||
"SPRITE_SUPER_NERD", "SPRITE_GAMBLER", "SPRITE_GENTLEMAN", "SPRITE_FISHER",
|
||||
"SPRITE_SAILOR", "SPRITE_SWIMMER", "SPRITE_ROCKER", "SPRITE_SCIENTIST",
|
||||
"SPRITE_MIDDLE_AGED_MAN" },
|
||||
[1] = { "SPRITE_BRUNETTE_GIRL", "SPRITE_LITTLE_GIRL", "SPRITE_GIRL", "SPRITE_COOLTRAINER_F",
|
||||
"SPRITE_BEAUTY", "SPRITE_CHANNELER", "SPRITE_MIDDLE_AGED_WOMAN" },
|
||||
}
|
||||
-- pokecrystal/constants/sprite_constants.asm:4
|
||||
Avatars.HOST_GB2 = {
|
||||
[0] = { "SPRITE_YOUNGSTER", "SPRITE_BUG_CATCHER", "SPRITE_COOLTRAINER_M", "SPRITE_BIKER",
|
||||
"SPRITE_BLACK_BELT", "SPRITE_FISHER", "SPRITE_GENTLEMAN", "SPRITE_POKEFAN_M",
|
||||
"SPRITE_ROCKER", "SPRITE_SAILOR", "SPRITE_SCIENTIST", "SPRITE_SUPER_NERD",
|
||||
"SPRITE_SWIMMER_GUY", "SPRITE_SAGE" },
|
||||
[1] = { "SPRITE_LASS", "SPRITE_COOLTRAINER_F", "SPRITE_BEAUTY", "SPRITE_POKEFAN_F",
|
||||
"SPRITE_SWIMMER_GIRL", "SPRITE_TEACHER", "SPRITE_TWIN" },
|
||||
}
|
||||
-- pokefirered/include/constants/event_objects.h:22
|
||||
Avatars.HOST_FRLG = {
|
||||
[0] = { 16, 18, 19, 20, 25, 26, 27, 30, 32, 39, 41, 52, 53, 54, 55, 56, 57, 61, 62 },
|
||||
[1] = { 17, 22, 23, 24, 28, 29, 31, 35, 40, 42, 58 },
|
||||
}
|
||||
-- pokeemerald/include/constants/event_objects.h:14
|
||||
Avatars.HOST_RSE = {
|
||||
[0] = { 7, 9, 13, 19, 23, 31, 35, 36, 37, 38, 39, 44, 46, 48, 49, 50, 55 },
|
||||
[1] = { 8, 10, 14, 16, 32, 40, 45, 47 },
|
||||
}
|
||||
|
||||
local function hashOf(text)
|
||||
local h = 5381
|
||||
for i = 1, #text do h = (h * 33 + text:byte(i)) % 4294967296 end
|
||||
return h
|
||||
end
|
||||
|
||||
function Avatars.pickKey(p)
|
||||
if type(p) ~= "table" then return "?" end
|
||||
return table.concat({ tostring(p.game), tostring(math.floor(tonumber(p.trainerId) or 0)),
|
||||
tostring(p.name or ""), tostring(p.gender == 1 and 1 or 0) }, "|")
|
||||
end
|
||||
|
||||
function Avatars.hostOf(host)
|
||||
local version = type(host) == "table" and host.version or host or GameVersion.get()
|
||||
if not GameVersion.VERSIONS[version or ""] then return nil end
|
||||
return version, GameVersion.generation(version)
|
||||
end
|
||||
|
||||
function Avatars.hostList(version, gender)
|
||||
local gen = GameVersion.generation(version)
|
||||
local lists
|
||||
if gen == 1 then lists = Avatars.HOST_GB1
|
||||
elseif gen == 2 then lists = Avatars.HOST_GB2
|
||||
elseif version == "firered" or version == "leafgreen" then lists = Avatars.HOST_FRLG
|
||||
else lists = Avatars.HOST_RSE end
|
||||
return lists[gender == 1 and 1 or 0]
|
||||
end
|
||||
|
||||
local function hostBase(version, ref)
|
||||
local key = version .. "|" .. tostring(ref)
|
||||
local hit = hostBases[key]
|
||||
if hit ~= nil then return hit or nil end
|
||||
local gen = GameVersion.generation(version)
|
||||
local entry
|
||||
if gen == 3 then
|
||||
entry = gbaEntry(version, "host", { gid = ref })
|
||||
else
|
||||
local sprites = tableAt(version, GB2_SPRITES)
|
||||
local def = sprites and sprites[ref]
|
||||
if type(def) == "table" and def.walker and type(def.image) == "string" then
|
||||
local palette = { mode = "dmg" }
|
||||
if gen == 2 then palette = gb2PaletteFor(version, ref) end
|
||||
if palette then entry = gbEntry(version, gen, def.image, palette) end
|
||||
end
|
||||
end
|
||||
if entry then entry.hostRef = ref end
|
||||
hostBases[key] = entry or false
|
||||
return entry
|
||||
end
|
||||
|
||||
Avatars.hostEntry = hostBase
|
||||
|
||||
local function hostStandin(p, host, need, family)
|
||||
local version = Avatars.hostOf(host)
|
||||
if not version then return nil end
|
||||
local list = Avatars.hostList(version, p.gender)
|
||||
if not list or #list == 0 then return nil end
|
||||
local start = hashOf(Avatars.pickKey(p)) % #list
|
||||
for i = 0, #list - 1 do
|
||||
local base = hostBase(version, list[(start + i) % #list + 1])
|
||||
if base then
|
||||
local out = {}
|
||||
for k, v in pairs(base) do out[k] = v end
|
||||
out.hostStandin = true
|
||||
out.hostVersion = version
|
||||
out.hostGen = base.gen
|
||||
out.gen = GameVersion.VERSIONS[p.game or ""] and GameVersion.generation(p.game) or tonumber(p.gen)
|
||||
out.need = need
|
||||
out.family = family
|
||||
return out
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Avatars.resolve(p, host)
|
||||
local key = Avatars.key(p)
|
||||
local hit = resolved[key]
|
||||
if hit and not hit.hostStandin and not hit.standin then return hit end
|
||||
local hostVersion = Avatars.hostOf(host)
|
||||
local pickKey = "host|" .. tostring(hostVersion) .. "|" .. Avatars.pickKey(p)
|
||||
local picked = resolved[pickKey]
|
||||
if hit and picked then return picked end
|
||||
stats.resolves = stats.resolves + 1
|
||||
local kind, versions = Avatars.family(p)
|
||||
local entry
|
||||
if hit then
|
||||
kind, versions = hit.family, hit.need
|
||||
elseif kind then
|
||||
for _, version in ipairs(versions) do
|
||||
entry = candidate(kind, version, p)
|
||||
if entry then break end
|
||||
end
|
||||
end
|
||||
if entry then
|
||||
entry.key = key
|
||||
entry.family = kind
|
||||
resolved[key] = entry
|
||||
return entry
|
||||
end
|
||||
if not hit then resolved[key] = { key = key, standin = true, family = kind, need = versions } end
|
||||
local gen = GameVersion.VERSIONS[p and p.game or ""] and GameVersion.generation(p.game) or nil
|
||||
entry = type(p) == "table" and hostStandin(p, host, versions, kind) or nil
|
||||
if entry then
|
||||
entry.key = pickKey
|
||||
else
|
||||
entry = { key = pickKey, standin = true, family = kind, gen = gen, need = versions }
|
||||
end
|
||||
resolved[pickKey] = entry
|
||||
return entry
|
||||
end
|
||||
|
||||
function Avatars.pose(entry, facing, walkPhase, stepFlip)
|
||||
facing = facing or "down"
|
||||
if type(entry) ~= "table" or entry.standin then return 0, facing == "right" end
|
||||
local flip = facing == "right"
|
||||
local frame
|
||||
if entry.layout == "gba" then
|
||||
if walkPhase == 1 then
|
||||
frame = stepFlip and Avatars.GBA_WALK_A[facing] or Avatars.GBA_WALK_B[facing]
|
||||
end
|
||||
frame = frame or Avatars.GBA_STAND[facing] or 0
|
||||
else
|
||||
if walkPhase == 1 then
|
||||
frame = Avatars.GB_WALK[facing]
|
||||
if (facing == "down" or facing == "up") and stepFlip then flip = true end
|
||||
end
|
||||
frame = frame or Avatars.GB_STAND[facing] or 0
|
||||
end
|
||||
if frame >= entry.frames then frame = 0 end
|
||||
return frame, flip
|
||||
end
|
||||
|
||||
local function shadeIndex(r)
|
||||
if r > 0.83 then return 0 end
|
||||
if r > 0.5 then return 1 end
|
||||
if r > 0.17 then return 2 end
|
||||
return 3
|
||||
end
|
||||
|
||||
local function bakeShades(id, colors)
|
||||
id:mapPixel(function(_, _, r, g, b, a)
|
||||
local s = shadeIndex(r)
|
||||
if s == 0 then return r, g, b, 0 end
|
||||
local c = colors and colors[s + 1]
|
||||
if not c then return r, g, b, a end
|
||||
return c[1] / 255, c[2] / 255, c[3] / 255, a
|
||||
end)
|
||||
end
|
||||
|
||||
function Avatars.imageData(entry)
|
||||
if type(entry) ~= "table" or entry.standin then return nil end
|
||||
if entry._imageData then return entry._imageData end
|
||||
if not (love and love.image and love.image.newImageData) then return nil end
|
||||
local src = entry.source
|
||||
local id
|
||||
if src.kind == "rgba" then
|
||||
id = love.image.newImageData(entry.sheetW, entry.sheetH, "rgba8", src.bytes)
|
||||
else
|
||||
local data = love.filesystem.newFileData(src.bytes, "avatar.png")
|
||||
id = love.image.newImageData(data)
|
||||
end
|
||||
entry._imageData = id
|
||||
return id
|
||||
end
|
||||
|
||||
function Avatars.image(entry, colors)
|
||||
if type(entry) ~= "table" or entry.standin then return nil end
|
||||
local paletteKey = colors and table.concat({ tostring(colors[2] and colors[2][1]),
|
||||
tostring(colors[3] and colors[3][1]), tostring(colors[4] and colors[4][1]),
|
||||
tostring(colors[2] and colors[2][2]), tostring(colors[3] and colors[3][2]),
|
||||
tostring(colors[4] and colors[4][3]) }, ",") or "own"
|
||||
entry._images = entry._images or {}
|
||||
local img = entry._images[paletteKey]
|
||||
if img then return img end
|
||||
if not (love and love.graphics and love.graphics.newImage) then return nil end
|
||||
local base = Avatars.imageData(entry)
|
||||
if not base then return nil end
|
||||
local id = base
|
||||
if entry.palette.mode ~= "rgba" then
|
||||
id = base.clone and base:clone() or base
|
||||
bakeShades(id, colors or entry.palette.colors)
|
||||
end
|
||||
img = love.graphics.newImage(id)
|
||||
if img.setFilter then img:setFilter("nearest", "nearest") end
|
||||
stats.images = stats.images + 1
|
||||
entry._images[paletteKey] = img
|
||||
return img
|
||||
end
|
||||
|
||||
function Avatars.quads(entry)
|
||||
if type(entry) ~= "table" or entry.standin then return nil end
|
||||
if entry._quads then return entry._quads end
|
||||
if not (love and love.graphics and love.graphics.newQuad) then return nil end
|
||||
local q = {}
|
||||
for i = 0, entry.frames - 1 do
|
||||
local r = entry.rects[i]
|
||||
q[i] = love.graphics.newQuad(r.x, r.y, r.w, r.h, entry.sheetW, entry.sheetH)
|
||||
end
|
||||
entry._quads = q
|
||||
return q
|
||||
end
|
||||
|
||||
function Avatars.draw(entry, footX, footY, facing, walkPhase, stepFlip, scale, colors)
|
||||
scale = scale or 1
|
||||
if type(entry) ~= "table" or entry.standin then return false end
|
||||
local img, quads = Avatars.image(entry, colors), Avatars.quads(entry)
|
||||
if not (img and quads) then return false end
|
||||
local frame, flip = Avatars.pose(entry, facing, walkPhase, stepFlip)
|
||||
local x = footX - entry.anchor.x * scale
|
||||
local y = footY - entry.anchor.y * scale
|
||||
if flip then
|
||||
love.graphics.draw(img, quads[frame], x + entry.w * scale, y, 0, -scale, scale)
|
||||
else
|
||||
love.graphics.draw(img, quads[frame], x, y, 0, scale, scale)
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
Avatars.STANDIN_H = 16
|
||||
|
||||
function Avatars.drawStandin()
|
||||
return false
|
||||
end
|
||||
|
||||
return Avatars
|
||||
@@ -0,0 +1,135 @@
|
||||
local Badge = {}
|
||||
|
||||
Badge.SIZE = 9
|
||||
|
||||
Badge.MASK = {
|
||||
"..#####..",
|
||||
".#.....#.",
|
||||
"#.......#",
|
||||
"#.......#",
|
||||
"#.......#",
|
||||
"#.......#",
|
||||
"#.......#",
|
||||
".#.....#.",
|
||||
"..#####..",
|
||||
}
|
||||
|
||||
Badge.DIGITS = {
|
||||
[1] = { ".#.", "##.", ".#.", ".#.", "###" },
|
||||
[2] = { "##.", "..#", ".#.", "#..", "###" },
|
||||
[3] = { "###", "..#", ".##", "..#", "###" },
|
||||
}
|
||||
|
||||
Badge.DIGIT_X = 3
|
||||
Badge.DIGIT_Y = 2
|
||||
|
||||
Badge.STYLES = {
|
||||
[1] = { ring = { 8, 24, 32 }, fill = { 224, 248, 208 }, digit = { 8, 24, 32 } },
|
||||
[2] = { ring = { 24, 56, 160 }, fill = { 248, 248, 248 }, digit = { 24, 56, 160 } },
|
||||
[3] = { ring = { 56, 56, 64 }, fill = { 255, 255, 255 }, digit = { 208, 48, 48 } },
|
||||
}
|
||||
|
||||
local images = {}
|
||||
local builds = 0
|
||||
|
||||
local function inside(row, col)
|
||||
local line = Badge.MASK[row]
|
||||
local first = line:find("#", 1, true)
|
||||
local last = #line - (line:reverse():find("#", 1, true) or 1) + 1
|
||||
return first ~= nil and col > first and col < last
|
||||
end
|
||||
|
||||
function Badge.styleOf(style)
|
||||
if type(style) == "table" and style.ring and style.fill and style.digit then return style end
|
||||
return Badge.STYLES[tonumber(style) or 3] or Badge.STYLES[3]
|
||||
end
|
||||
|
||||
function Badge.size(scale)
|
||||
local s = math.max(1, math.floor(tonumber(scale) or 1))
|
||||
return Badge.SIZE * s, Badge.SIZE * s
|
||||
end
|
||||
|
||||
function Badge.pixels(digit, style)
|
||||
local st = Badge.styleOf(style)
|
||||
local glyph = Badge.DIGITS[tonumber(digit) or 0]
|
||||
local out = {}
|
||||
for row = 1, Badge.SIZE do
|
||||
local line = Badge.MASK[row]
|
||||
for col = 1, Badge.SIZE do
|
||||
local c = nil
|
||||
if line:sub(col, col) == "#" then
|
||||
c = st.ring
|
||||
elseif inside(row, col) then
|
||||
c = st.fill
|
||||
local gy, gx = row - Badge.DIGIT_Y, col - Badge.DIGIT_X
|
||||
if glyph and gy >= 1 and gy <= 5 and gx >= 1 and gx <= 3 and glyph[gy]:sub(gx, gx) == "#" then
|
||||
c = st.digit
|
||||
end
|
||||
end
|
||||
if c then out[#out + 1] = { x = col - 1, y = row - 1, c = c } end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function styleKey(st)
|
||||
return table.concat({ st.ring[1], st.ring[2], st.ring[3], st.fill[1], st.fill[2], st.fill[3],
|
||||
st.digit[1], st.digit[2], st.digit[3] }, ",")
|
||||
end
|
||||
|
||||
local function imageFor(digit, st)
|
||||
if not (love and love.image and love.image.newImageData and love.graphics.newImage) then return nil end
|
||||
local key = tostring(digit) .. "|" .. styleKey(st)
|
||||
local hit = images[key]
|
||||
if hit then return hit end
|
||||
local id = love.image.newImageData(Badge.SIZE, Badge.SIZE)
|
||||
for _, p in ipairs(Badge.pixels(digit, st)) do
|
||||
id:setPixel(p.x, p.y, p.c[1] / 255, p.c[2] / 255, p.c[3] / 255, 1)
|
||||
end
|
||||
local img = love.graphics.newImage(id)
|
||||
if img.setFilter then img:setFilter("nearest", "nearest") end
|
||||
images[key] = img
|
||||
builds = builds + 1
|
||||
return img
|
||||
end
|
||||
|
||||
function Badge.builds()
|
||||
return builds
|
||||
end
|
||||
|
||||
function Badge.reset()
|
||||
images = {}
|
||||
builds = 0
|
||||
end
|
||||
|
||||
function Badge.draw(x, y, digit, style, scale)
|
||||
if not (love and love.graphics) then return false end
|
||||
if not Badge.DIGITS[tonumber(digit) or 0] then return false end
|
||||
local s = math.max(1, math.floor(tonumber(scale) or 1))
|
||||
local st = Badge.styleOf(style)
|
||||
local img = imageFor(tonumber(digit), st)
|
||||
local g = love.graphics
|
||||
local r0, g0, b0, a0 = 1, 1, 1, 1
|
||||
if g.getColor then r0, g0, b0, a0 = g.getColor() end
|
||||
if img then
|
||||
g.setColor(1, 1, 1, 1)
|
||||
g.draw(img, math.floor(x), math.floor(y), 0, s, s)
|
||||
else
|
||||
for _, p in ipairs(Badge.pixels(digit, st)) do
|
||||
g.setColor(p.c[1] / 255, p.c[2] / 255, p.c[3] / 255, 1)
|
||||
g.rectangle("fill", math.floor(x) + p.x * s, math.floor(y) + p.y * s, s, s)
|
||||
end
|
||||
end
|
||||
g.setColor(r0, g0, b0, a0)
|
||||
return true
|
||||
end
|
||||
|
||||
function Badge.drawBeside(nameRight, nameTop, nameHeight, digit, style, scale, gap)
|
||||
local w, h = Badge.size(scale)
|
||||
local x = nameRight + (gap or 1) * math.max(1, math.floor(tonumber(scale) or 1))
|
||||
local y = nameTop + math.floor(((nameHeight or h) - h) / 2)
|
||||
Badge.draw(x, y, digit, style, scale)
|
||||
return x + w, y, w, h
|
||||
end
|
||||
|
||||
return Badge
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,598 @@
|
||||
local Table = require("src.battle.g3u.Table")
|
||||
local Match = require("src.battle.g3u.Match")
|
||||
local Wire = require("src.battle.g3u.Wire")
|
||||
local Policy = require("src.online.xgen.Policy")
|
||||
|
||||
local BattleSession = {}
|
||||
BattleSession.__index = BattleSession
|
||||
|
||||
BattleSession.SETUP_TIMEOUT = 60
|
||||
BattleSession.RESUME_WINDOW = 120
|
||||
BattleSession.PEER_GONE = 45
|
||||
BattleSession.FINAL_HASH_WAIT = 5
|
||||
|
||||
local OWN = {}
|
||||
for _, t in ipairs(Wire.TYPES) do OWN[t] = true end
|
||||
OWN.xg_closed = true
|
||||
|
||||
local REPORT = { win = "win", lose = "lose", draw = "draw" }
|
||||
|
||||
local function clock()
|
||||
if love and love.timer and love.timer.getTime then return love.timer.getTime() end
|
||||
return os.clock()
|
||||
end
|
||||
|
||||
local function copyAct(a)
|
||||
return { kind = a.kind, slot = a.slot, index = a.index }
|
||||
end
|
||||
|
||||
local function msgBytes(m)
|
||||
local ok, n = pcall(Wire.size, m)
|
||||
return ok and n or math.huge
|
||||
end
|
||||
|
||||
function BattleSession.lowerSeat(gens)
|
||||
local g0, g1 = tonumber(gens and gens[0]), tonumber(gens and gens[1])
|
||||
if g0 and g1 and g1 < g0 then return 1 end
|
||||
return 0
|
||||
end
|
||||
|
||||
-- pokefirered/src/pokemon.c:2845
|
||||
local function hpRange(base, level)
|
||||
local lo = math.floor((2 * base) * level / 100) + level + 10
|
||||
local hi = math.floor((2 * base + 31 + 63) * level / 100) + level + 10
|
||||
return lo, hi
|
||||
end
|
||||
|
||||
-- pokefirered/src/pokemon.c:2814
|
||||
local function statRange(base, level)
|
||||
local lo = math.floor((2 * base) * level / 100) + 5
|
||||
local hi = math.floor((2 * base + 31 + 63) * level / 100) + 5
|
||||
return lo, hi
|
||||
end
|
||||
|
||||
local STAT_KEYS = { { "atk", "atk" }, { "def", "def" }, { "speed", "spe" }, { "spAtk", "spa" }, { "spDef", "spd" } }
|
||||
|
||||
function BattleSession.checkParty(records, t, opts)
|
||||
opts = opts or {}
|
||||
local msg = Wire.party(records)
|
||||
local ok, why = Wire.validate(msg, { table = t, bytes = msgBytes(msg) })
|
||||
if not ok then return nil, why end
|
||||
if opts.size and #records > opts.size then return nil, "party_size" end
|
||||
local loose = opts.senderGen ~= nil and t.gen == 1 and opts.senderGen > 1
|
||||
for i, r in ipairs(records) do
|
||||
local base = Table.baseStats(t, r.species)
|
||||
if r.hp ~= r.maxHp then return nil, "record_" .. i .. ":hp_not_full" end
|
||||
local lo, hi = hpRange(base.hp, r.level)
|
||||
if r.maxHp < lo or r.maxHp > hi then return nil, "record_" .. i .. ":hp_range" end
|
||||
for _, pair in ipairs(STAT_KEYS) do
|
||||
local key, b = pair[1], pair[2]
|
||||
if not (loose and (b == "spa" or b == "spd")) then
|
||||
local slo, shi = statRange(base[b], r.level)
|
||||
if r[key] < slo or r[key] > shi then return nil, "record_" .. i .. ":" .. key .. "_range" end
|
||||
end
|
||||
end
|
||||
for j, mv in ipairs(r.moves) do
|
||||
local full = math.min(64, Policy.maxPp(t.moves[mv.id][4], mv.ppUps or 0))
|
||||
if mv.pp > full then return nil, "record_" .. i .. ":pp_" .. j end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local GENDER_CODE = { male = 0, M = 0, female = 1, F = 1, unknown = 2, U = 2, genderless = 2 }
|
||||
local IV_KEYS = { "hp", "atk", "def", "spe", "spa", "spd" }
|
||||
|
||||
function BattleSession.wireRecord(mon)
|
||||
if type(mon) ~= "table" then return nil end
|
||||
local moves = {}
|
||||
for _, m in ipairs(mon.moves or {}) do
|
||||
local id = type(m) == "table" and (m.id or m.move) or m
|
||||
if tonumber(id) then
|
||||
moves[#moves + 1] = { id = tonumber(id), pp = tonumber(type(m) == "table" and m.pp) or 0,
|
||||
ppUps = tonumber(type(m) == "table" and m.ppUps) or 0 }
|
||||
end
|
||||
end
|
||||
local r = {
|
||||
species = tonumber(mon.species) or tonumber(mon.national), level = mon.level, hp = mon.hp, maxHp = mon.maxHp,
|
||||
atk = mon.atk, def = mon.def, spAtk = mon.spAtk, spDef = mon.spDef, speed = mon.speed, moves = moves,
|
||||
}
|
||||
if type(mon.nickname) == "string" and mon.nickname ~= "" then r.nickname = mon.nickname end
|
||||
local g = mon.gender
|
||||
if type(g) == "string" then g = GENDER_CODE[g] end
|
||||
if type(g) == "number" then r.gender = g end
|
||||
if tonumber(mon.friendship) then r.friendship = tonumber(mon.friendship) end
|
||||
if type(mon.ivs) == "table" then
|
||||
r.ivs = {}
|
||||
for _, k in ipairs(IV_KEYS) do r.ivs[k] = tonumber(mon.ivs[k]) or 0 end
|
||||
end
|
||||
return r
|
||||
end
|
||||
|
||||
function BattleSession.wireRecords(list)
|
||||
local out = {}
|
||||
for i, mon in ipairs(list or {}) do out[i] = BattleSession.wireRecord(mon) end
|
||||
return out
|
||||
end
|
||||
|
||||
function BattleSession.new(opts)
|
||||
assert(type(opts) == "table", "BattleSession.new needs opts")
|
||||
assert(type(opts.net) == "table", "BattleSession.new needs a net")
|
||||
local seat = tonumber(opts.seat)
|
||||
assert(seat == 0 or seat == 1, "BattleSession.new needs seat 0 or 1")
|
||||
local go = opts.go or {}
|
||||
local gens = opts.gens or {}
|
||||
local self = setmetatable({
|
||||
net = opts.net,
|
||||
seat = seat,
|
||||
peer = 1 - seat,
|
||||
go = go,
|
||||
seed = tonumber(go.seed) or 0,
|
||||
size = tonumber(go.size),
|
||||
gens = { [0] = tonumber(gens[0]), [1] = tonumber(gens[1]) },
|
||||
data = opts.data,
|
||||
records = type(opts.records) == "table" and BattleSession.wireRecords(opts.records) or nil,
|
||||
names = opts.names or {},
|
||||
client = opts.client,
|
||||
linkState = opts.linkState,
|
||||
now = opts.now or clock,
|
||||
timeouts = {
|
||||
setup = (opts.timeouts and opts.timeouts.setup) or BattleSession.SETUP_TIMEOUT,
|
||||
resume = (opts.timeouts and opts.timeouts.resume) or BattleSession.RESUME_WINDOW,
|
||||
peer = (opts.timeouts and opts.timeouts.peer) or BattleSession.PEER_GONE,
|
||||
final = (opts.timeouts and opts.timeouts.final) or BattleSession.FINAL_HASH_WAIT,
|
||||
},
|
||||
phase = "setup",
|
||||
queue = {},
|
||||
table = opts.table,
|
||||
peerRecords = nil,
|
||||
match = nil,
|
||||
mine = {},
|
||||
theirs = {},
|
||||
myRep = nil,
|
||||
theirReps = {},
|
||||
hashSent = {},
|
||||
myHashes = {},
|
||||
peerHashes = {},
|
||||
result = nil,
|
||||
reported = false,
|
||||
log = {},
|
||||
}, BattleSession)
|
||||
self.lower = BattleSession.lowerSeat(self.gens)
|
||||
self.tableGen = math.min(self.gens[0] or 3, self.gens[1] or 3)
|
||||
self.startedAt = self.now()
|
||||
self:_begin()
|
||||
return self
|
||||
end
|
||||
|
||||
function BattleSession:isLower()
|
||||
return self.seat == self.lower
|
||||
end
|
||||
|
||||
function BattleSession:_note(fmt, ...)
|
||||
local line = select("#", ...) > 0 and fmt:format(...) or fmt
|
||||
self.log[#self.log + 1] = line
|
||||
end
|
||||
|
||||
function BattleSession:_push(ev)
|
||||
self.queue[#self.queue + 1] = ev
|
||||
end
|
||||
|
||||
function BattleSession:events()
|
||||
local out = self.queue
|
||||
self.queue = {}
|
||||
return out
|
||||
end
|
||||
|
||||
function BattleSession:_send(msg)
|
||||
local net = self.net
|
||||
if net.closed then return false end
|
||||
net:send(msg)
|
||||
return true
|
||||
end
|
||||
|
||||
function BattleSession:_begin()
|
||||
if self:isLower() then
|
||||
local t, why = self.table, nil
|
||||
if not t then
|
||||
if type(self.data) ~= "table" then return self:_fail("bad_table", "no_data") end
|
||||
t, why = Table.build(self.data, self.tableGen)
|
||||
if not t then return self:_fail("bad_table", why) end
|
||||
end
|
||||
local ok, vwhy = Table.validate(t, self.tableGen)
|
||||
if not ok then return self:_fail("bad_table", vwhy) end
|
||||
self.table = t
|
||||
end
|
||||
if type(self.records) ~= "table" or #self.records == 0 then return self:_fail("bad_party", "no_records") end
|
||||
if self.table then
|
||||
local ok, why = BattleSession.checkParty(self.records, self.table, { size = self.size, senderGen = self.gens[self.seat] })
|
||||
if not ok then return self:_fail("bad_party", why) end
|
||||
self:_send(Wire.table(self.table))
|
||||
end
|
||||
self:_send(Wire.party(self.records))
|
||||
end
|
||||
|
||||
function BattleSession:_finish(outcome, why, opts)
|
||||
if self.result then return end
|
||||
opts = opts or {}
|
||||
self.result = { outcome = outcome, why = why, detail = opts.detail }
|
||||
self.phase = "over"
|
||||
if opts.bye then self:_send(Wire.bye(opts.bye)) end
|
||||
self:_note("over %s %s %s", tostring(outcome), tostring(why), tostring(opts.detail))
|
||||
self:_push({ kind = "over", outcome = outcome, why = why, detail = opts.detail })
|
||||
self:_report()
|
||||
end
|
||||
|
||||
function BattleSession:_fail(why, detail)
|
||||
local bye = Wire.BYE[why] and why or "error"
|
||||
self:_finish("draw", why, { bye = bye, detail = detail })
|
||||
end
|
||||
|
||||
function BattleSession:_report()
|
||||
if self.reported then return end
|
||||
local r = self.result
|
||||
if not r or r.why == "disconnect" then return end
|
||||
self.reported = true
|
||||
local client = self.client
|
||||
if type(client) ~= "table" then return end
|
||||
local word = REPORT[r.outcome] or "draw"
|
||||
local fn = client.report
|
||||
if type(fn) == "function" then pcall(fn, word) end
|
||||
end
|
||||
|
||||
function BattleSession:_mapEnd(result)
|
||||
if result.draw then return "draw" end
|
||||
return result.winner == self.seat and "win" or "lose"
|
||||
end
|
||||
|
||||
function BattleSession:_absorb(events)
|
||||
for _, ev in ipairs(events or {}) do
|
||||
self:_push(ev)
|
||||
end
|
||||
local m = self.match
|
||||
local h = m:hash(m.turn)
|
||||
if h and not self.hashSent[m.turn] then
|
||||
self.hashSent[m.turn] = true
|
||||
self.myHashes[m.turn] = h
|
||||
local out = h
|
||||
if self.tamperHash then out = self.tamperHash(m.turn, h) or h end
|
||||
self:_send(Wire.hash(m.turn, out))
|
||||
end
|
||||
self:_checkHashes()
|
||||
if self.result then return end
|
||||
if m.phase == "over" then
|
||||
self.overAt = self.overAt or self.now()
|
||||
self.matchResult = m.result
|
||||
self.phase = "ending"
|
||||
self:_tryEnd()
|
||||
return
|
||||
end
|
||||
if m.phase == "choose" then
|
||||
self.phase = "choose"
|
||||
self:_push({ kind = "prompt", what = "move", turn = m.turn + 1 })
|
||||
elseif m.phase == "replace" then
|
||||
if m:needs(self.seat) then
|
||||
self.phase = "replace"
|
||||
self:_push({ kind = "prompt", what = "replace", turn = m.turn, reason = m.need[self.seat].kind })
|
||||
else
|
||||
self.phase = "replace_wait"
|
||||
self:_push({ kind = "waiting", what = "replace" })
|
||||
end
|
||||
self:_tryReplace()
|
||||
end
|
||||
end
|
||||
|
||||
function BattleSession:_tryEnd()
|
||||
if self.phase ~= "ending" or self.result then return end
|
||||
local m = self.match
|
||||
local turn = m.turn
|
||||
local theirs = self.peerHashes[turn]
|
||||
if theirs == nil and self.now() - self.overAt < self.timeouts.final then return end
|
||||
local r = self.matchResult
|
||||
self:_finish(self:_mapEnd(r), r.why, { winner = r.winner })
|
||||
end
|
||||
|
||||
function BattleSession:_checkHashes()
|
||||
for turn, mine in pairs(self.myHashes) do
|
||||
local theirs = self.peerHashes[turn]
|
||||
if theirs ~= nil and theirs ~= mine then
|
||||
self:_note("desync turn %d mine %s theirs %s", turn, mine, theirs)
|
||||
self:_finish("draw", "desync", { bye = "desync", detail = turn })
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function BattleSession:_tryStart()
|
||||
if self.match or self.result then return end
|
||||
if not (self.table and self.peerRecords) then return end
|
||||
local parties = { [self.seat] = self.records, [self.peer] = self.peerRecords }
|
||||
local ok, m = pcall(Match.new, { table = self.table, parties = parties, seed = self.seed,
|
||||
names = self.names })
|
||||
if not ok then return self:_fail("error", tostring(m)) end
|
||||
self.match = m
|
||||
self:_push({ kind = "ready", seat = self.seat, table = self.table, parties = parties,
|
||||
names = self.names, tableGen = self.table.gen })
|
||||
local okS, events = pcall(m.start, m)
|
||||
if not okS then return self:_fail("error", tostring(events)) end
|
||||
self:_absorb(events)
|
||||
end
|
||||
|
||||
function BattleSession:_onTable(msg)
|
||||
if self:isLower() then return self:_fail("bad_table", "from_higher_seat") end
|
||||
if self.table then return self:_fail("bad_table", "duplicate") end
|
||||
local ok, why = Wire.validate(msg, { gen = self.tableGen, bytes = msgBytes(msg) })
|
||||
if not ok then return self:_fail("bad_table", why) end
|
||||
self.table = msg.table
|
||||
local okP, pwhy = BattleSession.checkParty(self.records, self.table, { size = self.size, senderGen = self.gens[self.seat] })
|
||||
if not okP then return self:_fail("bad_party", pwhy) end
|
||||
if self.peerRecords then
|
||||
local okQ, qwhy = BattleSession.checkParty(self.peerRecords, self.table,
|
||||
{ size = self.size, senderGen = self.gens[self.peer] })
|
||||
if not okQ then return self:_fail("bad_party", qwhy) end
|
||||
end
|
||||
self:_tryStart()
|
||||
end
|
||||
|
||||
function BattleSession:_onParty(msg)
|
||||
if self.peerRecords then return self:_fail("bad_party", "duplicate") end
|
||||
if type(msg.records) ~= "table" then return self:_fail("bad_party", "shape") end
|
||||
if msgBytes(msg) > Wire.MAX_BYTES.g3u_party then return self:_fail("bad_party", "too_big") end
|
||||
if self.table then
|
||||
local ok, why = BattleSession.checkParty(msg.records, self.table,
|
||||
{ size = self.size, senderGen = self.gens[self.peer] })
|
||||
if not ok then return self:_fail("bad_party", why) end
|
||||
end
|
||||
self.peerRecords = msg.records
|
||||
self:_tryStart()
|
||||
end
|
||||
|
||||
function BattleSession:_tryResolve()
|
||||
local m = self.match
|
||||
if not m or m.phase ~= "choose" or self.result then return end
|
||||
local turn = m.turn + 1
|
||||
local mine, theirs = self.mine[turn], self.theirs[turn]
|
||||
if not (mine and theirs) then return end
|
||||
if not m:isLegal(self.peer, theirs) then
|
||||
return self:_finish("draw", "illegal", { bye = "illegal", detail = turn })
|
||||
end
|
||||
local acts = { [self.seat] = copyAct(mine), [self.peer] = copyAct(theirs) }
|
||||
local ok, events = pcall(m.submit, m, acts)
|
||||
if not ok then return self:_fail("error", tostring(events)) end
|
||||
self:_absorb(events)
|
||||
end
|
||||
|
||||
function BattleSession:_tryReplace()
|
||||
local m = self.match
|
||||
if not m or m.phase ~= "replace" or self.result then return end
|
||||
local picks = {}
|
||||
for s = 0, 1 do
|
||||
if m:needs(s) then
|
||||
if s == self.seat then
|
||||
if not self.myRep then return end
|
||||
picks[s] = self.myRep
|
||||
else
|
||||
local rep = self.theirReps[1]
|
||||
if not rep then return end
|
||||
if not m:isLegal(s, { kind = "switch", index = rep.index }) then
|
||||
return self:_finish("draw", "illegal", { bye = "illegal", detail = m.turn })
|
||||
end
|
||||
picks[s] = rep.index
|
||||
end
|
||||
end
|
||||
end
|
||||
if picks[self.seat] then self.myRep = nil end
|
||||
if picks[self.peer] then table.remove(self.theirReps, 1) end
|
||||
local ok, events = pcall(m.replace, m, picks)
|
||||
if not ok then return self:_fail("error", tostring(events)) end
|
||||
self:_absorb(events)
|
||||
end
|
||||
|
||||
function BattleSession:_onBye(msg)
|
||||
local why = msg.why
|
||||
if why == "forfeit" or why == "quit" or why == "timeout" then
|
||||
return self:_finish("win", "forfeit")
|
||||
end
|
||||
if why == "desync" then return self:_finish("draw", "desync") end
|
||||
if why == "illegal" then return self:_finish("draw", "illegal") end
|
||||
if why == "bad_table" or why == "bad_party" then return self:_finish("draw", why, { detail = "peer" }) end
|
||||
return self:_finish("draw", "error", { detail = why })
|
||||
end
|
||||
|
||||
function BattleSession:_handle(msg)
|
||||
local t = msg.type
|
||||
if t == "xg_closed" then
|
||||
return self:_finish("draw", "disconnect", { detail = msg.why })
|
||||
end
|
||||
if msg.seat ~= nil and msg.seat ~= -1 and msg.seat ~= self.peer then return end
|
||||
local bytes = msgBytes(msg)
|
||||
local clean = {}
|
||||
for k, v in pairs(msg) do
|
||||
if k ~= "seat" and k ~= "relay" then clean[k] = v end
|
||||
end
|
||||
if t == "g3u_table" then return self:_onTable(clean) end
|
||||
if t == "g3u_party" then return self:_onParty(clean) end
|
||||
local ok, why = Wire.validate(clean, { bytes = bytes })
|
||||
if not ok then
|
||||
self:_note("drop %s %s", tostring(t), tostring(why))
|
||||
return self:_finish("draw", "illegal", { bye = "illegal", detail = t .. ":" .. tostring(why) })
|
||||
end
|
||||
if t == "g3u_bye" then return self:_onBye(clean) end
|
||||
if t == "g3u_hash" then
|
||||
if self.peerHashes[clean.turn] == nil then self.peerHashes[clean.turn] = clean.hash end
|
||||
self:_checkHashes()
|
||||
self:_tryEnd()
|
||||
return
|
||||
end
|
||||
if not self.match then return self:_fail("error", "early_" .. t) end
|
||||
if t == "g3u_action" then
|
||||
local m = self.match
|
||||
if clean.turn ~= m.turn + 1 or self.theirs[clean.turn] then
|
||||
return self:_finish("draw", "illegal", { bye = "illegal", detail = "turn" })
|
||||
end
|
||||
local act = Wire.toAction(clean)
|
||||
if m.phase == "choose" and not m:isLegal(self.peer, act) then
|
||||
return self:_finish("draw", "illegal", { bye = "illegal", detail = clean.turn })
|
||||
end
|
||||
self.theirs[clean.turn] = act
|
||||
self:_tryResolve()
|
||||
elseif t == "g3u_replace" then
|
||||
if clean.turn ~= self.match.turn then
|
||||
return self:_finish("draw", "illegal", { bye = "illegal", detail = "replace_turn" })
|
||||
end
|
||||
self.theirReps[#self.theirReps + 1] = { index = clean.index, turn = clean.turn }
|
||||
self:_tryReplace()
|
||||
end
|
||||
end
|
||||
|
||||
local function wanted(msg)
|
||||
return type(msg) == "table" and OWN[msg.type] == true
|
||||
end
|
||||
|
||||
function BattleSession:_drain()
|
||||
local net = self.net
|
||||
if type(net.takeWhere) == "function" then
|
||||
for _ = 1, 256 do
|
||||
if self.result then return end
|
||||
local msg = net:takeWhere(wanted)
|
||||
if not msg then return end
|
||||
self:_handle(msg)
|
||||
end
|
||||
return
|
||||
end
|
||||
if type(net.poll) == "function" then
|
||||
for _, msg in ipairs(net:poll() or {}) do
|
||||
if self.result then return end
|
||||
if wanted(msg) then self:_handle(msg) end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function BattleSession:_link()
|
||||
if self.linkState then return self.linkState() end
|
||||
if self.net.closed then return "gone" end
|
||||
return "ok"
|
||||
end
|
||||
|
||||
function BattleSession:_watchLink()
|
||||
local state = self:_link()
|
||||
local now = self.now()
|
||||
if state == "gone" then
|
||||
if self.phase == "ending" then
|
||||
local r = self.matchResult
|
||||
return self:_finish(self:_mapEnd(r), r.why)
|
||||
end
|
||||
return self:_finish("draw", "disconnect")
|
||||
end
|
||||
if state == "resuming" then
|
||||
self.resumingSince = self.resumingSince or now
|
||||
if now - self.resumingSince > self.timeouts.resume then return self:_finish("draw", "disconnect") end
|
||||
else
|
||||
self.resumingSince = nil
|
||||
end
|
||||
local online = true
|
||||
if type(self.net.peerOnline) == "function" then
|
||||
local ok, v = pcall(self.net.peerOnline, self.net, self.peer)
|
||||
online = not ok or v ~= false
|
||||
end
|
||||
if online then
|
||||
self.peerGoneSince = nil
|
||||
else
|
||||
self.peerGoneSince = self.peerGoneSince or now
|
||||
if now - self.peerGoneSince > self.timeouts.peer then return self:_finish("draw", "disconnect") end
|
||||
end
|
||||
if not self.match and now - self.startedAt > self.timeouts.setup then
|
||||
return self:_finish("draw", "disconnect", { bye = "timeout", detail = "setup" })
|
||||
end
|
||||
end
|
||||
|
||||
function BattleSession:update()
|
||||
if self.result then return end
|
||||
if type(self.net.update) == "function" then pcall(self.net.update, self.net) end
|
||||
self:_drain()
|
||||
if self.result then return end
|
||||
self:_tryStart()
|
||||
self:_tryEnd()
|
||||
if self.result then return end
|
||||
self:_watchLink()
|
||||
end
|
||||
|
||||
function BattleSession:legal()
|
||||
local m = self.match
|
||||
if not m or self.result then return {} end
|
||||
if self.phase == "choose" or self.phase == "replace" then return m:legalActions(self.seat) end
|
||||
return {}
|
||||
end
|
||||
|
||||
function BattleSession:choose(act)
|
||||
local m = self.match
|
||||
if self.phase ~= "choose" or not m then return false, "not_choosing" end
|
||||
if not m:isLegal(self.seat, act) then return false, "illegal" end
|
||||
local turn = m.turn + 1
|
||||
self.mine[turn] = copyAct(act)
|
||||
self:_send(Wire.action(turn, act))
|
||||
self.phase = "wait"
|
||||
self:_push({ kind = "waiting", what = "move" })
|
||||
self:_tryResolve()
|
||||
return true
|
||||
end
|
||||
|
||||
function BattleSession:pickReplacement(index)
|
||||
local m = self.match
|
||||
if self.phase ~= "replace" or not m then return false, "not_replacing" end
|
||||
local act = { kind = "switch", index = index }
|
||||
if not m:isLegal(self.seat, act) then return false, "illegal" end
|
||||
self.myRep = index
|
||||
self:_send(Wire.replace(m.turn, index))
|
||||
self.phase = "replace_wait"
|
||||
self:_push({ kind = "waiting", what = "replace" })
|
||||
self:_tryReplace()
|
||||
return true
|
||||
end
|
||||
|
||||
function BattleSession:forfeit()
|
||||
if self.result then return false end
|
||||
if self.phase == "choose" then return self:choose({ kind = "forfeit" }) end
|
||||
self:_finish("lose", "forfeit", { bye = "forfeit" })
|
||||
return true
|
||||
end
|
||||
|
||||
function BattleSession:quit()
|
||||
if self.result then return false end
|
||||
self:_finish("lose", "forfeit", { bye = "quit" })
|
||||
return true
|
||||
end
|
||||
|
||||
function BattleSession:side(seat)
|
||||
if seat == nil then return nil end
|
||||
return seat == self.seat and "me" or "foe"
|
||||
end
|
||||
|
||||
function BattleSession:myParty()
|
||||
return self.match and self.match:party(self.seat) or nil
|
||||
end
|
||||
|
||||
function BattleSession:foeParty()
|
||||
return self.match and self.match:party(self.peer) or nil
|
||||
end
|
||||
|
||||
function BattleSession:activeMon(seat)
|
||||
local st = self.match and self.match.st
|
||||
if not st then return nil end
|
||||
local b = require("src.core.game3.battle.state").battler(st, seat)
|
||||
return b and b.mon, b
|
||||
end
|
||||
|
||||
function BattleSession:active(seat)
|
||||
return self.match and self.match:active(seat) or nil
|
||||
end
|
||||
|
||||
function BattleSession:over()
|
||||
return self.result ~= nil
|
||||
end
|
||||
|
||||
function BattleSession:close()
|
||||
if not self.result then self:quit() end
|
||||
end
|
||||
|
||||
return BattleSession
|
||||
@@ -0,0 +1,59 @@
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Policy = require("src.online.xgen.Policy")
|
||||
|
||||
local Caps = {}
|
||||
|
||||
local NO_MODS = {}
|
||||
|
||||
function Caps.gameplayMods(game)
|
||||
if type(game) ~= "table" or not game.mods then return false end
|
||||
local ok, ArenaData = pcall(require, "src.online.ArenaData")
|
||||
if not ok then return true end
|
||||
local okB, blockers = pcall(ArenaData.onlineBlockers3, game)
|
||||
if not okB or type(blockers) ~= "table" then return true end
|
||||
return #blockers > 0
|
||||
end
|
||||
|
||||
function Caps.fingerprint(data, gen)
|
||||
if type(data) ~= "table" then return nil end
|
||||
local Fingerprint = require("src.link.Fingerprint")
|
||||
local ok, fp = pcall(Fingerprint.compute, data, NO_MODS, gen)
|
||||
if not ok or type(fp) ~= "string" then return nil end
|
||||
return fp:lower()
|
||||
end
|
||||
|
||||
function Caps.compute(ctx)
|
||||
ctx = type(ctx) == "table" and ctx or {}
|
||||
local version = ctx.version
|
||||
local gen = GameVersion.VERSIONS[version or ""] and GameVersion.generation(version) or nil
|
||||
local out = { proto = Policy.PROTO, policy = Policy.VERSION, gens = {} }
|
||||
if not gen or (ctx.gen ~= nil and ctx.gen ~= gen) then return out end
|
||||
local blocked = ctx.gameplayMods
|
||||
if blocked == nil then blocked = Caps.gameplayMods(ctx.game) end
|
||||
if blocked then return out end
|
||||
local fp = ctx.vanillaFingerprint or Caps.fingerprint(ctx.data or (ctx.game and ctx.game.data), gen)
|
||||
if type(fp) ~= "string" or not fp:match("^[0-9a-f]+$") or #fp > 64 then return out end
|
||||
out.gens[tostring(gen)] = { { version = version, fp = fp } }
|
||||
return out
|
||||
end
|
||||
|
||||
function Caps.list(caps, gen)
|
||||
if type(caps) ~= "table" or type(caps.gens) ~= "table" then return {} end
|
||||
return caps.gens[tostring(gen)] or caps.gens[gen] or {}
|
||||
end
|
||||
|
||||
function Caps.has(caps, gen, version, fp)
|
||||
for _, e in ipairs(Caps.list(caps, gen)) do
|
||||
if e.version == version and (fp == nil or e.fp == fp) then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function Caps.empty(caps)
|
||||
for gen = 1, 3 do
|
||||
if #Caps.list(caps, gen) > 0 then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
return Caps
|
||||
@@ -0,0 +1,36 @@
|
||||
local Origin = {}
|
||||
|
||||
Origin.KEY = "unionOrigin"
|
||||
|
||||
local FIELDS = { map = "string", x = "number", y = "number" }
|
||||
|
||||
local function valid(o)
|
||||
if type(o) ~= "table" then return false end
|
||||
for k, t in pairs(FIELDS) do
|
||||
if type(o[k]) ~= t then return false end
|
||||
end
|
||||
return o.gen == 1 or o.gen == 2 or o.gen == 3
|
||||
end
|
||||
|
||||
function Origin.record(save, o)
|
||||
if type(save) ~= "table" then return nil end
|
||||
local rec = {
|
||||
gen = o.gen, version = o.version, map = o.map, warp = o.warp,
|
||||
x = o.x, y = o.y, facing = o.facing, at = o.at or os.time(),
|
||||
}
|
||||
if not valid(rec) then return nil end
|
||||
save[Origin.KEY] = rec
|
||||
return rec
|
||||
end
|
||||
|
||||
function Origin.get(save)
|
||||
local o = type(save) == "table" and save[Origin.KEY] or nil
|
||||
if valid(o) then return o end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Origin.clear(save)
|
||||
if type(save) == "table" then save[Origin.KEY] = nil end
|
||||
end
|
||||
|
||||
return Origin
|
||||
@@ -0,0 +1,121 @@
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Wire = require("src.link.Wire")
|
||||
|
||||
local Participant = {}
|
||||
|
||||
Participant.NAME_MAX = 10
|
||||
Participant.SLOT_MAX = 40
|
||||
Participant.DEFAULT_STYLE = "player"
|
||||
|
||||
local function knownVersion(version)
|
||||
return type(version) == "string" and GameVersion.VERSIONS[version] ~= nil
|
||||
end
|
||||
|
||||
function Participant.genOf(version)
|
||||
if not knownVersion(version) then return nil end
|
||||
return GameVersion.generation(version)
|
||||
end
|
||||
|
||||
local function caps(v)
|
||||
if type(v) ~= "table" then return nil end
|
||||
return Wire.caps(v)
|
||||
end
|
||||
|
||||
local function groupActivity(row)
|
||||
local g = type(row.group) == "table" and row.group or nil
|
||||
return g and g.activity or nil
|
||||
end
|
||||
|
||||
function Participant.fromMember(row)
|
||||
if type(row) ~= "table" then return nil, "not a row" end
|
||||
local id = Wire.playerId(row.id)
|
||||
if not id then return nil, "no id" end
|
||||
local slot = tonumber(row.slot)
|
||||
if not slot or slot ~= math.floor(slot) or slot < 1 or slot > Participant.SLOT_MAX then
|
||||
return nil, "no slot"
|
||||
end
|
||||
local av = type(row.avatar) == "table" and row.avatar or nil
|
||||
local version = av and av.version or nil
|
||||
if not knownVersion(version) then return nil, "no version" end
|
||||
local family = GameVersion.generation(version)
|
||||
local gen = av.gen
|
||||
local legacy = false
|
||||
if gen == nil then
|
||||
legacy = true
|
||||
gen = family
|
||||
elseif gen ~= family then
|
||||
return nil, "gen mismatch"
|
||||
end
|
||||
local name = av.name
|
||||
if type(name) ~= "string" or name == "" then name = row.name end
|
||||
return {
|
||||
slot = slot,
|
||||
id = id,
|
||||
name = type(name) == "string" and Wire.chars(name, "", Participant.NAME_MAX) or "",
|
||||
verified = row.verified == true,
|
||||
game = version,
|
||||
gen = gen,
|
||||
trainerId = math.floor(tonumber(av.trainerId) or 0) % 65536,
|
||||
gender = av.gender == 1 and 1 or 0,
|
||||
style = Wire.avatarStyle(av.style) or Participant.DEFAULT_STYLE,
|
||||
status = type(row.status) == "string" and row.status or "idle",
|
||||
activity = groupActivity(row),
|
||||
group = row.group,
|
||||
online = row.online ~= false,
|
||||
caps = caps(row.caps),
|
||||
board = row.board,
|
||||
legacy = legacy,
|
||||
}
|
||||
end
|
||||
|
||||
function Participant.badgeDigit(p)
|
||||
if type(p) ~= "table" then return nil end
|
||||
local gen = tonumber(p.gen)
|
||||
if gen == 1 or gen == 2 or gen == 3 then return gen end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Participant.busy(p)
|
||||
if type(p) ~= "table" then return true end
|
||||
if p.online == false then return true end
|
||||
return p.status ~= "idle" and p.status ~= "recruiting" and p.status ~= "waiting"
|
||||
end
|
||||
|
||||
function Participant.same(a, b)
|
||||
if type(a) ~= "table" or type(b) ~= "table" then return false end
|
||||
for _, k in ipairs({ "id", "slot", "name", "game", "gen", "trainerId", "gender", "style",
|
||||
"status", "activity", "online", "legacy", "verified" }) do
|
||||
if a[k] ~= b[k] then return false end
|
||||
end
|
||||
local ca, cb = a.caps, b.caps
|
||||
if (ca == nil) ~= (cb == nil) then return false end
|
||||
if ca and (ca.proto ~= cb.proto or ca.policy ~= cb.policy) then return false end
|
||||
if ca then
|
||||
for gen = 1, 3 do
|
||||
local la, lb = ca.gens[tostring(gen)], cb.gens[tostring(gen)]
|
||||
if (la == nil) ~= (lb == nil) then return false end
|
||||
if la then
|
||||
if #la ~= #lb then return false end
|
||||
for i = 1, #la do
|
||||
if la[i].version ~= lb[i].version or la[i].fp ~= lb[i].fp then return false end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function Participant.wireAvatar(fields)
|
||||
fields = type(fields) == "table" and fields or {}
|
||||
local style = Wire.avatarStyle(fields.style) or Participant.DEFAULT_STYLE
|
||||
return {
|
||||
name = Wire.chars(tostring(fields.name or ""), "", Participant.NAME_MAX),
|
||||
trainerId = math.floor(tonumber(fields.trainerId) or 0) % 65536,
|
||||
gender = (fields.gender == 1 or fields.gender == "female") and 1 or 0,
|
||||
version = fields.version,
|
||||
style = style,
|
||||
canLinkNationally = type(fields.canLinkNationally) == "boolean" and fields.canLinkNationally or nil,
|
||||
}
|
||||
end
|
||||
|
||||
return Participant
|
||||
@@ -0,0 +1,363 @@
|
||||
local Protocol2 = require("src.online.Protocol2")
|
||||
local Wire = require("src.link.Wire")
|
||||
|
||||
local Prep = {}
|
||||
Prep.__index = Prep
|
||||
|
||||
Prep.STATES = { rules_wait = true, blocked = true, prep = true, go = true, closed = true }
|
||||
|
||||
local BUMPING = { xg_roster = true, xg_size_req = true, xg_offer = true, xg_counter = true }
|
||||
local TRADE_CAUSES = { trade_commit = true, trade_abort = true }
|
||||
|
||||
local function isXg(msg)
|
||||
return Protocol2.isXg(msg)
|
||||
end
|
||||
|
||||
function Prep.new(session, opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({
|
||||
session = session,
|
||||
mode = opts.mode,
|
||||
rev = 0,
|
||||
rules = nil,
|
||||
blocked = nil,
|
||||
size = nil,
|
||||
state = "rules_wait",
|
||||
go = nil,
|
||||
closed = nil,
|
||||
round = 0,
|
||||
offerRev = 0,
|
||||
invalidated = false,
|
||||
mine = { pending = {} },
|
||||
peer = {},
|
||||
events = {},
|
||||
snapshotSeen = nil,
|
||||
sent = {},
|
||||
}, Prep)
|
||||
self:syncSnapshot()
|
||||
return self
|
||||
end
|
||||
|
||||
function Prep:seat()
|
||||
local seat = self.session.seat and self.session.seat() or nil
|
||||
if seat ~= nil then self.mySeat = seat end
|
||||
return self.mySeat
|
||||
end
|
||||
|
||||
function Prep:emit(kind, fields)
|
||||
local e = { kind = kind }
|
||||
for k, v in pairs(fields or {}) do e[k] = v end
|
||||
self.events[#self.events + 1] = e
|
||||
return e
|
||||
end
|
||||
|
||||
function Prep:clearReady(reason)
|
||||
if self.mine.ready then
|
||||
self.invalidated = true
|
||||
self:emit("invalidated", { reason = reason })
|
||||
end
|
||||
self.mine.ready = nil
|
||||
self.peer.ready = nil
|
||||
end
|
||||
|
||||
function Prep:setRev(rev, cause)
|
||||
rev = tonumber(rev)
|
||||
if not rev or rev <= self.rev then return false end
|
||||
self.rev = rev
|
||||
self:clearReady(cause or "rev")
|
||||
self:emit("rev", { rev = rev, cause = cause })
|
||||
return true
|
||||
end
|
||||
|
||||
function Prep:enterPrepState()
|
||||
if self.state == "go" or self.state == "closed" then return end
|
||||
if self.blocked then
|
||||
self.state = "blocked"
|
||||
elseif self.rules then
|
||||
self.state = "prep"
|
||||
else
|
||||
self.state = "rules_wait"
|
||||
end
|
||||
end
|
||||
|
||||
local function pairAt(list, seat)
|
||||
if type(list) ~= "table" or seat == nil then return nil end
|
||||
return list[seat + 1]
|
||||
end
|
||||
|
||||
function Prep:syncSnapshot()
|
||||
if not self.session.snapshot then return end
|
||||
local x, room = self.session.snapshot()
|
||||
if type(x) ~= "table" or x == self.snapshotSeen then return end
|
||||
self.snapshotSeen = x
|
||||
self.mode = x.mode or self.mode
|
||||
local seat = self:seat()
|
||||
if seat == nil then return end
|
||||
local other = 1 - seat
|
||||
if x.rev < self.rev then return end
|
||||
if x.rev > self.rev then
|
||||
self.rev = x.rev
|
||||
self:clearReady("resume")
|
||||
end
|
||||
self.rules = x.rules
|
||||
self.blocked = x.blocked
|
||||
self.size = x.size
|
||||
self.mine.roster = pairAt(x.rosters, seat)
|
||||
self.peer.roster = pairAt(x.rosters, other)
|
||||
self.mine.sizeReq = pairAt(x.sizeReq, seat)
|
||||
self.peer.sizeReq = pairAt(x.sizeReq, other)
|
||||
local myOffer, peerOffer = pairAt(x.offers, seat), pairAt(x.offers, other)
|
||||
if myOffer then
|
||||
self.offerRev = math.max(self.offerRev, myOffer.offerRev)
|
||||
if not (self.mine.offer and self.mine.offer.offerRev == myOffer.offerRev) then
|
||||
self.mine.offer = { offerRev = myOffer.offerRev, digest16 = myOffer.digest16 }
|
||||
end
|
||||
else
|
||||
self.mine.offer = nil
|
||||
end
|
||||
if peerOffer then
|
||||
if not (self.peer.offer and self.peer.offer.offerRev == peerOffer.offerRev) then
|
||||
self.peer.offer = { offerRev = peerOffer.offerRev, digest16 = peerOffer.digest16 }
|
||||
end
|
||||
else
|
||||
self.peer.offer = nil
|
||||
end
|
||||
if pairAt(x.ready, seat) then
|
||||
self.mine.ready = self.mine.ready or { rev = x.rev }
|
||||
else
|
||||
self.mine.ready = nil
|
||||
end
|
||||
self.peer.ready = pairAt(x.ready, other) and { rev = x.rev } or nil
|
||||
self.peer.capsSent = pairAt(x.caps, other) == true
|
||||
self.mine.capsSent = pairAt(x.caps, seat) == true
|
||||
local stage = type(room) == "table" and room.stage or nil
|
||||
if stage == "prep" then
|
||||
if self.state == "go" then self.state = "rules_wait" end
|
||||
self.go = nil
|
||||
self:enterPrepState()
|
||||
elseif stage == "trading" or stage == "battling" or stage == "ended" then
|
||||
if self.state ~= "closed" then self.state = "go" end
|
||||
else
|
||||
self:enterPrepState()
|
||||
end
|
||||
self:emit("snapshot", { rev = self.rev })
|
||||
end
|
||||
|
||||
function Prep:confirm(cause)
|
||||
local pending = self.mine.pending[cause]
|
||||
if not pending then return end
|
||||
self.mine.pending[cause] = nil
|
||||
if cause == "xg_roster" then
|
||||
self.mine.roster = { size = pending.size, digest16 = pending.digest16 }
|
||||
elseif cause == "xg_size_req" then
|
||||
self.mine.sizeReq = pending.size
|
||||
elseif cause == "xg_offer" then
|
||||
self.mine.offer = pending
|
||||
elseif cause == "xg_counter" then
|
||||
self.mine.counter = pending.gen
|
||||
end
|
||||
end
|
||||
|
||||
function Prep:handleRelay(m)
|
||||
local kind = m.type
|
||||
if kind == "xg_rules" then
|
||||
self:setRev(m.rev, "rules")
|
||||
self.rules = Wire.xgRules(m)
|
||||
self.blocked = nil
|
||||
self:enterPrepState()
|
||||
self:emit("rules", { rules = self.rules })
|
||||
elseif kind == "xg_blocked" then
|
||||
self:setRev(m.rev, "blocked")
|
||||
self.rules = nil
|
||||
self.blocked = m.why
|
||||
self:enterPrepState()
|
||||
self:emit("blocked", { why = m.why })
|
||||
elseif kind == "xg_rev" then
|
||||
self:setRev(m.rev, m.cause)
|
||||
if m.seat ~= nil and m.seat == self:seat() then self:confirm(m.cause) end
|
||||
if m.seat == -1 and TRADE_CAUSES[m.cause] then
|
||||
self.mine.offer, self.peer.offer = nil, nil
|
||||
self.mine.pending.xg_offer = nil
|
||||
self.go = nil
|
||||
if self.state == "go" then self.state = "rules_wait" end
|
||||
self.round = self.round + 1
|
||||
self:enterPrepState()
|
||||
self:emit("trade_round", { round = self.round, cause = m.cause })
|
||||
end
|
||||
elseif kind == "xg_size" then
|
||||
self:setRev(m.rev, "size")
|
||||
self.size = m.size
|
||||
self:emit("size", { size = m.size })
|
||||
elseif kind == "xg_nack" then
|
||||
self.mine.pending[m.of or ""] = nil
|
||||
if m.of == "xg_ready" then
|
||||
if self.mine.ready then self.invalidated = true end
|
||||
self.mine.ready = nil
|
||||
end
|
||||
if m.why == "stale_rev" then
|
||||
self:setRev(m.current, "stale_rev")
|
||||
self.invalidated = true
|
||||
elseif m.why == "digest" then
|
||||
self.invalidated = true
|
||||
self.mine.ready, self.peer.ready = nil, nil
|
||||
end
|
||||
self:emit("nack", { of = m.of, why = m.why, rev = m.rev, current = m.current })
|
||||
elseif kind == "xg_go" then
|
||||
if self.state == "closed" then return end
|
||||
self.go = {
|
||||
rev = m.rev, seed = m.seed, match = m.match, mode = m.mode or self.mode,
|
||||
ruleset = m.ruleset, size = m.size, gen = m.gen, dexMax = m.dexMax,
|
||||
moveMax = m.moveMax, moveGen = m.moveGen,
|
||||
}
|
||||
self.state = "go"
|
||||
self:emit("go", { go = self.go })
|
||||
elseif kind == "xg_closed" then
|
||||
self.state = "closed"
|
||||
self.closed = { why = m.why, seat = m.seat ~= -1 and m.seat or nil, detail = m.detail }
|
||||
self:emit("closed", self.closed)
|
||||
end
|
||||
end
|
||||
|
||||
function Prep:handlePeer(m)
|
||||
local kind = m.type
|
||||
if kind == "xg_roster" then
|
||||
self.peer.roster = { size = m.size, digest16 = m.digest16 }
|
||||
self:emit("peer_roster", { size = m.size })
|
||||
elseif kind == "xg_size_req" then
|
||||
self.peer.sizeReq = m.size
|
||||
self:emit("peer_size_req", { size = m.size })
|
||||
elseif kind == "xg_offer" then
|
||||
self.peer.offer = { offerRev = m.offerRev, payload = m.payload, digest16 = m.digest16 }
|
||||
self:emit("peer_offer", { offerRev = m.offerRev })
|
||||
elseif kind == "xg_ready" then
|
||||
if m.rev == self.rev then
|
||||
self.peer.ready = { rev = m.rev, digest16 = m.digest16 }
|
||||
self:emit("peer_ready", { rev = m.rev })
|
||||
end
|
||||
elseif kind == "xg_caps" then
|
||||
self.peer.caps = m.caps
|
||||
self.peer.capsSent = true
|
||||
self:emit("peer_caps", {})
|
||||
elseif kind == "xg_counter" then
|
||||
self.peer.counter = m.gen
|
||||
self:emit("peer_counter", { gen = m.gen })
|
||||
elseif kind == "xg_cancel" then
|
||||
self:emit("peer_cancel", { why = m.why })
|
||||
end
|
||||
end
|
||||
|
||||
function Prep:handle(m)
|
||||
if type(m) ~= "table" then return end
|
||||
if Protocol2.XG_RELAY_TYPES[m.type] then
|
||||
self:handleRelay(m)
|
||||
else
|
||||
self:handlePeer(m)
|
||||
end
|
||||
end
|
||||
|
||||
function Prep:poll()
|
||||
self:syncSnapshot()
|
||||
local take = self.session.take
|
||||
if take then
|
||||
for _ = 1, 256 do
|
||||
local m = take(isXg)
|
||||
if not m then break end
|
||||
self:handle(m)
|
||||
self:syncSnapshot()
|
||||
end
|
||||
end
|
||||
if self.state ~= "closed" and self.session.open and not self.session.open() then
|
||||
self.state = "closed"
|
||||
self.closed = { why = "gone" }
|
||||
self:emit("closed", self.closed)
|
||||
end
|
||||
local out = self.events
|
||||
self.events = {}
|
||||
return out
|
||||
end
|
||||
|
||||
function Prep:open()
|
||||
return self.state ~= "closed"
|
||||
end
|
||||
|
||||
function Prep:send(msg)
|
||||
if not msg or self.state == "closed" then return false end
|
||||
self.sent[#self.sent + 1] = msg.type
|
||||
return self.session.send(msg) and true or false
|
||||
end
|
||||
|
||||
function Prep:localChange(kind)
|
||||
if BUMPING[kind] and self.mine.ready then
|
||||
self.mine.ready = nil
|
||||
self.invalidated = true
|
||||
self:emit("invalidated", { reason = kind })
|
||||
end
|
||||
end
|
||||
|
||||
function Prep:roster(size, digest16)
|
||||
if self.mode ~= "battle" or self.state == "go" then return false end
|
||||
self:localChange("xg_roster")
|
||||
self.mine.pending.xg_roster = { size = size, digest16 = digest16 }
|
||||
return self:send(Protocol2.xgRoster(self.rev, size, digest16))
|
||||
end
|
||||
|
||||
function Prep:sizeRequest(size)
|
||||
if self.mode ~= "battle" or self.state == "go" then return false end
|
||||
self:localChange("xg_size_req")
|
||||
self.mine.pending.xg_size_req = { size = size }
|
||||
return self:send(Protocol2.xgSizeReq(self.rev, size))
|
||||
end
|
||||
|
||||
function Prep:counter(gen)
|
||||
if self.mode ~= "battle" or self.state == "go" then return false end
|
||||
self:localChange("xg_counter")
|
||||
self.mine.pending.xg_counter = { gen = gen }
|
||||
return self:send(Protocol2.xgCounter(self.rev, gen))
|
||||
end
|
||||
|
||||
function Prep:offer(payload, digest16)
|
||||
if self.mode ~= "trade" or self.state == "go" then return false end
|
||||
self:localChange("xg_offer")
|
||||
self.offerRev = self.offerRev + 1
|
||||
self.mine.pending.xg_offer = { offerRev = self.offerRev, payload = payload, digest16 = digest16 }
|
||||
return self:send(Protocol2.xgOffer(self.rev, self.offerRev, payload, digest16))
|
||||
end
|
||||
|
||||
function Prep:sendCaps(caps)
|
||||
return self:send(Protocol2.xgCaps(caps))
|
||||
end
|
||||
|
||||
function Prep:canReady()
|
||||
if self.state ~= "prep" or not self.rules then return false end
|
||||
if self.mode == "battle" then return self.size ~= nil end
|
||||
if self.mode == "trade" then return self.mine.offer ~= nil and self.peer.offer ~= nil end
|
||||
return false
|
||||
end
|
||||
|
||||
function Prep:ready(digest16)
|
||||
if self.state ~= "prep" or self.mine.ready then return false end
|
||||
self.invalidated = false
|
||||
self.mine.ready = { rev = self.rev, digest16 = digest16 }
|
||||
return self:send(Protocol2.xgReady(self.rev, digest16))
|
||||
end
|
||||
|
||||
function Prep:cancel(why)
|
||||
if self.state == "closed" then return false end
|
||||
return self:send(Protocol2.xgCancel(why or "cancel"))
|
||||
end
|
||||
|
||||
function Prep:leave()
|
||||
if self.session.leave then self.session.leave() end
|
||||
end
|
||||
|
||||
function Prep:ackInvalidated()
|
||||
local was = self.invalidated
|
||||
self.invalidated = false
|
||||
return was
|
||||
end
|
||||
|
||||
function Prep:bothReady()
|
||||
return self.mine.ready ~= nil and self.peer.ready ~= nil
|
||||
end
|
||||
|
||||
return Prep
|
||||
@@ -0,0 +1,401 @@
|
||||
local Caps = require("src.online.union.Caps")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Participant = require("src.online.union.Participant")
|
||||
local Prep = require("src.online.union.Prep")
|
||||
local Protocol2 = require("src.online.Protocol2")
|
||||
local Version = require("src.core.Version")
|
||||
local Wire = require("src.link.Wire")
|
||||
|
||||
local Room = {}
|
||||
Room.__index = Room
|
||||
|
||||
Room.CAP = Protocol2.PLAZA_CAP
|
||||
Room.KIND = "union"
|
||||
Room.RULESET_GB = "union"
|
||||
Room.RULESET_G3 = "g3_single"
|
||||
|
||||
Room.ERRORS = {
|
||||
server_outdated = true, client_outdated = true, bad_profile = true, bad_avatar = true,
|
||||
bad_caps = true, profile = true, offline = true,
|
||||
}
|
||||
|
||||
local PLAZA_REASONS = { bad_profile = true, bad_avatar = true, bad_caps = true }
|
||||
|
||||
local function defaultClient()
|
||||
return require("src.online.Client")
|
||||
end
|
||||
|
||||
function Room.new(opts)
|
||||
opts = opts or {}
|
||||
return setmetatable({
|
||||
client = opts.client or defaultClient(),
|
||||
state = "idle",
|
||||
err = nil,
|
||||
errSent = false,
|
||||
bySlot = {},
|
||||
byId = {},
|
||||
me = nil,
|
||||
profile = nil,
|
||||
avatar = nil,
|
||||
caps = nil,
|
||||
ctx = nil,
|
||||
seen = nil,
|
||||
rejected = 0,
|
||||
builds = 0,
|
||||
handlers = nil,
|
||||
prepFor = nil,
|
||||
prepObj = nil,
|
||||
}, Room)
|
||||
end
|
||||
|
||||
local function genOf(ctx)
|
||||
local v = ctx.version
|
||||
if not GameVersion.VERSIONS[v or ""] then return nil end
|
||||
return GameVersion.generation(v)
|
||||
end
|
||||
|
||||
local function apiVersion()
|
||||
local ok, Handshake = pcall(require, "src.link.Handshake")
|
||||
return (ok and Handshake.apiVersion) or Version.modApi
|
||||
end
|
||||
|
||||
local function gbFingerprint(ctx, gen)
|
||||
if ctx.fingerprint then return ctx.fingerprint end
|
||||
local game = ctx.game
|
||||
local data = ctx.data or (game and game.data)
|
||||
if type(data) ~= "table" then return nil end
|
||||
local Fingerprint = require("src.link.Fingerprint")
|
||||
local Handshake = require("src.link.Handshake")
|
||||
local ok, fp = pcall(Fingerprint.compute, data, Handshake.mods(game), gen)
|
||||
if ok then return fp end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Room.buildProfile(ctx)
|
||||
ctx = type(ctx) == "table" and ctx or {}
|
||||
if type(ctx.profile) == "table" then return ctx.profile end
|
||||
local gen = genOf(ctx)
|
||||
if not gen then return nil, "unknown game" end
|
||||
if gen == 3 then
|
||||
local ok, ArenaData = pcall(require, "src.online.ArenaData")
|
||||
if not ok then return nil, "unavailable" end
|
||||
local okP, profile, why = pcall(ArenaData.liveProfile3, ctx.game, ctx.rulesetId or Room.RULESET_G3)
|
||||
if not okP then return nil, tostring(profile) end
|
||||
if not profile then return nil, why or "profile" end
|
||||
return profile
|
||||
end
|
||||
local fp = gbFingerprint(ctx, gen)
|
||||
if not fp then return nil, "fingerprint" end
|
||||
return {
|
||||
engine = gen,
|
||||
version = ctx.version,
|
||||
engineVersion = Version.engine,
|
||||
apiVersion = apiVersion(),
|
||||
fingerprint = fp,
|
||||
rulesetId = Room.RULESET_GB,
|
||||
kind = "vanilla",
|
||||
}
|
||||
end
|
||||
|
||||
function Room.buildAvatar(ctx)
|
||||
return Participant.wireAvatar({
|
||||
name = ctx.name, trainerId = ctx.trainerId, gender = ctx.gender,
|
||||
version = ctx.version, style = ctx.style, canLinkNationally = ctx.canLinkNationally,
|
||||
})
|
||||
end
|
||||
|
||||
function Room:fail(code, detail)
|
||||
if self.err then return end
|
||||
self.err = { error = code, detail = detail }
|
||||
self.errSent = false
|
||||
self.state = "error"
|
||||
end
|
||||
|
||||
function Room:error()
|
||||
return self.err
|
||||
end
|
||||
|
||||
function Room:hook()
|
||||
if self.handlers then return end
|
||||
local onError = function(e)
|
||||
if type(e) ~= "table" or e.scope ~= "join" or not PLAZA_REASONS[e.reason] then return end
|
||||
if self.state ~= "joining" and self.state ~= "joined" then return end
|
||||
if e.reason == "bad_profile" and self.gen ~= 3 then
|
||||
self:fail("server_outdated", e.reason)
|
||||
else
|
||||
self:fail(e.reason)
|
||||
end
|
||||
end
|
||||
local onUpgrade = function(msg)
|
||||
if self.state == "joining" or self.state == "joined" then
|
||||
self:fail("client_outdated", msg and msg.minBuild)
|
||||
end
|
||||
end
|
||||
self.handlers = { error = onError, upgrade_required = onUpgrade }
|
||||
for event, fn in pairs(self.handlers) do self.client.on(event, fn) end
|
||||
end
|
||||
|
||||
function Room:unhook()
|
||||
if not self.handlers then return end
|
||||
for event, fn in pairs(self.handlers) do self.client.off(event, fn) end
|
||||
self.handlers = nil
|
||||
end
|
||||
|
||||
function Room:join(ctx)
|
||||
ctx = type(ctx) == "table" and ctx or {}
|
||||
self.err, self.errSent = nil, false
|
||||
local gen = genOf(ctx)
|
||||
if not gen then
|
||||
self:fail("profile", "unknown game")
|
||||
return false, self.err
|
||||
end
|
||||
local profile, why = Room.buildProfile(ctx)
|
||||
if not profile then
|
||||
self:fail("profile", why)
|
||||
return false, self.err
|
||||
end
|
||||
if self.client.upgradeRequired and self.client.upgradeRequired() then
|
||||
self:fail("client_outdated")
|
||||
return false, self.err
|
||||
end
|
||||
self.ctx = ctx
|
||||
self.gen = gen
|
||||
self.profile = profile
|
||||
self.avatar = Room.buildAvatar(ctx)
|
||||
self.caps = ctx.caps or Caps.compute(ctx)
|
||||
self.state = "joining"
|
||||
self.seen = nil
|
||||
self:hook()
|
||||
self.client.joinPlaza(Room.KIND, profile, self.avatar, Room.CAP,
|
||||
{ xgen = Protocol2.XGEN, caps = self.caps })
|
||||
self.client.setStatus("idle")
|
||||
return true
|
||||
end
|
||||
|
||||
function Room:setCaps(caps)
|
||||
self.caps = caps
|
||||
return self.client.setCaps(caps)
|
||||
end
|
||||
|
||||
function Room:leave()
|
||||
if self.state == "idle" or self.state == "left" then return false end
|
||||
self.client.leavePlaza(Room.KIND)
|
||||
self:unhook()
|
||||
self.state = "left"
|
||||
self.bySlot, self.byId, self.me, self.seen = {}, {}, nil, nil
|
||||
return true
|
||||
end
|
||||
|
||||
local function myId(client)
|
||||
local you = client.you and client.you()
|
||||
return you and you.id or nil
|
||||
end
|
||||
|
||||
local function emptyDiff()
|
||||
return { joined = {}, left = {}, changed = {} }
|
||||
end
|
||||
|
||||
function Room:rebuild(plaza, diff)
|
||||
self.builds = self.builds + 1
|
||||
local me = myId(self.client)
|
||||
local mySlot = tonumber(plaza.you)
|
||||
local fresh, freshIds = {}, {}
|
||||
local mine = nil
|
||||
for _, row in ipairs(plaza.members or {}) do
|
||||
local isMe = (me ~= nil and row.id == me) or (mySlot ~= nil and row.slot == mySlot)
|
||||
if isMe then
|
||||
mine = row
|
||||
else
|
||||
local p = Participant.fromMember(row)
|
||||
if p and not fresh[p.slot] and not freshIds[p.id] then
|
||||
fresh[p.slot] = p
|
||||
freshIds[p.id] = p
|
||||
else
|
||||
self.rejected = self.rejected + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
if mine then
|
||||
local av = type(mine.avatar) == "table" and mine.avatar or nil
|
||||
if not av or av.gen == nil then
|
||||
self:fail("server_outdated", "legacy_shard")
|
||||
self.client.leavePlaza(Room.KIND)
|
||||
return
|
||||
end
|
||||
self.me = Participant.fromMember(mine)
|
||||
end
|
||||
for slot, old in pairs(self.bySlot) do
|
||||
local now = fresh[slot]
|
||||
if not now or now.id ~= old.id then diff.left[#diff.left + 1] = old end
|
||||
end
|
||||
for slot, now in pairs(fresh) do
|
||||
local old = self.bySlot[slot]
|
||||
if not old or old.id ~= now.id then
|
||||
diff.joined[#diff.joined + 1] = now
|
||||
elseif not Participant.same(old, now) then
|
||||
diff.changed[#diff.changed + 1] = now
|
||||
end
|
||||
end
|
||||
local bySlot = function(a, b) return a.slot < b.slot end
|
||||
table.sort(diff.joined, bySlot)
|
||||
table.sort(diff.left, bySlot)
|
||||
table.sort(diff.changed, bySlot)
|
||||
self.bySlot, self.byId = fresh, freshIds
|
||||
if self.state == "joining" then self.state = "joined" end
|
||||
end
|
||||
|
||||
function Room:poll()
|
||||
local diff = emptyDiff()
|
||||
if self.state == "idle" or self.state == "left" then return diff end
|
||||
if not self.err and self.client.upgradeRequired and self.client.upgradeRequired() then
|
||||
self:fail("client_outdated")
|
||||
end
|
||||
if not self.err then
|
||||
local plaza = self.client.plaza()
|
||||
if type(plaza) == "table" and plaza.kind == Room.KIND then
|
||||
local seen = self.seen
|
||||
if not seen or seen.plaza ~= plaza or seen.instance ~= plaza.instance or seen.rev ~= plaza.rev then
|
||||
self.seen = { plaza = plaza, instance = plaza.instance, rev = plaza.rev }
|
||||
self:rebuild(plaza, diff)
|
||||
end
|
||||
end
|
||||
end
|
||||
if self.err and not self.errSent then
|
||||
self.errSent = true
|
||||
diff.error = self.err
|
||||
end
|
||||
return diff
|
||||
end
|
||||
|
||||
function Room:members()
|
||||
local out = {}
|
||||
for _, p in pairs(self.bySlot) do out[#out + 1] = p end
|
||||
table.sort(out, function(a, b) return a.slot < b.slot end)
|
||||
return out
|
||||
end
|
||||
|
||||
function Room:member(ref)
|
||||
if type(ref) == "number" then return self.bySlot[ref] end
|
||||
if type(ref) == "string" then return self.byId[ref] end
|
||||
if type(ref) == "table" then return self.byId[ref.id] end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Room:self()
|
||||
return self.me
|
||||
end
|
||||
|
||||
function Room:count()
|
||||
local n = 0
|
||||
for _ in pairs(self.bySlot) do n = n + 1 end
|
||||
return n + (self.me and 1 or 0)
|
||||
end
|
||||
|
||||
function Room:invite(to, activity)
|
||||
activity = activity or "xg_battle"
|
||||
local id = type(to) == "table" and to.id or to
|
||||
id = Wire.playerId(id)
|
||||
if not id or not (Protocol2.XG_ACTIVITIES[activity] or Protocol2.ACTIVITY_SET[activity]) then
|
||||
return nil, "bad_invite"
|
||||
end
|
||||
return self.client.invite(id, activity, {}, self.profile)
|
||||
end
|
||||
|
||||
function Room:outgoing()
|
||||
return self.client.outgoing()
|
||||
end
|
||||
|
||||
local function fromParticipant(self, inv)
|
||||
local from = type(inv.from) == "table" and inv.from or {}
|
||||
local known = self.byId[from.id or ""]
|
||||
if known then return known end
|
||||
local av = type(from.avatar) == "table" and from.avatar or nil
|
||||
if not av then return nil end
|
||||
local slot = 1
|
||||
return Participant.fromMember({ id = from.id, name = from.name, verified = from.verified,
|
||||
slot = slot, avatar = av, status = "idle" })
|
||||
end
|
||||
|
||||
function Room:incoming(includeLegacy)
|
||||
local out = {}
|
||||
for _, inv in ipairs(self.client.invites() or {}) do
|
||||
local mode = Protocol2.XG_ACTIVITIES[inv.activity or ""]
|
||||
if mode or includeLegacy then
|
||||
out[#out + 1] = { id = inv.id, activity = inv.activity, mode = mode,
|
||||
from = fromParticipant(self, inv),
|
||||
fromId = type(inv.from) == "table" and inv.from.id or nil,
|
||||
expiresAt = inv.expiresAt }
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function Room:reply(id, accept)
|
||||
return self.client.replyInvite(id, accept == true)
|
||||
end
|
||||
|
||||
function Room:setStatus(status)
|
||||
if not Protocol2.STATUSES[status or ""] then return false end
|
||||
return self.client.setStatus(status)
|
||||
end
|
||||
|
||||
function Room:busy(p)
|
||||
return Participant.busy(p or self.me)
|
||||
end
|
||||
|
||||
function Room:xgRoom()
|
||||
local r = self.client.room()
|
||||
if type(r) == "table" and r.intent == "xg" then return r end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function adapter(client, roomId)
|
||||
local rs = client.roomSession()
|
||||
local a = {}
|
||||
function a.send(msg)
|
||||
if rs and not rs.closed then
|
||||
rs:send(msg)
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
function a.take(pred)
|
||||
if not rs then return nil end
|
||||
return rs:takeWhere(pred)
|
||||
end
|
||||
function a.snapshot()
|
||||
local r = client.room()
|
||||
if type(r) == "table" and r.room == roomId then return r.xg, r end
|
||||
return nil
|
||||
end
|
||||
function a.seat()
|
||||
local r = client.room()
|
||||
if type(r) == "table" and r.room == roomId then return client.seat() end
|
||||
return nil
|
||||
end
|
||||
function a.open()
|
||||
local r = client.room()
|
||||
return type(r) == "table" and r.room == roomId
|
||||
end
|
||||
function a.leave()
|
||||
if rs and not rs.left then rs:close() end
|
||||
end
|
||||
return a
|
||||
end
|
||||
|
||||
Room.adapter = adapter
|
||||
|
||||
function Room:prep()
|
||||
local r = self:xgRoom()
|
||||
if r and self.prepFor ~= r.room then
|
||||
self.prepFor = r.room
|
||||
self.prepObj = Prep.new(adapter(self.client, r.room), { mode = r.mode or (r.xg and r.xg.mode) })
|
||||
end
|
||||
return self.prepObj
|
||||
end
|
||||
|
||||
function Room:dropPrep()
|
||||
self.prepFor, self.prepObj = nil, nil
|
||||
end
|
||||
|
||||
return Room
|
||||
@@ -0,0 +1,26 @@
|
||||
local Setting = {}
|
||||
|
||||
Setting.KEY = "unionRoom"
|
||||
|
||||
function Setting.enabledIn(opts)
|
||||
return not (type(opts) == "table" and opts[Setting.KEY] == false)
|
||||
end
|
||||
|
||||
function Setting.enabled(fs)
|
||||
local ok, opts = pcall(function()
|
||||
return require("src.core.SaveData").loadOptions(fs)
|
||||
end)
|
||||
return Setting.enabledIn(ok and opts or nil)
|
||||
end
|
||||
|
||||
function Setting.appliesTo(generation)
|
||||
return generation == 1 or generation == 2
|
||||
end
|
||||
|
||||
function Setting.patchesOn(generation, opts)
|
||||
if not Setting.appliesTo(generation) then return true end
|
||||
if opts ~= nil then return Setting.enabledIn(opts) end
|
||||
return Setting.enabled()
|
||||
end
|
||||
|
||||
return Setting
|
||||
@@ -0,0 +1,386 @@
|
||||
local Datasets = require("src.online.xgen.Datasets")
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Policy = require("src.online.xgen.Policy")
|
||||
local Project = require("src.online.xgen.Project")
|
||||
local TradeConvert = require("src.online.xgen.TradeConvert")
|
||||
|
||||
local Model = {}
|
||||
Model.__index = Model
|
||||
|
||||
Model.PAYLOAD_VERSION = 1
|
||||
Model.MAX_STRING = 256
|
||||
Model.MAX_KEY = 64
|
||||
Model.MAX_DEPTH = 7
|
||||
Model.MAX_BYTES = 8192
|
||||
-- pokefirered/src/trade_scene.c:1073
|
||||
Model.GEN3_TRADED_FRIENDSHIP = 70
|
||||
|
||||
Model.TRADE_EVOLUTION = {
|
||||
[1] = { TRADE = true },
|
||||
[2] = { EVOLVE_TRADE = true },
|
||||
-- include/constants/pokemon.h:270
|
||||
[3] = { [5] = true, [6] = true, EVO_TRADE = true, EVO_TRADE_ITEM = true },
|
||||
}
|
||||
|
||||
local copy = Project.copy
|
||||
|
||||
local function genOf(version)
|
||||
if not (type(version) == "string" and GameVersion.VERSIONS[version]) then return nil end
|
||||
return GameVersion.generation(version)
|
||||
end
|
||||
Model.genOf = genOf
|
||||
|
||||
local function isArray(t)
|
||||
local n = #t
|
||||
if n == 0 then return false end
|
||||
local count = 0
|
||||
for _ in pairs(t) do count = count + 1 end
|
||||
return count == n
|
||||
end
|
||||
|
||||
local function encodeKey(k)
|
||||
if type(k) == "number" then
|
||||
if k ~= k or k ~= math.floor(k) then return nil end
|
||||
return ("#%.0f"):format(k)
|
||||
end
|
||||
if type(k) ~= "string" then return nil end
|
||||
if k:sub(1, 1) == "#" then return "#" .. k end
|
||||
return k
|
||||
end
|
||||
|
||||
local function decodeKey(k)
|
||||
if type(k) ~= "string" then return k end
|
||||
if k:sub(1, 2) == "##" then return k:sub(2) end
|
||||
if k:sub(1, 1) == "#" then return tonumber(k:sub(2)) end
|
||||
return k
|
||||
end
|
||||
|
||||
function Model.toWire(v, depth)
|
||||
depth = depth or 0
|
||||
local t = type(v)
|
||||
if t == "string" then
|
||||
if #v > Model.MAX_STRING then return nil, "too_big" end
|
||||
return v
|
||||
end
|
||||
if t == "number" then
|
||||
if v ~= v or v == math.huge or v == -math.huge then return nil, "bad_record" end
|
||||
return v
|
||||
end
|
||||
if t == "boolean" then return v end
|
||||
if t ~= "table" then return nil, "bad_record" end
|
||||
if depth >= Model.MAX_DEPTH then return nil, "too_big" end
|
||||
local out = {}
|
||||
if isArray(v) then
|
||||
for i = 1, #v do
|
||||
local item, why = Model.toWire(v[i], depth + 1)
|
||||
if item == nil then return nil, why end
|
||||
out[i] = item
|
||||
end
|
||||
return out
|
||||
end
|
||||
for k, val in pairs(v) do
|
||||
local key = encodeKey(k)
|
||||
if not key or #key > Model.MAX_KEY then return nil, "bad_record" end
|
||||
local item, why = Model.toWire(val, depth + 1)
|
||||
if item == nil then return nil, why end
|
||||
out[key] = item
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function Model.fromWire(v)
|
||||
if type(v) ~= "table" then return v end
|
||||
local out = {}
|
||||
for k, val in pairs(v) do
|
||||
local key = decodeKey(k)
|
||||
if key ~= nil then out[key] = Model.fromWire(val) end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function Model.plain(v, depth)
|
||||
depth = depth or 0
|
||||
local t = type(v)
|
||||
if t == "table" then
|
||||
if depth > 12 then return nil end
|
||||
local out = {}
|
||||
for k, val in pairs(v) do
|
||||
local kt = type(k)
|
||||
if kt == "string" or kt == "number" then
|
||||
local item = Model.plain(val, depth + 1)
|
||||
if item ~= nil then out[k] = item end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
if t == "string" or t == "number" or t == "boolean" then return v end
|
||||
return nil
|
||||
end
|
||||
|
||||
Model.datasetSource = nil
|
||||
|
||||
function Model.dataset(version)
|
||||
if type(Model.datasetSource) == "function" then return Model.datasetSource(version) end
|
||||
local data = Datasets.get(version)
|
||||
return data
|
||||
end
|
||||
|
||||
function Model.datasetFor(version)
|
||||
local gen = genOf(version)
|
||||
if not gen then return nil end
|
||||
local exact = Model.dataset(version)
|
||||
if exact then return exact, version end
|
||||
for _, v in ipairs(GameVersion.ORDER) do
|
||||
if v ~= version and GameVersion.generation(v) == gen then
|
||||
local d = Model.dataset(v)
|
||||
if d then return d, v end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Model.receiveRules(destGen, result)
|
||||
local final = copy(result)
|
||||
local changes = {}
|
||||
if destGen == 3 and not final.isEgg then
|
||||
local was = final.friendship
|
||||
final.friendship = Model.GEN3_TRADED_FRIENDSHIP
|
||||
if was ~= final.friendship then
|
||||
changes[#changes + 1] = { field = "friendship", from = was, to = final.friendship, kind = "change", rule = "received" }
|
||||
end
|
||||
end
|
||||
return final, changes
|
||||
end
|
||||
|
||||
function Model.tradeEvolution(data, national)
|
||||
local sp = type(data) == "table" and data.species and data.species[tonumber(national) or -1]
|
||||
if not sp then return nil end
|
||||
local methods = Model.TRADE_EVOLUTION[data.generation] or {}
|
||||
for _, evo in ipairs(sp.evolutions or {}) do
|
||||
if methods[evo.method] and data.species[evo.into] then
|
||||
return evo.into, data.species[evo.into].name
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function refusal(code, detail)
|
||||
return { ok = false, blocks = { { code = code, detail = detail } }, changes = {}, options = {} }
|
||||
end
|
||||
|
||||
function Model.convert(srcVersion, dstVersion, rec, adjustments)
|
||||
local src, srcUsed = Model.datasetFor(srcVersion)
|
||||
local dst, dstUsed = Model.datasetFor(dstVersion)
|
||||
if not src then return refusal("missing_import", { need = srcVersion }) end
|
||||
if not dst then return refusal("missing_import", { need = dstVersion }) end
|
||||
if type(rec) ~= "table" then return refusal("bad_record") end
|
||||
if rec.rental == true then return refusal("rental") end
|
||||
if rec.projected == true or rec.projection ~= nil or rec.sourceGen ~= nil then return refusal("projection") end
|
||||
local report = TradeConvert.convert({ source = { data = src }, target = { data = dst }, mon = rec,
|
||||
adjustments = adjustments, versions = { policy = Policy.VERSION } })
|
||||
report.datasets = { source = srcUsed, target = dstUsed }
|
||||
local destGen = dst.generation
|
||||
if report.ok then
|
||||
local final, extra = Model.receiveRules(destGen, report.result)
|
||||
for _, c in ipairs(extra) do report.changes[#report.changes + 1] = c end
|
||||
report.final = final
|
||||
report.finalCanonical = TradeConvert.canonical(final)
|
||||
local view = Project.read(final, dst)
|
||||
report.national = view and view.national
|
||||
report.speciesName = view and dst.species[view.national] and dst.species[view.national].name
|
||||
local into, name = Model.tradeEvolution(dst, report.national)
|
||||
if into then report.evolves = { national = into, name = name } end
|
||||
end
|
||||
local view = Project.read(rec, src)
|
||||
report.sourceNational = view and view.national
|
||||
report.sourceName = view and src.species[view.national] and src.species[view.national].name
|
||||
report.level = view and view.level
|
||||
return report
|
||||
end
|
||||
|
||||
function Model.new(opts)
|
||||
opts = opts or {}
|
||||
local self = setmetatable({
|
||||
version = opts.version,
|
||||
gen = genOf(opts.version),
|
||||
peerVersion = opts.peerVersion,
|
||||
peerGen = genOf(opts.peerVersion),
|
||||
peerName = opts.peerName,
|
||||
owned = opts.owned or {},
|
||||
mine = nil,
|
||||
peer = nil,
|
||||
refusal = nil,
|
||||
}, Model)
|
||||
return self
|
||||
end
|
||||
|
||||
function Model:setPeer(version, name)
|
||||
if version ~= self.peerVersion then
|
||||
self.peerVersion, self.peerGen = version, genOf(version)
|
||||
if self.mine then self:choose(self.mine.index) end
|
||||
end
|
||||
if name then self.peerName = name end
|
||||
end
|
||||
|
||||
function Model:entry(index)
|
||||
return self.owned[tonumber(index) or -1]
|
||||
end
|
||||
|
||||
function Model:choose(index)
|
||||
local entry = self:entry(index)
|
||||
if not entry then return nil, "not_owned" end
|
||||
if entry.locked then return refusal(entry.locked) end
|
||||
local rec = Model.plain(entry.rec)
|
||||
self.mine = { index = index, ref = copy(entry.ref), rec = rec, adjust = { moves = {} } }
|
||||
return self:refresh()
|
||||
end
|
||||
|
||||
function Model:refresh()
|
||||
local m = self.mine
|
||||
if not m then return nil end
|
||||
local entry = self:entry(m.index)
|
||||
local report
|
||||
if entry and entry.locked then
|
||||
report = refusal(entry.locked)
|
||||
else
|
||||
report = Model.convert(self.version, self.peerVersion, m.rec, m.adjust)
|
||||
end
|
||||
m.report = report
|
||||
m.offerable = report.ok == true
|
||||
return report
|
||||
end
|
||||
|
||||
function Model:stage(slot, move)
|
||||
local m = self.mine
|
||||
slot = tonumber(slot)
|
||||
if not (m and slot and slot >= 1 and slot <= Policy.MAX_MOVES) then return nil, "bad_slot" end
|
||||
if move == nil then
|
||||
m.adjust.moves[slot] = nil
|
||||
else
|
||||
m.adjust.moves[slot] = move == false and 0 or move
|
||||
end
|
||||
return self:refresh()
|
||||
end
|
||||
|
||||
function Model:moveOptions(slot)
|
||||
local m = self.mine
|
||||
local opts = m and m.report and m.report.options and m.report.options.moves
|
||||
return opts and opts[tonumber(slot) or -1] or nil
|
||||
end
|
||||
|
||||
function Model:stagedMoves()
|
||||
local out = {}
|
||||
if not self.mine then return out end
|
||||
for slot, move in pairs(self.mine.adjust.moves) do out[#out + 1] = { slot = slot, move = move } end
|
||||
table.sort(out, function(a, b) return a.slot < b.slot end)
|
||||
return out
|
||||
end
|
||||
|
||||
local function half(srcVersion, dstVersion, source, final)
|
||||
return { src = srcVersion, dst = dstVersion, source = TradeConvert.canonical(source),
|
||||
result = TradeConvert.canonical(final) }
|
||||
end
|
||||
|
||||
function Model.offerDigest(h)
|
||||
return TradeConvert.digest({ v = Model.PAYLOAD_VERSION, policy = Policy.VERSION, src = h.src, dst = h.dst,
|
||||
source = h.source, result = h.result })
|
||||
end
|
||||
|
||||
function Model:payload()
|
||||
local m = self.mine
|
||||
if not (m and m.offerable) then return nil, "not_ready" end
|
||||
local adjust = {}
|
||||
for slot, move in pairs(m.adjust.moves) do adjust[#adjust + 1] = { slot = slot, move = move } end
|
||||
table.sort(adjust, function(a, b) return a.slot < b.slot end)
|
||||
local body = { v = Model.PAYLOAD_VERSION, policy = Policy.VERSION, src = self.version, dst = self.peerVersion,
|
||||
source = m.rec, adjust = adjust, preview = m.report.final }
|
||||
local wire, why = Model.toWire(body)
|
||||
if not wire then return nil, why end
|
||||
local ok, text = pcall(require("src.link.Json").encode, wire)
|
||||
if not ok or #text > Model.MAX_BYTES then return nil, "too_big" end
|
||||
m.half = half(self.version, self.peerVersion, m.rec, m.report.final)
|
||||
m.digest16 = Model.offerDigest(m.half)
|
||||
return wire, m.digest16
|
||||
end
|
||||
|
||||
function Model:receive(payload, verify)
|
||||
self.peer = nil
|
||||
self.refusal = nil
|
||||
local function refuse(code, detail)
|
||||
self.refusal = { code = code, detail = detail }
|
||||
return nil, code, detail
|
||||
end
|
||||
if type(payload) ~= "table" then return refuse("bad_payload") end
|
||||
local body = Model.fromWire(payload)
|
||||
if body.v ~= Model.PAYLOAD_VERSION then return refuse("bad_payload", { field = "v" }) end
|
||||
if body.policy ~= Policy.VERSION then return refuse("policy_mismatch", { mine = Policy.VERSION, theirs = body.policy }) end
|
||||
if not genOf(body.src) then return refuse("unsupported_version", { version = body.src }) end
|
||||
if body.dst ~= self.version then return refuse("wrong_destination", { claimed = body.dst, mine = self.version }) end
|
||||
if self.peerVersion and body.src ~= self.peerVersion then
|
||||
return refuse("wrong_source", { claimed = body.src, peer = self.peerVersion })
|
||||
end
|
||||
if type(body.source) ~= "table" or type(body.preview) ~= "table" then return refuse("bad_payload") end
|
||||
local adjust = { moves = {} }
|
||||
for _, row in ipairs(type(body.adjust) == "table" and body.adjust or {}) do
|
||||
local slot = tonumber(type(row) == "table" and row.slot)
|
||||
if not slot or slot < 1 or slot > Policy.MAX_MOVES or slot % 1 ~= 0 or adjust.moves[slot] ~= nil then
|
||||
return refuse("bad_payload", { field = "adjust" })
|
||||
end
|
||||
adjust.moves[slot] = row.move
|
||||
end
|
||||
local report = Model.convert(body.src, self.version, body.source, adjust)
|
||||
if not report.ok then
|
||||
local b = report.blocks[1] or {}
|
||||
return refuse(b.code or "bad_record", b.detail)
|
||||
end
|
||||
local claimed = TradeConvert.canonical(body.preview)
|
||||
if claimed ~= report.finalCanonical then
|
||||
return refuse("preview_mismatch", { used = report.datasets and report.datasets.source })
|
||||
end
|
||||
if type(verify) == "function" then
|
||||
local ok, code, detail = verify(report.final, report)
|
||||
if not ok then return refuse(code or "not_valid_here", detail) end
|
||||
end
|
||||
self.peer = { source = body.source, adjust = adjust, report = report, final = report.final,
|
||||
half = half(body.src, self.version, body.source, report.final), version = body.src }
|
||||
self.peer.digest16 = Model.offerDigest(self.peer.half)
|
||||
return true
|
||||
end
|
||||
|
||||
function Model.agreedDigest(seat0, seat1)
|
||||
local function h(x)
|
||||
return { offerRev = tonumber(x.offerRev) or 0, src = x.src, dst = x.dst, source = x.source, result = x.result }
|
||||
end
|
||||
return TradeConvert.digest({ v = Model.PAYLOAD_VERSION, policy = Policy.VERSION, seats = { h(seat0), h(seat1) } })
|
||||
end
|
||||
|
||||
function Model:agreed(mySeat, myOfferRev, peerOfferRev)
|
||||
local m, p = self.mine, self.peer
|
||||
if not (m and m.half and p and p.half) then return nil end
|
||||
local mine = copy(m.half)
|
||||
mine.offerRev = myOfferRev
|
||||
local theirs = copy(p.half)
|
||||
theirs.offerRev = peerOfferRev
|
||||
if mySeat == 1 then return Model.agreedDigest(theirs, mine) end
|
||||
return Model.agreedDigest(mine, theirs)
|
||||
end
|
||||
|
||||
local function side(report, versionFrom, versionTo, name)
|
||||
if not report then return nil end
|
||||
return {
|
||||
from = versionFrom, to = versionTo, trainer = name,
|
||||
sourceName = report.sourceName, speciesName = report.speciesName, national = report.national,
|
||||
level = report.level, final = report.final, changes = report.changes or {}, blocks = report.blocks or {},
|
||||
evolves = report.evolves, ok = report.ok == true,
|
||||
}
|
||||
end
|
||||
|
||||
function Model:summary()
|
||||
return {
|
||||
mine = self.mine and side(self.mine.report, self.version, self.peerVersion, nil) or nil,
|
||||
theirs = self.peer and side(self.peer.report, self.peer.version, self.version, self.peerName) or nil,
|
||||
refusal = self.refusal,
|
||||
}
|
||||
end
|
||||
|
||||
return Model
|
||||
@@ -0,0 +1,806 @@
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Model = require("src.online.union.TradePrepModel")
|
||||
local TradeConvert = require("src.online.xgen.TradeConvert")
|
||||
|
||||
local Txn = {}
|
||||
Txn.__index = Txn
|
||||
|
||||
Txn.JOURNAL_VERSION = 1
|
||||
Txn.JOURNAL_TTL = 24 * 60 * 60
|
||||
Txn.RETRY_SECONDS = 5
|
||||
Txn.SAVE_RETRY_SECONDS = 3
|
||||
Txn.MARKERS = 32
|
||||
Txn.TICK_KEY = "union_xtrade"
|
||||
|
||||
Txn.fs = nil
|
||||
Txn.now = nil
|
||||
Txn._applied = {}
|
||||
Txn._live = {}
|
||||
Txn._resolver = nil
|
||||
|
||||
local function now()
|
||||
if type(Txn.now) == "function" then return Txn.now() end
|
||||
return os.time()
|
||||
end
|
||||
|
||||
local function SaveData()
|
||||
return require("src.core.SaveData")
|
||||
end
|
||||
|
||||
local function disk()
|
||||
if Txn.fs then return Txn.fs end
|
||||
return SaveData().persistenceFs()
|
||||
end
|
||||
|
||||
local function copy(v)
|
||||
if type(v) ~= "table" then return v end
|
||||
local out = {}
|
||||
for k, x in pairs(v) do out[k] = copy(x) end
|
||||
return out
|
||||
end
|
||||
|
||||
local function log(fmt, ...)
|
||||
print("[union-trade] " .. fmt:format(...))
|
||||
end
|
||||
|
||||
local function lt()
|
||||
return require("src.core.game3.link.trade")
|
||||
end
|
||||
|
||||
|
||||
local function decode(text)
|
||||
if type(text) ~= "string" or text == "" then return nil end
|
||||
local ok, data = pcall(SaveData().decode, text)
|
||||
if ok and type(data) == "table" then return data end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function readFile(fs, path)
|
||||
if not fs.getInfo(path) then return nil end
|
||||
local ok, text = pcall(fs.read, path)
|
||||
return ok and text or nil
|
||||
end
|
||||
|
||||
function Txn.writeAtomic(path, text)
|
||||
local fs = disk()
|
||||
if not (fs and path) then return false end
|
||||
local tmp = path .. ".tmp"
|
||||
local ok, wrote = pcall(fs.write, tmp, text)
|
||||
if not (ok and wrote) or readFile(fs, tmp) ~= text then return false end
|
||||
if fs.getInfo(path) then pcall(fs.remove, path) end
|
||||
ok, wrote = pcall(fs.write, path, text)
|
||||
if not (ok and wrote) or readFile(fs, path) ~= text then return false end
|
||||
pcall(fs.remove, tmp)
|
||||
return true
|
||||
end
|
||||
|
||||
function Txn.journalPath(version)
|
||||
local main = SaveData().saveFilename(version)
|
||||
if type(main) ~= "string" or main == "" then return nil end
|
||||
return (main:gsub("%.lua$", "")) .. "_xtrade.lua"
|
||||
end
|
||||
|
||||
function Txn.readJournal(version)
|
||||
local fs = disk()
|
||||
local path = Txn.journalPath(version)
|
||||
if not (fs and path) then return {} end
|
||||
local data = decode(readFile(fs, path)) or decode(readFile(fs, path .. ".tmp"))
|
||||
if not (data and type(data.entries) == "table") then return {} end
|
||||
local out = {}
|
||||
for _, e in ipairs(data.entries) do
|
||||
if type(e) == "table" and type(e.key) == "string" then out[#out + 1] = e end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function Txn.writeJournal(version, entries)
|
||||
local fs = disk()
|
||||
local path = Txn.journalPath(version)
|
||||
if not (fs and path) then return false end
|
||||
if #entries == 0 then
|
||||
for _, p in ipairs({ path, path .. ".tmp" }) do
|
||||
if fs.getInfo(p) then
|
||||
local ok, removed = pcall(fs.remove, p)
|
||||
if not ok or removed == false then return false end
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
local ok, text = pcall(SaveData().encode, { v = Txn.JOURNAL_VERSION, entries = entries })
|
||||
if not ok then return false end
|
||||
return Txn.writeAtomic(path, text)
|
||||
end
|
||||
|
||||
function Txn.journalPut(version, entry)
|
||||
local kept = {}
|
||||
for _, e in ipairs(Txn.readJournal(version)) do
|
||||
if e.key ~= entry.key then kept[#kept + 1] = e end
|
||||
end
|
||||
kept[#kept + 1] = entry
|
||||
return Txn.writeJournal(version, kept)
|
||||
end
|
||||
|
||||
function Txn.journalDrop(version, key)
|
||||
local list, kept = Txn.readJournal(version), {}
|
||||
for _, e in ipairs(list) do
|
||||
if e.key ~= key then kept[#kept + 1] = e end
|
||||
end
|
||||
if #kept == #list then return true end
|
||||
return Txn.writeJournal(version, kept)
|
||||
end
|
||||
|
||||
function Txn.pending(version)
|
||||
return Txn.readJournal(version)
|
||||
end
|
||||
|
||||
function Txn.entryKey(room, n, digest)
|
||||
return tostring(room) .. ":" .. tostring(n) .. ":" .. tostring(digest)
|
||||
end
|
||||
|
||||
|
||||
local function protocol()
|
||||
return require("src.link.Protocol")
|
||||
end
|
||||
|
||||
local function identityOf(gen, rec)
|
||||
if type(rec) ~= "table" then return nil end
|
||||
if gen == 3 then
|
||||
return ("%s:%s:%s"):format(tostring(tonumber(rec.personality) or 0), tostring((tonumber(rec.otId) or 0) % 65536),
|
||||
tostring(tonumber(rec.otSecretId) or 0))
|
||||
end
|
||||
local d = type(rec.dvs) == "table" and rec.dvs or {}
|
||||
return ("%s:%s:%s:%s:%s:%s"):format(tostring(rec.ot or rec.otName), tostring(rec.otId), tostring(d.attack),
|
||||
tostring(d.defense), tostring(d.speed), tostring(d.special or d.specialAttack))
|
||||
end
|
||||
Txn.identityOf = identityOf
|
||||
|
||||
local Adapter = {}
|
||||
Adapter.__index = Adapter
|
||||
|
||||
local function newAdapter(game, gen, version)
|
||||
return setmetatable({ game = game, gen = gen, version = version }, Adapter)
|
||||
end
|
||||
|
||||
function Adapter:data()
|
||||
return self.game and self.game.data
|
||||
end
|
||||
|
||||
function Adapter:save()
|
||||
if self.gen == 3 then
|
||||
local g = self.game
|
||||
if g and g.session then return g.session end
|
||||
local Runtime = package.loaded["src.core.game3.runtime"]
|
||||
return Runtime and Runtime.getSession and Runtime.getSession() or nil
|
||||
end
|
||||
return self.game and self.game.save
|
||||
end
|
||||
|
||||
local function game3()
|
||||
local Runtime = package.loaded["src.core.game3.runtime"]
|
||||
return Runtime and Runtime._game or nil
|
||||
end
|
||||
|
||||
function Adapter:ready()
|
||||
local s = self:save()
|
||||
if type(s) ~= "table" or type(s.party) ~= "table" then return false end
|
||||
local g = self.game or (self.gen == 3 and game3() or nil)
|
||||
if self.gen == 3 and g and g.phase ~= nil and g.phase ~= "field" then return false end
|
||||
return true
|
||||
end
|
||||
|
||||
local GEN_BOXES = { [1] = 12, [2] = 14 }
|
||||
|
||||
function Adapter:lists()
|
||||
local s = self:save()
|
||||
local out = {}
|
||||
if type(s) ~= "table" then return out end
|
||||
out[#out + 1] = { where = "party", list = s.party or {} }
|
||||
if self.gen ~= 3 then
|
||||
if self.gen == 1 then require("src.pokemon.Boxes").ensure(s) end
|
||||
s.boxes = s.boxes or {}
|
||||
for b = 1, GEN_BOXES[self.gen] do
|
||||
if type(s.boxes[b]) == "table" then out[#out + 1] = { where = "box", box = b, list = s.boxes[b] } end
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function Adapter:listFor(ref)
|
||||
local s = self:save()
|
||||
if type(s) ~= "table" or type(ref) ~= "table" then return nil end
|
||||
if ref.where == "party" then return s.party end
|
||||
if ref.where == "box" and self.gen ~= 3 and type(s.boxes) == "table" then return s.boxes[tonumber(ref.box) or -1] end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Adapter:at(ref)
|
||||
local list = self:listFor(ref)
|
||||
return list and list[tonumber(ref.index) or -1] or nil
|
||||
end
|
||||
|
||||
function Adapter:pack(mon)
|
||||
if type(mon) ~= "table" then return nil end
|
||||
local P = protocol()
|
||||
local ok, rec = pcall(function()
|
||||
if self.gen == 3 then return P.packMon3(mon) end
|
||||
if self.gen == 2 then return P.packMon2(mon) end
|
||||
local r = P.packMon(mon)
|
||||
r.catchRate = mon.catchRate
|
||||
return r
|
||||
end)
|
||||
if not ok then return nil end
|
||||
return Model.plain(rec)
|
||||
end
|
||||
|
||||
function Adapter:canonicalAt(ref)
|
||||
local rec = self:pack(self:at(ref))
|
||||
return rec and TradeConvert.canonical(rec) or nil
|
||||
end
|
||||
|
||||
function Adapter:lockOf(ref, mon, pending)
|
||||
if self.gen == 2 then
|
||||
local Mail = require("src.core.gen2.Mail")
|
||||
if Mail.monHoldsMail(mon) then return "mail" end
|
||||
local s = self:save()
|
||||
local mail = type(s.mail) == "table" and type(s.mail.party) == "table" and s.mail.party or nil
|
||||
if ref.where == "party" and mail and mail[ref.index] ~= nil then return "mail" end
|
||||
end
|
||||
if type(mon) == "table" and (mon.rental == true or mon.projected == true) then return "rental" end
|
||||
local id = pending and next(pending) ~= nil and identityOf(self.gen, self:pack(mon)) or nil
|
||||
if id and pending[id] then return "pending" end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Adapter:owned()
|
||||
local out, pending = {}, {}
|
||||
for _, e in ipairs(Txn.readJournal(self.version)) do
|
||||
if type(e.out) == "table" and e.out.identity then pending[e.out.identity] = true end
|
||||
end
|
||||
for _, l in ipairs(self:lists()) do
|
||||
for i, mon in ipairs(l.list) do
|
||||
local ref = { where = l.where, box = l.box, index = i }
|
||||
out[#out + 1] = { ref = ref, rec = self:pack(mon), live = mon, locked = self:lockOf(ref, mon, pending) }
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function Adapter:native(final)
|
||||
if type(final) ~= "table" then return nil, "bad_record" end
|
||||
local P = protocol()
|
||||
local data = self:data()
|
||||
local ok, mon, why = pcall(function()
|
||||
if self.gen == 3 then return P.unpackMon3(nil, copy(final), { strict = true }) end
|
||||
if self.gen == 2 then return P.unpackMon2(data, copy(final), { strict = true }) end
|
||||
return P.unpackMon(data, copy(final), { strict = true })
|
||||
end)
|
||||
if not ok then return nil, "not_valid_here", { error = tostring(mon) } end
|
||||
if not mon then return nil, "not_valid_here", { error = why } end
|
||||
if mon.level ~= final.level then return nil, "not_valid_here", { field = "level" } end
|
||||
if type(final.moves) == "table" and #mon.moves ~= #final.moves then return nil, "not_valid_here", { field = "moves" } end
|
||||
if self.gen == 1 then
|
||||
mon.catchRate = final.catchRate
|
||||
mon.traded = true
|
||||
elseif self.gen == 2 then
|
||||
mon.traded = true
|
||||
end
|
||||
return mon
|
||||
end
|
||||
|
||||
function Adapter:validate(final)
|
||||
local mon, code, detail = self:native(final)
|
||||
if not mon then return false, code, detail end
|
||||
return true
|
||||
end
|
||||
|
||||
local function markDex1(save, species)
|
||||
save.pokedex = save.pokedex or {}
|
||||
save.pokedex.seen = save.pokedex.seen or {}
|
||||
save.pokedex.owned = save.pokedex.owned or {}
|
||||
save.pokedex.seen[species] = true
|
||||
save.pokedex.owned[species] = true
|
||||
end
|
||||
|
||||
function Adapter:evolve1(list, index, mon)
|
||||
local data = self:data()
|
||||
local def = data and data.pokemon and data.pokemon[mon.species]
|
||||
for _, evo in ipairs(def and def.evolutions or {}) do
|
||||
-- engine/pokemon/evos_moves.asm:70
|
||||
if evo.method == "TRADE" and data.pokemon[evo.species] then
|
||||
require("src.pokemon.Evolution").apply(self.game, mon, evo.species, "TRADE")
|
||||
return evo.species
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Adapter:put(ref, mon)
|
||||
local list = self:listFor(ref)
|
||||
local index = tonumber(ref.index)
|
||||
if not (list and index and list[index]) then return nil, "missing" end
|
||||
local s = self:save()
|
||||
local sent = list[index]
|
||||
local evolved
|
||||
if self.gen == 1 then
|
||||
list[index] = mon
|
||||
markDex1(s, mon.species)
|
||||
-- engine/link/cable_club.asm:801
|
||||
if self.version == "yellow" then
|
||||
pcall(function() require("src.world.PikachuFollower").modifyHappiness(s, "TRADE", sent) end)
|
||||
end
|
||||
evolved = self:evolve1(list, index, mon)
|
||||
elseif self.gen == 2 then
|
||||
local Evolution = require("src.core.gen2.Evolution")
|
||||
list[index] = mon
|
||||
Evolution.markPokedex(s, mon.species)
|
||||
local data = self:data()
|
||||
-- engine/pokemon/evolve.asm:144
|
||||
local entry = Evolution.checkMon(data, mon, { link = true })
|
||||
if entry then
|
||||
local next = Evolution.apply(data, mon, entry)
|
||||
if next then
|
||||
list[index] = next
|
||||
Evolution.markPokedex(s, entry.into)
|
||||
evolved = entry.into
|
||||
end
|
||||
end
|
||||
else
|
||||
local Trade = require("src.core.game3.scripting.natives_trade")
|
||||
if not Trade.tradeMons(s, index - 1, mon) then return nil, "swap" end
|
||||
local okQ, key, args = pcall(Trade.noteLinkTrade, s, sent, mon, self.peerName, true)
|
||||
if okQ and key then pcall(function() require("src.core.game3.quest_log_recorder").event(s, key, args) end) end
|
||||
-- pokefirered/src/trade_scene.c:2311
|
||||
local Evolution = require("src.core.game3.evolution")
|
||||
local target = Evolution.tradeTarget(mon, s)
|
||||
if target then
|
||||
Evolution.apply(mon, target, s, s.bag, "trade")
|
||||
evolved = target
|
||||
end
|
||||
end
|
||||
return true, evolved
|
||||
end
|
||||
|
||||
function Adapter:hasMarker(key)
|
||||
if self.gen == 3 then return false end
|
||||
local s = self:save()
|
||||
for _, k in ipairs(type(s) == "table" and type(s.unionTrades) == "table" and s.unionTrades or {}) do
|
||||
if k == key then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function Adapter:mark(key)
|
||||
if self.gen == 3 then return end
|
||||
local s = self:save()
|
||||
local list = type(s.unionTrades) == "table" and s.unionTrades or {}
|
||||
list[#list + 1] = key
|
||||
while #list > Txn.MARKERS do table.remove(list, 1) end
|
||||
s.unionTrades = list
|
||||
end
|
||||
|
||||
function Adapter:write()
|
||||
local g = self.game or (self.gen == 3 and game3() or nil)
|
||||
if type(self.writer) == "function" then return self.writer(self) == true end
|
||||
if self.gen == 3 then
|
||||
local Runtime = package.loaded["src.core.game3.runtime"]
|
||||
if Runtime and Runtime._mod and g then
|
||||
local okB, Bridge = pcall(require, "src.core.game3.bridge")
|
||||
if okB and Bridge.persistSessionOnly then
|
||||
if not pcall(Bridge.persistSessionOnly, Runtime._mod, g) then return false end
|
||||
end
|
||||
end
|
||||
if not (g and g.saveGame) then return false end
|
||||
local ok, wrote = pcall(g.saveGame, g)
|
||||
return ok and wrote ~= false
|
||||
end
|
||||
if g and g.writeSave then
|
||||
local ok, wrote = pcall(g.writeSave, g)
|
||||
if not ok then log("save failed: %s", tostring(wrote)) end
|
||||
return ok and wrote ~= false and wrote ~= nil
|
||||
end
|
||||
local s = self:save()
|
||||
if self.gen == 2 then
|
||||
local ok, wrote = pcall(require("src.core.gen2.Save").save, s)
|
||||
return ok and wrote == true
|
||||
end
|
||||
local ok, wrote = pcall(SaveData().save, s)
|
||||
return ok and wrote == true
|
||||
end
|
||||
|
||||
function Adapter:scrubBackup()
|
||||
local fs = disk()
|
||||
local main = SaveData().saveFilename(self.version)
|
||||
if not (fs and main) then return end
|
||||
local bak = main .. ".bak"
|
||||
if not fs.getInfo(bak) then return end
|
||||
local body = readFile(fs, main)
|
||||
if decode(body) then pcall(fs.write, bak, body) end
|
||||
end
|
||||
|
||||
function Adapter:find(test)
|
||||
for _, l in ipairs(self:lists()) do
|
||||
for i, mon in ipairs(l.list) do
|
||||
local rec = self:pack(mon)
|
||||
if rec and test(rec) then return { where = l.where, box = l.box, index = i } end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
Txn.Adapter = Adapter
|
||||
|
||||
function Txn.adapterFor(game, opts)
|
||||
opts = opts or {}
|
||||
if type(opts.adapter) == "table" then return opts.adapter end
|
||||
if type(game) == "table" and type(game.unionTradeAdapter) == "table" then return game.unionTradeAdapter end
|
||||
local version = opts.version or (type(game) == "table" and type(game.save) == "table" and game.save.version)
|
||||
if type(game) == "table" and type(game.session) == "table" and game.session.version then
|
||||
version = opts.version or game.session.version
|
||||
end
|
||||
if not (type(version) == "string" and GameVersion.VERSIONS[version]) then version = GameVersion.get() end
|
||||
return newAdapter(game, GameVersion.generation(version), version)
|
||||
end
|
||||
|
||||
function Txn.newAdapter(game, version)
|
||||
return newAdapter(game, GameVersion.generation(version), version)
|
||||
end
|
||||
|
||||
|
||||
local function locateFor(adapter, entry)
|
||||
if adapter:hasMarker(entry.key) then return nil, "already" end
|
||||
local out = entry.out or {}
|
||||
if out.ref and adapter:canonicalAt(out.ref) == out.canonical then return out.ref end
|
||||
local inId = entry.incomingIdentity
|
||||
if inId and adapter:find(function(r) return identityOf(adapter.gen, r) == inId end) then return nil, "already" end
|
||||
local ref = adapter:find(function(r) return TradeConvert.canonical(r) == out.canonical end)
|
||||
if ref then return ref end
|
||||
ref = out.identity and adapter:find(function(r) return identityOf(adapter.gen, r) == out.identity end)
|
||||
if ref then return ref end
|
||||
return nil, "missing"
|
||||
end
|
||||
|
||||
function Txn.applyEntry(adapter, entry)
|
||||
local ref, why = locateFor(adapter, entry)
|
||||
if not ref then return why end
|
||||
local mon, code = adapter:native(entry.incoming)
|
||||
if not mon then return "invalid", code end
|
||||
local ok, evolved = adapter:put(ref, mon)
|
||||
if not ok then return "invalid", evolved end
|
||||
adapter:mark(entry.key)
|
||||
entry.evolved = evolved
|
||||
return "applied", evolved
|
||||
end
|
||||
|
||||
function Txn.settleCommit(adapter, entry)
|
||||
if Txn._applied[entry.key] == "saved" then
|
||||
Txn.journalDrop(adapter.version, entry.key)
|
||||
return "done"
|
||||
end
|
||||
local status = "applied"
|
||||
if Txn._applied[entry.key] ~= "memory" then
|
||||
local detail
|
||||
status, detail = Txn.applyEntry(adapter, entry)
|
||||
if status == "missing" or status == "invalid" then
|
||||
log("committed trade %s could not be applied: %s %s", entry.key, status, tostring(detail))
|
||||
Txn.journalDrop(adapter.version, entry.key)
|
||||
return status
|
||||
end
|
||||
Txn._applied[entry.key] = "memory"
|
||||
end
|
||||
if not adapter:write() then return "save_failed" end
|
||||
adapter:scrubBackup()
|
||||
Txn._applied[entry.key] = "saved"
|
||||
Txn.journalDrop(adapter.version, entry.key)
|
||||
return status == "already" and "done_already" or "done"
|
||||
end
|
||||
|
||||
function Txn.settle(adapter, entry, outcome)
|
||||
if outcome == "abort" then
|
||||
Txn.journalDrop(adapter.version, entry.key)
|
||||
return "aborted"
|
||||
end
|
||||
if outcome == "commit" then
|
||||
if entry.state ~= "committed" then
|
||||
entry.state = "committed"
|
||||
Txn.journalPut(adapter.version, entry)
|
||||
end
|
||||
return Txn.settleCommit(adapter, entry)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
|
||||
local function liveKeys()
|
||||
local out = {}
|
||||
for _, t in pairs(Txn._live) do
|
||||
if t.entry then out[t.entry.key] = true end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function Txn.step(st, dt)
|
||||
local adapter = st.adapter
|
||||
if not adapter:ready() then return false end
|
||||
local live = liveKeys()
|
||||
if st.job then
|
||||
local status, outcome = lt().pollOutcome(st.job)
|
||||
if status == "pending" then return false end
|
||||
local entry = st.job.entry
|
||||
st.job = nil
|
||||
local result = status == "ok" and Txn.settle(adapter, entry, outcome) or nil
|
||||
local settled = result ~= nil and result ~= "save_failed"
|
||||
st.fails = settled and 0 or math.min((st.fails or 0) + 1, 6)
|
||||
st.wait = (result == "save_failed" and Txn.SAVE_RETRY_SECONDS or Txn.RETRY_SECONDS) * (settled and 1 or 2 ^ st.fails)
|
||||
if settled then st.wait = 0 end
|
||||
st.last = result or outcome or status
|
||||
end
|
||||
if (st.wait or 0) > 0 then
|
||||
st.wait = st.wait - (tonumber(dt) or 0)
|
||||
return false
|
||||
end
|
||||
local open = {}
|
||||
for _, e in ipairs(Txn.readJournal(adapter.version)) do
|
||||
if not live[e.key] then
|
||||
if e.state == "committed" then
|
||||
local r = Txn.settleCommit(adapter, e)
|
||||
st.last = r
|
||||
if r == "save_failed" then
|
||||
st.wait = Txn.SAVE_RETRY_SECONDS
|
||||
return false
|
||||
end
|
||||
elseif now() - (tonumber(e.at) or 0) > Txn.JOURNAL_TTL then
|
||||
Txn.journalDrop(adapter.version, e.key)
|
||||
else
|
||||
open[#open + 1] = e
|
||||
end
|
||||
end
|
||||
end
|
||||
if #open == 0 then return true end
|
||||
st.index = ((st.index or 0) % #open) + 1
|
||||
st.job = lt().fetchOutcome(open[st.index])
|
||||
return false
|
||||
end
|
||||
|
||||
local function tick()
|
||||
local st = Txn._resolver
|
||||
if not st then return end
|
||||
local t = (type(love) == "table" and love.timer and love.timer.getTime) and love.timer.getTime() or os.clock()
|
||||
local dt = st.lastTick and (t - st.lastTick) or 0
|
||||
st.lastTick = t
|
||||
local ok, done = pcall(Txn.step, st, dt)
|
||||
if not ok then
|
||||
log("resolver failed: %s", tostring(done))
|
||||
done = true
|
||||
end
|
||||
if done then
|
||||
Txn._resolver = nil
|
||||
return
|
||||
end
|
||||
require("src.core.DeferredWrite").schedule(Txn.TICK_KEY, tick)
|
||||
end
|
||||
|
||||
function Txn.resumePending(game, opts)
|
||||
local adapter = Txn.adapterFor(game, opts)
|
||||
if #Txn.readJournal(adapter.version) == 0 then return nil end
|
||||
if Txn._resolver and Txn._resolver.adapter.version == adapter.version then
|
||||
Txn._resolver.adapter = adapter
|
||||
return Txn._resolver
|
||||
end
|
||||
local st = { adapter = adapter, wait = 0, fails = 0, index = 0 }
|
||||
Txn._resolver = st
|
||||
if adapter:ready() then
|
||||
for _, e in ipairs(Txn.readJournal(adapter.version)) do
|
||||
if e.state == "committed" and not liveKeys()[e.key] then Txn.settleCommit(adapter, e) end
|
||||
end
|
||||
end
|
||||
if not (opts and opts.manual) then
|
||||
require("src.core.DeferredWrite").schedule(Txn.TICK_KEY, tick)
|
||||
end
|
||||
return st
|
||||
end
|
||||
|
||||
|
||||
function Txn.new(opts)
|
||||
local adapter = opts.adapter or Txn.adapterFor(opts.game, opts)
|
||||
local self = setmetatable({
|
||||
game = opts.game, prep = opts.prep, model = opts.model, adapter = adapter,
|
||||
roomId = opts.roomId, peerName = opts.peerName, state = "prep", entry = nil,
|
||||
digest = nil, events = {}, saveWait = 0,
|
||||
}, Txn)
|
||||
adapter.peerName = opts.peerName
|
||||
Txn._live[self] = self
|
||||
return self
|
||||
end
|
||||
|
||||
function Txn:emit(kind, fields)
|
||||
local e = { kind = kind }
|
||||
for k, v in pairs(fields or {}) do e[k] = v end
|
||||
self.events[#self.events + 1] = e
|
||||
end
|
||||
|
||||
function Txn:room()
|
||||
if self.roomId then return self.roomId end
|
||||
local session = self.prep and self.prep.session
|
||||
if session and session.snapshot then
|
||||
local _, r = session.snapshot()
|
||||
if type(r) == "table" and type(r.room) == "string" then return r.room end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Txn:send(msg)
|
||||
local session = self.prep and self.prep.session
|
||||
return session and session.send and session.send(msg) and true or false
|
||||
end
|
||||
|
||||
function Txn:cancel(why)
|
||||
self.state = "aborted"
|
||||
self.why = why
|
||||
if self.prep and self.prep.cancel then self.prep:cancel(why) end
|
||||
self:emit("aborted", { why = why, roundEnded = true })
|
||||
end
|
||||
|
||||
function Txn:recheck()
|
||||
local m = self.model and self.model.mine
|
||||
if not (m and m.half) then return false, "no_offer" end
|
||||
local s = self.adapter:save()
|
||||
if type(s) ~= "table" then return false, "no_save" end
|
||||
local list = self.adapter:listFor(m.ref)
|
||||
if not (list and list[m.ref.index]) then return false, "capacity" end
|
||||
if self.adapter:canonicalAt(m.ref) ~= m.half.source then return false, "changed" end
|
||||
local ok, code = self.adapter:validate(self.model.peer and self.model.peer.final)
|
||||
if not ok then return false, code end
|
||||
return true
|
||||
end
|
||||
|
||||
function Txn:readyDigest()
|
||||
local prep, m = self.prep, self.model
|
||||
if not (prep and m and prep.mine.offer and prep.peer.offer) then return nil end
|
||||
local mineRev = prep.mine.offer.offerRev
|
||||
local peerRev = prep.peer.offer.offerRev
|
||||
return m:agreed(prep:seat(), mineRev, peerRev)
|
||||
end
|
||||
|
||||
function Txn:confirm()
|
||||
if self.state ~= "prep" and self.state ~= "ready" then return false end
|
||||
local digest = self:readyDigest()
|
||||
if not digest then
|
||||
self:cancel("no_offer")
|
||||
return false
|
||||
end
|
||||
local ok, why = self:recheck()
|
||||
if not ok then
|
||||
self:cancel("recheck_" .. tostring(why))
|
||||
return false
|
||||
end
|
||||
local room = self:room()
|
||||
if not room then
|
||||
self:cancel("no_room")
|
||||
return false
|
||||
end
|
||||
local m = self.model
|
||||
local round = (self.prep and self.prep.round or 0) + 1
|
||||
local entry = {
|
||||
key = Txn.entryKey(room, round, digest), room = room, n = round, digest = digest, at = now(),
|
||||
gen = self.adapter.gen, version = self.adapter.version, state = "confirming",
|
||||
peer = { name = self.peerName, version = m.peer and m.peer.version },
|
||||
out = { ref = copy(m.mine.ref), record = copy(m.mine.rec), canonical = m.mine.half.source,
|
||||
identity = identityOf(self.adapter.gen, m.mine.rec) },
|
||||
incoming = copy(m.peer.final), incomingCanonical = m.peer.half.result,
|
||||
incomingIdentity = identityOf(self.adapter.gen, m.peer.final),
|
||||
}
|
||||
if not Txn.journalPut(self.adapter.version, entry) then
|
||||
self:cancel("journal")
|
||||
return false
|
||||
end
|
||||
self.entry, self.digest = entry, digest
|
||||
self.state = "commit_wait"
|
||||
self:send({ type = "trade_confirm", digest = digest })
|
||||
self:emit("confirming", { digest = digest })
|
||||
return true
|
||||
end
|
||||
|
||||
function Txn:onCommit(msg)
|
||||
if self.state ~= "commit_wait" then return false end
|
||||
local d = type(msg) == "table" and msg.digests or nil
|
||||
if type(d) ~= "table" or d[1] ~= self.digest or d[2] ~= self.digest then return false end
|
||||
local entry = self.entry
|
||||
entry.state = "committed"
|
||||
entry.commitN = tonumber(msg.n)
|
||||
Txn.journalPut(self.adapter.version, entry)
|
||||
local status, detail = Txn.applyEntry(self.adapter, entry)
|
||||
if status == "missing" or status == "invalid" then
|
||||
log("committed trade %s could not be applied: %s %s", entry.key, status, tostring(detail))
|
||||
Txn.journalDrop(self.adapter.version, entry.key)
|
||||
self.state = "failed"
|
||||
self:emit("failed", { why = status })
|
||||
return true
|
||||
end
|
||||
Txn._applied[entry.key] = "memory"
|
||||
self.evolved = entry.evolved
|
||||
self.state = "saving"
|
||||
self:trySave()
|
||||
return true
|
||||
end
|
||||
|
||||
function Txn:trySave()
|
||||
if self.state ~= "saving" then return false end
|
||||
if self.adapter:write() then
|
||||
self.adapter:scrubBackup()
|
||||
Txn._applied[self.entry.key] = "saved"
|
||||
Txn.journalDrop(self.adapter.version, self.entry.key)
|
||||
self.state = "done"
|
||||
self:emit("done", { evolved = self.evolved })
|
||||
return true
|
||||
end
|
||||
self.saveFailed = true
|
||||
self.saveWait = Txn.SAVE_RETRY_SECONDS
|
||||
self:emit("save_failed", {})
|
||||
return false
|
||||
end
|
||||
|
||||
function Txn:onAbort(msg)
|
||||
if self.state ~= "commit_wait" then return false end
|
||||
Txn.journalDrop(self.adapter.version, self.entry.key)
|
||||
self.state = "aborted"
|
||||
self.why = type(msg) == "table" and msg.why or "aborted"
|
||||
self:emit("aborted", { why = self.why })
|
||||
return true
|
||||
end
|
||||
|
||||
function Txn:lost()
|
||||
if self.state ~= "commit_wait" then return false end
|
||||
self.state = "unresolved"
|
||||
Txn._live[self] = nil
|
||||
Txn.resumePending(self.game, { adapter = self.adapter })
|
||||
self:emit("unresolved", {})
|
||||
return true
|
||||
end
|
||||
|
||||
local function isBarrier(m)
|
||||
return type(m) == "table" and (m.type == "trade_commit" or m.type == "trade_abort")
|
||||
end
|
||||
|
||||
function Txn:pump(dt)
|
||||
local session = self.prep and self.prep.session
|
||||
if self.state == "commit_wait" and session and session.take then
|
||||
for _ = 1, 16 do
|
||||
local m = session.take(isBarrier)
|
||||
if not m then break end
|
||||
if m.type == "trade_commit" then self:onCommit(m) else self:onAbort(m) end
|
||||
if self.state ~= "commit_wait" then break end
|
||||
end
|
||||
if self.state == "commit_wait" and session.open and not session.open() then self:lost() end
|
||||
elseif self.state ~= "commit_wait" and session and session.take then
|
||||
for _ = 1, 16 do
|
||||
if not session.take(isBarrier) then break end
|
||||
end
|
||||
end
|
||||
if self.state == "saving" then
|
||||
self.saveWait = self.saveWait - (tonumber(dt) or 0)
|
||||
if self.saveWait <= 0 then self:trySave() end
|
||||
end
|
||||
local out = self.events
|
||||
self.events = {}
|
||||
return out
|
||||
end
|
||||
|
||||
function Txn:nextRound()
|
||||
if self.state == "commit_wait" or self.state == "saving" then return false end
|
||||
self.state, self.entry, self.digest, self.evolved, self.why = "prep", nil, nil, nil, nil
|
||||
return true
|
||||
end
|
||||
|
||||
function Txn:close()
|
||||
if self.state == "commit_wait" then self:lost() end
|
||||
Txn._live[self] = nil
|
||||
if self.state == "saving" then Txn.resumePending(self.game, { adapter = self.adapter }) end
|
||||
end
|
||||
|
||||
function Txn.reset()
|
||||
Txn._applied = {}
|
||||
Txn._live = {}
|
||||
Txn._resolver = nil
|
||||
end
|
||||
|
||||
return Txn
|
||||
@@ -0,0 +1,304 @@
|
||||
local Datasets = require("src.online.xgen.Datasets")
|
||||
local Policy = require("src.online.xgen.Policy")
|
||||
local Project = require("src.online.xgen.Project")
|
||||
local TradeConvert = require("src.online.xgen.TradeConvert")
|
||||
|
||||
local Compat = {}
|
||||
|
||||
local copy = Project.copy
|
||||
|
||||
local function asSet(list)
|
||||
local out = {}
|
||||
if type(list) ~= "table" then return out end
|
||||
for k, v in pairs(list) do
|
||||
if v == true then out[tonumber(k) or k] = true
|
||||
elseif tonumber(v) then out[tonumber(v)] = true end
|
||||
end
|
||||
return out
|
||||
end
|
||||
Compat.asSet = asSet
|
||||
|
||||
local function resolveRuleset(target)
|
||||
target = target or {}
|
||||
local id = target.ruleset or "g3u"
|
||||
if id == "native" then return { id = "native", engine = "native", gen = target.gen } end
|
||||
local base = Policy.ruleset(id) or (target.gen and Policy.rulesetForGen(target.gen)) or nil
|
||||
local r = { id = base and base.id or id, engine = "g3u", gen = base and base.gen or target.gen,
|
||||
dexMax = tonumber(target.dexMax) or (base and base.dexMax), moveMax = tonumber(target.moveMax) or (base and base.moveMax) }
|
||||
return r
|
||||
end
|
||||
Compat.resolveRuleset = resolveRuleset
|
||||
|
||||
function Compat.speciesEligible(national, ruleset)
|
||||
national = tonumber(national)
|
||||
if not national then return false, "species_unknown" end
|
||||
if national > (ruleset.dexMax or 0) then
|
||||
return false, "species_not_in_ruleset", { national = national, dexMax = ruleset.dexMax }
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function Compat.moveStatus(data, national, level, move, ruleset, unsupported)
|
||||
move = tonumber(move)
|
||||
if not move or move > (ruleset.moveMax or 0) or not data.moves[move] then
|
||||
return "move_not_in_ruleset"
|
||||
end
|
||||
if unsupported and unsupported[move] then return "move_unsupported" end
|
||||
if not Datasets.learnable(data, national, move, level) then return "move_not_legal" end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Compat.legalMoves(data, national, level, ruleset, unsupported)
|
||||
local out = {}
|
||||
for move in pairs(Datasets.learnSources(data, national, level)) do
|
||||
if not Compat.moveStatus(data, national, level, move, ruleset, unsupported) then out[#out + 1] = move end
|
||||
end
|
||||
table.sort(out)
|
||||
return out
|
||||
end
|
||||
|
||||
function Compat.moveSuggestions(data, national, level, ruleset, original, exclude, unsupported)
|
||||
local orig = data.moves[tonumber(original) or -1]
|
||||
local rows = {}
|
||||
for _, move in ipairs(Compat.legalMoves(data, national, level, ruleset, unsupported)) do
|
||||
if not (exclude and exclude[move]) then
|
||||
local mv = data.moves[move]
|
||||
rows[#rows + 1] = { move = move, name = mv.name, key = mv.key or "", type = mv.type,
|
||||
category = mv.category, power = mv.power,
|
||||
sameType = orig ~= nil and mv.type == orig.type,
|
||||
sameBand = orig ~= nil and mv.category == orig.category and Policy.powerBand(mv.power) == Policy.powerBand(orig.power) }
|
||||
end
|
||||
end
|
||||
table.sort(rows, function(a, b)
|
||||
if a.sameType ~= b.sameType then return a.sameType end
|
||||
if a.sameBand ~= b.sameBand then return a.sameBand end
|
||||
if a.key ~= b.key then return a.key < b.key end
|
||||
return a.move < b.move
|
||||
end)
|
||||
return rows
|
||||
end
|
||||
|
||||
local function sharesType(a, b)
|
||||
for _, x in ipairs(a or {}) do
|
||||
for _, y in ipairs(b or {}) do
|
||||
if x == y then return true end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function Compat.replacements(data, slotView, owned, ruleset, exclude, unsupported)
|
||||
local want = data.species[slotView.national]
|
||||
local wantTypes = want and want.types or {}
|
||||
local rows = {}
|
||||
for index, entry in ipairs(owned or {}) do
|
||||
if not (exclude and exclude[index]) then
|
||||
local rec = entry.rec or entry
|
||||
local view = Project.read(rec, data)
|
||||
if view and not view.isEgg and Compat.speciesEligible(view.national, ruleset)
|
||||
and #Compat.legalMoves(data, view.national, view.level, ruleset, unsupported) > 0 then
|
||||
local types = data.species[view.national].types
|
||||
rows[#rows + 1] = { index = index, ref = entry.ref, national = view.national, level = view.level,
|
||||
name = data.species[view.national].name,
|
||||
sharesType = sharesType(types, wantTypes), samePrimary = types[1] == wantTypes[1],
|
||||
distance = math.abs(view.level - slotView.level) }
|
||||
end
|
||||
end
|
||||
end
|
||||
table.sort(rows, function(a, b)
|
||||
if a.sharesType ~= b.sharesType then return a.sharesType end
|
||||
if a.samePrimary ~= b.samePrimary then return a.samePrimary end
|
||||
if a.distance ~= b.distance then return a.distance < b.distance end
|
||||
return a.index < b.index
|
||||
end)
|
||||
return rows
|
||||
end
|
||||
|
||||
function Compat.rentalCandidates(data, slotView, rentals)
|
||||
local want = data.species[slotView.national]
|
||||
local wantTypes = want and want.types or {}
|
||||
local rows = {}
|
||||
for index, r in ipairs(rentals or {}) do
|
||||
local types = r.types or {}
|
||||
rows[#rows + 1] = { index = index, rental = true, national = r.national, type = r.type,
|
||||
sharesType = sharesType(types, wantTypes), samePrimary = types[1] == wantTypes[1] }
|
||||
end
|
||||
table.sort(rows, function(a, b)
|
||||
if a.sharesType ~= b.sharesType then return a.sharesType end
|
||||
if a.samePrimary ~= b.samePrimary then return a.samePrimary end
|
||||
return a.index < b.index
|
||||
end)
|
||||
return rows
|
||||
end
|
||||
|
||||
function Compat.teamSize(mine, theirs, requested)
|
||||
mine, theirs = tonumber(mine) or 0, tonumber(theirs) or mine
|
||||
local size = math.min(mine, theirs)
|
||||
requested = tonumber(requested)
|
||||
if requested and requested >= 1 and requested <= size then size = requested end
|
||||
return math.max(0, math.min(6, size))
|
||||
end
|
||||
|
||||
local function versionBlocks(args, block)
|
||||
local v = args.versions
|
||||
if type(v) ~= "table" then return end
|
||||
if v.policy ~= nil and v.policy ~= Policy.VERSION then block(nil, "policy_mismatch", nil, { mine = Policy.VERSION, theirs = v.policy }) end
|
||||
if v.proto ~= nil and v.proto ~= Policy.PROTO then block(nil, "proto_mismatch", nil, { mine = Policy.PROTO, theirs = v.proto }) end
|
||||
end
|
||||
|
||||
local function battleReport(args, report, block)
|
||||
local source = args.source or {}
|
||||
local data = source.data
|
||||
local ruleset = resolveRuleset(args.target)
|
||||
local adjustments = args.adjustments or {}
|
||||
local unsupported = asSet(args.unsupported)
|
||||
local legacy = not (args.target and args.target.legacyPresent == false)
|
||||
if type(data) ~= "table" then block(nil, "missing_import", nil, { need = "source" }) return end
|
||||
local mons = args.mons or {}
|
||||
local replace = adjustments.replace or {}
|
||||
local sitOut = asSet(adjustments.sitOut)
|
||||
local active = {}
|
||||
for slot = 1, #mons do
|
||||
if not sitOut[slot] then active[#active + 1] = slot end
|
||||
end
|
||||
local size = Compat.teamSize(#mons, args.opponentSize, adjustments.size)
|
||||
if #active > size then
|
||||
block(nil, "choose_sit_out", "team", { need = #active - size, size = size })
|
||||
local choices = {}
|
||||
for _, slot in ipairs(active) do choices[#choices + 1] = slot end
|
||||
report.options.sitOut = choices
|
||||
elseif #active < size or #active == 0 then
|
||||
block(nil, "team_too_small", "team", { size = size, have = #active })
|
||||
end
|
||||
report.options.replacements, report.options.moves, report.options.rentals = {}, {}, {}
|
||||
report.options.allowEmpty = true
|
||||
local team = {}
|
||||
local usedOwned = {}
|
||||
for _, slot in ipairs(active) do
|
||||
local r = replace[slot]
|
||||
if r and r.owned then usedOwned[r.owned] = true end
|
||||
end
|
||||
for _, entry in ipairs(args.ownedInTeam or {}) do usedOwned[entry] = true end
|
||||
if ruleset.engine == "native" then
|
||||
if source.gen and ruleset.gen and source.gen ~= ruleset.gen then
|
||||
block(nil, "native_needs_same_gen", nil, { mine = source.gen, ruleset = ruleset.gen })
|
||||
end
|
||||
for _, slot in ipairs(active) do team[#team + 1] = copy(mons[slot]) end
|
||||
report.result = { ruleset = ruleset.id, size = size, team = team, slots = copy(active) }
|
||||
return
|
||||
end
|
||||
for _, slot in ipairs(active) do
|
||||
local r = replace[slot]
|
||||
local rec = mons[slot]
|
||||
local rental = nil
|
||||
if r and r.owned then
|
||||
local entry = (args.owned or {})[r.owned]
|
||||
rec = entry and (entry.rec or entry)
|
||||
report.changes[#report.changes + 1] = { slot = slot, field = "species", from = slot, to = { owned = r.owned }, kind = "change" }
|
||||
elseif r and r.rental then
|
||||
rental = (args.rentals or {})[r.rental]
|
||||
report.changes[#report.changes + 1] = { slot = slot, field = "species", from = slot, to = { rental = r.rental }, kind = "change" }
|
||||
end
|
||||
if rental then
|
||||
team[#team + 1] = copy(rental.record or rental)
|
||||
else
|
||||
local view, code, detail = Project.read(rec, data)
|
||||
if not view then
|
||||
block(slot, code, nil, detail)
|
||||
elseif view.isEgg then
|
||||
block(slot, "egg", "isEgg")
|
||||
else
|
||||
local okSpecies, why, info = Compat.speciesEligible(view.national, ruleset)
|
||||
if not okSpecies then
|
||||
block(slot, why, "species", info)
|
||||
report.options.replacements[slot] = Compat.replacements(data, view, args.owned, ruleset, usedOwned, unsupported)
|
||||
report.options.rentals[slot] = Compat.rentalCandidates(data, view, args.rentals)
|
||||
else
|
||||
local moveAdj = (adjustments.moves or {})[slot] or {}
|
||||
local final, seen, current = {}, {}, {}
|
||||
for _, m in ipairs(view.moves) do current[m.move] = true end
|
||||
for j, m in ipairs(view.moves) do
|
||||
local adj = moveAdj[j]
|
||||
if adj ~= nil then
|
||||
if adj == 0 or adj == false then
|
||||
report.changes[#report.changes + 1] = { slot = slot, field = "moves", from = m.move, to = 0, kind = "change", index = j }
|
||||
else
|
||||
local status = Compat.moveStatus(data, view.national, view.level, adj, ruleset, unsupported)
|
||||
if status or seen[adj] then
|
||||
block(slot, "replacement_not_legal", "moves", { index = j, move = adj, why = status or "duplicate" })
|
||||
else
|
||||
seen[adj] = true
|
||||
final[#final + 1] = { move = adj, ppUps = 0 }
|
||||
report.changes[#report.changes + 1] = { slot = slot, field = "moves", from = m.move, to = adj, kind = "change", index = j }
|
||||
end
|
||||
end
|
||||
else
|
||||
local status = Compat.moveStatus(data, view.national, view.level, m.move, ruleset, unsupported)
|
||||
if status then
|
||||
block(slot, status, "moves", { index = j, move = m.move })
|
||||
report.options.moves[slot] = report.options.moves[slot] or {}
|
||||
report.options.moves[slot][j] = Compat.moveSuggestions(data, view.national, view.level, ruleset, m.move, current, unsupported)
|
||||
elseif not seen[m.move] then
|
||||
seen[m.move] = true
|
||||
final[#final + 1] = { move = m.move, ppUps = m.ppUps }
|
||||
end
|
||||
end
|
||||
end
|
||||
local pending = report.options.moves[slot] ~= nil
|
||||
if #final == 0 and not pending then block(slot, "no_legal_moves", "moves") end
|
||||
local projected = Project.battleMon(view, data, { moves = final, legacyPresent = legacy })
|
||||
if view.gen < 3 then
|
||||
report.changes[#report.changes + 1] = { slot = slot, field = "dvs", from = copy(view.dvs), to = copy(projected.ivs), kind = "change" }
|
||||
report.changes[#report.changes + 1] = { slot = slot, field = "statExp", from = copy(view.statExp), to = copy(projected.evs), kind = "change" }
|
||||
elseif legacy then
|
||||
if view.nature ~= Policy.NATURE_NEUTRAL then
|
||||
report.changes[#report.changes + 1] = { slot = slot, field = "nature", from = view.nature, to = Policy.NATURE_NEUTRAL, kind = "change" }
|
||||
end
|
||||
if view.ability and view.ability ~= 0 then
|
||||
report.changes[#report.changes + 1] = { slot = slot, field = "ability", from = view.ability, to = 0, kind = "change" }
|
||||
end
|
||||
if view.item and view.item ~= 0 then
|
||||
report.changes[#report.changes + 1] = { slot = slot, field = "item", from = view.item, to = 0, kind = "change" }
|
||||
end
|
||||
end
|
||||
team[#team + 1] = projected
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
report.result = { ruleset = ruleset.id, dexMax = ruleset.dexMax, moveMax = ruleset.moveMax,
|
||||
size = size, team = team, slots = copy(active) }
|
||||
end
|
||||
|
||||
function Compat.report(args)
|
||||
args = args or {}
|
||||
local report = { ok = false, blocks = {}, options = {}, changes = {}, result = nil }
|
||||
local function block(slot, code, field, detail)
|
||||
report.blocks[#report.blocks + 1] = { slot = slot, code = code, field = field, detail = detail }
|
||||
end
|
||||
versionBlocks(args, block)
|
||||
if args.op == "battle" then
|
||||
battleReport(args, report, block)
|
||||
elseif args.op == "trade" then
|
||||
local mon = args.mon or (args.mons or {})[1]
|
||||
local t = TradeConvert.convert({ source = args.source, target = args.target, mon = mon,
|
||||
adjustments = args.adjustments, slot = args.slot or 1 })
|
||||
for _, b in ipairs(t.blocks) do report.blocks[#report.blocks + 1] = b end
|
||||
for _, c in ipairs(t.changes) do report.changes[#report.changes + 1] = c end
|
||||
report.options = t.options
|
||||
report.result = t.result
|
||||
report.preview = t.preview
|
||||
report.canonical = t.canonical
|
||||
report.accounting = t.accounting
|
||||
else
|
||||
block(nil, "bad_op", nil, { op = args.op })
|
||||
end
|
||||
report.ok = #report.blocks == 0
|
||||
if not report.ok and args.op == "battle" then
|
||||
report.preview = report.result
|
||||
report.result = nil
|
||||
end
|
||||
return report
|
||||
end
|
||||
|
||||
return Compat
|
||||
@@ -0,0 +1,408 @@
|
||||
local Identity = require("src.online.xgen.Identity")
|
||||
|
||||
local Datasets = {}
|
||||
|
||||
local cached = {}
|
||||
local readerOverride = nil
|
||||
|
||||
local GB_FILES = { pokemon = "data/generated/pokemon.lua", moves = "data/generated/moves.lua",
|
||||
items = "data/generated/items.lua" }
|
||||
local GBA_ROOT = "data/generated/gba/pokemon/"
|
||||
local GBA_FILES = {
|
||||
names = GBA_ROOT .. "names.lua", national = GBA_ROOT .. "national.lua",
|
||||
types = GBA_ROOT .. "types.lua", stats = GBA_ROOT .. "stats.lua", meta = GBA_ROOT .. "meta.lua",
|
||||
abilities = GBA_ROOT .. "abilities.lua", battleMoves = GBA_ROOT .. "battle_moves.lua",
|
||||
moveNames = GBA_ROOT .. "move_names.lua", learnsets = GBA_ROOT .. "learnsets.lua",
|
||||
tmhm = GBA_ROOT .. "tmhm.lua", tutor = GBA_ROOT .. "tutor.lua", eggMoves = GBA_ROOT .. "egg_moves.lua",
|
||||
evolutions = GBA_ROOT .. "evolutions.lua", items = "data/generated/gba/items/pack.lua",
|
||||
typeNames = GBA_ROOT .. "type_names.lua",
|
||||
}
|
||||
local GBA_REQUIRED = { "names", "national", "types", "stats", "meta", "battleMoves", "moveNames",
|
||||
"learnsets", "tmhm", "eggMoves", "evolutions", "items" }
|
||||
local NO_TUTORS = { ruby = true, sapphire = true }
|
||||
|
||||
local function versionInfo(version)
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
return GameVersion.generation(version), GameVersion.cachePrefix(version)
|
||||
end
|
||||
|
||||
local function defaultReader(version, rel)
|
||||
local _, prefix = versionInfo(version)
|
||||
return require("src.import.CacheFs").readAt(prefix .. rel)
|
||||
end
|
||||
|
||||
function Datasets.directoryReader(root)
|
||||
local CacheBlob = require("src.import.CacheBlob")
|
||||
return function(version, rel)
|
||||
local _, prefix = versionInfo(version)
|
||||
local path = root .. "/" .. prefix .. rel
|
||||
local f = io.open(path, "rb")
|
||||
if not f then return nil end
|
||||
local body = f:read("*a")
|
||||
f:close()
|
||||
return CacheBlob.decode(path, body)
|
||||
end
|
||||
end
|
||||
|
||||
function Datasets.setReader(fn)
|
||||
readerOverride = fn
|
||||
cached = {}
|
||||
end
|
||||
|
||||
function Datasets.reset()
|
||||
cached = {}
|
||||
end
|
||||
|
||||
local function evaluate(body, label)
|
||||
if type(body) ~= "string" then return nil end
|
||||
local chunk = (loadstring or load)(body, "@" .. label)
|
||||
if not chunk then return nil end
|
||||
if setfenv then setfenv(chunk, {}) end
|
||||
local ok, value = pcall(chunk)
|
||||
return ok and type(value) == "table" and value or nil
|
||||
end
|
||||
|
||||
local function readTables(version, reader)
|
||||
local generation = versionInfo(version)
|
||||
local files = generation == 3 and GBA_FILES or GB_FILES
|
||||
local out = {}
|
||||
for name, rel in pairs(files) do
|
||||
out[name] = evaluate(reader(version, rel), version .. "/" .. rel)
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function placeholder(name)
|
||||
return type(name) ~= "string" or name == "" or name:match("^%?+$") ~= nil or name == "TERU-SAMA"
|
||||
end
|
||||
|
||||
local function addLevel(list, level, move)
|
||||
if move then list[#list + 1] = { level = tonumber(level) or 1, move = move } end
|
||||
end
|
||||
|
||||
local function sortLevels(list)
|
||||
for i, row in ipairs(list) do row.order = i end
|
||||
table.sort(list, function(x, y)
|
||||
if x.level ~= y.level then return x.level < y.level end
|
||||
return x.order < y.order
|
||||
end)
|
||||
for _, row in ipairs(list) do row.order = nil end
|
||||
end
|
||||
|
||||
local function newData(version, generation)
|
||||
return {
|
||||
version = version, generation = generation,
|
||||
dexMax = generation == 1 and 151 or generation == 2 and 251 or 386,
|
||||
moveMax = generation == 1 and 165 or generation == 2 and 251 or 354,
|
||||
species = {}, moves = {},
|
||||
localToNational = {}, nationalToLocal = {},
|
||||
localToMove = {}, moveToLocal = {},
|
||||
localToType = {}, typeToLocal = {},
|
||||
items = { byLocal = {}, byKey = {}, held = generation ~= 1 },
|
||||
problems = {},
|
||||
}
|
||||
end
|
||||
|
||||
local function problem(data, code, detail)
|
||||
data.problems[#data.problems + 1] = { code = code, detail = detail }
|
||||
end
|
||||
|
||||
local function buildGB(data, raw)
|
||||
local generation = data.generation
|
||||
local Growth = generation == 1 and require("src.pokemon.Growth") or nil
|
||||
local Mon = generation == 2 and require("src.battle.gen2.Mon") or nil
|
||||
local growthRows = raw.pokemon and raw.pokemon.growthRates
|
||||
for key, def in pairs(raw.moves or {}) do
|
||||
if type(def) == "table" and tonumber(def.index) and def.index >= 1 and def.index <= data.moveMax then
|
||||
local id = def.index
|
||||
local typeName = Identity.canonType(def.type, generation)
|
||||
data.localToMove[key], data.moveToLocal[id] = id, key
|
||||
if def.type ~= nil and typeName then data.localToType[def.type], data.typeToLocal[typeName] = typeName, def.type end
|
||||
data.moves[id] = { id = id, key = Identity.normalize(def.name or key), name = def.name or tostring(key),
|
||||
localKey = key, type = typeName, power = tonumber(def.power) or 0, accuracy = tonumber(def.accuracy),
|
||||
pp = tonumber(def.pp) or 0, effect = def.effect, effectChance = def.effectChance,
|
||||
category = Identity.category(typeName, def.power) }
|
||||
end
|
||||
end
|
||||
for key, def in pairs(raw.pokemon or {}) do
|
||||
if type(def) == "table" and tonumber(def.dex) and def.dex >= 1 and def.dex <= data.dexMax then
|
||||
local n = def.dex
|
||||
data.localToNational[key], data.nationalToLocal[n] = n, key
|
||||
local types = {}
|
||||
for _, t in ipairs(def.types or {}) do
|
||||
local name = Identity.canonType(t, generation)
|
||||
if name and types[#types] ~= name then types[#types + 1] = name end
|
||||
end
|
||||
local b = def.baseStats or {}
|
||||
local base = { hp = b.hp, atk = b.attack, def = b.defense, spe = b.speed,
|
||||
spa = b.specialAttack or b.special, spd = b.specialDefense or b.special, special = b.special }
|
||||
local curve = def.growthRate
|
||||
local exp = {}
|
||||
for level = 1, 100 do
|
||||
if generation == 1 then
|
||||
exp[level] = Growth.expForLevel(curve, level)
|
||||
else
|
||||
exp[level] = Mon.experienceForLevel(growthRows and growthRows[curve], level)
|
||||
end
|
||||
end
|
||||
local levelMoves, tmhm, tutor, egg = {}, {}, {}, {}
|
||||
for _, m in ipairs(def.level1Moves or {}) do addLevel(levelMoves, 1, m) end
|
||||
for _, row in ipairs(def.learnset or {}) do addLevel(levelMoves, row.level, row.move) end
|
||||
for _, row in ipairs(def.levelMoves or {}) do addLevel(levelMoves, row.level, row.move) end
|
||||
for _, m in ipairs(def.tmhm or {}) do tmhm[#tmhm + 1] = m end
|
||||
for _, m in ipairs(def.tutorMoves or {}) do tutor[#tutor + 1] = m end
|
||||
for _, m in ipairs(def.eggMoves or {}) do egg[#egg + 1] = m end
|
||||
local evolutions = {}
|
||||
for _, evo in ipairs(def.evolutions or {}) do
|
||||
evolutions[#evolutions + 1] = { into = evo.into or evo.species, method = evo.method,
|
||||
level = evo.level, item = evo.item }
|
||||
end
|
||||
data.species[n] = { national = n, key = Identity.normalize(def.name or key), name = def.name or tostring(key),
|
||||
localKey = key, types = types, base = base, genderRatio = def.genderRatio,
|
||||
catchRate = def.catchRate, growth = curve, exp = exp, friendship = generation == 2 and 70 or nil,
|
||||
rawLevel = levelMoves, rawTmhm = tmhm, rawTutor = tutor, rawEgg = egg, rawEvolutions = evolutions }
|
||||
end
|
||||
end
|
||||
local Mail = generation == 2 and require("src.core.gen2.Mail") or nil
|
||||
for key, def in pairs(generation == 2 and raw.items or {}) do
|
||||
if type(def) == "table" and not placeholder(def.name) then
|
||||
local norm = Identity.normalize(def.name)
|
||||
if norm then
|
||||
data.items.byLocal[key] = { key = norm, name = def.name, localKey = key,
|
||||
mail = Mail and Mail.isMail(key) or false, index = def.index }
|
||||
if data.items.byKey[norm] ~= nil and data.items.byKey[norm] ~= key then
|
||||
problem(data, "item_name_collision", { key = norm })
|
||||
end
|
||||
data.items.byKey[norm] = key
|
||||
end
|
||||
end
|
||||
end
|
||||
data.items.timeCapsule = raw.items and raw.items.timeCapsule
|
||||
data.raw = { items = raw.items, pokemon = raw.pokemon, moves = raw.moves }
|
||||
end
|
||||
|
||||
local CAPE_BRINK_VERSIONS = { firered = true, leafgreen = true }
|
||||
|
||||
local function bitSet(value, index)
|
||||
return math.floor((tonumber(value) or 0) / 2 ^ index) % 2 == 1
|
||||
end
|
||||
|
||||
local function buildGBA(data, raw)
|
||||
local generation = 3
|
||||
local SummaryData = require("src.core.game3.summary_data")
|
||||
for id = 1, data.moveMax do
|
||||
local row = raw.battleMoves and raw.battleMoves.moves and raw.battleMoves.moves[id]
|
||||
local name = raw.moveNames and raw.moveNames[id]
|
||||
if type(row) == "table" and not placeholder(name) then
|
||||
local typeName = Identity.canonType(row.type, generation)
|
||||
data.localToMove[id], data.moveToLocal[id] = id, id
|
||||
data.moves[id] = { id = id, key = Identity.normalize(name), name = name, localKey = id,
|
||||
type = typeName, power = tonumber(row.power) or 0, accuracy = tonumber(row.accuracy),
|
||||
pp = tonumber(row.pp) or 0, priority = tonumber(row.priority) or 0, effect = row.effect,
|
||||
effectChance = row.secondaryChance, target = row.target, flags = row.flags,
|
||||
category = Identity.category(typeName, row.power) }
|
||||
end
|
||||
end
|
||||
for id = 0, 17 do
|
||||
local name = Identity.GEN3_TYPES[id]
|
||||
data.localToType[id], data.typeToLocal[name] = name, id
|
||||
local shown = raw.typeNames and raw.typeNames[id]
|
||||
if shown and name ~= "MYSTERY" and not Identity.subsequence(Identity.normalize(shown) or "", name) then
|
||||
problem(data, "type_name_mismatch", { id = id, shown = shown, canonical = name })
|
||||
end
|
||||
end
|
||||
local toSpecies = raw.national and raw.national.toSpecies or {}
|
||||
local machines = raw.tmhm and raw.tmhm.machines or {}
|
||||
local tmLearn = raw.tmhm and raw.tmhm.learnsets or {}
|
||||
local tutorMoves = raw.tutor and raw.tutor.moves or {}
|
||||
local tutorLearn = raw.tutor and raw.tutor.learnsets or {}
|
||||
local tutorCount = 0
|
||||
while tutorMoves[tutorCount] ~= nil do tutorCount = tutorCount + 1 end
|
||||
for n = 1, data.dexMax do
|
||||
local internal = tonumber(toSpecies[n])
|
||||
local name = internal and raw.names and raw.names[internal]
|
||||
local stats = internal and raw.stats and raw.stats[internal]
|
||||
local meta = internal and raw.meta and raw.meta[internal]
|
||||
if internal and not placeholder(name) and type(stats) == "table" and type(meta) == "table" then
|
||||
data.localToNational[internal], data.nationalToLocal[n] = n, internal
|
||||
local types = {}
|
||||
for _, t in ipairs(raw.types and raw.types[internal] or {}) do
|
||||
local tn = Identity.canonType(t, generation)
|
||||
if tn and types[#types] ~= tn then types[#types + 1] = tn end
|
||||
end
|
||||
local exp = {}
|
||||
for level = 1, 100 do exp[level] = SummaryData.expForLevel(meta.growthRate, level) end
|
||||
local levelMoves, tmhm, tutor, egg = {}, {}, {}, {}
|
||||
for _, row in ipairs(raw.learnsets and raw.learnsets[internal] or {}) do
|
||||
addLevel(levelMoves, row[1] or row.level, tonumber(row[2] or row.move))
|
||||
end
|
||||
local tm = tmLearn[internal]
|
||||
if type(tm) == "table" then
|
||||
for index = 0, 57 do
|
||||
local bits = index < 32 and tm.lo or tm.hi
|
||||
if bitSet(bits, index < 32 and index or index - 32) and machines[index] then
|
||||
tmhm[#tmhm + 1] = tonumber(machines[index])
|
||||
end
|
||||
end
|
||||
end
|
||||
local tutorBits = tutorLearn[internal]
|
||||
for index = 0, tutorCount - 1 do
|
||||
if bitSet(tutorBits, index) then tutor[#tutor + 1] = tonumber(tutorMoves[index]) end
|
||||
end
|
||||
if CAPE_BRINK_VERSIONS[data.version] then
|
||||
for _, row in pairs(require("src.core.game3.move_learn").CAPE_BRINK) do
|
||||
if row.species == internal then tutor[#tutor + 1] = row.move end
|
||||
end
|
||||
end
|
||||
for _, m in ipairs(raw.eggMoves and raw.eggMoves[internal] or {}) do egg[#egg + 1] = tonumber(m) end
|
||||
local evolutions = {}
|
||||
for _, evo in ipairs(raw.evolutions and raw.evolutions[internal] or {}) do
|
||||
evolutions[#evolutions + 1] = { into = tonumber(evo.target), method = evo.method, param = evo.param }
|
||||
end
|
||||
local pair = raw.abilities and raw.abilities[internal] or {}
|
||||
data.species[n] = { national = n, key = Identity.normalize(name), name = name, localKey = internal,
|
||||
types = types, base = { hp = stats.hp, atk = stats.atk, def = stats.def, spe = stats.spe,
|
||||
spa = stats.spa, spd = stats.spd },
|
||||
genderRatio = meta.genderRatio, catchRate = meta.catchRate, growth = meta.growthRate, exp = exp,
|
||||
friendship = meta.friendship, abilities = { tonumber(pair[1]) or 0, tonumber(pair[2]) or 0 },
|
||||
rawLevel = levelMoves, rawTmhm = tmhm, rawTutor = tutor, rawEgg = egg, rawEvolutions = evolutions }
|
||||
end
|
||||
end
|
||||
for id, def in pairs(raw.items and raw.items.items or {}) do
|
||||
if type(def) == "table" and tonumber(id) and not placeholder(def.name) then
|
||||
local norm = Identity.normalize(def.name)
|
||||
if norm and norm ~= "" then
|
||||
data.items.byLocal[id] = { key = norm, name = def.name, localKey = id,
|
||||
mail = def.fieldUseName == "ItemUseOutOfBattle_Mail", index = id }
|
||||
if data.items.byKey[norm] == nil or id < data.items.byKey[norm] then data.items.byKey[norm] = id end
|
||||
end
|
||||
end
|
||||
end
|
||||
data.raw = { items = raw.items }
|
||||
end
|
||||
|
||||
local function canonicalizeLearnsets(data)
|
||||
local function moveOf(m)
|
||||
if type(m) == "number" and data.generation == 3 then return data.moves[m] and m or nil end
|
||||
return data.localToMove[m]
|
||||
end
|
||||
local function setOf(list)
|
||||
local out = {}
|
||||
for _, m in ipairs(list) do
|
||||
local id = moveOf(m)
|
||||
if id then out[id] = true end
|
||||
end
|
||||
return out
|
||||
end
|
||||
for n, sp in pairs(data.species) do
|
||||
local level = {}
|
||||
for _, row in ipairs(sp.rawLevel) do
|
||||
local id = moveOf(row.move)
|
||||
if id then level[#level + 1] = { level = row.level, move = id } end
|
||||
end
|
||||
sortLevels(level)
|
||||
sp.levelMoves, sp.tmhm, sp.tutor, sp.egg = level, setOf(sp.rawTmhm), setOf(sp.rawTutor), setOf(sp.rawEgg)
|
||||
local evolutions = {}
|
||||
for _, evo in ipairs(sp.rawEvolutions) do
|
||||
local into = evo.into
|
||||
local target = into ~= nil and data.localToNational[into] or nil
|
||||
if target then
|
||||
evolutions[#evolutions + 1] = { into = target, method = evo.method, level = evo.level, param = evo.param }
|
||||
local child = data.species[target]
|
||||
if child and child.evolvesFrom == nil then child.evolvesFrom = n end
|
||||
end
|
||||
end
|
||||
sp.evolutions = evolutions
|
||||
sp.rawLevel, sp.rawTmhm, sp.rawTutor, sp.rawEgg, sp.rawEvolutions = nil, nil, nil, nil, nil
|
||||
end
|
||||
end
|
||||
|
||||
function Datasets.build(version, raw)
|
||||
local generation = versionInfo(version)
|
||||
if not generation then return nil, "unknown_version" end
|
||||
local data = newData(version, generation)
|
||||
if generation == 3 then
|
||||
for _, name in ipairs(GBA_REQUIRED) do
|
||||
if type(raw[name]) ~= "table" then return nil, "missing_import", { file = GBA_FILES[name] } end
|
||||
end
|
||||
if not NO_TUTORS[version] and type(raw.tutor) ~= "table" then
|
||||
return nil, "missing_import", { file = GBA_FILES.tutor }
|
||||
end
|
||||
buildGBA(data, raw)
|
||||
else
|
||||
if type(raw.pokemon) ~= "table" or type(raw.moves) ~= "table" then
|
||||
return nil, "missing_import", { file = GB_FILES.pokemon }
|
||||
end
|
||||
buildGB(data, raw)
|
||||
end
|
||||
canonicalizeLearnsets(data)
|
||||
data.ready = next(data.species) ~= nil and next(data.moves) ~= nil
|
||||
if not data.ready then return nil, "missing_import" end
|
||||
return data
|
||||
end
|
||||
|
||||
function Datasets.get(version)
|
||||
if cached[version] ~= nil then return cached[version] or nil, cached[version] == false and "missing_import" or nil end
|
||||
local reader = readerOverride or defaultReader
|
||||
local data = Datasets.build(version, readTables(version, reader))
|
||||
cached[version] = data or false
|
||||
if not data then return nil, "missing_import" end
|
||||
return data
|
||||
end
|
||||
|
||||
function Datasets.put(version, data)
|
||||
cached[version] = data
|
||||
end
|
||||
|
||||
function Datasets.chain(data, national)
|
||||
local out, seen = {}, {}
|
||||
local n = tonumber(national)
|
||||
while n and data.species[n] and not seen[n] do
|
||||
seen[n] = true
|
||||
out[#out + 1] = n
|
||||
n = data.species[n].evolvesFrom
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function Datasets.learnSources(data, national, level)
|
||||
level = tonumber(level) or 100
|
||||
local out = {}
|
||||
local function add(move, source)
|
||||
if move and move <= data.moveMax then
|
||||
out[move] = out[move] or {}
|
||||
out[move][source] = true
|
||||
end
|
||||
end
|
||||
for depth, n in ipairs(Datasets.chain(data, national)) do
|
||||
local sp = data.species[n]
|
||||
local tag = depth == 1 and "" or "_prevo"
|
||||
for _, row in ipairs(sp.levelMoves) do
|
||||
if row.level <= level then add(row.move, "level" .. tag) end
|
||||
end
|
||||
for move in pairs(sp.tmhm) do add(move, "tmhm" .. tag) end
|
||||
for move in pairs(sp.tutor) do add(move, "tutor" .. tag) end
|
||||
for move in pairs(sp.egg) do add(move, "egg" .. tag) end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function Datasets.learnable(data, national, move, level)
|
||||
local sources = Datasets.learnSources(data, national, level)[tonumber(move) or -1]
|
||||
return sources ~= nil, sources
|
||||
end
|
||||
|
||||
function Datasets.expAt(data, national, level)
|
||||
local sp = data.species[tonumber(national) or -1]
|
||||
return sp and sp.exp[math.max(1, math.min(100, math.floor(tonumber(level) or 1)))] or nil
|
||||
end
|
||||
|
||||
function Datasets.levelForExp(data, national, exp)
|
||||
local sp = data.species[tonumber(national) or -1]
|
||||
if not sp then return nil end
|
||||
local level = 1
|
||||
while level < 100 and (sp.exp[level + 1] or math.huge) <= (tonumber(exp) or 0) do level = level + 1 end
|
||||
return level
|
||||
end
|
||||
|
||||
return Datasets
|
||||
@@ -0,0 +1,129 @@
|
||||
local Identity = {}
|
||||
|
||||
-- include/constants/pokemon.h:6
|
||||
Identity.GEN3_TYPES = {
|
||||
[0] = "NORMAL", [1] = "FIGHTING", [2] = "FLYING", [3] = "POISON", [4] = "GROUND",
|
||||
[5] = "ROCK", [6] = "BUG", [7] = "GHOST", [8] = "STEEL", [9] = "MYSTERY",
|
||||
[10] = "FIRE", [11] = "WATER", [12] = "GRASS", [13] = "ELECTRIC", [14] = "PSYCHIC",
|
||||
[15] = "ICE", [16] = "DRAGON", [17] = "DARK",
|
||||
}
|
||||
|
||||
Identity.TYPES = { "NORMAL", "FIGHTING", "FLYING", "POISON", "GROUND", "ROCK", "BUG", "GHOST",
|
||||
"STEEL", "FIRE", "WATER", "GRASS", "ELECTRIC", "PSYCHIC", "ICE", "DRAGON", "DARK" }
|
||||
|
||||
Identity.GEN1_TYPES = { "NORMAL", "FIGHTING", "FLYING", "POISON", "GROUND", "ROCK", "BUG", "GHOST",
|
||||
"FIRE", "WATER", "GRASS", "ELECTRIC", "PSYCHIC", "ICE", "DRAGON" }
|
||||
|
||||
local GEN3_TYPE_ID = {}
|
||||
for id, name in pairs(Identity.GEN3_TYPES) do GEN3_TYPE_ID[name] = id end
|
||||
Identity.GEN3_TYPE_ID = GEN3_TYPE_ID
|
||||
|
||||
local PHYSICAL = {}
|
||||
-- include/battle.h:466
|
||||
for id, name in pairs(Identity.GEN3_TYPES) do PHYSICAL[name] = id < 9 end
|
||||
Identity.PHYSICAL = PHYSICAL
|
||||
|
||||
function Identity.normalize(name)
|
||||
if type(name) ~= "string" then return nil end
|
||||
local s = name:upper()
|
||||
s = s:gsub("\226\153\128", "F"):gsub("\226\153\130", "M")
|
||||
s = s:gsub("<F>", "F"):gsub("<M>", "M")
|
||||
s = s:gsub("\195\169", "E"):gsub("\195\137", "E")
|
||||
s = s:gsub("[^A-Z0-9]", "")
|
||||
if s == "" then return nil end
|
||||
return s
|
||||
end
|
||||
|
||||
function Identity.canonType(value, generation)
|
||||
if generation == 3 or type(value) == "number" then
|
||||
return Identity.GEN3_TYPES[tonumber(value) or -1]
|
||||
end
|
||||
if type(value) ~= "string" then return nil end
|
||||
local s = value:upper():gsub("_TYPE$", "")
|
||||
if s == "CURSE" or s == "???" then return "MYSTERY" end
|
||||
if GEN3_TYPE_ID[s] then return s end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Identity.subsequence(short, long)
|
||||
if type(short) ~= "string" or type(long) ~= "string" then return false end
|
||||
local at = 1
|
||||
for i = 1, #short do
|
||||
local found = long:find(short:sub(i, i), at, true)
|
||||
if not found then return false end
|
||||
at = found + 1
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function Identity.category(typeName, power)
|
||||
if (tonumber(power) or 0) == 0 then return "status" end
|
||||
return PHYSICAL[typeName] and "physical" or "special"
|
||||
end
|
||||
|
||||
function Identity.speciesOf(data, localKey)
|
||||
if type(data) ~= "table" or localKey == nil then return nil end
|
||||
local n = data.localToNational[localKey]
|
||||
if n == nil and data.generation == 3 then n = data.localToNational[tonumber(localKey)] end
|
||||
return n
|
||||
end
|
||||
|
||||
function Identity.localSpecies(data, national)
|
||||
return data and data.nationalToLocal[tonumber(national) or -1] or nil
|
||||
end
|
||||
|
||||
function Identity.moveOf(data, localKey)
|
||||
if type(data) ~= "table" or localKey == nil then return nil end
|
||||
local n = data.localToMove[localKey]
|
||||
if n == nil and data.generation == 3 then n = data.localToMove[tonumber(localKey)] end
|
||||
return n
|
||||
end
|
||||
|
||||
function Identity.localMove(data, move)
|
||||
return data and data.moveToLocal[tonumber(move) or -1] or nil
|
||||
end
|
||||
|
||||
function Identity.itemKey(data, localItem)
|
||||
if type(data) ~= "table" or localItem == nil or localItem == 0 then return nil end
|
||||
local row = data.items.byLocal[localItem]
|
||||
if row == nil and data.generation == 3 then row = data.items.byLocal[tonumber(localItem)] end
|
||||
return row and row.key or nil
|
||||
end
|
||||
|
||||
function Identity.localItem(data, key)
|
||||
return data and key and data.items.byKey[key] or nil
|
||||
end
|
||||
|
||||
function Identity.maps(data)
|
||||
return {
|
||||
species = { toCanonical = data.localToNational, toLocal = data.nationalToLocal },
|
||||
moves = { toCanonical = data.localToMove, toLocal = data.moveToLocal },
|
||||
types = { toCanonical = data.localToType, toLocal = data.typeToLocal },
|
||||
items = { toCanonical = data.items.byLocal, toLocal = data.items.byKey },
|
||||
}
|
||||
end
|
||||
|
||||
local function compareNames(kind, a, b, out, max)
|
||||
local left = kind == "species" and a.species or a.moves
|
||||
local right = kind == "species" and b.species or b.moves
|
||||
local ids = {}
|
||||
for id in pairs(left) do
|
||||
if right[id] and id <= max then ids[#ids + 1] = id end
|
||||
end
|
||||
table.sort(ids)
|
||||
for _, id in ipairs(ids) do
|
||||
local x, y = left[id].key, right[id].key
|
||||
if x ~= y and not Identity.subsequence(x, y) and not Identity.subsequence(y, x) then
|
||||
out[#out + 1] = { kind = kind, id = id, a = a.version, b = b.version, left = x, right = y }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Identity.agree(a, b)
|
||||
local out = {}
|
||||
compareNames("species", a, b, out, math.min(a.dexMax, b.dexMax))
|
||||
compareNames("moves", a, b, out, math.min(a.moveMax, b.moveMax))
|
||||
return #out == 0, out
|
||||
end
|
||||
|
||||
return Identity
|
||||
@@ -0,0 +1,146 @@
|
||||
local Messages = {}
|
||||
|
||||
Messages.GB_WIDTH = 18
|
||||
|
||||
local TEXT = {
|
||||
missing_import = "Import {need} first.",
|
||||
policy_mismatch = "The other player has a different Union Room version.",
|
||||
proto_mismatch = "The other player has a different Union Room version.",
|
||||
bad_op = "That can't be done here.",
|
||||
bad_record = "This Pokemon's data can't be read.",
|
||||
species_unknown = "This Pokemon's species is unknown.",
|
||||
move_unknown = "One of its moves is unknown.",
|
||||
egg = "Eggs can't be used here.",
|
||||
mail = "Take the Mail off first.",
|
||||
item_unknown = "Its held item is unknown.",
|
||||
item_unrepresentable = "The other game has no {item}. Take it off first.",
|
||||
species_missing = "{species} doesn't exist in the other game.",
|
||||
species_not_in_ruleset = "{species} can't join this battle. Only No. 1 to {dexMax} can.",
|
||||
move_not_in_ruleset = "{move} isn't used in this battle.",
|
||||
move_unsupported = "{move} can't be used in this battle.",
|
||||
move_not_legal = "{species} can't learn {move}.",
|
||||
move_missing = "{move} doesn't exist in the other game.",
|
||||
replacement_not_legal = "That move can't be chosen.",
|
||||
no_legal_moves = "It needs at least one move.",
|
||||
no_moves = "It needs at least one move.",
|
||||
nickname_unencodable = "Its nickname can't be written in the other game.",
|
||||
ot_unencodable = "Its trainer name can't be written in the other game.",
|
||||
ot_invalid = "Its trainer ID is invalid.",
|
||||
personality_unrepresentable = "It can't keep its looks in the other game.",
|
||||
traits_unrepresentable = "It can't keep its looks in the other game.",
|
||||
choose_sit_out = "Choose {need} to sit out.",
|
||||
team_too_small = "Choose {size} Pokemon.",
|
||||
native_needs_same_gen = "This battle needs the same generation.",
|
||||
rental_type_mismatch = "A rental Pokemon is unavailable.",
|
||||
rental_duplicate_move = "A rental Pokemon is unavailable.",
|
||||
rental_bad_moves = "A rental Pokemon is unavailable.",
|
||||
unknown_ruleset = "These rules are unknown.",
|
||||
}
|
||||
|
||||
local CHANGE = {
|
||||
change = "{field} will change.",
|
||||
loss = "{field} will be lost.",
|
||||
}
|
||||
|
||||
local FIELD = {
|
||||
dvs = "Its strength values", ivs = "Its strength values", statExp = "Its training", evs = "Its training",
|
||||
exp = "Its Exp. Points", experience = "Its Exp. Points", nickname = "Its nickname",
|
||||
moves = "A move", pp = "PP", item = "Its held item", heldItem = "Its held item",
|
||||
catchRate = "Its held item", happiness = "Its friendship", friendship = "Its friendship",
|
||||
pokerus = "Pokerus", ribbons = "Its ribbons", contest = "Its contest stats", nature = "Its nature",
|
||||
ability = "Its ability", abilityNum = "Its ability", personality = "Its personality",
|
||||
origin = "Where it was met", caughtData = "Where it was met", shiny = "Its color",
|
||||
gender = "Its gender", unownLetter = "Its form", species = "The Pokemon",
|
||||
hp = "Its HP", status = "Its status", markings = "Its marks", otSecretId = "Its trainer data",
|
||||
otGender = "Its trainer data", language = "Its language", metLocation = "Where it was met",
|
||||
metLevel = "Where it was met", metGame = "Where it was met", pokeball = "Its Ball",
|
||||
caughtLevel = "Where it was met", caughtTime = "When it was met", caughtLocation = "Where it was met",
|
||||
caughtByGender = "Its trainer data", caughtGender = "Its trainer data", caught = "Where it was met",
|
||||
fatefulEncounter = "Its obedience mark", modernFatefulEncounter = "Its obedience mark",
|
||||
championRibbon = "Its ribbons", extra = "Its extra data from mods",
|
||||
egg = "Its egg data", eggSteps = "Its egg data", eggCycles = "Its egg data", isEgg = "Its egg data",
|
||||
sleep = "Its status", sleepTurns = "Its status",
|
||||
ot = "Its trainer data", otId = "Its trainer ID", otName = "Its trainer name", level = "Its level",
|
||||
speciesId = "The Pokemon", stats = "Its stats", baseStats = "Its stats", types = "Its type",
|
||||
evolution = "How it evolves", mail = "Its Mail", ppBonuses = "PP", ppBonusesPacked = "PP",
|
||||
}
|
||||
|
||||
local function fill(template, detail, names)
|
||||
detail = detail or {}
|
||||
names = names or {}
|
||||
return (template:gsub("{(%w+)}", function(key)
|
||||
local value = detail[key]
|
||||
if key == "species" then
|
||||
local n = detail.national or detail.species
|
||||
value = names.species and names.species(n) or (n and ("No. " .. tostring(n))) or "It"
|
||||
elseif key == "move" and detail.move ~= nil then
|
||||
value = names.move and names.move(detail.move) or ("move " .. tostring(detail.move))
|
||||
elseif key == "item" and detail.item ~= nil then
|
||||
value = names.item and names.item(detail.item) or tostring(detail.item)
|
||||
elseif key == "need" and type(value) == "table" then
|
||||
value = "the other game"
|
||||
end
|
||||
return value ~= nil and tostring(value) or ""
|
||||
end))
|
||||
end
|
||||
|
||||
local function wrap(text, width)
|
||||
local lines, line = {}, ""
|
||||
for word in text:gmatch("%S+") do
|
||||
if line == "" then
|
||||
line = word
|
||||
elseif #line + 1 + #word <= width then
|
||||
line = line .. " " .. word
|
||||
else
|
||||
lines[#lines + 1] = line
|
||||
line = word
|
||||
end
|
||||
end
|
||||
if line ~= "" then lines[#lines + 1] = line end
|
||||
return lines
|
||||
end
|
||||
|
||||
function Messages.style(gen)
|
||||
return (tonumber(gen) or 3) >= 3 and "gba" or "gb"
|
||||
end
|
||||
|
||||
function Messages.format(text, gen)
|
||||
if Messages.style(gen) == "gb" then
|
||||
return wrap(text:upper(), Messages.GB_WIDTH)
|
||||
end
|
||||
return { text }
|
||||
end
|
||||
|
||||
function Messages.text(code, detail, names)
|
||||
local template = TEXT[code] or "That can't be done."
|
||||
return fill(template, detail, names)
|
||||
end
|
||||
|
||||
function Messages.about(name, code, detail, names)
|
||||
local template = TEXT[code] or "That can't be done."
|
||||
if not template:find("{species}", 1, true) then return tostring(name) .. ": " .. fill(template, detail, names) end
|
||||
local own = {}
|
||||
for k, v in pairs(names or {}) do own[k] = v end
|
||||
own.species = function() return name end
|
||||
return fill(template, detail, own)
|
||||
end
|
||||
|
||||
function Messages.render(code, gen, detail, names)
|
||||
return Messages.format(Messages.text(code, detail, names), gen)
|
||||
end
|
||||
|
||||
function Messages.block(row, gen, names)
|
||||
return Messages.render(row.code, gen, row.detail, names)
|
||||
end
|
||||
|
||||
function Messages.change(row, gen)
|
||||
local template = CHANGE[row.kind] or CHANGE.change
|
||||
local field = FIELD[row.field] or FIELD[(tostring(row.field):match("^(%a+)"))] or "Some data"
|
||||
return Messages.format(fill(template, { field = field }), gen)
|
||||
end
|
||||
|
||||
function Messages.known(code)
|
||||
return TEXT[code] ~= nil
|
||||
end
|
||||
|
||||
return Messages
|
||||
@@ -0,0 +1,131 @@
|
||||
local Policy = {}
|
||||
|
||||
Policy.VERSION = 1
|
||||
Policy.RENTALS = 1
|
||||
Policy.PROTO = 1
|
||||
|
||||
Policy.RULESETS = {
|
||||
["g3u-gen1"] = { id = "g3u-gen1", engine = "g3u", gen = 1, dexMax = 151, moveMax = 165 },
|
||||
["g3u-gen2"] = { id = "g3u-gen2", engine = "g3u", gen = 2, dexMax = 251, moveMax = 251 },
|
||||
["g3u-gen3"] = { id = "g3u-gen3", engine = "g3u", gen = 3, dexMax = 386, moveMax = 354 },
|
||||
}
|
||||
|
||||
function Policy.ruleset(id)
|
||||
return Policy.RULESETS[id]
|
||||
end
|
||||
|
||||
function Policy.rulesetForGen(gen)
|
||||
return Policy.RULESETS["g3u-gen" .. tostring(gen)]
|
||||
end
|
||||
|
||||
Policy.GEN_LIMITS = {
|
||||
[1] = { dexMax = 151, moveMax = 165 },
|
||||
[2] = { dexMax = 251, moveMax = 251 },
|
||||
[3] = { dexMax = 386, moveMax = 354 },
|
||||
}
|
||||
|
||||
Policy.STAT_ORDER = { "hp", "atk", "def", "spe", "spa", "spd" }
|
||||
Policy.GB_STAT = { hp = "hp", atk = "attack", def = "defense", spe = "speed", spa = "special", spd = "special" }
|
||||
Policy.GB_STAT_ORDER = { "hp", "attack", "defense", "speed", "special" }
|
||||
Policy.EV_TOTAL = 510
|
||||
Policy.EV_MAX = 255
|
||||
Policy.STAT_EXP_MAX = 65535
|
||||
Policy.NATURE_NEUTRAL = 0
|
||||
Policy.ABILITY_SLOT = 0
|
||||
Policy.LEVEL = { min = 1, max = 100 }
|
||||
Policy.RENTAL_LEVEL = 50
|
||||
Policy.MAX_MOVES = 4
|
||||
|
||||
-- constants/pokemon_data_constants.asm:231
|
||||
Policy.GEN2_BASE_HAPPINESS = 70
|
||||
-- include/constants/region_map_sections.h:228
|
||||
Policy.METLOC_IN_GAME_TRADE = 0xFE
|
||||
-- include/constants/items.h:13
|
||||
Policy.ITEM_POKE_BALL = 4
|
||||
-- include/constants/global.h:8
|
||||
Policy.GBA_VERSION_ID = { sapphire = 1, ruby = 2, emerald = 3, firered = 4, leafgreen = 5 }
|
||||
Policy.GBA_LANGUAGE_ENGLISH = 2
|
||||
|
||||
Policy.NAME_LIMIT = { nickname = 10, ot = 7 }
|
||||
|
||||
Policy.POWER_BANDS = { 0, 40, 70, 100 }
|
||||
|
||||
function Policy.powerBand(power)
|
||||
power = tonumber(power) or 0
|
||||
if power <= 0 then return 0 end
|
||||
for i = 2, #Policy.POWER_BANDS do
|
||||
if power <= Policy.POWER_BANDS[i] then return i - 1 end
|
||||
end
|
||||
return #Policy.POWER_BANDS
|
||||
end
|
||||
|
||||
function Policy.maxPp(base, ups)
|
||||
base = tonumber(base) or 0
|
||||
return base + (tonumber(ups) or 0) * math.floor(base / 5)
|
||||
end
|
||||
|
||||
function Policy.ivFromDv(dv)
|
||||
return (tonumber(dv) or 0) * 2 + 1
|
||||
end
|
||||
|
||||
function Policy.dvFromIv(iv)
|
||||
return math.floor((tonumber(iv) or 0) / 2)
|
||||
end
|
||||
|
||||
function Policy.evFromStatExp(statExp)
|
||||
return math.min(Policy.EV_MAX, math.floor(math.sqrt(math.max(0, tonumber(statExp) or 0))))
|
||||
end
|
||||
|
||||
function Policy.statExpFromEv(ev)
|
||||
local v = math.max(0, tonumber(ev) or 0)
|
||||
return math.min(Policy.STAT_EXP_MAX, v * v)
|
||||
end
|
||||
|
||||
Policy.BATTLE = {
|
||||
{ field = "species", rule = "national dex kept; must be <= ruleset dexMax" },
|
||||
{ field = "level", rule = "kept" },
|
||||
{ field = "ivs", rule = "Gen 3 IVs kept; Gen 1/2 DV d -> IV 2d+1 (HP from the derived HP DV); Special DV -> SpA and SpD" },
|
||||
{ field = "evs", rule = "Gen 3 EVs kept; Gen 1/2 Stat Exp s -> EV min(255, floor(sqrt(s))); Special -> SpA and SpD; 510 total cap applied in HP, Atk, Def, Spe, SpA, SpD order" },
|
||||
{ field = "baseStats", rule = "owner's own game base stats; Gen 1 base Special used for both SpA and SpD" },
|
||||
{ field = "stats", rule = "Gen 3 formula from projected IV/EV, owner's base stats and nature" },
|
||||
{ field = "nature", rule = "neutral (Hardy) when a Gen 1/2 player is present, else the Gen 3 nature" },
|
||||
{ field = "ability", rule = "off when a Gen 1/2 player is present, else the Gen 3 ability" },
|
||||
{ field = "item", rule = "off when a Gen 1/2 player is present" },
|
||||
{ field = "types", rule = "owner's own game types, canonical names" },
|
||||
{ field = "moves", rule = "canonical id <= ruleset moveMax and learnable per the owner's game data; illegal moves are replaced by the player or left empty; order kept" },
|
||||
{ field = "pp", rule = "owner's base PP with the same PP Up count; current PP full" },
|
||||
{ field = "gender", rule = "Gen 2 DV rule, Gen 3 personality rule, none for Gen 1" },
|
||||
{ field = "shiny", rule = "Gen 1/2 DV rule, Gen 3 personality rule" },
|
||||
{ field = "hp", rule = "full" },
|
||||
{ field = "status", rule = "clear" },
|
||||
}
|
||||
|
||||
Policy.TRADE = {
|
||||
{ field = "species", rule = "national dex kept; must exist in the destination game" },
|
||||
{ field = "level", rule = "kept" },
|
||||
{ field = "exp", rule = "kept, clamped to the destination growth curve range of the kept level" },
|
||||
{ field = "dvs", rule = "Gen 3 -> Gen 1/2: nearest DVs to floor(IV/2) that keep shininess, Gen 2 gender and Unown letter; Special from SpA; HP DV derived" },
|
||||
{ field = "ivs", rule = "Gen 1/2 -> Gen 3: IV = 2*DV+1, HP from the HP DV, SpA and SpD from Special" },
|
||||
{ field = "statExp", rule = "Gen 3 EV e -> Stat Exp min(65535, e*e); Special from SpA" },
|
||||
{ field = "evs", rule = "Gen 1/2 Stat Exp s -> EV min(255, floor(sqrt(s))); Special -> SpA and SpD; 510 cap in slot order" },
|
||||
{ field = "personality", rule = "Gen 1/2 -> Gen 3: deterministic search from the record digest for a Hardy, even personality that keeps gender, shininess and Unown letter with Secret ID 0" },
|
||||
{ field = "nature", rule = "from personality" },
|
||||
{ field = "ability", rule = "Gen 1/2 -> Gen 3: slot 0; Gen 3 -> Gen 3: kept; Gen 3 -> Gen 1/2: lost" },
|
||||
{ field = "moves", rule = "canonical id must exist in the destination; kept when learnable in the destination game OR learnable in the source game; replacements limited to moves the species learns in the destination game" },
|
||||
{ field = "pp", rule = "destination base PP with the same PP Up count, full" },
|
||||
{ field = "nickname", rule = "kept when the destination charset can encode it, else refused; a nickname equal to the species name becomes the destination default" },
|
||||
{ field = "ot", rule = "OT name and ID kept when encodable, else refused" },
|
||||
{ field = "item", rule = "kept by canonical name when it exists in the destination, else refused until removed; mail refused; Gen 2 -> Gen 1 stored as catch rate; Gen 1 -> Gen 2 from catch rate" },
|
||||
{ field = "friendship", rule = "kept; Gen 1 -> Gen 2 is 70; Gen 1 -> Gen 3 is the species base; Gen 1 target loses it" },
|
||||
{ field = "pokerus", rule = "kept Gen 2 <-> Gen 3; Gen 1 target loses it" },
|
||||
{ field = "origin", rule = "Gen 3 target: met in a trade, met level = level, Poke Ball, destination game id; Gen 2 target from another gen: caught data cleared; Gen 3 -> Gen 3 kept" },
|
||||
{ field = "ribbons", rule = "kept Gen 3 -> Gen 3, else lost" },
|
||||
{ field = "contest", rule = "kept Gen 3 -> Gen 3, else lost" },
|
||||
{ field = "egg", rule = "refused" },
|
||||
{ field = "mail", rule = "refused" },
|
||||
{ field = "hp", rule = "full" },
|
||||
{ field = "status", rule = "clear" },
|
||||
{ field = "evolution", rule = "not run here; the destination game applies its own trade evolution after receipt" },
|
||||
}
|
||||
|
||||
return Policy
|
||||
@@ -0,0 +1,288 @@
|
||||
local bit = require("bit")
|
||||
local Identity = require("src.online.xgen.Identity")
|
||||
local Datasets = require("src.online.xgen.Datasets")
|
||||
local Policy = require("src.online.xgen.Policy")
|
||||
|
||||
local Project = {}
|
||||
|
||||
local function int(v, lo, hi, default)
|
||||
local n = tonumber(v)
|
||||
if not n or n ~= n then return default end
|
||||
n = math.floor(n)
|
||||
if lo and n < lo then n = lo end
|
||||
if hi and n > hi then n = hi end
|
||||
return n
|
||||
end
|
||||
|
||||
local function copy(v)
|
||||
if type(v) ~= "table" then return v end
|
||||
local out = {}
|
||||
for k, x in pairs(v) do out[k] = copy(x) end
|
||||
return out
|
||||
end
|
||||
Project.copy = copy
|
||||
|
||||
function Project.hpDv(dvs)
|
||||
-- engine/pokemon/move_mon.asm:1483
|
||||
return (dvs.attack % 2) * 8 + (dvs.defense % 2) * 4 + (dvs.speed % 2) * 2 + (dvs.special % 2)
|
||||
end
|
||||
|
||||
function Project.shinyDv(dvs)
|
||||
-- engine/gfx/color.asm:8
|
||||
if dvs.defense ~= 10 or dvs.speed ~= 10 or dvs.special ~= 10 then return false end
|
||||
return dvs.attack % 4 >= 2
|
||||
end
|
||||
|
||||
function Project.genderDv(ratio, dvs)
|
||||
return require("src.core.gen2.Gender").of(ratio, dvs)
|
||||
end
|
||||
|
||||
function Project.unownLetterDv(dvs)
|
||||
return require("src.core.gen2.Unown").letterFromDVs(dvs)
|
||||
end
|
||||
|
||||
function Project.shiny3(personality, otId, otSecretId)
|
||||
local p = (tonumber(personality) or 0) % 4294967296
|
||||
-- src/pokemon.c:6740
|
||||
local value = bit.bxor(bit.bxor(int(otId, 0, 65535, 0), int(otSecretId, 0, 65535, 0)),
|
||||
bit.bxor(math.floor(p / 65536), p % 65536))
|
||||
return value < 8
|
||||
end
|
||||
|
||||
function Project.gender3(ratio, personality)
|
||||
-- src/pokemon.c:3452
|
||||
if ratio == nil or ratio == 255 then return "unknown" end
|
||||
if ratio == 0 then return "male" end
|
||||
if ratio == 254 then return "female" end
|
||||
return ratio > (int(personality, 0, nil, 0) % 256) and "female" or "male"
|
||||
end
|
||||
|
||||
function Project.unownLetter3(personality)
|
||||
return require("src.core.game3.pokemon").unownLetter(personality)
|
||||
end
|
||||
|
||||
local function gen3MoveRows(rec)
|
||||
local rows = {}
|
||||
local list = type(rec.moves) == "table" and rec.moves or {}
|
||||
for slot = 1, math.min(4, #list) do
|
||||
local entry = list[slot]
|
||||
local id = type(entry) == "table" and tonumber(entry.id or entry.moveId or entry.move) or tonumber(entry)
|
||||
local ups
|
||||
if type(rec.ppBonusesPacked) == "number" then
|
||||
ups = math.floor(rec.ppBonusesPacked / 4 ^ (slot - 1)) % 4
|
||||
elseif type(rec.ppBonuses) == "number" then
|
||||
ups = math.floor(rec.ppBonuses / 4 ^ (slot - 1)) % 4
|
||||
elseif type(entry) == "table" then
|
||||
ups = int(entry.ppUps, 0, 3, 0)
|
||||
end
|
||||
local pp = tonumber(rec.pp and rec.pp[slot]) or (type(entry) == "table" and tonumber(entry.pp)) or nil
|
||||
rows[#rows + 1] = { localKey = id, ppUps = ups or 0, pp = pp }
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
local function gbMoveRows(rec, data)
|
||||
local rows = {}
|
||||
for _, entry in ipairs(type(rec.moves) == "table" and rec.moves or {}) do
|
||||
local key = type(entry) == "table" and (entry.id or entry.move) or entry
|
||||
local ups = type(entry) == "table" and tonumber(entry.ppUps) or nil
|
||||
if ups == nil and type(entry) == "table" and tonumber(entry.maxPp) then
|
||||
local mv = data.moves[Identity.moveOf(data, key) or -1]
|
||||
local base = mv and mv.pp or 0
|
||||
local bonus = math.floor(base / 5)
|
||||
if bonus > 0 and entry.maxPp > base and (entry.maxPp - base) % bonus == 0 then
|
||||
ups = (entry.maxPp - base) / bonus
|
||||
end
|
||||
end
|
||||
rows[#rows + 1] = { localKey = key, ppUps = int(ups, 0, 3, 0),
|
||||
pp = type(entry) == "table" and tonumber(entry.pp) or nil }
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
function Project.read(rec, data)
|
||||
if type(rec) ~= "table" then return nil, "bad_record" end
|
||||
if type(data) ~= "table" then return nil, "missing_import" end
|
||||
local gen = data.generation
|
||||
local localSpecies = rec.species
|
||||
if gen == 3 then localSpecies = tonumber(rec.speciesId or rec.species) end
|
||||
local national = Identity.speciesOf(data, localSpecies)
|
||||
if not national then return nil, "species_unknown" end
|
||||
local sp = data.species[national]
|
||||
local view = { gen = gen, version = data.version, national = national, localSpecies = localSpecies,
|
||||
speciesName = sp.name, isEgg = rec.isEgg == true or rec.isBadEgg == true }
|
||||
local rows = gen == 3 and gen3MoveRows(rec) or gbMoveRows(rec, data)
|
||||
view.moves = {}
|
||||
for _, row in ipairs(rows) do
|
||||
if row.localKey ~= nil and row.localKey ~= 0 then
|
||||
local id = Identity.moveOf(data, row.localKey)
|
||||
if not id then return nil, "move_unknown", { move = row.localKey } end
|
||||
view.moves[#view.moves + 1] = { move = id, ppUps = row.ppUps, pp = row.pp }
|
||||
end
|
||||
end
|
||||
if #view.moves > Policy.MAX_MOVES then return nil, "bad_record", { field = "moves" } end
|
||||
view.exp = tonumber(rec.exp or rec.experience)
|
||||
view.level = int(rec.level, 1, 100, nil)
|
||||
if not view.level and view.exp then view.level = Datasets.levelForExp(data, national, view.exp) end
|
||||
if not view.level then return nil, "bad_record", { field = "level" } end
|
||||
view.nickname = type(rec.nickname) == "string" and rec.nickname ~= "" and rec.nickname or nil
|
||||
view.otName = rec.otName or rec.ot
|
||||
view.otId = tonumber(rec.otId)
|
||||
view.otSecretId = gen == 3 and int(rec.otSecretId, 0, 65535, nil) or nil
|
||||
if gen == 3 and view.otId and view.otId > 65535 then
|
||||
view.otSecretId = view.otSecretId or math.floor(view.otId / 65536)
|
||||
view.otId = view.otId % 65536
|
||||
end
|
||||
if gen == 3 then
|
||||
local iv, ev = type(rec.ivs) == "table" and rec.ivs or {}, type(rec.evs) == "table" and rec.evs or {}
|
||||
view.ivs, view.evs = {}, {}
|
||||
for _, k in ipairs(Policy.STAT_ORDER) do
|
||||
view.ivs[k] = int(iv[k], 0, 31, 0)
|
||||
view.evs[k] = int(ev[k], 0, 255, 0)
|
||||
end
|
||||
view.personality = int(rec.personality, 0, 4294967295, 0)
|
||||
view.nature = view.personality % 25
|
||||
view.abilityNum = int(rec.abilityNum, 0, 1, nil)
|
||||
if view.abilityNum == nil then
|
||||
local pair = sp.abilities or {}
|
||||
view.abilityNum = (pair[2] or 0) ~= 0 and view.personality % 2 or 0
|
||||
end
|
||||
view.ability = (sp.abilities or {})[view.abilityNum + 1]
|
||||
if view.ability == 0 then view.ability = (sp.abilities or {})[1] end
|
||||
view.friendship = int(rec.friendship or rec.happiness, 0, 255, nil)
|
||||
local item = rec.heldItem
|
||||
if item == nil then item = rec.item end
|
||||
view.item = tonumber(item) or 0
|
||||
else
|
||||
if type(rec.dvs) ~= "table" then return nil, "bad_record", { field = "dvs" } end
|
||||
local d = rec.dvs
|
||||
view.dvs = { attack = int(d.attack, 0, 15, 0), defense = int(d.defense, 0, 15, 0),
|
||||
speed = int(d.speed, 0, 15, 0), special = int(d.special or d.specialAttack, 0, 15, 0) }
|
||||
view.dvs.hp = Project.hpDv(view.dvs)
|
||||
local se = type(rec.statExp) == "table" and rec.statExp or {}
|
||||
view.statExp = {}
|
||||
for _, k in ipairs(Policy.GB_STAT_ORDER) do
|
||||
local v = se[k]
|
||||
if k == "special" and v == nil then v = se.specialAttack end
|
||||
view.statExp[k] = int(v, 0, 65535, 0)
|
||||
end
|
||||
if gen == 2 then
|
||||
view.item = rec.item
|
||||
view.friendship = int(rec.happiness, 0, 255, nil)
|
||||
else
|
||||
view.catchRate = int(rec.catchRate, 0, 255, nil)
|
||||
end
|
||||
end
|
||||
view.pokerus = int(rec.pokerus, 0, 255, 0)
|
||||
return view
|
||||
end
|
||||
|
||||
function Project.ivs(view)
|
||||
if view.gen == 3 then return copy(view.ivs) end
|
||||
local d = view.dvs
|
||||
return { hp = Policy.ivFromDv(d.hp), atk = Policy.ivFromDv(d.attack), def = Policy.ivFromDv(d.defense),
|
||||
spe = Policy.ivFromDv(d.speed), spa = Policy.ivFromDv(d.special), spd = Policy.ivFromDv(d.special) }
|
||||
end
|
||||
|
||||
function Project.evs(view)
|
||||
if view.gen == 3 then return copy(view.evs) end
|
||||
local s = view.statExp
|
||||
local raw = { hp = s.hp, atk = s.attack, def = s.defense, spe = s.speed, spa = s.special, spd = s.special }
|
||||
local out, total = {}, 0
|
||||
for _, k in ipairs(Policy.STAT_ORDER) do
|
||||
local ev = math.min(Policy.evFromStatExp(raw[k]), Policy.EV_TOTAL - total)
|
||||
out[k], total = ev, total + ev
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function Project.natureMultiplier(nature, statIndex)
|
||||
-- src/pokemon.c:1365
|
||||
local up, down = math.floor(nature / 5) + 1, nature % 5 + 1
|
||||
if up == down then return 1 end
|
||||
if statIndex == up then return 1.1 end
|
||||
if statIndex == down then return 0.9 end
|
||||
return 1
|
||||
end
|
||||
|
||||
function Project.stats3(base, level, ivs, evs, nature, national)
|
||||
nature = tonumber(nature) or 0
|
||||
local out = {}
|
||||
-- src/pokemon.c:2845
|
||||
if national == 292 then
|
||||
out.hp = 1
|
||||
else
|
||||
out.hp = math.floor(((2 * base.hp + ivs.hp + math.floor(evs.hp / 4)) * level) / 100) + level + 10
|
||||
end
|
||||
-- src/pokemon.c:2814
|
||||
for index, key in ipairs({ "atk", "def", "spe", "spa", "spd" }) do
|
||||
local n = math.floor(((2 * base[key] + ivs[key] + math.floor(evs[key] / 4)) * level) / 100) + 5
|
||||
local mul = Project.natureMultiplier(nature, index)
|
||||
if mul > 1 then n = math.floor(n * 110 / 100) elseif mul < 1 then n = math.floor(n * 90 / 100) end
|
||||
out[key] = n
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function Project.moveRecord(data, move, ppUps)
|
||||
local mv = data.moves[move]
|
||||
if not mv then return nil end
|
||||
local maxPp = Policy.maxPp(mv.pp, ppUps)
|
||||
return { id = mv.id, name = mv.name, type = mv.type, power = mv.power, accuracy = mv.accuracy,
|
||||
pp = maxPp, maxPp = maxPp, ppUps = ppUps or 0, basePp = mv.pp, priority = mv.priority,
|
||||
effect = mv.effect, effectChance = mv.effectChance, category = mv.category,
|
||||
sourceGen = data.generation }
|
||||
end
|
||||
|
||||
function Project.traits(view, data)
|
||||
local sp = data.species[view.national]
|
||||
if view.gen == 3 then
|
||||
local letter = view.national == 201 and Project.unownLetter3(view.personality) or nil
|
||||
return { gender = Project.gender3(sp.genderRatio, view.personality),
|
||||
shiny = Project.shiny3(view.personality, view.otId, view.otSecretId), unownLetter = letter }
|
||||
end
|
||||
return { gender = view.gen == 2 and Project.genderDv(sp.genderRatio, view.dvs) or nil,
|
||||
shiny = Project.shinyDv(view.dvs),
|
||||
unownLetter = view.national == 201 and Project.unownLetterDv(view.dvs) - 1 or nil }
|
||||
end
|
||||
|
||||
function Project.battleMon(view, data, opts)
|
||||
opts = opts or {}
|
||||
local sp = data.species[view.national]
|
||||
local legacy = opts.legacyPresent ~= false
|
||||
local ivs, evs = Project.ivs(view), Project.evs(view)
|
||||
local nature = (not legacy and view.gen == 3) and view.nature or Policy.NATURE_NEUTRAL
|
||||
local level = view.level
|
||||
local stats = Project.stats3(sp.base, level, ivs, evs, nature, view.national)
|
||||
local moves = {}
|
||||
local list = opts.moves or view.moves
|
||||
for _, m in ipairs(list) do
|
||||
local mv = data.moves[m.move]
|
||||
if mv then
|
||||
local ups = math.max(0, math.min(3, math.floor(tonumber(m.ppUps) or 0)))
|
||||
moves[#moves + 1] = { id = mv.id, pp = Policy.maxPp(mv.pp, ups), ppUps = ups }
|
||||
end
|
||||
end
|
||||
local traits = Project.traits(view, data)
|
||||
local out = {
|
||||
national = view.national, species = view.national, nickname = view.nickname or sp.name,
|
||||
level = level, hp = stats.hp, maxHp = stats.hp, atk = stats.atk, def = stats.def,
|
||||
spAtk = stats.spa, spDef = stats.spd, speed = stats.spe, moves = moves,
|
||||
ability = 0, item = 0, ivs = ivs, evs = evs, nature = nature,
|
||||
gender = traits.gender, shiny = traits.shiny, unownLetter = traits.unownLetter,
|
||||
sourceGen = view.gen, rental = opts.rental or nil,
|
||||
}
|
||||
if not legacy and view.gen == 3 then
|
||||
out.ability, out.abilityNum, out.personality = view.ability or 0, view.abilityNum, view.personality
|
||||
out.item = tonumber(view.item) or 0
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function Project.mon(rec, data, opts)
|
||||
local view, code, detail = Project.read(rec, data)
|
||||
if not view then return nil, code, detail end
|
||||
return Project.battleMon(view, data, opts)
|
||||
end
|
||||
|
||||
return Project
|
||||
@@ -0,0 +1,139 @@
|
||||
local Datasets = require("src.online.xgen.Datasets")
|
||||
local Policy = require("src.online.xgen.Policy")
|
||||
local Project = require("src.online.xgen.Project")
|
||||
|
||||
local Rentals = {}
|
||||
|
||||
Rentals.VERSION = 1
|
||||
Rentals.LEVEL = 50
|
||||
Rentals.IV = 20
|
||||
Rentals.PERSONALITY = 150
|
||||
|
||||
Rentals.DEFINITIONS = {
|
||||
["g3u-gen1"] = {
|
||||
{ type = "NORMAL", species = 128, moves = { 70, 33, 59, 87 } },
|
||||
{ type = "FIGHTING", species = 68, moves = { 66, 67, 126, 89 } },
|
||||
{ type = "FLYING", species = 142, moves = { 17, 126, 36, 44 } },
|
||||
{ type = "POISON", species = 89, moves = { 124, 87, 126, 1 } },
|
||||
{ type = "GROUND", species = 28, moves = { 89, 70, 40, 163 } },
|
||||
{ type = "ROCK", species = 76, moves = { 157, 88, 126, 89 } },
|
||||
{ type = "BUG", species = 127, moves = { 66, 70, 11, 15 } },
|
||||
{ type = "GHOST", species = 94, moves = { 122, 87, 94, 70 } },
|
||||
{ type = "FIRE", species = 59, moves = { 126, 53, 36, 52 } },
|
||||
{ type = "WATER", species = 130, moves = { 56, 57, 59, 87 } },
|
||||
{ type = "GRASS", species = 3, moves = { 75, 22, 15, 33 } },
|
||||
{ type = "ELECTRIC", species = 135, moves = { 87, 84, 36, 44 } },
|
||||
{ type = "PSYCHIC", species = 97, moves = { 94, 93, 29, 1 } },
|
||||
{ type = "ICE", species = 124, moves = { 59, 8, 94, 1 } },
|
||||
{ type = "DRAGON", species = 149, moves = { 59, 87, 126, 57 } },
|
||||
},
|
||||
["g3u-gen2"] = {
|
||||
{ type = "NORMAL", species = 128, moves = { 70, 30, 59, 87 } },
|
||||
{ type = "FIGHTING", species = 68, moves = { 223, 238, 126, 89 } },
|
||||
{ type = "FLYING", species = 18, moves = { 17, 16, 211, 185 } },
|
||||
{ type = "POISON", species = 89, moves = { 188, 124, 87, 126 } },
|
||||
{ type = "GROUND", species = 51, moves = { 89, 189, 188, 163 } },
|
||||
{ type = "ROCK", species = 76, moves = { 157, 88, 126, 89 } },
|
||||
{ type = "BUG", species = 127, moves = { 66, 70, 168, 11 } },
|
||||
{ type = "GHOST", species = 94, moves = { 247, 122, 87, 94 } },
|
||||
{ type = "STEEL", species = 208, moves = { 231, 89, 21, 157 } },
|
||||
{ type = "FIRE", species = 59, moves = { 126, 53, 231, 36 } },
|
||||
{ type = "WATER", species = 130, moves = { 56, 57, 59, 87 } },
|
||||
{ type = "GRASS", species = 3, moves = { 202, 75, 15, 22 } },
|
||||
{ type = "ELECTRIC", species = 135, moves = { 87, 84, 231, 36 } },
|
||||
{ type = "PSYCHIC", species = 65, moves = { 94, 60, 247, 7 } },
|
||||
{ type = "ICE", species = 124, moves = { 59, 8, 94, 247 } },
|
||||
{ type = "DRAGON", species = 149, moves = { 225, 239, 59, 87 } },
|
||||
{ type = "DARK", species = 197, moves = { 44, 185, 231, 36 } },
|
||||
},
|
||||
["g3u-gen3"] = {
|
||||
{ type = "NORMAL", species = 128, moves = { 70, 290, 59, 87 } },
|
||||
{ type = "FIGHTING", species = 68, moves = { 223, 238, 126, 89 } },
|
||||
{ type = "FLYING", species = 18, moves = { 17, 332, 211, 290 } },
|
||||
{ type = "POISON", species = 89, moves = { 188, 124, 87, 126 } },
|
||||
{ type = "GROUND", species = 51, moves = { 89, 189, 188, 161 } },
|
||||
{ type = "ROCK", species = 76, moves = { 157, 88, 38, 126 } },
|
||||
{ type = "BUG", species = 127, moves = { 89, 66, 70, 185 } },
|
||||
{ type = "GHOST", species = 94, moves = { 247, 325, 87, 94 } },
|
||||
{ type = "STEEL", species = 208, moves = { 231, 89, 21, 157 } },
|
||||
{ type = "FIRE", species = 59, moves = { 315, 126, 231, 36 } },
|
||||
{ type = "WATER", species = 130, moves = { 56, 57, 59, 87 } },
|
||||
{ type = "GRASS", species = 3, moves = { 202, 345, 89, 188 } },
|
||||
{ type = "ELECTRIC", species = 135, moves = { 87, 85, 231, 36 } },
|
||||
{ type = "PSYCHIC", species = 65, moves = { 94, 60, 231, 247 } },
|
||||
{ type = "ICE", species = 124, moves = { 59, 58, 94, 247 } },
|
||||
{ type = "DRAGON", species = 149, moves = { 337, 225, 59, 87 } },
|
||||
{ type = "DARK", species = 197, moves = { 44, 185, 231, 36 } },
|
||||
},
|
||||
}
|
||||
|
||||
function Rentals.definitions(rulesetId)
|
||||
return Project.copy(Rentals.DEFINITIONS[rulesetId])
|
||||
end
|
||||
|
||||
local function hasType(types, want)
|
||||
for _, t in ipairs(types or {}) do
|
||||
if t == want then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function Rentals.validate(def, ruleset, data, unsupported)
|
||||
local sp = data.species[def.species]
|
||||
if def.species > ruleset.dexMax or not sp then return "species_not_in_ruleset" end
|
||||
if not hasType(sp.types, def.type) then return "rental_type_mismatch" end
|
||||
local seen = {}
|
||||
for _, move in ipairs(def.moves) do
|
||||
if seen[move] then return "rental_duplicate_move", { move = move } end
|
||||
seen[move] = true
|
||||
if move > ruleset.moveMax or not data.moves[move] then return "move_not_in_ruleset", { move = move } end
|
||||
if unsupported and unsupported[move] then return "move_unsupported", { move = move } end
|
||||
if not Datasets.learnable(data, def.species, move, Rentals.LEVEL) then return "move_not_legal", { move = move } end
|
||||
end
|
||||
if #def.moves < 1 or #def.moves > Policy.MAX_MOVES then return "rental_bad_moves" end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Rentals.build(rulesetId, data, opts)
|
||||
opts = opts or {}
|
||||
local ruleset = Policy.ruleset(rulesetId)
|
||||
local out = { version = Rentals.VERSION, ruleset = rulesetId, rentals = {}, excluded = {} }
|
||||
local defs = Rentals.DEFINITIONS[rulesetId]
|
||||
if not ruleset or not defs then
|
||||
out.excluded[1] = { code = "unknown_ruleset", detail = { ruleset = rulesetId } }
|
||||
return out
|
||||
end
|
||||
if type(data) ~= "table" then
|
||||
out.excluded[1] = { code = "missing_import" }
|
||||
return out
|
||||
end
|
||||
local unsupported = {}
|
||||
for k, v in pairs(opts.unsupported or {}) do
|
||||
if v == true then unsupported[tonumber(k) or k] = true elseif tonumber(v) then unsupported[tonumber(v)] = true end
|
||||
end
|
||||
for index, def in ipairs(defs) do
|
||||
local code, detail = Rentals.validate(def, ruleset, data, unsupported)
|
||||
if code then
|
||||
out.excluded[#out.excluded + 1] = { index = index, type = def.type, species = def.species, code = code, detail = detail }
|
||||
else
|
||||
local ivs, evs = {}, {}
|
||||
for _, k in ipairs(Policy.STAT_ORDER) do ivs[k], evs[k] = Rentals.IV, 0 end
|
||||
local view = { gen = 3, national = def.species, level = Rentals.LEVEL, ivs = ivs, evs = evs,
|
||||
personality = Rentals.PERSONALITY, nature = Rentals.PERSONALITY % 25, otId = 0, otSecretId = 0,
|
||||
abilityNum = 0, item = 0, moves = {} }
|
||||
for _, move in ipairs(def.moves) do view.moves[#view.moves + 1] = { move = move, ppUps = 0 } end
|
||||
local record = Project.battleMon(view, data, { legacyPresent = opts.legacyPresent ~= false, rental = true })
|
||||
record.nickname = data.species[def.species].name
|
||||
local disclosed = {}
|
||||
for _, move in ipairs(def.moves) do disclosed[#disclosed + 1] = Project.moveRecord(data, move, 0) end
|
||||
out.rentals[#out.rentals + 1] = { rental = true, index = index, type = def.type, national = def.species,
|
||||
name = data.species[def.species].name, types = Project.copy(data.species[def.species].types),
|
||||
level = Rentals.LEVEL, record = record, moves = disclosed,
|
||||
stats = { hp = record.maxHp, atk = record.atk, def = record.def, spAtk = record.spAtk,
|
||||
spDef = record.spDef, speed = record.speed } }
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
return Rentals
|
||||
@@ -0,0 +1,634 @@
|
||||
local bit = require("bit")
|
||||
local Identity = require("src.online.xgen.Identity")
|
||||
local Datasets = require("src.online.xgen.Datasets")
|
||||
local Policy = require("src.online.xgen.Policy")
|
||||
local Project = require("src.online.xgen.Project")
|
||||
|
||||
local TradeConvert = {}
|
||||
|
||||
local copy = Project.copy
|
||||
|
||||
local DERIVED = {
|
||||
stats = true, maxHp = true, types = true, name = true, gender = true, shiny = true, isShiny = true,
|
||||
nature = true, growthRate = true, speciesNumbering = true, attack = true, defense = true, speed = true,
|
||||
spAtk = true, spDef = true, unownLetter = true, traded = true, maxPp = true, abilityId = true,
|
||||
ability = true, speciesId = true,
|
||||
}
|
||||
|
||||
local function canonicalString(s)
|
||||
return '"' .. s:gsub('[%c"\\]', function(c)
|
||||
if c == '"' then return '\\"' end
|
||||
if c == "\\" then return "\\\\" end
|
||||
return string.format("\\u%04x", c:byte())
|
||||
end) .. '"'
|
||||
end
|
||||
|
||||
local function isArray(t)
|
||||
local n = #t
|
||||
if n == 0 then return next(t) == nil end
|
||||
local count = 0
|
||||
for _ in pairs(t) do count = count + 1 end
|
||||
return count == n
|
||||
end
|
||||
|
||||
local function encode(v, out)
|
||||
local t = type(v)
|
||||
if t == "boolean" then
|
||||
out[#out + 1] = v and "true" or "false"
|
||||
elseif t == "number" then
|
||||
if v ~= v or v == math.huge or v == -math.huge then
|
||||
out[#out + 1] = "null"
|
||||
elseif v == math.floor(v) then
|
||||
out[#out + 1] = string.format("%.0f", v)
|
||||
else
|
||||
out[#out + 1] = string.format("%.17g", v)
|
||||
end
|
||||
elseif t == "string" then
|
||||
out[#out + 1] = canonicalString(v)
|
||||
elseif t == "table" then
|
||||
if isArray(v) then
|
||||
out[#out + 1] = "["
|
||||
for i = 1, #v do
|
||||
if i > 1 then out[#out + 1] = "," end
|
||||
encode(v[i], out)
|
||||
end
|
||||
out[#out + 1] = "]"
|
||||
else
|
||||
local keys = {}
|
||||
for k in pairs(v) do keys[#keys + 1] = k end
|
||||
table.sort(keys, function(a, b)
|
||||
local ta, tb = type(a), type(b)
|
||||
if ta ~= tb then return ta < tb end
|
||||
return a < b
|
||||
end)
|
||||
out[#out + 1] = "{"
|
||||
for i, k in ipairs(keys) do
|
||||
if i > 1 then out[#out + 1] = "," end
|
||||
out[#out + 1] = canonicalString(tostring(k))
|
||||
out[#out + 1] = ":"
|
||||
encode(v[k], out)
|
||||
end
|
||||
out[#out + 1] = "}"
|
||||
end
|
||||
else
|
||||
out[#out + 1] = "null"
|
||||
end
|
||||
end
|
||||
|
||||
function TradeConvert.canonical(rec)
|
||||
local out = {}
|
||||
encode(rec, out)
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
function TradeConvert.digest(rec)
|
||||
local Fingerprint = require("src.link.Fingerprint")
|
||||
local text = TradeConvert.canonical(rec)
|
||||
return ("%08x%08x"):format(Fingerprint.fnv1a32(text, 0x811C9DC5), Fingerprint.fnv1a32(text, 0x050C5D1F))
|
||||
end
|
||||
|
||||
local charTokens = {}
|
||||
local function tokensFor(generation)
|
||||
if charTokens[generation] then return charTokens[generation] end
|
||||
local chars = generation == 1 and require("src.save_convert.data.charmap").byToken
|
||||
or require("src.save_convert.Gen2Layout").charmap
|
||||
local tokens = {}
|
||||
for key, value in pairs(chars) do
|
||||
local glyph = generation == 1 and key or value
|
||||
local code = generation == 1 and value or key
|
||||
if type(glyph) == "string" and not glyph:find("[<>@{}]") and code ~= 0x50 then tokens[#tokens + 1] = glyph end
|
||||
end
|
||||
table.sort(tokens, function(a, b)
|
||||
if #a ~= #b then return #a > #b end
|
||||
return a < b
|
||||
end)
|
||||
charTokens[generation] = tokens
|
||||
return tokens
|
||||
end
|
||||
|
||||
function TradeConvert.nameFits(text, generation, limit)
|
||||
if type(text) ~= "string" or text == "" or text:find("%c") then return false end
|
||||
if generation == 3 then
|
||||
local codec = require("src.save_convert.Gen3Save").forVersion("emerald")
|
||||
return codec.decodeString(codec.encodeString(text, limit, 0xFF), 0, limit) == text
|
||||
end
|
||||
local tokens = tokensFor(generation)
|
||||
local at, count = 1, 0
|
||||
while at <= #text do
|
||||
local found
|
||||
for _, token in ipairs(tokens) do
|
||||
if #token > 0 and text:sub(at, at + #token - 1) == token then found = token break end
|
||||
end
|
||||
if not found then return false end
|
||||
at, count = at + #found, count + 1
|
||||
if count > limit then return false end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
local function fnv(text)
|
||||
return require("src.link.Fingerprint").fnv1a32(text, 0x811C9DC5)
|
||||
end
|
||||
|
||||
function TradeConvert.personalityFor(view, destSpecies)
|
||||
local tid = math.floor(tonumber(view.otId) or 0) % 65536
|
||||
local ratio = destSpecies.genderRatio
|
||||
local wantGender = Project.genderDv(ratio, view.dvs)
|
||||
local shiny = Project.shinyDv(view.dvs)
|
||||
local letter = view.national == 201 and Project.unownLetterDv(view.dvs) - 1 or nil
|
||||
local seed = fnv(TradeConvert.canonical({ view.national, tid, view.dvs, view.exp or 0, view.level }))
|
||||
local start = seed % 65536
|
||||
local function ok(pid)
|
||||
if pid % 50 ~= 0 then return false end
|
||||
if Project.gender3(ratio, pid) ~= wantGender then return false end
|
||||
if letter and Project.unownLetter3(pid) ~= letter then return false end
|
||||
return true
|
||||
end
|
||||
for i = 0, 65535 do
|
||||
local high = (start + i) % 65536
|
||||
if shiny then
|
||||
for k = 0, 7 do
|
||||
local low = bit.band(bit.bxor(bit.bxor(tid, high), k), 0xFFFF)
|
||||
local pid = high * 65536 + low
|
||||
if ok(pid) then return pid end
|
||||
end
|
||||
else
|
||||
local first = (-(high * 65536)) % 50
|
||||
for low = first, 65535, 50 do
|
||||
local pid = high * 65536 + low
|
||||
if bit.bxor(bit.bxor(tid, high), low) >= 8 and ok(pid) then return pid end
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function TradeConvert.dvsFor(view, destGen, destSpecies)
|
||||
local iv = view.ivs
|
||||
local target = { attack = Policy.dvFromIv(iv.atk), defense = Policy.dvFromIv(iv.def),
|
||||
speed = Policy.dvFromIv(iv.spe), special = Policy.dvFromIv(iv.spa) }
|
||||
local shiny = Project.shiny3(view.personality, view.otId, view.otSecretId)
|
||||
local gender = destGen == 2 and Project.gender3(destSpecies.genderRatio, view.personality) or nil
|
||||
local letter = view.national == 201 and Project.unownLetter3(view.personality) or nil
|
||||
if letter and letter > 25 then return nil, target end
|
||||
local best, score
|
||||
for n = 0, 65535 do
|
||||
local d = { attack = math.floor(n / 4096), defense = math.floor(n / 256) % 16,
|
||||
speed = math.floor(n / 16) % 16, special = n % 16 }
|
||||
if Project.shinyDv(d) == shiny and (not gender or Project.genderDv(destSpecies.genderRatio, d) == gender)
|
||||
and (not letter or Project.unownLetterDv(d) - 1 == letter) then
|
||||
local distance = math.abs(d.attack - target.attack) + math.abs(d.defense - target.defense)
|
||||
+ math.abs(d.speed - target.speed) + math.abs(d.special - target.special)
|
||||
if score == nil or distance < score then
|
||||
best, score = d, distance
|
||||
if distance == 0 then break end
|
||||
end
|
||||
end
|
||||
end
|
||||
if best then best.hp = Project.hpDv(best) end
|
||||
return best, target
|
||||
end
|
||||
|
||||
local function itemInfo(data, localItem)
|
||||
if localItem == nil or localItem == 0 then return nil end
|
||||
local row = data.items.byLocal[localItem]
|
||||
if row == nil and data.generation == 3 then row = data.items.byLocal[tonumber(localItem)] end
|
||||
return row
|
||||
end
|
||||
|
||||
local function gbStats(data, national, level, dvs, statExp)
|
||||
local localKey = data.species[national].localKey
|
||||
local def = data.raw.pokemon[localKey]
|
||||
if data.generation == 1 then
|
||||
return require("src.pokemon.Stats").calc(def, level, dvs, statExp)
|
||||
end
|
||||
return require("src.battle.gen2.Mon").stats(def.baseStats, dvs, level, statExp)
|
||||
end
|
||||
|
||||
local function gen3Gender(ratio, personality)
|
||||
local g = Project.gender3(ratio, personality)
|
||||
return g == "female" and "F" or g == "male" and "M" or "U"
|
||||
end
|
||||
|
||||
local function destLegalMoves(dst, national, level, exclude)
|
||||
local out = {}
|
||||
for move in pairs(Datasets.learnSources(dst, national, level)) do
|
||||
if dst.moves[move] and not (exclude and exclude[move]) then out[#out + 1] = move end
|
||||
end
|
||||
table.sort(out, function(a, b)
|
||||
local x, y = dst.moves[a].key or "", dst.moves[b].key or ""
|
||||
if x ~= y then return x < y end
|
||||
return a < b
|
||||
end)
|
||||
return out
|
||||
end
|
||||
TradeConvert.destLegalMoves = destLegalMoves
|
||||
|
||||
function TradeConvert.convert(args)
|
||||
local src, dst = args.source and args.source.data, args.target and args.target.data
|
||||
local rec = args.mon
|
||||
local adjustments = args.adjustments or {}
|
||||
local report = { ok = false, blocks = {}, options = {}, changes = {}, accounting = {} }
|
||||
local function block(code, field, detail) report.blocks[#report.blocks + 1] = { slot = args.slot, code = code, field = field, detail = detail } end
|
||||
local function mark(field, fate, from, to)
|
||||
if report.accounting[field] ~= nil then return end
|
||||
report.accounting[field] = fate
|
||||
if fate == "changed" then
|
||||
report.changes[#report.changes + 1] = { slot = args.slot, field = field, from = copy(from), to = copy(to), kind = "change" }
|
||||
elseif fate == "lost" then
|
||||
report.changes[#report.changes + 1] = { slot = args.slot, field = field, from = copy(from), kind = "loss" }
|
||||
end
|
||||
end
|
||||
local function change(field, from, to)
|
||||
report.changes[#report.changes + 1] = { slot = args.slot, field = field, from = copy(from), to = copy(to), kind = "change" }
|
||||
end
|
||||
local function loss(field, from)
|
||||
report.changes[#report.changes + 1] = { slot = args.slot, field = field, from = copy(from), kind = "loss" }
|
||||
end
|
||||
if type(src) ~= "table" or type(dst) ~= "table" then
|
||||
block("missing_import", nil, { need = type(src) ~= "table" and "source" or "target" })
|
||||
return report
|
||||
end
|
||||
local versions = args.versions
|
||||
if versions and versions.policy ~= nil and versions.policy ~= Policy.VERSION then
|
||||
block("policy_mismatch", nil, { mine = Policy.VERSION, theirs = versions.policy })
|
||||
end
|
||||
local view, code, detail = Project.read(rec, src)
|
||||
if not view then
|
||||
block(code, nil, detail)
|
||||
return report
|
||||
end
|
||||
local g1, g2 = src.generation, dst.generation
|
||||
local sameGen = g1 == g2
|
||||
local national, level = view.national, view.level
|
||||
if view.isEgg then block("egg", "isEgg") end
|
||||
local srcItem = g1 ~= 1 and itemInfo(src, view.item) or nil
|
||||
if (g1 ~= 1 and view.item ~= nil and view.item ~= 0 and not srcItem) then block("item_unknown", "item", { item = view.item }) end
|
||||
if rec.mail ~= nil and rec.mail ~= 255 and rec.mail ~= false or (srcItem and srcItem.mail) then
|
||||
block("mail", "item", { item = srcItem and srcItem.key })
|
||||
end
|
||||
local dsp = dst.species[national]
|
||||
if not dsp then
|
||||
block("species_missing", "species", { national = national, dexMax = dst.dexMax })
|
||||
return report
|
||||
end
|
||||
local ssp = src.species[national]
|
||||
local out = {}
|
||||
|
||||
local destLocal = dsp.localKey
|
||||
out.species = destLocal
|
||||
mark("species", "carried")
|
||||
if g2 == 3 then mark("speciesId", "derived") end
|
||||
mark("level", "carried")
|
||||
out.level = level
|
||||
|
||||
local destExpLo = Datasets.expAt(dst, national, level)
|
||||
local destExpHi = level < 100 and (Datasets.expAt(dst, national, level + 1) - 1) or destExpLo
|
||||
local exp = view.exp and math.floor(view.exp) or destExpLo
|
||||
local clamped = math.max(destExpLo, math.min(destExpHi, exp))
|
||||
local expField = rec.exp ~= nil and "exp" or rec.experience ~= nil and "experience" or "exp"
|
||||
if view.exp == nil then
|
||||
mark(expField, "derived")
|
||||
elseif clamped ~= view.exp then
|
||||
mark(expField, "changed", view.exp, clamped)
|
||||
else
|
||||
mark(expField, "carried")
|
||||
end
|
||||
if rec.exp ~= nil and rec.experience ~= nil then mark("experience", report.accounting.exp) end
|
||||
if g2 == 2 then out.experience = clamped else out.exp = clamped end
|
||||
|
||||
local srcDefault = ssp.key
|
||||
local nickname = view.nickname
|
||||
if nickname and Identity.normalize(nickname) == srcDefault then nickname = nil end
|
||||
if nickname then
|
||||
if not TradeConvert.nameFits(nickname, g2, Policy.NAME_LIMIT.nickname) then
|
||||
block("nickname_unencodable", "nickname", { text = nickname })
|
||||
end
|
||||
out.nickname = nickname
|
||||
mark("nickname", "carried")
|
||||
else
|
||||
if g2 == 3 then out.nickname = dsp.name end
|
||||
if view.nickname and view.nickname ~= (g2 == 3 and dsp.name or nil) then
|
||||
mark("nickname", "changed", view.nickname, out.nickname)
|
||||
else
|
||||
mark("nickname", "carried")
|
||||
end
|
||||
end
|
||||
|
||||
local ot = view.otName
|
||||
if type(ot) ~= "string" or not TradeConvert.nameFits(ot, g2, Policy.NAME_LIMIT.ot) then
|
||||
block("ot_unencodable", "otName", { text = ot })
|
||||
end
|
||||
if type(view.otId) ~= "number" or view.otId % 1 ~= 0 or view.otId < 0 or view.otId > 65535 then
|
||||
block("ot_invalid", "otId", { id = view.otId })
|
||||
end
|
||||
if g2 == 3 then out.otName = ot else out.ot = ot end
|
||||
out.otId = view.otId
|
||||
mark("ot", "carried")
|
||||
mark("otName", "carried")
|
||||
mark("otId", "carried")
|
||||
|
||||
if g2 == 3 then
|
||||
if g1 == 3 then
|
||||
out.otSecretId = view.otSecretId or 0
|
||||
mark("otSecretId", "carried")
|
||||
else
|
||||
out.otSecretId = 0
|
||||
change("otSecretId", nil, 0)
|
||||
end
|
||||
elseif g1 == 3 then
|
||||
mark("otSecretId", "lost", rec.otSecretId)
|
||||
end
|
||||
|
||||
local legalFlag = {}
|
||||
local destMoves = {}
|
||||
local adjustMoves = adjustments.moves or {}
|
||||
local listed = {}
|
||||
for _, m in ipairs(view.moves) do listed[m.move] = true end
|
||||
local legalDest = Datasets.learnSources(dst, national, level)
|
||||
local moveOptions = nil
|
||||
for j, m in ipairs(view.moves) do
|
||||
local adj = adjustMoves[j]
|
||||
if adj ~= nil then
|
||||
if adj == 0 or adj == false then
|
||||
change("moves", m.move, 0)
|
||||
elseif dst.moves[adj] and legalDest[adj] and not legalFlag[adj] then
|
||||
destMoves[#destMoves + 1] = { move = adj, ppUps = 0 }
|
||||
legalFlag[adj] = true
|
||||
change("moves", m.move, adj)
|
||||
else
|
||||
block("replacement_not_legal", "moves", { index = j, move = adj })
|
||||
end
|
||||
else
|
||||
local exists = dst.moves[m.move] ~= nil
|
||||
local okDest = exists and legalDest[m.move] ~= nil
|
||||
local okSrc = exists and Datasets.learnable(src, national, m.move, level)
|
||||
if not exists then
|
||||
block("move_missing", "moves", { index = j, move = m.move, moveMax = dst.moveMax })
|
||||
moveOptions = moveOptions or {}
|
||||
moveOptions[j] = destLegalMoves(dst, national, level, listed)
|
||||
elseif not (okDest or okSrc) then
|
||||
block("move_not_legal", "moves", { index = j, move = m.move })
|
||||
moveOptions = moveOptions or {}
|
||||
moveOptions[j] = destLegalMoves(dst, national, level, listed)
|
||||
elseif not legalFlag[m.move] then
|
||||
destMoves[#destMoves + 1] = { move = m.move, ppUps = m.ppUps, pp = m.pp }
|
||||
legalFlag[m.move] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
if moveOptions then report.options.moves = moveOptions end
|
||||
if #destMoves == 0 and #report.blocks == 0 then block("no_moves", "moves") end
|
||||
local ppChanged = false
|
||||
local outMoves = {}
|
||||
for _, m in ipairs(destMoves) do
|
||||
local base = dst.moves[m.move].pp
|
||||
local max = Policy.maxPp(base, m.ppUps)
|
||||
local srcBase = src.moves[m.move] and src.moves[m.move].pp
|
||||
if srcBase ~= base or (m.pp ~= nil and m.pp ~= max) then ppChanged = true end
|
||||
local localKey = dst.moves[m.move].localKey
|
||||
if g2 == 3 then
|
||||
outMoves[#outMoves + 1] = { id = localKey, pp = max, ppUps = m.ppUps }
|
||||
elseif g2 == 2 then
|
||||
outMoves[#outMoves + 1] = { id = localKey, pp = max, ppUps = m.ppUps, maxPp = max }
|
||||
else
|
||||
outMoves[#outMoves + 1] = { id = localKey, pp = max, ppUps = m.ppUps }
|
||||
end
|
||||
end
|
||||
out.moves = outMoves
|
||||
mark("moves", "carried")
|
||||
if g1 == 3 then
|
||||
for _, key in ipairs({ "pp", "ppBonusesPacked", "ppBonuses" }) do mark(key, ppChanged and "changed" or "carried", rec[key], nil) end
|
||||
end
|
||||
if ppChanged then change("pp", nil, "full") end
|
||||
|
||||
if g1 == 3 and g2 == 3 then
|
||||
out.ivs, out.evs = copy(view.ivs), copy(view.evs)
|
||||
mark("ivs", "carried")
|
||||
mark("evs", "carried")
|
||||
out.personality = view.personality
|
||||
mark("personality", "carried")
|
||||
out.abilityNum = view.abilityNum
|
||||
mark("abilityNum", "carried")
|
||||
local pair = dsp.abilities or {}
|
||||
out.ability = pair[view.abilityNum + 1] ~= 0 and pair[view.abilityNum + 1] or pair[1]
|
||||
elseif g1 < 3 and g2 < 3 then
|
||||
out.dvs = copy(view.dvs)
|
||||
out.statExp = copy(view.statExp)
|
||||
mark("dvs", "carried")
|
||||
mark("statExp", "carried")
|
||||
elseif g1 < 3 and g2 == 3 then
|
||||
local ivs = Project.ivs(view)
|
||||
local evs = Project.evs(view)
|
||||
out.ivs, out.evs = ivs, evs
|
||||
mark("dvs", "changed", view.dvs, ivs)
|
||||
mark("statExp", "changed", view.statExp, evs)
|
||||
local pid = TradeConvert.personalityFor(view, dsp)
|
||||
if not pid then
|
||||
block("personality_unrepresentable", "personality")
|
||||
else
|
||||
out.personality = pid
|
||||
change("personality", nil, pid)
|
||||
end
|
||||
out.abilityNum = Policy.ABILITY_SLOT
|
||||
out.ability = (dsp.abilities or {})[1] or 0
|
||||
change("abilityNum", nil, Policy.ABILITY_SLOT)
|
||||
else
|
||||
local dvs, target = TradeConvert.dvsFor(view, g2, dsp)
|
||||
if not dvs then
|
||||
block("traits_unrepresentable", "personality", { national = national })
|
||||
dvs = { attack = target.attack, defense = target.defense, speed = target.speed, special = target.special }
|
||||
dvs.hp = Project.hpDv(dvs)
|
||||
end
|
||||
out.dvs = dvs
|
||||
mark("ivs", "changed", view.ivs, dvs)
|
||||
local statExp = { hp = Policy.statExpFromEv(view.evs.hp), attack = Policy.statExpFromEv(view.evs.atk),
|
||||
defense = Policy.statExpFromEv(view.evs.def), speed = Policy.statExpFromEv(view.evs.spe),
|
||||
special = Policy.statExpFromEv(view.evs.spa) }
|
||||
out.statExp = statExp
|
||||
mark("evs", "changed", view.evs, statExp)
|
||||
mark("personality", "lost", view.personality)
|
||||
mark("abilityNum", "lost", view.abilityNum)
|
||||
loss("nature", view.nature)
|
||||
loss("ability", view.ability)
|
||||
if view.evs.spd ~= view.evs.spa then loss("evs.spd", view.evs.spd) end
|
||||
if view.ivs.hp ~= nil then loss("ivs.hp", view.ivs.hp) end
|
||||
end
|
||||
|
||||
local traitsBefore = Project.traits(view, src)
|
||||
if g2 == 3 and out.personality then
|
||||
out.nature = out.personality % 25
|
||||
out.gender = gen3Gender(dsp.genderRatio, out.personality)
|
||||
end
|
||||
|
||||
local item, catchRate
|
||||
if g1 == 1 then
|
||||
if g2 == 2 then
|
||||
item = require("src.online.Convert").heldItemFromCatchRate(view.catchRate or ssp.catchRate, dst.raw)
|
||||
if item then change("item", view.catchRate, item) end
|
||||
mark("catchRate", item and "changed" or "lost", view.catchRate, item)
|
||||
elseif g2 == 1 then
|
||||
catchRate = view.catchRate or dsp.catchRate
|
||||
mark("catchRate", "carried")
|
||||
else
|
||||
mark("catchRate", "lost", view.catchRate)
|
||||
end
|
||||
elseif srcItem then
|
||||
if g2 == 1 then
|
||||
if g1 == 2 then
|
||||
catchRate = tonumber(srcItem.index) or 0
|
||||
mark("item", "changed", srcItem.key, catchRate)
|
||||
else
|
||||
block("item_unrepresentable", "item", { item = srcItem.key })
|
||||
end
|
||||
else
|
||||
local mapped = Identity.localItem(dst, srcItem.key)
|
||||
if mapped == nil then
|
||||
block("item_unrepresentable", "item", { item = srcItem.key })
|
||||
else
|
||||
item = mapped
|
||||
end
|
||||
mark("item", "carried")
|
||||
mark("heldItem", "carried")
|
||||
end
|
||||
else
|
||||
mark("item", "carried")
|
||||
mark("heldItem", "carried")
|
||||
if g2 == 1 then catchRate = g1 == 2 and 0 or dsp.catchRate end
|
||||
end
|
||||
if g2 == 2 then out.item = item elseif g2 == 3 then out.item = item or 0 end
|
||||
if g2 == 1 then out.catchRate = catchRate end
|
||||
|
||||
local friendship = view.friendship
|
||||
local friendField = g1 == 3 and "friendship" or "happiness"
|
||||
if g2 == 1 then
|
||||
if friendship ~= nil and g1 ~= 1 then mark(friendField, "lost", friendship) end
|
||||
elseif g1 == 1 then
|
||||
out[g2 == 2 and "happiness" or "friendship"] = g2 == 2 and Policy.GEN2_BASE_HAPPINESS or dsp.friendship
|
||||
change(g2 == 2 and "happiness" or "friendship", nil, out[g2 == 2 and "happiness" or "friendship"])
|
||||
else
|
||||
local value = friendship
|
||||
if value == nil then value = g2 == 2 and Policy.GEN2_BASE_HAPPINESS or dsp.friendship end
|
||||
out[g2 == 2 and "happiness" or "friendship"] = value
|
||||
mark(friendField, "carried")
|
||||
end
|
||||
if g1 == 3 then mark("happiness", report.accounting.friendship or "carried") end
|
||||
|
||||
if g2 == 1 then
|
||||
if view.pokerus ~= 0 then mark("pokerus", "lost", view.pokerus) else mark("pokerus", "carried") end
|
||||
else
|
||||
out.pokerus = view.pokerus
|
||||
mark("pokerus", "carried")
|
||||
end
|
||||
|
||||
if g2 == 2 then
|
||||
if g1 == 2 then
|
||||
out.caughtLevel, out.caughtTime, out.caughtLocation = rec.caughtLevel, rec.caughtTime, rec.caughtLocation
|
||||
out.caughtByGender = rec.caughtByGender or rec.caughtGender
|
||||
for _, key in ipairs({ "caughtLevel", "caughtTime", "caughtLocation", "caughtByGender", "caughtGender" }) do mark(key, "carried") end
|
||||
else
|
||||
out.caughtLevel, out.caughtTime, out.caughtLocation, out.caughtByGender = 0, 0, 0, 0
|
||||
change("caughtData", nil, 0)
|
||||
end
|
||||
elseif g1 == 2 then
|
||||
for _, key in ipairs({ "caughtLevel", "caughtTime", "caughtLocation", "caughtByGender", "caughtGender" }) do
|
||||
if rec[key] ~= nil and rec[key] ~= 0 then mark(key, "lost", rec[key]) else mark(key, "carried") end
|
||||
end
|
||||
end
|
||||
|
||||
local GEN3_KEEP = { "otGender", "language", "metLocation", "metLevel", "metGame", "pokeball", "markings",
|
||||
"fatefulEncounter", "modernFatefulEncounter" }
|
||||
if g2 == 3 then
|
||||
if g1 == 3 then
|
||||
for _, key in ipairs(GEN3_KEEP) do out[key] = copy(rec[key]); mark(key, "carried") end
|
||||
out.contest = copy(rec.contest)
|
||||
mark("contest", "carried")
|
||||
out.ribbons = require("src.core.game3.rse.ribbons").word(rec)
|
||||
mark("ribbons", "carried")
|
||||
mark("championRibbon", "carried")
|
||||
out.otGender = out.otGender or 0
|
||||
out.language = out.language or Policy.GBA_LANGUAGE_ENGLISH
|
||||
else
|
||||
out.otGender = (g1 == 2 and (rec.caughtByGender or rec.caughtGender)) or 0
|
||||
out.language = Policy.GBA_LANGUAGE_ENGLISH
|
||||
out.metLocation = Policy.METLOC_IN_GAME_TRADE
|
||||
out.metLevel = level
|
||||
out.metGame = Policy.GBA_VERSION_ID[dst.version] or 0
|
||||
out.pokeball = Policy.ITEM_POKE_BALL
|
||||
out.markings = 0
|
||||
out.ribbons = national == 151 and 2147483648 or 0
|
||||
out.contest = { cool = 0, beauty = 0, cute = 0, smart = 0, tough = 0, sheen = 0 }
|
||||
out.fatefulEncounter = false
|
||||
-- src/battle_util.c:3898
|
||||
out.modernFatefulEncounter = national == 151
|
||||
change("origin", nil, { metLocation = out.metLocation, metLevel = level, metGame = out.metGame,
|
||||
pokeball = out.pokeball, language = out.language, otGender = out.otGender })
|
||||
if out.modernFatefulEncounter then change("modernFatefulEncounter", nil, true) end
|
||||
end
|
||||
out.isEgg = false
|
||||
out.eggCycles = 0
|
||||
elseif g1 == 3 then
|
||||
for _, key in ipairs(GEN3_KEEP) do
|
||||
if rec[key] ~= nil then mark(key, "lost", rec[key]) end
|
||||
end
|
||||
if rec.contest ~= nil then mark("contest", "lost", rec.contest) end
|
||||
local word = require("src.core.game3.rse.ribbons").word(rec)
|
||||
if word ~= 0 then mark("ribbons", "lost", word) else mark("ribbons", "carried") end
|
||||
mark("championRibbon", word ~= 0 and "lost" or "carried", rec.championRibbon)
|
||||
end
|
||||
|
||||
for _, key in ipairs({ "isEgg", "eggSteps", "eggCycles", "isBadEgg", "mail" }) do mark(key, "carried") end
|
||||
|
||||
local hpWas, statusWas = rec.hp, rec.status
|
||||
if g2 == 3 then
|
||||
local stats = Project.stats3(dsp.base, level, out.ivs, out.evs, out.nature or 0, national)
|
||||
out.hp = stats.hp
|
||||
out.status = ""
|
||||
else
|
||||
local stats = gbStats(dst, national, level, out.dvs, out.statExp)
|
||||
out.stats = stats
|
||||
out.hp = stats.hp
|
||||
out.maxHp = stats.hp
|
||||
out.status = nil
|
||||
end
|
||||
if hpWas ~= nil and hpWas ~= out.hp then mark("hp", "changed", hpWas, out.hp) else mark("hp", "derived") end
|
||||
if statusWas ~= nil and statusWas ~= "" and statusWas ~= 0 then mark("status", "changed", statusWas, nil) else mark("status", "carried") end
|
||||
if rec.sleepTurns ~= nil then mark("sleepTurns", "changed", rec.sleepTurns, nil) end
|
||||
if rec.sleep ~= nil then mark("sleep", "changed", rec.sleep, nil) end
|
||||
|
||||
if g2 == 2 then
|
||||
out.gender = Project.genderDv(dsp.genderRatio, out.dvs)
|
||||
out.shiny = Project.shinyDv(out.dvs)
|
||||
if national == 201 then out.unownLetter = Project.unownLetterDv(out.dvs) end
|
||||
end
|
||||
local destView = { gen = g2, national = national, dvs = out.dvs, personality = out.personality,
|
||||
otId = out.otId, otSecretId = out.otSecretId }
|
||||
if g2 ~= 3 or out.personality then
|
||||
local after = Project.traits(destView, dst)
|
||||
if after.shiny ~= traitsBefore.shiny then change("shiny", traitsBefore.shiny, after.shiny) end
|
||||
if traitsBefore.gender and after.gender and after.gender ~= traitsBefore.gender then change("gender", traitsBefore.gender, after.gender) end
|
||||
if traitsBefore.gender and not after.gender then loss("gender", traitsBefore.gender) end
|
||||
if traitsBefore.unownLetter ~= after.unownLetter then change("unownLetter", traitsBefore.unownLetter, after.unownLetter) end
|
||||
end
|
||||
|
||||
if not sameGen then
|
||||
if rec.extra ~= nil then mark("extra", "lost", rec.extra) end
|
||||
else
|
||||
out.extra = copy(rec.extra)
|
||||
mark("extra", "carried")
|
||||
end
|
||||
|
||||
for key in pairs(rec) do
|
||||
if report.accounting[key] == nil then
|
||||
if DERIVED[key] then mark(key, "derived") else mark(key, "lost", rec[key]) end
|
||||
end
|
||||
end
|
||||
|
||||
report.result = out
|
||||
report.canonical = TradeConvert.canonical(out)
|
||||
report.ok = #report.blocks == 0
|
||||
if not report.ok then report.result = nil; report.canonical = nil; report.preview = out end
|
||||
return report
|
||||
end
|
||||
|
||||
return TradeConvert
|
||||
+12
-2
@@ -54,11 +54,19 @@ function Assets.resolve(path)
|
||||
return path
|
||||
end
|
||||
|
||||
local composed = {}
|
||||
|
||||
function Assets.compose(path, build)
|
||||
composed[path] = build
|
||||
cache[path] = nil
|
||||
end
|
||||
|
||||
function Assets.image(path)
|
||||
local resolved = Assets.resolve(path)
|
||||
local build = composed[path]
|
||||
local resolved = build and path or Assets.resolve(path)
|
||||
local image = cache[resolved]
|
||||
if not image then
|
||||
image = love.graphics.newImage(resolved)
|
||||
image = love.graphics.newImage(build and build() or resolved)
|
||||
cache[resolved] = image
|
||||
end
|
||||
return image
|
||||
@@ -67,6 +75,8 @@ end
|
||||
-- pixel-level reads (tile-shift variants, the spinner strip blit) resolve
|
||||
-- the same way but stay uncached: the caller keeps the derived product
|
||||
function Assets.imageData(path)
|
||||
local build = composed[path]
|
||||
if build then return build() end
|
||||
return love.image.newImageData(Assets.resolve(path))
|
||||
end
|
||||
|
||||
|
||||
@@ -793,7 +793,7 @@ end
|
||||
|
||||
-- the palette-group (0-7) a tile GRAPHIC id resolves to in this tileset,
|
||||
-- with the current map's tile-id exceptions (if any) applied first
|
||||
function PaletteFX.worldGroupAt(tileset, mapId, tileId)
|
||||
function PaletteFX.worldGroupAt(tileset, mapId, tileId, tileSources)
|
||||
local w = PaletteFX.worldPack()
|
||||
local groups = w and w.tileGroups[tileset]
|
||||
if not groups then return nil end
|
||||
@@ -801,6 +801,11 @@ function PaletteFX.worldGroupAt(tileset, mapId, tileId)
|
||||
if exc and exc.tiles[tileId] then return exc.group end
|
||||
exc = TILESET_GROUP_EXCEPTIONS[tileset]
|
||||
if exc and exc.tiles[tileId] then return exc.group end
|
||||
local src = groups[tileId] == nil and tileSources and tileSources[tileId]
|
||||
if src then
|
||||
local from = w.tileGroups[src.tileset]
|
||||
if from and from[src.tile] then return from[src.tile] end
|
||||
end
|
||||
return groups[tileId] or 7 -- TEXT: tile ids past the tileset's 96 (menus)
|
||||
end
|
||||
|
||||
|
||||
@@ -427,6 +427,8 @@ end
|
||||
|
||||
local function getGbcAtlas(imagePath, tilesetId, mapId, perRow, data, bgp)
|
||||
local key = imagePath .. gbcKeyFor(mapId)
|
||||
local tileSources = data and data.tilesets and data.tilesets[tilesetId]
|
||||
tileSources = tileSources and tileSources.tileSources
|
||||
if not bgp and gbcAtlasCache[key] ~= nil then return gbcAtlasCache[key] or nil end
|
||||
local img = false
|
||||
if love.image and love.image.newImageData then
|
||||
@@ -440,7 +442,7 @@ local function getGbcAtlas(imagePath, tilesetId, mapId, perRow, data, bgp)
|
||||
for t = 0, total - 1 do
|
||||
local colors = tileColors[t]
|
||||
if colors == nil then
|
||||
local group = PaletteFX.worldGroupAt(tilesetId, mapId, t)
|
||||
local group = PaletteFX.worldGroupAt(tilesetId, mapId, t, tileSources)
|
||||
colors = (group and groupColors[group + 1]) or false
|
||||
tileColors[t] = colors
|
||||
end
|
||||
@@ -504,6 +506,7 @@ function TileRenderer.new(map, data)
|
||||
-- context rather than re-deriving it per draw.
|
||||
gbcCtx.imagePath = map.tileset.image
|
||||
gbcCtx.perRow = map.tileset.tilesPerRow
|
||||
gbcCtx.tileSources = map.tileset.tileSources
|
||||
self.gbcCtx = gbcCtx
|
||||
self.gbcAtlasKey = map.tileset.image .. gbcCtx.key
|
||||
self.gbcKeyed = {}
|
||||
@@ -711,7 +714,7 @@ local function getKeyedTile(self, tile)
|
||||
if cached ~= nil then return cached or nil end
|
||||
local img = false
|
||||
if ctx.groupColors and love.image and love.image.newImageData then
|
||||
local group = PaletteFX.worldGroupAt(ctx.tilesetId, ctx.mapId, tile)
|
||||
local group = PaletteFX.worldGroupAt(ctx.tilesetId, ctx.mapId, tile, ctx.tileSources)
|
||||
local colors = group and ctx.groupColors[group + 1]
|
||||
local src = Assets.imageData(ctx.imagePath)
|
||||
local ox = (tile % ctx.perRow) * 8
|
||||
|
||||
@@ -111,10 +111,11 @@ local DATA_MODULES = {
|
||||
hiddenItems = { "src.save_convert.data.hidden_items", "src/save_convert/data/hidden_items.lua" },
|
||||
tradeFlags = { "src.save_convert.data.trade_flags", "src/save_convert/data/trade_flags.lua" },
|
||||
field = { "data.generated.field", "data/generated/field.lua" },
|
||||
text_pointers = { "data.generated.text_pointers", "data/generated/text_pointers.lua" },
|
||||
trainerHeaders = { "data.generated.trainer_headers", "data/generated/trainer_headers.lua" },
|
||||
}
|
||||
|
||||
local OPTIONAL_MODULES = { tilesets = true, audio = true, field = true, trainerHeaders = true }
|
||||
local OPTIONAL_MODULES = { tilesets = true, audio = true, field = true, trainerHeaders = true, text_pointers = true }
|
||||
|
||||
-- Yellow renumbers wEventFlags bits: pokeyellow's constants/event_constants.asm
|
||||
-- inserts events pokered does not have (the Jessie & James fights, catch
|
||||
@@ -619,6 +620,54 @@ end
|
||||
-- GenSave reproduces every unmodeled region from it; otherwise those regions
|
||||
-- are zero-filled. gameVersion selects the crosswalk tables exactly as in
|
||||
-- importSav. On failure returns nil + a message (never raises).
|
||||
local function gen2Sealed(saveTable, gameVersion, g2data)
|
||||
local Safety = require("src.world.gen2.UnionSafety")
|
||||
local view = { gen2Maps = g2data.maps }
|
||||
if not Safety.isAdded(view, saveTable.position) then return saveTable end
|
||||
for key, name in pairs({ gen2Tilesets = "tilesets", gen2Landmarks = "landmarks" }) do
|
||||
g2data[name] = g2data[name] or loadCacheTable(gameVersion, "data/generated/" .. name .. ".lua")
|
||||
if not g2data[name] then return nil, gen2CacheMissing(gameVersion) end
|
||||
view[key] = g2data[name]
|
||||
end
|
||||
local out = {}
|
||||
for k, v in pairs(saveTable) do out[k] = v end
|
||||
Safety.seal(out, view)
|
||||
return out
|
||||
end
|
||||
|
||||
local function gen1Sealed(saveTable, data, gameVersion)
|
||||
local UnionSafety = require("src.world.gen1.UnionSafety")
|
||||
if not data.text_pointers then
|
||||
local p = type(saveTable.player) == "table" and saveTable.player or {}
|
||||
local def = data.maps and data.maps[p.map]
|
||||
local desk = UnionSafety.ADDED[p.map]
|
||||
for _, o in ipairs(type(def) == "table" and def.objects or {}) do
|
||||
if o.sprite == "SPRITE_LINK_RECEPTIONIST" then desk = true end
|
||||
end
|
||||
if desk then return nil, gen1CacheMissing(gameVersion, DATA_MODULES.text_pointers[2]) end
|
||||
end
|
||||
return UnionSafety.forWrite(saveTable, data)
|
||||
end
|
||||
|
||||
local function gen3Sealed(saveTable, gameVersion)
|
||||
local Spot = require("src.core.game3.link.union_save_spot")
|
||||
local codec = Gen3Save.forVersion(gameVersion)
|
||||
local lookup = Spot.cacheLookup(function(rel) return gen3CacheBytes(gameVersion, rel) end,
|
||||
function(mapId)
|
||||
local w = codec.cartWarp({ map = mapId })
|
||||
return w and (w.group .. "_" .. w.num) or nil
|
||||
end, codec.mapFor)
|
||||
if not Spot.isAdded(gameVersion, lookup, saveTable.map, saveTable.x, saveTable.y) then return saveTable end
|
||||
local o = Spot.resolve(gameVersion, lookup, saveTable)
|
||||
if not o then return nil, codec.MSG.noMap end
|
||||
local out = {}
|
||||
for k, v in pairs(saveTable) do out[k] = v end
|
||||
out.map, out.x, out.y, out.facing = o.map, o.x, o.y, o.facing
|
||||
local bit = require("bit")
|
||||
out.specialSaveWarpFlags = bit.band(tonumber(saveTable.specialSaveWarpFlags) or 0, bit.bnot(codec.L.CONTINUE_GAME_WARP))
|
||||
return out
|
||||
end
|
||||
|
||||
local function exportRaw(saveTable, gameVersion, cartImage)
|
||||
if type(saveTable) ~= "table" then
|
||||
return nil, "expected a save table"
|
||||
@@ -626,7 +675,9 @@ local function exportRaw(saveTable, gameVersion, cartImage)
|
||||
local supported, unsupportedWhy = SaveConvert.exportSupported(gameVersion)
|
||||
if not supported then return nil, unsupportedWhy end
|
||||
if isGen3(gameVersion) then
|
||||
local ok, bytes, err = pcall(Gen3Save.forVersion(gameVersion).exportPort, saveTable, gen3ExportOpts(gameVersion, cartImage))
|
||||
local sealed, serr = gen3Sealed(saveTable, gameVersion)
|
||||
if not sealed then return nil, serr end
|
||||
local ok, bytes, err = pcall(Gen3Save.forVersion(gameVersion).exportPort, sealed, gen3ExportOpts(gameVersion, cartImage))
|
||||
if not ok then return nil, "encode failed: " .. tostring(bytes) end
|
||||
return bytes, err
|
||||
end
|
||||
@@ -634,12 +685,17 @@ local function exportRaw(saveTable, gameVersion, cartImage)
|
||||
if Gen2Save.layoutFor(gameVersion) then
|
||||
local g2data = ensureGen2Data(gameVersion)
|
||||
if not g2data then return nil, gen2CacheMissing(gameVersion) end
|
||||
local ok, bytes, err = pcall(Gen2Save.encode, saveTable, gameVersion, cartImage, g2data)
|
||||
local sealed, serr = gen2Sealed(saveTable, gameVersion, g2data)
|
||||
if not sealed then return nil, serr end
|
||||
local ok, bytes, err = pcall(Gen2Save.encode, sealed, gameVersion, cartImage, g2data)
|
||||
if not ok then return nil, "encode failed: " .. tostring(bytes) end
|
||||
return bytes, err
|
||||
end
|
||||
local data, derr = ensureData(gameVersion)
|
||||
if not data then return nil, derr end
|
||||
local serr
|
||||
saveTable, serr = gen1Sealed(saveTable, data, gameVersion)
|
||||
if not saveTable then return nil, serr end
|
||||
local ok, bytes = pcall(GenSave.encode, saveTable, data, saveTable.rawImport or cartImage)
|
||||
if not ok then return nil, "encode failed: " .. tostring(bytes) end
|
||||
return bytes
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,239 @@
|
||||
local BattleSession = require("src.online.union.BattleSession")
|
||||
|
||||
local Launch = {}
|
||||
|
||||
Launch.PRESENTERS = {
|
||||
[1] = "src.ui.g3u.Gen1Screen",
|
||||
[2] = "src.ui.g3u.Gen2Facade",
|
||||
[3] = "src.ui.g3u.Gen3Presenter",
|
||||
}
|
||||
|
||||
Launch.NATIVE_WAIT = 60
|
||||
|
||||
local function version(game)
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
return (game and game.save and game.save.version) or GameVersion.get()
|
||||
end
|
||||
|
||||
local function once(fn)
|
||||
local done = false
|
||||
return function(...)
|
||||
if done then return end
|
||||
done = true
|
||||
if fn then return fn(...) end
|
||||
end
|
||||
end
|
||||
|
||||
function Launch.linkState(client, roomId)
|
||||
if type(client) ~= "table" then return nil end
|
||||
return function()
|
||||
local room = type(client.room) == "function" and client.room() or nil
|
||||
if not room or (roomId ~= nil and room.room ~= roomId) then return "gone" end
|
||||
if type(client.state) == "function" and client.state() ~= "online" then return "resuming" end
|
||||
return "ok"
|
||||
end
|
||||
end
|
||||
|
||||
function Launch.names(session, seat)
|
||||
local n = session.names or {}
|
||||
return { me = n[seat], foe = n[1 - seat] }
|
||||
end
|
||||
|
||||
function Launch.session(game, session)
|
||||
local seat = tonumber(session.seat)
|
||||
if seat ~= 0 and seat ~= 1 then return nil, "no_seat" end
|
||||
local gens = session.gens or {}
|
||||
local lower = BattleSession.lowerSeat(gens)
|
||||
local data = session.data
|
||||
if not data and lower == seat and not session.table then
|
||||
local Datasets = require("src.online.xgen.Datasets")
|
||||
local why
|
||||
data, why = Datasets.get(version(game))
|
||||
if not data then return nil, why or "missing_import" end
|
||||
end
|
||||
local client = session.client
|
||||
return BattleSession.new({
|
||||
net = session.net,
|
||||
seat = seat,
|
||||
go = session.go,
|
||||
gens = gens,
|
||||
data = data,
|
||||
table = session.table,
|
||||
records = session.records,
|
||||
names = session.names,
|
||||
client = client,
|
||||
linkState = session.linkState or Launch.linkState(client, session.roomId),
|
||||
timeouts = session.timeouts,
|
||||
})
|
||||
end
|
||||
|
||||
local function startG3u(game, gen, session)
|
||||
local bs, why = Launch.session(game, session)
|
||||
if not bs then return nil, why end
|
||||
local mod = require(Launch.PRESENTERS[gen])
|
||||
local done = once(session.onDone)
|
||||
local handle, err = mod.start(game, bs, {
|
||||
names = Launch.names(session, bs.seat),
|
||||
foeClass = session.foeClass,
|
||||
onDone = function(result) done(result) end,
|
||||
})
|
||||
if not handle then
|
||||
bs:quit()
|
||||
return nil, err or "presenter_failed"
|
||||
end
|
||||
return { kind = "g3u", gen = gen, bs = bs, screen = handle }
|
||||
end
|
||||
|
||||
local Native = {}
|
||||
Native.__index = Native
|
||||
|
||||
function Native.new(game, gen, session)
|
||||
local Protocol = require("src.link.Protocol")
|
||||
local party = game.save and game.save.party or {}
|
||||
local size = tonumber(session.go and session.go.size) or 6
|
||||
local indices = {}
|
||||
for _, i in ipairs(session.team or {}) do
|
||||
if party[i] and #indices < size then indices[#indices + 1] = i end
|
||||
end
|
||||
if #indices == 0 then
|
||||
for i = 1, math.min(#party, size) do indices[i] = i end
|
||||
end
|
||||
if #indices == 0 then return nil, "no_party" end
|
||||
local packed = gen == 2 and Protocol.packParty2(party, indices) or Protocol.packParty(party, indices)
|
||||
local self = setmetatable({
|
||||
game = game, gen = gen, session = session, net = session.net,
|
||||
seat = tonumber(session.seat) or 0, packed = packed, stage = "party",
|
||||
waited = 0, done = once(session.onDone), isOpaque = false,
|
||||
}, Native)
|
||||
return self
|
||||
end
|
||||
|
||||
function Native:enter()
|
||||
self.net:send({ type = "party", mons = self.packed })
|
||||
end
|
||||
|
||||
function Native:takeParty()
|
||||
local net = self.net
|
||||
if type(net.take) == "function" then return net:take("party") end
|
||||
if type(net.takeWhere) == "function" then
|
||||
return net:takeWhere(function(m) return m.type == "party" end)
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
function Native:finish(result)
|
||||
if self.stage == "over" then return end
|
||||
self.stage = "over"
|
||||
local game = self.game
|
||||
if game.linkNet == self.net then game.linkNet = nil end
|
||||
game.linkSession = nil
|
||||
if game.stack:top() == self then game.stack:pop() end
|
||||
local client = self.session.client
|
||||
if client and (result == "win" or result == "lose" or result == "draw") and type(client.report) == "function" then
|
||||
pcall(client.report, result)
|
||||
end
|
||||
self.done(result)
|
||||
end
|
||||
|
||||
function Native:start(theirs)
|
||||
local session, game = self.session, self.game
|
||||
local names = Launch.names(session, self.seat)
|
||||
local opts = {
|
||||
myParty = self.packed,
|
||||
theirParty = theirs.mons,
|
||||
theirName = names.foe or "FOE",
|
||||
seed = tonumber(session.go and session.go.seed) or 1,
|
||||
verdict = "full",
|
||||
strict = true,
|
||||
keepNetOpen = true,
|
||||
}
|
||||
local LB = require(self.gen == 2 and "src.link.LinkBattle2" or "src.link.LinkBattle")
|
||||
local battle, why
|
||||
if self.seat == 0 then
|
||||
battle, why = LB.newHost(game, self.net, opts)
|
||||
else
|
||||
battle, why = LB.newGuest(game, self.net, opts)
|
||||
end
|
||||
if not battle then
|
||||
self.net:send({ type = "bye" })
|
||||
return self:finish("error", why)
|
||||
end
|
||||
self.battle = battle
|
||||
self.stage = "battle"
|
||||
game.linkSession = true
|
||||
game.stack:push(battle)
|
||||
end
|
||||
|
||||
function Native:update(dt)
|
||||
if self.stage == "party" then
|
||||
if type(self.net.update) == "function" then self.net:update() end
|
||||
local theirs = self:takeParty()
|
||||
if theirs and type(theirs.mons) == "table" then return self:start(theirs) end
|
||||
self.waited = self.waited + (dt or 0)
|
||||
if self.net.closed or self.waited > Launch.NATIVE_WAIT then return self:finish("error") end
|
||||
return
|
||||
end
|
||||
if self.stage == "battle" and self.game.stack:top() == self then
|
||||
local battle = self.battle
|
||||
self.battle = nil
|
||||
self:finish((battle and battle.result) or "draw")
|
||||
end
|
||||
end
|
||||
|
||||
function Native:draw() end
|
||||
|
||||
local function startNative(game, gen, session)
|
||||
if gen == 3 then
|
||||
local Gen3 = require(Launch.PRESENTERS[3])
|
||||
return Gen3.startNative(game, session, { onDone = once(session.onDone) })
|
||||
end
|
||||
local state, why = Native.new(game, gen, session)
|
||||
if not state then return nil, why end
|
||||
game.stack:push(state)
|
||||
return { kind = "native", gen = gen, state = state }
|
||||
end
|
||||
|
||||
Launch.RESULT_TEXT = {
|
||||
[3] = { win = "You won the battle!", lose = "You lost the battle.", draw = "The battle ended in a draw.",
|
||||
desync = "The battle stopped: the two games disagreed.", disconnect = "The link was lost.",
|
||||
error = "The battle could not continue." },
|
||||
gb = { win = "You won the\nbattle!", lose = "You lost the\nbattle.", draw = "The battle was\na draw.",
|
||||
desync = "The games fell\nout of step.", disconnect = "The link was\nlost.",
|
||||
error = "The battle could\nnot go on." },
|
||||
}
|
||||
|
||||
function Launch.resultText(gen, result)
|
||||
local set = Launch.RESULT_TEXT[gen == 3 and 3 or "gb"]
|
||||
if type(result) == "string" then return set[result] or set.error end
|
||||
if type(result) ~= "table" then return set.error end
|
||||
local why = result.why
|
||||
if why == "desync" or why == "disconnect" then return set[why] end
|
||||
if why == "illegal" or why == "error" or why == "bad_table" or why == "bad_party" then return set.error end
|
||||
return set[result.outcome] or set.error
|
||||
end
|
||||
|
||||
function Launch.start(game, gen, session, act)
|
||||
gen = tonumber(gen)
|
||||
if not Launch.PRESENTERS[gen] then return nil, "bad_gen" end
|
||||
if type(session) ~= "table" or type(session.net) ~= "table" then return nil, "no_session" end
|
||||
if act ~= nil then
|
||||
local inner = session.onDone
|
||||
local wrapped = {}
|
||||
for k, v in pairs(session) do wrapped[k] = v end
|
||||
wrapped.onDone = function(result)
|
||||
if inner then inner(result) end
|
||||
if type(act) == "table" and type(act.finish) == "function" then
|
||||
act:finish("battle_end", Launch.resultText(gen, result))
|
||||
end
|
||||
end
|
||||
session = wrapped
|
||||
end
|
||||
local ruleset = session.ruleset or (session.go and session.go.ruleset)
|
||||
if ruleset == "native" then return startNative(game, gen, session) end
|
||||
if ruleset ~= "g3u" then return nil, "bad_ruleset" end
|
||||
return startG3u(game, gen, session)
|
||||
end
|
||||
|
||||
Launch.Native = Native
|
||||
|
||||
return Launch
|
||||
@@ -419,6 +419,7 @@ function Boot.update(state, input, dt)
|
||||
if pending == "continue" then
|
||||
state.fadeT, state.fadeTarget = 0, 0
|
||||
require("src.core.game3.link.trade").resumePending()
|
||||
require("src.online.union.TradeTxn").resumePending(nil)
|
||||
return { action = "continue" }
|
||||
elseif pending == "new_game" then
|
||||
state.fadeT, state.fadeTarget = 0, 0
|
||||
|
||||
@@ -10,6 +10,11 @@ LinkTags.EDGE = 1
|
||||
LinkTags.BLENDER_EM_BASE = 0x7F00
|
||||
LinkTags.BLENDER_RS_BASE = 0xF0
|
||||
LinkTags.MAX_SEATS = 5
|
||||
LinkTags.BADGE_GAP = 1
|
||||
LinkTags.NEAR_CELLS = 2
|
||||
LinkTags.UNION_PAD = 3
|
||||
LinkTags.UNION_HEAD_GAP = 1
|
||||
LinkTags.RS_TEXT_DY = -6
|
||||
|
||||
LinkTags.PLATE = { 0.06, 0.07, 0.12, 0.62 }
|
||||
LinkTags.PLATE_SELF = { 0.08, 0.2, 0.46, 0.7 }
|
||||
@@ -97,9 +102,14 @@ local function tagRow(i)
|
||||
row = {}
|
||||
LinkTags._rows[i] = row
|
||||
end
|
||||
row.badge, row.union, row.near = nil, nil, nil
|
||||
return row
|
||||
end
|
||||
|
||||
local function badgeModule()
|
||||
return require("src.online.union.Badge")
|
||||
end
|
||||
|
||||
local function localKind()
|
||||
local LT = package.loaded["src.core.game3.link.trade"]
|
||||
if LT and LT.isActive and LT.isActive() then return "trade" end
|
||||
@@ -146,14 +156,22 @@ function LinkTags.sources()
|
||||
src.player = nil
|
||||
local active, n = false, 0
|
||||
local U = union()
|
||||
if U and U.isActive and U.isActive() and U.onUnionRoomMap and U.onUnionRoomMap() then
|
||||
local inUnion = U and U.isActive and U.isActive() and U.onUnionRoomMap and U.onUnionRoomMap()
|
||||
if inUnion then
|
||||
active = true
|
||||
local P = package.loaded["src.core.game3.player"]
|
||||
local px, py = P and tonumber(P.cellX), P and tonumber(P.cellY)
|
||||
for slot = 1, U.capacity() do
|
||||
local p = U.players[slot]
|
||||
if p and not p.gone and type(p.name) == "string" then
|
||||
n = n + 1
|
||||
local row = tagRow(n)
|
||||
row.name, row.kind = p.name, U.memberStatusKind(p)
|
||||
row.badge = tonumber(p.sourceGen) or 3
|
||||
row.union = true
|
||||
local cx, cy = U.avatarCell(slot)
|
||||
row.near = U._talkSlot == slot or (px ~= nil and cx ~= nil
|
||||
and math.abs(cx - px) + math.abs(cy - py) <= LinkTags.NEAR_CELLS)
|
||||
src.byVobj[U.vobjId(slot)] = row
|
||||
end
|
||||
end
|
||||
@@ -180,6 +198,7 @@ function LinkTags.sources()
|
||||
local me = LinkTags._self
|
||||
me.name = type(s) == "table" and tostring(s.name or s.playerName or ""):sub(1, 7) or nil
|
||||
me.kind = localKind()
|
||||
me.badge, me.hidden = nil, inUnion and true or nil
|
||||
if me.name and me.name ~= "" then src.player = me end
|
||||
return src
|
||||
end
|
||||
@@ -215,7 +234,8 @@ function LinkTags.layout(tag, headX, headY, viewW, measure, out)
|
||||
local textW = LinkTags.textWidth(tag.name, measure)
|
||||
local iconW = tag.kind and LinkTags.iconSize(tag.kind) or 0
|
||||
local lead = iconW > 0 and (iconW + LinkTags.ICON_GAP) or 0
|
||||
local w = LinkTags.PAD * 2 + textW + lead
|
||||
local badgeW = tag.badge and (badgeModule().SIZE + LinkTags.BADGE_GAP) or 0
|
||||
local w = LinkTags.PAD * 2 + textW + lead + badgeW
|
||||
local x = math.floor(headX - w / 2 + 0.5)
|
||||
if viewW then
|
||||
x = math.max(LinkTags.EDGE, math.min(viewW - w - LinkTags.EDGE, x))
|
||||
@@ -223,6 +243,7 @@ function LinkTags.layout(tag, headX, headY, viewW, measure, out)
|
||||
out = out or {}
|
||||
out.x, out.y, out.w, out.h = x, math.floor(headY - LinkTags.PLATE_H + LinkTags.HEAD_OVERLAP), w, LinkTags.PLATE_H
|
||||
out.textW, out.iconW, out.textX = textW, iconW, x + LinkTags.PAD + lead
|
||||
out.badgeX = tag.badge and (out.textX + textW + LinkTags.BADGE_GAP) or nil
|
||||
return out
|
||||
end
|
||||
|
||||
@@ -281,7 +302,16 @@ local function opaqueTop(spr)
|
||||
return top
|
||||
end
|
||||
|
||||
local function foreignHeight(eo)
|
||||
local Avatars = require("src.online.union.Avatars")
|
||||
local entry = Avatars.resolve(eo.foreign, eo.foreign.host)
|
||||
if entry.standin then return Avatars.STANDIN_H end
|
||||
return tonumber(entry.h) or Avatars.GB_FRAME
|
||||
end
|
||||
|
||||
local function headTop(a, camY)
|
||||
local eo = a.eventObject
|
||||
if eo and eo.foreign then return a.y - camY + 16 - foreignHeight(eo) end
|
||||
local h, offY, top = 32, 0, 0
|
||||
local Ow = package.loaded["src.core.game3.ow_sprites"]
|
||||
if Ow and Ow.getDraw and a.graphicsId ~= nil then
|
||||
@@ -295,7 +325,60 @@ local function headTop(a, camY)
|
||||
return a.y - camY + 16 - h + offY + top
|
||||
end
|
||||
|
||||
local function textDy()
|
||||
local Family = require("src.core.game3.link.family")
|
||||
return Family.isRubySapphire(Family.activeVersion()) and LinkTags.RS_TEXT_DY or LinkTags.TEXT_DY
|
||||
end
|
||||
|
||||
function LinkTags.unionLayout(tag, headX, headY, viewW, measure, out)
|
||||
local Badge = badgeModule()
|
||||
out = out or {}
|
||||
local bs = Badge.SIZE
|
||||
if not tag.near then
|
||||
out.x, out.y, out.w, out.h = math.floor(headX - bs / 2), math.floor(headY - bs - LinkTags.UNION_HEAD_GAP), bs, bs
|
||||
out.textW, out.badgeX, out.badgeY, out.plate = 0, out.x, out.y, false
|
||||
return out
|
||||
end
|
||||
local textW = LinkTags.textWidth(tag.name, measure)
|
||||
local pad = LinkTags.UNION_PAD
|
||||
local w = pad + textW + LinkTags.BADGE_GAP + 1 + bs + 1
|
||||
local h = LinkTags.PLATE_H
|
||||
local x = math.floor(headX - w / 2)
|
||||
if viewW then x = math.max(LinkTags.EDGE, math.min(viewW - w - LinkTags.EDGE, x)) end
|
||||
local y = math.floor(headY - h - LinkTags.UNION_HEAD_GAP)
|
||||
out.x, out.y, out.w, out.h, out.textW, out.plate = x, y, w, h, textW, true
|
||||
out.textX = x + pad
|
||||
out.badgeX = x + w - 1 - bs
|
||||
out.badgeY = y + math.floor((h - bs) / 2)
|
||||
return out
|
||||
end
|
||||
|
||||
local function windowPlate(x, y, w, h)
|
||||
local FrlgFont = require("src.ui.game3.frlg_font")
|
||||
local Chrome = require("src.ui.game3.chrome")
|
||||
local fill = Chrome.windowFillColor()
|
||||
local edge = FrlgFont.COLOR.NORMAL.fg
|
||||
local g = love.graphics
|
||||
g.setColor(edge[1], edge[2], edge[3], 1)
|
||||
g.rectangle("fill", x + 1, y, w - 2, h)
|
||||
g.rectangle("fill", x, y + 1, w, h - 2)
|
||||
g.setColor(fill[1], fill[2], fill[3], 1)
|
||||
g.rectangle("fill", x + 1, y + 1, w - 2, h - 2)
|
||||
end
|
||||
|
||||
local function drawUnionTag(a, tag, camX, camY, viewW)
|
||||
local L = LinkTags.unionLayout(tag, math.floor(a.x - camX) + 8, math.floor(headTop(a, camY)), viewW, nil, LAYOUT)
|
||||
if L.plate then
|
||||
windowPlate(L.x, L.y, L.w, L.h)
|
||||
local FrlgFont = require("src.ui.game3.frlg_font")
|
||||
TEXT_OPTS.colors, TEXT_OPTS.maxWidth = FrlgFont.COLOR.NORMAL, L.textW
|
||||
pcall(FrlgFont.draw, tag.name, L.textX, L.y + textDy(), TEXT_OPTS)
|
||||
end
|
||||
badgeModule().draw(L.badgeX, L.badgeY, tag.badge, tag.badge, 1)
|
||||
end
|
||||
|
||||
local function drawTag(a, tag, camX, camY, viewW, isSelf)
|
||||
if tag.union then return drawUnionTag(a, tag, camX, camY, viewW) end
|
||||
local L = LinkTags.layout(tag, a.x - camX + 8, headTop(a, camY), viewW, nil, LAYOUT)
|
||||
plate(L.x, L.y, L.w, L.h, isSelf and LinkTags.PLATE_SELF or LinkTags.PLATE)
|
||||
if tag.kind then
|
||||
@@ -308,14 +391,18 @@ local function drawTag(a, tag, camX, camY, viewW, isSelf)
|
||||
end
|
||||
local FrlgFont = require("src.ui.game3.frlg_font")
|
||||
TEXT_OPTS.colors, TEXT_OPTS.maxWidth = FrlgFont.COLOR.WHITE, L.textW
|
||||
pcall(FrlgFont.draw, tag.name, L.textX, L.y + LinkTags.TEXT_DY, TEXT_OPTS)
|
||||
pcall(FrlgFont.draw, tag.name, L.textX, L.y + textDy(), TEXT_OPTS)
|
||||
if L.badgeX then
|
||||
local Badge = badgeModule()
|
||||
Badge.draw(L.badgeX, L.y + math.floor((L.h - Badge.SIZE) / 2), tag.badge, tag.badge, 1)
|
||||
end
|
||||
end
|
||||
|
||||
local function drawList(src, list, camX, camY, viewW, billboard)
|
||||
if not list then return end
|
||||
for _, a in ipairs(list) do
|
||||
local tag = LinkTags.tagFor(src, a)
|
||||
if tag and tag.name and tag.name ~= "" then
|
||||
if tag and tag.name and tag.name ~= "" and not tag.hidden then
|
||||
local pushed = billboard and billboard(a.x, a.y, camX, camY)
|
||||
drawTag(a, tag, camX, camY, viewW, a.kind == "player")
|
||||
if pushed then love.graphics.pop() end
|
||||
|
||||
@@ -9,6 +9,13 @@ function M.show(session)
|
||||
local Space = package.loaded["src.core.game3.scripting.space"]
|
||||
M.owner = Space and Space.vm
|
||||
end
|
||||
function M.isVisible()
|
||||
if M.visible then
|
||||
local Space = package.loaded["src.core.game3.scripting.space"]
|
||||
if M.owner ~= (Space and Space.vm) then M.reset() end
|
||||
end
|
||||
return M.visible
|
||||
end
|
||||
function M.eraseBox(l, t, r, b)
|
||||
if l <= 1 and t <= 0 and r >= 28 and b >= 18 then M.reset() end
|
||||
end
|
||||
|
||||
@@ -414,6 +414,7 @@ UnionRoomScreen.LABELS = RomText.lazy({
|
||||
})
|
||||
|
||||
function UnionRoomScreen.labelFor(item)
|
||||
if type(item) == "table" and type(item.label) == "string" then return item.label end
|
||||
return UnionRoomScreen.LABELS[item and item.key] or ""
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
local Dialog = require("src.ui.gen2.union.Dialog")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Text = require("src.ui.gen2.union.Text")
|
||||
|
||||
local Activity = {}
|
||||
Activity.__index = Activity
|
||||
|
||||
Activity.screens = { battle = nil, trade = nil }
|
||||
|
||||
local function peerOf(room)
|
||||
local xr = room:xgRoom()
|
||||
local seat = room.client.seat and room.client.seat()
|
||||
for _, row in ipairs(xr and xr.players or {}) do
|
||||
if row.seat ~= seat then
|
||||
local av = type(row.avatar) == "table" and row.avatar or {}
|
||||
return { id = row.id, name = av.name or row.name or "?", gen = row.gen, game = av.version, seat = row.seat }
|
||||
end
|
||||
end
|
||||
return { name = "?" }
|
||||
end
|
||||
|
||||
local function flow()
|
||||
return require("src.ui.union.Flow")
|
||||
end
|
||||
|
||||
function Activity.begin(game, room, mode, opts)
|
||||
opts = opts or {}
|
||||
if not Activity.installed then
|
||||
Activity.installed = true
|
||||
flow().install(Activity)
|
||||
end
|
||||
local self = setmetatable({
|
||||
game = game, room = room, mode = mode == "trade" and "trade" or "battle",
|
||||
session = opts.session, prep = room:prep(), peer = peerOf(room),
|
||||
state = "preparing", done = false, why = nil, events = {},
|
||||
}, Activity)
|
||||
if self.session then self.session:uiOpen("activity") end
|
||||
self:show()
|
||||
return self
|
||||
end
|
||||
|
||||
function Activity:readyText()
|
||||
return Text.say(self.mode == "trade" and "readyTrade" or "readyBattle", self.peer.name)
|
||||
end
|
||||
|
||||
function Activity:show()
|
||||
self.waiter = Dialog.hold(self.game, self:readyText(), function(_, input)
|
||||
self:tick(input)
|
||||
end)
|
||||
end
|
||||
|
||||
function Activity:closeUi()
|
||||
if self.waiter then self.waiter:close() end
|
||||
self.waiter = nil
|
||||
end
|
||||
|
||||
function Activity:finish(why, text)
|
||||
if self.done then return end
|
||||
if why == "go" and not self.launched then
|
||||
self.state = "battle"
|
||||
self:closeUi()
|
||||
if flow().launch(self, 2) then return end
|
||||
why, text = "error", require("src.ui.g3u.Launch").resultText(2, "error")
|
||||
end
|
||||
self.why = why
|
||||
self.state = "done"
|
||||
self:closeUi()
|
||||
flow().leaveRoom(self)
|
||||
local session = self.session
|
||||
local function after()
|
||||
self.done = true
|
||||
if session then session:uiDone() end
|
||||
end
|
||||
if text then
|
||||
Dialog.say(self.game, text, after)
|
||||
else
|
||||
after()
|
||||
end
|
||||
end
|
||||
|
||||
function Activity:cancel(why)
|
||||
local prep = self.prep
|
||||
if prep then
|
||||
prep:cancel(why or "cancel")
|
||||
prep:leave()
|
||||
end
|
||||
Dialog.sfx(self.game, "Sfx_ReadText2")
|
||||
self:finish(why or "cancel", Text.cancelled(self.game))
|
||||
end
|
||||
|
||||
function Activity:abort(why)
|
||||
if self.done then return end
|
||||
local prep = self.prep
|
||||
if prep and prep:open() then
|
||||
prep:cancel(why or "left")
|
||||
prep:leave()
|
||||
end
|
||||
self:closeUi()
|
||||
self.state = "done"
|
||||
self.why = why or "left"
|
||||
self.done = true
|
||||
end
|
||||
|
||||
function Activity:handle(e)
|
||||
self.events[#self.events + 1] = e.kind
|
||||
if e.kind == "closed" then
|
||||
local mine = e.seat ~= nil and e.seat == self.prep:seat()
|
||||
if mine then
|
||||
self:finish("closed", Text.cancelled(self.game))
|
||||
else
|
||||
Dialog.sfx(self.game, "Sfx_Wrong")
|
||||
self:finish("peer", Text.say("peerCancel", self.peer.name))
|
||||
end
|
||||
return true
|
||||
end
|
||||
if e.kind == "blocked" then
|
||||
self.prep:cancel("blocked")
|
||||
self.prep:leave()
|
||||
self:finish("blocked", Strings(Text.S.mismatch))
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function Activity:tick(input)
|
||||
if self.done or self.state == "done" then return end
|
||||
local prep = self.prep
|
||||
if not prep then return self:finish("gone", Text.cancelled(self.game)) end
|
||||
for _, e in ipairs(prep:poll()) do
|
||||
if self:handle(e) then return end
|
||||
end
|
||||
if prep.state == "prep" and self.state == "preparing" then
|
||||
self.state = "ready"
|
||||
local screen = Activity.screens[self.mode]
|
||||
if screen then
|
||||
self:closeUi()
|
||||
screen(self)
|
||||
return
|
||||
end
|
||||
end
|
||||
if Dialog.pressed(input, "b") then self:cancel("cancel") end
|
||||
end
|
||||
|
||||
return Activity
|
||||
@@ -0,0 +1,110 @@
|
||||
local ScriptMenu = require("src.ui.gen2.ScriptMenu")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Strings = require("src.core.Strings")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
local Dialog = {}
|
||||
|
||||
-- constants/menu_constants.asm:21
|
||||
local STATICMENU_CURSOR = 0x80
|
||||
|
||||
Dialog.MENU_LEFT, Dialog.MENU_RIGHT = 11, 19
|
||||
Dialog.MENU_BOTTOM = 11
|
||||
|
||||
local function popIf(stack, state)
|
||||
if state and stack:top() == state then
|
||||
stack:pop()
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function Dialog.sfx(game, name)
|
||||
local data = game and game.data
|
||||
local sfx = data and data.audio and data.audio.sfx
|
||||
if sfx and sfx[Sound.resolve(data, name)] then Sound.play(data, name) end
|
||||
end
|
||||
|
||||
function Dialog.say(game, text, onDone)
|
||||
local box = TextBox.new(game, text, onDone)
|
||||
game.stack:push(box)
|
||||
return box
|
||||
end
|
||||
|
||||
function Dialog.ask(game, text, onAnswer, opts)
|
||||
opts = opts or {}
|
||||
local box = TextBox.new(game, text, nil, {
|
||||
choice = function(yes) onAnswer(yes and true or false) end,
|
||||
defaultNo = opts.defaultNo,
|
||||
})
|
||||
game.stack:push(box)
|
||||
return box
|
||||
end
|
||||
|
||||
function Dialog.menuHeader(labels)
|
||||
local items = {}
|
||||
for i, label in ipairs(labels) do items[i] = Strings(label) end
|
||||
local top = Dialog.MENU_BOTTOM - (#items * 2 + 1)
|
||||
return {
|
||||
left = Dialog.MENU_LEFT, right = Dialog.MENU_RIGHT,
|
||||
top = top, bottom = Dialog.MENU_BOTTOM,
|
||||
items = items, dataFlags = STATICMENU_CURSOR, cursor = 1,
|
||||
}
|
||||
end
|
||||
|
||||
function Dialog.menu(game, text, labels, onChoose)
|
||||
local stack = game.stack
|
||||
local box, menu
|
||||
box = TextBox.new(game, text, nil, { stay = { onShown = function()
|
||||
menu = ScriptMenu.new(game, {
|
||||
header = Dialog.menuHeader(labels), kind = "union_talk",
|
||||
onChoose = function(index)
|
||||
popIf(stack, menu)
|
||||
popIf(stack, box)
|
||||
onChoose(index)
|
||||
end,
|
||||
})
|
||||
stack:push(menu)
|
||||
end } })
|
||||
stack:push(box)
|
||||
return box
|
||||
end
|
||||
|
||||
local Waiter = {}
|
||||
Waiter.__index = Waiter
|
||||
Waiter.isOpaque = false
|
||||
|
||||
function Waiter:update()
|
||||
if self.closed then return end
|
||||
self.tick(self, self.game and self.game.input)
|
||||
end
|
||||
|
||||
function Waiter:draw() end
|
||||
|
||||
function Waiter:close()
|
||||
if self.closed then return end
|
||||
self.closed = true
|
||||
local stack = self.game.stack
|
||||
popIf(stack, self)
|
||||
popIf(stack, self.box)
|
||||
end
|
||||
|
||||
function Waiter:shown()
|
||||
return self.game.stack:top() == self
|
||||
end
|
||||
|
||||
function Dialog.hold(game, text, tick)
|
||||
local stack = game.stack
|
||||
local waiter = setmetatable({ game = game, tick = tick, closed = false }, Waiter)
|
||||
waiter.box = TextBox.new(game, text, nil, { stay = { onShown = function()
|
||||
if not waiter.closed then stack:push(waiter) end
|
||||
end } })
|
||||
stack:push(waiter.box)
|
||||
return waiter
|
||||
end
|
||||
|
||||
function Dialog.pressed(input, key)
|
||||
return input and input.wasPressed and input:wasPressed(key) and true or false
|
||||
end
|
||||
|
||||
return Dialog
|
||||
@@ -0,0 +1,57 @@
|
||||
local Badge = require("src.online.union.Badge")
|
||||
local Font = require("src.render.Font")
|
||||
|
||||
local Tag = {}
|
||||
|
||||
Tag.HOST_GEN = 2
|
||||
Tag.PAD = 2
|
||||
Tag.GLYPH_H = 8
|
||||
Tag.LIFT = 2
|
||||
Tag.FILL = { 1, 1, 1 }
|
||||
Tag.EDGE = { 0, 0, 0 }
|
||||
|
||||
local widths = setmetatable({}, { __mode = "k" })
|
||||
|
||||
function Tag.nameWidth(name)
|
||||
local hit = widths[name]
|
||||
if hit then return hit end
|
||||
local w = Font.width and Font.width(name) or #name * 8
|
||||
widths[name] = w
|
||||
return w
|
||||
end
|
||||
|
||||
function Tag.size(name, withName)
|
||||
local bw, bh = Badge.size(1)
|
||||
if not withName then return bw, bh end
|
||||
local w = Tag.nameWidth(name) + Tag.PAD * 3 + bw
|
||||
local h = math.max(Tag.GLYPH_H, bh) + Tag.PAD * 2
|
||||
return w, h
|
||||
end
|
||||
|
||||
function Tag.draw(x, y, name, digit, withName, scale)
|
||||
local G = love.graphics
|
||||
local s = math.max(1, math.floor(tonumber(scale) or 1))
|
||||
local w, h = Tag.size(name, withName)
|
||||
local left = -math.floor(w / 2)
|
||||
local top = -Tag.LIFT - h
|
||||
G.push()
|
||||
G.translate(math.floor(x), math.floor(y))
|
||||
G.scale(s, s)
|
||||
if withName then
|
||||
G.setColor(Tag.EDGE[1], Tag.EDGE[2], Tag.EDGE[3], 1)
|
||||
G.rectangle("fill", left, top, w, h)
|
||||
G.setColor(Tag.FILL[1], Tag.FILL[2], Tag.FILL[3], 1)
|
||||
G.rectangle("fill", left + 1, top + 1, w - 2, h - 2)
|
||||
G.setColor(0, 0, 0, 1)
|
||||
Font.draw(name, left + Tag.PAD, top + Tag.PAD)
|
||||
Badge.drawBeside(left + Tag.PAD + Tag.nameWidth(name), top + Tag.PAD - 1,
|
||||
Tag.GLYPH_H + 2, digit, Tag.HOST_GEN, 1, Tag.PAD)
|
||||
else
|
||||
Badge.draw(left, top, digit, Tag.HOST_GEN, 1)
|
||||
end
|
||||
G.pop()
|
||||
G.setColor(1, 1, 1, 1)
|
||||
return math.floor(x) + left * s, math.floor(y) + top * s, w * s, h * s
|
||||
end
|
||||
|
||||
return Tag
|
||||
@@ -0,0 +1,149 @@
|
||||
local Dialog = require("src.ui.gen2.union.Dialog")
|
||||
local Participant = require("src.online.union.Participant")
|
||||
local Strings = require("src.core.Strings")
|
||||
local Text = require("src.ui.gen2.union.Text")
|
||||
|
||||
local Talk = {}
|
||||
|
||||
Talk.ACTIVITIES = { "xg_battle", "xg_trade" }
|
||||
Talk.LINK_SECONDS = 15
|
||||
|
||||
local function now()
|
||||
local t = love and love.timer and love.timer.getTime
|
||||
return t and t() or os.clock()
|
||||
end
|
||||
|
||||
local function finish(session)
|
||||
session:uiDone()
|
||||
end
|
||||
|
||||
local function sayThen(session, text, after)
|
||||
Dialog.say(session.game, text, function()
|
||||
if after then after() else finish(session) end
|
||||
end)
|
||||
end
|
||||
|
||||
function Talk.wait(session, p, handle)
|
||||
local game = session.game
|
||||
Dialog.hold(game, Text.say("waiting", p.name), function(w, input)
|
||||
if handle.state == "accepted" or handle.why == "crossed" then
|
||||
w:close()
|
||||
finish(session)
|
||||
return
|
||||
end
|
||||
if handle.state == "closed" then
|
||||
w:close()
|
||||
Dialog.sfx(game, "Sfx_Wrong")
|
||||
sayThen(session, Text.closed(handle.why, p.name))
|
||||
return
|
||||
end
|
||||
if Dialog.pressed(input, "b") then
|
||||
session.abandoned[handle] = true
|
||||
w:close()
|
||||
Dialog.sfx(game, "Sfx_ReadText2")
|
||||
sayThen(session, Text.cancelled(game))
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
function Talk.invite(session, p, activity)
|
||||
local handle, why = session.room:invite(p, activity)
|
||||
if not handle then
|
||||
sayThen(session, Text.closed(why, p.name))
|
||||
return
|
||||
end
|
||||
if handle.state == "closed" then
|
||||
sayThen(session, Text.closed(handle.why, p.name))
|
||||
return
|
||||
end
|
||||
Talk.wait(session, p, handle)
|
||||
end
|
||||
|
||||
function Talk.menu(session, p)
|
||||
local labels = { Text.MENU.battle, Text.MENU.trade, Text.MENU.cancel }
|
||||
Dialog.menu(session.game, Text.say("what", p.name), labels, function(index)
|
||||
local activity = Talk.ACTIVITIES[index]
|
||||
if not activity then return finish(session) end
|
||||
local live = session.room:member(p.id) or p
|
||||
if Participant.busy(live) then
|
||||
return sayThen(session, Text.say("busy", p.name))
|
||||
end
|
||||
Talk.invite(session, live, activity)
|
||||
end)
|
||||
end
|
||||
|
||||
function Talk.open(session, e)
|
||||
local p = e.participant
|
||||
session:uiOpen("talk")
|
||||
Dialog.sfx(session.game, "Sfx_ReadText2")
|
||||
local function body()
|
||||
if Participant.busy(session.room:member(p.id) or p) then
|
||||
return sayThen(session, Text.say("busy", p.name))
|
||||
end
|
||||
Talk.menu(session, p)
|
||||
end
|
||||
local entry = e.avatar
|
||||
if entry and (entry.standin or entry.hostStandin) then
|
||||
local game = Text.needName(entry.need)
|
||||
return sayThen(session, Text.say("standin", p.name, Text.gameName(p.game), game), body)
|
||||
end
|
||||
body()
|
||||
end
|
||||
|
||||
function Talk.linking(session, name, inv)
|
||||
local game = session.game
|
||||
local started = now()
|
||||
Dialog.hold(game, Text.say("linking", name), function(w)
|
||||
if session.room:xgRoom() then
|
||||
w:close()
|
||||
finish(session)
|
||||
return
|
||||
end
|
||||
if now() - started > Talk.LINK_SECONDS or session.state ~= "joined" then
|
||||
w:close()
|
||||
sayThen(session, Text.cancelled(game))
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
function Talk.prompt(session, inv)
|
||||
local from = inv.from or {}
|
||||
local name = from.name or "?"
|
||||
session:uiOpen("prompt")
|
||||
local e = from.slot and session:entity(from.slot)
|
||||
if e and e.participant.id == inv.fromId then e:facePlayer(session.world and session.world.player) end
|
||||
Dialog.sfx(session.game, "Sfx_Call")
|
||||
local key = inv.mode == "trade" and "askTrade" or "askBattle"
|
||||
Dialog.ask(session.game, Text.say(key, name), function(yes)
|
||||
local sent = session.room:reply(inv.id, yes)
|
||||
if not yes then return finish(session) end
|
||||
if not sent then return sayThen(session, Text.say("gone", name)) end
|
||||
Talk.linking(session, name, inv)
|
||||
end)
|
||||
end
|
||||
|
||||
function Talk.notice(session, n)
|
||||
local game = session.game
|
||||
session:uiOpen("notice")
|
||||
if n.kind == "connecting" then
|
||||
Dialog.hold(game, Strings(Text.S.connecting), function(w, input)
|
||||
if session.state ~= "connecting" or Dialog.pressed(input, "b") then
|
||||
w:close()
|
||||
finish(session)
|
||||
end
|
||||
end)
|
||||
elseif n.kind == "lost" then
|
||||
Dialog.hold(game, Strings(Text.S.lost), function(w, input)
|
||||
if not session.lost or Dialog.pressed(input, "b") then
|
||||
w:close()
|
||||
finish(session)
|
||||
end
|
||||
end)
|
||||
elseif n.kind == "error" then
|
||||
sayThen(session, Text.error(n.code))
|
||||
else
|
||||
sayThen(session, n.text or "")
|
||||
end
|
||||
end
|
||||
|
||||
return Talk
|
||||
@@ -0,0 +1,90 @@
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local Text = {}
|
||||
|
||||
Text.MENU = {
|
||||
battle = Strings.source("BATTLE"),
|
||||
trade = Strings.source("TRADE"),
|
||||
cancel = Strings.source("CANCEL"),
|
||||
}
|
||||
|
||||
local S = {
|
||||
what = Strings.source("With %s,\nwhat'll you do?"),
|
||||
busy = Strings.source("%s is\nbusy right now."),
|
||||
waiting = Strings.source("Waiting for\n%s…"),
|
||||
declined = Strings.source("%s said no."),
|
||||
timeout = Strings.source("%s didn't\nanswer."),
|
||||
gone = Strings.source("%s left the\nUNION ROOM."),
|
||||
refused = Strings.source("%s can't do\nthat right now."),
|
||||
askBattle = Strings.source("%s wants\nto battle. OK?"),
|
||||
askTrade = Strings.source("%s wants\nto trade. OK?"),
|
||||
linking = Strings.source("Linking up with\n%s…"),
|
||||
readyBattle = Strings.source("Getting ready to\nbattle %s…"),
|
||||
readyTrade = Strings.source("Getting ready to\ntrade with %s…"),
|
||||
peerCancel = Strings.source("%s cancelled."),
|
||||
standin = Strings.source("%s is playing\n%s.\fImport %s\nto see them here."),
|
||||
connecting = Strings.source("Connecting to the\nUNION ROOM…"),
|
||||
offline = Strings.source("Couldn't connect.\fNo other trainers\ncan be seen now."),
|
||||
serverOld = Strings.source("The UNION ROOM\nserver is too old.\fNo other trainers\ncan be seen now."),
|
||||
clientOld = Strings.source("This game is too\nold to link up.\fUpdate it to meet\nother trainers."),
|
||||
joinFailed = Strings.source("Couldn't enter\nthe UNION ROOM.\fNo other trainers\ncan be seen now."),
|
||||
lost = Strings.source("The link was lost.\nReconnecting…"),
|
||||
lostForGood = Strings.source("Couldn't link up\nagain.\fNo other trainers\ncan be seen now."),
|
||||
mismatch = Strings.source("Your games can't\nlink up for this."),
|
||||
}
|
||||
Text.S = S
|
||||
|
||||
local function clean(name)
|
||||
name = tostring(name or "")
|
||||
name = name:gsub("[{}%c]", "")
|
||||
if name == "" then name = "?" end
|
||||
return name
|
||||
end
|
||||
|
||||
Text.clean = clean
|
||||
|
||||
function Text.say(key, ...)
|
||||
local args = { ... }
|
||||
for i = 1, select("#", ...) do args[i] = clean(args[i]) end
|
||||
return Strings(S[key], unpack(args, 1, select("#", ...)))
|
||||
end
|
||||
|
||||
function Text.gameName(version)
|
||||
local info = GameVersion.VERSIONS[version or ""]
|
||||
local label = info and info.label or tostring(version or "?")
|
||||
return "POKéMON " .. label:upper()
|
||||
end
|
||||
|
||||
function Text.needName(need)
|
||||
if type(need) ~= "table" or not need[1] then return "?" end
|
||||
return Text.gameName(need[1])
|
||||
end
|
||||
|
||||
Text.CLOSED = {
|
||||
declined = "declined", timeout = "timeout", busy = "busy",
|
||||
offline = "gone", target_left = "gone", sender_left = "gone",
|
||||
}
|
||||
|
||||
function Text.closed(why, name)
|
||||
return Text.say(Text.CLOSED[why or ""] or "refused", name)
|
||||
end
|
||||
|
||||
Text.ERRORS = {
|
||||
server_outdated = "serverOld", client_outdated = "clientOld",
|
||||
offline = "offline", lost = "lostForGood",
|
||||
}
|
||||
|
||||
function Text.error(code)
|
||||
return Strings(S[Text.ERRORS[code or ""] or "joinFailed"])
|
||||
end
|
||||
|
||||
-- data/text/common_2.asm:216
|
||||
function Text.cancelled(game)
|
||||
local text = game and game.data and game.data.text
|
||||
local line = text and text._MysteryGiftCanceledText
|
||||
assert(type(line) == "string", "gen2 cache has no _MysteryGiftCanceledText")
|
||||
return line
|
||||
end
|
||||
|
||||
return Text
|
||||
@@ -0,0 +1,86 @@
|
||||
local Flow = {}
|
||||
|
||||
Flow.seams = { client = nil, launch = nil }
|
||||
|
||||
local function client()
|
||||
return Flow.seams.client or require("src.online.Client")
|
||||
end
|
||||
|
||||
function Flow.install(Activity)
|
||||
require("src.ui.union.prep.Open").register(Activity)
|
||||
require("src.ui.union.prep.OpenTrade").register(Activity)
|
||||
return Activity
|
||||
end
|
||||
|
||||
function Flow.gens(list)
|
||||
if type(list) ~= "table" then return nil end
|
||||
local a, b = tonumber(list[1]), tonumber(list[2])
|
||||
if not (a and b) then return nil end
|
||||
return { [0] = a, [1] = b }
|
||||
end
|
||||
|
||||
function Flow.myName(game, gen)
|
||||
if gen == 3 then
|
||||
local s = game and game.session
|
||||
if not s then
|
||||
local ok, Runtime = pcall(require, "src.core.game3.runtime")
|
||||
s = ok and Runtime.getSession and Runtime.getSession() or nil
|
||||
end
|
||||
return s and s.name or nil
|
||||
end
|
||||
local player = game and game.save and game.save.player
|
||||
return player and player.name or nil
|
||||
end
|
||||
|
||||
function Flow.session(act, gen)
|
||||
local bp = act.battlePrep
|
||||
if type(bp) ~= "table" then return nil, "no_prep" end
|
||||
local C = client()
|
||||
local room = C.room and C.room() or nil
|
||||
if type(room) ~= "table" or (act.roomId ~= nil and room.room ~= act.roomId) then return nil, "no_room" end
|
||||
local seat = tonumber(C.seat and C.seat())
|
||||
if seat ~= 0 and seat ~= 1 then return nil, "no_seat" end
|
||||
local net = C.roomSession and C.roomSession() or nil
|
||||
if not net then return nil, "no_session" end
|
||||
local rules = bp.ruleset or {}
|
||||
local go = (act.prep and act.prep.go) or bp.go or {}
|
||||
go = {
|
||||
rev = go.rev or bp.rev, seed = go.seed or bp.seed, match = go.match or bp.match,
|
||||
ruleset = go.ruleset or rules.id, size = go.size or bp.size, gen = go.gen or rules.gen,
|
||||
dexMax = go.dexMax or rules.dexMax, moveMax = go.moveMax or rules.moveMax, moveGen = go.moveGen or rules.moveGen,
|
||||
}
|
||||
local gens = Flow.gens(room.xg and room.xg.gens) or Flow.gens(rules.gens)
|
||||
if not gens then
|
||||
gens = { [0] = gen, [1] = gen }
|
||||
end
|
||||
local peer = act.peer or {}
|
||||
local names = { [seat] = Flow.myName(act.game, gen), [1 - seat] = peer.name }
|
||||
return {
|
||||
ruleset = go.ruleset, net = net, seat = seat, go = go, gens = gens, names = names,
|
||||
records = bp.records, team = bp.team, client = C, roomId = room.room,
|
||||
}
|
||||
end
|
||||
|
||||
function Flow.launch(act, gen)
|
||||
if act.launched then return true end
|
||||
local session, why = Flow.session(act, gen)
|
||||
local handle
|
||||
if session then
|
||||
local Launch = Flow.seams.launch or require("src.ui.g3u.Launch")
|
||||
handle, why = Launch.start(act.game, gen, session, act)
|
||||
end
|
||||
if not handle then
|
||||
print("[union] battle launch failed: " .. tostring(why))
|
||||
return false, why
|
||||
end
|
||||
act.launched = true
|
||||
act.battle = handle
|
||||
return true
|
||||
end
|
||||
|
||||
function Flow.leaveRoom(act)
|
||||
local prep = act and act.prep
|
||||
if prep and prep.leave then prep:leave() end
|
||||
end
|
||||
|
||||
return Flow
|
||||
@@ -0,0 +1,171 @@
|
||||
local Dialog = require("src.ui.union.gen1.Dialog")
|
||||
local Text = require("src.ui.union.gen1.Text")
|
||||
|
||||
local Activity = {}
|
||||
Activity.__index = Activity
|
||||
|
||||
Activity.LINK_SECONDS = 10
|
||||
Activity.screens = { battle = nil, trade = nil }
|
||||
|
||||
local MODES = { battle = true, trade = true }
|
||||
|
||||
local function peerOf(room, fallback)
|
||||
local xr = room:xgRoom()
|
||||
local seat = room.client and room.client.seat and room.client.seat()
|
||||
for _, row in ipairs(xr and xr.players or {}) do
|
||||
if row.seat ~= seat then
|
||||
local av = type(row.avatar) == "table" and row.avatar or {}
|
||||
return { id = row.id, name = av.name or row.name or "?", gen = row.gen, game = av.version, seat = row.seat }
|
||||
end
|
||||
end
|
||||
if fallback then
|
||||
return { id = fallback.id, name = fallback.name, gen = fallback.gen, game = fallback.game }
|
||||
end
|
||||
return { name = "?" }
|
||||
end
|
||||
|
||||
local function flow()
|
||||
return require("src.ui.union.Flow")
|
||||
end
|
||||
|
||||
function Activity.begin(game, room, mode, opts)
|
||||
opts = opts or {}
|
||||
if not Activity.installed then
|
||||
Activity.installed = true
|
||||
flow().install(Activity)
|
||||
end
|
||||
assert(MODES[mode], "union activity mode must be battle or trade")
|
||||
local self = setmetatable({
|
||||
game = game, room = room, mode = mode, opts = opts, session = opts.session,
|
||||
prep = nil, peer = peerOf(room, opts.peer), state = "preparing", done = false,
|
||||
why = nil, events = {}, started = love.timer.getTime(), linked = false,
|
||||
}, Activity)
|
||||
if self.session then self.session.activity = self end
|
||||
self:show(Text.pleaseWait(game))
|
||||
return self
|
||||
end
|
||||
|
||||
function Activity:readyText()
|
||||
return Text.say(self.mode == "trade" and "readyTrade" or "readyBattle", self.peer.name)
|
||||
end
|
||||
|
||||
function Activity:show(text)
|
||||
self:closeUi()
|
||||
self.waiter = Dialog.hold(self.game, text, function(_, input)
|
||||
self:tick(input)
|
||||
end)
|
||||
end
|
||||
|
||||
function Activity:closeUi()
|
||||
if self.waiter then self.waiter:close() end
|
||||
self.waiter = nil
|
||||
end
|
||||
|
||||
function Activity:finish(why, text)
|
||||
if self.done or self.state == "done" then return end
|
||||
if why == "go" and not self.launched then
|
||||
self.state = "battle"
|
||||
self:closeUi()
|
||||
if flow().launch(self, 1) then return end
|
||||
why, text = "error", require("src.ui.g3u.Launch").resultText(1, "error")
|
||||
end
|
||||
self.why = why
|
||||
self.state = "done"
|
||||
self:closeUi()
|
||||
flow().leaveRoom(self)
|
||||
self.room:dropPrep()
|
||||
local session = self.session
|
||||
local onDone = self.opts.onDone
|
||||
local function after()
|
||||
self.done = true
|
||||
if session and session.activity == self then session.activity = nil end
|
||||
if onDone then onDone() end
|
||||
end
|
||||
if text then
|
||||
Dialog.say(self.game, text, after)
|
||||
else
|
||||
after()
|
||||
end
|
||||
end
|
||||
|
||||
function Activity:cancel(why)
|
||||
local prep = self.prep
|
||||
if prep and prep:open() then
|
||||
prep:cancel(why or "cancel")
|
||||
prep:leave()
|
||||
end
|
||||
Dialog.sfx(self.game, "Press_AB")
|
||||
self:finish(why or "cancel", Text.say("cancelled"))
|
||||
end
|
||||
|
||||
function Activity:abort(why)
|
||||
if self.done then return end
|
||||
local prep = self.prep
|
||||
if prep and prep:open() then
|
||||
prep:cancel(why or "left")
|
||||
prep:leave()
|
||||
end
|
||||
self:closeUi()
|
||||
self.room:dropPrep()
|
||||
self.state = "done"
|
||||
self.why = why or "left"
|
||||
self.done = true
|
||||
if self.session and self.session.activity == self then self.session.activity = nil end
|
||||
end
|
||||
|
||||
function Activity:handle(e)
|
||||
self.events[#self.events + 1] = e.kind
|
||||
if e.kind == "closed" then
|
||||
local mine = e.seat ~= nil and e.seat == self.prep:seat()
|
||||
if mine then
|
||||
self:finish("closed", Text.say("cancelled"))
|
||||
else
|
||||
Dialog.sfx(self.game, "Denied")
|
||||
self:finish("peer", Text.say("peerCancel", self.peer.name))
|
||||
end
|
||||
return true
|
||||
end
|
||||
if e.kind == "blocked" then
|
||||
self.prep:cancel("blocked")
|
||||
self.prep:leave()
|
||||
Dialog.sfx(self.game, "Denied")
|
||||
self:finish("blocked", Text.blocked(e.why))
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function Activity:tick(input)
|
||||
if self.done or self.state == "done" then return end
|
||||
if Dialog.pressed(input, "b") then return self:cancel("cancel") end
|
||||
if not self.prep then
|
||||
self.prep = self.room:prep()
|
||||
if not self.prep then
|
||||
if love.timer.getTime() - self.started > Activity.LINK_SECONDS then
|
||||
self:finish("gone", Text.say("gone", self.peer.name))
|
||||
end
|
||||
return
|
||||
end
|
||||
self.peer = peerOf(self.room, self.opts.peer)
|
||||
self.linked = true
|
||||
end
|
||||
local prep = self.prep
|
||||
for _, e in ipairs(prep:poll()) do
|
||||
if self:handle(e) then return end
|
||||
end
|
||||
if prep.state == "closed" then
|
||||
return self:finish("peer", Text.say("peerCancel", self.peer.name))
|
||||
end
|
||||
if prep.state == "prep" and self.state == "preparing" then
|
||||
self.state = "ready"
|
||||
local screen = Activity.screens[self.mode]
|
||||
if screen then
|
||||
self:closeUi()
|
||||
screen(self)
|
||||
return
|
||||
end
|
||||
self:show(self:readyText())
|
||||
end
|
||||
end
|
||||
|
||||
return Activity
|
||||
@@ -0,0 +1,95 @@
|
||||
local Menu = require("src.ui.Menu")
|
||||
local Sound = require("src.core.Sound")
|
||||
local Strings = require("src.core.Strings")
|
||||
local TextBox = require("src.render.TextBox")
|
||||
|
||||
local Dialog = {}
|
||||
|
||||
Dialog.MENU_TX, Dialog.MENU_BOTTOM = 11, 12
|
||||
|
||||
local function popIf(stack, state)
|
||||
if state and stack:top() == state then
|
||||
stack:pop()
|
||||
return true
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
Dialog.popIf = popIf
|
||||
|
||||
function Dialog.sfx(game, name)
|
||||
return Sound.play(game.data, name)
|
||||
end
|
||||
|
||||
function Dialog.say(game, text, onDone)
|
||||
local box = TextBox.new(game, text, onDone)
|
||||
game.stack:push(box)
|
||||
return box
|
||||
end
|
||||
|
||||
function Dialog.ask(game, text, onAnswer)
|
||||
local box = TextBox.new(game, text, nil, {
|
||||
choice = function(yes) onAnswer(yes and true or false) end,
|
||||
})
|
||||
game.stack:push(box)
|
||||
return box
|
||||
end
|
||||
|
||||
function Dialog.menu(game, text, labels, onChoose)
|
||||
local stack = game.stack
|
||||
local box, menu
|
||||
local function finish(index)
|
||||
popIf(stack, menu)
|
||||
popIf(stack, box)
|
||||
onChoose(index)
|
||||
end
|
||||
box = TextBox.new(game, text, nil, { stay = { onShown = function()
|
||||
local items = {}
|
||||
for i, label in ipairs(labels) do
|
||||
items[i] = { label = Strings(label), keepOpen = true, onSelect = function() finish(i) end }
|
||||
end
|
||||
local th = #items * 2 + 2
|
||||
menu = Menu.new(game, items, {
|
||||
tx = Dialog.MENU_TX, ty = Dialog.MENU_BOTTOM - th, th = th, tw = 20 - Dialog.MENU_TX,
|
||||
keepOnCancel = true, onCancel = function() finish(#items) end,
|
||||
})
|
||||
stack:push(menu)
|
||||
end } })
|
||||
stack:push(box)
|
||||
return box
|
||||
end
|
||||
|
||||
local Waiter = {}
|
||||
Waiter.__index = Waiter
|
||||
Waiter.isOpaque = false
|
||||
|
||||
function Waiter:update()
|
||||
if self.closed then return end
|
||||
self.tick(self, self.game and self.game.input)
|
||||
end
|
||||
|
||||
function Waiter:draw() end
|
||||
|
||||
function Waiter:close()
|
||||
if self.closed then return end
|
||||
self.closed = true
|
||||
local stack = self.game.stack
|
||||
popIf(stack, self)
|
||||
popIf(stack, self.box)
|
||||
end
|
||||
|
||||
function Dialog.hold(game, text, tick)
|
||||
local stack = game.stack
|
||||
local waiter = setmetatable({ game = game, tick = tick, closed = false, text = text }, Waiter)
|
||||
waiter.box = TextBox.new(game, text, nil, { stay = { onShown = function()
|
||||
if not waiter.closed then stack:push(waiter) end
|
||||
end } })
|
||||
stack:push(waiter.box)
|
||||
return waiter
|
||||
end
|
||||
|
||||
function Dialog.pressed(input, key)
|
||||
return input and input.wasPressed and input:wasPressed(key) and true or false
|
||||
end
|
||||
|
||||
return Dialog
|
||||
@@ -0,0 +1,138 @@
|
||||
local Avatars = require("src.online.union.Avatars")
|
||||
local PaletteFX = require("src.render.PaletteFX")
|
||||
|
||||
local Look = {}
|
||||
|
||||
Look.LEVELS = { 0.7, 0.33, 0 }
|
||||
|
||||
local stats = { grays = 0 }
|
||||
|
||||
function Look.stats()
|
||||
return { grays = stats.grays }
|
||||
end
|
||||
|
||||
function Look.trueColor()
|
||||
return PaletteFX.usesGbcPack()
|
||||
end
|
||||
|
||||
local function levelOf(r, g, b)
|
||||
local l = 0.299 * r + 0.587 * g + 0.114 * b
|
||||
if l > 0.78 then return Look.LEVELS[1] end
|
||||
if l > 0.42 then return Look.LEVELS[2] end
|
||||
return Look.LEVELS[3]
|
||||
end
|
||||
|
||||
function Look.grayEntry(entry)
|
||||
if entry._grayEntry ~= nil then return entry._grayEntry or nil end
|
||||
local base = Avatars.imageData(entry)
|
||||
if not base then
|
||||
entry._grayEntry = false
|
||||
return nil
|
||||
end
|
||||
local id = base.clone and base:clone() or base
|
||||
local colors = entry.palette and entry.palette.mode == "gbc" and entry.palette.colors or nil
|
||||
id:mapPixel(function(_, _, r, g, b, a)
|
||||
if a == 0 then return 1, 1, 1, 0 end
|
||||
if colors then
|
||||
if r > 0.83 then return 1, 1, 1, 0 end
|
||||
local c = colors[r > 0.5 and 2 or r > 0.17 and 3 or 4]
|
||||
r, g, b = c[1] / 255, c[2] / 255, c[3] / 255
|
||||
end
|
||||
local v = levelOf(r, g, b)
|
||||
return v, v, v, a
|
||||
end)
|
||||
local gray = {}
|
||||
for k, v in pairs(entry) do gray[k] = v end
|
||||
gray.palette = { mode = "dmg" }
|
||||
gray._imageData, gray._images, gray._lookKey, gray._lookImg = id, nil, nil, nil
|
||||
gray._grayEntry = nil
|
||||
entry._grayEntry = gray
|
||||
stats.grays = stats.grays + 1
|
||||
return gray
|
||||
end
|
||||
|
||||
function Look.objColors()
|
||||
if PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
|
||||
return PaletteFX.ogObjWorld(), true
|
||||
end
|
||||
return PaletteFX.dmgObj(), false
|
||||
end
|
||||
|
||||
function Look.gen1Colors(playerDef, seed)
|
||||
if PaletteFX.usesGbcPack() then
|
||||
local colors = PaletteFX.spriteObp(playerDef, seed)
|
||||
if colors then return colors, false end
|
||||
return PaletteFX.dmgObj(), false
|
||||
end
|
||||
if PaletteFX.usesSpriteObp() and PaletteFX.spriteRedrawPassActive() then
|
||||
return PaletteFX.ogObjWorld(), true
|
||||
end
|
||||
return PaletteFX.dmgObj(), false
|
||||
end
|
||||
|
||||
local function blit(img, quad, x, y, w, flip, redraw)
|
||||
if flip then
|
||||
love.graphics.draw(img, quad, x + w, y, 0, -1, 1)
|
||||
if redraw then PaletteFX.markSpriteRedraw(img, quad, x + w, y, -1) end
|
||||
else
|
||||
love.graphics.draw(img, quad, x, y)
|
||||
if redraw then PaletteFX.markSpriteRedraw(img, quad, x, y, 1) end
|
||||
end
|
||||
end
|
||||
|
||||
local OWN = {}
|
||||
|
||||
local function imageFor(entry, colors)
|
||||
local key = colors or OWN
|
||||
if entry._lookKey == key and entry._lookImg then return entry._lookImg end
|
||||
local img = Avatars.image(entry, colors)
|
||||
entry._lookKey, entry._lookImg = key, img
|
||||
return img
|
||||
end
|
||||
|
||||
local function artHeight(entry)
|
||||
local id = Avatars.imageData(entry)
|
||||
if not (id and id.getPixel) then return entry.h end
|
||||
local keyed = entry.palette.mode ~= "rgba"
|
||||
for y = 0, entry.h - 1 do
|
||||
for x = 0, entry.w - 1 do
|
||||
local r, _, _, a = id:getPixel(x, y)
|
||||
if a > 0 and not (keyed and r > 0.83) then return entry.h - y end
|
||||
end
|
||||
end
|
||||
return entry.h
|
||||
end
|
||||
|
||||
function Look.height(entry)
|
||||
if type(entry) ~= "table" or entry.standin then return 0 end
|
||||
if not entry._artH then entry._artH = artHeight(entry) end
|
||||
return entry._artH
|
||||
end
|
||||
|
||||
function Look.draw(entry, footX, footY, facing, opts)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
if type(entry) ~= "table" or entry.standin then return false end
|
||||
local quads = Avatars.quads(entry)
|
||||
if not quads then return false end
|
||||
local frame, flip = Avatars.pose(entry, facing, 0, false)
|
||||
local x = footX - entry.anchor.x
|
||||
local y = footY - entry.anchor.y
|
||||
local img, redraw
|
||||
if entry.palette.mode == "dmg" then
|
||||
local colors
|
||||
colors, redraw = Look.gen1Colors(opts and opts.playerDef, opts and opts.seed)
|
||||
img = imageFor(entry, colors)
|
||||
elseif Look.trueColor() then
|
||||
img = imageFor(entry, nil)
|
||||
else
|
||||
local gray = Look.grayEntry(entry)
|
||||
local colors
|
||||
colors, redraw = Look.objColors()
|
||||
img = gray and imageFor(gray, colors)
|
||||
end
|
||||
if not img then return false end
|
||||
blit(img, quads[frame], x, y, entry.w, flip, redraw)
|
||||
return true
|
||||
end
|
||||
|
||||
return Look
|
||||
@@ -0,0 +1,55 @@
|
||||
local Badge = require("src.online.union.Badge")
|
||||
local Font = require("src.render.Font")
|
||||
|
||||
local Tag = {}
|
||||
|
||||
Tag.HOST_GEN = 1
|
||||
Tag.PAD = 2
|
||||
Tag.GLYPH_H = 8
|
||||
Tag.LIFT = 2
|
||||
Tag.FILL = { 1, 1, 1 }
|
||||
Tag.EDGE = { 0, 0, 0 }
|
||||
|
||||
local widths = {}
|
||||
|
||||
function Tag.nameWidth(name)
|
||||
local hit = widths[name]
|
||||
if hit then return hit end
|
||||
local w = Font.width(name)
|
||||
widths[name] = w
|
||||
return w
|
||||
end
|
||||
|
||||
function Tag.size(name, withName)
|
||||
local bw, bh = Badge.size(1)
|
||||
if not withName then return bw, bh end
|
||||
local w = Tag.nameWidth(name) + Tag.PAD * 3 + bw
|
||||
local h = math.max(Tag.GLYPH_H, bh) + Tag.PAD * 2
|
||||
return w, h
|
||||
end
|
||||
|
||||
function Tag.rect(x, y, name, withName)
|
||||
local w, h = Tag.size(name, withName)
|
||||
return math.floor(x) - math.floor(w / 2), math.floor(y) - Tag.LIFT - h, w, h
|
||||
end
|
||||
|
||||
function Tag.draw(x, y, name, digit, withName)
|
||||
local G = love.graphics
|
||||
local left, top, w, h = Tag.rect(x, y, name, withName)
|
||||
if withName then
|
||||
G.setColor(Tag.EDGE[1], Tag.EDGE[2], Tag.EDGE[3], 1)
|
||||
G.rectangle("fill", left, top, w, h)
|
||||
G.setColor(Tag.FILL[1], Tag.FILL[2], Tag.FILL[3], 1)
|
||||
G.rectangle("fill", left + 1, top + 1, w - 2, h - 2)
|
||||
G.setColor(0, 0, 0, 1)
|
||||
Font.draw(name, left + Tag.PAD, top + Tag.PAD)
|
||||
Badge.drawBeside(left + Tag.PAD + Tag.nameWidth(name), top + Tag.PAD - 1,
|
||||
Tag.GLYPH_H + 2, digit, Tag.HOST_GEN, 1, Tag.PAD)
|
||||
else
|
||||
Badge.draw(left, top, digit, Tag.HOST_GEN, 1)
|
||||
end
|
||||
G.setColor(1, 1, 1, 1)
|
||||
return left, top, w, h
|
||||
end
|
||||
|
||||
return Tag
|
||||
@@ -0,0 +1,102 @@
|
||||
local Activity = require("src.ui.union.gen1.Activity")
|
||||
local Dialog = require("src.ui.union.gen1.Dialog")
|
||||
local Participant = require("src.online.union.Participant")
|
||||
local Protocol2 = require("src.online.Protocol2")
|
||||
local Text = require("src.ui.union.gen1.Text")
|
||||
|
||||
local Talk = {}
|
||||
|
||||
Talk.LABELS = { Text.MENU.battle, Text.MENU.trade, Text.MENU.cancel }
|
||||
Talk.ACTIVITIES = { "xg_battle", "xg_trade" }
|
||||
|
||||
local function release(presence, done)
|
||||
return function()
|
||||
presence:setBusy(false)
|
||||
if done then done() end
|
||||
end
|
||||
end
|
||||
|
||||
function Talk.invite(game, presence, p, activity, done)
|
||||
local room = presence.room
|
||||
local finish = release(presence, done)
|
||||
local mode = Protocol2.XG_ACTIVITIES[activity]
|
||||
local h, why = room:invite(p, activity)
|
||||
if not h or h.state == "closed" then
|
||||
Dialog.sfx(game, "Denied")
|
||||
Dialog.say(game, Text.closed(h and h.why or why, p.name), finish)
|
||||
return nil
|
||||
end
|
||||
presence:track(h)
|
||||
Dialog.hold(game, Text.say("waiting", p.name), function(w, input)
|
||||
if h.state == "accepted" then
|
||||
w:close()
|
||||
presence:untrack(h)
|
||||
Activity.begin(game, room, mode, { peer = p, onDone = finish, session = presence })
|
||||
elseif h.state == "closed" then
|
||||
w:close()
|
||||
presence:untrack(h)
|
||||
Dialog.sfx(game, "Denied")
|
||||
Dialog.say(game, Text.closed(h.why, p.name), finish)
|
||||
elseif Dialog.pressed(input, "b") then
|
||||
Dialog.sfx(game, "Press_AB")
|
||||
w:close()
|
||||
presence:abandon(h)
|
||||
Dialog.say(game, Text.say("cancelled"), finish)
|
||||
end
|
||||
end)
|
||||
return h
|
||||
end
|
||||
|
||||
function Talk.begin(game, presence, member, done)
|
||||
local p = presence:participant(member)
|
||||
if not p or presence.state ~= "joined" then
|
||||
Dialog.say(game, Text.say("gone", member and member.p and member.p.name), done)
|
||||
return
|
||||
end
|
||||
if Participant.busy(p) then
|
||||
Dialog.say(game, Text.say("busy", p.name), done)
|
||||
return
|
||||
end
|
||||
presence:setBusy(true, member)
|
||||
local finish = release(presence, done)
|
||||
local function menu()
|
||||
Dialog.menu(game, Text.say("what", p.name), Talk.LABELS, function(i)
|
||||
local activity = Talk.ACTIVITIES[i]
|
||||
if activity then
|
||||
Talk.invite(game, presence, p, activity, done)
|
||||
else
|
||||
finish()
|
||||
end
|
||||
end)
|
||||
end
|
||||
if member.entry and (member.entry.standin or member.entry.hostStandin) then
|
||||
Dialog.say(game, Text.standin(p, member.entry), menu)
|
||||
else
|
||||
menu()
|
||||
end
|
||||
end
|
||||
|
||||
function Talk.incoming(game, presence, inv, done)
|
||||
local room = presence.room
|
||||
local p = inv.from
|
||||
local name = p and p.name or nil
|
||||
local finish = release(presence, done)
|
||||
local member = presence:memberById(inv.fromId)
|
||||
presence:setBusy(true, member)
|
||||
if member and game.overworld and game.overworld.player then
|
||||
member:facePlayer(game.overworld.player)
|
||||
end
|
||||
Dialog.sfx(game, "Safari_Zone_PA")
|
||||
Dialog.ask(game, Text.say(inv.mode == "trade" and "askTrade" or "askBattle", name), function(yes)
|
||||
local sent = room:reply(inv.id, yes)
|
||||
if not yes then
|
||||
finish()
|
||||
elseif not sent then
|
||||
Dialog.say(game, Text.say("timeout", name), finish)
|
||||
else
|
||||
Activity.begin(game, room, inv.mode, { peer = p, onDone = finish, session = presence })
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
return Talk
|
||||
@@ -0,0 +1,106 @@
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Messages = require("src.online.xgen.Messages")
|
||||
local Strings = require("src.core.Strings")
|
||||
|
||||
local Text = {}
|
||||
|
||||
Text.MENU = {
|
||||
battle = Strings.source("BATTLE"),
|
||||
trade = Strings.source("TRADE"),
|
||||
cancel = Strings.source("CANCEL"),
|
||||
}
|
||||
|
||||
local S = {
|
||||
what = Strings.source("Do what with\n%s?"),
|
||||
busy = Strings.source("%s looks\nbusy right now."),
|
||||
waiting = Strings.source("Waiting for\n%s…"),
|
||||
declined = Strings.source("%s\nsaid no."),
|
||||
timeout = Strings.source("%s\ndidn't answer."),
|
||||
gone = Strings.source("%s\nhas left."),
|
||||
refused = Strings.source("%s can't\ndo that now."),
|
||||
askBattle = Strings.source("%s wants\nto BATTLE!\fWill you accept?"),
|
||||
askTrade = Strings.source("%s wants\nto TRADE!\fWill you accept?"),
|
||||
linking = Strings.source("Linking up with\n%s…"),
|
||||
readyBattle = Strings.source("Getting ready to\nBATTLE %s…"),
|
||||
readyTrade = Strings.source("Getting ready to\nTRADE: %s…"),
|
||||
peerCancel = Strings.source("%s\ncanceled."),
|
||||
cancelled = Strings.source("The request was\ncanceled."),
|
||||
standin = Strings.source("%s\nplays %s!\fImport %s\nto see them here."),
|
||||
connecting = Strings.source("Linking to the\nUNION ROOM…"),
|
||||
offline = Strings.source("No link could be\nmade.\fNo other TRAINERS\nare here for now."),
|
||||
serverOld = Strings.source("The UNION ROOM\nlink is too old.\fNo other TRAINERS\nare here for now."),
|
||||
clientOld = Strings.source("This game is too\nold to link up.\fUpdate it to meet\nother TRAINERS."),
|
||||
joinFailed = Strings.source("The UNION ROOM\ncouldn't be used.\fNo other TRAINERS\nare here for now."),
|
||||
lost = Strings.source("The link was lost.\nReconnecting…"),
|
||||
lostForGood = Strings.source("The link couldn't\nbe made again.\fNo other TRAINERS\nare here for now."),
|
||||
}
|
||||
Text.S = S
|
||||
|
||||
local function clean(name)
|
||||
name = tostring(name or "")
|
||||
name = name:gsub("[{}%c]", "")
|
||||
if name == "" then name = "?" end
|
||||
return name
|
||||
end
|
||||
|
||||
Text.clean = clean
|
||||
|
||||
function Text.say(key, ...)
|
||||
local n = select("#", ...)
|
||||
local args = { ... }
|
||||
for i = 1, n do args[i] = clean(args[i]) end
|
||||
return Strings(S[key], unpack(args, 1, n))
|
||||
end
|
||||
|
||||
function Text.gameName(version)
|
||||
local info = GameVersion.VERSIONS[version or ""]
|
||||
local label = info and info.label or tostring(version or "?")
|
||||
return label:upper()
|
||||
end
|
||||
|
||||
function Text.needName(need)
|
||||
if type(need) ~= "table" or not need[1] then return "?" end
|
||||
return Text.gameName(need[1])
|
||||
end
|
||||
|
||||
function Text.standin(p, entry)
|
||||
return Text.say("standin", p and p.name, Text.gameName(p and p.game), Text.needName(entry and entry.need))
|
||||
end
|
||||
|
||||
Text.CLOSED = {
|
||||
declined = "declined", timeout = "timeout", busy = "busy",
|
||||
offline = "gone", target_left = "gone", sender_left = "gone",
|
||||
}
|
||||
|
||||
function Text.closed(why, name)
|
||||
return Text.say(Text.CLOSED[why or ""] or "refused", name)
|
||||
end
|
||||
|
||||
Text.ERRORS = {
|
||||
server_outdated = "serverOld", client_outdated = "clientOld",
|
||||
offline = "offline", lost = "lostForGood",
|
||||
}
|
||||
|
||||
function Text.error(code)
|
||||
return Strings(S[Text.ERRORS[code or ""] or "joinFailed"])
|
||||
end
|
||||
|
||||
function Text.blocked(code, detail)
|
||||
local lines = Messages.render(code, 1, detail)
|
||||
local out = {}
|
||||
for i, line in ipairs(lines) do
|
||||
if i > 1 then out[#out + 1] = (i % 2 == 0) and "\n" or "\f" end
|
||||
out[#out + 1] = line
|
||||
end
|
||||
return table.concat(out)
|
||||
end
|
||||
|
||||
-- pokered/data/text/text_4.asm:209
|
||||
function Text.pleaseWait(game)
|
||||
local text = game and game.data and game.data.text
|
||||
local line = text and text._CableClubNPCPleaseWaitText
|
||||
assert(type(line) == "string", "gen1 cache has no _CableClubNPCPleaseWaitText")
|
||||
return line
|
||||
end
|
||||
|
||||
return Text
|
||||
@@ -0,0 +1,113 @@
|
||||
local Font = require("src.render.Font")
|
||||
local Theme = require("src.ui.Theme")
|
||||
|
||||
local Screen = {}
|
||||
Screen.__index = Screen
|
||||
Screen.isOpaque = true
|
||||
|
||||
Screen.COLS, Screen.ROWS = 20, 18
|
||||
Screen.MENU_MAX = 4
|
||||
Screen.KEYS = { "up", "down", "a", "b" }
|
||||
|
||||
function Screen.layout(pg, model)
|
||||
local items = pg.items or {}
|
||||
local visible = math.min(#items, Screen.MENU_MAX)
|
||||
local menuH = visible > 0 and visible + 2 or 0
|
||||
local infoH = Screen.ROWS - menuH
|
||||
local room = infoH - 3
|
||||
local body = model:formatLines(pg.lines)
|
||||
local lines = {}
|
||||
if pg.pager then
|
||||
local first = math.max(0, math.min(pg.scroll or 0, math.max(0, #body - room)))
|
||||
for i = first + 1, math.min(#body, first + room) do lines[#lines + 1] = body[i] end
|
||||
return { menuH = menuH, infoH = infoH, title = model:label(pg.title), lines = lines,
|
||||
more = first + room < #body, less = first > 0, visible = 0, scroll = 0, items = items }
|
||||
end
|
||||
lines = model.fitGroups(model:formatGroups(pg.lines), model:formatGroups(pg.info), room,
|
||||
{ notice = pg.notice, keepBody = pg.keepBody })
|
||||
local cursor = pg.cursor or 1
|
||||
local scroll = 0
|
||||
if visible > 0 then
|
||||
scroll = math.max(0, math.min(cursor - visible, #items - visible))
|
||||
if cursor - 1 < scroll then scroll = cursor - 1 end
|
||||
end
|
||||
return { menuH = menuH, infoH = infoH, title = model:label(pg.title), lines = lines,
|
||||
visible = visible, scroll = scroll, cursor = cursor, items = items,
|
||||
more = #items > scroll + visible }
|
||||
end
|
||||
|
||||
function Screen.label(model, it)
|
||||
local text = model:label(it.label)
|
||||
if it.disabled then text = "(" .. text .. ")" end
|
||||
local spans = Font.split(text)
|
||||
if #spans > 17 then text = text:sub(1, spans[17].to) end
|
||||
return text
|
||||
end
|
||||
|
||||
function Screen.open(game, model, onDone)
|
||||
local self = setmetatable({ game = game, model = model, onDone = onDone, closed = false }, Screen)
|
||||
game.stack:push(self)
|
||||
return self
|
||||
end
|
||||
|
||||
function Screen:press(key)
|
||||
if key == "a" or key == "b" then
|
||||
local data = self.game.data
|
||||
if data and data.audio then require("src.core.Sound").play(data, "Press_AB") end
|
||||
end
|
||||
self.model:input(key)
|
||||
end
|
||||
|
||||
function Screen:update(_dt)
|
||||
if self.closed then return end
|
||||
self.model:poll()
|
||||
local input = self.game.input
|
||||
if input then
|
||||
for _, key in ipairs(Screen.KEYS) do
|
||||
if input:wasPressed(key) then
|
||||
self:press(key)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if self.model.done then self:finish() end
|
||||
end
|
||||
|
||||
function Screen:finish()
|
||||
if self.closed then return end
|
||||
self.closed = true
|
||||
local stack = self.game.stack
|
||||
if stack:top() == self then stack:pop() end
|
||||
if self.onDone then self.onDone() end
|
||||
end
|
||||
|
||||
function Screen:draw()
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, Screen.COLS * 8, Screen.ROWS * 8)
|
||||
local pg = self.model:page()
|
||||
local L = Screen.layout(pg, self.model)
|
||||
Font.drawBox(0, 0, Screen.COLS, L.infoH)
|
||||
G.setColor(0, 0, 0, 1)
|
||||
Font.draw(L.title, 8, 8)
|
||||
for i, line in ipairs(L.lines) do Font.draw(line, 8, (1 + i) * 8) end
|
||||
if pg.pager and L.more then Font.drawCode(Theme.moreArrow, 18 * 8, (L.infoH - 2) * 8) end
|
||||
if L.menuH > 0 then
|
||||
local top = L.infoH
|
||||
Font.drawBox(0, top, Screen.COLS, L.menuH)
|
||||
G.setColor(0, 0, 0, 1)
|
||||
for row = 1, L.visible do
|
||||
local it = L.items[L.scroll + row]
|
||||
if it then
|
||||
local y = (top + row) * 8
|
||||
if it.chosen and L.scroll + row ~= L.cursor then Font.drawCode(Theme.cursorHollow, 8, y) end
|
||||
Font.draw(Screen.label(self.model, it), 16, y)
|
||||
end
|
||||
end
|
||||
Font.drawCode(Theme.cursor, 8, (top + L.cursor - L.scroll) * 8)
|
||||
if L.more then Font.drawCode(Theme.moreArrow, 18 * 8, (top + L.menuH - 1) * 8) end
|
||||
end
|
||||
G.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
return Screen
|
||||
@@ -0,0 +1,123 @@
|
||||
local Font = require("src.render.Font")
|
||||
local Theme = require("src.ui.Theme")
|
||||
|
||||
local Screen = {}
|
||||
Screen.__index = Screen
|
||||
Screen.isOpaque = true
|
||||
|
||||
Screen.COLS, Screen.ROWS = 20, 18
|
||||
Screen.MENU_MAX = 4
|
||||
Screen.KEYS = { "up", "down", "left", "right", "a", "b" }
|
||||
Screen.LABEL_MAX = 17
|
||||
|
||||
function Screen.layout(pg, ctl)
|
||||
local items = pg.items or {}
|
||||
local visible = math.min(#items, Screen.MENU_MAX)
|
||||
local menuH = visible > 0 and visible + 2 or 0
|
||||
local infoH = Screen.ROWS - menuH
|
||||
local room = infoH - 3
|
||||
local body = ctl:formatLines(pg.lines)
|
||||
local info = ctl:formatLines(pg.info)
|
||||
for _, l in ipairs(info) do body[#body + 1] = l end
|
||||
local maxFirst = math.max(0, #body - room)
|
||||
local first = math.min(pg.scroll or 0, maxFirst)
|
||||
if (pg.scroll or 0) > maxFirst then ctl.scroll = maxFirst end
|
||||
local lines = {}
|
||||
for i = first + 1, math.min(#body, first + room) do lines[#lines + 1] = body[i] end
|
||||
local cursor = pg.cursor or 1
|
||||
local scroll = 0
|
||||
if visible > 0 then
|
||||
scroll = math.max(0, math.min(cursor - visible, #items - visible))
|
||||
if cursor - 1 < scroll then scroll = cursor - 1 end
|
||||
end
|
||||
return { menuH = menuH, infoH = infoH, title = ctl:label(pg.title), lines = lines, first = first, room = room,
|
||||
more = first < maxFirst, less = first > 0, visible = visible, scroll = scroll, cursor = cursor,
|
||||
items = items, listMore = #items > scroll + visible }
|
||||
end
|
||||
|
||||
function Screen.label(ctl, it)
|
||||
local text = ctl:label(it.label)
|
||||
if it.disabled then text = "(" .. text .. ")" end
|
||||
local spans = Font.split(text)
|
||||
if #spans > Screen.LABEL_MAX then text = text:sub(1, spans[Screen.LABEL_MAX].to) end
|
||||
return text
|
||||
end
|
||||
|
||||
function Screen.open(game, ctl, onDone)
|
||||
local self = setmetatable({ game = game, ctl = ctl, onDone = onDone, closed = false }, Screen)
|
||||
game.stack:push(self)
|
||||
return self
|
||||
end
|
||||
|
||||
function Screen.pageOn(self, key)
|
||||
if key ~= "a" then return false end
|
||||
local L = Screen.layout(self.ctl:page(), self.ctl)
|
||||
if not L.more then return false end
|
||||
self.ctl.scroll = L.first + L.room
|
||||
self.blink = 0
|
||||
return true
|
||||
end
|
||||
|
||||
function Screen.arrowOn(self, period)
|
||||
return (self.blink or 0) % period < period / 2
|
||||
end
|
||||
|
||||
function Screen:press(key)
|
||||
if key == "a" or key == "b" then
|
||||
local data = self.game.data
|
||||
if data and data.audio then require("src.core.Sound").play(data, "Press_AB") end
|
||||
end
|
||||
if Screen.pageOn(self, key) then return end
|
||||
self.ctl:input(key)
|
||||
end
|
||||
|
||||
function Screen:update(dt)
|
||||
if self.closed then return end
|
||||
self.blink = (self.blink or 0) + 1
|
||||
self.ctl:poll(dt)
|
||||
local input = self.game.input
|
||||
if input then
|
||||
for _, key in ipairs(Screen.KEYS) do
|
||||
if input:wasPressed(key) then
|
||||
self:press(key)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
if self.ctl.done then self:finish() end
|
||||
end
|
||||
|
||||
function Screen:finish()
|
||||
if self.closed then return end
|
||||
self.closed = true
|
||||
local stack = self.game.stack
|
||||
if stack:top() == self then stack:pop() end
|
||||
if self.onDone then self.onDone() end
|
||||
end
|
||||
|
||||
function Screen:draw()
|
||||
local G = love.graphics
|
||||
G.setColor(1, 1, 1, 1)
|
||||
G.rectangle("fill", 0, 0, Screen.COLS * 8, Screen.ROWS * 8)
|
||||
local pg = self.ctl:page()
|
||||
local L = Screen.layout(pg, self.ctl)
|
||||
Font.drawBox(0, 0, Screen.COLS, L.infoH)
|
||||
G.setColor(0, 0, 0, 1)
|
||||
Font.draw(L.title, 8, 8)
|
||||
for i, line in ipairs(L.lines) do Font.draw(line, 8, (1 + i) * 8) end
|
||||
if L.more and Screen.arrowOn(self, 60) then Font.drawCode(Theme.moreArrow, 18 * 8, (L.infoH - 2) * 8) end
|
||||
if L.menuH > 0 then
|
||||
local top = L.infoH
|
||||
Font.drawBox(0, top, Screen.COLS, L.menuH)
|
||||
G.setColor(0, 0, 0, 1)
|
||||
for row = 1, L.visible do
|
||||
local it = L.items[L.scroll + row]
|
||||
if it then Font.draw(Screen.label(self.ctl, it), 16, (top + row) * 8) end
|
||||
end
|
||||
Font.drawCode(Theme.cursor, 8, (top + L.cursor - L.scroll) * 8)
|
||||
if L.listMore then Font.drawCode(Theme.moreArrow, 18 * 8, (top + L.menuH - 1) * 8) end
|
||||
end
|
||||
G.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
return Screen
|
||||
@@ -0,0 +1,60 @@
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local Font = require("src.render.Font")
|
||||
local Gen1 = require("src.ui.union.prep.Gen1BattlePrep")
|
||||
local Sound = require("src.core.Sound")
|
||||
|
||||
local Screen = {}
|
||||
Screen.__index = Screen
|
||||
Screen.isOpaque = true
|
||||
|
||||
function Screen.open(game, model, onDone)
|
||||
local self = setmetatable({ game = game, model = model, onDone = onDone, closed = false }, Screen)
|
||||
game.stack:push(self)
|
||||
return self
|
||||
end
|
||||
|
||||
function Screen:wantsFillScale() return true end
|
||||
|
||||
function Screen:press(key)
|
||||
if key == "a" or key == "b" then
|
||||
local data = self.game.data
|
||||
local sfx = data and data.audio and data.audio.sfx
|
||||
if sfx and sfx[Sound.resolve(data, "Sfx_ReadText2")] then Sound.play(data, "Sfx_ReadText2") end
|
||||
end
|
||||
self.model:input(key)
|
||||
end
|
||||
|
||||
function Screen.arrow(tx, ty)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.drawCode(Chrome.DOWN_ARROW, tx * 8, ty * 8)
|
||||
end
|
||||
|
||||
Screen.update = Gen1.update
|
||||
Screen.finish = Gen1.finish
|
||||
|
||||
function Screen:draw()
|
||||
local G = love.graphics
|
||||
Chrome.paletteFill(0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8)
|
||||
local pg = self.model:page()
|
||||
local L = Gen1.layout(pg, self.model)
|
||||
Chrome.box(0, 0, Chrome.SCREEN_W, L.infoH)
|
||||
Chrome.print(L.title, 1, 1)
|
||||
for i, line in ipairs(L.lines) do Chrome.print(line, 1, 1 + i) end
|
||||
if pg.pager and L.more then Screen.arrow(18, L.infoH - 2) end
|
||||
if L.menuH > 0 then
|
||||
local top = L.infoH
|
||||
Chrome.box(0, top, Chrome.SCREEN_W, L.menuH)
|
||||
for row = 1, L.visible do
|
||||
local it = L.items[L.scroll + row]
|
||||
if it then
|
||||
if it.chosen and L.scroll + row ~= L.cursor then Chrome.cursor(1, top + row, true) end
|
||||
Chrome.print(Gen1.label(self.model, it), 2, top + row)
|
||||
end
|
||||
end
|
||||
Chrome.cursor(1, top + L.cursor - L.scroll)
|
||||
if L.more then Screen.arrow(18, top + L.menuH - 1) end
|
||||
end
|
||||
G.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
return Screen
|
||||
@@ -0,0 +1,59 @@
|
||||
local Chrome = require("src.ui.gen2.Chrome")
|
||||
local Font = require("src.render.Font")
|
||||
local Gen1 = require("src.ui.union.prep.Gen1TradePrep")
|
||||
local Sound = require("src.core.Sound")
|
||||
|
||||
local Screen = {}
|
||||
Screen.__index = Screen
|
||||
Screen.isOpaque = true
|
||||
|
||||
function Screen.open(game, ctl, onDone)
|
||||
local self = setmetatable({ game = game, ctl = ctl, onDone = onDone, closed = false }, Screen)
|
||||
game.stack:push(self)
|
||||
return self
|
||||
end
|
||||
|
||||
function Screen:wantsFillScale() return true end
|
||||
|
||||
function Screen:press(key)
|
||||
if key == "a" or key == "b" then
|
||||
local data = self.game.data
|
||||
local sfx = data and data.audio and data.audio.sfx
|
||||
if sfx and sfx[Sound.resolve(data, "Sfx_ReadText2")] then Sound.play(data, "Sfx_ReadText2") end
|
||||
end
|
||||
if Gen1.pageOn(self, key) then return end
|
||||
self.ctl:input(key)
|
||||
end
|
||||
|
||||
Screen.update = Gen1.update
|
||||
Screen.finish = Gen1.finish
|
||||
|
||||
function Screen.arrow(tx, ty)
|
||||
love.graphics.setColor(0, 0, 0, 1)
|
||||
Font.drawCode(Chrome.DOWN_ARROW, tx * 8, ty * 8)
|
||||
end
|
||||
|
||||
function Screen:draw()
|
||||
local G = love.graphics
|
||||
Chrome.paletteFill(0, 0, Chrome.SCREEN_W * 8, Chrome.SCREEN_H * 8)
|
||||
local pg = self.ctl:page()
|
||||
local L = Gen1.layout(pg, self.ctl)
|
||||
Chrome.box(0, 0, Chrome.SCREEN_W, L.infoH)
|
||||
Chrome.print(L.title, 1, 1)
|
||||
for i, line in ipairs(L.lines) do Chrome.print(line, 1, 1 + i) end
|
||||
-- pokegold/home/joypad.asm:430
|
||||
if L.more and Gen1.arrowOn(self, 32) then Screen.arrow(18, L.infoH - 2) end
|
||||
if L.menuH > 0 then
|
||||
local top = L.infoH
|
||||
Chrome.box(0, top, Chrome.SCREEN_W, L.menuH)
|
||||
for row = 1, L.visible do
|
||||
local it = L.items[L.scroll + row]
|
||||
if it then Chrome.print(Gen1.label(self.ctl, it), 2, top + row) end
|
||||
end
|
||||
Chrome.cursor(1, top + L.cursor - L.scroll)
|
||||
if L.listMore then Screen.arrow(18, top + L.menuH - 1) end
|
||||
end
|
||||
G.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
return Screen
|
||||
@@ -0,0 +1,141 @@
|
||||
local Stack = require("src.ui.game3.stack")
|
||||
local Window = require("src.ui.game3.window")
|
||||
local FrlgFont = require("src.ui.game3.frlg_font")
|
||||
|
||||
local Screen = {}
|
||||
|
||||
Screen.ID = "union_battle_prep"
|
||||
Screen.W, Screen.H = 240, 160
|
||||
Screen.INFO = Window.template(1, 1, 13, 18)
|
||||
Screen.MENU = Window.template(16, 1, 13, 18)
|
||||
Screen.PITCH = 15
|
||||
Screen.ROWS = 9
|
||||
Screen.KEYS = { "up", "down", "a", "b" }
|
||||
Screen.TITLE = { colors = FrlgFont.COLOR.BLUE }
|
||||
Screen.BODY = { colors = FrlgFont.COLOR.NORMAL }
|
||||
Screen.BACKGROUND = { 0.37, 0.56, 0.71 }
|
||||
|
||||
local function groups(lines, width)
|
||||
local out = {}
|
||||
for _, line in ipairs(lines or {}) do
|
||||
local g = {}
|
||||
for part in (FrlgFont.wrap(line, width) .. "\n"):gmatch("(.-)\n") do g[#g + 1] = part end
|
||||
out[#out + 1] = g
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
local function wrapped(lines, width)
|
||||
local out = {}
|
||||
for _, g in ipairs(groups(lines, width)) do
|
||||
for _, part in ipairs(g) do out[#out + 1] = part end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function Screen.layout(pg, model)
|
||||
local width = Screen.INFO.width * 8
|
||||
local room = Screen.ROWS - 1
|
||||
local body = wrapped(pg.lines, width)
|
||||
local lines = {}
|
||||
local more, less = false, false
|
||||
if pg.pager then
|
||||
local first = math.max(0, math.min(pg.scroll or 0, math.max(0, #body - room)))
|
||||
for i = first + 1, math.min(#body, first + room) do lines[#lines + 1] = body[i] end
|
||||
more, less = first + room < #body, first > 0
|
||||
else
|
||||
lines = model.fitGroups(groups(pg.lines, width), groups(pg.info, width), room,
|
||||
{ notice = pg.notice, keepBody = pg.keepBody })
|
||||
end
|
||||
local items = pg.items or {}
|
||||
local visible = math.min(#items, Screen.ROWS)
|
||||
local cursor = pg.cursor or 1
|
||||
local scroll = 0
|
||||
if visible > 0 and cursor > visible then scroll = cursor - visible end
|
||||
return { lines = lines, more = more, less = less, items = items, visible = visible, scroll = scroll,
|
||||
cursor = cursor, listMore = #items > scroll + visible }
|
||||
end
|
||||
|
||||
function Screen.open(game, model, onDone)
|
||||
local mod = {}
|
||||
local closed = false
|
||||
local function finish()
|
||||
if closed then return end
|
||||
closed = true
|
||||
Stack.pop(Screen.ID)
|
||||
if onDone then onDone() end
|
||||
end
|
||||
function mod.update(_dt)
|
||||
if closed then return end
|
||||
model:poll()
|
||||
if model.done then finish() end
|
||||
end
|
||||
function mod.handleInput(input)
|
||||
if closed or not input then return end
|
||||
for _, key in ipairs(Screen.KEYS) do
|
||||
if input:wasPressed(key) then
|
||||
if key == "a" or key == "b" then
|
||||
local okA, Audio = pcall(require, "src.core.game3.audio")
|
||||
local okS, SE = pcall(require, "src.core.game3.se_ids")
|
||||
if okA and okS and Audio.playSe then pcall(Audio.playSe, SE.SE_SELECT) end
|
||||
end
|
||||
model:input(key)
|
||||
break
|
||||
end
|
||||
end
|
||||
if model.done then finish() end
|
||||
end
|
||||
function mod.draw()
|
||||
Screen.draw(model)
|
||||
end
|
||||
mod.model = model
|
||||
mod.finish = finish
|
||||
Stack.push(Screen.ID, mod, { hideBelow = true, fullscreen = true })
|
||||
return mod
|
||||
end
|
||||
|
||||
function Screen.arrow(px, py)
|
||||
local c = FrlgFont.COLOR.NORMAL.fg
|
||||
love.graphics.setColor(c[1] or 0, c[2] or 0, c[3] or 0, 1)
|
||||
love.graphics.polygon("fill", px, py, px + 6, py, px + 3, py + 4)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
function Screen.draw(model)
|
||||
local G = love.graphics
|
||||
local bg = Screen.BACKGROUND
|
||||
G.setColor(bg[1], bg[2], bg[3], 1)
|
||||
G.rectangle("fill", 0, 0, Screen.W, Screen.H)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
local pg = model:page()
|
||||
local L = Screen.layout(pg, model)
|
||||
local info, menu = Screen.INFO, Screen.MENU
|
||||
Window.stdFrame(info)
|
||||
local x, y = info.left * 8, info.top * 8
|
||||
Window.printPx(pg.title or "", x, y, Screen.TITLE)
|
||||
for i, line in ipairs(L.lines) do
|
||||
Window.printPx(line, x, y + i * Screen.PITCH, Screen.BODY)
|
||||
end
|
||||
if L.more then Screen.arrow(x + info.width * 8 - 8, y + Screen.ROWS * Screen.PITCH - 4) end
|
||||
if L.visible > 0 then
|
||||
Window.stdFrame(menu)
|
||||
local mx, my = menu.left * 8, menu.top * 8
|
||||
local maxW = menu.width * 8 - Window.CURSOR_WIDTH
|
||||
for row = 1, L.visible do
|
||||
local it = L.items[L.scroll + row]
|
||||
if it then
|
||||
local ry = my + (row - 1) * Screen.PITCH
|
||||
local opt = it.chosen and Screen.TITLE or Screen.BODY
|
||||
local label = it.disabled and ("(" .. (it.label or "") .. ")") or (it.label or "")
|
||||
Window.printPx(label, mx + Window.CURSOR_WIDTH, ry, { colors = opt.colors, maxWidth = maxW })
|
||||
end
|
||||
end
|
||||
Window.cursorPx(mx, my + (L.cursor - L.scroll - 1) * Screen.PITCH)
|
||||
if L.listMore then
|
||||
Screen.arrow(mx + menu.width * 8 - 8, my + Screen.ROWS * Screen.PITCH - 4)
|
||||
end
|
||||
end
|
||||
G.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
return Screen
|
||||
@@ -0,0 +1,144 @@
|
||||
local Stack = require("src.ui.game3.stack")
|
||||
local Window = require("src.ui.game3.window")
|
||||
local FrlgFont = require("src.ui.game3.frlg_font")
|
||||
|
||||
local Screen = {}
|
||||
|
||||
Screen.ID = "union_trade_prep"
|
||||
Screen.W, Screen.H = 240, 160
|
||||
Screen.INFO = Window.template(1, 1, 17, 18)
|
||||
Screen.MENU = Window.template(20, 1, 9, 18)
|
||||
Screen.PITCH = 15
|
||||
Screen.ROWS = 9
|
||||
Screen.KEYS = { "up", "down", "left", "right", "a", "b" }
|
||||
Screen.TITLE = { colors = FrlgFont.COLOR.BLUE }
|
||||
Screen.BODY = { colors = FrlgFont.COLOR.NORMAL }
|
||||
Screen.BACKGROUND = { 0.37, 0.56, 0.71 }
|
||||
|
||||
local function wrapped(lines, width)
|
||||
local out = {}
|
||||
for _, line in ipairs(lines or {}) do
|
||||
local text = FrlgFont.wrap(line, width)
|
||||
for part in (text .. "\n"):gmatch("(.-)\n") do out[#out + 1] = part end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function Screen.layout(pg, ctl)
|
||||
local width = Screen.INFO.width * 8
|
||||
local room = Screen.ROWS - 1
|
||||
local body = wrapped(pg.lines, width)
|
||||
for _, l in ipairs(wrapped(pg.info, width)) do body[#body + 1] = l end
|
||||
local maxFirst = math.max(0, #body - room)
|
||||
local first = math.min(pg.scroll or 0, maxFirst)
|
||||
if ctl and (pg.scroll or 0) > maxFirst then ctl.scroll = maxFirst end
|
||||
local lines = {}
|
||||
for i = first + 1, math.min(#body, first + room) do lines[#lines + 1] = body[i] end
|
||||
local items = pg.items or {}
|
||||
local visible = math.min(#items, Screen.ROWS)
|
||||
local cursor = pg.cursor or 1
|
||||
local scroll = 0
|
||||
if visible > 0 and cursor > visible then scroll = cursor - visible end
|
||||
return { lines = lines, first = first, room = room, more = first < maxFirst, items = items, visible = visible, scroll = scroll,
|
||||
cursor = cursor, listMore = #items > scroll + visible }
|
||||
end
|
||||
|
||||
function Screen.pageOn(ctl, key)
|
||||
if key ~= "a" then return false end
|
||||
local L = Screen.layout(ctl:page(), ctl)
|
||||
if not L.more then return false end
|
||||
ctl.scroll = L.first + L.room
|
||||
return true
|
||||
end
|
||||
|
||||
function Screen.open(_game, ctl, onDone)
|
||||
local mod = { ticks = 0 }
|
||||
local closed = false
|
||||
local function finish()
|
||||
if closed then return end
|
||||
closed = true
|
||||
Stack.pop(Screen.ID)
|
||||
if onDone then onDone() end
|
||||
end
|
||||
function mod.update(dt)
|
||||
if closed then return end
|
||||
mod.ticks = mod.ticks + 1
|
||||
ctl:poll(dt)
|
||||
if ctl.done then finish() end
|
||||
end
|
||||
function mod.handleInput(input)
|
||||
if closed or not input then return end
|
||||
for _, key in ipairs(Screen.KEYS) do
|
||||
if input:wasPressed(key) then
|
||||
if key == "a" or key == "b" then
|
||||
local okA, Audio = pcall(require, "src.core.game3.audio")
|
||||
local okS, SE = pcall(require, "src.core.game3.se_ids")
|
||||
if okA and okS and Audio.playSe then pcall(Audio.playSe, SE.SE_SELECT) end
|
||||
end
|
||||
if Screen.pageOn(ctl, key) then mod.ticks = 0 else ctl:input(key) end
|
||||
break
|
||||
end
|
||||
end
|
||||
if ctl.done then finish() end
|
||||
end
|
||||
function mod.draw() Screen.draw(ctl, mod.ticks) end
|
||||
mod.ctl = ctl
|
||||
mod.finish = finish
|
||||
Stack.push(Screen.ID, mod, { hideBelow = true, fullscreen = true })
|
||||
return mod
|
||||
end
|
||||
|
||||
function Screen.arrow(px, py)
|
||||
local c = FrlgFont.COLOR.NORMAL.fg
|
||||
love.graphics.setColor(c[1] or 0, c[2] or 0, c[3] or 0, 1)
|
||||
love.graphics.polygon("fill", px, py, px + 6, py, px + 3, py + 4)
|
||||
love.graphics.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
local BOUNCE = { 0, 1, 2, 3, 2, 1 }
|
||||
|
||||
function Screen.moreArrow(px, py, ticks)
|
||||
local Chrome = require("src.ui.game3.chrome")
|
||||
local rse = Chrome.arrowSpec()
|
||||
local frame
|
||||
if rse then
|
||||
frame = math.floor(ticks / (rse.period or ((rse.delay or 0) + 1)))
|
||||
else
|
||||
frame = BOUNCE[1 + math.floor(ticks / 8) % #BOUNCE]
|
||||
end
|
||||
Chrome.promptArrow(px, py, frame)
|
||||
end
|
||||
|
||||
function Screen.draw(ctl, ticks)
|
||||
local G = love.graphics
|
||||
local bg = Screen.BACKGROUND
|
||||
G.setColor(bg[1], bg[2], bg[3], 1)
|
||||
G.rectangle("fill", 0, 0, Screen.W, Screen.H)
|
||||
G.setColor(1, 1, 1, 1)
|
||||
local pg = ctl:page()
|
||||
local L = Screen.layout(pg, ctl)
|
||||
local info, menu = Screen.INFO, Screen.MENU
|
||||
Window.stdFrame(info)
|
||||
local x, y = info.left * 8, info.top * 8
|
||||
Window.printPx(pg.title or "", x, y, Screen.TITLE)
|
||||
for i, line in ipairs(L.lines) do Window.printPx(line, x, y + i * Screen.PITCH, Screen.BODY) end
|
||||
if L.more then Screen.moreArrow(x + info.width * 8 - 10, y + Screen.ROWS * Screen.PITCH - 8, ticks or 0) end
|
||||
if L.visible > 0 then
|
||||
Window.stdFrame(menu)
|
||||
local mx, my = menu.left * 8, menu.top * 8
|
||||
local maxW = menu.width * 8 - Window.CURSOR_WIDTH
|
||||
for row = 1, L.visible do
|
||||
local it = L.items[L.scroll + row]
|
||||
if it then
|
||||
local label = it.disabled and ("(" .. (it.label or "") .. ")") or (it.label or "")
|
||||
Window.printPx(label, mx + Window.CURSOR_WIDTH, my + (row - 1) * Screen.PITCH,
|
||||
{ colors = Screen.BODY.colors, maxWidth = maxW })
|
||||
end
|
||||
end
|
||||
Window.cursorPx(mx, my + (L.cursor - L.scroll - 1) * Screen.PITCH)
|
||||
if L.listMore then Screen.arrow(mx + menu.width * 8 - 8, my + Screen.ROWS * Screen.PITCH - 4) end
|
||||
end
|
||||
G.setColor(1, 1, 1, 1)
|
||||
end
|
||||
|
||||
return Screen
|
||||
@@ -0,0 +1,89 @@
|
||||
local Model = require("src.online.union.BattlePrepModel")
|
||||
|
||||
local Open = {}
|
||||
|
||||
Open.RENDERERS = {
|
||||
[1] = "src.ui.union.prep.Gen1BattlePrep",
|
||||
[2] = "src.ui.union.prep.Gen2BattlePrep",
|
||||
[3] = "src.ui.union.prep.Gen3BattlePrep",
|
||||
}
|
||||
|
||||
function Open.source(game, gen)
|
||||
if gen == 3 then
|
||||
local session = game and game.session
|
||||
if not session then
|
||||
local ok, Runtime = pcall(require, "src.core.game3.runtime")
|
||||
session = ok and Runtime.getSession and Runtime.getSession() or nil
|
||||
end
|
||||
if not session then return { party = {}, generation = 3 } end
|
||||
local PartyView = require("src.core.game3.battle.party_view")
|
||||
local party = PartyView.fromSession(session.party, session.move_overlay)
|
||||
return { party = party, save = { storage = session.storage }, generation = 3 }
|
||||
end
|
||||
local save = game and game.save or {}
|
||||
return { party = save.party or {}, save = save, generation = gen }
|
||||
end
|
||||
|
||||
function Open.opponent(room, prep)
|
||||
local r = room and room.xgRoom and room:xgRoom() or nil
|
||||
local mine = prep and prep.seat and prep:seat() or nil
|
||||
for _, p in ipairs(r and r.players or {}) do
|
||||
if mine == nil or p.seat ~= mine then
|
||||
local av = type(p.avatar) == "table" and p.avatar or {}
|
||||
return { name = av.name or p.name, version = av.version, gen = p.gen or av.gen }
|
||||
end
|
||||
end
|
||||
return { name = "?" }
|
||||
end
|
||||
|
||||
function Open.gameplayMods(game)
|
||||
return require("src.online.union.Caps").gameplayMods(game)
|
||||
end
|
||||
|
||||
function Open.model(game, room, prep, opts)
|
||||
opts = opts or {}
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local version = opts.version or GameVersion.get()
|
||||
local gen = GameVersion.generation(version)
|
||||
local mods = opts.gameplayMods
|
||||
if mods == nil then mods = Open.gameplayMods(game) end
|
||||
return Model.new({
|
||||
version = version, gen = gen, data = opts.data, prep = prep,
|
||||
opponent = opts.opponent or Open.opponent(room, prep),
|
||||
gameplayMods = mods, owned = opts.owned or Open.source(game, gen), rules = opts.rules,
|
||||
})
|
||||
end
|
||||
|
||||
function Open.battle(game, room, prep, onDone, opts)
|
||||
local model = Open.model(game, room, prep, opts)
|
||||
local Renderer = require(Open.RENDERERS[model.gen] or Open.RENDERERS[1])
|
||||
local screen = Renderer.open(game, model, function()
|
||||
local result, outcome = model:result(), model.outcome
|
||||
model:discard()
|
||||
if outcome ~= "go" and prep and prep.leave then prep:leave() end
|
||||
if onDone then onDone(result, outcome) end
|
||||
end)
|
||||
return screen, model
|
||||
end
|
||||
|
||||
function Open.screen(act, opts)
|
||||
opts = opts or {}
|
||||
local peer = act.peer or {}
|
||||
opts.opponent = opts.opponent or { name = peer.name, version = peer.game, gen = peer.gen }
|
||||
return Open.battle(act.game, act.room, act.prep, function(result, outcome)
|
||||
act.battlePrep = result
|
||||
act:finish(outcome == "go" and "go" or outcome or "cancel")
|
||||
end, opts)
|
||||
end
|
||||
|
||||
function Open.register(Activity)
|
||||
if type(Activity) == "table" and type(Activity.screens) == "table" then
|
||||
Activity.screens.battle = Open.screen
|
||||
end
|
||||
return Activity
|
||||
end
|
||||
|
||||
Open.register(require("src.ui.gen2.union.Activity"))
|
||||
Open.register(require("src.ui.union.gen1.Activity"))
|
||||
|
||||
return Open
|
||||
@@ -0,0 +1,636 @@
|
||||
local GameVersion = require("src.core.GameVersion")
|
||||
local Messages = require("src.online.xgen.Messages")
|
||||
local Model = require("src.online.union.TradePrepModel")
|
||||
local Project = require("src.online.xgen.Project")
|
||||
local Txn = require("src.online.union.TradeTxn")
|
||||
|
||||
local Open = {}
|
||||
|
||||
Open.RENDERERS = {
|
||||
[1] = "src.ui.union.prep.Gen1TradePrep",
|
||||
[2] = "src.ui.union.prep.Gen2TradePrep",
|
||||
[3] = "src.ui.union.prep.Gen3TradePrep",
|
||||
}
|
||||
|
||||
local TEXT = {
|
||||
title_pick = "Union Trade",
|
||||
title_offer = "Your Offer",
|
||||
title_moves = "Moves",
|
||||
title_wait = "Union Trade",
|
||||
title_confirm = "Final Trade",
|
||||
title_trading = "Trading",
|
||||
title_done = "Trade Done",
|
||||
title_closed = "Union Trade",
|
||||
|
||||
with = "Trade with {name}.",
|
||||
plays = "{name} is playing {game}.",
|
||||
feature = "Trades between generations are a feature of this app, not of the original games.",
|
||||
pick = "Choose a Pokémon to offer.",
|
||||
rules_wait = "Waiting for the trade to open…",
|
||||
becomes = "In {game} it becomes {species} {level}.",
|
||||
no_change = "Nothing else changes.",
|
||||
changes = "Permanent changes:",
|
||||
blocked = "It can't be offered yet:",
|
||||
pick_move = "Choose a move {species} can learn in {game}, or remove the move.",
|
||||
move_row = "{type} Power {power} PP {pp}",
|
||||
evolves = "{species} may evolve when it arrives.",
|
||||
wait_offer = "Waiting for {name} to offer a Pokémon…",
|
||||
sent = "You offered {species}.",
|
||||
their_offer = "{name} offers {species} {level}.",
|
||||
refused = "{name}'s offer can't be accepted here:",
|
||||
refused_peer = "{name} can't accept your offer:",
|
||||
refused_unknown = "Their game can't take it as it is.",
|
||||
you_send = "You send {species} {level}.",
|
||||
you_get = "You get {species} {level} from {name}.",
|
||||
their_changes = "Changes to what you get:",
|
||||
my_changes = "Changes to what you send:",
|
||||
final_note = "This is final. The sent Pokémon is gone from your game for good.",
|
||||
wait_ready = "Waiting for {name} to agree…",
|
||||
changed = "An offer changed. Check the trade again.",
|
||||
trading = "Trading… Don't turn off the power.",
|
||||
save_failed = "The game couldn't be saved. Trying again…",
|
||||
unresolved = "The link was lost during the trade. It will be finished when the server answers.",
|
||||
done = "{name} sent {species}!",
|
||||
evolved = "{species} evolved into {into}!",
|
||||
again = "Trade again?",
|
||||
closed_cancel = "{name} stopped trading.",
|
||||
closed_self = "Trading stopped.",
|
||||
closed_gone = "The link to {name} was lost.",
|
||||
closed_other = "The trade was cancelled.",
|
||||
aborted = "The trade didn't happen. Both Pokémon stay where they were.",
|
||||
recheck = "Your Pokémon changed after it was offered, so the trade was stopped.",
|
||||
|
||||
item_offer = "Offer",
|
||||
item_moves = "Fix moves",
|
||||
item_back = "Back",
|
||||
item_trade = "Trade",
|
||||
item_change = "Change",
|
||||
item_stop = "Stop",
|
||||
item_remove = "Remove move",
|
||||
item_ok = "OK",
|
||||
item_yes = "Yes",
|
||||
item_no = "No",
|
||||
item_details = "Details",
|
||||
place_party = "party",
|
||||
place_box = "Box {box}",
|
||||
}
|
||||
|
||||
local CODE_TEXT = {
|
||||
rental = "Rental Pokémon can't be traded.",
|
||||
projection = "Battle copies can't be traded.",
|
||||
pending = "Its last trade isn't settled yet.",
|
||||
not_owned = "That Pokémon isn't there anymore.",
|
||||
preview_mismatch = "Their game and this one disagree about how it converts. Both players need the same games imported.",
|
||||
wrong_destination = "It was meant for another game.",
|
||||
wrong_source = "It doesn't match the other player's game.",
|
||||
bad_payload = "Its data can't be read.",
|
||||
unsupported_version = "Its game isn't supported.",
|
||||
not_valid_here = "This game can't store it as it is.",
|
||||
too_big = "It carries too much data to send.",
|
||||
not_ready = "It can't be offered yet.",
|
||||
}
|
||||
|
||||
local function fill(template, args)
|
||||
args = args or {}
|
||||
return (template:gsub("{(%w+)}", function(k)
|
||||
local v = args[k]
|
||||
return v ~= nil and tostring(v) or ""
|
||||
end))
|
||||
end
|
||||
|
||||
local function say(key, args) return fill(TEXT[key] or key, args) end
|
||||
Open.say = say
|
||||
Open.TEXT = TEXT
|
||||
Open.REFUSED = "refused:"
|
||||
|
||||
local function gameName(version)
|
||||
local info = GameVersion.VERSIONS[version or ""]
|
||||
return "Pokémon " .. (info and info.label or tostring(version or "?"))
|
||||
end
|
||||
|
||||
local function item(id, label, extra)
|
||||
local out = { id = id, label = label }
|
||||
for k, v in pairs(extra or {}) do out[k] = v end
|
||||
return out
|
||||
end
|
||||
|
||||
function Open.opponent(room, prep)
|
||||
local r = room and room.xgRoom and room:xgRoom() or nil
|
||||
local mine = prep and prep.seat and prep:seat() or nil
|
||||
for _, p in ipairs(r and r.players or {}) do
|
||||
if mine == nil or p.seat ~= mine then
|
||||
local av = type(p.avatar) == "table" and p.avatar or {}
|
||||
return { name = av.name or p.name, version = av.version, gen = p.gen or av.gen }
|
||||
end
|
||||
end
|
||||
return { name = "?" }
|
||||
end
|
||||
|
||||
local Ctl = {}
|
||||
Ctl.__index = Ctl
|
||||
Open.Controller = Ctl
|
||||
|
||||
function Ctl.new(opts)
|
||||
local self = setmetatable({}, Ctl)
|
||||
self.game = opts.game
|
||||
self.prep = opts.prep
|
||||
self.version = opts.version or GameVersion.get()
|
||||
self.gen = GameVersion.generation(self.version)
|
||||
self.opponent = opts.opponent or { name = "?" }
|
||||
self.adapter = opts.adapter or Txn.newAdapter(self.game, self.version)
|
||||
self.model = Model.new({ version = self.version, peerVersion = self.opponent.version, peerName = self.opponent.name,
|
||||
owned = self.adapter:owned() })
|
||||
self.txn = Txn.new({ game = self.game, prep = self.prep, model = self.model, adapter = self.adapter,
|
||||
peerName = self.opponent.name, roomId = opts.roomId })
|
||||
self.step = "pick"
|
||||
self.cursor = 1
|
||||
self.scroll = 0
|
||||
self.events = {}
|
||||
self.done = false
|
||||
self.sentOffer = nil
|
||||
self.myData = Model.datasetFor(self.version)
|
||||
return self
|
||||
end
|
||||
|
||||
function Ctl:style() return Messages.style(self.gen) end
|
||||
|
||||
function Ctl:label(text)
|
||||
if self:style() == "gb" then return (text or ""):upper() end
|
||||
return text or ""
|
||||
end
|
||||
|
||||
function Ctl:formatLines(lines)
|
||||
local out = {}
|
||||
for _, line in ipairs(lines or {}) do
|
||||
for _, l in ipairs(Messages.format(line, self.gen)) do out[#out + 1] = l end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function Ctl:levelLabel(level)
|
||||
if level == nil then return "" end
|
||||
if self.gen <= 2 then return ":L" .. tostring(level) end
|
||||
return "Lv" .. tostring(level)
|
||||
end
|
||||
|
||||
function Ctl:names(data)
|
||||
data = data or self.myData
|
||||
return {
|
||||
species = function(n) local sp = data and data.species[tonumber(n) or -1] return sp and sp.name or ("No. " .. tostring(n)) end,
|
||||
move = function(m) local mv = data and data.moves[tonumber(m) or -1] return mv and mv.name or ("move " .. tostring(m)) end,
|
||||
item = function(i) return tostring(i) end,
|
||||
}
|
||||
end
|
||||
|
||||
function Ctl:monLabel(entry)
|
||||
local view = entry and entry.rec and self.myData and Project.read(entry.rec, self.myData)
|
||||
local name = view and self.myData.species[view.national] and self.myData.species[view.national].name or "?"
|
||||
if view and view.isEgg then name = "Egg" end
|
||||
return name, view and view.level
|
||||
end
|
||||
|
||||
function Ctl:go(step)
|
||||
self.step = step
|
||||
self.cursor = 1
|
||||
self.scroll = 0
|
||||
end
|
||||
|
||||
function Ctl:close(key, args, reason)
|
||||
self.closedText = say(key, args)
|
||||
self.closedReason = reason
|
||||
self:go("closed")
|
||||
end
|
||||
|
||||
function Ctl:reasonText(code, detail)
|
||||
if code == nil or code == "" then return say("refused_unknown") end
|
||||
if CODE_TEXT[code] then return CODE_TEXT[code] end
|
||||
if Messages.known(code) then return Messages.text(code, detail, self:names()) end
|
||||
return say("refused_unknown")
|
||||
end
|
||||
|
||||
function Ctl:blockLines(report, data)
|
||||
local lines = {}
|
||||
local names = self:names(data)
|
||||
for _, b in ipairs(report.blocks or {}) do
|
||||
local text = CODE_TEXT[b.code] or Messages.text(b.code, b.detail, names)
|
||||
lines[#lines + 1] = "- " .. text
|
||||
end
|
||||
return lines
|
||||
end
|
||||
|
||||
function Ctl:changeLines(changes)
|
||||
local lines, seen = {}, {}
|
||||
for _, c in ipairs(changes or {}) do
|
||||
local text = Messages.change(c, 3)[1]
|
||||
if text and not seen[text] then
|
||||
seen[text] = true
|
||||
lines[#lines + 1] = "- " .. text
|
||||
end
|
||||
end
|
||||
return lines
|
||||
end
|
||||
|
||||
function Ctl:pagePick()
|
||||
local lines = { say("with", { name = self.opponent.name }) }
|
||||
if self.opponent.version then
|
||||
lines[#lines + 1] = say("plays", { name = self.opponent.name, game = gameName(self.opponent.version) })
|
||||
end
|
||||
local items = {}
|
||||
if self.prep and self.prep.state == "rules_wait" then
|
||||
lines[#lines + 1] = say("rules_wait")
|
||||
items[#items + 1] = item("stop", TEXT.item_stop)
|
||||
return { title = TEXT.title_pick, lines = lines, items = items }
|
||||
end
|
||||
lines[#lines + 1] = say("pick")
|
||||
for i, entry in ipairs(self.model.owned) do
|
||||
local name, level = self:monLabel(entry)
|
||||
local place = entry.ref.where == "box" and say("place_box", { box = entry.ref.box }) or nil
|
||||
local label = name .. " " .. self:levelLabel(level)
|
||||
items[#items + 1] = item("mon", label, { arg = i, disabled = entry.locked ~= nil,
|
||||
detail = { place and (place .. ".") or nil, entry.locked and (CODE_TEXT[entry.locked] or Messages.text(entry.locked)) or nil } })
|
||||
end
|
||||
items[#items + 1] = item("stop", TEXT.item_stop)
|
||||
return { title = TEXT.title_pick, lines = lines, items = items }
|
||||
end
|
||||
|
||||
function Ctl:pageOffer()
|
||||
local m = self.model.mine
|
||||
local r = m and m.report or {}
|
||||
local lines = {}
|
||||
local items = {}
|
||||
if r.ok then
|
||||
lines[#lines + 1] = say("becomes", { game = gameName(self.opponent.version), species = r.speciesName,
|
||||
level = self:levelLabel(r.level) })
|
||||
local changes = self:changeLines(r.changes)
|
||||
if #changes == 0 then lines[#lines + 1] = say("no_change") else
|
||||
lines[#lines + 1] = say("changes")
|
||||
for _, l in ipairs(changes) do lines[#lines + 1] = l end
|
||||
end
|
||||
if r.evolves then lines[#lines + 1] = say("evolves", { species = r.speciesName }) end
|
||||
items[#items + 1] = item("offer", TEXT.item_offer)
|
||||
else
|
||||
lines[#lines + 1] = say("blocked")
|
||||
for _, l in ipairs(self:blockLines(r, self.myData)) do lines[#lines + 1] = l end
|
||||
if r.options and r.options.moves and next(r.options.moves) then items[#items + 1] = item("moves", TEXT.item_moves) end
|
||||
end
|
||||
items[#items + 1] = item("back", TEXT.item_back)
|
||||
return { title = TEXT.title_offer, lines = lines, items = items, pager = self.pager }
|
||||
end
|
||||
|
||||
function Ctl:moveSlot()
|
||||
local m = self.model.mine
|
||||
local opts = m and m.report and m.report.options and m.report.options.moves
|
||||
if not opts then return nil end
|
||||
local slots = {}
|
||||
for slot in pairs(opts) do slots[#slots + 1] = slot end
|
||||
table.sort(slots)
|
||||
return slots[1], opts[slots[1]]
|
||||
end
|
||||
|
||||
function Ctl:pageMoves()
|
||||
local slot, list = self:moveSlot()
|
||||
local peerData = Model.datasetFor(self.opponent.version)
|
||||
local r = self.model.mine and self.model.mine.report or {}
|
||||
local lines = { say("pick_move", { species = r.sourceName or "?", game = gameName(self.opponent.version) }) }
|
||||
local items = {}
|
||||
for _, move in ipairs(list or {}) do
|
||||
local mv = peerData and peerData.moves[move]
|
||||
if mv then
|
||||
items[#items + 1] = item("move", mv.name, { arg = { slot = slot, move = move },
|
||||
detail = { say("move_row", { type = mv.type or "?", power = mv.power, pp = mv.pp }) } })
|
||||
end
|
||||
end
|
||||
if slot then items[#items + 1] = item("remove", TEXT.item_remove, { arg = { slot = slot } }) end
|
||||
items[#items + 1] = item("back", TEXT.item_back)
|
||||
return { title = TEXT.title_moves, lines = lines, items = items }
|
||||
end
|
||||
|
||||
function Ctl:peerOfferLines()
|
||||
local p = self.model.peer
|
||||
local lines = {}
|
||||
if p then
|
||||
lines[#lines + 1] = say("their_offer", { name = self.opponent.name, species = p.report.speciesName,
|
||||
level = self:levelLabel(p.report.level) })
|
||||
elseif self.model.refusal then
|
||||
lines[#lines + 1] = say("refused", { name = self.opponent.name })
|
||||
local code = self.model.refusal.code
|
||||
lines[#lines + 1] = "- " .. self:reasonText(code, self.model.refusal.detail)
|
||||
end
|
||||
return lines
|
||||
end
|
||||
|
||||
function Ctl:pageWait()
|
||||
local m = self.model.mine
|
||||
local lines = {}
|
||||
if m and m.report and m.report.ok then
|
||||
lines[#lines + 1] = say("sent", { species = m.report.sourceName })
|
||||
end
|
||||
local peer = self:peerOfferLines()
|
||||
if #peer == 0 then lines[#lines + 1] = say("wait_offer", { name = self.opponent.name }) end
|
||||
for _, l in ipairs(peer) do lines[#lines + 1] = l end
|
||||
local items = { item("change", TEXT.item_change), item("stop", TEXT.item_stop) }
|
||||
return { title = TEXT.title_wait, lines = lines, items = items }
|
||||
end
|
||||
|
||||
function Ctl:confirmLines()
|
||||
local s = self.model:summary()
|
||||
local lines = {}
|
||||
local mine, theirs = s.mine, s.theirs
|
||||
lines[#lines + 1] = say("you_send", { species = mine.sourceName, level = self:levelLabel(mine.level) })
|
||||
lines[#lines + 1] = say("becomes", { game = gameName(self.opponent.version), species = mine.speciesName,
|
||||
level = self:levelLabel(mine.level) })
|
||||
local mc = self:changeLines(mine.changes)
|
||||
if #mc > 0 then
|
||||
lines[#lines + 1] = say("my_changes")
|
||||
for _, l in ipairs(mc) do lines[#lines + 1] = l end
|
||||
end
|
||||
if mine.evolves then lines[#lines + 1] = say("evolves", { species = mine.speciesName }) end
|
||||
lines[#lines + 1] = say("you_get", { species = theirs.speciesName, level = self:levelLabel(theirs.level),
|
||||
name = self.opponent.name })
|
||||
local tc = self:changeLines(theirs.changes)
|
||||
if #tc > 0 then
|
||||
lines[#lines + 1] = say("their_changes")
|
||||
for _, l in ipairs(tc) do lines[#lines + 1] = l end
|
||||
end
|
||||
if theirs.evolves then lines[#lines + 1] = say("evolves", { species = theirs.speciesName }) end
|
||||
lines[#lines + 1] = say("final_note")
|
||||
lines[#lines + 1] = say("feature")
|
||||
return lines
|
||||
end
|
||||
|
||||
function Ctl:pageConfirm()
|
||||
local lines = self:confirmLines()
|
||||
if self.notice then table.insert(lines, 1, self.notice) end
|
||||
local items = { item("trade", TEXT.item_trade), item("change", TEXT.item_change), item("stop", TEXT.item_stop) }
|
||||
return { title = TEXT.title_confirm, lines = lines, items = items, pager = self.pager }
|
||||
end
|
||||
|
||||
function Ctl:pageReady()
|
||||
return { title = TEXT.title_confirm, lines = { say("wait_ready", { name = self.opponent.name }) },
|
||||
items = { item("change", TEXT.item_change), item("stop", TEXT.item_stop) } }
|
||||
end
|
||||
|
||||
function Ctl:pageTrading()
|
||||
local key = "trading"
|
||||
if self.txn.state == "saving" and self.txn.saveFailed then key = "save_failed" end
|
||||
if self.txn.state == "unresolved" then key = "unresolved" end
|
||||
local items = {}
|
||||
if self.txn.state == "unresolved" then items[1] = item("leave", TEXT.item_ok) end
|
||||
return { title = TEXT.title_trading, lines = { say(key) }, items = items }
|
||||
end
|
||||
|
||||
function Ctl:pageDone()
|
||||
local r = self.result or {}
|
||||
local lines = { say("done", { name = self.opponent.name, species = r.species or "?" }) }
|
||||
if r.evolvedName then lines[#lines + 1] = say("evolved", { species = r.species, into = r.evolvedName }) end
|
||||
lines[#lines + 1] = say("again")
|
||||
return { title = TEXT.title_done, lines = lines, items = { item("again", TEXT.item_yes), item("stop", TEXT.item_no) } }
|
||||
end
|
||||
|
||||
function Ctl:pageClosed()
|
||||
local lines = { self.closedText or say("closed_other") }
|
||||
if self.closedReason then lines[2] = "- " .. self.closedReason end
|
||||
return { title = TEXT.title_closed, lines = lines, items = { item("leave", TEXT.item_ok) } }
|
||||
end
|
||||
|
||||
local PAGES = { pick = Ctl.pagePick, offer = Ctl.pageOffer, moves = Ctl.pageMoves, wait = Ctl.pageWait,
|
||||
confirm = Ctl.pageConfirm, ready = Ctl.pageReady, trading = Ctl.pageTrading, done = Ctl.pageDone,
|
||||
closed = Ctl.pageClosed }
|
||||
|
||||
function Ctl:page()
|
||||
local pg = PAGES[self.step](self)
|
||||
pg.step = self.step
|
||||
if #pg.items > 0 then
|
||||
if self.cursor > #pg.items then self.cursor = #pg.items end
|
||||
if self.cursor < 1 then self.cursor = 1 end
|
||||
end
|
||||
pg.cursor = self.cursor
|
||||
pg.scroll = self.scroll
|
||||
local sel = pg.items[self.cursor]
|
||||
pg.info = sel and sel.detail or nil
|
||||
return pg
|
||||
end
|
||||
|
||||
function Ctl:sendOffer()
|
||||
local payload, digest = self.model:payload()
|
||||
if not payload then return false end
|
||||
self.sentOffer = { payload = payload, digest = digest }
|
||||
self.prep:offer(payload, digest)
|
||||
return true
|
||||
end
|
||||
|
||||
function Ctl:canConfirm()
|
||||
local p = self.prep
|
||||
return self.model.mine and self.model.mine.digest16 and self.model.peer ~= nil
|
||||
and p.mine.offer ~= nil and p.peer.offer ~= nil and p.state == "prep"
|
||||
end
|
||||
|
||||
function Ctl:receivePeer()
|
||||
local offer = self.prep.peer.offer
|
||||
if not (offer and offer.payload) then return end
|
||||
if self.seenPeer == offer then return end
|
||||
self.seenPeer = offer
|
||||
self.model:receive(offer.payload, function(final) return self.adapter:validate(final) end)
|
||||
local r = self.model.refusal
|
||||
if r and self.step ~= "trading" and self.step ~= "done" then
|
||||
self.prep:cancel(Open.REFUSED .. tostring(r.code))
|
||||
self:close("refused", { name = self.opponent.name }, self:reasonText(r.code, r.detail))
|
||||
end
|
||||
end
|
||||
|
||||
function Ctl:resetRound()
|
||||
self.model.owned = self.adapter:owned()
|
||||
self.model.mine, self.model.peer, self.model.refusal = nil, nil, nil
|
||||
self.sentOffer, self.seenPeer, self.notice = nil, nil, nil
|
||||
self.txn:nextRound()
|
||||
end
|
||||
|
||||
function Ctl:handlePrep(e)
|
||||
local k = e.kind
|
||||
if k == "closed" then
|
||||
if self.step == "trading" or self.step == "closed" then return end
|
||||
local why = e.why
|
||||
local refused = type(e.detail) == "string" and e.detail:sub(1, #Open.REFUSED) == Open.REFUSED
|
||||
and e.detail:sub(#Open.REFUSED + 1) or nil
|
||||
if why == "cancel" and refused and e.seat ~= nil and e.seat ~= self.prep:seat() then
|
||||
self:close("refused_peer", { name = self.opponent.name }, self:reasonText(refused))
|
||||
elseif why == "cancel" and e.seat ~= nil and e.seat ~= self.prep:seat() then
|
||||
self:close("closed_cancel", { name = self.opponent.name })
|
||||
elseif why == "cancel" then
|
||||
self:close("closed_self")
|
||||
elseif why == "gone" or why == "left" then
|
||||
self:close("closed_gone", { name = self.opponent.name })
|
||||
else
|
||||
self:close("closed_other")
|
||||
end
|
||||
elseif k == "nack" and e.of == "xg_offer" and self.sentOffer then
|
||||
self.prep:offer(self.sentOffer.payload, self.sentOffer.digest)
|
||||
elseif k == "invalidated" or (k == "nack" and e.of == "xg_ready") then
|
||||
if self.step == "ready" then
|
||||
self.notice = say("changed")
|
||||
self:go("confirm")
|
||||
end
|
||||
elseif k == "go" then
|
||||
if self.txn:confirm() then
|
||||
self:go("trading")
|
||||
else
|
||||
self:close(self.txn.why and self.txn.why:find("^recheck") and "recheck" or "aborted")
|
||||
end
|
||||
elseif k == "trade_round" and self.step == "pick" then
|
||||
self:resetRound()
|
||||
end
|
||||
end
|
||||
|
||||
function Ctl:handleTxn(e)
|
||||
if e.kind == "done" then
|
||||
local final = self.model.peer and self.model.peer.report
|
||||
local evolvedName
|
||||
if e.evolved and self.myData then
|
||||
local view = self.adapter:pack(self.adapter:at(self.model.mine.ref))
|
||||
local v = view and Project.read(view, self.myData)
|
||||
evolvedName = v and self.myData.species[v.national] and self.myData.species[v.national].name
|
||||
end
|
||||
self.result = { species = final and final.speciesName, evolvedName = evolvedName }
|
||||
self:go("done")
|
||||
elseif e.kind == "aborted" then
|
||||
self:close("aborted")
|
||||
elseif e.kind == "failed" then
|
||||
self:close("closed_other")
|
||||
end
|
||||
end
|
||||
|
||||
function Ctl:poll(dt)
|
||||
if self.done then return {} end
|
||||
local p = self.prep
|
||||
if p then
|
||||
for _, e in ipairs(p:poll()) do self:handlePrep(e) end
|
||||
end
|
||||
for _, e in ipairs(self.txn:pump(dt or 0)) do self:handleTxn(e) end
|
||||
if self.step ~= "closed" and self.step ~= "trading" and self.step ~= "done" and p then
|
||||
self:receivePeer()
|
||||
if (self.step == "wait") and self:canConfirm() then self:go("confirm") end
|
||||
if self.step == "confirm" and not self:canConfirm() then self:go("wait") end
|
||||
if self.step == "ready" and not p.mine.ready and p.state == "prep" then
|
||||
self.notice = say("changed")
|
||||
self:go(self:canConfirm() and "confirm" or "wait")
|
||||
end
|
||||
if p.state == "closed" and self.step ~= "closed" then self:handlePrep({ kind = "closed", why = p.closed and p.closed.why }) end
|
||||
end
|
||||
local out = self.events
|
||||
self.events = {}
|
||||
return out
|
||||
end
|
||||
|
||||
function Ctl:choose(it)
|
||||
if not it then return end
|
||||
local id = it.id
|
||||
if it.disabled then return end
|
||||
if id == "stop" or id == "leave" then
|
||||
if self.step ~= "closed" and self.step ~= "done" then self.prep:cancel("cancel") end
|
||||
self:finish()
|
||||
elseif id == "mon" then
|
||||
self.model:choose(it.arg)
|
||||
self:go("offer")
|
||||
elseif id == "back" then
|
||||
if self.step == "moves" then self:go("offer") else self:go("pick") end
|
||||
elseif id == "moves" then
|
||||
self:go("moves")
|
||||
elseif id == "move" then
|
||||
self.model:stage(it.arg.slot, it.arg.move)
|
||||
self:go("offer")
|
||||
elseif id == "remove" then
|
||||
self.model:stage(it.arg.slot, false)
|
||||
self:go("offer")
|
||||
elseif id == "offer" then
|
||||
if self:sendOffer() then self:go("wait") end
|
||||
elseif id == "change" then
|
||||
self.notice = nil
|
||||
self:go("pick")
|
||||
elseif id == "trade" then
|
||||
local digest = self.txn:readyDigest()
|
||||
if digest and self.prep:ready(digest) then
|
||||
self.notice = nil
|
||||
self:go("ready")
|
||||
end
|
||||
elseif id == "again" then
|
||||
self:resetRound()
|
||||
self:go("pick")
|
||||
end
|
||||
end
|
||||
|
||||
function Ctl:input(key)
|
||||
if self.done then return end
|
||||
local pg = self:page()
|
||||
local n = #pg.items
|
||||
if key == "up" then
|
||||
if n > 0 then self.cursor = self.cursor > 1 and self.cursor - 1 or n end
|
||||
elseif key == "down" then
|
||||
if n > 0 then self.cursor = self.cursor < n and self.cursor + 1 or 1 end
|
||||
elseif key == "left" then
|
||||
self.scroll = math.max(0, self.scroll - 1)
|
||||
elseif key == "right" then
|
||||
self.scroll = self.scroll + 1
|
||||
elseif key == "a" then
|
||||
self:choose(pg.items[self.cursor])
|
||||
elseif key == "b" then
|
||||
if self.step == "moves" then self:go("offer")
|
||||
elseif self.step == "offer" then self:go("pick")
|
||||
elseif self.step == "closed" then self:finish()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Ctl:finish()
|
||||
if self.done then return end
|
||||
self.done = true
|
||||
self.txn:close()
|
||||
end
|
||||
|
||||
function Open.controller(game, room, prep, opts)
|
||||
opts = opts or {}
|
||||
return Ctl.new({ game = game, prep = prep, version = opts.version, adapter = opts.adapter, roomId = opts.roomId,
|
||||
opponent = opts.opponent or Open.opponent(room, prep) })
|
||||
end
|
||||
|
||||
local function isActivity(v)
|
||||
return type(v) == "table" and v.prep ~= nil and type(v.finish) == "function" and v.game ~= nil
|
||||
end
|
||||
|
||||
function Open.trade(game, room, prep, onDone, opts)
|
||||
local act
|
||||
if isActivity(game) then
|
||||
act = game
|
||||
opts = room or {}
|
||||
game, room, prep = act.game, act.room, act.prep
|
||||
local peer = act.peer or {}
|
||||
opts.opponent = opts.opponent or { name = peer.name, version = peer.game, gen = peer.gen }
|
||||
onDone = function(result)
|
||||
if prep and prep.open and prep:open() then
|
||||
if not result then prep:cancel("cancel") end
|
||||
prep:leave()
|
||||
end
|
||||
act:finish(result and "traded" or "cancel")
|
||||
end
|
||||
end
|
||||
local ctl = Open.controller(game, room, prep, opts)
|
||||
local Renderer = require(Open.RENDERERS[ctl.gen] or Open.RENDERERS[1])
|
||||
local screen = Renderer.open(game, ctl, function()
|
||||
if onDone then onDone(ctl.result) end
|
||||
end)
|
||||
return screen, ctl
|
||||
end
|
||||
|
||||
Open.ACTIVITIES = { "src.ui.gen2.union.Activity", "src.ui.union.gen1.Activity" }
|
||||
|
||||
function Open.register(Activity)
|
||||
if type(Activity) ~= "table" then return false end
|
||||
Activity.screens = Activity.screens or {}
|
||||
Activity.screens.trade = function(act) return Open.trade(act) end
|
||||
return true
|
||||
end
|
||||
|
||||
for _, path in ipairs(Open.ACTIVITIES) do
|
||||
if type(package.loaded[path]) == "table" then Open.register(package.loaded[path]) end
|
||||
end
|
||||
|
||||
return Open
|
||||
@@ -3980,13 +3980,10 @@ end
|
||||
-- for the original serial handshake; declining prints "Please come again!"
|
||||
function OverworldState:cableClubReceptionist(onDone)
|
||||
local t = Game.data.text
|
||||
if self.map.id == "PEWTER_POKECENTER" and self.pikachuPewterSleepScene then
|
||||
Game.stack:push(TextBox.new(Game,
|
||||
t._LooksContentText or Strings("PIKACHU looks\ncontent."), onDone))
|
||||
return
|
||||
end
|
||||
local welcome = t._CableClubNPCWelcomeText or romText(Game.data, "_CableClubNPCWelcomeText", "Welcome to the\nCable Club!")
|
||||
if not Game.save.flags.EVENT_GOT_POKEDEX then
|
||||
-- engine/link/cable_club_npc.asm:4
|
||||
if require("src.world.PikachuFollower").isFollowingDisabled(self)
|
||||
or not Game.save.flags.EVENT_GOT_POKEDEX then
|
||||
-- CableClubNPC .didNotConnect path before the pokedex
|
||||
Game.stack:push(TextBox.new(Game, welcome .. "\f"
|
||||
.. (t._CableClubNPCMakingPreparationsText
|
||||
|
||||
@@ -339,6 +339,8 @@ function PikachuFollower.onMapEntered(game, ow, opts, viaMapLoad)
|
||||
-- Pikachu off the normal trailing loop. A new map instance ends it.
|
||||
ow.pikachuBillsScene = nil
|
||||
ow.pikachuFanClubScene = nil
|
||||
-- home/overworld.asm:1895
|
||||
if viaMapLoad and not (opts and opts.keepPikachu) then ow.pikachuPewterSleepScene = nil end
|
||||
remove(ow)
|
||||
if not ModRuntime.call("world.follower.spawn", shouldSpawn, game, ow) then return end
|
||||
-- opts.keepPikachu is the follower a connection crossing kept alive:
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
local Assets = require("src.render.Assets")
|
||||
local Logger = require("src.core.Logger")
|
||||
local UnionRoomMap = require("src.world.gen1.UnionRoomMap")
|
||||
|
||||
local UnionCenters = {}
|
||||
|
||||
UnionCenters.FLOOR_2F = "POKECENTER_2F"
|
||||
UnionCenters.LABEL_2F = "Pokecenter2F"
|
||||
UnionCenters.UNION_ROOM = UnionRoomMap.MAP_ID
|
||||
UnionCenters.IMAGE = "assets/composed/pokecenter_union.png"
|
||||
UnionCenters.STAIR_TILESET = "REDS_HOUSE_1"
|
||||
UnionCenters.STAIR_SOURCE_TILES = { 10, 11, 12, 13, 26, 27, 28, 29 }
|
||||
UnionCenters.TEXT_LINK = "TEXT_POKECENTER2F_LINK_RECEPTIONIST"
|
||||
UnionCenters.TEXT_UNION = "TEXT_POKECENTER2F_UNION_RECEPTIONIST"
|
||||
UnionCenters.LINK_RECEPTIONIST = 1
|
||||
UnionCenters.UNION_RECEPTIONIST = 2
|
||||
UnionCenters.STAIRS_2F = { x = 13, y = 1 }
|
||||
UnionCenters.DOOR_2F = { x = 6, y = 1 }
|
||||
UnionCenters.DOOR_WARP_2F = 2
|
||||
UnionCenters.GATE_2F = { bx = 3, by = 1 }
|
||||
|
||||
UnionCenters.EXPECTED = {
|
||||
"VIRIDIAN_POKECENTER", "PEWTER_POKECENTER", "CERULEAN_POKECENTER",
|
||||
"MT_MOON_POKECENTER", "ROCK_TUNNEL_POKECENTER", "VERMILION_POKECENTER",
|
||||
"CELADON_POKECENTER", "LAVENDER_POKECENTER", "FUCHSIA_POKECENTER",
|
||||
"CINNABAR_POKECENTER", "SAFFRON_POKECENTER", "INDIGO_PLATEAU_LOBBY",
|
||||
}
|
||||
|
||||
local TILESETS = { POKECENTER = true, MART = true }
|
||||
local DESK_LEFT, DESK_RIGHT, DESK_DOOR, COUNTER = 34, 35, 13, 7
|
||||
local SRC = {
|
||||
floor = { 15, 0 }, wall = { 12, 0 }, pillar = { 1, 1 }, cap = { 5, 1 },
|
||||
counter = { 7, 0 }, gate = { 34, 0 }, pc = { 35, 3 },
|
||||
}
|
||||
local COUNTER_LEFT_END = 6
|
||||
|
||||
local registry = setmetatable({}, { __mode = "k" })
|
||||
|
||||
local function copy(t)
|
||||
if type(t) ~= "table" then return t end
|
||||
local out = {}
|
||||
for k, v in pairs(t) do out[k] = copy(v) end
|
||||
return out
|
||||
end
|
||||
|
||||
local function shallow(t)
|
||||
local out = {}
|
||||
for k, v in pairs(t or {}) do out[k] = v end
|
||||
return out
|
||||
end
|
||||
|
||||
local function inList(list, v)
|
||||
for _, x in ipairs(list or {}) do
|
||||
if x == v then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function blockAt(def, bx, by)
|
||||
if bx < 0 or by < 0 or bx >= def.width or by >= def.height then return nil end
|
||||
return def.blocks[by * def.width + bx + 1]
|
||||
end
|
||||
|
||||
local function setBlock(def, bx, by, id)
|
||||
def.blocks[by * def.width + bx + 1] = id
|
||||
end
|
||||
|
||||
local function quadOf(ts, block, cell)
|
||||
local b = ts.blocks[block + 1]
|
||||
if not b then return nil end
|
||||
local ox, oy = (cell % 2) * 2, math.floor(cell / 2) * 2
|
||||
local function at(x, y) return b[(oy + y) * 4 + ox + x + 1] end
|
||||
return { at(0, 0), at(1, 0), at(0, 1), at(1, 1) }
|
||||
end
|
||||
|
||||
local function assemble(tl, tr, bl, br)
|
||||
return {
|
||||
tl[1], tl[2], tr[1], tr[2],
|
||||
tl[3], tl[4], tr[3], tr[4],
|
||||
bl[1], bl[2], br[1], br[2],
|
||||
bl[3], bl[4], br[3], br[4],
|
||||
}
|
||||
end
|
||||
|
||||
function UnionCenters.findReceptionist(def, pointers)
|
||||
if type(def) ~= "table" or type(def.objects) ~= "table" then return nil end
|
||||
local entries = type(pointers) == "table" and pointers[def.label] or nil
|
||||
for i, o in ipairs(def.objects) do
|
||||
local entry = entries and entries[o.text]
|
||||
if o.sprite == "SPRITE_LINK_RECEPTIONIST" and type(entry) == "table" and entry.cableClub then
|
||||
return o, i, entry
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function pcTileAt(data, mapId, x, y)
|
||||
local extras = data.field and data.field.hiddenExtras
|
||||
for _, h in ipairs(extras and extras.pcTiles and extras.pcTiles[mapId] or {}) do
|
||||
if h.x == x and h.y == y then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function UnionCenters.plan(data, mapId)
|
||||
local def = data.maps and data.maps[mapId]
|
||||
if type(def) ~= "table" then return nil, "map missing" end
|
||||
if not TILESETS[def.tileset] then return nil, "tileset " .. tostring(def.tileset) end
|
||||
local ts = data.tilesets and data.tilesets[def.tileset]
|
||||
if not ts then return nil, "tileset data missing" end
|
||||
local obj, objIndex = UnionCenters.findReceptionist(def, data.text_pointers)
|
||||
if not obj then return nil, "no cable club receptionist" end
|
||||
local bc, br = math.floor(obj.x / 2), math.floor(obj.y / 2)
|
||||
if obj.x % 2 ~= 1 or obj.y % 2 ~= 0 then
|
||||
return nil, ("receptionist at %d,%d is not the desk gap"):format(obj.x, obj.y)
|
||||
end
|
||||
local want = {
|
||||
{ bc, br, DESK_LEFT }, { bc + 1, br, DESK_RIGHT },
|
||||
{ bc, br - 1, DESK_DOOR }, { bc + 1, br - 1, DESK_DOOR },
|
||||
{ bc - 1, br, COUNTER },
|
||||
}
|
||||
for _, w in ipairs(want) do
|
||||
local got = blockAt(def, w[1], w[2])
|
||||
if got ~= w[3] then
|
||||
return nil, ("block %d,%d is %s, expected %d"):format(w[1], w[2], tostring(got), w[3])
|
||||
end
|
||||
end
|
||||
local pcX, pcY = bc * 2 + 3, br * 2 + 1
|
||||
if not pcTileAt(data, mapId, pcX, pcY) then
|
||||
return nil, ("no PC hidden tile at %d,%d"):format(pcX, pcY)
|
||||
end
|
||||
local ax0, ax1, ay0, ay1 = bc * 2 + 1, bc * 2 + 3, br * 2 - 1, br * 2 + 1
|
||||
for _, o in ipairs(def.objects) do
|
||||
if o ~= obj and o.x >= ax0 and o.x <= ax1 and o.y >= ay0 and o.y <= ay1 then
|
||||
return nil, ("object %s stands in the stairs area"):format(tostring(o.name))
|
||||
end
|
||||
end
|
||||
for _, w in ipairs(def.warps or {}) do
|
||||
if w.x >= ax0 and w.x <= ax1 and w.y >= ay0 and w.y <= ay1 then
|
||||
return nil, ("warp at %d,%d overlaps the stairs area"):format(w.x, w.y)
|
||||
end
|
||||
end
|
||||
return {
|
||||
map = mapId, label = def.label, tileset = def.tileset,
|
||||
receptionist = { index = obj.index, objIndex = objIndex, x = obj.x, y = obj.y,
|
||||
text = obj.text, name = obj.name },
|
||||
front = { x = obj.x, y = obj.y + 1 },
|
||||
desk = { bx = bc, by = br },
|
||||
changes = {
|
||||
{ bx = bc, by = br - 1, from = DESK_DOOR, to = "pillarWall" },
|
||||
{ bx = bc + 1, by = br - 1, from = DESK_DOOR, to = "upStairs" },
|
||||
{ bx = bc, by = br, from = DESK_LEFT, to = "capFloor" },
|
||||
{ bx = bc + 1, by = br, from = DESK_RIGHT, to = "pcCorner" },
|
||||
},
|
||||
stairs = { x = bc * 2 + 3, y = br * 2 - 1 },
|
||||
pc = { x = pcX, y = pcY },
|
||||
}
|
||||
end
|
||||
|
||||
function UnionCenters.candidates(data)
|
||||
local seen, out = {}, {}
|
||||
for _, id in ipairs(UnionCenters.EXPECTED) do
|
||||
if data.maps and data.maps[id] then seen[id] = true out[#out + 1] = id end
|
||||
end
|
||||
local extra = {}
|
||||
for id, def in pairs(data.maps or {}) do
|
||||
if not seen[id] and type(def) == "table" and TILESETS[def.tileset]
|
||||
and UnionCenters.findReceptionist(def, data.text_pointers) then
|
||||
extra[#extra + 1] = id
|
||||
end
|
||||
end
|
||||
table.sort(extra)
|
||||
for _, id in ipairs(extra) do out[#out + 1] = id end
|
||||
return out
|
||||
end
|
||||
|
||||
local function composeImage(base, source, perRow, srcPerRow, first)
|
||||
return function()
|
||||
local baseData = Assets.imageData(base)
|
||||
local srcData = Assets.imageData(source)
|
||||
local w, h = baseData:getDimensions()
|
||||
local tiles = UnionCenters.STAIR_SOURCE_TILES
|
||||
local rows = math.ceil(#tiles / perRow)
|
||||
local out = love.image.newImageData(w, h + rows * 8)
|
||||
out:paste(baseData, 0, 0, 0, 0, w, h)
|
||||
for i, t in ipairs(tiles) do
|
||||
local d = first + i - 1
|
||||
out:paste(srcData, (d % perRow) * 8, math.floor(d / perRow) * 8,
|
||||
(t % srcPerRow) * 8, math.floor(t / srcPerRow) * 8, 8, 8)
|
||||
end
|
||||
return out
|
||||
end
|
||||
end
|
||||
|
||||
local function patchTileset(ts)
|
||||
local perRow = ts.tilesPerRow
|
||||
local first = (ts.imageWidth / 8) * (ts.imageHeight / 8)
|
||||
local q = {}
|
||||
for k, s in pairs(SRC) do
|
||||
q[k] = quadOf(ts, s[1], s[2])
|
||||
if not q[k] then return nil, "block " .. s[1] .. " missing" end
|
||||
end
|
||||
if not (inList(ts.walkable, q.floor[3]) and not inList(ts.walkable, q.pillar[3])
|
||||
and not inList(ts.walkable, q.cap[3]) and inList(ts.counterTiles, q.counter[3])) then
|
||||
return nil, "tileset collision does not match the vanilla center"
|
||||
end
|
||||
q.up = { first + 2, first + 3, first + 6, first + 7 }
|
||||
q.down = { first + 0, first + 1, first + 4, first + 5 }
|
||||
ts.image = UnionCenters.IMAGE
|
||||
ts.imageHeight = ts.imageHeight + math.ceil(#UnionCenters.STAIR_SOURCE_TILES / perRow) * 8
|
||||
ts.tileSources = ts.tileSources or {}
|
||||
for i, t in ipairs(UnionCenters.STAIR_SOURCE_TILES) do
|
||||
ts.tileSources[first + i - 1] = { tileset = UnionCenters.STAIR_TILESET, tile = t }
|
||||
end
|
||||
ts.walkable = ts.walkable or {}
|
||||
ts.warpTiles = ts.warpTiles or {}
|
||||
for _, t in ipairs({ q.up[3], q.down[3] }) do
|
||||
ts.walkable[#ts.walkable + 1] = t
|
||||
ts.warpTiles[#ts.warpTiles + 1] = t
|
||||
end
|
||||
local ids = {}
|
||||
local function add(name, b)
|
||||
ts.blocks[#ts.blocks + 1] = b
|
||||
ids[name] = #ts.blocks - 1
|
||||
end
|
||||
add("pillarWall", assemble(q.pillar, q.wall, q.pillar, q.floor))
|
||||
add("upStairs", assemble(q.wall, q.wall, q.floor, q.up))
|
||||
add("capFloor", assemble(q.cap, q.floor, q.floor, q.floor))
|
||||
add("pcCorner", assemble(q.floor, q.floor, q.floor, q.pc))
|
||||
add("downStairs", assemble(q.pillar, q.wall, q.pillar, q.down))
|
||||
add("gateClosed", assemble(q.gate, q.counter, q.floor, q.floor))
|
||||
add("gateOpen", assemble(q.floor, q.counter, q.floor, q.floor))
|
||||
ids.counterEnd = ids.capFloor
|
||||
ids.firstTile = first
|
||||
ids.upTile, ids.downTile = q.up[3], q.down[3]
|
||||
return ids
|
||||
end
|
||||
|
||||
local function build2F(ids, borderBlock, home)
|
||||
local W, D, F = 12, DESK_DOOR, 15
|
||||
local function receptionist(index, x, text, name)
|
||||
return { index = index, movement = "STAY", range = "DOWN",
|
||||
sprite = "SPRITE_LINK_RECEPTIONIST", x = x, y = 1,
|
||||
name = name, text = text }
|
||||
end
|
||||
return {
|
||||
id = UnionCenters.FLOOR_2F, label = UnionCenters.LABEL_2F, tileset = "POKECENTER",
|
||||
width = 7, height = 4, borderBlock = borderBlock,
|
||||
blocks = {
|
||||
W, W, W, D, W, D, ids.downStairs,
|
||||
COUNTER_LEFT_END, COUNTER, COUNTER, ids.gateClosed, COUNTER, ids.gateClosed, ids.counterEnd,
|
||||
F, F, F, F, F, F, F,
|
||||
14, F, F, F, F, F, 14,
|
||||
},
|
||||
warps = {
|
||||
{ x = UnionCenters.STAIRS_2F.x, y = UnionCenters.STAIRS_2F.y,
|
||||
destMap = home.map, destWarp = home.warp },
|
||||
{ x = UnionCenters.DOOR_2F.x, y = UnionCenters.DOOR_2F.y,
|
||||
destMap = UnionCenters.UNION_ROOM, destWarp = 1 },
|
||||
},
|
||||
objects = {
|
||||
receptionist(UnionCenters.LINK_RECEPTIONIST, 11, UnionCenters.TEXT_LINK,
|
||||
"POKECENTER2F_LINK_RECEPTIONIST"),
|
||||
receptionist(UnionCenters.UNION_RECEPTIONIST, 7, UnionCenters.TEXT_UNION,
|
||||
"POKECENTER2F_UNION_RECEPTIONIST"),
|
||||
},
|
||||
signs = {}, connections = {},
|
||||
}
|
||||
end
|
||||
|
||||
local function verify(def, plan, ids)
|
||||
for _, c in ipairs(plan.changes) do
|
||||
if blockAt(def, c.bx, c.by) ~= ids[c.to] then return false end
|
||||
end
|
||||
local w = def.warps[plan.warp]
|
||||
if not (w and w.x == plan.stairs.x and w.y == plan.stairs.y
|
||||
and w.destMap == UnionCenters.FLOOR_2F) then
|
||||
return false
|
||||
end
|
||||
for _, o in ipairs(def.objects) do
|
||||
if o.sprite == "SPRITE_LINK_RECEPTIONIST" and o.text == plan.receptionist.text then
|
||||
return false
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
|
||||
function UnionCenters.forData(data)
|
||||
return data and registry[data] or nil
|
||||
end
|
||||
|
||||
function UnionCenters.planFor(data, mapId)
|
||||
local r = UnionCenters.forData(data)
|
||||
return r and r.plans[mapId] or nil
|
||||
end
|
||||
|
||||
function UnionCenters.apply(data)
|
||||
local maps, tilesets = data.maps, data.tilesets
|
||||
if not (maps and tilesets) then return nil, "no map data" end
|
||||
if maps[UnionCenters.FLOOR_2F] then return registry[data], "already applied" end
|
||||
local stairs, club = tilesets[UnionCenters.STAIR_TILESET], tilesets[UnionRoomMap.TILESET]
|
||||
if not (stairs and club and tilesets.POKECENTER) then return nil, "tilesets missing" end
|
||||
local result = { plans = {}, order = {}, refused = {}, blocks = {} }
|
||||
local ok = {}
|
||||
for _, id in ipairs(UnionCenters.candidates(data)) do
|
||||
local plan, why = UnionCenters.plan(data, id)
|
||||
if plan then
|
||||
ok[#ok + 1] = plan
|
||||
else
|
||||
result.refused[id] = why
|
||||
end
|
||||
end
|
||||
if #ok == 0 then return nil, "no center matched" end
|
||||
|
||||
data.maps = shallow(maps)
|
||||
data.tilesets = shallow(tilesets)
|
||||
maps, tilesets = data.maps, data.tilesets
|
||||
local baseImage = tilesets.POKECENTER.image
|
||||
local patched = {}
|
||||
local function tilesetIds(name)
|
||||
if patched[name] ~= nil then return patched[name] end
|
||||
local ts = copy(tilesets[name])
|
||||
local ids, why = patchTileset(ts)
|
||||
if ids then
|
||||
tilesets[name] = ts
|
||||
result.blocks[name] = ids
|
||||
else
|
||||
Logger.warn("union room: tileset %s not patched: %s", name, why)
|
||||
end
|
||||
patched[name] = ids or false
|
||||
return ids
|
||||
end
|
||||
local pcIds = tilesetIds("POKECENTER")
|
||||
if not pcIds then return nil, "POKECENTER tileset did not match" end
|
||||
Assets.compose(UnionCenters.IMAGE,
|
||||
composeImage(baseImage, stairs.image, tilesets.POKECENTER.tilesPerRow,
|
||||
stairs.tilesPerRow, pcIds.firstTile))
|
||||
|
||||
for _, plan in ipairs(ok) do
|
||||
local ids = tilesetIds(plan.tileset)
|
||||
if not ids then
|
||||
result.refused[plan.map] = "tileset " .. plan.tileset .. " did not match"
|
||||
else
|
||||
local def = copy(maps[plan.map])
|
||||
for _, c in ipairs(plan.changes) do
|
||||
c.toId = ids[c.to]
|
||||
setBlock(def, c.bx, c.by, c.toId)
|
||||
end
|
||||
table.remove(def.objects, plan.receptionist.objIndex)
|
||||
def.warps[#def.warps + 1] = { x = plan.stairs.x, y = plan.stairs.y,
|
||||
destMap = UnionCenters.FLOOR_2F, destWarp = 1 }
|
||||
plan.warp = #def.warps
|
||||
plan.verified = verify(def, plan, ids)
|
||||
maps[plan.map] = def
|
||||
result.plans[plan.map] = plan
|
||||
result.order[#result.order + 1] = plan.map
|
||||
end
|
||||
end
|
||||
local home = result.plans[result.order[1]]
|
||||
local floor2 = build2F(pcIds, maps[home.map].borderBlock, home)
|
||||
maps[UnionCenters.FLOOR_2F] = floor2
|
||||
maps[UnionCenters.UNION_ROOM] = UnionRoomMap.build(
|
||||
maps.TRADE_CENTER and maps.TRADE_CENTER.borderBlock or 0,
|
||||
UnionCenters.FLOOR_2F, UnionCenters.DOOR_WARP_2F)
|
||||
result.gateClosed, result.gateOpen = pcIds.gateClosed, pcIds.gateOpen
|
||||
|
||||
local pointers = data.text_pointers
|
||||
local entry = pointers and pointers[home.label]
|
||||
entry = entry and entry[home.receptionist.text]
|
||||
if pointers then
|
||||
data.text_pointers = shallow(pointers)
|
||||
data.text_pointers[UnionCenters.LABEL_2F] = { [UnionCenters.TEXT_LINK] = copy(entry) }
|
||||
end
|
||||
local songs = data.audio and data.audio.mapSongs
|
||||
if songs then
|
||||
data.audio = shallow(data.audio)
|
||||
songs = shallow(songs)
|
||||
data.audio.mapSongs = songs
|
||||
songs[UnionCenters.FLOOR_2F] = songs[home.map]
|
||||
songs[UnionCenters.UNION_ROOM] = songs.TRADE_CENTER
|
||||
end
|
||||
|
||||
registry[data] = result
|
||||
local refused = {}
|
||||
for id, why in pairs(result.refused) do refused[#refused + 1] = id .. " (" .. why .. ")" end
|
||||
table.sort(refused)
|
||||
Logger.info("union room: %d centers patched%s", #result.order,
|
||||
#refused > 0 and (", refused: " .. table.concat(refused, ", ")) or "")
|
||||
return result
|
||||
end
|
||||
|
||||
function UnionCenters.seed(data, opts)
|
||||
local Setting = require("src.online.union.Setting")
|
||||
if not Setting.patchesOn(1, opts) then return nil, "setting off" end
|
||||
return UnionCenters.apply(data)
|
||||
end
|
||||
|
||||
return UnionCenters
|
||||
@@ -0,0 +1,62 @@
|
||||
local Plaza = require("src.core.game3.link.union_plaza_map")
|
||||
|
||||
local UnionRoomMap = {}
|
||||
|
||||
UnionRoomMap.MAP_ID = "UNION_ROOM"
|
||||
UnionRoomMap.LABEL = "UnionRoom"
|
||||
UnionRoomMap.TILESET = "CLUB"
|
||||
UnionRoomMap.WIDTH = 13
|
||||
UnionRoomMap.HEIGHT = 13
|
||||
UnionRoomMap.CAP = Plaza.CAP
|
||||
UnionRoomMap.EXIT_X, UnionRoomMap.EXIT_Y = 12, 25
|
||||
UnionRoomMap.EXITS = { { x = 12, y = 25 }, { x = 13, y = 25 } }
|
||||
|
||||
local WALL_LEFT, WALL, POSTER, WALL_RIGHT = 27, 3, 19, 23
|
||||
local FLOOR, CARPET = 10, 1
|
||||
|
||||
function UnionRoomMap.cellFor(slot)
|
||||
return Plaza.cellFor(slot)
|
||||
end
|
||||
|
||||
function UnionRoomMap.slotAt(x, y)
|
||||
return Plaza.slotAt(x, y)
|
||||
end
|
||||
|
||||
function UnionRoomMap.entry()
|
||||
return UnionRoomMap.EXIT_X, UnionRoomMap.EXIT_Y, "up"
|
||||
end
|
||||
|
||||
function UnionRoomMap.blocks()
|
||||
local w, h = UnionRoomMap.WIDTH, UnionRoomMap.HEIGHT
|
||||
local out = {}
|
||||
for by = 0, h - 1 do
|
||||
for bx = 0, w - 1 do
|
||||
local b = FLOOR
|
||||
if by == 0 then
|
||||
b = (bx == 0 and WALL_LEFT) or (bx == w - 1 and WALL_RIGHT)
|
||||
or (bx == 6 and POSTER) or WALL
|
||||
elseif by == h - 1 and bx == 6 then
|
||||
b = CARPET
|
||||
end
|
||||
out[by * w + bx + 1] = b
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
function UnionRoomMap.build(borderBlock, floor2Id, floor2DoorWarp)
|
||||
local warps = {}
|
||||
for i, e in ipairs(UnionRoomMap.EXITS) do
|
||||
warps[i] = { x = e.x, y = e.y, destMap = floor2Id, destWarp = floor2DoorWarp }
|
||||
end
|
||||
return {
|
||||
id = UnionRoomMap.MAP_ID, label = UnionRoomMap.LABEL,
|
||||
tileset = UnionRoomMap.TILESET,
|
||||
width = UnionRoomMap.WIDTH, height = UnionRoomMap.HEIGHT,
|
||||
borderBlock = borderBlock,
|
||||
blocks = UnionRoomMap.blocks(),
|
||||
warps = warps, objects = {}, signs = {}, connections = {},
|
||||
}
|
||||
end
|
||||
|
||||
return UnionRoomMap
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user