union for all

This commit is contained in:
bryanthaboi
2026-10-07 14:31:38 -04:00
parent c68ba87907
commit e69b408744
198 changed files with 34391 additions and 285 deletions
@@ -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
+4 -2
View File
@@ -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
+123
View File
@@ -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,
},
},
}
+5
View File
@@ -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
+197
View File
@@ -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.
+147
View File
@@ -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
+214
View File
@@ -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
+82
View File
@@ -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
+156
View File
@@ -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
+452
View File
@@ -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
+29
View File
@@ -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
+230
View File
@@ -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
+257
View File
@@ -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
+192
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
+5
View File
@@ -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()
+5
View File
@@ -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
View File
@@ -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
+6
View File
@@ -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
+8
View File
@@ -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
+1
View File
@@ -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)
+1
View File
@@ -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)
+9 -3
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
+9 -1
View File
@@ -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,
}
+110 -24
View File
@@ -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
+445 -8
View File
@@ -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"]
+175
View File
@@ -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
+14 -2
View File
@@ -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
+2 -2
View File
@@ -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)
+4 -2
View File
@@ -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",
+501
View File
@@ -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
+51
View File
@@ -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
+12
View File
@@ -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"),
+5 -1
View File
@@ -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
+248
View File
@@ -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
View File
@@ -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)
+56 -3
View File
@@ -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.")
+7
View File
@@ -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
+507
View File
@@ -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
+135
View File
@@ -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
+598
View File
@@ -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
+59
View File
@@ -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
+36
View File
@@ -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
+121
View File
@@ -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
+363
View File
@@ -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
+401
View File
@@ -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
+26
View File
@@ -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
+386
View File
@@ -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
+806
View File
@@ -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
+304
View File
@@ -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
+408
View File
@@ -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
+129
View File
@@ -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
+146
View File
@@ -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
+131
View File
@@ -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
+288
View File
@@ -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
+139
View File
@@ -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
+634
View File
@@ -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
View File
@@ -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
+6 -1
View File
@@ -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
+5 -2
View File
@@ -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
+59 -3
View File
@@ -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
+239
View File
@@ -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
+1
View File
@@ -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
+91 -4
View File
@@ -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
+7
View File
@@ -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
+1
View File
@@ -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
+144
View File
@@ -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
+110
View File
@@ -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
+57
View File
@@ -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
+149
View File
@@ -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
+90
View File
@@ -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
+86
View File
@@ -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
+171
View File
@@ -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
+95
View File
@@ -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
+138
View File
@@ -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
+55
View File
@@ -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
+102
View File
@@ -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
+106
View File
@@ -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
+113
View File
@@ -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
+123
View File
@@ -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
+60
View File
@@ -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
+59
View File
@@ -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
+141
View File
@@ -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
+144
View File
@@ -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
+89
View File
@@ -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
+636
View File
@@ -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
+3 -6
View File
@@ -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
+2
View File
@@ -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:
+398
View File
@@ -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
+62
View File
@@ -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