big online energy

This commit is contained in:
bryanthaboi
2026-08-28 10:14:44 -04:00
parent 2441996d45
commit 680db777a3
105 changed files with 21808 additions and 1755 deletions
+2 -1
View File
@@ -4905,7 +4905,8 @@ function BattleState:openReplacementMenu()
end
self:restoreMimicked(self.player)
local previous = self.player
self.player = makeBattler(self.data, mon, true, game.save)
self.player = makeBattler(self.data, mon, true,
self.kind ~= "link" and game.save or nil)
clearTrapping(self.enemy) -- SendOutMon clears foe trap
self:syncSides()
Runtime.emit("battle.battler_switched", {
+296 -58
View File
@@ -885,8 +885,13 @@ function Battle:orderOf(playerMove, enemyMove)
local playerClaw = playerEffect == "HELD_QUICK_CLAW"
local enemyClaw = enemyEffect == "HELD_QUICK_CLAW"
if playerClaw and enemyClaw then
if rand(self.random, 256) < enemyParam then return "enemy" end
if rand(self.random, 256) < playerParam then return "player" end
if self.mirrored then
if rand(self.random, 256) < playerParam then return "player" end
if rand(self.random, 256) < enemyParam then return "enemy" end
else
if rand(self.random, 256) < enemyParam then return "enemy" end
if rand(self.random, 256) < playerParam then return "player" end
end
elseif playerClaw then
if rand(self.random, 256) < playerParam then return "player" end
elseif enemyClaw then
@@ -897,7 +902,9 @@ function Battle:orderOf(playerMove, enemyMove)
if playerSpeed ~= enemySpeed then
return playerSpeed > enemySpeed and "player" or "enemy"
end
return rand(self.random, 2) == 0 and "player" or "enemy"
local playerFirst = rand(self.random, 2) == 0
if self.mirrored then playerFirst = not playerFirst end
return playerFirst and "player" or "enemy"
end
-- Gen 2 priority moves. data/moves/effects_priorities.asm keys off the move
@@ -1377,6 +1384,7 @@ Battle.AI_FAIL_STATUSES = {
-- engine/battle/effect_commands.asm:3615
function Battle:aiRandomFail(attacker, defender)
if self.linkBattle then return false end
if self:sideOf(attacker) ~= "enemy" then return false end
if self:volatile(defender).lockOn then return false end
return rand(self.random, 256) < 64
@@ -3260,6 +3268,11 @@ function Battle:resolveFaints()
end
if (self.enemy.hp or 0) <= 0 then
if self.linkBattle and self.enemyFaintAnnounced == self.enemy then
self.faintInterrupt = true
return false
end
self.enemyFaintAnnounced = self.enemy
self:emit({ kind = "faint", side = "enemy",
text = (self.wild and "Wild " or "") .. self:monName(self.enemy)
.. " fainted!" })
@@ -3287,6 +3300,11 @@ function Battle:resolveFaints()
self:endBattle("win")
return true
end
if self.linkBattle then
self.pendingEnemySwitch = true
self.faintInterrupt = true
return false
end
local previous = self.enemy
self:clearVolatile(self.enemy)
self.enemyIndex = nextIndex
@@ -3579,6 +3597,7 @@ end
-- IsAnyMonHoldingExpShare's `cp EXP_SHARE` does, and a fainted holder gets
-- nothing (the pass loop skips fainted mons).
function Battle:awardExperience(loser)
if self.linkBattle then return self:resetParticipants() end
local def = self:speciesDef(loser)
local participants = {}
@@ -4183,6 +4202,25 @@ end
-- a trainer's class decides how eager it is. Returns true when the turn was
-- spent on the switch or the item.
function Battle:enemyTrySwitchOrItem()
if not Runtime.wantsHook("battle.enemy_switch_or_item") then
return Battle.vanillaEnemySwitchOrItem(self)
end
local chosen = Runtime.call("battle.enemy_switch_or_item", function(battle)
return Battle.vanillaEnemySwitchOrItem(battle)
end, self)
if type(chosen) == "table" then
if chosen.kind == "switch" then
return self:switchEnemy(tonumber(chosen.index) or 0)
end
if chosen.kind == "item" then
return self:enemyUseItem(chosen.item)
end
return false
end
return chosen and true or false
end
function Battle.vanillaEnemySwitchOrItem(self)
if self.wild or not self.trainer then return false end
local attributes = self.trainer.attributes
if type(attributes) ~= "table" then return false end
@@ -4229,39 +4267,7 @@ function Battle:enemyTrySwitchOrItem()
})
if not trapped and target
and Ai.shouldSwitch(attributes, score, self.random) then
self:clearVolatile(self.enemy)
-- AI_Switch prints EnemyWithdrewText BEFORE it farcalls EnemySwitch
-- (engine/battle/ai/items.asm:685), so a rotation announces the mon
-- coming OFF the field as well as the one coming on; without it a
-- trainer swapping between two of the same species looked like nothing
-- had happened. The line is skipped only when Pursuit hit the mon on
-- its way out, which this port has no analogue for yet.
local outgoing = self.enemy
self:emit({ kind = "message",
text = (self.trainer.name or "TRAINER") .. " withdrew "
.. self:monName(outgoing) .. "!" })
self.enemyIndex = target
self.enemy = self.enemyParty[target]
-- AI_Switch (engine/battle/ai/items.asm:697)
self:resetParticipants()
-- ResetEnemyBattleVars (engine/battle/core.asm:3016) zeroes wCurEnemyMove
-- and wLastEnemyMove and NewEnemyMonStatus wipes the substatus bytes, so
-- the mon coming IN starts from an empty area -- the same pair of clears
-- Battle:switch makes for the player's side.
self:clearVolatile(self.enemy)
self.stages.enemy = Battle.newStages()
self:emit({ kind = "send", side = "enemy", mon = self.enemy,
hp = self.enemy.hp or 0, status = self.enemy.status or false,
level = self.enemy.level, experience = self.enemy.experience,
text = (self.trainer.name or "TRAINER") .. " sent out "
.. self:monName(self.enemy) .. "!" })
Runtime.emit("battle.battler_switched", {
battle = self, side = self:sideRecord(self.enemy), battler = self.enemy,
previous = outgoing,
})
self:breakTrapsOnSend(self.enemy)
self:spikesDamage(self.enemy)
return true
return self:switchEnemy(target)
end
-- AI_TryItem: only the trainer's highest-level mon is worth an item.
@@ -4277,9 +4283,42 @@ function Battle:enemyTrySwitchOrItem()
status = self.enemy.status,
enemyTurns = self:volatile(self.enemy).turnsTaken or 0,
})
return self:enemyUseItem(item)
end
-- (engine/battle/ai/items.asm:685), so a rotation announces the mon coming
function Battle:switchEnemy(index)
local mon = self.enemyParty and self.enemyParty[index]
if not mon or (mon.hp or 0) <= 0 or mon == self.enemy then return false end
self:clearVolatile(self.enemy)
local outgoing = self.enemy
local trainerName = (self.trainer and self.trainer.name) or "TRAINER"
self:emit({ kind = "message",
text = trainerName .. " withdrew " .. self:monName(outgoing) .. "!" })
self.enemyIndex = index
self.enemy = mon
-- AI_Switch (engine/battle/ai/items.asm:697)
self:resetParticipants()
-- ResetEnemyBattleVars (engine/battle/core.asm:3016) zeroes wCurEnemyMove
self:clearVolatile(self.enemy)
self.stages.enemy = Battle.newStages()
self:emit({ kind = "send", side = "enemy", mon = self.enemy,
hp = self.enemy.hp or 0, status = self.enemy.status or false,
level = self.enemy.level, experience = self.enemy.experience,
text = trainerName .. " sent out " .. self:monName(self.enemy) .. "!" })
Runtime.emit("battle.battler_switched", {
battle = self, side = self:sideRecord(self.enemy), battler = self.enemy,
previous = outgoing,
})
self:breakTrapsOnSend(self.enemy)
self:spikesDamage(self.enemy)
return true
end
function Battle:enemyUseItem(item)
if not item then return false end
-- Consume it, so a trainer with one Potion cannot drink it every turn.
for index, id in ipairs(self.trainer.items or {}) do
for index, id in ipairs((self.trainer and self.trainer.items) or {}) do
if id == item then table.remove(self.trainer.items, index) break end
end
local heal = Ai.HEAL_ITEMS[item]
@@ -4294,8 +4333,9 @@ function Battle:enemyTrySwitchOrItem()
self.enemy.status = nil
self:volatile(self.enemy).confuseCount = nil
end
self:emit({ kind = "message", text = (self.trainer.name or "TRAINER")
.. " used " .. item .. "!" })
self:emit({ kind = "message",
text = ((self.trainer and self.trainer.name) or "TRAINER")
.. " used " .. item .. "!" })
return true
end
@@ -4409,7 +4449,7 @@ end
-- { kind = "item", item = <id>, target = n } (handled by the caller, which
-- applies the effect and then calls this with kind = "item" so the enemy
-- still gets its turn)
local function runTurn(self, action)
local function runTurn(self, action, enemyAction)
if self.over then return self:takeEvents() end
self.turn = self.turn + 1
action = action or { kind = "move" }
@@ -4458,9 +4498,24 @@ local function runTurn(self, action)
if action.kind == "item" then self:cancelBide(self.player) end
-- AI_SwitchOrTryItem runs BEFORE the move is chosen: a trainer that decides
-- to rotate or drink a potion spends its whole turn on it.
local enemyActed = self:enemyTrySwitchOrItem()
local enemyMoveId = (not enemyActed) and self:enemyMove() or nil
local enemyActed, enemyMoveId
if enemyAction then
if enemyAction.kind == "switch" then
self:switchEnemy(tonumber(enemyAction.index) or 0)
enemyActed = true
elseif enemyAction.kind == "item" then
self:enemyUseItem(enemyAction.item)
enemyActed = true
elseif enemyAction.kind == "move" then
enemyActed = false
enemyMoveId = enemyAction.move
else
enemyActed = true
end
else
enemyActed = self:enemyTrySwitchOrItem()
enemyMoveId = (not enemyActed) and self:enemyMove() or nil
end
-- battle.turn_started, where BattleState:resolveTurn raises it on Gen 1:
-- once both sides have chosen and before either acts. Gen 1's action tables
@@ -4483,6 +4538,8 @@ local function runTurn(self, action)
local playerFirst
if action.kind == "skip" or action.kind == "item" then
playerFirst = true
elseif self.linkBattle and enemyActed then
playerFirst = false
elseif Runtime.wantsHook("battle.turn_order") then
-- battle.turn_order, the same hook BattleState:resolveTurn calls on Gen 1
-- and with the same five arguments: both battlers, both move records, and
@@ -4504,6 +4561,7 @@ local function runTurn(self, action)
local function playerAttack()
if action.kind ~= "move" then return end
if self.linkBattle and (self.player.hp or 0) <= 0 then return end
local move = action.move
-- An encored mon has no choice, whatever the menu said.
local forced = self:forcedMove(self.player)
@@ -4564,6 +4622,32 @@ local function runTurn(self, action)
-- TryEnemyFlee sits here in both of the cart's turn orders, ahead of the
-- enemy's move and behind the faint checks.
if self:tryEnemyFlee() then return end
if self.linkBattle then
local forced = self:forcedMove(self.enemy)
if forced then enemyMoveId = forced end
local stored = self:volatile(self.enemy).chargeMove
if stored then enemyMoveId = stored end
if not self:canAct(self.enemy, enemyMoveId) then return end
local charging = stored == enemyMoveId
or self:lockedInMove(self.enemy) == enemyMoveId
local bideLocked = self:fightLockedMove(self.enemy) == enemyMoveId
if not charging and not bideLocked
and not self:hasUsableMoves(self.enemy) then
self:emit({ kind = "message",
text = self:monName(self.enemy) .. " has no moves left!" })
enemyMoveId = Battle.STRUGGLE
end
if not enemyMoveId then enemyMoveId = Battle.STRUGGLE end
if self:moveDisabled(self.enemy, enemyMoveId) then
local state = self:volatile(self.enemy)
state.chargeMove, state.vanished = nil, nil
self:emit({ kind = "message", text = self:monName(self.enemy)
.. "'s " .. enemyMoveId .. " is DISABLED!" })
return
end
self:useMove(self.enemy, self.player, enemyMoveId)
return
end
if not enemyMoveId then
-- `.struggle` (engine/battle/core.asm:5630-5632) sets STRUGGLE and
-- finishes silently: BattleText_MonHasNoMovesLeft is text_ram
@@ -4640,22 +4724,24 @@ local function runTurn(self, action)
-- weather, then status chip and the Leech Seed / Curse residuals, then
-- the wrap ticks, then held items, then Future Sight and Perish Song,
-- then the screens and the per-turn counters.
local firstMon, secondMon = self.player, self.enemy
if self.mirrored then firstMon, secondMon = self.enemy, self.player end
self:tickWeather()
self:tickStatus(self.player)
self:tickSeedAndCurse(self.player)
self:tickStatus(self.enemy)
self:tickSeedAndCurse(self.enemy)
self:tickWrap(self.player)
self:tickWrap(self.enemy)
self:tickHeldItem(self.player)
self:tickHeldItem(self.enemy)
self:tickFutureSight(self.player)
self:tickFutureSight(self.enemy)
self:tickPerish(self.player)
self:tickPerish(self.enemy)
self:tickStatus(firstMon)
self:tickSeedAndCurse(firstMon)
self:tickStatus(secondMon)
self:tickSeedAndCurse(secondMon)
self:tickWrap(firstMon)
self:tickWrap(secondMon)
self:tickHeldItem(firstMon)
self:tickHeldItem(secondMon)
self:tickFutureSight(firstMon)
self:tickFutureSight(secondMon)
self:tickPerish(firstMon)
self:tickPerish(secondMon)
self:tickScreens()
self:tickCounters(self.player)
self:tickCounters(self.enemy)
self:tickCounters(firstMon)
self:tickCounters(secondMon)
self:resolveFaints()
return self:takeEvents()
end
@@ -4666,7 +4752,15 @@ end
-- was already over) opened nothing and so closes nothing, which is what keeps
-- the two events paired the way Gen 1's endOfTurn keeps them.
function Battle:takeTurn(action)
local events = runTurn(self, action)
return self:closeTurn(runTurn(self, action))
end
function Battle:takeLinkTurn(playerAction, enemyAction)
return self:closeTurn(runTurn(self, playerAction,
enemyAction or { kind = "skip" }))
end
function Battle:closeTurn(events)
if self.turnOpen then
self.turnOpen = nil
if Runtime.wants("battle.turn_ended") then
@@ -4941,6 +5035,150 @@ function Battle:tickHeldItem(mon)
end
end
function Battle:forcedReplacement(side, index)
index = tonumber(index)
if side == "enemy" then
local party = self.enemyParty or {}
local mon = index and party[index]
if not mon or (mon.hp or 0) <= 0 or mon.isEgg then
index = Battle.firstHealthy(party)
mon = index and party[index]
end
if not mon then return false end
self.pendingEnemySwitch = nil
local previous = self.enemy
self:clearVolatile(previous)
self.enemyIndex = index
self.enemy = mon
self:clearVolatile(mon)
self.stages.enemy = Battle.newStages()
self:emit({ kind = "send", side = "enemy", mon = mon, replacement = true,
hp = mon.hp or 0, status = mon.status or false,
level = mon.level, experience = mon.experience,
text = ((self.trainer and self.trainer.name) or "Foe") .. " sent out "
.. self:monName(mon) .. "!" })
Runtime.emit("battle.battler_switched", {
battle = self, side = self:sideRecord(mon), battler = mon,
previous = previous,
})
self:breakTrapsOnSend(mon)
self:spikesDamage(mon)
return true
end
local mon = index and self.party[index]
if not mon or (mon.hp or 0) <= 0 or mon.isEgg then
index = Battle.firstHealthy(self.party)
end
if not index then return false end
return self:switch(index)
end
Battle.LINK_STAGES = { "attack", "defense", "speed", "specialAttack",
"specialDefense", "accuracy", "evasion" }
Battle.LINK_VOLATILE = {
"bideStored", "bideTurns", "chargeMove", "confuseCount", "curled", "cursed",
"disabled", "disabledTurns", "encore", "encoreTurns", "endure", "flinched",
"focusEnergy", "futureSight", "futureSightDamage", "futureSightSide",
"identified", "lastMove", "leechSeed", "lockOn", "mist", "perish", "protect",
"protectCount", "rage", "rampCount", "rampMove", "rampageMove",
"rampageTurns", "recharge", "rolloutLock", "substitute", "tookThisTurn",
"transformed", "trapsTarget", "turnsTaken", "vanished", "wrapCount",
"wrapMoveId", "xAccuracy",
}
Battle.LINK_SCREENS = { "lightScreen", "reflect", "safeguard" }
local function linkOff(v)
return v == nil or v == false or v == 0
end
local function linkScalar(v)
if type(v) == "table" then return tostring(v.id or v.move or "?") end
if type(v) == "boolean" then return v and "T" or "F" end
return tostring(v)
end
local function linkPp(mon)
local out = {}
for i, mv in ipairs((mon and mon.moves) or {}) do
out[i] = ("%s=%s"):format(tostring(mv.id), tostring(mv.pp or 0))
end
return table.concat(out, ",")
end
local function linkStages(self, key)
local stages = (self.stages and self.stages[key]) or {}
local out = {}
for i, stat in ipairs(Battle.LINK_STAGES) do
out[i] = tostring(stages[stat] or 0)
end
return table.concat(out, ",")
end
local function linkActive(self, mon, key)
if not mon then return "-" end
return ("%s:%d:%s:%s:%s:%s"):format(tostring(mon.species), mon.hp or 0,
tostring(mon.status or false), linkStages(self, key), linkPp(mon),
tostring(mon.item or "-"))
end
local function linkVolatile(mon)
if not mon then return "-" end
local state = mon.volatile or {}
local out = {}
for _, field in ipairs(Battle.LINK_VOLATILE) do
if not linkOff(state[field]) then
out[#out + 1] = field .. "=" .. linkScalar(state[field])
end
end
if not linkOff(mon.statusTurns) then
out[#out + 1] = "statusTurns=" .. tostring(mon.statusTurns)
end
if not linkOff(mon.toxicCounter) then
out[#out + 1] = "toxicCounter=" .. tostring(mon.toxicCounter)
end
return table.concat(out, ",")
end
local function linkSide(self, key)
local screens = (self.screens and self.screens[key]) or {}
local out = { ((self.spikes or {})[key] and "spikes" or "-") }
for _, field in ipairs(Battle.LINK_SCREENS) do
out[#out + 1] = field .. "=" .. tostring(screens[field] or 0)
end
return table.concat(out, ",")
end
local function linkBench(party)
local out = {}
for i, mon in ipairs(party or {}) do
out[i] = ("%s:%d:%s:%s"):format(tostring(mon.species), mon.hp or 0,
tostring(mon.status or false), tostring(mon.item or "-"))
end
return table.concat(out, "|")
end
function Battle:linkSignature(role)
local hostIsPlayer = role ~= "guest"
local hostKey = hostIsPlayer and "player" or "enemy"
local guestKey = hostIsPlayer and "enemy" or "player"
local hostMon = hostIsPlayer and self.player or self.enemy
local guestMon = hostIsPlayer and self.enemy or self.player
local hostParty = hostIsPlayer and self.party or self.enemyParty
local guestParty = hostIsPlayer and self.enemyParty or self.party
return {
actives = linkActive(self, hostMon, hostKey) .. "|"
.. linkActive(self, guestMon, guestKey)
.. "|r" .. tostring(self.rngDraws or 0),
volatile = linkVolatile(hostMon) .. "|" .. linkVolatile(guestMon)
.. "|" .. linkSide(self, hostKey) .. "|" .. linkSide(self, guestKey)
.. "|w" .. tostring(self.weather or "-")
.. ":" .. tostring(self.weatherTurns or 0),
bench = linkBench(hostParty) .. "|" .. linkBench(guestParty),
}
end
Battle.Damage = Damage
Battle.Mon = Mon
+19 -20
View File
@@ -602,14 +602,14 @@ local function subscribe(game)
end)
end
-- Advertise (or clear, with code=nil) an online-match/tournament code as a
-- Discord join secret. LinkState/Tournament call this once they have a
-- real code from the relay, and clear it again once paired/started or the
-- hosting screen exits (an invite that's already full or gone is worse
-- than no invite). kind is "match" (default) or "tournament"; size/max are
-- the party.size Discord shows (default 1/2, a plain 1v1 room) -- a
-- tournament passes its live roster count and a generous cap instead, and
-- should call this again whenever the roster changes, not just once.
-- Advertise (or clear, with code=nil) a room code as a Discord join
-- secret. The launcher's ONLINE tab calls this once the relay has given it
-- a real code, and clears it again once the room fills or closes (an invite
-- that's already full or gone is worse than no invite). kind is "match"
-- (default) or "tournament"; size/max are the party.size Discord shows
-- (default 1/2, a plain 1v1 room) -- a tournament passes its live roster
-- count and a generous cap instead, and should call this again whenever the
-- roster changes, not just once.
function DiscordPresence.setJoinCode(code, kind, size, max)
pcall(function()
state.joinCode = code
@@ -632,23 +632,22 @@ local function handleJoinRequest(secret)
if not secret or secret == "" then return end
if state.activity == "battle" then return end
local game = state.game
if not game or not game.stack then return end
local top = game.stack:top()
local top = game and game.stack and game.stack:top()
if top and top.stage and top.net then return end -- already in a link session
local kindTag, code = secret:match("^(%a):(.+)$")
if not kindTag then kindTag, code = "m", secret end -- older/plain secret: assume match
Runtime.emit("discord.join_requested", { code = code, kind = kindTag })
if kindTag == "t" then
local ok, Tournament = pcall(require, "src.link.Tournament")
if ok and Tournament.newJoinOnline then
game.stack:push(Tournament.newJoinOnline(game, code))
end
else
local ok, LinkState = pcall(require, "src.link.LinkState")
if ok and LinkState.newJoinOnline then
game.stack:push(LinkState.newJoinOnline(game, code))
end
if game and not game.returnToLauncher then
print("[discord] no launcher to return to; ignoring join code " .. tostring(code))
return
end
local ok, Client = pcall(require, "src.online.Client")
if not ok or type(Client) ~= "table" or not Client.joinRoom then
print("[discord] online client unavailable; ignoring join code " .. tostring(code))
return
end
if game then game.returnToLauncher({ tab = "online", joinCode = code }) end
Client.joinRoom(code, "player")
end
-- non-blocking peek for an incoming ACTIVITY_JOIN dispatch. Unix (FFI)
+48 -8
View File
@@ -32,7 +32,9 @@ local function bootScreens(game)
return (boot and boot.screens) or {}
end
function Game:load()
function Game:load(opts)
opts = opts or {}
local arena = opts.arena
self.data = Data
self.sessionStartedAt = os.time()
Data:load()
@@ -42,7 +44,15 @@ function Game:load()
-- the rest of the game consumes. A broken mod is reported and skipped by
-- the loader without preventing the base game from booting.
self.mods = ModLoader.new()
self.mods:load(Data)
if arena then
self.mods:load(Data, {
mode = (arena.profile and arena.profile.kind == "cart")
and "cartOnly" or "disableAll",
cartId = opts.cartId,
})
else
self.mods:load(Data)
end
self.modStatus = self.mods:status()
-- render pipelines dispatch off the merged dataset; point them at the
-- one the mods just merged into before anything can draw a frame
@@ -102,7 +112,9 @@ function Game:load()
-- boot into the title screen (engine/movie/title.asm); NEW GAME runs
-- the Oak speech + naming, CONTINUE restores the save. The headless
-- autopilot skips straight into the overworld.
if os.getenv("POKEPORT_AUTOPILOT") then
if arena then
self:enterArena(arena)
elseif os.getenv("POKEPORT_AUTOPILOT") then
StateStack:push(OverworldState, self.save.player.map,
self.save.player.x, self.save.player.y, self.save.player.facing)
else
@@ -176,6 +188,28 @@ function Game:startNewGame(opts)
end
end
function Game:enterArena(spec)
local version = require("src.core.GameVersion").get()
if spec.slotId then pcall(SaveData.setActiveSlot, version, spec.slotId) end
local loaded = SaveData.load()
if loaded then
local activeMods = self.modStatus and self.modStatus.loaded
SaveData.runMigrations(loaded, self.mods and self.mods.migrations, activeMods)
self.saveReport = SaveData.validate(loaded, self.data)
self.save = loaded
loaded.startMenuIndex = nil
self.startMenuIndex = nil
self:adoptSave(loaded)
self:applyOptions(loaded.options)
local stamp = require("src.battle.BattleState").stampOT
for _, mon in ipairs(loaded.party or {}) do stamp(loaded, mon) end
else
Logger.warn("arena: save slot %s could not be loaded", tostring(spec.slotId))
end
self.linkSession = true
StateStack:push(require("src.ui.ArenaState").new(self, spec))
end
-- the title screen with its NEW GAME / CONTINUE wiring; used at boot
-- and by the START-menu QUIT confirmation
function Game:makeTitleState()
@@ -239,8 +273,12 @@ function Game:touchSkinHotkey(action, pressed)
end
end
function Game:breakLink(err)
Logger.error("link: torn down after an error\n%s", tostring(err))
function Game:breakLink(err, source)
if source == "engine" then
Logger.error("link: engine error, link torn down\n%s", tostring(err))
else
Logger.error("link: torn down after an error\n%s", tostring(err))
end
self.linkSession = nil
local net = self.linkNet
self.linkNet = nil
@@ -255,7 +293,9 @@ function Game:breakLink(err)
pcall(function()
local Strings = require("src.core.Strings")
local TextBox = require("src.render.TextBox")
stack:push(TextBox.new(self, Strings("The link was\nbroken.")))
stack:push(TextBox.new(self, source == "engine"
and Strings("Something broke\nduring the link.")
or Strings("The link was\nbroken.")))
end)
end
@@ -288,7 +328,7 @@ function Game:step(dt)
if self.linkNet and not self.linkNet.closed then
local ok, err = pcall(self.linkNet.update, self.linkNet)
if not ok then
self:breakLink(err)
self:breakLink(err, "transport")
return
end
end
@@ -296,7 +336,7 @@ function Game:step(dt)
local ok, err = xpcall(function() self.stack:update(dt) end,
function(e) return debug.traceback(tostring(e), 2) end)
if not ok then
self:breakLink(err)
self:breakLink(err, "engine")
return
end
else
+18 -3
View File
@@ -205,6 +205,12 @@ function Game2:adoptSave(save, seedBuckets)
loader.modSave = save.modData
end
function Game2:enterArena(spec)
self.phase = "boot"
self.stack:clear()
self.stack:push(require("src.ui.gen2.ArenaState").new(self, spec))
end
function Game2:startWorld()
if self.world and self.world.map then
self.phase = "play"
@@ -886,7 +892,9 @@ function Game2:writeSave()
return Save.save(save)
end
function Game2:load()
function Game2:load(opts)
opts = opts or {}
local arena = opts.arena
Input:init()
-- Before applyOptions, which is what pushes options.touchControls into it:
-- init() decides whether the platform wants the overlay at all and loads the
@@ -990,13 +998,18 @@ function Game2:load()
-- self.data and the ones without report instead of silently vanishing. The
-- whole thing is behind a pcall so a mod problem can never cost Gold its
-- boot.
local modOpts = arena and {
mode = (arena.profile and arena.profile.kind == "cart")
and "cartOnly" or "disableAll",
cartId = opts.cartId,
} or nil
local ok, loader = pcall(function()
local mods = require("src.mods.Loader").new()
-- the live service owner, before load: mod.world and mod.input resolve
-- through this, and without it the facade would bind to the Gen 1
-- src/core/Game.lua singleton that a Gold boot never loads
mods.game = self
mods:load(self.data)
mods:load(self.data, modOpts)
return mods
end)
if ok and loader then
@@ -1072,7 +1085,9 @@ function Game2:load()
-- Drivers that walk the overworld skip boot cinema so smoke stays stable.
-- POKEPORT_BOOT_CINEMA=1 opts back in, which is how the boot-chain driver
-- exercises copyright -> title -> intro menu -> Oak -> naming.
if os.getenv("POKEPORT_DRIVER")
if arena then
self:enterArena(arena)
elseif os.getenv("POKEPORT_DRIVER")
and os.getenv("POKEPORT_BOOT_CINEMA") ~= "1" then
self:startWorld()
else
+1 -1
View File
@@ -140,7 +140,7 @@ end
local function appVersion()
local version = clean(Version.engine)
if not version or version == "0.0.0" or version == "0.0.0-dev" then return "" end
if not version or version == "0.0.0" or version:match("^0%.0%.0%-dev") then return "" end
return version
end
+65
View File
@@ -22,6 +22,71 @@ local Version = {
cache = "rom-cache-v5", -- ROM import cache generation (RomImporter marker)
}
local DEV_PLACEHOLDER = "0.0.0-dev"
local function readFile(path)
local handle = io.open(path, "rb")
if not handle then return nil end
local text = handle:read("*a")
handle:close()
if type(text) ~= "string" or text == "" then return nil end
return text
end
local function commitFromBuildInfo()
local fs = rawget(_G, "love") and love.filesystem
if not (fs and fs.read) then return nil end
local ok, text = pcall(fs.read, "build-info.json")
if not ok or type(text) ~= "string" then return nil end
return text:match('"gitCommitFull"%s*:%s*"(%x+)"')
or text:match('"gitCommit"%s*:%s*"(%x+)"')
end
local function commitFromGit(source, read)
read = read or readFile
if type(source) ~= "string" or source == "" then return nil end
local gitDir = source .. "/.git"
local head = read(gitDir .. "/HEAD")
if not head then
local pointer = read(gitDir)
local target = pointer and pointer:match("^gitdir:%s*(%S+)")
if not target then return nil end
if not target:match("^/") then target = source .. "/" .. target end
gitDir = target
head = read(gitDir .. "/HEAD")
if not head then return nil end
end
local ref = head:match("^ref:%s*(%S+)")
if not ref then return head:match("^(%x+)") end
local hash = read(gitDir .. "/" .. ref)
if hash then return hash:match("^(%x+)") end
local packed = read(gitDir .. "/packed-refs")
if packed then
return packed:match("(%x+)%s+" .. ref:gsub("%p", "%%%0"))
end
return nil
end
function Version.devEngine(engine, source, read)
engine = tostring(engine or "")
if engine ~= DEV_PLACEHOLDER then return engine end
local commit = commitFromBuildInfo() or commitFromGit(source, read)
if type(commit) ~= "string" or #commit < 7 then return engine end
return engine .. "+" .. commit:sub(1, 12):lower()
end
do
local ok, derived = pcall(function()
local source
local fs = rawget(_G, "love") and love.filesystem
if fs and fs.getSource then source = fs.getSource() end
return Version.devEngine(Version.engine, source)
end)
if ok and type(derived) == "string" and derived ~= "" then
Version.engine = derived
end
end
-- True for the working-tree placeholder and any stamped "-dev" pre-release.
-- Shipped builds get a bare X.Y.Z from CI and are not "dev" here.
function Version.isDev()
+4 -1
View File
@@ -382,7 +382,9 @@ function Evolution.apply(data, mon, entry)
local level = mon.level or 1
-- CalcMonStats runs through the one builder, so an evolved mon's stats can
-- never disagree with a freshly built one's.
local stats = Mon.stats(def.baseStats, mon.dvs, level, mon.statExp)
-- engine/pokemon/evolve.asm:261-264
local statExp = mon.statExp
local stats = Mon.stats(def.baseStats, mon.dvs, level, statExp)
local previousMax = mon.maxHp or (mon.stats and mon.stats.hp) or stats.hp
local hp = (mon.hp or previousMax) + (stats.hp - previousMax)
-- The cart does not clamp; the bound only matters for data where an
@@ -398,6 +400,7 @@ function Evolution.apply(data, mon, entry)
local evolved = Mon.new(data, species, level, {
dvs = mon.dvs,
statExp = statExp,
moves = mon.moves,
hp = hp,
item = heldItem,
+17
View File
@@ -714,6 +714,23 @@ function LauncherSettings.open(hooks, version)
{ title = Strings("OPTIONS"), rows = coreRows(opts, hooks) },
}
end
sections[#sections + 1] = {
title = Strings("LAUNCHER"),
rows = {
{
label = Strings("REDUCE MOTION"),
value = function()
return opts.reduceMotion == true and Strings("ON") or Strings("OFF")
end,
step = function()
opts.reduceMotion = not (opts.reduceMotion == true)
local okT, Transition = pcall(require, "src.ui.kit.Transition")
if okT then Transition.reduceMotion = opts.reduceMotion end
return true
end,
},
},
}
-- Mod options are generation-agnostic (the manager's options_schema
-- contract), so they ride along either way.
for _, mod in ipairs(discoverModSchemas(opts)) do
+197 -38
View File
@@ -31,6 +31,7 @@ local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme")
local Layout = require("src.ui.kit.Layout")
local Loader = require("src.ui.kit.Loader")
local Transition = require("src.ui.kit.Transition")
local GameVersion = require("src.core.GameVersion")
local Version = require("src.core.Version")
local Strings = require("src.core.Strings")
@@ -116,6 +117,7 @@ function LauncherView.detach(imp)
elseif imp and imp._restoreNxPointerBridge then
pcall(imp._restoreNxPointerBridge, imp)
end
Transition.reset()
if not imp or not imp._flex then return end
imp._flex = nil
if love.keyboard and love.keyboard.setKeyRepeat then
@@ -330,9 +332,15 @@ function LauncherView.keypressed(imp, key)
Kit.navigate(key)
return true
end
if imp._ringArmed and (key == "return" or key == "kpenter" or key == "space") then
Kit.activateFocused()
return true
if key == "return" or key == "kpenter" or key == "space" then
if imp._ringArmed then
Kit.activateFocused()
return true
end
if imp._tradeModal then
require("src.import.OnlinePanel").tradeModalAction(imp, "a")
return true
end
end
return false
end
@@ -370,6 +378,10 @@ local function rowHit(imp, x, y, w, h, selected, key, action)
return ink
end
LauncherView.btn = btn
LauncherView.rowHit = rowHit
LauncherView.queueAction = queueAction
-- ------------------------------------------------------- shared widgets
-- Read-only text field. The importer owns the string (its textinput /
@@ -1292,13 +1304,29 @@ local GAME_TABS = {
color = PAL.railCrystal, label = "Crystal" },
}
local function drawOnlineGlyph(x, y, w, h, hot)
local box = math.min(w, h)
local bx = x + (w - box) / 2
local by = y + (h - box) / 2
local pad = box * 0.24
local d = box - 2 * pad
local ink = hot and PAL.inverse or PAL.ink
local lw = math.max(1, math.floor(Kit.scale + 0.5))
Theme.strokeRounded(bx + pad, by + pad, d, d, ink, 1, lw, d / 2)
Theme.fill(bx + pad, by + pad + d / 2 - lw / 2, d, lw, ink, 1)
Theme.strokeRounded(bx + pad + d * 0.30, by + pad, d * 0.40, d, ink, 0.8, lw,
d * 0.20)
end
local HEADER_TABS = {
{ id = "mods", key = "tab-mods" },
{ id = "find", key = "tab-find" },
{ id = "online", key = "tab-online", glyph = true, beta = true },
{ id = "skins", key = "tab-skins", glyph = true, beta = true },
{ id = "bug", key = "tab-bug" },
}
LauncherView.HEADER_TABS = HEADER_TABS
local BETA_TAG_OPTS = { fill = true, bold = true, ink = PAL.inverse }
local function drawBetaTag(x, y, w, h)
@@ -1314,7 +1342,7 @@ end
for _, t in ipairs(HEADER_TABS) do
t.opts = { face = "tab", font = "tab", color = t.color, letter = t.letter }
if t.glyph then
t.opts.drawFn = drawSkinGlyph
t.opts.drawFn = t.id == "online" and drawOnlineGlyph or drawSkinGlyph
end
end
@@ -1467,7 +1495,6 @@ local function buildHeader(imp, m)
for _, t in ipairs(tabs) do
if t.id == "mods" then t.icon = imp._modsIcon end
if t.id == "find" then t.icon = imp._findIcon end
if t.id == "bug" then t.icon = imp._bugIcon end
end
local tabH = m.chip
local tx = m.x + m.pad
@@ -1519,11 +1546,7 @@ local function buildHeader(imp, m)
if t.beta then overlayBeta(tx, ty, w, tabH, m) end
tx = tx + w + tabGap
end
-- The bug-report chip sits LAST, past the sync chip.
local bugTab
for _, t in ipairs(tabs) do
if t.id == "bug" then bugTab = t else headerTab(t) end
end
for _, t in ipairs(tabs) do headerTab(t) end
do
local w = tabH
@@ -1542,7 +1565,6 @@ local function buildHeader(imp, m)
end
tx = tx + w + tabGap
end
if bugTab then headerTab(bugTab) end
-- `ty` has walked down with the wraps, so this stays correct at one row too.
y = ty + tabH + math.floor(8 * m.s)
@@ -1550,6 +1572,8 @@ local function buildHeader(imp, m)
return y + math.floor(10 * m.s)
end
LauncherView.textField = textField
-- The state of the self-updater, shown in the launcher footer.
-- Returns status, label, action, glow.
function LauncherView._updateControl(imp)
@@ -3517,23 +3541,58 @@ end
-- underneath is inert, then lowers it for its own panel. There is no
-- z-ordered hit test, so this ordering IS the z-order.
local modalRect = { x = 0, y = 0, w = 0, h = 0 }
local modalTransform = false
local function modalAmount()
local L = Transition.get("modal")
if not L then return 1 end
local p = Transition.progress("modal")
if L.kind == "out" then return 1 - p end
return p
end
local function modalPanel(m, w, h)
-- A near-opaque scrim, not a tint. At 0.82 the header and the wordmark
-- still read through the settings panel and the screen looked like two
-- layouts fighting rather than one panel on top ("the settings is covering
-- the logo"); at this weight the page behind is present but plainly out of
-- play, which is what a modal is supposed to say.
Theme.fill(0, 0, m.W, m.H, PAL.bg, 0.93)
local amt = modalAmount()
Theme.fill(0, 0, m.W, m.H, PAL.bg, 0.93 * amt)
Kit.blockClicks = true
local pw = math.floor(math.min(w, m.W - 2 * m.pad))
local ph = math.floor(math.min(h, m.H - 2 * m.pad))
local px = math.floor((m.W - pw) / 2)
local py = math.floor((m.H - ph) / 2)
modalRect.x, modalRect.y, modalRect.w, modalRect.h = px, py, pw, ph
if amt < 1 and love.graphics and love.graphics.push then
local s = 0.96 + 0.04 * amt
love.graphics.push()
love.graphics.translate(m.W / 2, m.H / 2)
love.graphics.scale(s, s)
love.graphics.translate(-m.W / 2, -m.H / 2)
modalTransform = true
end
Kit.card(px, py, pw, ph, true)
Kit.blockClicks = false
Kit.blockClicks = Transition.active()
return px, py, pw, ph
end
local function endModalDraw(m)
if not modalTransform then return end
modalTransform = false
local fade = 1 - modalAmount()
if fade > 0 then
local pad = math.floor(6 * m.s)
Theme.fillRounded(modalRect.x - pad, modalRect.y - pad,
modalRect.w + 2 * pad, modalRect.h + 2 * pad, PAL.bg, fade, 10)
end
love.graphics.pop()
end
LauncherView.modalPanel = modalPanel
-- Shared prompt: title, read-only field over the importer's text, buttons.
local function buildPrompt(imp, m, spec)
local pad = math.floor(18 * m.s)
@@ -4795,6 +4854,19 @@ local function buildGameManageModal(imp, m)
action = function() imp._gameManage = nil end })
end
local function buildBugModal(imp, m)
local pad = math.floor(18 * m.s)
local w = math.floor(560 * m.s)
local btnH = math.max(m.btnH, Kit.tapMin())
local h = math.floor(math.min(m.H - 2 * m.pad, 420 * m.s))
local px, py, pw, ph = modalPanel(m, w, h)
buildBugPanel(imp, px + pad, py + pad, pw - 2 * pad,
ph - 2 * pad - btnH - m.gap, m)
btn(imp, px + pad, py + ph - pad - btnH, pw - 2 * pad, btnH, "bug-close",
Strings("Close"), { kind = "primary", font = "small",
action = function() imp:_closeBugPanel() end })
end
local function buildSettingsModal(imp, m)
local model = imp._settings
local SaveData = require("src.core.SaveData")
@@ -4809,6 +4881,11 @@ local function buildSettingsModal(imp, m)
btn(imp, px + pw - pad - cw, cy, cw, m.btnH, "settings-close",
Strings("Close"), { font = "small",
action = function() imp:_closeSettings() end })
local bugLabel = Strings("Troubleshooting")
local bw = Kit.textWidth("small", bugLabel) + math.floor(24 * m.s)
btn(imp, px + pw - pad - cw - m.gap - bw, cy, bw, m.btnH, "settings-bug",
bugLabel, { kind = "ghost", font = "small",
action = function() imp:_openBugPanel() end })
cy = cy + math.max(Kit.textHeight("stat"), m.btnH) + math.floor(6 * m.s)
-- WRAPPED, not printed flat: on a portrait panel this line ran straight off
-- the right edge and the sentence ended mid-word at the card border.
@@ -5645,6 +5722,19 @@ end
-- panels underneath a modal must run with Kit.blockClicks already raised or
-- a click on the scrim lands on whatever button happens to be behind it.
-- Keep this list in sync with buildModals below.
local MODAL_KEYS = {
"_profileRenamePrompt", "_profileSavePrompt", "_settingsText",
"_cartSave", "_bugModal", "_settings", "_rename", "_indexPrompt",
"_modConfirm", "_appPatchNotes", "_modReleaseNotes", "_findDetails",
"_modVersions", "_modDepResolver", "_modImports", "_singleProfileActions",
"_profilesPopup", "_modHeaderActionsPopup", "_sortPopup", "_gamePopup",
"_cartPopup", "_modScopePopup", "_filterPopup", "_indexManage",
"_syncModal", "_pcPicker", "_tradeModal", "_skinActions", "_modActions",
"_findEntry", "_gameManage",
}
LauncherView.MODAL_KEYS = MODAL_KEYS
local function modalUp(imp)
if Kit.FileBrowser and Kit.FileBrowser.active then return true end
if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then return true end
@@ -5656,9 +5746,22 @@ local function modalUp(imp)
or imp._gamePopup or imp._cartPopup or imp._cartSave
or imp._modActions or imp._modImports or imp._skinActions or imp._syncModal
or imp._modHeaderActionsPopup or imp._profilesPopup or imp._singleProfileActions or imp._profileSavePrompt
or imp._profileRenamePrompt or imp._findEntry or imp._gameManage) ~= nil
or imp._profileRenamePrompt or imp._findEntry or imp._gameManage
or imp._tradeModal or imp._bugModal or imp._pcPicker) ~= nil
end
local function modalKey(imp)
if not modalUp(imp) then return nil end
if Kit.FileBrowser and Kit.FileBrowser.active then return "filebrowser" end
if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then return "vkeyboard" end
for i = 1, #MODAL_KEYS do
if imp[MODAL_KEYS[i]] ~= nil then return MODAL_KEYS[i] end
end
return nil
end
LauncherView.modalKey = modalKey
local function buildModals(imp, m)
if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then
Kit.VirtualKeyboard.draw(m)
@@ -5730,6 +5833,7 @@ local function buildModals(imp, m)
return true
end
if imp._cartSave then buildCartSaveModal(imp, m) return true end
if imp._bugModal then buildBugModal(imp, m) return true end
if imp._settings then buildSettingsModal(imp, m) return true end
if imp._rename then
buildPrompt(imp, m, {
@@ -5810,6 +5914,12 @@ local function buildModals(imp, m)
if imp._filterPopup then buildFilterModal(imp, m) return true end
if imp._indexManage then buildIndexesModal(imp, m) return true end
if imp._syncModal then buildSyncModal(imp, m) return true end
if imp._pcPicker then
return require("src.import.online.PcPicker").draw(imp, m) == true
end
if imp._tradeModal then
return require("src.import.online.TradeScreen").drawModal(imp, m) == true
end
if imp._skinActions then buildSkinActionsModal(imp, m) return true end
if imp._modActions then buildModActionsModal(imp, m) return true end
if imp._findEntry then buildFindEntryModal(imp, m) return true end
@@ -5894,6 +6004,43 @@ local function minPanelHeight(m)
return math.floor((m.twoCol and 340 or 470) * m.s)
end
local function buildTabPanel(imp, x, y, w, availH, budgetH, m)
if imp.tab == "mods" then
return buildModsPanel(imp, x, y, w, budgetH, m)
elseif imp.tab == "find" then
return buildFindPanel(imp, x, y, w, budgetH, m)
elseif imp.tab == "skins" then
return buildSkinsPanel(imp, x, y, w, budgetH, m)
elseif imp.tab == "online" then
return require("src.import.OnlinePanel")
.buildOnlinePanel(imp, x, y, w, budgetH, m)
end
return buildGamePanel(imp, x, y, w, availH, m, imp.tab, budgetH)
end
local function drawTabLayer(imp, tabId, x, contentY, w, viewH, availH, m, dx)
local prevTab = imp.tab
imp.tab = tabId
local at = tabScrollAt(imp)
local maxAt = tabScrollMax(imp)
if dx ~= 0 then
love.graphics.push()
love.graphics.translate(dx, 0)
end
local py = Kit.scrollBegin(x, contentY, w, viewH, at, maxAt)
local budgetH = math.floor(viewH * (1 + PANEL_OVERSCAN))
local panelW = math.max(0, w - Kit.scrollGutter(m.s))
local contentH = buildTabPanel(imp, x, py, panelW, availH, budgetH, m)
contentH = contentH or availH
imp._tabContentH[tabId] = contentH
imp._tabScrollMax[tabId] = Kit.scrollExtent(contentH, viewH)
at = clamp(at, 0, tabScrollMax(imp))
imp._tabScroll[tabId] = at
Kit.scrollEnd(x, contentY, w, viewH, at, maxAt)
if dx ~= 0 then love.graphics.pop() end
imp.tab = prevTab
end
function LauncherView.draw(imp)
ensureState(imp)
local m = Layout.metrics(1200)
@@ -5929,9 +6076,28 @@ function LauncherView.draw(imp)
-- Everything from here to buildModals sits UNDER any open modal, so the
-- whole stage draws shielded (no clicks, no hover, no focus ring) while
-- one is up; buildModals lowers the shield for the modal's own controls.
imp._modalUpNow = modalUp(imp)
local mkey = modalKey(imp)
if mkey ~= imp._modalKey then
if mkey then
if imp._modalHeld then
Transition.clear("modal")
imp._modalHeld = nil
end
Transition.start("modal", "in")
elseif imp._modalKey and imp._modalLastValue ~= nil then
if Transition.start("modal", "out") then
imp._modalHeld = { key = imp._modalKey, value = imp._modalLastValue }
end
end
imp._modalKey = mkey
end
imp._modalLastValue = mkey and imp[mkey] or nil
if imp._modalHeld and not Transition.active("modal") then
imp._modalHeld = nil
end
imp._modalUpNow = mkey ~= nil
if imp._modalUpNow then imp:_blurPanelFields() end
Kit.blockClicks = imp._modalUpNow
Kit.blockClicks = imp._modalUpNow or Transition.active()
local step = Kit.scrollStep(m.s)
do
@@ -5983,32 +6149,25 @@ function LauncherView.draw(imp)
if not rect then rect = {}; imp._tabRegionRect = rect end
rect.x, rect.y, rect.w, rect.h = x, contentY, w, viewH
local at = tabScrollAt(imp)
local py = Kit.scrollBegin(x, contentY, w, viewH, at, tabScrollMax(imp))
local budgetH = math.floor(viewH * (1 + PANEL_OVERSCAN))
local panelW = math.max(0, w - Kit.scrollGutter(m.s))
local contentH
if imp.tab == "mods" then
contentH = buildModsPanel(imp, x, py, panelW, budgetH, m)
elseif imp.tab == "find" then
contentH = buildFindPanel(imp, x, py, panelW, budgetH, m)
elseif imp.tab == "skins" then
contentH = buildSkinsPanel(imp, x, py, panelW, budgetH, m)
elseif imp.tab == "bug" then
contentH = buildBugPanel(imp, x, py, panelW, budgetH, m)
local tabTr = Transition.get("tabs")
if tabTr and tabTr.from and tabTr.from ~= tabKeyOf(imp) then
local p = Transition.progress("tabs")
local dir = tabTr.dir >= 0 and 1 or -1
pcall(drawTabLayer, imp, tabTr.from, x, contentY, w, viewH, availH, m,
-dir * p * w)
drawTabLayer(imp, tabKeyOf(imp), x, contentY, w, viewH, availH, m,
dir * (1 - p) * w)
else
contentH = buildGamePanel(imp, x, py, panelW, availH, m, imp.tab, budgetH)
drawTabLayer(imp, tabKeyOf(imp), x, contentY, w, viewH, availH, m, 0)
end
contentH = contentH or availH
imp._tabContentH[tabKeyOf(imp)] = contentH
imp._tabScrollMax[tabKeyOf(imp)] = Kit.scrollExtent(contentH, viewH)
at = clamp(at, 0, tabScrollMax(imp))
imp._tabScroll[tabKeyOf(imp)] = at
Kit.scrollEnd(x, contentY, w, viewH, at, tabScrollMax(imp))
buildFooter(imp, m, footY)
Kit.blockClicks = false
Kit.blockClicks = Transition.active()
local held = imp._modalHeld
if held then imp[held.key] = held.value end
buildModals(imp, m)
endModalDraw(m)
if held then imp[held.key] = nil end
-- The loader sits above everything, including modals: it is the one thing
-- that must never be clicked around.
File diff suppressed because it is too large Load Diff
+304 -3
View File
@@ -39,7 +39,25 @@ local function cartOfScope(scope)
end
local CacheContract = require("src.import.CacheContract")
local Transition = require("src.ui.kit.Transition")
local COMMUNITY_URL = "https://bois.icu"
local TAB_ORDER, TAB_AT = {}, {}
local function tabOrder()
if #TAB_ORDER == 0 then
for _, version in ipairs(GameVersion.ORDER) do
TAB_ORDER[#TAB_ORDER + 1] = version
end
local okView, View = pcall(require, "src.import.LauncherView")
if okView then
for _, t in ipairs(View.HEADER_TABS or {}) do
TAB_ORDER[#TAB_ORDER + 1] = t.id
end
end
for i, id in ipairs(TAB_ORDER) do TAB_AT[id] = i end
end
return TAB_AT
end
local TRUST_WARNING = "if you did not get this from bryanthaboi's github " ..
"or a link from the discord that bryanthaboi himself posted, just know " ..
"it might have been tampered with. go to the discord to verify " ..
@@ -1447,7 +1465,24 @@ function RomImporter.new(onComplete, opts)
and ".gbc" or ".gb")
end
RomImporter.syncAndroidShortcuts()
Transition.reset()
Transition.armed = false
local okMotion, motionOpts = pcall(function()
return require("src.core.SaveData").loadOptions()
end)
Transition.reduceMotion = os.getenv("POKEPORT_REDUCE_MOTION") == "1"
or opts.reduceMotion == true
or (okMotion and type(motionOpts) == "table"
and motionOpts.reduceMotion == true) or false
self:_applyLastVersionTab()
if type(opts.initialTab) == "string" and opts.initialTab ~= "" then
self:_switchTab(opts.initialTab)
end
if type(opts.joinCode) == "string" and opts.joinCode ~= "" then
self:_switchTab("online")
local okOnline, OnlinePanel = pcall(require, "src.import.OnlinePanel")
if okOnline then pcall(OnlinePanel.deepLink, self, opts.joinCode, "player") end
end
self:_queueBaseRomScan()
-- Android: import a save-dir .gb/.gbc that is not yet ready (USB drop or a
@@ -2819,6 +2854,10 @@ end
function RomImporter:update(dt)
self.pulse = self.pulse + dt
if not Transition.armed then
self._motionFrames = (self._motionFrames or 0) + 1
if self._motionFrames > 1 then Transition.armed = true end
end
if self._launchFade then
self._launchFade.elapsed = self._launchFade.elapsed + dt
if self._launchFade.elapsed >= self._launchFade.duration then
@@ -2848,6 +2887,7 @@ function RomImporter:update(dt)
self:_pumpFindThumbs()
self:_pumpSkinFetch()
self:_pumpSync(dt)
self:_pumpOnline(dt)
self:_pumpModCheck()
self:_pumpModInstall()
self:_pumpCartInstall()
@@ -2861,6 +2901,9 @@ function RomImporter:update(dt)
if shot and not self._shotDone then
if not self._shotSized then
self._shotSized = true
if os.getenv("POKEPORT_REDUCE_MOTION") ~= "0" then
Transition.reduceMotion = true
end
local w, h = (os.getenv("POKEPORT_WIN") or ""):match("^(%d+)x(%d+)$")
if w and love.window and love.window.setMode then
pcall(love.window.setMode, tonumber(w), tonumber(h),
@@ -2886,6 +2929,9 @@ function RomImporter:update(dt)
if os.getenv("POKEPORT_LAUNCHER_SETTINGS") == "1" then
self:_openSettings()
end
if os.getenv("POKEPORT_LAUNCHER_BUG") == "1" then
self:_openBugPanel()
end
-- POKEPORT_LAUNCHER_FIND_KIND=mods|carts picks which half of the feed
-- the FIND tab is browsing; the switch is otherwise only a click.
local findKind = os.getenv("POKEPORT_LAUNCHER_FIND_KIND")
@@ -2927,7 +2973,48 @@ function RomImporter:update(dt)
{ resizable = true })
end
end
if self._shotTimer > 1.2 then
local motion = os.getenv("POKEPORT_LAUNCHER_MOTION")
if motion and motion ~= "" and not self._shotMotionAt
and self._shotTimer > 0.9 then
self._shotMotionAt = 0
local ms = tonumber(os.getenv("POKEPORT_MOTION_MS") or "")
if ms and ms > 0 then
for kind in pairs(Transition.DURATIONS) do
Transition.DURATIONS[kind] = ms / 1000
end
end
if motion:match("^go:") then
local okOP, OP = pcall(require, "src.import.OnlinePanel")
if okOP then OP.go(self, motion:sub(4)) end
elseif motion == "modal-close" then
self._modConfirm = nil
elseif motion == "modal" then
self._modConfirm = {
kind = "update", title = "Install mod", yesLabel = "Install",
lines = { "JP GREEN - Poketto Monsuta Midori v0.4.4",
"by bryanthaboi",
"Mods are not reviewed - trust the author." },
}
else
self:_switchTab(motion)
end
end
if self._shotMotionAt then
self._shotMotionAt = self._shotMotionAt + 1
local want = tonumber(os.getenv("POKEPORT_LAUNCHER_SHOT_SEQ") or "4") or 4
if self._shotMotionAt <= want then
local path = (shot:gsub("%.png$", ""))
.. "-" .. self._shotMotionAt .. ".png"
love.graphics.captureScreenshot(function(imagedata)
local fd = imagedata:encode("png")
local f = io.open(path, "wb")
if f then f:write(fd:getString()) f:close() end
end)
else
self._shotDone = true
love.event.quit()
end
elseif self._shotTimer > 1.2 then
self._shotDone = true
love.graphics.captureScreenshot(function(imagedata)
local fd = imagedata:encode("png")
@@ -3090,7 +3177,7 @@ function RomImporter:resumeAfterOverlay()
end
function RomImporter:_cycleTab(delta)
local order = { "mods", "find", "skins", "bug" }
local order = { "mods", "find", "skins" }
for i = #GameVersion.ORDER, 1, -1 do
table.insert(order, 1, GameVersion.ORDER[i])
end
@@ -3173,7 +3260,8 @@ function RomImporter:_updatePadCursor(dt)
end
function RomImporter:gamepadpressed(_, button)
local action = (GamepadMap.mapLauncherButton and GamepadMap.mapLauncherButton(button)) or button
if Transition.active() then return end
local action =(GamepadMap.mapLauncherButton and GamepadMap.mapLauncherButton(button)) or button
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
if okKit then
if Kit.VirtualKeyboard and Kit.VirtualKeyboard.active then
@@ -3314,6 +3402,11 @@ function RomImporter:gamepadpressed(_, button)
end
return
elseif action == "b" then
if self._bugModal then self:_closeBugPanel(); return end
if self.tab == "online" then
local okOnline, OnlinePanel = pcall(require, "src.import.OnlinePanel")
if okOnline and OnlinePanel.back(self) then return end
end
if self._indexPrompt then self._indexPrompt = nil; self:_disarmTextInput(); return
elseif self._rename then self._rename = nil; self:_disarmTextInput(); return
elseif self._profileRenamePrompt then self._profileRenamePrompt = nil; self:_disarmTextInput(); return
@@ -3341,6 +3434,11 @@ function RomImporter:gamepadpressed(_, button)
self._padCursor.x, self._padCursor.y)
end
elseif action == "b" then
if self._bugModal then self:_closeBugPanel(); return end
if self.tab == "online" then
local okOnline, OnlinePanel = pcall(require, "src.import.OnlinePanel")
if okOnline and OnlinePanel.back(self) then return end
end
if self._indexPrompt then self._indexPrompt = nil; self:_disarmTextInput(); return
elseif self._rename then self._rename = nil; self:_disarmTextInput(); return
elseif self._profileRenamePrompt then self._profileRenamePrompt = nil; self:_disarmTextInput(); return
@@ -3665,10 +3763,19 @@ end
-- the soft keyboard drop with the panel they belonged to; each tab's scroll
-- offset persists inside the view's per-tab scroll container.
function RomImporter:_switchTab(id)
if id == "bug" then return self:_openBugPanel() end
if self.tab and self.tab ~= id then
local at = tabOrder()
Transition.start("tabs", "tab", {
dir = ((at[id] or 0) >= (at[self.tab] or 0)) and 1 or -1,
from = self.tab, to = id,
})
end
self.tab = id
if id ~= "find" then self._findVisibleEntries = nil end
self._findSearchFocus = false
self._skinUrlFocus = false
self._onlineFocus = nil
self:_disarmTextInput()
-- the skins list is cheap and can change behind the launcher's back
-- (an export, a hand-dropped folder), so re-read it on every visit
@@ -3985,6 +4092,13 @@ function RomImporter:_syncSupported()
return self._syncTransportOk
end
function RomImporter:_pumpOnline(dt)
if not self._online then return end
local ok, OnlinePanel = pcall(require, "src.import.OnlinePanel")
if not ok then return end
pcall(OnlinePanel.update, self, dt)
end
function RomImporter:_pumpSync(dt)
if self._sync == nil then
if not self.launcher or self._syncBooted then return end
@@ -4199,6 +4313,17 @@ end
-- ------- settings gear (options.lua + enabled mods' option schemas)
function RomImporter:_openBugPanel()
self._settings = nil
self._bugModal = true
return true
end
function RomImporter:_closeBugPanel()
self._bugModal = nil
return true
end
function RomImporter:_openSettings()
-- The touch-overlay editor is a host screen, so the model gets it as a
-- hook rather than reaching for main.lua's handler itself. Closing the
@@ -4352,6 +4477,7 @@ function RomImporter:fileUrl(path)
end
function RomImporter:keypressed(key)
if Transition.active() then return end
local okKit, Kit = pcall(require, "src.ui.kit.Kit")
if okKit then
if Kit.FileBrowser and Kit.FileBrowser.active then
@@ -4501,6 +4627,41 @@ function RomImporter:keypressed(key)
if key == "escape" then self._cartPopup = nil end
return
end
if self._bugModal and key == "escape" then
self:_closeBugPanel()
return
end
if self._pcPicker and key == "escape" and not self._onlineFocus then
require("src.import.OnlinePanel").pcClose(self)
return
end
if self._onlineFocus then
local OnlinePanel = require("src.import.OnlinePanel")
local st = OnlinePanel.state(self)
local field = self._onlineFocus
if key == "backspace" then
if field == "online-name" then
st.nameDraft = utf8Back(st.nameDraft or "")
elseif field == "online-note" then
st.note = utf8Back(st.note or "")
elseif field == "online-code" then
st.joinCode = utf8Back(st.joinCode or "")
elseif field == "online-trade-code" then
local tr = OnlinePanel.tradeState(self)
tr.code = utf8Back(tr.code or "")
elseif field == OnlinePanel.PC_FIELD then
local pc = OnlinePanel.pcPicker(self)
OnlinePanel.pcQuery(self, utf8Back((pc and pc.query) or ""))
end
elseif key == "return" or key == "kpenter" then
self:_commitOnlineField()
elseif key == "escape" then
self._onlineFocus = nil
st.nameDraft = nil
self:_disarmTextInput()
end
return
end
if self._skinUrlFocus then
if key == "backspace" then
self.skinUrl = utf8Back(self.skinUrl or "")
@@ -4526,6 +4687,10 @@ function RomImporter:keypressed(key)
end
return
end
if key == "escape" and self.tab == "online" then
local okOnline, OnlinePanel = pcall(require, "src.import.OnlinePanel")
if okOnline and OnlinePanel.back(self) then return end
end
if self.workState == "working" then return end
-- Keyboard focus ring: arrows move it, Enter activates it -- but only once
-- the arrows have been used, so the long-standing "Enter plays the visible
@@ -5124,7 +5289,40 @@ function RomImporter:_disarmTextInput()
end
end
function RomImporter:_focusOnlineField(key)
self._onlineFocus = key
local OnlinePanel = require("src.import.OnlinePanel")
local st = OnlinePanel.state(self)
if key == "online-name" then st.nameDraft = st.name or "" end
self:_armTextInput()
end
function RomImporter:_commitOnlineField()
local key = self._onlineFocus
self._onlineFocus = nil
self:_disarmTextInput()
if key ~= "online-name" then return end
local OnlinePanel = require("src.import.OnlinePanel")
local st = OnlinePanel.state(self)
OnlinePanel.setName(self, st.nameDraft or "")
st.nameDraft = nil
end
function RomImporter:playArena(version, cartId, spec)
if not version or not spec then return false end
self._handedOff = true
resetPointerCursor(self)
if self._flex then require("src.import.LauncherView").detach(self) end
if self.onComplete then self.onComplete(version, cartId, { arena = spec }) end
return true
end
function RomImporter:_blurPanelFields()
if self._pcPicker then return end
if self._onlineFocus then
self:_commitOnlineField()
return
end
if not (self._findSearchFocus or self._skinUrlFocus) then return end
if self._indexPrompt or self._rename or self._settingsText
or self._profileSavePrompt or self._profileRenamePrompt
@@ -5193,6 +5391,24 @@ function RomImporter:textinput(text)
utf8Cap(self._indexPrompt.text .. text:gsub("%s", ""), MAX_INDEX_URL)
return
end
if self._onlineFocus then
local OnlinePanel = require("src.import.OnlinePanel")
local st = OnlinePanel.state(self)
if self._onlineFocus == "online-name" then
st.nameDraft = OnlinePanel.sanitizeName((st.nameDraft or "") .. text)
elseif self._onlineFocus == "online-note" then
st.note = utf8Cap((st.note or "") .. text, OnlinePanel.NOTE_MAX)
elseif self._onlineFocus == "online-code" then
st.joinCode = OnlinePanel.sanitizeCode((st.joinCode or "") .. text)
elseif self._onlineFocus == "online-trade-code" then
local tr = OnlinePanel.tradeState(self)
tr.code = OnlinePanel.sanitizeCode((tr.code or "") .. text)
elseif self._onlineFocus == OnlinePanel.PC_FIELD then
local pc = OnlinePanel.pcPicker(self)
OnlinePanel.pcQuery(self, ((pc and pc.query) or "") .. text)
end
return
end
if self._skinUrlFocus then
self.skinUrl = utf8Cap((self.skinUrl or "") .. text:gsub("%s", ""),
MAX_SKIN_URL)
@@ -6015,6 +6231,91 @@ function RomImporter:_installCartPins(version, id)
self:pressInstallCartMods(version)
end
-- ------- one-call cart install for the ONLINE tab
function RomImporter:installCartForOnline(cartId, version, onDone)
if type(cartId) ~= "string" or cartId == "" then
if onDone then onDone(false, "no cart was named") end
return false
end
if self._onlineCart then return false end
self._onlineCart = { id = cartId, base = version, done = onDone,
stage = "index" }
self:_ensureFind()
return true
end
function RomImporter:_finishOnlineCart(ok, text)
local job = self._onlineCart
self._onlineCart = nil
if job and job.done then pcall(job.done, ok, text) end
end
function RomImporter:pumpOnlineCartInstall()
local job = self._onlineCart
if not job then return end
local CartStore = require("src.carts.CartStore")
if job.stage == "index" then
if self._findFetch then return end
if not self.findLoaded then
self:_ensureFind()
return
end
local entry
for _, row in ipairs((self.findIndex and self.findIndex.carts) or {}) do
if row.id == job.id then
entry = row
break
end
end
if not entry then
return self:_finishOnlineCart(false,
Strings("No mod index lists a cart called %s.", tostring(job.id)))
end
if self._modInstall or self._cartInstall then return end
self.findNotice = nil
self:_beginCartInstall(entry)
if not self._cartInstall then
return self:_finishOnlineCart(false,
tostring((self.findNotice and self.findNotice.text)
or "that cart has no download"))
end
job.stage = "download"
return
end
if job.stage == "download" then
if self._cartInstall then return end
local ok, cart = pcall(CartStore.get, job.id)
if not ok or type(cart) ~= "table" then
return self:_finishOnlineCart(false,
tostring((self.findNotice and self.findNotice.text)
or "that cart did not install"))
end
self._modConfirm = nil
job.base = cart.base
job.title = cart.title or cart.id
if #self:_cartPinsMissing(cart.base, job.id) == 0 then
return self:_finishOnlineCart(true,
Strings("%s is installed.", tostring(job.title)))
end
self:_installCartPins(cart.base, job.id)
job.stage = "pins"
return
end
if job.stage == "pins" then
if self._cartFill then return end
if #self:_cartPinsMissing(job.base, job.id) > 0 then
return self:_finishOnlineCart(false,
tostring((self.cartFillNotice and self.cartFillNotice.text)
or "some of this cart's mods could not be installed"))
end
return self:_finishOnlineCart(true,
Strings("%s and its mods are installed.", tostring(job.title or job.id)))
end
end
-- Start an async pull for a single dependency
function RomImporter:_startDepPull(dep)
if not dep or not dep.github then return end
+142
View File
@@ -0,0 +1,142 @@
local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme")
local Strings = require("src.core.Strings")
local Ui = require("src.import.online.Ui")
local PAL = Theme.PAL
local Home = {}
local function LV() return require("src.import.LauncherView") end
local function OP() return require("src.import.OnlinePanel") end
local function Client() return require("src.online.Client") end
local STATE_TEXT = {
online = "Connected to the lobby.",
connecting = "Connecting...",
reconnecting = "Reconnecting...",
}
function Home.identity(imp, x, y, w, m)
local OnlinePanel = OP()
local st = OnlinePanel.state(imp)
local client = Client()
local pad, gap = Ui.pads(m)
local rowH = math.max(m.btnH, Kit.tapMin())
local state = client.state()
local you = client.you()
local verified = type(you) == "table" and you.verified == true
local offline = state == "offline" or state == "error"
local statusLine = st.status
if not statusLine then
if state == "error" then
statusLine = tostring(client.error() or "Disconnected.")
else
statusLine = Strings(STATE_TEXT[state]
or "Offline. Connect to see who is playing.")
end
end
local statusH = Kit.wrapHeight("small", statusLine, w - 2 * pad, 2)
local stacked = not m.twoCol
local cardH = pad + Kit.textHeight("small") + gap + rowH + gap + statusH + pad
if stacked then cardH = cardH + rowH + gap end
Kit.card(x, y, w, cardH)
local cx, cy = x + pad, y + pad
local inner = w - 2 * pad
cy = cy + Ui.label(Strings("Display name"), cx, cy) + gap
local connectLabel = offline and Strings("Connect") or Strings("Disconnect")
local connectW = math.min(math.floor(inner * (stacked and 0.5 or 0.4)),
Kit.textWidth("small", connectLabel) + math.floor(36 * m.s))
local pillW = math.floor(64 * m.s)
local badgeW = verified and (Kit.textWidth("micro", "VERIFIED")
+ math.floor(18 * m.s)) or 0
local fieldW = stacked and inner
or math.max(math.floor(60 * m.s),
inner - connectW - badgeW - pillW - 3 * gap)
local editing = imp._onlineFocus == "online-name"
local shown = editing and (st.nameDraft or "") or OnlinePanel.ensureName(imp)
Ui.field(imp, cx, cy, fieldW, rowH, "online-name", shown,
Strings("Display name"), editing, function(text)
OnlinePanel.setName(imp, text)
end)
if stacked then cy = cy + rowH + gap end
local px = stacked and cx or (cx + fieldW + gap)
local badgeY = cy + math.floor((rowH - 18 * m.s) / 2)
if verified then
Kit.tag(px, badgeY, badgeW, math.floor(18 * m.s), "VERIFIED", PAL.green,
{ fill = true, bold = true })
px = px + badgeW + gap
end
Kit.tag(px, badgeY, pillW, math.floor(18 * m.s),
offline and "OFFLINE" or "ONLINE", offline and PAL.line or PAL.green)
LV().btn(imp, x + w - pad - connectW, cy, connectW, rowH, "online-connect",
connectLabel, {
kind = offline and "primary" or "ghost", font = "small",
enabled = st.job == nil,
action = function()
if offline then
OnlinePanel.connect(imp)
else
OnlinePanel.disconnect(imp)
end
end })
cy = cy + rowH + gap
Kit.textWrapped("small", statusLine, cx, cy, inner,
st.statusOk and PAL.green or (state == "error" and PAL.red or PAL.muted), 2)
return cardH
end
local CARDS = {
{ id = "play", title = "Play",
note = "Find an open lobby or host your own battle." },
{ id = "watch", title = "Watch",
note = "Open matches and running tournaments to spectate." },
{ id = "trade", title = "Trade",
note = "Swap POKeMON with another save or another trainer." },
}
function Home.draw(imp, x, y, w, availH, m)
local OnlinePanel = OP()
local c = OnlinePanel.cache(imp)
local pad, gap = Ui.pads(m)
local online = Client().state() == "online"
local cy = y + Home.identity(imp, x, y, w, m) + gap
if online then
Kit.text("small", Strings("%d players online, %d open lobbies",
c.counts.players, c.counts.lobbies), x, cy, PAL.heading)
else
Kit.text("small", Strings("Connect to see who is playing."), x, cy,
PAL.muted)
end
cy = cy + Kit.textHeight("small") + gap
local cardH = math.max(math.floor(74 * m.s), m.rowH * 2)
if m.twoCol then
local colW = math.floor((w - 2 * gap) / 3)
for i, card in ipairs(CARDS) do
local cx = x + (i - 1) * (colW + gap)
Ui.entryCard(imp, cx, cy, colW, cardH, "online-go-" .. card.id,
Strings(card.title), Strings(card.note),
online or card.id == "trade",
function() OnlinePanel.go(imp, card.id) end)
end
cy = cy + cardH
else
for _, card in ipairs(CARDS) do
Ui.entryCard(imp, x, cy, w, cardH, "online-go-" .. card.id,
Strings(card.title), Strings(card.note),
online or card.id == "trade",
function() OnlinePanel.go(imp, card.id) end)
cy = cy + cardH + gap
end
cy = cy - gap
end
cy = cy + gap
return (cy - y) + pad
end
return Home
+92
View File
@@ -0,0 +1,92 @@
local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme")
local Strings = require("src.core.Strings")
local Ui = require("src.import.online.Ui")
local PAL = Theme.PAL
local PcPicker = {}
local function LV() return require("src.import.LauncherView") end
local function OP() return require("src.import.OnlinePanel") end
local function Sprites() return require("src.online.OnlineSprites") end
function PcPicker.draw(imp, m)
local OnlinePanel = OP()
local pc = OnlinePanel.pcPicker(imp)
if not pc then return false end
local pad = math.floor(18 * m.s)
local gap = math.floor(8 * m.s)
local tiny = math.floor(4 * m.s)
local w = math.floor(520 * m.s)
local btnH = math.max(m.btnH, Kit.tapMin())
local rowH = math.max(m.rowH, Kit.tapMin())
local rows = OnlinePanel.pcRows(imp)
local perPage = 6
local h = math.floor(math.min(m.H - 2 * m.pad,
pad + Kit.textHeight("button") + gap + Kit.textHeight("small") + tiny
+ btnH + gap + perPage * (rowH + tiny) + gap + btnH + pad))
local px, py, pw, ph = LV().modalPanel(m, w, h)
local innerW = pw - 2 * pad
local cy = py + pad
Kit.textBold("button", Strings("From the PC"), px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + gap
cy = cy + Ui.label(Strings("Search"), px + pad, cy) + tiny
Ui.field(imp, px + pad, cy, innerW, btnH, OnlinePanel.PC_FIELD, pc.query,
Strings("Name, species or box"),
imp._onlineFocus == OnlinePanel.PC_FIELD,
function(text) OnlinePanel.pcQuery(imp, text) end)
cy = cy + btnH + gap
local listBottom = py + ph - pad - btnH - gap
local fits = math.max(1, math.floor((listBottom - cy) / (rowH + tiny)))
if #rows == 0 then
Kit.emptyBox(px + pad, cy, innerW, rowH * 2,
Strings("Nothing in the PC matches that."))
else
imp._pages = imp._pages or {}
local pageKey = "online-pc"
local first, last, pageNow = Kit.pageBounds(imp._pages[pageKey] or 1,
#rows, fits)
imp._pages[pageKey] = pageNow
local sprites = Sprites()
local iconSize = math.max(16, math.floor(rowH * 0.62))
for i = first, last do
local row = rows[i]
local ref = row.ref
local ink = LV().rowHit(imp, px + pad, cy, innerW, rowH,
row.order ~= nil, "online-pc-" .. row.key, function()
OnlinePanel.pcPick(imp, row)
end)
local tx = px + pad + math.floor(10 * m.s)
local sprite = sprites.get(row.version, row.mon)
if sprite and sprites.drawIcon(sprite, tx, cy + (rowH - iconSize) / 2,
iconSize) then
tx = tx + iconSize + math.floor(6 * m.s)
end
if row.order then
Kit.textBold("small", ("%d."):format(row.order), tx,
cy + (rowH - Kit.textHeight("small")) / 2, ink or PAL.heading)
tx = tx + math.floor(20 * m.s)
end
local textW = innerW - (tx - px - pad) - math.floor(10 * m.s)
Kit.text("small", Kit.ellipsize("small", row.label, textW), tx,
cy + math.floor(5 * m.s), ink or PAL.text)
Kit.text("micro", Kit.ellipsize("micro",
tostring(row.source or ""), textW), tx,
cy + rowH - Kit.textHeight("micro") - math.floor(5 * m.s), PAL.muted)
cy = cy + rowH + tiny
if ref == nil then break end
end
end
local by = math.floor(py + ph - pad - btnH)
LV().btn(imp, px + pad, by, innerW, btnH, OnlinePanel.PC_CLOSE,
Strings("Done"), { kind = "primary", font = "small",
action = function() OnlinePanel.pcClose(imp) end })
return true
end
return PcPicker
+188
View File
@@ -0,0 +1,188 @@
local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme")
local Strings = require("src.core.Strings")
local Ui = require("src.import.online.Ui")
local PAL = Theme.PAL
local Play = {}
local function LV() return require("src.import.LauncherView") end
local function OP() return require("src.import.OnlinePanel") end
local function Client() return require("src.online.Client") end
function Play.yourLobby(imp, x, y, w, m)
local OnlinePanel = OP()
local c = OnlinePanel.cache(imp)
local mine = c.mine
if not mine then return 0 end
local _, gap, tiny = Ui.pads(m)
local rowH = math.max(m.rowH, Kit.tapMin())
local btnH = math.max(m.btnH, Kit.tapMin())
local h = rowH + btnH + tiny + math.floor(16 * m.s)
Kit.card(x, y, w, h)
local tx = x + math.floor(12 * m.s)
local cy = y + math.floor(8 * m.s)
Kit.textBold("small", Strings("Your lobby"), tx, cy, PAL.heading)
cy = cy + Kit.textHeight("small") + tiny
Kit.text("micro", Kit.ellipsize("micro",
(mine.players or 0) < 2 and Strings("Waiting for an opponent - code %s",
tostring(mine.code or "------"))
or Strings("Code %s", tostring(mine.code or "------")),
w - math.floor(24 * m.s)), tx, cy, PAL.muted)
cy = cy + Kit.textHeight("micro") + tiny
local half = math.floor((w - math.floor(24 * m.s) - gap) / 2)
LV().btn(imp, tx, cy, half, btnH, "online-mine-return", Strings("Return"),
{ kind = "primary", font = "small",
action = function() OnlinePanel.go(imp, "room") end })
LV().btn(imp, tx + half + gap, cy, half, btnH, "online-mine-close",
Strings("Close"),
{ kind = "danger", font = "small",
action = function()
pcall(Client().closeRoom)
OnlinePanel.invalidate(imp, "lobby")
end })
return h + gap
end
function Play.filters(imp, x, y, w, m)
local OnlinePanel = OP()
local _, gap, tiny = Ui.pads(m)
local h = math.max(math.floor(26 * m.s), Kit.tapMin())
local cy = y + Ui.label(Strings(OnlinePanel.FILTER_LABEL), x, y) + tiny
local current = OnlinePanel.filter(imp)
local n = #OnlinePanel.FILTERS
local chipW = math.floor((w - gap * (n - 1)) / n)
for i, filter in ipairs(OnlinePanel.FILTERS) do
local id = filter.id
if Kit.chip(x + (i - 1) * (chipW + gap), cy, chipW, h,
Strings(filter.label), current == id, PAL.lineStrong,
"online-filter-" .. id) then
LV().queueAction(imp, "online-filter-" .. id, function()
OnlinePanel.setFilter(imp, id)
end)
end
end
return (cy - y) + h + tiny
end
function Play.roomList(imp, x, y, w, availH, m, rows, idPrefix, action, label)
local _, gap, tiny = Ui.pads(m)
local rowH = math.max(m.rowH, Kit.tapMin())
local btnH = math.max(m.btnH, Kit.tapMin())
local online = Client().state() == "online"
local cy = y
if #rows == 0 then
Kit.emptyBox(x, cy, w, rowH * 2, online
and Strings("No open lobbies right now. Host one and it shows up here.")
or Strings("Connect to see who is playing."))
return (rowH * 2) + gap
end
imp._pages = imp._pages or {}
local pageKey = idPrefix
local perPage = Kit.rowsThatFit(math.max(rowH * 2, availH), rowH, tiny, 2, 10)
local first, last, pageNow = Kit.pageBounds(imp._pages[pageKey] or 1,
#rows, perPage)
imp._pages[pageKey] = pageNow
local joinW = math.floor(84 * m.s)
for i = first, last do
local row = rows[i]
local ink = LV().rowHit(imp, x, cy, w, rowH, false,
idPrefix .. "-" .. row.id, nil)
local tx = x + math.floor(10 * m.s)
local textW = w - joinW - math.floor(24 * m.s)
Kit.text("small", Kit.ellipsize("small", row.name, textW), tx,
cy + math.floor(5 * m.s),
row.reason and PAL.faint or (ink or PAL.heading))
if row.verified then
local bw = math.floor(16 * m.s)
Kit.tag(tx + Kit.textWidth("small", row.name) + math.floor(6 * m.s),
cy + math.floor(5 * m.s), bw, math.floor(14 * m.s), "*", PAL.green)
end
local sub = row.reason and Strings("Can't join: %s", row.reason)
or ("%s %s %s %s"):format(row.game, row.arena, row.rule,
Strings("%d watching", row.spectators))
if not row.reason and row.note then sub = sub .. " " .. row.note end
Kit.text("micro", Kit.ellipsize("micro", sub, textW), tx,
cy + rowH - Kit.textHeight("micro") - math.floor(5 * m.s),
row.reason and PAL.yellow or PAL.muted)
if row.reason == nil then
local code, rule = row.code, row.ruleTable
LV().btn(imp, x + w - joinW - math.floor(6 * m.s),
cy + (rowH - btnH) / 2, joinW, btnH, idPrefix .. "-go-" .. row.id,
Strings(label),
{ kind = "accent", font = "small", enabled = online,
action = function() action(code, rule, row) end })
end
cy = cy + rowH + tiny
end
if #rows > perPage then
local page, ph = Kit.pager(x, cy, w, pageNow, #rows, perPage, pageKey)
imp._pages[pageKey] = page
cy = cy + ph
end
return (cy - y) + gap
end
function Play.draw(imp, x, y, w, availH, m)
local OnlinePanel = OP()
local st = OnlinePanel.state(imp)
local c = OnlinePanel.cache(imp)
local client = Client()
local _, gap, tiny = Ui.pads(m)
local btnH = math.max(m.btnH, Kit.tapMin())
local online = client.state() == "online"
local version = OnlinePanel.engineVersion(imp)
local profile = OnlinePanel.myProfile(imp)
local canBattle = online and OnlinePanel.canBattleWith(version)
local cy = y + Ui.header(imp, x, y, w, m, Strings("Play"),
online and "ONLINE" or "OFFLINE", online and PAL.green or PAL.line)
cy = cy + Play.yourLobby(imp, x, cy, w, m)
if version and OnlinePanel.isGen2(version) and not OnlinePanel.gen2Battles() then
cy = cy + Kit.textWrapped("small",
Strings("Gen 2 battles come later; you can still browse and trade."),
x, cy, w, PAL.yellow, 2) + tiny
end
LV().btn(imp, x, cy, w, btnH, "online-host", Strings("Host a battle"),
{ kind = "primary", font = "small", enabled = canBattle,
action = function() OnlinePanel.startWizard(imp, "hostBattle") end })
cy = cy + btnH + gap
cy = cy + Ui.label(Strings("Join by code"), x, cy) + tiny
local codeW = math.floor(w * 0.5)
Ui.field(imp, x, cy, codeW, btnH, "online-code", st.joinCode,
Strings("Six characters"), imp._onlineFocus == "online-code",
function(text) st.joinCode = OnlinePanel.sanitizeCode(text) end)
LV().btn(imp, x + codeW + gap, cy, w - codeW - gap, btnH,
"online-join-code", Strings("Join"),
{ kind = "accent", font = "small",
enabled = online and #st.joinCode == OnlinePanel.CODE_LEN,
action = function() OnlinePanel.startJoin(imp, st.joinCode) end })
cy = cy + btnH + gap
local rows = c.rooms
if #rows > OnlinePanel.FILTER_AT then
cy = cy + Play.filters(imp, x, cy, w, m)
end
cy = cy + Ui.label(Strings("Open lobbies"), x, cy) + tiny
cy = cy + Play.roomList(imp, x, cy, w,
math.max(m.rowH * 2, availH - (cy - y) - btnH * 2), m, rows,
"online-entry", function(code, rule)
OnlinePanel.startJoin(imp, code, rule)
end, "Join")
LV().btn(imp, x, cy, w, btnH, "online-tour-open",
Strings("Host a tournament"),
{ kind = "ghost", font = "small", enabled = online and profile ~= nil,
action = function() OnlinePanel.startWizard(imp, "hostTournament") end })
cy = cy + btnH + tiny
cy = cy + Ui.statusLine(imp, x, cy, w, m)
return cy - y
end
return Play
+225
View File
@@ -0,0 +1,225 @@
local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme")
local Strings = require("src.core.Strings")
local Ui = require("src.import.online.Ui")
local PAL = Theme.PAL
local Room = {}
local function LV() return require("src.import.LauncherView") end
local function OP() return require("src.import.OnlinePanel") end
local function Client() return require("src.online.Client") end
Room.TRADE_STAGE_TEXT = {
waiting = "Waiting for the other trainer",
ready = "Both trainers are here",
battling = "Trade in progress",
ended = "Trade over",
}
Room.STAGE_TEXT = {
waiting = "Waiting for a challenger",
ready = "Both trainers are picking a team",
battling = "Battle in progress",
ended = "Match over",
}
function Room.codeCard(imp, x, y, w, m, code, idPrefix, copied)
local _, gap = Ui.pads(m)
local rowH = math.max(m.rowH, Kit.tapMin())
local btnH = math.max(m.btnH, Kit.tapMin())
local copyW = math.floor(80 * m.s)
Kit.card(x, y, w, rowH)
Kit.text("title", tostring(code or "------"), x + math.floor(12 * m.s),
y + (rowH - Kit.textHeight("title")) / 2, PAL.heading)
LV().btn(imp, x + w - copyW - math.floor(8 * m.s), y + (rowH - btnH) / 2,
copyW, btnH, idPrefix .. "-copy", Strings("Copy"),
{ kind = "ghost", font = "small",
action = function()
if love.system and love.system.setClipboardText then
pcall(love.system.setClipboardText, tostring(code or ""))
end
local st = OP().state(imp)
st.status, st.statusOk = Strings(copied), true
end })
return rowH + gap
end
function Room.draw(imp, x, y, w, availH, m)
local OnlinePanel = OP()
local st = OnlinePanel.state(imp)
local client = Client()
local room = client.room()
local _, gap, tiny = Ui.pads(m)
local rowH = math.max(m.rowH, Kit.tapMin())
local btnH = math.max(m.btnH, Kit.tapMin())
if not room then
local cy = y + Ui.header(imp, x, y, w, m, Strings("Room"))
Kit.emptyBox(x, cy, w, rowH * 2, Strings("You are not in a room."))
return (cy - y) + rowH * 2
end
local stage = tostring(room.stage or "waiting")
local trade = tostring(room.intent or "battle") == "trade"
local cy = y + Ui.header(imp, x, y, w, m,
trade and Strings("Trade room") or Strings("Room"),
Strings(trade and (Room.TRADE_STAGE_TEXT[stage] or stage)
or (Room.STAGE_TEXT[stage] or stage)), PAL.lineStrong)
cy = cy + Room.codeCard(imp, x, cy, w, m, room.code, "online",
"Room code copied.")
cy = cy + Kit.textWrapped("small",
Strings("Give this code to your friend."), x, cy, w, PAL.muted, 1) + tiny
if not trade then
local rule = room.profile and room.profile.rule
cy = cy + Ui.label(Strings("Rules"), x, cy) + tiny
cy = cy + Kit.textWrapped("small",
Strings("%s - %s", OnlinePanel.arenaText(room.profile or {}),
OnlinePanel.ruleText(rule)), x, cy, w, PAL.muted, 1) + tiny
end
local deadline = not trade and room.deadlines
and (room.deadlines.ready or room.deadlines.shot)
if type(deadline) == "number" then
local left = OnlinePanel.countdown(deadline, client.serverTime() or 0)
if left then
cy = cy + Kit.textWrapped("small", Strings("%ds left", left), x, cy, w,
left <= 10 and PAL.red or PAL.muted, 1) + tiny
end
end
if OnlinePanel.lastResult then
cy = cy + Kit.textWrapped("small",
Strings(OnlinePanel.RESULT_TEXT[OnlinePanel.lastResult]
or "The match ended."), x, cy, w, PAL.green, 1) + tiny
end
local need = st.roomCart
if need then
cy = cy + Kit.textWrapped("small",
Strings("This room plays the %s cart.", tostring(need.id)),
x, cy, w, PAL.yellow, 2) + tiny
LV().btn(imp, x, cy, w, btnH, "online-install-cart",
st.cartInstall and Strings("Installing...") or Strings("Install cart"),
{ kind = "accent", font = "small", enabled = st.cartInstall == nil,
action = function() OnlinePanel.installCart(imp, need) end })
cy = cy + btnH + gap
end
local me = OnlinePanel.mySeatId()
local hosting = me ~= nil and room.host == me
local playing = false
cy = cy + Ui.label(Strings("Players"), x, cy) + tiny
for _, player in ipairs(room.players or {}) do
if me and player.id == me then playing = true end
Kit.card(x, cy, w, rowH, "badge")
local text = tostring(player.name or "?")
if player.verified then text = text .. " *" end
if player.online == false then text = text .. Strings(" (away)") end
Kit.text("small", Kit.ellipsize("small", text, w - math.floor(96 * m.s)),
x + math.floor(10 * m.s), cy + (rowH - Kit.textHeight("small")) / 2,
PAL.heading)
if not trade then
local tagW = math.floor(74 * m.s)
Kit.tag(x + w - tagW - math.floor(8 * m.s), cy + (rowH - 18 * m.s) / 2,
tagW, math.floor(18 * m.s), player.ready and "READY" or "PICKING",
player.ready and PAL.green or PAL.line)
end
cy = cy + rowH + tiny
end
if trade then
cy = cy + Kit.textWrapped("small",
(#(room.players or {}) < 2)
and Strings("Waiting for the other trainer.")
or Strings("Opening the trade..."),
x, cy, w, PAL.muted, 2) + tiny
LV().btn(imp, x, cy, w, btnH, "online-leave",
st.confirmLeave and Strings("Really leave?") or Strings("Leave"),
{ kind = "danger", font = "small",
action = function()
if not st.confirmLeave then
st.confirmLeave = true
return
end
st.confirmLeave, st.ready = nil, false
client.leaveRoom()
OnlinePanel.clearPresence()
OnlinePanel.go(imp, "trade")
end })
cy = cy + btnH + tiny
cy = cy + Ui.statusLine(imp, x, cy, w, m)
return cy - y
end
local spectators = room.spectators or {}
cy = cy + Ui.label(Strings("Spectators (%d)", #spectators), x, cy) + gap
local rematch = OnlinePanel.lastResult ~= nil and stage ~= "battling"
local half = math.floor((w - gap) / 2)
LV().btn(imp, x, cy, half, btnH, "online-ready",
st.ready and Strings("Unready")
or (rematch and Strings("Rematch") or Strings("Ready")),
{ kind = st.ready and "ghost" or "primary", font = "small",
enabled = playing and stage ~= "battling",
action = function()
if st.ready then
OnlinePanel.unready(imp)
else
OnlinePanel.sendReady(imp)
end
end })
LV().btn(imp, x + half + gap, cy, half, btnH, "online-leave",
st.confirmLeave and Strings("Really leave?") or Strings("Leave"),
{ kind = "danger", font = "small",
action = function()
if not st.confirmLeave then
st.confirmLeave = true
return
end
st.confirmLeave, st.ready = nil, false
client.leaveRoom()
OnlinePanel.clearPresence()
OnlinePanel.go(imp, "play")
end })
cy = cy + btnH + tiny
if hosting then
LV().btn(imp, x, cy, w, btnH, "online-host-more",
st.hostMore and Strings("Hide host controls")
or Strings("Host controls"),
{ kind = "ghost", font = "small",
action = function() st.hostMore = not st.hostMore end })
cy = cy + btnH + tiny
if st.hostMore then
for _, watcher in ipairs(spectators) do
local kickW = math.floor(64 * m.s)
Kit.text("small", Kit.ellipsize("small",
tostring(watcher.name or "?"), w - kickW), x + math.floor(10 * m.s),
cy + math.floor(4 * m.s), PAL.muted)
local id = watcher.id
LV().btn(imp, x + w - kickW, cy, kickW, btnH,
"online-kick-" .. tostring(id), Strings("Kick"),
{ kind = "danger", font = "small",
action = function()
if type(client.kick) == "function" then pcall(client.kick, id) end
end })
cy = cy + btnH + tiny
end
LV().btn(imp, x, cy, w, btnH, "online-close-room",
Strings("Close the room"),
{ kind = "danger", font = "small",
action = function()
pcall(client.closeRoom)
OnlinePanel.go(imp, "play")
end })
cy = cy + btnH + tiny
end
end
cy = cy + Ui.statusLine(imp, x, cy, w, m)
return cy - y
end
return Room
+214
View File
@@ -0,0 +1,214 @@
local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme")
local Strings = require("src.core.Strings")
local Ui = require("src.import.online.Ui")
local Room = require("src.import.online.Room")
local PAL = Theme.PAL
local Tournaments = {}
local function LV() return require("src.import.LauncherView") end
local function OP() return require("src.import.OnlinePanel") end
local function Client() return require("src.online.Client") end
Tournaments.STAGE_TEXT = {
registering = "Waiting for trainers",
running = "Bracket in progress",
finished = "Tournament over",
}
function Tournaments.bracket(imp, x, y, w, m, tour)
local OnlinePanel = OP()
local gap = math.floor(6 * m.s)
local columns = OnlinePanel.bracketColumns(tour)
local cardH = math.floor(34 * m.s)
if #columns == 0 then
Kit.emptyBox(x, y, w, cardH,
Strings("The bracket is drawn when the tournament starts."))
return cardH
end
local minColW = math.floor(96 * m.s)
local fit = math.max(1, math.floor((w + gap) / (minColW + gap)))
local shown = math.min(#columns, fit)
local first = 1
if shown < #columns then
first = math.max(1, math.min(#columns - shown + 1, tonumber(tour.round) or 1))
end
local colW = math.floor((w - gap * (shown - 1)) / shown)
local tallest = 0
for i = 0, shown - 1 do
local column = columns[first + i]
local cx = x + i * (colW + gap)
local cy = y
Kit.text("micro", Strings("Round %d", column.round or (first + i)), cx, cy,
PAL.faint)
cy = cy + Kit.textHeight("micro") + math.floor(3 * m.s)
for _, entry in ipairs(column.matches) do
Kit.card(cx, cy, colW, cardH, entry.live and "badge" or nil)
if entry.live then
Theme.stroke(cx, cy, colW, cardH, PAL.green, Theme.A.focus, 2)
end
local pad = math.floor(6 * m.s)
local ink = PAL.text
if entry.state == "done" then ink = PAL.muted end
if entry.live then ink = PAL.heading end
Kit.text("micro",
Kit.ellipsize("micro", OnlinePanel.matchText(entry), colW - 2 * pad),
cx + pad, cy + math.floor(4 * m.s), ink)
local sub
if entry.bye and not entry.b then
sub = Strings("bye")
elseif entry.winnerName then
sub = entry.how and ("%s (%s)"):format(entry.winnerName, entry.how)
or entry.winnerName
elseif entry.live then
sub = Strings("live")
else
sub = Strings("pending")
end
Kit.text("micro", Kit.ellipsize("micro", sub, colW - 2 * pad),
cx + pad, cy + cardH - Kit.textHeight("micro") - math.floor(4 * m.s),
entry.live and PAL.green or PAL.faint)
cy = cy + cardH + math.floor(4 * m.s)
end
tallest = math.max(tallest, cy - y)
end
return tallest
end
local function lobbyView(imp, x, y, w, m)
local OnlinePanel = OP()
local st = OnlinePanel.state(imp)
local _, gap, tiny = Ui.pads(m)
local btnH = math.max(m.btnH, Kit.tapMin())
local online = Client().state() == "online"
local profile = OnlinePanel.myProfile(imp)
local version = OnlinePanel.engineVersion(imp)
local canPlay = online and profile ~= nil
and OnlinePanel.canBattleWith(version)
local cy = y
LV().btn(imp, x, cy, w, btnH, "online-tour-host",
Strings("Host a tournament"),
{ kind = "primary", font = "small", enabled = online and profile ~= nil,
action = function()
OnlinePanel.startWizard(imp, "hostTournament")
end })
cy = cy + btnH + gap
cy = cy + Ui.label(Strings("Join by code"), x, cy) + tiny
local codeW = math.floor(w * 0.5)
Ui.field(imp, x, cy, codeW, btnH, "online-code", st.joinCode,
Strings("Six characters"), imp._onlineFocus == "online-code",
function(text) st.joinCode = OnlinePanel.sanitizeCode(text) end)
local restW = math.floor((w - codeW - 2 * gap) / 2)
LV().btn(imp, x + codeW + gap, cy, restW, btnH, "online-tour-join",
Strings("Join"),
{ kind = "accent", font = "small",
enabled = canPlay and #st.joinCode == OnlinePanel.CODE_LEN,
action = function()
OnlinePanel.startJoinTournament(imp, st.joinCode)
end })
LV().btn(imp, x + codeW + restW + 2 * gap, cy, restW, btnH,
"online-tour-watch", Strings("Watch"),
{ kind = "ghost", font = "small",
enabled = online and #st.joinCode == OnlinePanel.CODE_LEN,
action = function()
OnlinePanel.joinTournamentByCode(imp, st.joinCode, "spectator")
end })
cy = cy + btnH + tiny
return cy - y
end
function Tournaments.draw(imp, x, y, w, availH, m)
local OnlinePanel = OP()
local st = OnlinePanel.state(imp)
local client = Client()
local tour = client.tournament()
local _, gap, tiny = Ui.pads(m)
local rowH = math.max(m.rowH, Kit.tapMin())
local btnH = math.max(m.btnH, Kit.tapMin())
if not tour then
local cy = y + Ui.header(imp, x, y, w, m, Strings("Tournaments"))
cy = cy + lobbyView(imp, x, cy, w, m)
cy = cy + Ui.statusLine(imp, x, cy, w, m)
return cy - y
end
local me = OnlinePanel.mySeatId()
local controls = OnlinePanel.creatorControls(tour, me)
local stage = tostring(tour.stage or "registering")
local cy = y + Ui.header(imp, x, y, w, m, Strings("Tournament"),
Strings(Tournaments.STAGE_TEXT[stage] or stage), PAL.lineStrong)
local banner = OnlinePanel.bannerText(tour, me) or OnlinePanel.tourNotice
if banner then
cy = cy + Kit.textWrapped("small", banner, x, cy, w, PAL.green, 2) + tiny
end
local left = OnlinePanel.tourDeadline(tour)
if left then
cy = cy + Kit.textWrapped("small", Strings("%ds left", left), x, cy, w,
left <= 10 and PAL.red or PAL.muted, 1) + tiny
end
cy = cy + Tournaments.bracket(imp, x, cy, w, m, tour) + gap
cy = cy + Room.codeCard(imp, x, cy, w, m, tour.code, "online-tour",
"Tournament code copied.")
cy = cy + Ui.label(Strings("Players (%d) - spectators (%d)",
#(tour.players or {}), #(tour.spectators or {})), x, cy) + tiny
for _, player in ipairs(tour.players or {}) do
local kickW = math.floor(64 * m.s)
local wide = controls.canKick and player.id ~= me and st.hostMore
local text = tostring(player.name or "?")
if player.verified then text = text .. " *" end
if player.eliminated then text = text .. Strings(" (out)") end
if player.online == false then text = text .. Strings(" (away)") end
Kit.text("small", Kit.ellipsize("small", text,
w - (wide and kickW or 0) - math.floor(20 * m.s)),
x + math.floor(10 * m.s), cy,
player.eliminated and PAL.faint or PAL.text)
if wide then
local id = player.id
LV().btn(imp, x + w - kickW, cy - math.floor(4 * m.s), kickW, btnH,
"online-tour-kick-" .. tostring(id), Strings("Kick"),
{ kind = "danger", font = "small",
action = function() pcall(client.kickFromTournament, id) end })
cy = cy + btnH + tiny
else
cy = cy + Kit.textHeight("small") + tiny
end
end
cy = cy + gap
local third = math.floor((w - 2 * gap) / 3)
LV().btn(imp, x, cy, third, btnH, "online-tour-start", Strings("Start"),
{ kind = "primary", font = "small", enabled = controls.canStart,
action = function() pcall(client.startTournament) end })
LV().btn(imp, x + third + gap, cy, third, btnH, "online-tour-more",
st.hostMore and Strings("Hide host") or Strings("Host controls"),
{ kind = "ghost", font = "small", enabled = controls.isCreator,
action = function() st.hostMore = not st.hostMore end })
LV().btn(imp, x + 2 * (third + gap), cy, third, btnH, "online-tour-leave",
Strings("Leave"),
{ kind = "danger", font = "small",
action = function()
OnlinePanel.leaveTournament(imp)
OnlinePanel.go(imp, "play")
end })
cy = cy + btnH + tiny
if st.hostMore and controls.canClose then
LV().btn(imp, x, cy, w, btnH, "online-tour-close",
Strings("Close the tournament"),
{ kind = "danger", font = "small",
action = function() pcall(client.closeTournament) end })
cy = cy + btnH + tiny
end
cy = cy + Ui.statusLine(imp, x, cy, w, m)
return cy - y
end
return Tournaments
+318
View File
@@ -0,0 +1,318 @@
local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme")
local Strings = require("src.core.Strings")
local Ui = require("src.import.online.Ui")
local PAL = Theme.PAL
local TradeScreen = {}
local function LV() return require("src.import.LauncherView") end
local function OP() return require("src.import.OnlinePanel") end
local function Client() return require("src.online.Client") end
local function Sprites() return require("src.online.OnlineSprites") end
local function rowId(side, row, at)
local key = tostring(row.key or row.index or at):gsub("|", "-")
return ("online-trade-%s-%s"):format(side, key)
end
local function column(imp, x, y, w, m, side, rows, onPick)
local _, gap, tiny = Ui.pads(m)
local rowH = math.max(m.rowH, Kit.tapMin())
local cy = y
if #rows == 0 then
Kit.emptyBox(x, cy, w, rowH, Strings("No POKeMON here."))
return rowH + gap
end
local sprites = Sprites()
local iconSize = math.max(16, math.floor(rowH * 0.62))
for at, row in ipairs(rows) do
local pick = row.ref ~= nil and row.ref or row.index
local ink = LV().rowHit(imp, x, cy, w, rowH, row.picked,
rowId(side, row, at), function()
if row.pickable == false then return end
onPick(pick)
end)
local tx = x + math.floor(10 * m.s)
local sprite = sprites.get(row.version, row.mon)
if sprite and sprites.drawIcon(sprite, tx, cy + (rowH - iconSize) / 2,
iconSize) then
tx = tx + iconSize + math.floor(6 * m.s)
end
Kit.text("small", Kit.ellipsize("small", row.label,
w - (tx - x) - math.floor(10 * m.s)), tx,
cy + (rowH - Kit.textHeight("small")) / 2,
(row.pickable == false) and PAL.faint or (ink or PAL.text))
cy = cy + rowH + tiny
end
return (cy - y) + gap
end
local function localTrade(imp, x, y, w, m)
local OnlinePanel = OP()
local tr = OnlinePanel.tradeState(imp)
local c = OnlinePanel.cache(imp)
local _, gap, tiny = Ui.pads(m)
local rowH = math.max(m.btnH, Kit.tapMin())
local cy = y
local colW = math.floor((w - gap) / 2)
if #c.tradeSlots < 2 then
Kit.emptyBox(x, cy, w, rowH * 2,
Strings("Trading needs two saves on this machine."))
return (rowH * 2) + gap
end
cy = cy + Ui.label(Strings("Saves"), x, cy) + tiny
for i, side in ipairs({ "a", "b" }) do
local entry = tr.sides[side]
Ui.chooser(imp, x + (i - 1) * (colW + gap), cy, colW, rowH,
"online-trade-slot-" .. side,
entry and entry.label or Strings("Pick a save"),
function() OnlinePanel.tradeCycleSide(imp, side, -1) end,
function() OnlinePanel.tradeCycleSide(imp, side, 1) end)
end
cy = cy + rowH + gap
local tallest = 0
for i, side in ipairs({ "a", "b" }) do
local sx = x + (i - 1) * (colW + gap)
local view = OnlinePanel.tradeSideView(imp, side)
local h
if view and not view.handle then
Kit.emptyBox(sx, cy, colW, rowH,
tostring(view.reason or "that save can't be read"))
h = rowH + gap
else
h = column(imp, sx, cy, colW, m, side, c.tradeRows[side] or {},
function(ref) OnlinePanel.tradePick(imp, side, ref) end)
if OnlinePanel.tradePcAllowed(imp, side) then
LV().btn(imp, sx, cy + h, colW, rowH, "online-trade-pc-" .. side,
Strings("From PC"),
{ kind = "ghost", font = "small",
action = function()
OnlinePanel.pcOpen(imp, { side = side })
end })
h = h + rowH + tiny
end
end
tallest = math.max(tallest, h)
end
cy = cy + tallest
LV().btn(imp, x, cy, w, rowH, "online-trade-preview", Strings("Preview"),
{ kind = "primary", font = "small",
enabled = tr.picks.a ~= nil and tr.picks.b ~= nil,
action = function() OnlinePanel.tradeModalPreview(imp) end })
cy = cy + rowH + tiny
if tr.status then
cy = cy + Kit.textWrapped("small", tostring(tr.status), x, cy, w,
tr.statusOk and PAL.green or PAL.yellow, 3) + tiny
end
return cy - y
end
local function remoteTrade(imp, x, y, w, m)
local OnlinePanel = OP()
local st = OnlinePanel.state(imp)
local tr = OnlinePanel.tradeState(imp)
local _, gap, tiny = Ui.pads(m)
local rowH = math.max(m.btnH, Kit.tapMin())
local cy = y
local colW = math.floor((w - gap) / 2)
local online = Client().state() == "online"
if tr.remote then
local stage = tr.remote:stage()
cy = cy + Kit.textWrapped("small", OnlinePanel.remoteStageText(stage),
x, cy, w, PAL.heading, 2) + tiny
local mine, theirs = OnlinePanel.remoteRows(tr.remote)
local left = column(imp, x, cy, colW, m, "mine", mine, function(ref)
OnlinePanel.remotePick(imp, ref)
end)
local right = column(imp, x + colW + gap, cy, colW, m, "theirs", theirs,
function() end)
cy = cy + math.max(left, right)
local third = math.floor((w - 2 * gap) / 3)
LV().btn(imp, x, cy, third, rowH, "online-trade-yes", Strings("Confirm"),
{ kind = "primary", font = "small", enabled = stage == "confirming",
action = function() OnlinePanel.remoteConfirm(imp, true) end })
LV().btn(imp, x + third + gap, cy, third, rowH, "online-trade-no",
Strings("Say no"),
{ kind = "ghost", font = "small", enabled = stage == "confirming",
action = function() OnlinePanel.remoteConfirm(imp, false) end })
LV().btn(imp, x + 2 * (third + gap), cy, third, rowH,
"online-trade-cancel", Strings("Cancel"),
{ kind = "danger", font = "small",
action = function() OnlinePanel.endRemoteTrade(imp) end })
return (cy + rowH + gap) - y
end
local refusal = OnlinePanel.remoteTradeRefusal(imp)
if refusal then
cy = cy + Kit.textWrapped("small", refusal, x, cy, w, PAL.yellow, 3) + tiny
end
LV().btn(imp, x, cy, w, rowH, "online-trade-remote-start",
Strings("Set up a trade"),
{ kind = "primary", font = "small", enabled = online,
action = function() OnlinePanel.startWizard(imp, "tradeRemote") end })
cy = cy + rowH + tiny
local note = tr.remoteError or tr.remoteResult or tr.status or st.status
if note then
cy = cy + Kit.textWrapped("small", tostring(note), x, cy, w,
tr.remoteError and PAL.red or PAL.muted, 3) + tiny
end
return cy - y
end
local MODES = {
{ id = "local", title = "On this device",
note = "Trade between two saves on this device." },
{ id = "remote", title = "Over the internet",
note = "Trade with another trainer over the relay." },
}
function TradeScreen.draw(imp, x, y, w, availH, m)
local OnlinePanel = OP()
local tr = OnlinePanel.tradeState(imp)
local _, gap, tiny = Ui.pads(m)
local rowH = math.max(m.btnH, Kit.tapMin())
local cy = y + Ui.header(imp, x, y, w, m, Strings("Trade"))
if tr.remote then tr.chosen = true end
if not tr.chosen then
local cardH = math.max(math.floor(74 * m.s), m.rowH * 2)
if m.twoCol then
local colW = math.floor((w - gap) / 2)
for i, mode in ipairs(MODES) do
local id = mode.id
Ui.entryCard(imp, x + (i - 1) * (colW + gap), cy, colW, cardH,
"online-trade-mode-" .. id, Strings(mode.title), Strings(mode.note),
true, function()
OnlinePanel.tradeMode(imp, id)
tr.chosen = true
end)
end
cy = cy + cardH + gap
else
for _, mode in ipairs(MODES) do
local id = mode.id
Ui.entryCard(imp, x, cy, w, cardH, "online-trade-mode-" .. id,
Strings(mode.title), Strings(mode.note), true, function()
OnlinePanel.tradeMode(imp, id)
tr.chosen = true
end)
cy = cy + cardH + gap
end
end
return cy - y
end
cy = cy + Ui.label(Strings("Trade with"), x, cy) + tiny
local half = math.floor((w - gap) / 2)
for i, mode in ipairs(MODES) do
local id = mode.id
if Kit.chip(x + (i - 1) * (half + gap), cy, half, rowH,
Strings(mode.title), tr.mode == id, PAL.lineStrong,
"online-trade-pick-" .. id) then
LV().queueAction(imp, "online-trade-pick-" .. id, function()
OnlinePanel.tradeMode(imp, id)
end)
end
end
cy = cy + rowH + gap
if tr.mode == "remote" then
cy = cy + remoteTrade(imp, x, cy, w, m)
else
cy = cy + localTrade(imp, x, cy, w, m)
end
return cy - y
end
local function modalMon(m, entry, label, x, w, y, box)
local sprites = Sprites()
local sprite = entry and entry.mon
and sprites.get(entry.version, entry.mon) or nil
if sprite then
sprites.drawFront(sprite, x + math.floor((w - box) / 2), y, box)
end
local cy = y + box + math.floor(4 * m.s)
Kit.textCenter("micro", label, x, cy, w, PAL.faint)
cy = cy + Kit.textHeight("micro") + math.floor(2 * m.s)
Kit.textCenter("small",
Kit.ellipsize("small", (entry and entry.label) or "?", w),
x, cy, w, PAL.heading)
return (cy + Kit.textHeight("small")) - y
end
function TradeScreen.drawModal(imp, m)
local OnlinePanel = OP()
local mo = OnlinePanel.tradeModal(imp)
if not mo then return false end
local pad = math.floor(18 * m.s)
local gap = math.floor(8 * m.s)
local w = math.floor(460 * m.s)
local innerW = w - 2 * pad
local btnH = math.max(m.btnH, Kit.tapMin())
local box = math.floor(56 * m.s)
local monH = box + math.floor(6 * m.s) + Kit.textHeight("micro")
+ Kit.textHeight("small")
local result = mo.view == "result"
local lines = result and (mo.resultLines or {}) or (mo.lines or {})
local heading = result and (mo.message or "")
or ((#lines > 0) and Strings("What changes") or nil)
local linesH = heading and (Kit.textHeight("small") + math.floor(4 * m.s))
or 0
for _, line in ipairs(lines) do
linesH = linesH + Kit.wrapHeight("small", tostring(line), innerW, 2)
+ math.floor(3 * m.s)
end
local h = pad + Kit.textHeight("button") + gap + monH + gap + linesH + gap
+ btnH + pad
local px, py, pw, ph = LV().modalPanel(m, w, h)
innerW = pw - 2 * pad
local cy = py + pad
Kit.textBold("button", Strings("Trade"), px + pad, cy, PAL.heading)
cy = cy + Kit.textHeight("button") + gap
local slotW = math.floor((innerW - gap) / 2)
local left = modalMon(m, mo.give, Strings("Give"), px + pad, slotW, cy, box)
local right = modalMon(m, mo.get, Strings("Get"),
px + pad + slotW + gap, slotW, cy, box)
cy = cy + math.max(left, right) + gap
if heading then
Kit.text("small", Kit.ellipsize("small", heading, innerW), px + pad, cy,
result and (mo.ok and PAL.green or PAL.yellow) or PAL.detail)
cy = cy + Kit.textHeight("small") + math.floor(4 * m.s)
end
for _, line in ipairs(lines) do
cy = cy + Kit.textWrapped("small", tostring(line), px + pad, cy, innerW,
PAL.muted, 2) + math.floor(3 * m.s)
end
cy = math.floor(py + ph - pad - btnH)
if result then
LV().btn(imp, px + pad, cy, innerW, btnH,
OnlinePanel.TRADE_MODAL_DONE, Strings("Done"),
{ kind = "primary", font = "small",
action = function() OnlinePanel.tradeModalClose(imp) end })
return true
end
local half = math.floor((innerW - gap) / 2)
LV().btn(imp, px + pad, cy, half, btnH,
OnlinePanel.TRADE_MODAL_CANCEL, Strings("Cancel"),
{ kind = "ghost", font = "small",
action = function() OnlinePanel.tradeModalClose(imp) end })
LV().btn(imp, px + pad + half + gap, cy, innerW - half - gap, btnH,
OnlinePanel.TRADE_MODAL_CONFIRM, Strings("Confirm"),
{ kind = "primary", font = "small",
action = function() OnlinePanel.tradeModalConfirm(imp) end })
return true
end
return TradeScreen
+137
View File
@@ -0,0 +1,137 @@
local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme")
local Strings = require("src.core.Strings")
local PAL = Theme.PAL
local Ui = {}
local function LV()
return require("src.import.LauncherView")
end
local function OP()
return require("src.import.OnlinePanel")
end
Ui.LV = LV
Ui.OP = OP
function Ui.pads(m)
local s = m.s
return math.floor(14 * s), math.floor(8 * s), math.floor(4 * s)
end
function Ui.label(text, x, y, color)
Kit.text("small", text, x, y, color or PAL.muted)
return Kit.textHeight("small")
end
function Ui.field(imp, x, y, w, h, key, text, placeholder, focused, set)
Kit._audit("control", x, y, w, h, key)
local ring = Kit.focusable(key, x, y, w, h)
Theme.fill(x, y, w, h, PAL.bg, 1)
Theme.stroke(x, y, w, h, PAL.line,
(focused or ring) and Theme.A.focus
or (Kit.hover(x, y, w, h) and Theme.A.hover or Theme.A.hairline),
focused and 2 or 1)
local pad = math.floor(10 * Kit.scale)
local ty = y + (h - Kit.textHeight("button")) / 2
if (text or "") == "" and not focused then
Kit.text("button", Kit.ellipsize("button", placeholder or "", w - 2 * pad),
x + pad, ty, PAL.faint)
else
local shown = Kit.ellipsizeLeft("button", text or "", w - 2 * pad)
local tw = Kit.text("button", shown, x + pad, ty, PAL.heading)
if focused and ((imp.pulse or 0) * 2 % 1) < 0.5 then
Theme.fill(x + pad + tw + 2, ty, math.max(1, Kit.scale),
Kit.textHeight("button"), PAL.ink, 1)
end
end
if Kit.press(x, y, w, h) or Kit._activateId == key then
if Kit.VirtualKeyboard then
Kit.VirtualKeyboard.open({
text = text or "",
targetId = key,
title = placeholder or "Enter Text",
onDone = function(newText, confirmed)
if confirmed then set(newText) end
end,
})
end
LV().queueAction(imp, key, function() imp:_focusOnlineField(key) end)
end
end
function Ui.chooser(imp, x, y, w, h, key, text, onPrev, onNext)
local bw = math.max(Kit.tapMin(), math.floor(28 * Kit.scale))
local gap = math.floor(4 * Kit.scale)
LV().btn(imp, x, y, bw, h, key .. "-prev", "<",
{ kind = "ghost", font = "small", action = onPrev })
LV().btn(imp, x + w - bw, y, bw, h, key .. "-next", ">",
{ kind = "ghost", font = "small", action = onNext })
local inner = math.max(0, w - 2 * bw - 2 * gap)
Kit.textCenter("small", Kit.ellipsize("small", text, inner),
x + bw + gap, y + (h - Kit.textHeight("small")) / 2, inner, PAL.heading)
end
function Ui.indexOf(list, value)
for i = 1, #list do
if list[i] == value or (list[i] == false and value == nil) then return i end
end
return 1
end
function Ui.cycle(list, value, delta)
local at = Ui.indexOf(list, value)
local to = ((at - 1 + delta) % #list) + 1
local picked = list[to]
if picked == false then return nil end
return picked
end
function Ui.header(imp, x, y, w, m, title, pill, pillColor)
local _, gap = Ui.pads(m)
local h = math.max(m.btnH, Kit.tapMin())
local backW = math.floor(82 * m.s)
LV().btn(imp, x, y, backW, h, "online-back", Strings("Back"),
{ kind = "ghost", font = "small",
action = function() OP().back(imp) end })
local right = 0
if pill and pill ~= "" then
right = math.min(math.floor(w * 0.4),
Kit.textWidth("micro", pill) + math.floor(22 * m.s))
Kit.tag(x + w - right, y + (h - math.floor(18 * m.s)) / 2, right,
math.floor(18 * m.s), pill, pillColor or PAL.line)
end
local tx = x + backW + 2 * gap
Kit.textBold("button",
Kit.ellipsize("button", title,
math.max(0, w - backW - 3 * gap - right)),
tx, y + (h - Kit.textHeight("button")) / 2, PAL.heading)
return h + gap
end
function Ui.statusLine(imp, x, y, w, m)
local st = OP().state(imp)
if not st.status then return 0 end
local _, gap = Ui.pads(m)
return Kit.textWrapped("small", tostring(st.status), x, y, w,
st.statusOk and PAL.green or PAL.yellow, 2) + gap
end
function Ui.entryCard(imp, x, y, w, h, key, title, note, enabled, action)
local pad = math.floor(12 * Kit.scale)
local clicked, hot = Kit.row(x, y, w, h, false, enabled and key or nil)
local ink = enabled and (hot or PAL.heading) or PAL.faint
Kit.textBold("button", Kit.ellipsize("button", title, w - 2 * pad),
x + pad, y + pad, ink)
Kit.textWrapped("small", note, x + pad,
y + pad + Kit.textHeight("button") + math.floor(4 * Kit.scale),
w - 2 * pad, enabled and PAL.muted or PAL.faint, 2)
if clicked and enabled and action then
LV().queueAction(imp, key, action)
end
end
return Ui
+98
View File
@@ -0,0 +1,98 @@
local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme")
local Strings = require("src.core.Strings")
local Ui = require("src.import.online.Ui")
local PAL = Theme.PAL
local Watch = {}
local function LV() return require("src.import.LauncherView") end
local function OP() return require("src.import.OnlinePanel") end
local function Client() return require("src.online.Client") end
function Watch.draw(imp, x, y, w, availH, m)
local OnlinePanel = OP()
local st = OnlinePanel.state(imp)
local c = OnlinePanel.cache(imp)
local _, gap, tiny = Ui.pads(m)
local rowH = math.max(m.rowH, Kit.tapMin())
local btnH = math.max(m.btnH, Kit.tapMin())
local online = Client().state() == "online"
local cy = y + Ui.header(imp, x, y, w, m, Strings("Watch"),
online and "ONLINE" or "OFFLINE", online and PAL.green or PAL.line)
cy = cy + Ui.label(Strings("Watch by code"), x, cy) + tiny
local codeW = math.floor(w * 0.5)
Ui.field(imp, x, cy, codeW, btnH, "online-code", st.joinCode,
Strings("Six characters"), imp._onlineFocus == "online-code",
function(text) st.joinCode = OnlinePanel.sanitizeCode(text) end)
LV().btn(imp, x + codeW + gap, cy, w - codeW - gap, btnH,
"online-spectate-code", Strings("Spectate"),
{ kind = "primary", font = "small",
enabled = online and #st.joinCode == OnlinePanel.CODE_LEN,
action = function()
if OnlinePanel.joinByCode(imp, st.joinCode, "spectator") then
OnlinePanel.go(imp, "room")
end
end })
cy = cy + btnH + gap
local rows = c.watch
cy = cy + Ui.label(Strings("Live now"), x, cy) + tiny
if #rows == 0 then
Kit.emptyBox(x, cy, w, rowH * 2, online
and Strings("Nothing to watch yet. Paste a code, or check back.")
or Strings("Connect to see who is playing."))
cy = cy + rowH * 2 + gap
else
imp._pages = imp._pages or {}
local pageKey = "online-watch"
local perPage = Kit.rowsThatFit(math.max(rowH * 2,
availH - (cy - y) - btnH), rowH, tiny, 2, 8)
local first, last, pageNow = Kit.pageBounds(imp._pages[pageKey] or 1,
#rows, perPage)
imp._pages[pageKey] = pageNow
local watchW = math.floor(88 * m.s)
for i = first, last do
local row = rows[i]
local ink = LV().rowHit(imp, x, cy, w, rowH, false,
"online-watch-" .. row.id, nil)
local tx = x + math.floor(10 * m.s)
local textW = w - watchW - math.floor(24 * m.s)
Kit.text("small", Kit.ellipsize("small", row.name, textW), tx,
cy + math.floor(5 * m.s), ink or PAL.heading)
local tournament = row.intent == "tournament"
local sub = ("%s %s %s %s"):format(row.game, row.arena, row.rule,
tournament and Strings("tournament")
or Strings("%d watching", row.spectators))
Kit.text("micro", Kit.ellipsize("micro", sub, textW), tx,
cy + rowH - Kit.textHeight("micro") - math.floor(5 * m.s), PAL.muted)
local code = row.code
LV().btn(imp, x + w - watchW - math.floor(6 * m.s),
cy + (rowH - btnH) / 2, watchW, btnH, "online-watch-go-" .. row.id,
tournament and Strings("Watch") or Strings("Spectate"),
{ kind = "accent", font = "small", enabled = online,
action = function()
if tournament then
OnlinePanel.joinTournamentByCode(imp, code, "spectator")
elseif OnlinePanel.joinByCode(imp, code, "spectator") then
OnlinePanel.go(imp, "room")
end
end })
cy = cy + rowH + tiny
end
if #rows > perPage then
local page, ph = Kit.pager(x, cy, w, pageNow, #rows, perPage, pageKey)
imp._pages[pageKey] = page
cy = cy + ph
end
cy = cy + gap
end
cy = cy + Ui.statusLine(imp, x, cy, w, m)
return cy - y
end
return Watch
+519
View File
@@ -0,0 +1,519 @@
local Kit = require("src.ui.kit.Kit")
local Theme = require("src.ui.kit.Theme")
local Strings = require("src.core.Strings")
local GameVersion = require("src.core.GameVersion")
local Ui = require("src.import.online.Ui")
local PAL = Theme.PAL
local Wizard = {}
local function LV() return require("src.import.LauncherView") end
local function OP() return require("src.import.OnlinePanel") end
local function Sprites() return require("src.online.OnlineSprites") end
local SPECTATOR_CHOICES = { 0, 4, 8, 16 }
Wizard.SPECTATOR_CHOICES = SPECTATOR_CHOICES
local function chipRow(imp, x, y, w, m, label, id, options, isOn, onPick)
local _, gap, tiny = Ui.pads(m)
local h = math.max(m.btnH, Kit.tapMin())
local cy = y
if label then cy = cy + Ui.label(label, x, cy) + tiny end
local n = math.max(1, #options)
local perRow = math.min(n, (m.twoCol and 4) or 2)
local chipW = math.floor((w - gap * (perRow - 1)) / perRow)
local col = 0
for _, option in ipairs(options) do
local cx = x + (col % perRow) * (chipW + gap)
local key = id .. "-" .. tostring(option.id)
if Kit.chip(cx, cy, chipW, h, Kit.ellipsize("small", option.label, chipW),
isOn(option), PAL.lineStrong, key) then
local pick = option
LV().queueAction(imp, key, function() onPick(pick) end)
end
col = col + 1
if col % perRow == 0 then cy = cy + h + tiny end
end
if col % perRow ~= 0 then cy = cy + h + tiny end
return (cy - y) + gap - tiny
end
Wizard.chipRow = chipRow
-- ------------------------------------------------------------------ steps
local function stepGame(imp, x, y, w, m)
local OnlinePanel = OP()
local st = OnlinePanel.state(imp)
local c = OnlinePanel.cache(imp)
local _, gap = Ui.pads(m)
local cy = y
local versions = OnlinePanel.readyVersions(imp)
if #versions == 0 then
Kit.emptyBox(x, cy, w, math.max(m.rowH, Kit.tapMin()),
Strings("Import a game first."))
return math.max(m.rowH, Kit.tapMin()) + gap
end
local options = {}
for _, id in ipairs(versions) do
local info = GameVersion.info(id)
options[#options + 1] = { id = id, label = (info and info.name) or id }
end
cy = cy + chipRow(imp, x, cy, w, m, Strings("Game"), "online-game", options,
function(option) return option.id == OnlinePanel.selectedVersion(imp) end,
function(option)
st.version, st.slotId, st.cartId, st.team = option.id, nil, nil, {}
st.slotRead, st.ready, st.kind = nil, false, "vanilla"
OnlinePanel.invalidate(imp)
end)
local arena = { { id = "vanilla", label = Strings("Vanilla") } }
for _, row in ipairs(c.carts) do
arena[#arena + 1] = { id = row.id, label = row.title, cart = true }
end
cy = cy + chipRow(imp, x, cy, w, m, Strings("Arena"), "online-arena", arena,
function(option)
if option.cart then return st.kind == "cart" and st.cartId == option.id end
return st.kind ~= "cart"
end,
function(option)
if option.cart then
st.kind, st.cartId = "cart", option.id
else
st.kind, st.cartId = "vanilla", nil
end
st.slotId, st.team, st.slotRead, st.ready = nil, {}, nil, false
OnlinePanel.invalidate(imp)
end)
if #c.carts == 0 then
cy = cy + Kit.textWrapped("micro",
Strings("No sealed carts on this machine."), x, cy, w, PAL.faint, 1)
+ gap
end
return cy - y
end
local function stepSave(imp, x, y, w, availH, m)
local OnlinePanel = OP()
local st = OnlinePanel.state(imp)
local c = OnlinePanel.cache(imp)
local _, gap, tiny = Ui.pads(m)
local rowH = math.max(m.rowH, Kit.tapMin())
local cy = y + Ui.label(Strings("Save"), x, y) + tiny
if #c.slots == 0 then
Kit.emptyBox(x, cy, w, rowH, Strings("No save in this game yet."))
return (cy - y) + rowH + gap
end
imp._pages = imp._pages or {}
local pageKey = "online-slots"
local perPage = Kit.rowsThatFit(math.max(rowH * 2, availH), rowH, tiny, 2, 6)
local first, last, pageNow = Kit.pageBounds(imp._pages[pageKey] or 1,
#c.slots, perPage)
imp._pages[pageKey] = pageNow
for i = first, last do
local row = c.slots[i]
local id = row.id
local ink = LV().rowHit(imp, x, cy, w, rowH, st.slotId == id,
"online-slot-" .. id, function()
st.slotId, st.team, st.slotRead, st.ready = id, {}, nil, false
OnlinePanel.invalidate(imp, "party", "summary")
end)
Kit.text("small", Kit.ellipsize("small", row.label,
w - math.floor(20 * m.s)), x + math.floor(10 * m.s),
cy + math.floor(5 * m.s), ink or PAL.heading)
Kit.text("micro", row.sub, x + math.floor(10 * m.s),
cy + rowH - Kit.textHeight("micro") - math.floor(5 * m.s), PAL.muted)
cy = cy + rowH + tiny
end
if #c.slots > perPage then
local page, ph = Kit.pager(x, cy, w, pageNow, #c.slots, perPage, pageKey)
imp._pages[pageKey] = page
cy = cy + ph
end
return (cy - y) + gap
end
function Wizard.monRow(imp, row, x, y, w, m, onPick)
local rowH = math.max(m.rowH, Kit.tapMin())
local sprites = Sprites()
local iconSize = math.max(16, math.floor(rowH * 0.62))
local ink = LV().rowHit(imp, x, y, w, rowH, row.order ~= nil,
"online-mon-" .. row.key, onPick)
local tx = x + math.floor(10 * m.s)
local sprite = sprites.get(row.version, row.mon)
if sprite and sprites.drawIcon(sprite, tx, y + (rowH - iconSize) / 2,
iconSize) then
tx = tx + iconSize + math.floor(6 * m.s)
end
if row.order then
Kit.textBold("small", ("%d."):format(row.order), tx,
y + (rowH - Kit.textHeight("small")) / 2, ink or PAL.heading)
tx = tx + math.floor(20 * m.s)
end
local textW = w - (tx - x) - math.floor(10 * m.s)
Kit.text("small", Kit.ellipsize("small", row.label, textW), tx,
y + math.floor(5 * m.s),
row.refused and PAL.faint or (ink or PAL.text))
local sub = row.note or ((row.where == "box") and row.source or nil)
if sub then
Kit.text("micro", Kit.ellipsize("micro", sub, textW), tx,
y + rowH - Kit.textHeight("micro") - math.floor(5 * m.s),
row.refused and PAL.red or PAL.faint)
end
return rowH
end
local function stepTeam(imp, x, y, w, m)
local OnlinePanel = OP()
local st = OnlinePanel.state(imp)
local c = OnlinePanel.cache(imp)
local _, gap, tiny = Ui.pads(m)
local rowH = math.max(m.rowH, Kit.tapMin())
local btnH = math.max(m.btnH, Kit.tapMin())
local cy = y
local generations = OnlinePanel.installedGenerations(imp)
if #generations > 1 and OnlinePanel.crossGen(imp) then
cy = cy + Kit.textWrapped("small",
Strings("Time Capsule: your team is converted for %s.",
tostring(OnlinePanel.engineVersion(imp) or "?")),
x, cy, w, PAL.yellow, 2) + tiny
end
cy = cy + Ui.label(Strings("Your party"), x, cy) + tiny
if #c.party == 0 then
Kit.emptyBox(x, cy, w, rowH,
c.partyReason or Strings("That save has no POKeMON."))
cy = cy + rowH + tiny
else
local box = math.floor(56 * m.s)
local listW = w - box - gap
local listY = cy
for _, row in ipairs(c.party) do
local ref = row.ref
cy = cy + Wizard.monRow(imp, row, x, cy, listW, m, function()
st.focusMon = row.key
if row.refused then return end
OnlinePanel.toggleTeam(st.team, ref, OnlinePanel.TEAM_MAX)
st.ready = false
OnlinePanel.invalidate(imp, "party", "summary")
end) + tiny
end
local focus
for _, row in ipairs(c.party) do
if row.key == st.focusMon then focus = row end
end
focus = focus or c.team[1] or c.party[1]
if focus then
local sprite = Sprites().get(focus.version, focus.mon)
if sprite then
Sprites().drawFront(sprite, x + listW + gap, listY, box)
Kit.text("micro", Kit.ellipsize("micro",
tostring(focus.mon.species or "?"), box), x + listW + gap,
listY + box, PAL.faint)
end
end
end
LV().btn(imp, x, cy, w, btnH, "online-team-pc", Strings("From PC"),
{ kind = "ghost", font = "small", enabled = st.slotId ~= nil,
action = function() OnlinePanel.pcOpen(imp) end })
cy = cy + btnH + gap
cy = cy + Ui.label(Strings("Picked (%d)", #(st.team or {})), x, cy) + tiny
if #(c.team or {}) == 0 then
Kit.emptyBox(x, cy, w, rowH,
Strings("Tap a POKeMON above, or pull one out of the PC."))
cy = cy + rowH + tiny
else
for _, row in ipairs(c.team) do
if row then
local ref = row.ref
cy = cy + Wizard.monRow(imp, row, x, cy, w, m, function()
OnlinePanel.toggleTeam(st.team, ref, OnlinePanel.TEAM_MAX)
OnlinePanel.invalidate(imp, "party", "summary")
end) + tiny
end
end
end
if c.teamNote and not c.teamOk then
cy = cy + Kit.textWrapped("small", tostring(c.teamNote):gsub("\n", " "),
x, cy, w, PAL.yellow, 2) + tiny
end
return (cy - y) + gap
end
local function stepRules(imp, x, y, w, m)
local OnlinePanel = OP()
local st = OnlinePanel.state(imp)
local _, gap, tiny = Ui.pads(m)
local rowH = math.max(m.btnH, Kit.tapMin())
local cy = y
local rule = OnlinePanel.ruleFor(imp)
local function bump()
OnlinePanel.editRule(imp)
st.ready = false
OnlinePanel.invalidate(imp, "party", "summary")
end
local third = m.twoCol and math.floor((w - 2 * gap) / 3) or w
cy = cy + Ui.label(Strings("How many POKeMON each"), x, cy) + tiny
Ui.chooser(imp, x, cy, third, rowH, "online-size",
("%d v %d"):format(rule.partySize or 1, rule.partySize or 1),
function()
rule.partySize = Ui.cycle(OnlinePanel.SIZES, rule.partySize, -1)
bump()
end,
function()
rule.partySize = Ui.cycle(OnlinePanel.SIZES, rule.partySize, 1)
bump()
end)
cy = cy + rowH + gap
cy = cy + Ui.label(Strings("Levels"), x, cy) + tiny
local col2 = m.twoCol and (x + third + gap) or x
local row2 = m.twoCol and cy or (cy + rowH + tiny)
Ui.chooser(imp, x, cy, third, rowH, "online-min",
rule.minLevel and ("Lv%d+"):format(rule.minLevel) or Strings("Any min"),
function()
rule.minLevel = Ui.cycle(OnlinePanel.LEVELS, rule.minLevel, -1)
bump()
end,
function()
rule.minLevel = Ui.cycle(OnlinePanel.LEVELS, rule.minLevel, 1)
bump()
end)
Ui.chooser(imp, col2, row2, third, rowH, "online-max",
rule.maxLevel and ("Lv%d-"):format(rule.maxLevel) or Strings("Any max"),
function()
rule.maxLevel = Ui.cycle(OnlinePanel.LEVELS, rule.maxLevel, -1)
bump()
end,
function()
rule.maxLevel = Ui.cycle(OnlinePanel.LEVELS, rule.maxLevel, 1)
bump()
end)
cy = row2 + rowH + gap
cy = cy + Ui.label(Strings("Force every level"), x, cy) + tiny
Ui.chooser(imp, x, cy, third, rowH, "online-force",
rule.forceLevel and ("All Lv%d"):format(rule.forceLevel)
or Strings("Own levels"),
function()
rule.forceLevel = Ui.cycle(OnlinePanel.FORCE_LEVELS, rule.forceLevel, -1)
bump()
end,
function()
rule.forceLevel = Ui.cycle(OnlinePanel.FORCE_LEVELS, rule.forceLevel, 1)
bump()
end)
cy = cy + rowH + gap
return cy - y
end
local function stepVisibility(imp, x, y, w, m)
local OnlinePanel = OP()
local st = OnlinePanel.state(imp)
local _, gap, tiny = Ui.pads(m)
local btnH = math.max(m.btnH, Kit.tapMin())
local cy = y
cy = cy + chipRow(imp, x, cy, w, m, Strings("Visibility"), "online-visible",
{ { id = "public", label = Strings("Public") },
{ id = "code", label = Strings("Code only") } },
function(option)
if option.id == "public" then return st.public ~= false end
return st.public == false
end,
function(option) st.public = option.id == "public" end)
cy = cy + Kit.textWrapped("micro", st.public == false
and Strings("Only trainers you give the code to can join.")
or Strings("Anyone browsing Play sees this lobby."),
x, cy, w, PAL.faint, 2) + gap
cy = cy + Ui.label(Strings("Note (optional)"), x, cy) + tiny
Ui.field(imp, x, cy, w, btnH, "online-note", st.note,
Strings("Say something, like first to three"),
imp._onlineFocus == "online-note", function(text) st.note = text end)
cy = cy + btnH + gap
return cy - y
end
local function stepPlaying(imp, x, y, w, m)
local OnlinePanel = OP()
local st = OnlinePanel.state(imp)
local _, gap = Ui.pads(m)
local cy = y + chipRow(imp, x, y, w, m, Strings("Your seat"),
"online-tour-playing",
{ { id = "play", label = Strings("Play in it") },
{ id = "organize", label = Strings("Organize and watch") } },
function(option)
if option.id == "play" then return st.tourPlaying ~= false end
return st.tourPlaying == false
end,
function(option)
st.tourPlaying = option.id == "play"
OnlinePanel.invalidate(imp, "summary")
end)
cy = cy + Kit.textWrapped("micro", st.tourPlaying == false
and Strings("You run the bracket and never take a seat in it.")
or Strings("You take one of the seats in the bracket."),
x, cy, w, PAL.faint, 2) + gap
return cy - y
end
local function stepShotClock(imp, x, y, w, m)
local OnlinePanel = OP()
local st = OnlinePanel.state(imp)
local _, gap = Ui.pads(m)
local options = {}
for _, seconds in ipairs(OnlinePanel.SHOT_CLOCKS) do
options[#options + 1] = { id = seconds,
label = Strings("%d seconds", seconds) }
end
local cy = y + chipRow(imp, x, y, w, m, Strings("Thinking time a move"),
"online-shot", options,
function(option) return option.id == st.tourShotClock end,
function(option) st.tourShotClock = option.id end)
cy = cy + Kit.textWrapped("micro",
Strings("Thinking time for each move. Run out and the move is picked for you."),
x, cy, w, PAL.faint, 2) + gap
return cy - y
end
local function stepSpectators(imp, x, y, w, m)
local OnlinePanel = OP()
local st = OnlinePanel.state(imp)
local _, gap = Ui.pads(m)
local options = {}
for _, n in ipairs(SPECTATOR_CHOICES) do
options[#options + 1] = { id = n,
label = (n == 0) and Strings("None") or Strings("Up to %d", n) }
end
local cy = y + chipRow(imp, x, y, w, m, Strings("How many can watch"),
"online-spectators", options,
function(option) return option.id == (tonumber(st.tourSpectators) or 0) end,
function(option) st.tourSpectators = option.id end)
cy = cy + Kit.textWrapped("micro",
Strings("How many people can watch the bracket play out."),
x, cy, w, PAL.faint, 2) + gap
return cy - y
end
local function stepRole(imp, x, y, w, m)
local OnlinePanel = OP()
local st = OnlinePanel.state(imp)
local tr = OnlinePanel.tradeState(imp)
local _, gap, tiny = Ui.pads(m)
local btnH = math.max(m.btnH, Kit.tapMin())
local cy = y + chipRow(imp, x, y, w, m, Strings("Trade"), "online-trade-role",
{ { id = "host", label = Strings("Host a trade") },
{ id = "join", label = Strings("Join with a code") } },
function(option) return option.id == (st.tradeRole or "host") end,
function(option) st.tradeRole = option.id end)
if (st.tradeRole or "host") == "join" then
cy = cy + Ui.label(Strings("Trade code"), x, cy) + tiny
Ui.field(imp, x, cy, w, btnH, "online-trade-code", tr.code,
Strings("Six characters"), imp._onlineFocus == "online-trade-code",
function(text) tr.code = OnlinePanel.sanitizeCode(text) end)
cy = cy + btnH + gap
else
cy = cy + Kit.textWrapped("micro",
Strings("You get a code to give the other trainer."),
x, cy, w, PAL.faint, 2) + gap
end
local refusal = OnlinePanel.remoteTradeRefusal(imp)
if refusal then
cy = cy + Kit.textWrapped("small", refusal, x, cy, w, PAL.yellow, 2) + gap
end
return cy - y
end
local function stepSummary(imp, x, y, w, m)
local OnlinePanel = OP()
local _, gap, tiny = Ui.pads(m)
local rowH = math.max(m.rowH, Kit.tapMin())
local btnH = math.max(m.btnH, Kit.tapMin())
local changeW = math.floor(84 * m.s)
local cy = y
for _, answer in ipairs(OnlinePanel.wizardAnswers(imp)) do
Kit.card(x, cy, w, rowH, "badge")
local tx = x + math.floor(10 * m.s)
Kit.text("micro", answer.label, tx, cy + math.floor(5 * m.s), PAL.muted)
Kit.text("small", Kit.ellipsize("small", answer.value,
w - changeW - math.floor(28 * m.s)), tx,
cy + rowH - Kit.textHeight("small") - math.floor(5 * m.s), PAL.heading)
local step = answer.step
LV().btn(imp, x + w - changeW - math.floor(8 * m.s),
cy + (rowH - btnH) / 2, changeW, btnH,
"online-change-" .. step, Strings("Change"),
{ kind = "ghost", font = "small",
action = function() OnlinePanel.wizardTo(imp, step) end })
cy = cy + rowH + tiny
end
return (cy - y) + gap
end
local BODIES = {
game = function(imp, x, y, w, availH, m) return stepGame(imp, x, y, w, m) end,
save = stepSave,
team = function(imp, x, y, w, availH, m) return stepTeam(imp, x, y, w, m) end,
rules = function(imp, x, y, w, availH, m) return stepRules(imp, x, y, w, m) end,
visibility = function(imp, x, y, w, availH, m)
return stepVisibility(imp, x, y, w, m)
end,
playing = function(imp, x, y, w, availH, m)
return stepPlaying(imp, x, y, w, m)
end,
shotclock = function(imp, x, y, w, availH, m)
return stepShotClock(imp, x, y, w, m)
end,
spectators = function(imp, x, y, w, availH, m)
return stepSpectators(imp, x, y, w, m)
end,
role = function(imp, x, y, w, availH, m) return stepRole(imp, x, y, w, m) end,
summary = function(imp, x, y, w, availH, m)
return stepSummary(imp, x, y, w, m)
end,
}
Wizard.BODIES = BODIES
function Wizard.draw(imp, x, y, w, availH, m)
local OnlinePanel = OP()
local _, gap, tiny = Ui.pads(m)
local btnH = math.max(m.btnH, Kit.tapMin())
local def = OnlinePanel.wizardDef(imp)
if not def then
local cy = y + Ui.header(imp, x, y, w, m, Strings("Set up"))
Kit.emptyBox(x, cy, w, m.rowH * 2, Strings("Nothing to set up."))
return (cy - y) + m.rowH * 2
end
local id, at, total = OnlinePanel.wizardStep(imp)
local cy = y + Ui.header(imp, x, y, w, m, Strings(def.title),
("%d/%d"):format(at or 1, total or 1), PAL.lineStrong)
Kit.textBold("button", Strings(OnlinePanel.STEP_TITLE[id] or id), x, cy,
PAL.heading)
cy = cy + Kit.textHeight("button") + tiny
local body = math.max(m.rowH * 2, availH - (cy - y) - btnH * 2)
local run = BODIES[id] or BODIES.summary
cy = cy + run(imp, x, cy, w, body, m)
local last = (at or 1) >= (total or 1)
local half = math.floor((w - gap) / 2)
LV().btn(imp, x, cy, half, btnH, "online-wizard-back", Strings("Back"),
{ kind = "ghost", font = "small",
action = function() OnlinePanel.wizardBack(imp) end })
LV().btn(imp, x + half + gap, cy, w - half - gap, btnH,
"online-wizard-next",
last and Strings(def.confirm) or Strings("Next"),
{ kind = "primary", font = "small",
enabled = OnlinePanel.wizardReady(imp),
action = function() OnlinePanel.wizardNext(imp) end })
cy = cy + btnH + tiny
cy = cy + Ui.statusLine(imp, x, cy, w, m)
return cy - y
end
return Wizard
+14
View File
@@ -315,6 +315,19 @@ local function writeGen2TypeChart(out, data)
end
end
local function writeRulesets(out, data)
local rulesets = data.rulesets
if not rulesets then return end
out[#out + 1] = "[rulesets]"
for _, id in ipairs(sortedIds(rulesets)) do
local record = rulesets[id]
if type(record) == "table" then
out[#out + 1] = "@" .. id
writeValue(out, record)
end
end
end
local function writeConstants(out, data)
if not data.constants then return end
out[#out + 1] = "[constants]"
@@ -365,6 +378,7 @@ local function surfaceGen1(data, mods)
writeTypeChart(out, data)
writeSection(out, data, "statuses")
writeSection(out, data, "move_effects")
writeRulesets(out, data)
writeConstants(out, data)
writeLinkFields(out, data)
out[#out + 1] = "[mods]" .. modKey(mods)
+43 -3
View File
@@ -140,7 +140,7 @@ end
-- the enabled mods that keep this install out of online play, in the
-- id order Handshake.mods sorts: everything except verified translations.
-- LinkState:offerVanillaRestart names these and switches off exactly these.
-- The launcher's ONLINE tab names these and switches off exactly these.
function Handshake.onlineBlockers(game)
local mod = loader(game)
local blockers = {}
@@ -152,8 +152,8 @@ function Handshake.onlineBlockers(game)
return blockers
end
-- online play (the relay-based online match / tournament flows in
-- LinkState/Tournament) meets strangers, not a coordinating friend, so it
-- online play (the launcher's relay-based rooms and tournaments) meets
-- strangers, not a coordinating friend, so it
-- skips the LAN path's per-peer compatibility negotiation entirely and
-- just requires a vanilla simulation on both ends: no mod-added Pokemon, no
-- surprises. #501 carves out translations, because a language is not a
@@ -175,6 +175,21 @@ function Handshake.generation(game)
return Fingerprint.generationOf(game and game.data)
end
Handshake.DEFAULT_RULESET = "gen1_faithful"
function Handshake.ruleset(game)
local data = game and game.data
local fallback = (data and data.constants and data.constants.defaultRuleset)
or Handshake.DEFAULT_RULESET
local selected = game and game.save and game.save.options
and game.save.options.ruleset
if selected == nil then return fallback end
selected = tostring(selected)
local rulesets = data and data.rulesets
if rulesets and rulesets[selected] == nil then return fallback end
return selected
end
-- mode is nil on the guest: it pairs and announces itself before the host
-- has picked, and compatibility is decided from the two hellos, not the mode
function Handshake.hello(game, mode)
@@ -193,6 +208,7 @@ function Handshake.hello(game, mode)
generation = generation,
fingerprint = Fingerprint.compute(game and game.data, mods, generation),
linkModified = Handshake.linkModified(game),
ruleset = Handshake.ruleset(game),
mods = mods,
}
end
@@ -247,6 +263,11 @@ function Handshake.checkCompat(localHello, remoteHello)
return "engine_skew", "engine_release_mismatch"
end
if remoteHello.fingerprint == localHello.fingerprint then
local localRuleset = localHello.ruleset or Handshake.DEFAULT_RULESET
local remoteRuleset = remoteHello.ruleset or Handshake.DEFAULT_RULESET
if tostring(localRuleset) ~= tostring(remoteRuleset) then
return "ruleset_skew", "ruleset_mismatch"
end
return "full", nil
end
return "subset", "fingerprint_mismatch"
@@ -256,6 +277,7 @@ end
-- peer keeps the old substitute-a-move behaviour it was built against
function Handshake.strict(verdict)
return verdict == "full" or verdict == "subset" or verdict == "engine_skew"
or verdict == "ruleset_skew"
end
function Handshake.battleAllowed(verdict)
@@ -359,6 +381,24 @@ function Handshake.describe(localHello, remoteHello, verdict, mode)
end
return lines
end
if verdict == "ruleset_skew" then
wrap(lines, "Your battle rules")
wrap(lines, "differ:")
wrap(lines, (" you: %s"):format(
tostring(localHello.ruleset or Handshake.DEFAULT_RULESET):upper():sub(1, 12)))
wrap(lines, (" %s: %s"):format(peer:sub(1, 8),
tostring((remoteHello and remoteHello.ruleset)
or Handshake.DEFAULT_RULESET):upper():sub(1, 12)))
if mode == "battle" then
wrap(lines, "Battle needs the")
wrap(lines, "same RULESET in")
wrap(lines, "OPTIONS.")
else
wrap(lines, "Trading still")
wrap(lines, "works.")
end
return lines
end
if verdict == "engine_skew" then
-- name both releases so two friends can tell WHO updates: this used
-- to surface three turns in as a desync draw blaming mods (#758)
+30 -10
View File
@@ -29,12 +29,14 @@ local LinkBattle = {}
-- Deterministic Park-Miller PRNG: both sides must roll identical
-- streams, so love.math.random can't be used.
local function makeRng(seed)
local function makeRng(seed, owner)
local s = tonumber(seed) or 1
if s ~= s or s == math.huge or s == -math.huge then s = 1 end
s = math.floor(s) % 2147483647
if s <= 0 then s = s + 2147483646 end
if owner then owner.rngDraws = 0 end
return function(a, b)
if owner then owner.rngDraws = (owner.rngDraws or 0) + 1 end
s = (s * 16807) % 2147483647
if a == nil then return s / 2147483647 end
if b == nil then a, b = 1, a end
@@ -42,6 +44,19 @@ local function makeRng(seed)
end
end
local BUILTIN_RULESETS = {
gen1_faithful = require("src.battle.rulesets.gen1_faithful"),
modern_clean = require("src.battle.rulesets.modern_clean"),
}
local function rulesetFor(game, id)
local rulesets = game.data.rulesets or BUILTIN_RULESETS
local fallback = (game.data.constants and game.data.constants.defaultRuleset)
or "gen1_faithful"
return (id and rulesets[id]) or rulesets[fallback]
or BUILTIN_RULESETS.gen1_faithful
end
-- Battlers go through BattleState.makeBattler so pics get the same
-- Assets.resolve + SGB/GBC palette + padBottom path as wild/trainer
-- battles. save=nil skips badge boosts so both machines keep identical
@@ -177,7 +192,8 @@ local function stateSig(self, role, myParty, theirParty)
local hostParty = role == "host" and myParty or theirParty
local guestParty = role == "host" and theirParty or myParty
return {
actives = Fingerprint.digest(activeStr(host) .. "|" .. activeStr(guest)),
actives = Fingerprint.digest(activeStr(host) .. "|" .. activeStr(guest)
.. "|r" .. tostring(self.rngDraws or 0)),
volatile = Fingerprint.digest(volStr(host) .. "|" .. volStr(guest)),
bench = Fingerprint.digest(benchStr(hostParty) .. "|" .. benchStr(guestParty)),
}
@@ -197,7 +213,7 @@ local PARTS = { "actives", "volatile", "bench" }
local FATAL_PART = { actives = true, bench = true }
-- opts: { myParty = packed, theirParty = packed, theirName, role =
-- "host"/"guest", seed, verdict, strict }. Returns nil plus a reason when
-- "host"/"guest", seed, ruleset, verdict, strict }. Returns nil plus a reason when
-- the handshake says the two link surfaces don't match: a lockstep
-- simulation of two different rulebooks can only end in a bogus draw.
function LinkBattle.new(game, net, opts)
@@ -255,7 +271,9 @@ function LinkBattle.new(game, net, opts)
-- back up and the link can stall or time out. Game:step services
-- game.linkNet unconditionally every frame.
game.linkNet = net
self.rng = makeRng(opts.seed or 1)
self.rng = makeRng(opts.seed or 1, self)
self.rulesetId = opts.ruleset
self.ruleset = rulesetFor(game, opts.ruleset)
self.player = mkBattler(game.data, myParty[1], true)
self.enemy = mkBattler(game.data, theirParty[1], false)
self.enemyParty = theirParty
@@ -751,8 +769,8 @@ function LinkBattle.new(game, net, opts)
-- the whole tournament, not just this match.
if not opts.keepNetOpen then
net:close()
if game.linkNet == net then game.linkNet = nil end
end
if game.linkNet == net then game.linkNet = nil end
baseFinish(s)
end
@@ -802,7 +820,9 @@ function LinkBattle.newSpectator(game, net, opts)
self.spectating = true -- Tournament.lua's marker: don't report a result for this one
self.net = net
game.linkNet = net
self.rng = makeRng(opts.seed or 1)
self.rng = makeRng(opts.seed or 1, self)
self.rulesetId = opts.ruleset
self.ruleset = rulesetFor(game, opts.ruleset)
self.player = mkBattler(game.data, hostParty[1], true)
self.enemy = mkBattler(game.data, guestParty[1], false)
self.enemyParty = guestParty
@@ -1011,10 +1031,11 @@ function LinkBattle.newSpectator(game, net, opts)
end
elseif inner.type == "bye" or inner.type == "forfeit" then
if not s.result then endSpectate(s, "The match ended.") end
elseif inner.type == "hello" or inner.type == "party" then
s.pendingTournamentMessages = s.pendingTournamentMessages or {}
table.insert(s.pendingTournamentMessages, msg)
end
-- "hello"/"party"/"hash" ride along too (Tournament.lua already
-- consumed hello/party before building this battle); none of them
-- need any action here
-- a spectated "hash" needs no action here
else
-- same reasoning as the real-participant loop above: don't lose a
-- bracket_update/match_start_spectate that arrives mid-match
@@ -1046,7 +1067,6 @@ function LinkBattle.newSpectator(game, net, opts)
local baseFinish = self.finish
self.finish = function(s)
s.linkEnded = true
if game.linkNet == net then game.linkNet = nil end
baseFinish(s) -- deliberately doesn't touch net: it's the caller's
-- tournament connection, still needed after this match
end
+599
View File
@@ -0,0 +1,599 @@
local Battle = require("src.battle.gen2.Battle")
local BattleState = require("src.ui.gen2.BattleState")
local Fingerprint = require("src.link.Fingerprint")
local Handshake = require("src.link.Handshake")
local Logger = require("src.core.Logger")
local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime")
local Strings = require("src.core.Strings")
local LinkBattle2 = {}
local function makeRandom(seed, owner)
local s = tonumber(seed) or 1
if s ~= s or s == math.huge or s == -math.huge then s = 1 end
s = math.floor(s) % 2147483647
if s <= 0 then s = s + 2147483646 end
owner.rngDraws = 0
return function(n)
owner.rngDraws = owner.rngDraws + 1
s = (s * 16807) % 2147483647
n = math.floor(tonumber(n) or 1)
if n < 1 then n = 1 end
return s % n
end
end
local PARTS = { "actives", "volatile", "bench" }
local FATAL_PART = { actives = true, bench = true }
local IDLE_PHASE = {
menu = true, moves = true, ["locked-in"] = true, ["forced-switch"] = true,
["link-wait"] = true, ["link-hold"] = true, ["refuse-menu"] = true,
["refuse-move"] = true, ["refuse-switch"] = true,
}
local function unpackParty(game, packed, unpackOpts, errFmt)
local out = {}
for _, p in ipairs(packed or {}) do
local mon, why = Protocol.unpackMon2(game.data, p, unpackOpts)
if mon then
table.insert(out, mon)
elseif unpackOpts.strict then
return nil, errFmt(p, why)
end
end
return out
end
local function linkSave(game, party)
local player = (game and game.save and game.save.player) or {}
return {
party = party,
player = { name = player.name or "PLAYER", gender = player.gender },
inventory = {},
pokedex = { seen = {}, caught = {} },
options = {},
modData = {},
}
end
local function digestParts(parts)
return {
actives = Fingerprint.digest(parts.actives),
volatile = Fingerprint.digest(parts.volatile),
bench = Fingerprint.digest(parts.bench),
}
end
local function moveSlot(mon, moveId)
for i, mv in ipairs((mon and mon.moves) or {}) do
if mv.id == moveId then return i end
end
return nil
end
local function encodeAction(battle, action)
if action.kind == "switch" then
return { type = "action", kind = "switch", index = action.index }
end
if action.kind == "run" then
return { type = "action", kind = "run" }
end
if action.move == Battle.STRUGGLE then
return { type = "action", kind = "struggle" }
end
local slot = moveSlot(battle.player, action.move)
if not slot then
return { type = "action", kind = "locked" }
end
return { type = "action", kind = "move", slot = slot }
end
local function decodeAction(battle, msg, mon)
local kind = msg.kind
if kind == "switch" then
local index = math.floor(tonumber(msg.index) or 1)
return { kind = "switch", index = index }
end
if kind == "run" then return { kind = "run" } end
if kind == "struggle" then
return { kind = "move", move = Battle.STRUGGLE }
end
if kind == "locked" then
local locked = battle:lockedInMove(mon)
or battle:volatile(mon).chargeMove
or battle:forcedMove(mon)
return { kind = "move", move = locked }
end
local moves = (mon and mon.moves) or {}
local slot = math.max(1, math.min(math.max(1, #moves),
math.floor(tonumber(msg.slot) or 1)))
local mv = moves[slot]
return { kind = "move", move = mv and mv.id or Battle.STRUGGLE }
end
local function outcomeResult(outcome, battle)
if outcome == "win" then
if battle and not Battle.firstHealthy(battle.party) then return "draw" end
return "win"
end
if outcome == "lose" then return "lose" end
return "draw"
end
function LinkBattle2.new(game, net, opts)
local role = opts.role
local theirName = opts.theirName or "FOE"
if not Handshake.battleAllowed(opts.verdict) then
return nil, Strings("Link battle needs\nthe same version\nand mods.")
end
local unpackOpts = { strict = opts.strict or false,
forceLevel = opts.forceLevel }
local myParty, myErr = unpackParty(game, opts.myParty, unpackOpts, function(p)
return Strings("Your %s can't\nbattle on the\nother game.",
tostring(p.species))
end)
if not myParty then return nil, myErr end
local theirParty, theirErr = unpackParty(game, opts.theirParty, unpackOpts,
function(p, why)
return Strings("Their %s isn't\nin this game.\n(%s)",
tostring(p.species), tostring(why))
end)
if not theirParty then return nil, theirErr end
if #myParty == 0 or #theirParty == 0 then
Logger.warn("link2: empty party on one side")
end
local function announceReceived(party)
for _, mon in ipairs(party) do
Runtime.emit("pokemon.received",
{ mon = mon, from = "link", peerName = theirName })
end
end
announceReceived(role == "host" and myParty or theirParty)
announceReceived(role == "host" and theirParty or myParty)
local owner = {}
local random = makeRandom(opts.seed or 1, owner)
local battle = Battle.new({
data = game.data,
random = random,
party = myParty,
trainer = { name = theirName, party = theirParty },
save = nil,
})
battle.linkBattle = true
battle.mirrored = role == "guest"
theirParty = battle.enemyParty
local hooks = {}
local screen
local ended, byeSent = false, false
local pendingMine, pendingTheirs = nil, nil
local remoteReplace = {}
local turnCount = 0
local localHashes, remoteHashes = {}, {}
local localParts, remoteParts = {}, {}
local checkedTurns = {}
local clock = nil
local function send(msg) net:send(msg) end
local function kick(s)
if IDLE_PHASE[s.phase] then
s.phase = "resolving"
s:advanceQueue()
end
end
local function endWithResult(s, result, text)
if ended then return end
ended = true
s.result = result
if text then s:pushAll({ { kind = "message", text = text } }) end
if not battle.over then
battle:endBattle(result == "win" and "win"
or (result == "lose" and "lose" or "draw"))
end
kick(s)
end
local function endAsDraw(s, text)
endWithResult(s, "draw", text)
end
local function reportDesync(s, turn, component, localH, remoteH)
Logger.warn("link2: desync turn %s component=%s (%s vs %s)",
tostring(turn), component, tostring(localH), tostring(remoteH))
Runtime.emit("link.desync", { turn = turn, component = component,
localHash = localH, remoteHash = remoteH,
fatal = true })
endAsDraw(s, Strings(
"Link desync! %s differs. Are both games the same version and mods?",
component))
end
local function noteDrift(turn, component, localH, remoteH)
Logger.warn("link2: %s drift on turn %s (%s vs %s) -- match continues",
component, tostring(turn), tostring(localH), tostring(remoteH))
Runtime.emit("link.desync", { turn = turn, component = component,
localHash = localH, remoteHash = remoteH,
fatal = false })
end
local function checkHashes(s)
for turn, localH in pairs(localHashes) do
local remoteH = remoteHashes[turn]
if remoteH and not checkedTurns[turn] then
checkedTurns[turn] = true
local mine, theirs = localParts[turn], remoteParts[turn]
if mine and theirs then
for _, component in ipairs(PARTS) do
if mine[component] ~= theirs[component] then
if FATAL_PART[component] then
reportDesync(s, turn, component, mine[component],
theirs[component])
return
end
noteDrift(turn, component, mine[component], theirs[component])
end
end
end
if remoteH ~= localH then
reportDesync(s, turn, "state", localH, remoteH)
return
end
end
end
end
local function signTurn(s)
battle.rngDraws = owner.rngDraws
turnCount = turnCount + 1
local raw = battle:linkSignature(role)
local parts = digestParts(raw)
local value = parts.actives .. "|" .. parts.bench
localHashes[turnCount] = value
localParts[turnCount] = parts
if LinkBattle2.keepSignatures then s.linkSignatures[turnCount] = raw end
send({ type = "hash", turn = turnCount, value = value, parts = parts })
checkHashes(s)
end
local function resolveLockstep(s, myMsg, theirMsg)
if myMsg.kind == "run" or theirMsg.kind == "run" then
local who = myMsg.kind == "run"
and ((game.save and game.save.player and game.save.player.name)
or "PLAYER")
or theirName
endAsDraw(s, Strings("%s ran from the battle!", who))
return
end
local myAction = decodeAction(battle, myMsg, battle.player)
local theirAction = decodeAction(battle, theirMsg, battle.enemy)
local events = battle:takeLinkTurn(myAction, theirAction)
signTurn(s)
if ended then return end
s.phase = "resolving"
s:pushAll(events)
s.message = nil
s.messageTimer = 0
s:advanceQueue()
end
local function tryResolve(s)
if not pendingMine or not pendingTheirs then return end
local mine, theirs = pendingMine, pendingTheirs
pendingMine, pendingTheirs = nil, nil
resolveLockstep(s, mine, theirs)
end
local function applyRemoteReplace(s)
if not battle.pendingEnemySwitch or #remoteReplace == 0 then return end
local index = table.remove(remoteReplace, 1)
battle:forcedReplacement("enemy", index)
s:pushAll(battle:takeEvents())
if s.phase == "link-hold" then kick(s) end
end
hooks.submit = function(s, action)
if ended then return end
if action.kind == "item" then
return s:refuseMenu(Strings("Items can't be used in a link battle!"))
end
if action.kind == "run" then
return s:refuseMenu(Strings("No running from a link battle!"))
end
local msg = encodeAction(battle, action)
send(msg)
pendingMine = msg
s.phase = "link-wait"
tryResolve(s)
end
hooks.menuChoice = function(s, choice)
if choice == "item" then
s:refuseMenu(Strings("Items can't be used in a link battle!"))
return true
end
if choice == "run" then
s:refuseMenu(Strings("No running from a link battle!"))
return true
end
return false
end
-- ChooseNextMon (engine/battle/core.asm:1086-1103): the replacement after a
hooks.forcedSwitch = function(s, index)
local mon = myParty[index]
if not mon or (mon.hp or 0) <= 0 or mon.isEgg then
return s:refuseSwitch(true)
end
if not battle:forcedReplacement("player", index) then
return s:refuseSwitch(true)
end
send({ type = "replace", index = index })
s.phase = "resolving"
s:pushAll(battle:takeEvents())
s:advanceQueue()
return true
end
screen = BattleState.new(game, {
battle = battle,
save = linkSave(game, myParty),
link = hooks,
onDone = function(outcome)
local result = screen.result or outcomeResult(outcome, battle)
screen.result = result
if not byeSent then
byeSent = true
send({ type = "bye" })
end
if not opts.keepNetOpen then
net:close()
if game.linkNet == net then game.linkNet = nil end
end
if game.stack and game.stack:top() == screen then game.stack:pop() end
if screen.onFinish then screen.onFinish(result) end
end,
})
screen.kind = "link"
screen.linkRole = role
screen.result = nil
screen.playerParty = myParty
screen.enemyParty = theirParty
screen.localHashes = localHashes
screen.remoteHashes = remoteHashes
screen.localParts = localParts
screen.remoteParts = remoteParts
screen.linkSignatures = {}
screen.rngOwner = owner
game.linkNet = net
local baseUpdate = screen.update
screen.update = function(s, dt)
net:update()
for _, msg in ipairs(net:poll()) do
if msg.type == "action" then
pendingTheirs = msg
tryResolve(s)
elseif msg.type == "hash" then
remoteHashes[msg.turn or 0] = msg.value
remoteParts[msg.turn or 0] = msg.parts
checkHashes(s)
elseif msg.type == "replace" then
table.insert(remoteReplace, math.max(1, math.min(#theirParty,
math.floor(tonumber(msg.index) or 1))))
elseif msg.type == "bye" then
if not ended and not battle.over then
endAsDraw(s, Strings("%s left the battle.", theirName))
end
elseif msg.type == "forfeit" then
if not ended and not battle.over then
endWithResult(s, "win", Strings("%s ran out of time!", theirName))
end
else
s.pendingLinkMessages = s.pendingLinkMessages or {}
table.insert(s.pendingLinkMessages, msg)
end
end
if net.closed and not ended and not battle.over then
endAsDraw(s)
end
applyRemoteReplace(s)
if battle.pendingEnemySwitch
and (s.phase == "menu" or s.phase == "moves"
or s.phase == "locked-in") then
s.phase = "link-hold"
end
if opts.turnLimit and (s.phase == "menu" or s.phase == "moves") then
clock = (clock or opts.turnLimit) - (dt or 0)
if clock <= 0 then
clock = nil
send({ type = "forfeit" })
endWithResult(s, "lose",
Strings("Time's up! You forfeit the match."))
end
elseif opts.turnLimit then
clock = nil
end
return baseUpdate(s, dt)
end
return screen
end
function LinkBattle2.newSpectator(game, net, opts)
local hostName = opts.hostName or "HOST"
local guestName = opts.guestName or "GUEST"
if not Handshake.battleAllowed(opts.verdict) then
return nil, Strings("Link battle needs\nthe same version\nand mods.")
end
local unpackOpts = { strict = opts.strict or false,
forceLevel = opts.forceLevel }
local hostParty, hostErr = unpackParty(game, opts.hostParty, unpackOpts,
function(p)
return Strings("%s's %s can't\nbattle on this\ngame.", hostName,
tostring(p.species))
end)
if not hostParty then return nil, hostErr end
local guestParty, guestErr = unpackParty(game, opts.guestParty, unpackOpts,
function(p, why)
return Strings("%s's %s can't\nbattle on this\ngame.\n(%s)", guestName,
tostring(p.species), tostring(why))
end)
if not guestParty then return nil, guestErr end
local owner = {}
local random = makeRandom(opts.seed or 1, owner)
local battle = Battle.new({
data = game.data,
random = random,
party = hostParty,
trainer = { name = guestName, party = guestParty },
save = nil,
})
battle.linkBattle = true
guestParty = battle.enemyParty
local hooks = {}
local screen
local hostMsg, guestMsg = nil, nil
local hostReplace, guestReplace = {}, {}
local ended = false
local function endSpectate(s, text)
if ended then return end
ended = true
s.result = s.result or "ended"
if text then s:pushAll({ { kind = "message", text = text } }) end
if not battle.over then battle:endBattle("draw") end
if IDLE_PHASE[s.phase] then
s.phase = "resolving"
s:advanceQueue()
end
end
local function resolveSpecTurn(s, hMsg, gMsg)
if hMsg.kind == "run" or gMsg.kind == "run" then
endSpectate(s, Strings("The match ended."))
return
end
local hostAction = decodeAction(battle, hMsg, battle.player)
local guestAction = decodeAction(battle, gMsg, battle.enemy)
local events = battle:takeLinkTurn(hostAction, guestAction)
if ended then return end
s.phase = "resolving"
s:pushAll(events)
s.message = nil
s.messageTimer = 0
s:advanceQueue()
end
hooks.forcedPrompt = function(s)
local index = table.remove(hostReplace, 1)
if not index then return true end
battle:forcedReplacement("player", index)
s.phase = "resolving"
s:pushAll(battle:takeEvents())
s:advanceQueue()
return true
end
hooks.submit = function() end
hooks.menuChoice = function() return true end
hooks.forcedSwitch = function(s) return s:refuseSwitch(true) end
screen = BattleState.new(game, {
battle = battle,
save = linkSave(game, hostParty),
link = hooks,
onDone = function(outcome)
local result = screen.result or outcomeResult(outcome, battle)
screen.result = result
if game.stack and game.stack:top() == screen then game.stack:pop() end
if screen.onFinish then screen.onFinish(result) end
end,
})
screen.kind = "link"
screen.spectating = true
screen.result = nil
screen.playerParty = hostParty
screen.enemyParty = guestParty
game.linkNet = net
local baseUpdate = screen.update
screen.update = function(s, dt)
net:update()
for _, msg in ipairs(net:poll()) do
if msg.type == "spectate" and type(msg.msg) == "table" then
local inner = msg.msg
if inner.type == "action" then
if msg.side == "host" then hostMsg = inner else guestMsg = inner end
if hostMsg and guestMsg then
local h, g = hostMsg, guestMsg
hostMsg, guestMsg = nil, nil
resolveSpecTurn(s, h, g)
end
elseif inner.type == "replace" then
local index = math.floor(tonumber(inner.index) or 1)
if msg.side == "host" then
table.insert(hostReplace,
math.max(1, math.min(#hostParty, index)))
else
table.insert(guestReplace,
math.max(1, math.min(#guestParty, index)))
end
elseif inner.type == "bye" or inner.type == "forfeit" then
endSpectate(s, Strings("The match ended."))
end
else
s.pendingLinkMessages = s.pendingLinkMessages or {}
table.insert(s.pendingLinkMessages, msg)
end
end
if net.closed and not ended and not battle.over then endSpectate(s) end
if battle.pendingEnemySwitch then
local index = table.remove(guestReplace, 1)
if index then
battle:forcedReplacement("enemy", index)
s:pushAll(battle:takeEvents())
if s.phase == "link-hold" then
s.phase = "resolving"
s:advanceQueue()
end
elseif s.phase == "menu" or s.phase == "moves"
or s.phase == "locked-in" then
s.phase = "link-hold"
end
end
if s.phase == "menu" or s.phase == "moves" or s.phase == "locked-in" then
s.phase = "link-wait"
end
return baseUpdate(s, dt)
end
return screen
end
function LinkBattle2.newHost(game, net, opts)
opts.role = "host"
return LinkBattle2.new(game, net, opts)
end
function LinkBattle2.newGuest(game, net, opts)
opts.role = "guest"
return LinkBattle2.new(game, net, opts)
end
return LinkBattle2
+19 -202
View File
@@ -2,7 +2,6 @@
-- the other joins by typing that address in. Direct peer-to-peer over
-- lua-enet (bundled with LÖVE), no relay server.
local CodeEntry = require("src.link.CodeEntry")
local DiscordPresence = require("src.core.DiscordPresence")
local Font = require("src.render.Font")
local Handshake = require("src.link.Handshake")
@@ -48,7 +47,7 @@ end
-- stages before a successful transport has become this link's Session;
-- terminal checks skip them rather than keying off self.net's presence
local PRE_CONNECT_STAGES = { menu = true, lanMenu = true, onlineMenu = true }
local PRE_CONNECT_STAGES = { menu = true }
-- how long the host waits for a v2 hello before deciding the peer predates
-- the handshake (a pre-mod guest sends nothing until it hears the mode)
@@ -94,30 +93,12 @@ function LinkState.new(game)
return self
end
-- entry point for a Discord "Ask to Join" click (see DiscordPresence.lua):
-- skips the whole LAN/ONLINE/TOURNAMENT menu and jumps straight to
-- "connecting with this code", same as if the player had typed it in
function LinkState.newJoinOnline(game, code)
local self = LinkState.new(game)
local session, detail = openSession("guest", function(transport)
return transport:joinOnline(nil, code)
end)
if session then
self.net = session
self.stage = "onlineJoining"
else
self.stage = "menu" -- exitWith below needs a real stage to unwind from
self:exitWith(Strings("Link error:\n%s", detail))
end
return self
end
-- Adopt a transport that is ALREADY paired and skip the connect UI: an
-- overworld multiplayer session handing one pair of players off to a battle
-- or a trade. The caller has settled which mode and which side hosts, so
-- all that is left is the hello exchange every link session runs before it
-- commits -- the fingerprint/mod compatibility check still gets its say,
-- exactly as it would have on the LAN or ONLINE path.
-- exactly as it would have on the LAN path.
--
-- `transport` is anything Session accepts (update/poll/send/close plus the
-- .paired/.closed/.error fields), which is what lets a mod route a battle
@@ -150,48 +131,6 @@ function LinkState:exitWith(message, reason)
end
end
-- Online play meets strangers, so it requires a vanilla simulation on both
-- ends (Handshake.onlineAllowed). Mods merge into the shared Data
-- registries at boot and there is no unmerge, so switching them off has to
-- go through a relaunch -- but the player should not have to go find the
-- mod manager and work out which mods count. This turns the blocking mods
-- off, records them so the mod manager can put them back, and relaunches.
-- Verified translations are not blockers (#501), so a player keeps their
-- language across the restart and only the gameplay mods go. The restart
-- is confirmed rather than silent: it drops unsaved progress.
function LinkState:offerVanillaRestart()
local game = self.game
local loader = game.mods
local mods = Handshake.onlineBlockers(game)
local names = {}
for i, mod in ipairs(mods) do
if i > 2 then break end
names[#names + 1] = tostring(mod.id):upper():sub(1, 12)
end
local list = table.concat(names, ", ")
if #mods > #names then list = list .. (" +%d"):format(#mods - #names) end
local text = Strings(
"Online play runs\nvanilla for both\nplayers.\fTurn off %s\nand restart?", list)
self.game.linkSession = nil
Runtime.emit("link.ended", { reason = "error" })
if self.net then self.net:close() end
game.stack:pop()
game.stack:push(TextBox.new(game, text, nil, { choice = function(yes)
if not yes then return end
-- setEnabled persists the toggle itself (Loader:_saveState), so the
-- relaunch comes up vanilla and the mod manager lists them as disabled
-- for the player to switch back on afterwards
for _, mod in ipairs(mods) do
if loader and loader.setEnabled then loader:setEnabled(mod.id, false) end
end
if game.restartWithMods then
game:restartWithMods()
elseif love.event and love.event.quit then
love.event.quit("restart")
end
end }))
end
-- -------------------------------------------------------------------
-- handshake v2 (D8): both peers announce engine version, api version and
-- a fingerprint of their link surface, and the verdict comes from the two
@@ -263,8 +202,7 @@ function LinkState:update(dt)
-- so a final message travelling with the disconnect still counts)
if status == "closed"
and not PRE_CONNECT_STAGES[self.stage] and self.stage ~= "addrEntry"
and self.stage ~= "codeEntry" and self.stage ~= "notice"
and self.stage ~= "battleRunning" then
and self.stage ~= "notice" and self.stage ~= "battleRunning" then
self:exitWith(Strings("The link was\nbroken."))
return
end
@@ -281,38 +219,10 @@ function LinkState:update(dt)
end
if self.stage == "menu" then
if input:wasPressed("down") then
self.index = self.index % 3 + 1
elseif input:wasPressed("up") then
self.index = (self.index - 2) % 3 + 1
elseif input:wasPressed("b") then
self:exitWith(nil)
elseif input:wasPressed("a") then
if self.index == 1 then
self.stage = "lanMenu"
self.index = 1
elseif self.index == 2 or self.index == 3 then
if not Handshake.onlineAllowed(self.game) then
self:offerVanillaRestart()
return
end
if self.index == 2 then
self.stage = "onlineMenu"
else
local Tournament = require("src.link.Tournament")
self.game.stack:pop()
self.game.stack:push(Tournament.new(self.game))
end
self.index = 1
end
end
elseif self.stage == "lanMenu" then
if input:wasPressed("up") or input:wasPressed("down") then
self.index = self.index == 1 and 2 or 1
elseif input:wasPressed("b") then
self.stage = "menu"
self.index = 1
self:exitWith(nil)
elseif input:wasPressed("a") then
if self.index == 1 then
local session, detail = openSession("host", function(transport)
@@ -329,73 +239,6 @@ function LinkState:update(dt)
end
end
elseif self.stage == "onlineMenu" then
if input:wasPressed("up") or input:wasPressed("down") then
self.index = self.index == 1 and 2 or 1
elseif input:wasPressed("b") then
self.stage = "menu"
self.index = 2
elseif input:wasPressed("a") then
if self.index == 1 then
local session, detail = openSession("host", function(transport)
return transport:hostOnline()
end)
if session then
self.net = session
self.stage = "onlineHosting"
else
self:exitWith(Strings("Link error:\n%s", detail))
end
else
self.stage = "codeEntry"
self.codeEntry = CodeEntry.new()
end
end
elseif self.stage == "onlineHosting" then
if not self.discordCodeSet and self.net.code then
DiscordPresence.setJoinCode(self.net.code)
self.discordCodeSet = true
end
if input:wasPressed("b") then self:exitWith(nil) return end
if self.net.paired then
DiscordPresence.setJoinCode(nil) -- someone's here now; stop advertising
self.stage = "modeSelect"
self.index = 1
end
elseif self.stage == "codeEntry" then
if input:wasPressed("b") then
self.stage = "onlineMenu"
self.index = 2
elseif input:wasPressed("up") then
CodeEntry.up(self.codeEntry)
elseif input:wasPressed("down") then
CodeEntry.down(self.codeEntry)
elseif input:wasPressed("left") then
CodeEntry.left(self.codeEntry)
elseif input:wasPressed("right") then
CodeEntry.right(self.codeEntry)
elseif input:wasPressed("a") then
local code = CodeEntry.text(self.codeEntry)
local session, detail = openSession("guest", function(transport)
return transport:joinOnline(nil, code)
end)
if session then
self.net = session
self.stage = "onlineJoining"
else
self:exitWith(Strings("Link error:\n%s", detail))
end
end
elseif self.stage == "onlineJoining" then
if input:wasPressed("b") then self:exitWith(nil) return end
if self.net.paired then
self.stage = "waitMode"
self:sendHello(nil) -- the host owns the mode; this is just who we are
end
elseif self.stage == "hosting" then
if input:wasPressed("b") then self:exitWith(nil) return end
if self.net.paired then
@@ -511,7 +354,10 @@ function LinkState:update(dt)
if message then
-- the host owns this rule (same as mode); the guest only learns
-- it here, off the host's own party message
if not self.isHost then self.forceLevel = message.forceLevel end
if not self.isHost then
self.forceLevel = message.forceLevel
self.rulesetId = message.ruleset
end
local LinkBattle = require("src.link.LinkBattle")
local opts = {
myParty = Protocol.packParty(self.game.save.party),
@@ -521,6 +367,7 @@ function LinkState:update(dt)
verdict = self.verdict,
strict = Handshake.strict(self.verdict),
forceLevel = self.forceLevel,
ruleset = self.rulesetId,
}
local battle, why
if self.isHost then
@@ -581,11 +428,13 @@ function LinkState:startMode(mode, isHost)
-- the host deals the shared RNG seed for the lockstep simulation
if isHost then
self.linkSeed = love.math.random(1, 2 ^ 30)
self.rulesetId = Handshake.ruleset(self.game)
end
self.net:send({ type = "party",
mons = Protocol.packParty(self.game.save.party),
seed = self.linkSeed,
forceLevel = isHost and self.forceLevel or nil })
forceLevel = isHost and self.forceLevel or nil,
ruleset = isHost and self.rulesetId or nil })
end
end
@@ -734,49 +583,12 @@ end
function LinkState:draw()
if self.stage == "menu" then
drawTitle("BOIS CLUB LIVE")
Font.draw(Strings("LINK CABLE (LAN)"), 32, 44)
Font.draw(Strings("ONLINE MATCH"), 32, 60)
Font.draw(Strings("TOURNAMENT"), 32, 76)
Font.drawCode(CURSOR, 24, 44 + (self.index - 1) * 16)
elseif self.stage == "lanMenu" then
drawTitle("LINK CABLE (LAN)")
Font.draw(Strings("HOST A GAME"), 32, 48)
Font.draw(Strings("JOIN A GAME"), 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
Font.draw(Strings("UDP port %s", Net.defaultPort()), 8, 128)
elseif self.stage == "onlineMenu" then
drawTitle("ONLINE MATCH")
Font.draw(Strings("HOST ONLINE"), 32, 48)
Font.draw(Strings("JOIN ONLINE"), 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
elseif self.stage == "onlineHosting" then
drawTitle("HOSTING ONLINE")
Font.draw(Strings("Tell your friend"), 16, 40)
Font.draw(Strings("the code:"), 16, 52)
Font.draw(self.net.code or "??????", 32, 68)
Font.draw(Strings("Waiting for join..."), 8, 96)
elseif self.stage == "codeEntry" then
drawTitle("ENTER CODE")
for i = 1, CodeEntry.LENGTH do
local x = 16 + (i - 1) * 16
local ch = CodeEntry.CHARSET:sub(self.codeEntry.chars[i], self.codeEntry.chars[i])
Font.draw(ch, x, 64)
if i == self.codeEntry.pos then
Font.drawCode(0xEE, x, 76) -- ▼ under the active slot
end
end
Font.draw(Strings("A: connect B: back"), 8, 128)
elseif self.stage == "onlineJoining" then
drawTitle("CONNECTING...")
Font.draw(Strings("Calling..."), 8, 56)
Font.draw(self.net.target or "", 8, 72)
elseif self.stage == "hosting" then
drawTitle("HOSTING")
Font.draw(Strings("Friend joins at:"), 16, 48)
@@ -828,8 +640,13 @@ function LinkState:draw()
elseif self.stage == "notice" then
-- a version-skew notice has nothing to do with mods (#758)
drawTitle(self.verdict == "engine_skew" and "UPDATE YOUR GAME"
or "CHECK YOUR MODS")
local noticeTitle = "CHECK YOUR MODS"
if self.verdict == "engine_skew" then
noticeTitle = "UPDATE YOUR GAME"
elseif self.verdict == "ruleset_skew" then
noticeTitle = "CHECK YOUR RULES"
end
drawTitle(noticeTitle)
for i, line in ipairs(self.noticeLines or {}) do
if i > 8 then break end -- what fits above the prompt row
Font.draw(line, 8, 24 + (i - 1) * 12)
+133 -14
View File
@@ -38,12 +38,18 @@ local Net = {}
Net.__index = Net
Net.DEFAULT_PORT = 7777
Net.DEFAULT_RELAY_ADDRESS = "147.182.215.255:7778"
Net.DEFAULT_RELAY_ADDRESS = "relay.gen1re.com:7778"
Net.MAX_LINE = 256 * 1024
Net.MAX_RX_PER_FRAME = 512 * 1024
Net.ENET_BANDWIDTH = 256 * 1024
Net.CONNECT_TIMEOUT = 5
Net.PING_INTERVAL = 10
Net.PING_MISSES = 3
Net.CLOSE_FLUSH_TIMEOUT = 0.25
Net.CLOSE_FLUSH_ATTEMPTS = 256
function Net.available()
return enet ~= nil
end
@@ -97,6 +103,11 @@ function Net.new()
rxBuf = "", -- relay backend: bytes read but not yet a full line
txBuf = "", -- relay backend: bytes queued but not yet written
code = nil, -- relay backend, hosting: the room/tournament code
connecting = false, -- relay backend: TCP connect still in flight
selectable = true,
lastSendAt = nil,
pendingPings = 0,
sawPong = false,
}, Net)
end
@@ -160,13 +171,34 @@ function Net:join(address)
return true
end
-- opens a TCP connection to a pokeserver relay (blocking connect with a
-- short timeout -- this runs once, from a single explicit user action, not
-- from a per-frame poll, so blocking briefly is fine). Callers then send
-- opens a TCP connection to a pokeserver relay. The connect is
-- non-blocking: connectTCP returns true once the socket is dialing and
-- updateTCP polls writability until the 5 s deadline. Callers send
-- whatever control message starts their session ({type="host"},
-- {type="join",...}, {type="host_tournament",...}, ...); every reply that
-- isn't one of the four generic ones below lands in the normal inbox for
-- the caller (LinkState, or Tournament.lua) to interpret.
-- {type="join",...}, {type="host_tournament",...}, ...) straight away; it
-- waits in txBuf until the connection lands. Every reply that isn't one
-- of the generic ones below reaches the normal inbox for the caller
-- (LinkState, or the launcher online client) to interpret.
local function connectPending(err)
err = tostring(err or "")
return err == "timeout" or err:find("in progress", 1, true) ~= nil
end
function Net:beginTCP(tcp, host, port, pending)
self.tcpSocket = tcp
self.connectHost, self.connectPort = host, port
self.rxBuf = ""
self.txBuf = ""
self.connecting = pending and true or false
self.connectDeadline = now() + Net.CONNECT_TIMEOUT
self.lastSendAt = now()
if not pending then self:applyKeepalive() end
end
function Net:applyKeepalive()
pcall(function() self.tcpSocket:setoption("keepalive", true) end)
end
function Net:connectTCP(addr)
if not socket then
self.error = "online play needs luasocket (bundled with LOVE)"
@@ -176,19 +208,56 @@ function Net:connectTCP(addr)
host = host or addr
port = tonumber(port) or 7778
local tcp = socket.tcp()
tcp:settimeout(5)
tcp:settimeout(0)
local ok, err = tcp:connect(host, port)
if not ok then
self.error = Strings("can't reach relay %s:%d\n(%s)", host, port, tostring(err))
if ok then
self:beginTCP(tcp, host, port, false)
return true
end
if connectPending(err) then
self:beginTCP(tcp, host, port, true)
return true
end
pcall(function() tcp:close() end)
local blocking = socket.tcp()
blocking:settimeout(Net.CONNECT_TIMEOUT)
local bok, berr = blocking:connect(host, port)
if not bok then
pcall(function() blocking:close() end)
self.error = Strings("can't reach relay %s:%d\n(%s)", host, port, tostring(berr))
return false
end
tcp:settimeout(0)
self.tcpSocket = tcp
self.rxBuf = ""
self.txBuf = ""
blocking:settimeout(0)
self:beginTCP(blocking, host, port, false)
return true
end
function Net:pollConnectTCP()
local sock = self.tcpSocket
local okSel, _, writable = pcall(socket.select, nil, { sock }, 0)
if okSel and writable and #writable > 0 then
local ok, err = sock:connect(self.connectHost, self.connectPort)
err = tostring(err or "")
if ok or err:find("already connected", 1, true) then
self.connecting = false
self:applyKeepalive()
return true
end
if not connectPending(err) then
self.error = Strings("can't reach relay %s:%d\n(%s)",
self.connectHost, self.connectPort, err)
self.closed = true
return false
end
end
if now() > (self.connectDeadline or 0) then
self.error = Strings("can't reach relay %s:%d\n(%s)",
self.connectHost, self.connectPort, "timeout")
self.closed = true
end
return false
end
function Net:hostOnline(addr)
if not self:connectTCP(addr or Net.defaultRelayAddress()) then return false end
self.mode = "onlineHosting"
@@ -208,6 +277,7 @@ function Net:send(msg)
if self.closed then return end
if self.tcpSocket then
self.txBuf = self.txBuf .. Json.encode(msg) .. "\n"
self.lastSendAt = now()
return
end
if self.peerEnd then -- loopback: re-encode through json like the wire
@@ -242,6 +312,7 @@ local function handleGenericRelayControl(self, msg)
self.paired = true
return true
elseif msg.type == "join_error" then
if self.v2 then return false end
self.error = Strings(({
not_found = Strings.source("That code wasn't\nfound."),
full = Strings.source("That game already\nhas two players."),
@@ -255,6 +326,13 @@ local function handleGenericRelayControl(self, msg)
elseif msg.type == "peer_gone" then
self.closed = true
return true
elseif msg.type == "ping" then
self:send({ type = "pong", t = msg.t })
return true
elseif msg.type == "pong" then
self.sawPong = true
self.pendingPings = 0
return true
end
return false
end
@@ -295,9 +373,24 @@ end
-- non-blocking caller recover the partial line across calls -- a
-- byte-count read hands back whatever's available via the third return
-- value on timeout, which we can buffer ourselves.
function Net:heartbeatTCP()
local t = now()
if not self.lastSendAt then self.lastSendAt = t end
if t - self.lastSendAt < Net.PING_INTERVAL then return end
self:send({ type = "ping", t = math.floor(t * 1000) })
self.pendingPings = (self.pendingPings or 0) + 1
if self.sawPong and self.pendingPings >= Net.PING_MISSES then
self.error = Strings("The relay stopped\nanswering.")
self.closed = true
end
end
function Net:updateTCP()
if self.closed then return end
local sock = self.tcpSocket
if self.connecting then
if not self:pollConnectTCP() then return end
end
if #self.txBuf > 0 then
local sent, err, lastByte = sock:send(self.txBuf)
if sent then
@@ -329,6 +422,31 @@ function Net:updateTCP()
if not data then break end -- nothing more buffered this frame
end
self:drainLines()
if not self.closed then self:heartbeatTCP() end
end
function Net:flushTCP(seconds)
local sock = self.tcpSocket
if not sock or self.connecting then return end
local deadline = now() + (seconds or Net.CLOSE_FLUSH_TIMEOUT)
for _ = 1, Net.CLOSE_FLUSH_ATTEMPTS do
if #self.txBuf == 0 then return end
local sent, err, lastByte = sock:send(self.txBuf)
if sent then
self.txBuf = ""
return
elseif err == "timeout" then
self.txBuf = self.txBuf:sub((lastByte or 0) + 1)
else
return
end
if #self.txBuf == 0 or now() >= deadline then return end
if self.selectable and socket and socket.select then
if not pcall(socket.select, nil, { sock }, 0.01) then
self.selectable = false
end
end
end
end
-- pump enet events; decoded JSON messages are queued for poll()
@@ -400,6 +518,7 @@ function Net:close()
return
end
if self.tcpSocket then
pcall(function() self:flushTCP() end)
pcall(function() self.tcpSocket:close() end)
self.tcpSocket = nil
self.closed = true
+15 -1
View File
@@ -103,7 +103,7 @@ function Protocol.unpackMon(data, packed, opts)
end
-- forceLevel comes from an "auto-level" ruling. The picker's ANY choice
-- ("use each mon's real level", Gen1's only mode) is a string sentinel on
-- the LinkState/Tournament side (see levelForWire) that must mean "no
-- the LinkState side (see levelForWire) that must mean "no
-- forced level" here. Coerce once so a non-numeric level string -- the ANY
-- sentinel, an old peer, or a mod (#204) -- can never reach math.floor
-- below: tonumber("ANY") == nil, i.e. keep the packed real level, while
@@ -404,6 +404,20 @@ function Protocol.packParty(party, indices)
return mons
end
function Protocol.packParty2(party, indices)
local mons = {}
if indices then
for _, i in ipairs(indices) do
table.insert(mons, Protocol.packMon2(party[i]))
end
return mons
end
for _, mon in ipairs(party) do
table.insert(mons, Protocol.packMon2(mon))
end
return mons
end
-- ------- subset negotiation
-- every record hash, not just this party's slice: the receiver filters
+16 -4
View File
@@ -4,13 +4,13 @@ local Wire = require("src.link.Wire")
local Session = {}
Session.__index = Session
local VALID_ROLES = { host = true, guest = true }
local VALID_ROLES = { host = true, guest = true, client = true }
local REQUIRED_METHODS = { "update", "poll", "send", "close" }
function Session.new(transport, options)
assert(type(transport) == "table", "Session.new requires a transport")
assert(type(options) == "table", "Session.new requires options")
assert(VALID_ROLES[options.role], "Session role must be host or guest")
assert(VALID_ROLES[options.role], "Session role must be host, guest or client")
assert(type(options.kind) == "string" and options.kind ~= "",
"Session kind must be a non-empty string")
for _, method in ipairs(REQUIRED_METHODS) do
@@ -155,10 +155,11 @@ local function finishRead(self)
self:_refreshStatus()
end
function Session:take(messageType)
function Session:take(messageType, predicate)
assert(type(messageType) == "string", "Session.take requires a message type")
for index, message in ipairs(self._inbox) do
if message.type == messageType then
if message.type == messageType
and (predicate == nil or predicate(message) == true) then
local found = table.remove(self._inbox, index)
finishRead(self)
return found
@@ -181,6 +182,17 @@ function Session:poll()
return messages
end
function Session:unread(messages)
if type(messages) ~= "table" then return end
for index = #messages, 1, -1 do
local message = messages[index]
if type(message) == "table" and type(message.type) == "string" then
table.insert(self._inbox, 1, message)
end
end
finishRead(self)
end
function Session:close()
if self._status == "closed" or self._status == "failed" then return end
if self._terminal then
-651
View File
@@ -1,651 +0,0 @@
-- Tournament play: host or join a bracket over the pokeserver relay.
-- Single-elimination, server-managed (pokeserver's `tournaments` map).
-- Matches run one at a time in bracket order; everyone not currently
-- playing -- still waiting their turn, or already eliminated -- watches
-- the live match play out via LinkBattle.newSpectator, reconstructed from
-- a copy of the real traffic the server fans out. Battle mode only (no
-- trade); vanilla only (Handshake.onlineAllowed already gated entry here
-- from LinkState). Elite Four music (Music_IndigoPlateau) loops the whole
-- time, uninterrupted by individual matches.
local CodeEntry = require("src.link.CodeEntry")
local DiscordPresence = require("src.core.DiscordPresence")
local Font = require("src.render.Font")
local Handshake = require("src.link.Handshake")
local LinkBattle = require("src.link.LinkBattle")
local Net = require("src.link.Net")
local Session = require("src.link.Session")
local Protocol = require("src.link.Protocol")
local Runtime = require("src.mods.Runtime")
local Sound = require("src.core.Sound")
local TextBox = require("src.render.TextBox")
local Strings = require("src.core.Strings")
local Tournament = {}
Tournament.__index = Tournament
Tournament.isOpaque = true
local CURSOR = 0xED
local MUSIC = "Music_IndigoPlateau"
local TURN_LIMITS = { 3, 6, 9 }
local PARTY_SIZES = { 1, 2, 3, 4, 5, 6 }
local ANY = "ANY" -- sentinel: a leading *nil* array entry breaks ipairs
-- (LuaJIT's # still reports the literal's full size, but
-- ipairs stops dead at the hole), so level bounds use
-- this string in self.settings instead of nil, converted
-- to nil only on the wire
local LEVEL_STEPS = { ANY, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65,
70, 75, 80, 85, 90, 95, 100 }
local SETTINGS_ROWS = 6 -- POKEMON, MIN LV, MAX LV, TIMER, LEVELS, PLAYING
-- LEVELS cycles through this: ANY (real levels) or a fixed level every
-- match normalizes to, regardless of each side's real party
local FORCE_LEVEL_STEPS = { ANY, 50, 100 }
-- tournaments have no real participant cap (any number can join before
-- start); this is just generous headroom so Discord's party.size never
-- reads as "full" and blocks a real invite click
local TOURNAMENT_PARTY_MAX = 16
local function indexOf(list, value)
for i, v in ipairs(list) do
if v == value then return i end
end
return 1
end
-- accepts either the internal ANY sentinel or a raw wire value (nil means
-- "any" there too, since the server never round-trips the sentinel string)
local function levelLabel(v)
return (v == ANY or v == nil) and "ANY" or tostring(v)
end
local function levelForWire(v)
-- ANY ("use each mon's real level") goes on the wire as nil (no forced
-- level). An explicit guard, not `v == ANY and nil or v`: that idiom's
-- true branch is nil, so it falls through to `or v` and returned the
-- literal "ANY" string, which then crashed math.floor in unpackMon (#204).
if v == ANY then return nil end
return v
end
local function forceLevelLabel(v)
return (v == ANY or v == nil) and "ANY" or ("AUTO " .. tostring(v))
end
local function partyStats(party)
local size, minLevel, maxLevel = 0, nil, nil
for _, mon in ipairs(party or {}) do
size = size + 1
local lvl = mon.level or 1
minLevel = minLevel and math.min(minLevel, lvl) or lvl
maxLevel = maxLevel and math.max(maxLevel, lvl) or lvl
end
return size, minLevel or 0, maxLevel or 0
end
function Tournament.new(game)
local self = setmetatable({}, Tournament)
self.game = game
self.stage = "menu"
self.index = 1
self.settings = { turnLimit = 6, requiredPartySize = 3, minLevel = ANY, maxLevel = ANY,
forceLevel = ANY, participating = true }
self.settingsIndex = 1
self.roster = {}
self.spectatorRoster = {}
-- everything from here to exitWith runs at 1X regardless of the GAME
-- SPEED option (see Game:logicSpeed): a tournament's shot clock counts
-- down on the logic step, so fast-forward would hand one player less
-- real time to choose than the opponent they are racing
game.linkSession = true
Sound.startLoop(game.data, MUSIC)
return self
end
-- entry point for a Discord "Ask to Join" click on a tournament invite
-- (see DiscordPresence.lua): skips the HOST/JOIN menu and code entry,
-- straight to "connecting with this code"
function Tournament.newJoinOnline(game, code)
local self = Tournament.new(game)
self:startJoining(code)
return self
end
-- headcount for Discord's party.size: the roster only ever lists
-- competing players, so a non-participating (organizer-only) host isn't
-- in it even though they're right here running the thing
function Tournament:discordPartySize()
return #self.roster + (self.participating == false and 1 or 0)
end
function Tournament:exitWith(message)
DiscordPresence.setJoinCode(nil)
self.game.linkSession = nil -- back to the player's own GAME SPEED
Sound.stopLoop(MUSIC)
Runtime.emit("link.ended", { reason = message and "error" or "bye" })
if self.net then self.net:close() end
self.game.stack:pop()
if message then
self.game.stack:push(TextBox.new(self.game, message))
end
end
-- -------------------------------------------------------------------
-- host / join
-- -------------------------------------------------------------------
local function openSession(role)
local transport = Net.new()
if transport:connectTCP(Net.defaultRelayAddress()) then
return Session.new(transport, { role = role, kind = "tournament" })
end
local detail = transport.error or "?"
transport:close()
return nil, detail
end
function Tournament:startHosting()
local session, detail = openSession("host")
if not session then
self:exitWith(Strings("Link error:\n%s", detail))
return
end
self.net = session
local size, minL, maxL = partyStats(self.game.save.party)
self.isCreator = true
self.participating = self.settings.participating
self.net:send({
type = "host_tournament",
turnLimit = self.settings.turnLimit,
requiredPartySize = self.settings.requiredPartySize,
minLevel = levelForWire(self.settings.minLevel),
maxLevel = levelForWire(self.settings.maxLevel),
forceLevel = levelForWire(self.settings.forceLevel),
participating = self.settings.participating,
name = self.game.save.player.name,
partySize = size, partyMinLevel = minL, partyMaxLevel = maxL,
})
self.stage = "registering"
end
function Tournament:startJoining(code)
local session, detail = openSession("guest")
if not session then
self:exitWith(Strings("Link error:\n%s", detail))
return
end
self.net = session
local size, minL, maxL = partyStats(self.game.save.party)
self.isCreator = false
self.participating = true -- joining is always to compete; only hosting can opt out
self.code = code
self.net:send({
type = "join_tournament", code = code, name = self.game.save.player.name,
partySize = size, partyMinLevel = minL, partyMaxLevel = maxL,
})
self.stage = "registering"
end
-- -------------------------------------------------------------------
-- message handling (shared between "registering"/"bracket" and drained
-- again from a just-finished match's pendingTournamentMessages)
-- -------------------------------------------------------------------
local JOIN_ERROR_TEXT = {
not_found = Strings.source("That code wasn't\nfound."),
already_started = Strings.source("That tournament\nhas already begun."),
expired = Strings.source("That code has\nexpired."),
}
function Tournament:handleMessage(msg)
if msg.type == "tournament_hosted" then
self.code = msg.code
self.settings.turnLimit = msg.turnLimit
self.settings.forceLevel = msg.forceLevel == nil and ANY or msg.forceLevel
self.participating = msg.participating
self.stage = "bracket"
-- the server already seeded t.players/spectators with the creator at
-- creation time; mirror that here so the Discord party size (and the
-- "waiting for players" list) show the host from the very first frame,
-- not just once someone else joins and a real roster broadcast arrives
if self.participating then
self.roster = { self.game.save.player.name }
else
self.spectatorRoster = { self.game.save.player.name }
end
if self.isCreator then
DiscordPresence.setJoinCode(self.code, "tournament",
self:discordPartySize(), TOURNAMENT_PARTY_MAX)
end
elseif msg.type == "tournament_host_error" then
if msg.reason == "party_ineligible" then
self:exitWith(Strings("Can't host:\nneed %d Pokemon\nLv %s-%s.", msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
else
self:exitWith(Strings("Couldn't host\nthat tournament."))
end
elseif msg.type == "tournament_join_error" then
if msg.reason == "party_ineligible" then
self:exitWith(Strings("Your party needs\n%d Pokemon, Lv\n%s-%s.", msg.requiredPartySize, levelLabel(msg.minLevel), levelLabel(msg.maxLevel)))
else
self:exitWith(JOIN_ERROR_TEXT[msg.reason]
and Strings(JOIN_ERROR_TEXT[msg.reason])
or Strings("Couldn't join\nthat tournament."))
end
elseif msg.type == "tournament_roster" then
self.roster = msg.players
self.spectatorRoster = msg.spectators or {}
self.settings.turnLimit = msg.turnLimit
self.settings.requiredPartySize = msg.requiredPartySize
self.settings.minLevel = msg.minLevel == nil and ANY or msg.minLevel
self.settings.maxLevel = msg.maxLevel == nil and ANY or msg.maxLevel
self.settings.forceLevel = msg.forceLevel == nil and ANY or msg.forceLevel
self.stage = "bracket"
if self.isCreator and self.code then
DiscordPresence.setJoinCode(self.code, "tournament",
self:discordPartySize(), TOURNAMENT_PARTY_MAX)
end
elseif msg.type == "bracket_update" then
self.bracket = msg.tournament
self.code = self.bracket.code
if self.stage == "registering" then self.stage = "bracket" end
elseif msg.type == "match_start" then
self:enterMatch(msg)
elseif msg.type == "match_start_spectate" then
self:enterSpectate(msg)
elseif msg.type == "tournament_bye" then
self.byeRound = msg.round
elseif msg.type == "tournament_over" then
self.champion = msg.champion
self.stage = "done"
end
end
-- -------------------------------------------------------------------
-- entering a match: real participant
-- -------------------------------------------------------------------
function Tournament:sendHello(mode)
self.myHello = Handshake.hello(self.game, mode)
self.net:send(self.myHello)
end
function Tournament:pollHello()
local message
if not self.peerHello then message = self.net:take("hello") end
if message then self.peerHello = message end
return message ~= nil
end
-- The relay assigns a side for each match; this can differ from the immutable
-- tournament creator/joiner role held by Session.
function Tournament:enterMatch(msg)
self.isHost = (msg.role == "host")
self.opponentName = msg.opponent
self.matchRound = msg.round
self.matchTurnLimit = msg.turnLimit
self.peerHello = nil
self:sendHello(self.isHost and "battle" or nil)
self.stage = "matchHello"
end
function Tournament:beginMatchBattle()
local verdict = Handshake.checkCompat(self.myHello, self.peerHello)
if not (verdict == "full" or verdict == "vanilla_peer") then
-- shouldn't happen (both sides already passed the online-play mods
-- gate), but a mismatched engine/build is still possible -- bail out
-- of just this match rather than crash the tournament
self:exitWith(Strings("Link error:\nversion mismatch\nwith opponent."))
return
end
self.linkSeed = self.isHost and love.math.random(1, 2 ^ 30) or nil
local opts = {
myParty = Protocol.packParty(self.game.save.party),
theirName = self.opponentName or "FOE",
seed = self.isHost and self.linkSeed or nil,
verdict = verdict,
strict = Handshake.strict(verdict),
turnLimit = self.matchTurnLimit,
forceLevel = levelForWire(self.settings.forceLevel),
keepNetOpen = true, -- this is the tournament's own connection, not a
-- dedicated match socket -- don't let finish() close it
}
self.stage = "matchWaitParty"
self.pendingBattleOpts = opts
self.net:send({ type = "party",
mons = Protocol.packParty(self.game.save.party),
seed = self.linkSeed })
end
-- -------------------------------------------------------------------
-- entering a match: spectator
-- -------------------------------------------------------------------
function Tournament:enterSpectate(msg)
self.matchRound = msg.round
self.spectate = {
hostName = msg.playerHost, guestName = msg.playerGuest,
hostParty = nil, guestParty = nil, seed = nil,
}
self.stage = "spectateWait"
end
-- -------------------------------------------------------------------
-- update
-- -------------------------------------------------------------------
function Tournament:update(dt)
local input = self.game.input
if self.stage == "matchRunning" or self.stage == "spectateRunning" then
if self.game.stack:top() == self then
-- the battle popped; drain anything the network handed to it but it
-- didn't itself understand (a bracket_update, the next match...)
local battle = self.activeBattle
self.activeBattle = nil
if self.stage == "matchRunning" and battle and battle.result and self.net
and not self.net.closed then
-- a real participant reports its own outcome; the server resolves
-- the match once both sides have (or one disconnects)
self.net:send({ type = "tournament_result", result = battle.result })
end
if battle and battle.pendingTournamentMessages then
for _, msg in ipairs(battle.pendingTournamentMessages) do
self:handleMessage(msg)
end
end
if self.stage == "matchRunning" or self.stage == "spectateRunning" then
self.stage = "bracket"
end
end
return
end
if self.net then
self.net:update()
local status = self.net:getStatus()
if status == "failed" and self.stage ~= "menu" and self.stage ~= "hostSettings"
and self.stage ~= "codeEntry" then
self:exitWith(Strings("Link error:\n%s", (self.net.error or "?"):sub(1, 60)))
return
end
if status == "closed" and self.stage ~= "done" then
self:exitWith(Strings("The tournament\nconnection was\nlost."))
return
end
end
if self.stage == "matchHello" then
if input:wasPressed("b") then self:exitWith(nil) return end
self:pollHello()
if self.peerHello then
self:beginMatchBattle()
return
end
for _, message in ipairs(self.net:poll()) do self:handleMessage(message) end
return
elseif self.stage == "matchWaitParty" then
if input:wasPressed("b") then self:exitWith(nil) return end
while self.net:hasPending() do
local message = self.net:pollOne()
if message.type == "party" then
self.pendingBattleOpts.theirParty = message.mons
if self.isHost then
self.pendingBattleOpts.seed = self.pendingBattleOpts.seed or self.linkSeed
else
self.pendingBattleOpts.seed = message.seed
end
local battle, why
if self.isHost then
battle, why = LinkBattle.newHost(self.game, self.net, self.pendingBattleOpts)
else
battle, why = LinkBattle.newGuest(self.game, self.net, self.pendingBattleOpts)
end
if not battle then
self:exitWith(why or Strings("Link battle\ncan't start."))
return
end
self.activeBattle = battle
self.game.stack:push(battle)
self.stage = "matchRunning"
return
else
self:handleMessage(message)
end
end
return
elseif self.stage == "spectateWait" then
if input:wasPressed("b") then self:exitWith(nil) return end
while self.net:hasPending() do
local message = self.net:pollOne()
if message.type == "spectate" and message.msg.type == "party" then
local inner = message.msg
if message.side == "host" then
self.spectate.hostParty = inner.mons
self.spectate.seed = inner.seed
else
self.spectate.guestParty = inner.mons
end
if self.spectate.hostParty and self.spectate.guestParty then
local battle, why = LinkBattle.newSpectator(self.game, self.net, {
hostParty = self.spectate.hostParty,
guestParty = self.spectate.guestParty,
hostName = self.spectate.hostName,
guestName = self.spectate.guestName,
seed = self.spectate.seed,
forceLevel = levelForWire(self.settings.forceLevel),
})
if not battle then
self:exitWith(why or Strings("Can't watch this\nmatch."))
return
end
self.activeBattle = battle
self.game.stack:push(battle)
self.stage = "spectateRunning"
return
end
else
self:handleMessage(message)
end
end
return
end
if self.net then
for _, msg in ipairs(self.net:poll()) do self:handleMessage(msg) end
end
if self.stage == "menu" then
if input:wasPressed("up") or input:wasPressed("down") then
self.index = self.index == 1 and 2 or 1
elseif input:wasPressed("b") then
self:exitWith(nil)
elseif input:wasPressed("a") then
if self.index == 1 then
self.stage = "hostSettings"
self.settingsIndex = 1
else
self.stage = "codeEntry"
self.codeEntry = CodeEntry.new()
end
end
elseif self.stage == "hostSettings" then
if input:wasPressed("b") then
self.stage = "menu"
self.index = 1
elseif input:wasPressed("up") then
self.settingsIndex = self.settingsIndex == 1 and SETTINGS_ROWS or self.settingsIndex - 1
elseif input:wasPressed("down") then
self.settingsIndex = self.settingsIndex % SETTINGS_ROWS + 1
elseif input:wasPressed("left") or input:wasPressed("right") then
local delta = input:wasPressed("right") and 1 or -1
if self.settingsIndex == 1 then
local i = indexOf(PARTY_SIZES, self.settings.requiredPartySize)
i = ((i - 1 + delta) % #PARTY_SIZES) + 1
self.settings.requiredPartySize = PARTY_SIZES[i]
elseif self.settingsIndex == 2 then
local i = indexOf(LEVEL_STEPS, self.settings.minLevel)
i = ((i - 1 + delta) % #LEVEL_STEPS) + 1
self.settings.minLevel = LEVEL_STEPS[i]
elseif self.settingsIndex == 3 then
local i = indexOf(LEVEL_STEPS, self.settings.maxLevel)
i = ((i - 1 + delta) % #LEVEL_STEPS) + 1
self.settings.maxLevel = LEVEL_STEPS[i]
elseif self.settingsIndex == 4 then
local i = indexOf(TURN_LIMITS, self.settings.turnLimit)
i = ((i - 1 + delta) % #TURN_LIMITS) + 1
self.settings.turnLimit = TURN_LIMITS[i]
elseif self.settingsIndex == 5 then
local i = indexOf(FORCE_LEVEL_STEPS, self.settings.forceLevel)
i = ((i - 1 + delta) % #FORCE_LEVEL_STEPS) + 1
self.settings.forceLevel = FORCE_LEVEL_STEPS[i]
elseif self.settingsIndex == 6 then
self.settings.participating = not self.settings.participating
end
elseif input:wasPressed("a") or input:wasPressed("start") then
self:startHosting()
end
elseif self.stage == "codeEntry" then
if input:wasPressed("b") then
self.stage = "menu"
self.index = 2
elseif input:wasPressed("up") then
CodeEntry.up(self.codeEntry)
elseif input:wasPressed("down") then
CodeEntry.down(self.codeEntry)
elseif input:wasPressed("left") then
CodeEntry.left(self.codeEntry)
elseif input:wasPressed("right") then
CodeEntry.right(self.codeEntry)
elseif input:wasPressed("a") then
self:startJoining(CodeEntry.text(self.codeEntry))
end
elseif self.stage == "registering" then
if input:wasPressed("b") then self:exitWith(nil) end
elseif self.stage == "bracket" then
if input:wasPressed("b") then self:exitWith(nil) return end
if input:wasPressed("a") and self.isCreator and #self.roster >= 2 then
DiscordPresence.setJoinCode(nil) -- roster's locking in; stop advertising
self.net:send({ type = "start_tournament" })
end
elseif self.stage == "done" then
if input:wasPressed("a") or input:wasPressed("b") then
self:exitWith(nil)
end
end
end
-- -------------------------------------------------------------------
-- draw
-- -------------------------------------------------------------------
local function drawTitle(text)
love.graphics.setColor(1, 1, 1, 1)
love.graphics.rectangle("fill", 0, 0, 160, 144)
love.graphics.setColor(0, 0, 0, 1)
Font.draw(text, 8, 6)
end
local SETTINGS_LABELS = { "POKEMON", "MIN LV", "MAX LV", "TIMER", "LEVELS", "PLAYING" }
function Tournament:draw()
if self.stage == "menu" then
drawTitle("TOURNAMENT")
Font.draw(Strings("HOST"), 32, 48)
Font.draw(Strings("JOIN"), 32, 68)
Font.drawCode(CURSOR, 24, self.index == 1 and 48 or 68)
elseif self.stage == "hostSettings" then
drawTitle("TOURNAMENT RULES")
local values = {
tostring(self.settings.requiredPartySize),
levelLabel(self.settings.minLevel),
levelLabel(self.settings.maxLevel),
self.settings.turnLimit .. "s",
forceLevelLabel(self.settings.forceLevel),
self.settings.participating and "YES" or "NO",
}
for i, label in ipairs(SETTINGS_LABELS) do
local y = 32 + (i - 1) * 16
Font.draw(label, 16, y)
Font.draw(values[i], 96, y)
if i == self.settingsIndex then Font.drawCode(CURSOR, 8, y) end
end
Font.draw(Strings("START: create"), 8, 128)
elseif self.stage == "codeEntry" then
drawTitle("ENTER CODE")
for i = 1, CodeEntry.LENGTH do
local x = 16 + (i - 1) * 16
local ch = CodeEntry.CHARSET:sub(self.codeEntry.chars[i], self.codeEntry.chars[i])
Font.draw(ch, x, 64)
if i == self.codeEntry.pos then
Font.drawCode(0xEE, x, 76)
end
end
Font.draw(Strings("A: join B: back"), 8, 128)
elseif self.stage == "registering" then
drawTitle("CONNECTING...")
Font.draw(Strings("B: cancel"), 8, 128)
elseif self.stage == "bracket" or self.stage == "matchHello"
or self.stage == "matchWaitParty" or self.stage == "spectateWait" then
drawTitle(Strings("TOURNAMENT %s", self.code or "??????"))
if self.bracket then
local y = 20
for _, round in ipairs(self.bracket.rounds) do
Font.draw(Strings("ROUND %d", round.round), 8, y)
y = y + 10
for _, m in ipairs(round.matches) do
local line
if m.bye then
line = Strings("%s (bye)", m.a or m.b or "?")
else
local mark = m.state == "live" and "*" or (m.winner and "" or "")
line = Strings("%s%s vs %s%s", m.winner == m.a and ">" or " ", m.a or "?",
m.b or "?", m.winner == m.b and "<" or (mark == "*" and " *" or ""))
end
Font.draw(line, 12, y)
y = y + 10
if y > 120 then break end
end
end
else
if self.participating == false then
Font.draw(Strings("(organizing --"), 16, 32)
Font.draw(Strings("not playing)"), 16, 42)
end
Font.draw(Strings("Waiting for"), 16, 48)
Font.draw(Strings("players to join:"), 16, 60)
local y = 60
for i, name in ipairs(self.roster) do
y = 60 + i * 10
Font.draw(name, 24, y)
end
for _, name in ipairs(self.spectatorRoster) do
y = y + 10
Font.draw(name .. " (watch)", 24, y)
end
end
if self.isCreator and #self.roster >= 2 and not self.bracket then
Font.draw(Strings("A: START B: cancel"), 8, 132)
else
Font.draw(Strings("B: cancel"), 8, 132)
end
elseif self.stage == "done" then
drawTitle("TOURNAMENT OVER")
if self.champion then
Font.draw(Strings("%s is the", self.champion), 16, 56)
Font.draw(Strings("champion!"), 16, 68)
end
Font.draw(Strings("A: continue"), 8, 128)
end
love.graphics.setColor(1, 1, 1, 1)
end
return Tournament
+496 -5
View File
@@ -137,6 +137,7 @@ local function name(v)
end
local sanitize
local arenaStart
local SCHEMAS = {}
@@ -150,6 +151,7 @@ SCHEMAS.hello = function(m)
generation = Wire.num(m.generation, nil, 0, 255),
fingerprint = Wire.str(m.fingerprint, nil, MAX_STRING),
linkModified = Wire.bool(m.linkModified, nil),
ruleset = Wire.str(m.ruleset, nil, MAX_STRING),
mods = Wire.list(m.mods, MAX_MODS, modEntry),
}
end
@@ -167,6 +169,7 @@ SCHEMAS.party = function(m)
mons = Wire.list(m.mons, MAX_PARTY, mon),
seed = Wire.num(m.seed, nil, 0, MAX_INT),
forceLevel = Wire.num(m.forceLevel, nil, 0, 65535),
ruleset = Wire.str(m.ruleset, nil, MAX_STRING),
}
end
@@ -207,15 +210,24 @@ SCHEMAS.replace = function(m)
end
SCHEMAS.bye = function() return {} end
SCHEMAS.forfeit = function() return {} end
SCHEMAS.forfeit = function(m)
return { match = Wire.str(m.match, nil, MAX_NAME) }
end
SCHEMAS.hosted = function(m)
return { code = Wire.str(m.code, nil, MAX_NAME) }
end
SCHEMAS.paired = function() return {} end
SCHEMAS.ping = function(m)
return { t = Wire.num(m.t, 0, 0) }
end
SCHEMAS.pong = SCHEMAS.ping
SCHEMAS.peer_gone = function() return {} end
SCHEMAS.join_error = function(m)
return { reason = Wire.str(m.reason, "", MAX_STRING) }
return { reason = Wire.str(m.reason, "", MAX_STRING),
field = Wire.str(m.field, nil, MAX_STRING),
detail = Wire.str(m.detail, nil, MAX_STRING),
code = Wire.str(m.code, nil, MAX_NAME) }
end
local function rule(m)
@@ -247,6 +259,7 @@ SCHEMAS.tournament_roster = function(m)
local out = rule(m)
out.players = Wire.list(m.players, MAX_MATCHES, name)
out.spectators = Wire.list(m.spectators, MAX_MATCHES, name)
out.creator = name(m.creator)
return out
end
@@ -256,6 +269,8 @@ local function match(v)
a = name(v.a), b = name(v.b), winner = name(v.winner),
bye = Wire.bool(v.bye, false),
state = Wire.str(v.state, nil, MAX_STRING),
match = Wire.str(v.match, nil, MAX_NAME),
how = Wire.str(v.how, nil, MAX_STRING),
}
end
@@ -279,24 +294,32 @@ SCHEMAS.bracket_update = function(m)
end
SCHEMAS.match_start = function(m)
return {
local out = {
opponent = name(m.opponent),
round = Wire.num(m.round, 0, 0, MAX_ROUNDS),
turnLimit = Wire.num(m.turnLimit, nil, 0, 65535),
role = Wire.str(m.role, "", MAX_STRING),
match = Wire.str(m.match, nil, MAX_NAME),
}
arenaStart(m, out)
return out
end
SCHEMAS.match_start_spectate = function(m)
return {
local out = {
round = Wire.num(m.round, 0, 0, MAX_ROUNDS),
playerHost = name(m.playerHost),
playerGuest = name(m.playerGuest),
match = Wire.str(m.match, nil, MAX_NAME),
role = Wire.str(m.role, nil, MAX_STRING),
}
arenaStart(m, out)
return out
end
SCHEMAS.tournament_bye = function(m)
return { round = Wire.num(m.round, 0, 0, MAX_ROUNDS) }
return { round = Wire.num(m.round, 0, 0, MAX_ROUNDS),
match = Wire.str(m.match, nil, MAX_NAME) }
end
SCHEMAS.tournament_over = function(m)
@@ -315,6 +338,474 @@ SCHEMAS.spectate = function(m)
return { side = Wire.str(m.side, "", MAX_STRING), msg = inner }
end
local CodeEntry = require("src.link.CodeEntry")
local MAX_DISPLAY_NAME = 16
local MAX_TICKET = 128
local MAX_SESSION = 64
local MAX_PROFILES = 16
local MAX_LOBBY_ENTRIES = 200
local MAX_NOTE = 40
local MAX_SPECTATORS = 64
local MAX_REPLAY = 512
local MAX_ROOM_PLAYERS = 4
local MAX_ENTRY_PLAYERS = 64
local MAX_TEAM = 6
local MAX_DEADLINES = 8
local function displayName(v)
return Wire.str(v, nil, MAX_DISPLAY_NAME)
end
local function codeStr(v)
if type(v) ~= "string" then return nil end
local s = v:upper()
if #s ~= CodeEntry.LENGTH then return nil end
for i = 1, #s do
if not CodeEntry.CHARSET:find(s:sub(i, i), 1, true) then return nil end
end
return s
end
Wire.code = codeStr
local function cartRef(v)
if type(v) ~= "table" then return nil end
return {
id = Wire.str(v.id, nil, MAX_NAME),
version = Wire.str(v.version, nil, MAX_NAME),
hash = Wire.str(v.hash, nil, MAX_STRING),
}
end
local function arenaRule(v)
if type(v) ~= "table" then v = {} end
return {
partySize = Wire.num(v.partySize, nil, 1, MAX_TEAM),
minLevel = Wire.num(v.minLevel, nil, 1, 100),
maxLevel = Wire.num(v.maxLevel, nil, 1, 100),
forceLevel = Wire.num(v.forceLevel, nil, 1, 100),
}
end
local function profile(v)
if type(v) ~= "table" then return nil end
return {
engine = Wire.num(v.engine, nil, 1, 2),
version = Wire.str(v.version, nil, MAX_NAME),
engineVersion = Wire.str(v.engineVersion, nil, MAX_STRING),
apiVersion = Wire.num(v.apiVersion, nil, 0, MAX_INT),
fingerprint = Wire.str(v.fingerprint, nil, MAX_STRING),
rulesetId = Wire.str(v.rulesetId, nil, MAX_NAME),
kind = Wire.str(v.kind, nil, MAX_NAME),
cart = cartRef(v.cart),
rule = arenaRule(v.rule),
}
end
Wire.profile = profile
arenaStart = function(m, out)
out.seed = Wire.num(m.seed, nil, 0, MAX_INT)
out.ruleset = Wire.str(m.ruleset, nil, MAX_STRING)
out.rule = m.rule ~= nil and arenaRule(m.rule) or nil
out.code = codeStr(m.code) or out.code
out.peerName = displayName(m.peerName)
out.hostName = displayName(m.hostName)
out.guestName = displayName(m.guestName)
out.theirParty = m.theirParty ~= nil and Wire.list(m.theirParty, MAX_TEAM, mon) or nil
out.hostParty = m.hostParty ~= nil and Wire.list(m.hostParty, MAX_TEAM, mon) or nil
out.guestParty = m.guestParty ~= nil and Wire.list(m.guestParty, MAX_TEAM, mon) or nil
return out
end
local function youEntry(v)
if type(v) ~= "table" then return nil end
return {
id = Wire.str(v.id, nil, MAX_NAME),
name = displayName(v.name),
verified = Wire.bool(v.verified, false),
account = Wire.str(v.account, nil, MAX_NAME),
}
end
local function lobbyEntry(v)
if type(v) ~= "table" then return nil end
local id = Wire.str(v.id, nil, MAX_NAME)
if not id then return nil end
return {
id = id,
name = displayName(v.name),
verified = Wire.bool(v.verified, false),
intent = Wire.str(v.intent, nil, MAX_NAME),
profile = profile(v.profile),
since = Wire.num(v.since, nil, 0),
note = Wire.str(v.note, nil, MAX_STRING),
code = codeStr(v.code),
open = Wire.bool(v.open, nil),
stage = Wire.str(v.stage, nil, MAX_NAME),
players = Wire.num(v.players, nil, 0, MAX_ENTRY_PLAYERS),
spectators = Wire.num(v.spectators, nil, 0, MAX_SPECTATORS),
maxSpectators = Wire.num(v.maxSpectators, nil, 0, MAX_SPECTATORS),
}
end
local function playerEntry(v)
if type(v) ~= "table" then return nil end
return {
id = Wire.str(v.id, nil, MAX_NAME),
name = displayName(v.name),
verified = Wire.bool(v.verified, false),
role = Wire.str(v.role, nil, MAX_NAME),
ready = Wire.bool(v.ready, false),
online = Wire.bool(v.online, true),
party = Wire.list(v.party, MAX_TEAM, mon),
partyDigest = Wire.str(v.partyDigest, nil, MAX_STRING),
}
end
local function spectatorEntry(v)
if type(v) ~= "table" then return nil end
return {
id = Wire.str(v.id, nil, MAX_NAME),
name = displayName(v.name),
verified = Wire.bool(v.verified, false),
}
end
local function deadlineEntry(v)
if type(v) ~= "table" then return nil end
return {
kind = Wire.str(v.kind, nil, MAX_NAME),
at = Wire.num(v.at, nil, 0),
}
end
local function innerMsg(v)
if type(v) ~= "table" then return nil end
return sanitize(v)
end
local function replayEntry(v)
if type(v) ~= "table" then return nil end
local inner = innerMsg(v.msg)
if not inner then
inner = innerMsg(v)
if not inner then return nil end
return { seq = Wire.num(v.seq, nil, 0, MAX_INT), msg = inner }
end
return {
seq = Wire.num(v.seq, nil, 0, MAX_INT),
clientSeq = Wire.num(v.clientSeq, nil, 0, MAX_INT),
side = Wire.str(v.side, nil, MAX_NAME),
msg = inner,
}
end
SCHEMAS.lobby_hello = function(m)
return {
ticket = Wire.str(m.ticket, nil, MAX_TICKET),
name = displayName(m.name),
engineVersion = Wire.str(m.engineVersion, nil, MAX_STRING),
platform = Wire.str(m.platform, nil, MAX_NAME),
profiles = Wire.list(m.profiles, MAX_PROFILES, profile),
}
end
SCHEMAS.lobby_welcome = function(m)
return {
session = Wire.str(m.session, nil, MAX_SESSION),
you = youEntry(m.you),
serverTime = Wire.num(m.serverTime, nil, 0),
heartbeatMs = Wire.num(m.heartbeatMs, nil, 0, 600000),
resumed = Wire.bool(m.resumed, false),
}
end
SCHEMAS.resume = function(m)
return {
session = Wire.str(m.session, nil, MAX_SESSION),
ack = Wire.num(m.ack, 0, 0, MAX_INT),
}
end
SCHEMAS.advertise = function(m)
return {
intent = Wire.str(m.intent, nil, MAX_NAME),
profile = profile(m.profile),
note = Wire.str(m.note, nil, MAX_STRING),
}
end
SCHEMAS.unadvertise = function() return {} end
SCHEMAS.room_create = function(m)
return {
intent = Wire.str(m.intent, nil, MAX_NAME),
profile = profile(m.profile),
playing = Wire.bool(m.playing, true),
maxSpectators = Wire.num(m.maxSpectators, nil, 0, MAX_SPECTATORS),
public = Wire.bool(m.public, true),
note = Wire.str(m.note, nil, MAX_NOTE),
}
end
SCHEMAS.room_join = function(m)
return {
code = codeStr(m.code),
as = Wire.str(m.as, nil, MAX_NAME),
profile = profile(m.profile),
}
end
SCHEMAS.room_leave = function() return {} end
SCHEMAS.room_ready = function(m)
return {
party = Wire.list(m.party, MAX_TEAM, mon),
partyDigest = Wire.str(m.partyDigest, nil, MAX_STRING),
}
end
SCHEMAS.room_msg = function(m)
local inner = innerMsg(m.msg)
if not inner then return nil end
return {
seq = Wire.num(m.seq, 0, 0, MAX_INT),
clientSeq = Wire.num(m.clientSeq, nil, 0, MAX_INT),
side = Wire.str(m.side, nil, MAX_NAME),
msg = inner,
}
end
SCHEMAS.room_report = function(m)
return {
match = Wire.str(m.match, nil, MAX_NAME),
result = Wire.str(m.result, nil, MAX_NAME),
}
end
SCHEMAS.room_kick = function(m)
return { id = Wire.str(m.id, nil, MAX_NAME) }
end
SCHEMAS.room_close = function() return {} end
SCHEMAS.lobby_query = function() return {} end
SCHEMAS.room_ack = function(m)
return { seq = Wire.num(m.seq, 0, 0, MAX_INT) }
end
SCHEMAS.lobby_list = function(m)
return {
entries = Wire.list(m.entries, MAX_LOBBY_ENTRIES, lobbyEntry),
online = Wire.num(m.online, nil, 0, MAX_INT),
}
end
SCHEMAS.lobby_delta = function(m)
local ids = function(v)
return Wire.list(v, MAX_LOBBY_ENTRIES, function(x)
return Wire.str(x, nil, MAX_NAME)
end)
end
return {
added = Wire.list(m.added, MAX_LOBBY_ENTRIES, lobbyEntry),
changed = Wire.list(m.changed, MAX_LOBBY_ENTRIES, lobbyEntry),
removed = ids(m.removed),
add = Wire.list(m.add, MAX_LOBBY_ENTRIES, lobbyEntry),
update = Wire.list(m.update, MAX_LOBBY_ENTRIES, lobbyEntry),
remove = Wire.list(m.remove, MAX_LOBBY_ENTRIES, function(v)
return Wire.str(v, nil, MAX_NAME)
end),
op = Wire.str(m.op, nil, MAX_NAME),
entry = lobbyEntry(m.entry),
id = Wire.str(m.id, nil, MAX_NAME),
}
end
SCHEMAS.room_state = function(m)
return {
code = codeStr(m.code),
players = Wire.list(m.players, MAX_ROOM_PLAYERS, playerEntry),
spectators = Wire.list(m.spectators, MAX_SPECTATORS, spectatorEntry),
stage = Wire.str(m.stage, nil, MAX_NAME),
profile = profile(m.profile),
host = Wire.str(m.host, nil, MAX_NAME),
seed = Wire.num(m.seed, nil, 0, MAX_INT),
intent = Wire.str(m.intent, nil, MAX_NAME),
maxSpectators = Wire.num(m.maxSpectators, nil, 0, MAX_SPECTATORS),
rule = m.rule ~= nil and arenaRule(m.rule) or nil,
match = Wire.str(m.match, nil, MAX_NAME),
deadlines = Wire.list(m.deadlines, MAX_DEADLINES, deadlineEntry),
}
end
SCHEMAS.room_replay = function(m)
return {
from = Wire.num(m.from, 0, 0, MAX_INT),
yourSeq = Wire.num(m.yourSeq, nil, 0, MAX_INT),
msgs = Wire.list(m.msgs, MAX_REPLAY, replayEntry),
}
end
SCHEMAS.room_deadline = function(m)
return {
kind = Wire.str(m.kind, nil, MAX_NAME),
at = Wire.num(m.at, nil, 0),
}
end
SCHEMAS.room_result = function(m)
return {
match = Wire.str(m.match, nil, MAX_NAME),
code = codeStr(m.code),
winner = displayName(m.winner),
winnerId = Wire.str(m.winnerId, nil, MAX_NAME),
how = Wire.str(m.how, nil, MAX_NAME),
}
end
SCHEMAS.room_closed = function(m)
return {
reason = Wire.str(m.reason, nil, MAX_NAME),
code = codeStr(m.code),
}
end
local MAX_TOUR_PLAYERS = 64
local MAX_TOUR_SPECTATORS = 64
local MAX_TOUR_ROUNDS = 7
local MAX_TOUR_MATCHES = 32
local TOUR_STAGES = { registering = true, running = true, finished = true }
local TOUR_MATCH_STATES = { pending = true, live = true, done = true,
bye = true }
local function tourPlayerEntry(v)
if type(v) ~= "table" then return nil end
return {
id = Wire.str(v.id, nil, MAX_NAME),
name = displayName(v.name),
verified = Wire.bool(v.verified, false),
online = Wire.bool(v.online, true),
eliminated = Wire.bool(v.eliminated, false),
}
end
local function tourMatchEntry(v)
if type(v) ~= "table" then return nil end
local state = Wire.str(v.state, nil, MAX_NAME)
return {
match = Wire.str(v.match, nil, MAX_NAME),
a = Wire.str(v.a, nil, MAX_NAME),
b = Wire.str(v.b, nil, MAX_NAME),
winner = Wire.str(v.winner, nil, MAX_NAME),
how = Wire.str(v.how, nil, MAX_NAME),
state = TOUR_MATCH_STATES[state or ""] and state or nil,
}
end
local function tourRoundEntry(v)
if type(v) ~= "table" then return nil end
return {
round = Wire.num(v.round, 0, 0, MAX_TOUR_ROUNDS),
matches = Wire.list(v.matches, MAX_TOUR_MATCHES, tourMatchEntry),
}
end
SCHEMAS.tour_create = function(m)
return {
profile = profile(m.profile),
rule = m.rule ~= nil and arenaRule(m.rule) or nil,
playing = Wire.bool(m.playing, true),
shotClock = Wire.num(m.shotClock, nil, 0, 3600),
maxSpectators = Wire.num(m.maxSpectators, nil, 0, MAX_TOUR_SPECTATORS),
party = m.party ~= nil and Wire.list(m.party, MAX_TEAM, mon) or nil,
partyDigest = Wire.str(m.partyDigest, nil, MAX_STRING),
public = Wire.bool(m.public, true),
note = Wire.str(m.note, nil, MAX_NOTE),
}
end
SCHEMAS.tour_join = function(m)
return {
code = codeStr(m.code),
as = Wire.str(m.as, nil, MAX_NAME),
profile = profile(m.profile),
party = Wire.list(m.party, MAX_TEAM, mon),
partyDigest = Wire.str(m.partyDigest, nil, MAX_STRING),
}
end
SCHEMAS.tour_leave = function() return {} end
SCHEMAS.tour_start = function() return {} end
SCHEMAS.tour_close = function() return {} end
SCHEMAS.tour_kick = function(m)
return { id = Wire.str(m.id, nil, MAX_NAME) }
end
SCHEMAS.tour_state = function(m)
local stage = Wire.str(m.stage, nil, MAX_NAME)
return {
code = codeStr(m.code),
creator = Wire.str(m.creator, nil, MAX_NAME),
stage = TOUR_STAGES[stage or ""] and stage or "registering",
players = Wire.list(m.players, MAX_TOUR_PLAYERS, tourPlayerEntry),
spectators = Wire.list(m.spectators, MAX_TOUR_SPECTATORS, spectatorEntry),
profile = profile(m.profile),
rule = m.rule ~= nil and arenaRule(m.rule) or nil,
shotClock = Wire.num(m.shotClock, nil, 0, 3600),
round = Wire.num(m.round, 0, 0, MAX_TOUR_ROUNDS),
bracket = Wire.list(m.bracket, MAX_TOUR_ROUNDS, tourRoundEntry),
live = Wire.str(m.live, nil, MAX_NAME),
champion = Wire.str(m.champion, nil, MAX_NAME),
championId = Wire.str(m.championId, nil, MAX_NAME),
maxSpectators = Wire.num(m.maxSpectators, nil, 0, MAX_TOUR_SPECTATORS),
}
end
SCHEMAS.tour_match = function(m)
return {
match = Wire.str(m.match, nil, MAX_NAME),
round = Wire.num(m.round, 0, 0, MAX_TOUR_ROUNDS),
code = codeStr(m.code),
}
end
SCHEMAS.tour_match_spectate = SCHEMAS.tour_match
SCHEMAS.tour_bye = function(m)
return {
match = Wire.str(m.match, nil, MAX_NAME),
round = Wire.num(m.round, 0, 0, MAX_TOUR_ROUNDS),
}
end
SCHEMAS.tour_deadline = function(m)
return {
kind = Wire.str(m.kind, nil, MAX_NAME),
at = Wire.num(m.at, nil, 0),
match = Wire.str(m.match, nil, MAX_NAME),
}
end
SCHEMAS.tour_closed = function(m)
return {
code = codeStr(m.code),
reason = Wire.str(m.reason, nil, MAX_NAME),
}
end
SCHEMAS.tour_over = function(m)
return {
code = codeStr(m.code),
champion = displayName(m.champion),
championId = Wire.str(m.championId, nil, MAX_NAME),
}
end
Wire.SCHEMAS = SCHEMAS
local function passthrough(m)
+112 -6
View File
@@ -327,6 +327,13 @@ end
function Loader:_loadState()
self.disabled = {}
if self.arenaMode then
self.safeMode = false
Runtime.safeMode = false
self.gen2Forced = {}
self.modOptions = {}
return
end
local options = SaveData.loadOptions(self.fs)
self.safeMode = SaveData.isSafeMode(options)
Runtime.safeMode = self.safeMode
@@ -608,6 +615,88 @@ function Loader:cartStatus()
return self.cartReport
end
Loader.ARENA_MODES = { normal = true, disableAll = true, cartOnly = true }
local function translationCandidate(manifest)
if type(manifest) ~= "table" then return false end
if manifest.language ~= true then return false end
if manifest.affects_link ~= false then return false end
return #(manifest.permissions or {}) == 0
end
function Loader:_arenaDisableAll()
for id, mod in pairs(self.mods) do
if not translationCandidate(mod.manifest) then self.disabled[id] = true end
end
end
function Loader:_arenaVerifyTranslations()
local ok, Handshake = pcall(require, "src.link.Handshake")
if not ok or not Handshake or not Handshake.onlineBlockers then return end
local blocked = {}
for _, entry in ipairs(Handshake.onlineBlockers({ mods = self })) do
blocked[entry.id] = true
end
if next(blocked) == nil then return end
for id in pairs(blocked) do
local mod = self.mods[id]
if mod then
self:_rollback(id)
mod.enabled, mod.state = false, "disabled"
mod.skipReason = "not a verified translation"
self.disabled[id] = true
end
end
for i = #self.loaded, 1, -1 do
if blocked[self.loaded[i].manifest.id] then table.remove(self.loaded, i) end
end
for i = #self.order, 1, -1 do
if blocked[self.order[i]] then table.remove(self.order, i) end
end
end
function Loader:_arenaCart()
local cartId = self.arenaCartId
if type(cartId) ~= "string" or cartId == "" then
return false, "no cart chosen"
end
local cart, err = self.cart
if not cart then cart, err = CartStore.get(cartId, self.fs) end
if not cart then
return false, tostring(err or "this cart is not installed")
end
local report = Loader.planCart(cart, self.mods, self.arenaSealBroken)
report.id = cartId
self.cartReport = report
if self.arenaSealBroken then return false, "this save's seal is broken" end
if cart.seal ~= "sealed" then
return false, ("%s is not a sealed cart"):format(cart.title or cartId)
end
if report.refused or not report.enforced then
return false, report.message or ("%s cannot be enforced"):format(cartId)
end
self.cartSwitches = {}
for id, mod in pairs(self.mods) do
local pin = report.pins[id]
if pin then
local on = CartManifest.modEnabled(pin)
mod.enabled, mod.state = on, on and "pending" or "disabled"
self.disabled[id] = not on or nil
else
mod.enabled, mod.state = false, "disabled"
self.disabled[id] = true
end
end
local merged = {}
for id, pin in pairs(report.pins) do
local bucket = {}
for key, value in pairs(pin.options or {}) do bucket[key] = value end
merged[id] = bucket
end
self.modOptions = merged
return true
end
function Loader:_applyCart()
local cartId = SaveData.getCart()
if not cartId or self.safeMode then return end
@@ -1737,7 +1826,15 @@ local function stampAudioOwners(data, name, registry)
owners[key] = map
end
function Loader:load(data)
function Loader:load(data, opts)
opts = opts or {}
local mode = opts.mode or "normal"
if not Loader.ARENA_MODES[mode] then
return false, ("unknown loader mode %q"):format(tostring(mode))
end
self.arenaMode = mode ~= "normal" and mode or nil
self.arenaCartId = mode == "cartOnly" and opts.cartId or nil
self.arenaSealBroken = opts.sealBroken == true
self.baseData = data
-- every registry folds against the pristine view of its Data target;
-- resolution is lazy so optional namespaces may appear later
@@ -1760,11 +1857,17 @@ function Loader:load(data)
if self.safeMode then
for id in pairs(self.mods) do self.disabled[id] = true end
end
if self.arenaMode == "disableAll" then
self:_arenaDisableAll()
elseif self.arenaMode == "cartOnly" then
local ok, reason = self:_arenaCart()
if not ok then return false, reason end
end
-- Existing installs stored one shared answer. Once their manifests are
-- known, split that answer across every game before the next launcher/game
-- toggle can change one independently. _loadState already used the same
-- fallback, so this write cannot change the current boot's result.
do
if not self.arenaMode then
local options = SaveData.loadOptions(self.fs)
local installed = {}
for id, mod in pairs(self.mods) do
@@ -1779,7 +1882,7 @@ function Loader:load(data)
end
-- Experimental mods stay off until the player opts in: a missing
-- options.mods entry normally means enabled, but experimental flips that.
do
if not self.arenaMode then
local options = SaveData.loadOptions(self.fs)
local scope = self:_enableScope()
for id, mod in pairs(self.mods) do
@@ -1795,7 +1898,8 @@ function Loader:load(data)
-- the one build where its env var is set.
for id, mod in pairs(self.mods) do
local envName = mod.manifest.force_enable_env
if not self.safeMode and envName and os.getenv(envName) == "1" then
if not self.safeMode and not self.arenaMode and envName
and os.getenv(envName) == "1" then
self.disabled[id] = nil
end
end
@@ -1803,7 +1907,7 @@ function Loader:load(data)
mod.enabled = not self.disabled[id]
mod.state = mod.enabled and "pending" or "disabled"
end
self:_applyCart()
if not self.arenaMode then self:_applyCart() end
-- engine call sites reach these buses -- and this error feed, for failures
-- that only surface at play time -- through Runtime from here on
Runtime.install(self.events, self.hooks, self.errors)
@@ -1856,6 +1960,7 @@ function Loader:load(data)
-- the commands registry is final once every entry chunk has run, so
-- each map_scripts contribution's rows can be judged before they merge
self:_validateScripts()
if self.arenaMode == "disableAll" then self:_arenaVerifyTranslations() end
-- merge: fold every touched id from its pristine base value and write it
-- home, creating the Data namespace when the base modules never shipped
-- one. A registry nobody wrote to -- engine included -- is skipped, so
@@ -1977,7 +2082,8 @@ function Loader:status()
table.sort(available, function(a, b) return a.id < b.id end)
table.sort(loaded, function(a, b) return a.id < b.id end)
return { available = available, loaded = loaded, errors = self.errors,
order = self.order, cart = self.cartReport }
order = self.order, cart = self.cartReport,
arenaMode = self.arenaMode or "normal" }
end
return Loader
+250
View File
@@ -0,0 +1,250 @@
local Protocol = require("src.link.Protocol")
local ArenaBoot = {}
local VERSIONS = {
red = true, blue = true, yellow = true,
gold = true, silver = true, crystal = true,
}
local ROLES = { host = true, guest = true, spectator = true }
local KINDS = { vanilla = true, cart = true }
local function isCallable(v)
if type(v) == "function" then return true end
if type(v) ~= "table" then return false end
local mt = getmetatable(v)
return type(mt) == "table" and type(mt.__call) == "function"
end
local function hasMethod(obj, name)
if type(obj) ~= "table" then return false end
return isCallable(obj[name])
end
local function isPackedParty(v)
if type(v) ~= "table" then return false end
for _, mon in ipairs(v) do
if type(mon) ~= "table" or type(mon.species) ~= "string" then return false end
end
return true
end
local function level(v)
return type(v) == "number" and v >= 1 and v <= 100 and math.floor(v) == v
end
local function normaliseRule(rule)
if rule == nil then rule = {} end
if type(rule) ~= "table" then return nil, "rule must be a table" end
local size = rule.partySize
if size == nil then size = 6 end
if type(size) ~= "number" or size < 1 or size > 6 or math.floor(size) ~= size then
return nil, "rule.partySize must be 1..6"
end
if rule.minLevel ~= nil and not level(rule.minLevel) then
return nil, "rule.minLevel must be 1..100"
end
if rule.maxLevel ~= nil and not level(rule.maxLevel) then
return nil, "rule.maxLevel must be 1..100"
end
if rule.minLevel and rule.maxLevel and rule.minLevel > rule.maxLevel then
return nil, "rule.minLevel is above rule.maxLevel"
end
if rule.forceLevel ~= nil and not level(rule.forceLevel) then
return nil, "rule.forceLevel must be 1..100"
end
return {
partySize = size,
minLevel = rule.minLevel,
maxLevel = rule.maxLevel,
forceLevel = rule.forceLevel,
}
end
function ArenaBoot.profile(fields)
if type(fields) ~= "table" then return nil, "profile must be a table" end
local engine = fields.engine
if engine ~= 1 and engine ~= 2 then return nil, "profile.engine must be 1 or 2" end
if type(fields.version) ~= "string" or not VERSIONS[fields.version] then
return nil, "profile.version is not a known game"
end
local kind = fields.kind or "vanilla"
if not KINDS[kind] then return nil, "profile.kind must be vanilla or cart" end
if kind == "cart" then
local cart = fields.cart
if type(cart) ~= "table" or type(cart.id) ~= "string" or cart.id == ""
or type(cart.hash) ~= "string" or cart.hash == "" then
return nil, "a cart profile needs cart.id and cart.hash"
end
end
if fields.engineVersion ~= nil and type(fields.engineVersion) ~= "string" then
return nil, "profile.engineVersion must be a string"
end
if fields.rulesetId ~= nil and type(fields.rulesetId) ~= "string" then
return nil, "profile.rulesetId must be a string"
end
local rule, ruleErr = normaliseRule(fields.rule)
if not rule then return nil, ruleErr end
local cart = nil
if kind == "cart" then
cart = {
id = fields.cart.id,
version = fields.cart.version,
hash = fields.cart.hash,
}
end
return {
engine = engine,
version = fields.version,
engineVersion = fields.engineVersion,
apiVersion = fields.apiVersion,
fingerprint = fields.fingerprint,
rulesetId = fields.rulesetId,
kind = kind,
cart = cart,
rule = rule,
}
end
local function normaliseTeam(team, size)
if team == nil then return nil end
if type(team) ~= "table" then return nil, "team must be an array of party indices" end
local seen, out = {}, {}
for _, index in ipairs(team) do
if type(index) ~= "number" or index < 1 or index > 6 or math.floor(index) ~= index then
return nil, "team holds a party index outside 1..6"
end
if seen[index] then return nil, "team repeats a party index" end
seen[index] = true
out[#out + 1] = index
end
if #out == 0 then return nil, "team is empty" end
if #out > size then return nil, "team is longer than the rule allows" end
return out
end
function ArenaBoot.spec(fields)
if type(fields) ~= "table" then return nil, "spec must be a table" end
local profile, profileErr = ArenaBoot.profile(fields.profile)
if not profile then return nil, profileErr end
local role = fields.role
if type(role) ~= "string" or not ROLES[role] then
return nil, "role must be host, guest or spectator"
end
local spectating = role == "spectator"
if not spectating then
if type(fields.slotId) ~= "string" or fields.slotId == "" then
return nil, "slotId is required"
end
elseif fields.slotId ~= nil and type(fields.slotId) ~= "string" then
return nil, "slotId must be a string"
end
local team, teamErr = normaliseTeam(fields.team, profile.rule.partySize)
if fields.team ~= nil and not team then return nil, teamErr end
if type(fields.seed) ~= "number" then return nil, "seed must be a number" end
local session = fields.session
if not (hasMethod(session, "send") and hasMethod(session, "poll")
and hasMethod(session, "close")) then
return nil, "session must provide send, poll and close"
end
if fields.onDone ~= nil and not isCallable(fields.onDone) then
return nil, "onDone must be a function"
end
if spectating then
if not isPackedParty(fields.hostParty) or not isPackedParty(fields.guestParty) then
return nil, "a spectator spec needs hostParty and guestParty"
end
else
if not isPackedParty(fields.theirParty) then
return nil, "theirParty must be a packed party"
end
if fields.myParty ~= nil and not isPackedParty(fields.myParty) then
return nil, "myParty must be a packed party"
end
end
local onDone = fields.onDone
return {
profile = profile,
role = role,
slotId = fields.slotId,
team = team,
seed = fields.seed,
peerName = fields.peerName or "FOE",
hostName = fields.hostName or "HOST",
guestName = fields.guestName or "GUEST",
myParty = fields.myParty,
theirParty = fields.theirParty,
hostParty = fields.hostParty,
guestParty = fields.guestParty,
session = session,
onDone = function(result)
if onDone then onDone(result) end
end,
}
end
function ArenaBoot.battleOpts(spec)
if type(spec) ~= "table" or type(spec.profile) ~= "table" then
return nil, "spec must carry a profile"
end
local rule = spec.profile.rule or {}
if spec.role == "spectator" then
return {
hostParty = spec.hostParty,
guestParty = spec.guestParty,
hostName = spec.hostName,
guestName = spec.guestName,
seed = spec.seed,
ruleset = spec.profile.rulesetId,
verdict = "full",
strict = true,
forceLevel = rule.forceLevel,
keepNetOpen = true,
}
end
return {
myParty = spec.myParty,
theirParty = spec.theirParty,
theirName = spec.peerName,
role = spec.role,
seed = spec.seed,
ruleset = spec.profile.rulesetId,
verdict = "full",
strict = true,
forceLevel = rule.forceLevel,
keepNetOpen = true,
}
end
function ArenaBoot.packOwnParty(game, spec)
if type(spec) ~= "table" then return nil, "spec must be a table" end
if spec.role == "spectator" then return nil end
if isPackedParty(spec.myParty) and #spec.myParty > 0 then return spec.myParty end
local party = game and game.save and game.save.party
if type(party) ~= "table" then return nil, "no party to send" end
local size = (spec.profile and spec.profile.rule and spec.profile.rule.partySize) or 6
local indices = {}
for _, index in ipairs(spec.team or {}) do
if party[index] then indices[#indices + 1] = index end
end
if #indices == 0 then
for index = 1, math.min(#party, size) do indices[index] = index end
end
if #indices == 0 then return nil, "no party to send" end
spec.myParty = (spec.profile and spec.profile.engine == 2)
and Protocol.packParty2(party, indices)
or Protocol.packParty(party, indices)
return spec.myParty
end
return ArenaBoot
+260
View File
@@ -0,0 +1,260 @@
local CacheFs = require("src.import.CacheFs")
local CartStore = require("src.carts.CartStore")
local Fingerprint = require("src.link.Fingerprint")
local GameVersion = require("src.core.GameVersion")
local Handshake = require("src.link.Handshake")
local Loader = require("src.mods.Loader")
local Runtime = require("src.mods.Runtime")
local SaveData = require("src.core.SaveData")
local Version = require("src.core.Version")
local ArenaData = {}
local rulesetMemo = {}
local FIELDS = {
{ key = "engine", text = "engine differs" },
{ key = "version", text = "game differs" },
{ key = "engineVersion", text = "engine version differs" },
{ key = "apiVersion", text = "mod api differs" },
{ key = "kind", text = "arena kind differs" },
{ key = "rulesetId", text = "ruleset differs" },
{ key = "fingerprint", text = "data differs" },
}
local CART_FIELDS = {
{ key = "id", text = "cart differs" },
{ key = "version", text = "cart version differs" },
{ key = "hash", text = "cart hash differs" },
}
function ArenaData.cacheKey(version, kind, cartHash)
return tostring(version) .. "|" .. tostring(kind) .. "|"
.. (cartHash or "-") .. "|" .. tostring(Version.engine)
end
local function copyRule(rule)
rule = type(rule) == "table" and rule or {}
local size = tonumber(rule.partySize) or 3
size = math.max(1, math.min(6, math.floor(size)))
return {
partySize = size,
minLevel = tonumber(rule.minLevel) or nil,
maxLevel = tonumber(rule.maxLevel) or nil,
forceLevel = tonumber(rule.forceLevel) or nil,
}
end
local function copyProfile(entry, rule)
local out = {
engine = entry.engine,
version = entry.version,
engineVersion = entry.engineVersion,
apiVersion = entry.apiVersion,
fingerprint = entry.fingerprint,
rulesetId = entry.rulesetId,
kind = entry.kind,
rule = copyRule(rule),
}
if type(entry.cart) == "table" then
out.cart = { id = entry.cart.id, version = entry.cart.version,
hash = entry.cart.hash }
end
return out
end
function ArenaData.equal(a, b)
if type(a) ~= "table" or type(b) ~= "table" then return false end
for _, field in ipairs(FIELDS) do
if a[field.key] ~= b[field.key] then return false end
end
local ca, cb = a.cart, b.cart
if (ca == nil) ~= (cb == nil) then return false end
if ca then
for _, field in ipairs(CART_FIELDS) do
if ca[field.key] ~= cb[field.key] then return false end
end
end
return true
end
function ArenaData.describeMismatch(a, b)
if type(a) ~= "table" or type(b) ~= "table" then return "no profile" end
for _, field in ipairs(FIELDS) do
if a[field.key] ~= b[field.key] then return field.text end
end
local ca, cb = a.cart, b.cart
if (ca == nil) ~= (cb == nil) then return "cart differs" end
if ca then
for _, field in ipairs(CART_FIELDS) do
if ca[field.key] ~= cb[field.key] then return field.text end
end
end
return nil
end
local function cachedProfiles()
local options = SaveData.loadOptions()
local bucket = options.arenaProfiles
return type(bucket) == "table" and bucket or {}, options
end
local function storeProfile(key, entry)
local bucket = cachedProfiles()
bucket[key] = entry
pcall(SaveData.saveOptions, { arenaProfiles = bucket })
end
function ArenaData.forget(version)
local bucket = cachedProfiles()
local prefix = version and (tostring(version) .. "|") or nil
for key in pairs(bucket) do
if not prefix or key:sub(1, #prefix) == prefix then bucket[key] = nil end
end
pcall(SaveData.saveOptions, { arenaProfiles = bucket })
if version then rulesetMemo[version] = nil else rulesetMemo = {} end
end
local function gen2Dataset()
local data = {}
local function load(name)
return CacheFs.loadActive("data/generated/" .. name .. ".lua")
end
data.pokemon = load("pokemon") or {}
data.items = load("items") or {}
data.moves = load("moves") or {}
data.type_chart = load("type_chart") or {}
data.gen2Constants = load("constants")
data.text = load("rom_text") or {}
data.font = load("font")
local chart = data.type_chart
chart.matchups = chart.matchups or {}
for _, row in ipairs(chart.foresightMatchups or {}) do
chart.matchups[#chart.matchups + 1] = row
end
local ItemEffects = require("src.core.gen2.ItemEffects")
data.gen2HeldItems = ItemEffects.heldItemsFrom(data.items)
return data, ItemEffects.heldSnapshot(data.gen2HeldItems)
end
local function loadDataset(version, generation)
if generation == 2 then
local data, held = gen2Dataset()
return data, function()
require("src.core.gen2.ItemEffects").applyHeldItems(data, held)
end
end
local Data = require("src.core.Data")
Data:load()
return Data, nil
end
local function busy()
return require("src.online.Trade").gameIsLive()
end
local function compute(version, kind, cartId, cart, cartHash)
local generation = GameVersion.generation(version)
local prevVersion = GameVersion.get()
local prevPrefix = CacheFs.prefix
local saved = { events = Runtime.events, hooks = Runtime.hooks,
errors = Runtime.errors, safeMode = Runtime.safeMode }
local function restore()
pcall(function() require("src.core.Data"):unloadGenerated() end)
pcall(CacheFs.unmountVersion, version)
if Loader.endSession then pcall(Loader.endSession) end
if Runtime.reset then pcall(Runtime.reset) end
Runtime.events, Runtime.hooks = saved.events, saved.hooks
Runtime.errors, Runtime.safeMode = saved.errors, saved.safeMode
Runtime.currentMod = nil
GameVersion.set(prevVersion)
CacheFs.prefix = prevPrefix
end
local ok, result = pcall(function()
GameVersion.set(version)
CacheFs.prefix = GameVersion.cachePrefix(version)
CacheFs.mountVersion(version)
if not CacheFs.readActive("data/generated/pokemon.lua") then
return { error = ("%s is not imported"):format(version) }
end
local data, after = loadDataset(version, generation)
local loader = Loader.new({ generation = generation, cart = cart })
local opts = kind == "cart"
and { mode = "cartOnly", cartId = cartId }
or { mode = "disableAll" }
local loaded, reason = loader:load(data, opts)
if loaded == false and reason then return { error = reason } end
if after then after() end
local mods = Handshake.mods({ mods = loader })
local rulesetId = "gen2"
if generation ~= 2 then
rulesetId = Handshake.ruleset({ data = data })
local ids = {}
for id in pairs(data.rulesets or {}) do ids[#ids + 1] = id end
table.sort(ids)
rulesetMemo[version] = ids
else
rulesetMemo[version] = { "gen2" }
end
return {
engine = generation,
version = version,
engineVersion = Version.engine,
apiVersion = Handshake.apiVersion or Version.modApi,
fingerprint = Fingerprint.compute(data, mods, generation),
rulesetId = rulesetId,
kind = kind,
cart = cart and { id = cartId, version = cart.version, hash = cartHash }
or nil,
}
end)
restore()
if not ok then return nil, tostring(result) end
if type(result) ~= "table" then return nil, "could not read that game" end
if result.error then return nil, result.error end
return result
end
function ArenaData.profile(version, kind, cartId, rule)
kind = kind or "vanilla"
if not GameVersion.VERSIONS[version] then return nil, "unknown game" end
if kind ~= "vanilla" and kind ~= "cart" then return nil, "unknown arena kind" end
local cart, cartHash
if kind == "cart" then
local got, hash = CartStore.get(cartId)
if not got then return nil, tostring(hash or "this cart is not installed") end
cart, cartHash = got, hash
if cart.base ~= version then return nil, "that cart is for another game" end
if cart.seal ~= "sealed" then return nil, "that cart is not sealed" end
end
local key = ArenaData.cacheKey(version, kind, cartHash)
local bucket = cachedProfiles()
local hit = bucket[key]
if type(hit) == "table" and hit.fingerprint then
return copyProfile(hit, rule)
end
if busy() then return nil, "close the game first" end
local entry, reason = compute(version, kind, cartId, cart, cartHash)
if not entry then return nil, reason end
storeProfile(key, entry)
return copyProfile(entry, rule)
end
function ArenaData.rulesetIds(version)
if rulesetMemo[version] then return rulesetMemo[version] end
if GameVersion.generation(version) == 2 then
rulesetMemo[version] = { "gen2" }
return rulesetMemo[version]
end
local _, reason = ArenaData.profile(version, "vanilla", nil, nil)
if rulesetMemo[version] then return rulesetMemo[version] end
return { Handshake.DEFAULT_RULESET }, reason
end
return ArenaData
File diff suppressed because it is too large Load Diff
+435
View File
@@ -0,0 +1,435 @@
-- engine/link/link.asm:1970 CheckTimeCapsuleCompatibility
-- engine/link/link.asm:640 Link_PrepPartyData_Gen1, :930 Link_ConvertPartyStruct1to2
local Mon = require("src.battle.gen2.Mon")
local Mail = require("src.core.gen2.Mail")
local Stats = require("src.pokemon.Stats")
local Growth = require("src.pokemon.Growth")
local Convert = {}
Convert.STATUS_1TO2 = {
SLP = "sleep", PSN = "poison", BRN = "burn",
PAR = "paralyze", FRZ = "freeze",
}
-- engine/link/link.asm:980
Convert.STATUS_2TO1 = {
sleep = "SLP", poison = "PSN", toxic = "PSN", burn = "BRN",
paralyze = "PAR", freeze = "FRZ",
}
-- engine/link/time_capsule.asm:41
Convert.TYPE_EXEMPT = { MAGNEMITE = true, MAGNETON = true }
Convert.DEFAULT_HAPPINESS = 70 -- engine/link/link.asm:1067
local function displayName(registry, id)
local def = registry and registry[id]
local name = def and def.name
if type(name) == "string" and name ~= "" then return name end
return tostring(id):gsub("_", " ")
end
local function baseStatsOf(def)
return (def and def.baseStats) or {}
end
local function copyStatExp(statExp)
statExp = statExp or {}
local special = statExp.special
if special == nil then
special = statExp.specialAttack or statExp.specialDefense
end
return {
hp = statExp.hp or 0,
attack = statExp.attack or 0,
defense = statExp.defense or 0,
speed = statExp.speed or 0,
special = special or 0,
}
end
local function copyDVs(dvs)
dvs = dvs or {}
local special = dvs.special
if special == nil then
special = dvs.specialAttack or dvs.specialDefense
end
local out = {
attack = dvs.attack or 0,
defense = dvs.defense or 0,
speed = dvs.speed or 0,
special = special or 0,
}
out.hp = Mon.hpDV(out)
return out
end
local function scaleHp(hp, oldMax, newMax)
hp = tonumber(hp)
if hp == nil then return newMax end
if not oldMax or oldMax <= 0 then return newMax end
if hp <= 0 then return 0 end
if hp >= oldMax then return newMax end
local scaled = math.floor(hp * newMax / oldMax + 0.5)
return math.max(1, math.min(newMax, scaled))
end
local function ppBonus(base, ups)
return (base or 0) + (ups or 0) * math.floor((base or 0) / 5)
end
-- engine/items/item_effects.asm:2736 ComputeMaxPP
local function ppUpsFrom(base, maxPp)
base = base or 0
maxPp = tonumber(maxPp)
if not maxPp or base < 5 then return 0 end
local step = math.floor(base / 5)
if step <= 0 then return 0 end
local ups = math.floor((maxPp - base) / step + 0.5)
return math.max(0, math.min(3, ups))
end
local function entry(list, kind, text, extra)
local row = extra or {}
row.kind = kind
row.text = text
list[#list + 1] = row
return row
end
local function newReport()
return { lost = {}, changed = {} }
end
local function levelOf(mon)
return math.max(1, math.min(100, math.floor(tonumber(mon.level) or 1)))
end
-- engine/link/link.asm:930 Link_ConvertPartyStruct1to2
function Convert.toGen2(mon, gen1Data, gen2Data)
if type(mon) ~= "table" then return nil, "not_a_mon" end
local def2 = gen2Data and gen2Data.pokemon and gen2Data.pokemon[mon.species]
if not def2 then return nil, "species_unknown" end
local def1 = gen1Data and gen1Data.pokemon and gen1Data.pokemon[mon.species]
local report = newReport()
local level = levelOf(mon)
local dvs = copyDVs(mon.dvs)
local statExp = copyStatExp(mon.statExp)
local stats = Mon.stats(baseStatsOf(def2), dvs, level, statExp)
local oldStats = mon.stats
if not (oldStats and oldStats.hp) then
oldStats = Stats.calc(def1 or { baseStats = baseStatsOf(def2) }, level,
dvs, statExp)
end
local oldSpecial = oldStats.special
if oldSpecial and (oldSpecial ~= stats.specialAttack
or oldSpecial ~= stats.specialDefense) then
-- engine/link/link.asm:1042
entry(report.changed, "special_split",
("SPECIAL SPLIT %d -> %d/%d"):format(oldSpecial, stats.specialAttack,
stats.specialDefense),
{ from = oldSpecial, specialAttack = stats.specialAttack,
specialDefense = stats.specialDefense })
end
local hp = scaleHp(mon.hp, oldStats.hp, stats.hp)
if oldStats.hp ~= stats.hp then
entry(report.changed, "hp",
("HP %d/%d -> %d/%d"):format(math.min(tonumber(mon.hp) or 0,
oldStats.hp), oldStats.hp, hp, stats.hp),
{ from = mon.hp, fromMax = oldStats.hp, to = hp, toMax = stats.hp })
end
local growth = Mon.growthFor(gen2Data, def2.growthRate)
local experience = Mon.experienceForLevel(growth, level)
if tonumber(mon.exp) and math.floor(mon.exp) ~= experience then
entry(report.changed, "experience",
("EXP %d -> %d"):format(math.floor(mon.exp), experience),
{ from = math.floor(mon.exp), to = experience })
end
local moves = {}
for _, mv in ipairs(mon.moves or {}) do
local mdef = gen2Data.moves and gen2Data.moves[mv.id]
local base = (mdef and mdef.pp) or 0
local maxPp = ppBonus(base, mv.ppUps)
moves[#moves + 1] = {
id = mv.id,
pp = math.max(0, math.min(tonumber(mv.pp) or maxPp, maxPp)),
maxPp = maxPp,
}
end
local status = mon.status and Convert.STATUS_1TO2[mon.status] or nil
if mon.status and status ~= mon.status then
entry(report.changed, "status",
("STATUS %s -> %s"):format(tostring(mon.status), tostring(status)),
{ from = mon.status, to = status })
end
local out = {
species = mon.species,
name = def2.name or mon.species,
nickname = mon.nickname,
level = level,
experience = experience,
dvs = dvs,
statExp = statExp,
stats = stats,
hp = hp,
maxHp = stats.hp,
types = def2.types,
moves = moves,
item = nil,
status = status,
-- engine/link/link.asm:1067
happiness = Convert.DEFAULT_HAPPINESS,
pokerus = 0,
caughtLevel = level,
isEgg = false,
ot = mon.ot,
otName = mon.ot,
otId = mon.otId,
traded = mon.traded,
}
out.shiny = Mon.isShiny(dvs,
{ species = mon.species, def = def2, level = level })
out.gender = Mon.gender(def2, dvs, { species = mon.species, level = level })
entry(report.changed, "happiness",
("FRIENDSHIP SET TO %d"):format(Convert.DEFAULT_HAPPINESS),
{ to = Convert.DEFAULT_HAPPINESS })
entry(report.changed, "caught_level",
("MET AT LEVEL %d"):format(level), { to = level })
return out, report
end
-- engine/link/link.asm:1985 species, :1999 mail, :2016 move
function Convert.refusalFor(mon, gen2Data, gen1Data)
if type(mon) ~= "table" then return "not_a_mon", {} end
if mon.isEgg then return "is_egg", {} end
local species1 = gen1Data and gen1Data.pokemon and gen1Data.pokemon[mon.species]
if not species1 then
return "species_too_new", { species = mon.species }
end
if mon.mail ~= nil or Mail.monHoldsMail(mon) then
return "has_mail", { item = mon.item }
end
for _, mv in ipairs(mon.moves or {}) do
if not (gen1Data.moves and gen1Data.moves[mv.id]) then
return "move_too_new", { move = mv.id }
end
end
return nil
end
function Convert.toGen1(mon, gen2Data, gen1Data)
local reason, info = Convert.refusalFor(mon, gen2Data, gen1Data)
if reason then return nil, reason, info end
local def1 = gen1Data.pokemon[mon.species]
local def2 = gen2Data and gen2Data.pokemon and gen2Data.pokemon[mon.species]
local report = newReport()
local level = levelOf(mon)
local dvs = copyDVs(mon.dvs)
local statExp = copyStatExp(mon.statExp)
-- engine/link/link.asm:784, data/pokemon/gen1_base_special.asm:3
local stats = Stats.calc(def1, level, dvs, statExp)
local oldMax = tonumber(mon.maxHp)
if not oldMax then
local old = mon.stats
or Mon.stats(baseStatsOf(def2), dvs, level, statExp)
oldMax = old.hp
end
local oldSpA = mon.stats and mon.stats.specialAttack
local oldSpD = mon.stats and mon.stats.specialDefense
if oldSpA or oldSpD then
entry(report.changed, "special_fold",
("SPECIAL FOLDED %s/%s -> %d"):format(tostring(oldSpA or "?"),
tostring(oldSpD or "?"), stats.special),
{ specialAttack = oldSpA, specialDefense = oldSpD, to = stats.special })
end
local hp = scaleHp(mon.hp, oldMax, stats.hp)
if oldMax ~= stats.hp then
entry(report.changed, "hp",
("HP %d/%d -> %d/%d"):format(math.min(tonumber(mon.hp) or 0, oldMax),
oldMax, hp, stats.hp),
{ from = mon.hp, fromMax = oldMax, to = hp, toMax = stats.hp })
end
local exp = Growth.expForLevel(def1.growthRate, level, gen1Data.growth_rates)
if tonumber(mon.experience) and math.floor(mon.experience) ~= exp then
entry(report.changed, "experience",
("EXP %d -> %d"):format(math.floor(mon.experience), exp),
{ from = math.floor(mon.experience), to = exp })
end
local moves = {}
for _, mv in ipairs(mon.moves or {}) do
local base2 = gen2Data and gen2Data.moves and gen2Data.moves[mv.id]
and gen2Data.moves[mv.id].pp
local ups = ppUpsFrom(base2, mv.maxPp)
local base1 = (gen1Data.moves[mv.id] and gen1Data.moves[mv.id].pp) or 0
local maxPp = ppBonus(base1, ups)
moves[#moves + 1] = {
id = mv.id,
pp = math.max(0, math.min(tonumber(mv.pp) or maxPp, maxPp)),
ppUps = ups,
}
end
local status = mon.status and Convert.STATUS_2TO1[mon.status] or nil
if mon.status and status ~= mon.status then
entry(report.changed, "status",
("STATUS %s -> %s"):format(tostring(mon.status), tostring(status)),
{ from = mon.status, to = status })
end
if mon.item then
-- engine/link/link.asm:756, :1078 TimeCapsule_ReplaceTeruSama
entry(report.lost, "item",
("HELD ITEM LOST: %s"):format(displayName(gen2Data and gen2Data.items,
mon.item)), { item = mon.item })
end
if tonumber(mon.happiness) then
entry(report.lost, "happiness", "FRIENDSHIP LOST",
{ from = math.floor(mon.happiness) })
end
if tonumber(mon.pokerus) and mon.pokerus ~= 0 then
entry(report.lost, "pokerus", "POKERUS LOST", { from = mon.pokerus })
end
if tonumber(mon.caughtLevel) then
entry(report.lost, "caught_level", "MET DATA LOST",
{ from = math.floor(mon.caughtLevel) })
end
local out = {
species = mon.species,
level = level,
exp = exp,
dvs = dvs,
statExp = statExp,
stats = stats,
hp = hp,
catchRate = def1.catchRate,
status = status,
moves = moves,
nickname = mon.nickname,
ot = mon.ot or mon.otName,
otId = mon.otId,
traded = mon.traded,
}
return out, report
end
-- engine/link/time_capsule.asm:3 ValidateOTTrademon, :41 the type carve-out
function Convert.validateArrival(mon, destData)
if type(mon) ~= "table" then return false, "not_a_mon" end
local def = destData and destData.pokemon and destData.pokemon[mon.species]
if not def then return false, "species_unknown" end
-- engine/link/time_capsule.asm:27
local level = tonumber(mon.level)
if not level or level < 1 or level > 100 then return false, "level" end
if Convert.TYPE_EXEMPT[mon.species] then return true end
local claimed = mon.types
if type(claimed) ~= "table" then return true end
local want = def.types or {}
local w1, w2 = want[1], want[2] or want[1]
local c1, c2 = claimed[1], claimed[2] or claimed[1]
if c1 ~= w1 or c2 ~= w2 then return false, "types" end
return true
end
local REFUSAL_TEXT = {
is_egg = function() return "EGGS CANNOT TRAVEL" end,
has_mail = function() return "MON IS HOLDING MAIL" end,
not_a_mon = function() return "NO MON" end,
species_unknown = function(_, info)
return ("SPECIES UNKNOWN: %s"):format(tostring(info.species or "?"))
end,
species_too_new = function(_, info)
return ("SPECIES NOT IN GEN 1: %s"):format(
tostring(info.species or "?"):gsub("_", " "))
end,
move_too_new = function(_, info)
return ("MOVE ILLEGAL IN GEN 1: %s"):format(
tostring(info.move or "?"):gsub("_", " "))
end,
}
local function previewLines(mon, report, reason, info)
local lines = {}
if reason then
local fn = REFUSAL_TEXT[reason]
lines[1] = fn and fn(mon, info or {}) or reason:upper()
return lines, false
end
for _, row in ipairs(report.lost) do lines[#lines + 1] = row.text end
for _, row in ipairs(report.changed) do lines[#lines + 1] = row.text end
if #lines == 0 then lines[1] = "NOTHING IS LOST" end
return lines, true
end
local function convertOne(mon, toGen, fromData, toData)
if toGen == 1 then
local out, second, info = Convert.toGen1(mon, fromData, toData)
if out then return out, second end
return nil, second, info or {}
end
local out, second = Convert.toGen2(mon, fromData, toData)
if out then return out, second end
return nil, second, { species = mon and mon.species }
end
function Convert.preview(mon, fromGen, toGen, fromData, toData)
local out, second, info = convertOne(mon, toGen, fromData, toData)
if out then return previewLines(mon, second) end
return previewLines(mon, nil, second, info)
end
-- engine/link/link.asm:2048 refuses the whole party; per-slot here instead
local function mapParty(party, toGen, fromData, toData)
local converted, results = {}, {}
for i, mon in ipairs(party or {}) do
local out, second, info = convertOne(mon, toGen, fromData, toData)
if out then
converted[#converted + 1] = out
results[i] = { index = i, ok = true, mon = out, report = second,
species = mon.species,
preview = previewLines(mon, second) }
else
results[i] = { index = i, ok = false, reason = second, info = info,
species = mon.species,
preview = previewLines(mon, nil, second, info) }
end
end
return converted, results
end
function Convert.partyToGen1(party, gen2Data, gen1Data)
return mapParty(party, 1, gen2Data, gen1Data)
end
function Convert.partyToGen2(party, gen1Data, gen2Data)
return mapParty(party, 2, gen1Data, gen2Data)
end
function Convert.refusals(results)
local out = {}
for _, row in ipairs(results or {}) do
if not row.ok then out[#out + 1] = row end
end
return out
end
return Convert
+234
View File
@@ -0,0 +1,234 @@
-- engine/gfx/mon_icons.asm WriteSymmetricMonPartySpriteOAM
-- engine/gfx/load_pics.asm
local CacheFs = require("src.import.CacheFs")
local GameVersion = require("src.core.GameVersion")
local OnlineSprites = {}
local catalogs = {}
local cache = {}
-- engine/gfx/mon_icons.asm:246
local function mirrors(name)
return name ~= nil and name ~= "HELIX"
end
local SHADES = { { 255, 255, 255 }, { 170, 170, 170 },
{ 85, 85, 85 }, { 0, 0, 0 } }
function OnlineSprites.key(version, species, shiny)
return ("%s|%s|%s"):format(tostring(version), tostring(species),
shiny and "shiny" or "normal")
end
function OnlineSprites.reset()
catalogs, cache = {}, {}
end
function OnlineSprites.keepOnly(versions)
local keep = {}
for _, version in ipairs(versions or {}) do keep[tostring(version)] = true end
for version in pairs(catalogs) do
if not keep[version] then catalogs[version] = nil end
end
for key in pairs(cache) do
local version = key:match("^([^|]*)|")
if version and not keep[version] then cache[key] = nil end
end
end
function OnlineSprites.readBytes(version, path)
local prefix = GameVersion.cachePrefix(version) or ""
local bytes = CacheFs.readAt(prefix .. path)
if type(bytes) == "string" and bytes ~= "" then return bytes end
return nil
end
local function loadTable(version, path)
local bytes = OnlineSprites.readBytes(version, path)
if not bytes then return nil end
local loader = loadstring or load
local chunk = loader(bytes, "@" .. path)
if not chunk then return nil end
local ok, value = pcall(chunk)
if not ok or type(value) ~= "table" then return nil end
return value
end
local function catalog(version)
local hit = catalogs[version]
if hit then return hit end
hit = {
pokemon = loadTable(version, "data/generated/pokemon.lua") or {},
icons = loadTable(version, "data/generated/icons.lua") or {},
palettes = loadTable(version, "data/generated/palettes.lua") or {},
generation = GameVersion.generation(version),
}
catalogs[version] = hit
return hit
end
local function paletteFor(cat, species, shiny)
local entry = cat.palettes and cat.palettes.pokemon
and cat.palettes.pokemon[species]
if type(entry) == "string" then
local ramp = cat.palettes.palettes and cat.palettes.palettes[entry]
if type(ramp) == "table" and #ramp == 4 then return ramp end
return nil
end
if type(entry) ~= "table" then return nil end
local pair = entry[shiny and "shiny" or "normal"] or entry.normal
if type(pair) ~= "table" or #pair < 2 then return nil end
return { { 255, 255, 255 }, pair[1], pair[2], { 0, 0, 0 } }
end
local function shadeIndex(r)
if r > 0.75 then return 1 end
if r > 0.5 then return 2 end
if r > 0.25 then return 3 end
return 4
end
function OnlineSprites.makeImage(bytes, path, palette)
if not (love and love.graphics and love.graphics.newImage) then return nil end
local ok, image = pcall(function()
local data = love.filesystem.newFileData(bytes, path)
if not (palette and love.image and love.image.newImageData) then
return love.graphics.newImage(data)
end
local pixels = love.image.newImageData(data)
if type(pixels.mapPixel) ~= "function" then
return love.graphics.newImage(data)
end
pixels:mapPixel(function(_, _, r, _, _, a)
local col = palette[shadeIndex(r)] or SHADES[shadeIndex(r)]
return (col[1] or 0) / 255, (col[2] or 0) / 255, (col[3] or 0) / 255, a
end)
return love.graphics.newImage(pixels)
end)
if not ok then return nil end
return image
end
local function image(version, path, palette)
local bytes = OnlineSprites.readBytes(version, path)
if not bytes then return false end
return OnlineSprites.makeImage(bytes, path, palette) or false
end
local function iconPath(cat, mon)
local icons = cat.icons or {}
if cat.generation == 2 then
local id = mon.isEgg and "ICON_EGG"
or (icons.species and icons.species[mon.species])
local entry = id and icons.icons and icons.icons[id]
if type(entry) == "table" then return entry.image, id end
return nil, id
end
local def = cat.pokemon and cat.pokemon[mon.species]
local entry = (icons.bySpecies and icons.bySpecies[mon.species])
or (def and def.icon)
local name, path
if type(entry) == "string" then
name = entry
path = icons.icons and icons.icons[entry]
elseif type(entry) == "table" then
path = entry.image
end
if not path then
name = def and def.dex and icons.byDex and icons.byDex[def.dex]
local hit = name and icons.icons and icons.icons[name]
path = (type(hit) == "table") and hit.image or hit
end
return path, name
end
function OnlineSprites.get(version, mon)
if type(mon) ~= "table" then return nil end
return cache[OnlineSprites.key(version, mon.species, mon.shiny == true)]
end
function OnlineSprites.ensure(version, mon)
if type(mon) ~= "table" or not version then return nil end
local key = OnlineSprites.key(version, mon.species, mon.shiny == true)
local hit = cache[key]
if hit then return hit end
local cat = catalog(version)
local def = cat.pokemon and cat.pokemon[mon.species]
local palette = paletteFor(cat, mon.species, mon.shiny == true)
local path, name = iconPath(cat, mon)
local entry = { key = key, mirror = false }
if path then
entry.icon = image(version, path, nil)
entry.mirror = (cat.generation ~= 2) and mirrors(name) or false
else
entry.icon = false
end
entry.front = def and def.spriteFront
and image(version, def.spriteFront, palette) or false
cache[key] = entry
return entry
end
function OnlineSprites.prime(version, party)
if not version or type(party) ~= "table" then return 0 end
local n = 0
for _, mon in ipairs(party) do
if OnlineSprites.ensure(version, mon) then n = n + 1 end
end
return n
end
local function paintWhite()
if not (love.graphics and love.graphics.getColor) then return nil end
local r, g, b, a = love.graphics.getColor()
love.graphics.setColor(1, 1, 1, 1)
return { r, g, b, a }
end
local function restore(saved)
if saved and love.graphics and love.graphics.setColor then
love.graphics.setColor(saved[1], saved[2], saved[3], saved[4])
end
end
function OnlineSprites.drawIcon(entry, x, y, size)
if type(entry) ~= "table" or not entry.icon then return false end
local img = entry.icon
if type(img.getDimensions) ~= "function" then return false end
local iw, ih = img:getDimensions()
if not iw or iw <= 0 or ih <= 0 then return false end
local scale = (size or 16) / 16
local saved = paintWhite()
if ih > 16 and entry.mirror then
local half = love.graphics.newQuad(0, 0, 8, 16, iw, ih)
love.graphics.draw(img, half, x, y, 0, scale, scale)
love.graphics.draw(img, half, x + 16 * scale, y, 0, -scale, scale)
elseif ih > 16 then
local frame = love.graphics.newQuad(0, 0, 16, 16, iw, ih)
love.graphics.draw(img, frame, x, y, 0, scale, scale)
else
love.graphics.draw(img, x, y, 0, scale, scale)
end
restore(saved)
return true
end
function OnlineSprites.drawFront(entry, x, y, box)
if type(entry) ~= "table" or not entry.front then return false end
local img = entry.front
if type(img.getDimensions) ~= "function" then return false end
local iw, ih = img:getDimensions()
if not iw or iw <= 0 or ih <= 0 then return false end
box = box or 56
local scale = box / math.max(iw, ih)
if scale >= 1 then scale = math.floor(scale) end
local saved = paintWhite()
love.graphics.draw(img, math.floor(x + (box - iw * scale) / 2),
math.floor(y + (box - ih * scale) / 2), 0, scale, scale)
restore(saved)
return true
end
return OnlineSprites
+381
View File
@@ -0,0 +1,381 @@
local Wire = require("src.link.Wire")
local Protocol2 = {}
Protocol2.VERSION = 2
Protocol2.INTENTS = { battle = true, trade = true, tournament = true }
Protocol2.JOIN_AS = { player = true, spectator = true }
local function build(msg)
local out = Wire.sanitize(msg)
return out
end
function Protocol2.lobbyHello(opts)
opts = opts or {}
return build({
type = "lobby_hello",
ticket = opts.ticket,
name = opts.name,
engineVersion = opts.engineVersion,
platform = opts.platform,
profiles = opts.profiles or {},
})
end
function Protocol2.resume(session, ack)
return build({ type = "resume", session = session, ack = ack or 0 })
end
function Protocol2.advertise(intent, profile, note)
return build({ type = "advertise", intent = intent, profile = profile,
note = note })
end
function Protocol2.unadvertise()
return build({ type = "unadvertise" })
end
function Protocol2.roomCreate(opts)
opts = opts or {}
return build({
type = "room_create",
intent = opts.intent or "battle",
profile = opts.profile,
playing = opts.playing ~= false,
maxSpectators = opts.maxSpectators,
public = opts.public ~= false,
note = opts.note,
})
end
function Protocol2.roomJoin(code, as, profile)
return build({ type = "room_join", code = code, as = as or "player",
profile = profile })
end
function Protocol2.roomLeave()
return build({ type = "room_leave" })
end
function Protocol2.roomReady(party, partyDigest)
return build({ type = "room_ready", party = party or {},
partyDigest = partyDigest })
end
function Protocol2.roomMsg(seq, msg)
return build({ type = "room_msg", seq = seq, msg = msg })
end
function Protocol2.roomAck(seq)
return build({ type = "room_ack", seq = seq })
end
function Protocol2.roomReport(match, result)
return build({ type = "room_report", match = match, result = result })
end
function Protocol2.forfeit(match)
return build({ type = "forfeit", match = match })
end
function Protocol2.roomKick(id)
return build({ type = "room_kick", id = id })
end
function Protocol2.roomClose()
return build({ type = "room_close" })
end
function Protocol2.lobbyQuery()
return build({ type = "lobby_query" })
end
Protocol2.SHOT_CLOCKS = { 3, 6, 9 }
Protocol2.TOUR_STAGES = { registering = true, running = true, finished = true }
Protocol2.TOUR_MATCH_STATES = { pending = true, live = true, done = true,
bye = true }
function Protocol2.shotClock(value)
local want = tonumber(value)
if not want then return Protocol2.SHOT_CLOCKS[2] end
local best, bestGap = Protocol2.SHOT_CLOCKS[2], math.huge
for _, allowed in ipairs(Protocol2.SHOT_CLOCKS) do
local gap = math.abs(allowed - want)
if gap < bestGap then best, bestGap = allowed, gap end
end
return best
end
function Protocol2.tourCreate(opts)
opts = opts or {}
return build({
type = "tour_create",
profile = opts.profile,
rule = opts.rule,
playing = opts.playing ~= false,
shotClock = Protocol2.shotClock(opts.shotClock),
maxSpectators = opts.maxSpectators,
party = opts.party,
partyDigest = opts.partyDigest,
public = opts.public ~= false,
note = opts.note,
})
end
function Protocol2.tourJoin(code, as, profile, party, partyDigest)
return build({
type = "tour_join",
code = code,
as = as or "player",
profile = profile,
party = party,
partyDigest = partyDigest,
})
end
function Protocol2.tourLeave()
return build({ type = "tour_leave" })
end
function Protocol2.tourStart()
return build({ type = "tour_start" })
end
function Protocol2.tourKick(id)
return build({ type = "tour_kick", id = id })
end
function Protocol2.tourClose()
return build({ type = "tour_close" })
end
Protocol2.CLIENT_TYPES = {
lobby_hello = true, ping = true, pong = true, resume = true,
advertise = true, unadvertise = true, room_create = true,
room_join = true, room_leave = true, room_ready = true,
room_msg = true, room_ack = true, room_report = true, forfeit = true,
room_kick = true, room_close = true, lobby_query = true,
tour_create = true, tour_join = true, tour_leave = true,
tour_start = true, tour_kick = true, tour_close = true,
}
Protocol2.SERVER_TYPES = {
lobby_welcome = true, lobby_list = true, lobby_delta = true,
room_state = true, room_replay = true, room_msg = true,
room_deadline = true, room_result = true, room_closed = true,
match_start = true, match_start_spectate = true, join_error = true,
ping = true, pong = true,
tour_state = true, tour_match = true, tour_match_spectate = true,
tour_bye = true, tour_deadline = true, tour_over = true,
tour_closed = true,
}
Protocol2.RESULTS = { win = true, lose = true, draw = true }
local VALIDATORS = {}
VALIDATORS.lobby_welcome = function(m)
if type(m.session) ~= "string" or m.session == "" then
return nil, "lobby_welcome without a session id"
end
if type(m.you) ~= "table" then return nil, "lobby_welcome without you" end
return m
end
VALIDATORS.lobby_list = function(m)
if type(m.entries) ~= "table" then return nil, "lobby_list without entries" end
return m
end
local DELTA_LISTS = { "added", "removed", "changed", "add", "update", "remove" }
VALIDATORS.lobby_delta = function(m)
for _, key in ipairs(DELTA_LISTS) do
if type(m[key]) == "table" and #m[key] > 0 then return m end
end
if type(m.op) == "string"
and (type(m.entry) == "table" or type(m.id) == "string") then
return m
end
return nil, "empty lobby_delta"
end
VALIDATORS.room_state = function(m)
if type(m.code) ~= "string" then return nil, "room_state without a code" end
if type(m.players) ~= "table" then return nil, "room_state without players" end
return m
end
VALIDATORS.room_replay = function(m)
if type(m.msgs) ~= "table" then return nil, "room_replay without msgs" end
return m
end
VALIDATORS.room_msg = function(m)
if type(m.msg) ~= "table" or type(m.msg.type) ~= "string" then
return nil, "room_msg without an inner message"
end
if type(m.seq) ~= "number" then return nil, "room_msg without a seq" end
return m
end
VALIDATORS.room_deadline = function(m)
if type(m.kind) ~= "string" then return nil, "room_deadline without a kind" end
if type(m.at) ~= "number" then return nil, "room_deadline without a time" end
return m
end
VALIDATORS.match_start = function(m)
if type(m.role) ~= "string" or m.role == "" then
return nil, "match_start without a role"
end
if type(m.match) ~= "string" or m.match == "" then
return nil, "match_start without a match token"
end
return m
end
VALIDATORS.match_start_spectate = VALIDATORS.match_start
VALIDATORS.room_closed = function(m)
if type(m.reason) ~= "string" or m.reason == "" then
return nil, "room_closed without a reason"
end
return m
end
VALIDATORS.room_result = function(m)
if type(m.how) ~= "string" then return nil, "room_result without how" end
return m
end
VALIDATORS.tour_state = function(m)
if type(m.code) ~= "string" or m.code == "" then
return nil, "tour_state without a code"
end
if type(m.players) ~= "table" then return nil, "tour_state without players" end
if type(m.bracket) ~= "table" then return nil, "tour_state without a bracket" end
if not Protocol2.TOUR_STAGES[m.stage or ""] then
return nil, "tour_state without a stage"
end
return m
end
VALIDATORS.tour_match = function(m)
if type(m.match) ~= "string" or m.match == "" then
return nil, "tour_match without a match token"
end
if type(m.code) ~= "string" or m.code == "" then
return nil, "tour_match without a child room code"
end
return m
end
VALIDATORS.tour_match_spectate = VALIDATORS.tour_match
VALIDATORS.tour_bye = function(m)
if type(m.match) ~= "string" or m.match == "" then
return nil, "tour_bye without a match token"
end
return m
end
VALIDATORS.tour_deadline = function(m)
if type(m.kind) ~= "string" or m.kind == "" then
return nil, "tour_deadline without a kind"
end
if type(m.at) ~= "number" then return nil, "tour_deadline without a time" end
return m
end
VALIDATORS.tour_closed = function(m)
if type(m.reason) ~= "string" or m.reason == "" then
return nil, "tour_closed without a reason"
end
return m
end
VALIDATORS.tour_over = function(m)
if type(m.code) ~= "string" or m.code == "" then
return nil, "tour_over without a code"
end
return m
end
VALIDATORS.join_error = function(m)
if type(m.reason) ~= "string" or m.reason == "" then
return nil, "join_error without a reason"
end
return m
end
Protocol2.VALIDATORS = VALIDATORS
function Protocol2.validate(raw)
if type(raw) ~= "table" then return nil, "not a table" end
local msg = Wire.sanitize(raw)
if not msg then return nil, "failed sanitize" end
local validator = VALIDATORS[msg.type]
if not validator then return msg end
local ok, reason = validator(msg)
if not ok then return nil, reason end
return ok
end
local REASONS = {
not_found = "That room code wasn't found.",
full = "That room is full.",
expired = "That room code has expired.",
profile_mismatch = "Your game doesn't match the room.",
rule_violation = "Your team doesn't meet the room's rule.",
spectate_late = "That match is too far along to watch.",
bad_ticket = "The relay didn't accept your sign-in.",
lobby_disabled = "This relay isn't running online play.",
resume_unknown = "The relay forgot your session.",
resume_expired = "You were away too long to rejoin.",
already_in_room = "You're already in a room.",
spectators_full = "That room has all the spectators it can take.",
bad_profile = "The relay couldn't read your game's profile.",
bad_party = "The relay couldn't read your team.",
party_ineligible = "Your team doesn't meet the room's rule.",
tour_not_found = "That tournament code wasn't found.",
tour_started = "That tournament has already started.",
tour_full = "That tournament is full.",
not_creator = "Only the tournament's creator can do that.",
}
local CLOSED_REASONS = {
kicked = "The host removed you from the room.",
closed = "The host closed the room.",
idle = "The room was closed for being idle.",
backlog = "You fell too far behind to keep watching.",
}
local TOUR_CLOSED_REASONS = {
kicked = "The creator removed you from the tournament.",
closed = "The creator closed the tournament.",
idle = "The tournament was closed for being idle.",
}
function Protocol2.tourClosedText(msg)
local reason = type(msg) == "table" and msg.reason or tostring(msg)
return TOUR_CLOSED_REASONS[reason]
or ("The tournament closed: " .. tostring(reason))
end
function Protocol2.roomClosedText(msg)
local reason = type(msg) == "table" and msg.reason or tostring(msg)
return CLOSED_REASONS[reason] or ("The room closed: " .. tostring(reason))
end
function Protocol2.joinErrorText(msg)
local reason = type(msg) == "table" and msg.reason or tostring(msg)
local text = REASONS[reason] or ("Couldn't join: " .. tostring(reason))
local detail = type(msg) == "table" and msg.detail or nil
if detail and detail ~= "" then return text .. " (" .. detail .. ")" end
return text
end
return Protocol2
+241
View File
@@ -0,0 +1,241 @@
local GameVersion = require("src.core.GameVersion")
local Protocol = require("src.link.Protocol")
local SaveData = require("src.core.SaveData")
local TeamPick = {}
local function generationOf(version, save)
if type(save) == "table" and tonumber(save.generation) then
return tonumber(save.generation)
end
return GameVersion.generation(version)
end
local function readCartSource(cartId, slotId)
local registered = false
for _, row in ipairs(SaveData.listCartSlots(cartId)) do
if row.id == slotId then registered = row.exists end
end
if not registered then return nil, "no save in that slot" end
local fs = SaveData.persistenceFs()
local main = "saves/cart_" .. cartId .. "/" .. slotId .. ".lua"
for _, name in ipairs({ main, main .. ".tmp", main .. ".bak" }) do
if fs.getInfo(name) then
local body = fs.read(name)
if type(body) == "string" and body ~= "" and SaveData.decode(body) then
return body
end
end
end
return nil, "no save in that slot"
end
local function readSource(version, slotId, cartId)
if cartId then return readCartSource(cartId, slotId) end
return SaveData.readSlotSource(version, slotId)
end
function TeamPick.readSlot(version, slotId, cartId)
if not GameVersion.VERSIONS[version] then return nil, "unknown game" end
if type(slotId) ~= "string" or slotId == "" then return nil, "no slot chosen" end
if cartId and SaveData.slotSealBroken(cartId, slotId) then
return nil, "this save's seal is broken"
end
local ok, source, err = pcall(readSource, version, slotId, cartId)
if not ok then return nil, tostring(source) end
if type(source) ~= "string" then
return nil, tostring(err or "no save in that slot")
end
local decoded, save = pcall(SaveData.decode, source)
if not decoded or type(save) ~= "table" then
return nil, "that save can't be read"
end
local name, meta = SaveData.slotSummary(save)
return {
save = save,
party = type(save.party) == "table" and save.party or {},
trainerName = name,
badges = (meta and meta.badges) or 0,
generation = generationOf(version, save),
}
end
-- engine/pokemon/bills_pc.asm
local GEN1_BOXES, GEN2_BOXES = 12, 14
local function boxName(save, generation, index)
if generation == 2 then
local names = type(save) == "table" and save.boxNames or nil
local given = type(names) == "table" and names[index] or nil
if type(given) == "string" and given ~= "" then return given end
return "BOX" .. tostring(index)
end
return "BOX " .. tostring(index)
end
TeamPick.boxName = boxName
function TeamPick.refKey(ref)
if type(ref) == "number" then return "party|" .. tostring(ref) end
if type(ref) ~= "table" then return "?" end
if ref.where == "box" then
return ("box|%s|%s"):format(tostring(ref.box), tostring(ref.index))
end
return "party|" .. tostring(ref.index)
end
function TeamPick.sameRef(a, b)
return TeamPick.refKey(a) == TeamPick.refKey(b)
end
local function slotOf(source)
if type(source) ~= "table" then return { party = {} } end
if type(source.party) == "table" then return source end
return { party = source }
end
TeamPick.slotOf = slotOf
local function boxesOf(slot)
local save = type(slot.save) == "table" and slot.save or nil
if save and type(save.boxes) == "table" then return save.boxes end
if type(slot.boxes) == "table" then return slot.boxes end
return nil
end
function TeamPick.monAt(source, ref)
local slot = slotOf(source)
if type(ref) == "number" then ref = { where = "party", index = ref } end
if type(ref) ~= "table" then return nil end
local index = tonumber(ref.index)
if not index or index < 1 or index ~= math.floor(index) then return nil end
if ref.where == "box" then
local boxes = boxesOf(slot)
if type(boxes) ~= "table" then return nil end
local list = boxes[tonumber(ref.box) or 0]
if type(list) ~= "table" then return nil end
local mon = list[index]
return type(mon) == "table" and mon or nil
end
local mon = (slot.party or {})[index]
return type(mon) == "table" and mon or nil
end
function TeamPick.candidates(source)
local slot = slotOf(source)
local out = {}
for index, mon in ipairs(slot.party or {}) do
out[#out + 1] = { where = "party", index = index, mon = mon,
source = "Party" }
end
local boxes = boxesOf(slot)
if type(boxes) ~= "table" then return out end
local generation = tonumber(slot.generation) or 1
local last = (generation == 2) and GEN2_BOXES or GEN1_BOXES
for index in pairs(boxes) do
local n = tonumber(index)
if n and n > last then last = n end
end
for box = 1, last do
local list = boxes[box]
if type(list) == "table" then
local name = boxName(slot.save, generation, box)
for index, mon in ipairs(list) do
if type(mon) == "table" then
out[#out + 1] = { where = "box", box = box, index = index,
mon = mon, source = name }
end
end
end
end
return out
end
function TeamPick.validate(source, team, rule)
local slot = slotOf(source)
rule = type(rule) == "table" and rule or {}
if type(team) ~= "table" then return false, "pick a team first" end
local want = tonumber(rule.partySize) or #team
if #team ~= want then
return false, ("this arena needs %d Pokemon."):format(want)
end
local seen = {}
for _, ref in ipairs(team) do
local key = TeamPick.refKey(ref)
local mon = TeamPick.monAt(slot, ref)
if not mon then return false, "that's not in this save." end
if seen[key] then return false, "no doubles allowed." end
seen[key] = true
if mon.isEgg then return false, "an EGG can't battle." end
if not rule.forceLevel then
local level = tonumber(mon.level) or 0
local min, max = tonumber(rule.minLevel), tonumber(rule.maxLevel)
if min and level < min then
return false, ("every Pokemon must be\nLv%d or higher."):format(min)
end
if max and level > max then
return false, ("every Pokemon must be\nLv%d or lower."):format(max)
end
end
end
return true
end
function TeamPick.pack(source, team, generation)
local slot = slotOf(source)
team = type(team) == "table" and team or {}
local gen2 = tonumber(generation) == 2
local mons = {}
for _, ref in ipairs(team) do
local mon = TeamPick.monAt(slot, ref)
if mon then
mons[#mons + 1] = gen2 and Protocol.packMon2(mon) or Protocol.packMon(mon)
end
end
return mons
end
-- engine/link/time_capsule.asm:41
function TeamPick.convert(source, toGeneration, fromData, toData)
local Convert = require("src.online.Convert")
toGeneration = tonumber(toGeneration) or 1
local list = TeamPick.candidates(source)
local mons = {}
for i, row in ipairs(list) do mons[i] = row.mon end
local results
if toGeneration == 1 then
_, results = Convert.partyToGen1(mons, fromData, toData)
else
_, results = Convert.partyToGen2(mons, fromData, toData)
end
local byKey, rows, refusals = {}, {}, {}
for index, row in ipairs(results or {}) do
local ref = list[index]
local key = ref and TeamPick.refKey(ref) or tostring(index)
row.ref, row.key = ref, key
rows[key] = row
if row.ok then
byKey[key] = row.mon
else
refusals[key] = row.reason or "refused"
end
end
return byKey, rows, refusals, list
end
function TeamPick.packConverted(converted, team, generation)
converted = type(converted) == "table" and converted or {}
team = type(team) == "table" and team or {}
local mons = {}
for _, ref in ipairs(team) do
local mon = converted[TeamPick.refKey(ref)]
if type(mon) ~= "table" then
return nil, "that Pokemon cannot cross generations."
end
mons[#mons + 1] = (tonumber(generation) == 2)
and Protocol.packMon2(mon) or Protocol.packMon(mon)
end
return mons
end
return TeamPick
+681
View File
@@ -0,0 +1,681 @@
local GameVersion = require("src.core.GameVersion")
local Protocol = require("src.link.Protocol")
local SaveData = require("src.core.SaveData")
local TeamPick = require("src.online.TeamPick")
local Trade = {}
local function disk()
return SaveData.persistenceFs()
end
local function scopeKey(version, cartId)
if cartId then return "cart_" .. cartId end
return version
end
function Trade.slotPath(version, slotId, cartId)
return "saves/" .. scopeKey(version, cartId) .. "/" .. slotId .. ".lua"
end
Trade.mountDepth = 0
function Trade.mounted()
return (Trade.mountDepth or 0) > 0
end
Trade.hostIsLive = nil
function Trade.gameIsLive()
if type(Trade.hostIsLive) == "function" then
return Trade.hostIsLive() == true
end
if Trade.mounted() then return false end
local Data = package.loaded["src.core.Data"]
return Data ~= nil and Data._pristineKeys ~= nil
end
local function deepCopy(value, seen)
if type(value) ~= "table" then return value end
seen = seen or {}
if seen[value] then return seen[value] end
local out = {}
seen[value] = out
for k, v in pairs(value) do out[deepCopy(k, seen)] = deepCopy(v, seen) end
return out
end
-- ------- dataset mounting (no Game)
local function gen2Dataset()
local CacheFs = require("src.import.CacheFs")
local data = {}
local function load(name)
return CacheFs.loadActive("data/generated/" .. name .. ".lua")
end
data.pokemon = load("pokemon") or {}
data.items = load("items") or {}
data.moves = load("moves") or {}
data.type_chart = load("type_chart") or {}
data.gen2Constants = load("constants")
local ItemEffects = require("src.core.gen2.ItemEffects")
data.gen2HeldItems = ItemEffects.heldItemsFrom(data.items)
return data
end
local function mountDataset(version)
local CacheFs = require("src.import.CacheFs")
local generation = GameVersion.generation(version)
local prevVersion, prevPrefix = GameVersion.get(), CacheFs.prefix
local released = false
Trade.mountDepth = (Trade.mountDepth or 0) + 1
local function release()
if released then return end
released = true
Trade.mountDepth = math.max(0, (Trade.mountDepth or 1) - 1)
if generation ~= 2 then
pcall(function() require("src.core.Data"):unloadGenerated() end)
end
pcall(CacheFs.unmountVersion, version)
GameVersion.set(prevVersion)
CacheFs.prefix = prevPrefix
end
local ok, data = pcall(function()
GameVersion.set(version)
CacheFs.prefix = GameVersion.cachePrefix(version)
CacheFs.mountVersion(version)
if not CacheFs.readActive("data/generated/pokemon.lua") then
error(("%s is not imported"):format(tostring(version)), 0)
end
if generation == 2 then return gen2Dataset() end
local Data = require("src.core.Data")
Data:load()
return Data
end)
if not ok then
release()
return nil, tostring(data)
end
return data, release
end
function Trade.withDataset(version, fn)
local data, release = mountDataset(version)
if not data then return nil, release end
local ok, result = pcall(fn, data)
release()
if not ok then return nil, tostring(result) end
return result
end
function Trade.withMountedData(data, fn)
Trade.mountDepth = (Trade.mountDepth or 0) + 1
local ok, result = pcall(fn, data)
Trade.mountDepth = math.max(0, (Trade.mountDepth or 1) - 1)
if not ok then return nil, tostring(result) end
return result
end
local function withData(handle, fn)
if handle.data then
local ok, result = pcall(fn, handle.data)
if not ok then return nil, tostring(result) end
return result
end
if Trade.gameIsLive() then return nil, "close the game first" end
return Trade.withDataset(handle.version, fn)
end
-- ------- handles
function Trade.openSlot(version, slotId, cartId, opts)
opts = type(opts) == "table" and opts or {}
if Trade.gameIsLive() then return nil, "close the game first" end
local slot, reason = TeamPick.readSlot(version, slotId, cartId)
if not slot then return nil, reason end
local save = slot.save
return {
version = version,
generation = slot.generation,
slotId = slotId,
cartId = cartId,
save = save,
path = Trade.slotPath(version, slotId, cartId),
party = slot.party,
boxes = type(save.boxes) == "table" and save.boxes or nil,
trainerName = slot.trainerName,
data = opts.data,
}
end
local function refOf(index)
local where, box, at = "party", nil, index
if type(index) == "table" then
at = index.index
if index.where == "box" then
where = "box"
box = tonumber(index.box)
if not box or box < 1 or box ~= math.floor(box) then return nil end
end
end
at = tonumber(at)
if not at or at < 1 or at ~= math.floor(at) then return nil end
return { where = where, box = box, index = at }
end
Trade.refOf = refOf
-- engine/link/link.asm:810
function Trade.holdsMail(handle, index)
if type(handle) ~= "table" or handle.generation ~= 2 then return false end
local ref = refOf(index)
if not ref or ref.where == "box" then return false end
local mon = (handle.party or {})[ref.index]
local Mail = require("src.core.gen2.Mail")
if Mail.monHoldsMail(mon) then return true end
local state = type(handle.save) == "table" and handle.save.mail or nil
local party = type(state) == "table" and state.party or nil
return type(party) == "table" and party[ref.index] ~= nil
end
local function pickable(handle, index)
if type(handle) ~= "table" or type(handle.party) ~= "table" then
return nil, "that save can't be read"
end
local ref = refOf(index)
if not ref then
return nil, (type(index) == "table" and index.where == "box")
and "that's not in the PC" or "that's not in the party"
end
local mon = TeamPick.monAt(handle, ref)
if type(mon) ~= "table" then
return nil, (ref.where == "box") and "that's not in the PC"
or "that's not in the party"
end
if mon.isEgg then return nil, "an EGG can't be traded" end
if Trade.holdsMail(handle, ref) then return nil, "take the MAIL first" end
return mon, nil, ref
end
-- ------- rebuild, evolve, dex
local function packFor(generation, mon)
if generation == 2 then return Protocol.packMon2(mon) end
return Protocol.packMon(mon)
end
local function packPartyFor(generation, party, indices)
if generation ~= 2 then return Protocol.packParty(party, indices) end
if Protocol.packParty2 then return Protocol.packParty2(party, indices) end
local mons = {}
if indices then
for _, i in ipairs(indices) do
mons[#mons + 1] = Protocol.packMon2(party[i])
end
return mons
end
for _, mon in ipairs(party) do mons[#mons + 1] = Protocol.packMon2(mon) end
return mons
end
-- engine/pokemon/evos_moves.asm
local function evolveGen1(data, record)
local def = data.pokemon and data.pokemon[record.species]
local into
for _, evo in ipairs((def and def.evolutions) or {}) do
if evo.method == "TRADE" then
into = evo.species
break
end
end
local newDef = into and data.pokemon and data.pokemon[into]
if not newDef then return nil end
local Stats = require("src.pokemon.Stats")
local evolved = deepCopy(record)
local previousMax = (record.stats and record.stats.hp) or record.hp or 0
local lost = previousMax - (record.hp or previousMax)
evolved.species = into
evolved.stats = Stats.calc(newDef, evolved.level, evolved.dvs, evolved.statExp)
evolved.hp = math.max(1, evolved.stats.hp - lost)
if evolved.nickname and evolved.nickname == (def.name or record.species) then
evolved.nickname = nil
end
return into, evolved, false
end
-- engine/pokemon/evolve.asm
local function evolveGen2(data, record)
local Evolution = require("src.core.gen2.Evolution")
local entry, consumes = Evolution.checkMon(data, record, { link = true })
if not entry then return nil end
local evolved = Evolution.apply(data, record, entry)
if not evolved then return nil end
return entry.into, evolved, consumes == true
end
local function markDex(save, generation, species)
local dex = save.pokedex
if type(dex) ~= "table" then
dex = {}
save.pokedex = dex
end
local ownedKey = generation == 2 and "caught" or "owned"
if type(dex.seen) ~= "table" then dex.seen = {} end
if type(dex[ownedKey]) ~= "table" then dex[ownedKey] = {} end
for _, id in ipairs(species) do
dex.seen[id] = true
dex[ownedKey][id] = true
end
end
local function sideFor(handle, ref, packed, record, warnings)
return withData(handle, function(data)
local built, why = record, nil
if not built then
local unpacker = handle.generation == 2
and Protocol.unpackMon2 or Protocol.unpackMon
built, why = unpacker(data, packed, { strict = true })
if not built then return { error = why or "unknown POKéMON" } end
end
-- engine/battle/experience.asm:69
built.traded = true
local into, evolved, consumed
if handle.generation == 2 then
into, evolved, consumed = evolveGen2(data, built)
else
into, evolved, consumed = evolveGen1(data, built)
end
local species = { built.species }
if into then species[#species + 1] = into end
if into then
warnings[#warnings + 1] =
{ code = "evolve", slot = handle.slotId, species = into }
end
if consumed then
warnings[#warnings + 1] =
{ code = "item_used", slot = handle.slotId, item = built.item }
end
return {
handle = handle,
ref = ref,
index = ref.index,
received = built,
record = evolved or built,
evolveTo = into,
dex = species,
sent = TeamPick.monAt(handle, ref),
}
end)
end
-- ------- plan
local function planFrom(sides, warnings)
local plan = { sides = sides, warnings = warnings }
for _, side in ipairs(sides) do
if side.role == "a" then
plan.get = side.record
plan.evolveA = side.evolveTo
plan.give = plan.give or side.sent
elseif side.role == "b" then
plan.give = side.record
plan.evolveB = side.evolveTo
end
end
return plan
end
function Trade.plan(req)
req = type(req) == "table" and req or {}
local from, to = req.from, req.to
if type(from) ~= "table" or type(to) ~= "table" then
return nil, "two saves are needed"
end
if from.path == to.path then return nil, "that's the same save" end
local monA, reasonA, refA = pickable(from, req.fromIndex)
if not monA then return nil, reasonA end
local monB, reasonB, refB = pickable(to, req.toIndex)
if not monB then return nil, reasonB end
local packA = packFor(from.generation, monA)
local packB = packFor(to.generation, monB)
if from.generation ~= to.generation then
if type(req.convert) ~= "function" then return nil, "needs_conversion" end
local intoB, whyB = req.convert(packA, from.generation, to.generation)
if not intoB then return nil, tostring(whyB or "needs_conversion") end
local intoA, whyA = req.convert(packB, to.generation, from.generation)
if not intoA then return nil, tostring(whyA or "needs_conversion") end
packA, packB = intoB, intoA
end
local warnings = {}
local sideB, whyB = sideFor(to, refB, packA, nil, warnings)
if not sideB then return nil, tostring(whyB) end
if sideB.error then return nil, sideB.error end
local sideA, whyA = sideFor(from, refA, packB, nil, warnings)
if not sideA then return nil, tostring(whyA) end
if sideA.error then return nil, sideA.error end
sideA.role, sideB.role = "a", "b"
return planFrom({ sideA, sideB }, warnings)
end
function Trade.planIncoming(req)
req = type(req) == "table" and req or {}
local to = req.to
if type(to) ~= "table" then return nil, "no save" end
local mon, reason, ref = pickable(to, req.toIndex)
if not mon then return nil, reason end
local warnings = {}
local side, why = sideFor(to, ref, req.mon, req.record, warnings)
if not side then return nil, tostring(why) end
if side.error then return nil, side.error end
side.role = "a"
return planFrom({ side }, warnings)
end
-- ------- commit
local function validateSave(save, generation, data)
if generation == 2 then
local Save2 = require("src.core.gen2.Save")
local report = Save2.validate(save)
if not Save2.emptyReport(report) then return false, "that save didn't validate" end
return true
end
local report = SaveData.validate(save, data)
if not SaveData.emptyReport(report) then return false, "that save didn't validate" end
return true
end
local function buildSave(side)
local handle = side.handle
local save = deepCopy(handle.save)
local ref = side.ref or { where = "party", index = side.index }
if ref.where == "box" then
if type(save.boxes) ~= "table" then save.boxes = {} end
if type(save.boxes[ref.box]) ~= "table" then save.boxes[ref.box] = {} end
save.boxes[ref.box][ref.index] = side.record
else
if type(save.party) ~= "table" then save.party = {} end
save.party[ref.index] = side.record
end
markDex(save, handle.generation, side.dex)
if handle.generation == 2 then
local state = ref.where ~= "box" and type(save.mail) == "table"
and save.mail or nil
if state and type(state.party) == "table" then
state.party[ref.index] = nil
end
elseif handle.version == "yellow" and side.sent then
-- engine/link/cable_club.asm:801
local previous = GameVersion.get()
GameVersion.set("yellow")
pcall(function()
require("src.world.PikachuFollower")
.modifyHappiness(save, "TRADE", side.sent)
end)
GameVersion.set(previous)
end
return save
end
function Trade.commit(plan)
if type(plan) ~= "table" or type(plan.sides) ~= "table"
or #plan.sides == 0 then
return false, "no trade to make"
end
if Trade.gameIsLive() then return false, "close the game first" end
local jobs = {}
for _, side in ipairs(plan.sides) do
local built, why = withData(side.handle, function(data)
local save = buildSave(side)
local encoded = SaveData.encode(save)
local decoded = SaveData.decode(encoded)
if type(decoded) ~= "table" then
return { error = "that save didn't encode" }
end
local ok, reason = validateSave(decoded, side.handle.generation, data)
if not ok then return { error = reason } end
return { save = save, encoded = encoded }
end)
if not built then return false, tostring(why) end
if built.error then return false, built.error end
jobs[#jobs + 1] = { side = side, path = side.handle.path,
save = built.save, encoded = built.encoded }
end
local fs = disk()
if not fs then return false, "no filesystem" end
local stamp = tostring(os.time())
local backups = {}
for _, job in ipairs(jobs) do
job.prevMain = fs.getInfo(job.path) and fs.read(job.path) or nil
job.prevBak = fs.getInfo(job.path .. ".bak") and fs.read(job.path .. ".bak")
or nil
job.backup = job.path .. ".trade-bak-" .. stamp
if job.prevMain then
local ok = fs.write(job.backup, job.prevMain)
if not ok then return false, "couldn't back that save up" end
backups[#backups + 1] = job.backup
end
end
local function restore()
for _, job in ipairs(jobs) do
if job.prevMain then
fs.write(job.path, job.prevMain)
elseif fs.remove then
fs.remove(job.path)
end
if job.prevBak then
fs.write(job.path .. ".bak", job.prevBak)
elseif fs.remove then
fs.remove(job.path .. ".bak")
end
if fs.remove then fs.remove(job.path .. ".tmp") end
end
end
for _, job in ipairs(jobs) do
local dir = job.path:match("^(.*)/[^/]+$")
if dir and fs.createDirectory then fs.createDirectory(dir) end
local ok, err = fs.write(job.path .. ".tmp", job.encoded)
if not ok then
restore()
return false, tostring(err or "couldn't write that save")
end
local body = fs.read(job.path .. ".tmp")
if body ~= job.encoded or type(SaveData.decode(body or "")) ~= "table" then
restore()
return false, "that save didn't write"
end
end
for _, job in ipairs(jobs) do
if job.prevMain then fs.write(job.path .. ".bak", job.prevMain) end
if fs.remove then fs.remove(job.path) end
local ok, err = fs.write(job.path, job.encoded)
if not ok then
restore()
return false, tostring(err or "couldn't write that save")
end
end
local handles = {}
for i, job in ipairs(jobs) do
if fs.remove then fs.remove(job.path .. ".tmp") end
local handle = job.side.handle
handle.save = job.save
handle.party = job.save.party
handle.boxes = type(job.save.boxes) == "table" and job.save.boxes or nil
handles[i] = handle
end
return true, handles, backups
end
function Trade.pruneBackups(path, keep)
keep = math.max(0, tonumber(keep) or 3)
if type(path) ~= "string" or path == "" then return 0 end
local fs = disk()
if not fs or type(fs.getDirectoryItems) ~= "function"
or type(fs.remove) ~= "function" then
return 0
end
local dir, base = path:match("^(.*)/([^/]+)$")
if not dir then dir, base = "", path end
local ok, items = pcall(fs.getDirectoryItems, dir)
if not ok or type(items) ~= "table" then return 0 end
local prefix = base .. ".trade-bak-"
local found = {}
for _, name in ipairs(items) do
if type(name) == "string" and name:sub(1, #prefix) == prefix then
local stamp = tonumber(name:sub(#prefix + 1))
if stamp then found[#found + 1] = { name = name, stamp = stamp } end
end
end
table.sort(found, function(a, b)
if a.stamp ~= b.stamp then return a.stamp > b.stamp end
return a.name > b.name
end)
local removed = 0
for i = keep + 1, #found do
local full = (dir == "") and found[i].name or (dir .. "/" .. found[i].name)
if fs.remove(full) then removed = removed + 1 end
end
return removed
end
-- ------- remote
local Remote = {}
Remote.__index = Remote
function Trade.remote(handle, link, opts)
if type(handle) ~= "table" then return nil, "no save" end
if type(link) ~= "table" or type(link.send) ~= "function" then
return nil, "no room"
end
opts = type(opts) == "table" and opts or {}
local data, release = handle.data, nil
if not data then
if Trade.gameIsLive() then return nil, "close the game first" end
local mounted, freeOrReason = mountDataset(handle.version)
if not mounted then return nil, tostring(freeOrReason) end
data, release = mounted, freeOrReason
end
local session = Protocol.TradeSession.new(data, handle.party, {
subset = opts.subset, strict = opts.strict, peerName = opts.peerName,
})
return setmetatable({
handle = handle,
link = link,
data = data,
session = session,
release = release,
game = { data = data, save = handle.save },
}, Remote)
end
function Remote:_send(msg)
if type(msg) ~= "table" then return end
if msg.type == "party" and self.handle.generation == 2 then
msg = { type = "party",
mons = packPartyFor(2, self.session.party,
self.session.sendIndices) }
end
self.link:send(msg)
end
function Remote:start()
self:_send(self.session:opening())
end
function Remote:_theirParty(msg)
local session = self.session
session.theirParty = {}
for _, packed in ipairs(msg.mons or {}) do
local mon, why = Protocol.unpackMon2(self.data, packed,
{ strict = session.strict })
if mon then
session.theirParty[#session.theirParty + 1] = mon
elseif session.strict then
session.stage = "cancelled"
session.error = why or "the other game sent an unknown POKéMON"
return
end
end
if session.stage == "waitParty" then session.stage = "picking" end
end
function Remote:update()
if self.link.update then self.link:update() end
local messages = self.link:poll() or {}
for _, msg in ipairs(messages) do
if type(msg) == "table" and type(msg.type) == "string" then
if msg.type == "party" and self.handle.generation == 2 then
self:_theirParty(msg)
else
local reply = self.session:handle(msg)
if reply then self:_send(reply) end
end
end
end
return self.session.stage
end
function Remote:stage()
return self.session.stage
end
function Remote:canPick(index)
return self.session:canPick(index)
end
function Remote:pick(index)
local mon, reason = pickable(self.handle, index)
if not mon then return false, reason end
self:_send(self.session:pick(index))
return true
end
function Remote:confirm(ok)
self:_send(self.session:confirm(ok and true or false))
return true
end
function Remote:plan()
local session = self.session
if session.stage ~= "done" then return nil, "the trade isn't finished" end
local record = session.theirParty[session.theirPick]
if type(record) ~= "table" then return nil, "the other game sent nothing" end
local handle = self.handle
local injected = handle.data
handle.data = self.data
local plan, reason = Trade.planIncoming({
to = handle, toIndex = session.myPick, record = record,
})
handle.data = injected
return plan, reason
end
function Remote:commit()
local plan, reason = self:plan()
if not plan then return false, reason end
local handle = self.handle
local injected = handle.data
handle.data = self.data
local ok, result, backups = Trade.commit(plan)
handle.data = injected
return ok, result, backups
end
function Remote:close()
if self.release then
self.release()
self.release = nil
end
if self.link.close then pcall(function() self.link:close() end) end
end
return Trade
+11
View File
@@ -190,6 +190,17 @@ function SyncClient:fetchShare(code)
{ noAuth = true, params = { code = trimmed } })
end
function SyncClient:lobbyTicket()
return self:send("POST", "/lobby/ticket", {})
end
function SyncClient:setDisplayName(name)
if type(name) ~= "string" or name == "" then
return nil, "pick a display name first"
end
return self:send("POST", "/sync/displayname", { displayName = name })
end
function SyncClient:unlink(device)
return self:send("POST", "/sync/unlink", { device = device })
end
+1
View File
@@ -35,6 +35,7 @@ function SyncState.sanitize(raw)
out.deviceToken = str(raw.deviceToken)
out.deviceId = str(raw.deviceId)
out.deviceLabel = str(raw.deviceLabel)
out.displayName = str(raw.displayName)
out.lastSyncAt = num(raw.lastSyncAt) or 0
if type(raw.revs) == "table" then
for key, rev in pairs(raw.revs) do
+100
View File
@@ -0,0 +1,100 @@
local ArenaBoot = require("src.online.ArenaBoot")
local LinkBattle = require("src.link.LinkBattle")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local ArenaState = {}
ArenaState.__index = ArenaState
ArenaState.isOpaque = true
ArenaState.screenId = "ArenaState"
function ArenaState.new(game, spec)
local self = setmetatable({}, ArenaState)
self.game = game
self.spec = spec
self.stage = "boot"
self.battle = nil
self.finished = false
return self
end
function ArenaState:report(result)
if self.finished then return end
self.finished = true
local onDone = self.spec and self.spec.onDone
if onDone then pcall(onDone, result) end
end
function ArenaState:leave()
local game = self.game
game.linkNet = nil
game.linkSession = nil
if game.stack and game.stack:top() == self then game.stack:pop() end
if type(game.returnToLauncher) == "function" then
game.returnToLauncher({ tab = "online" })
end
end
function ArenaState:fail(reason)
Logger.error("arena: battle could not start (%s)", tostring(reason))
self.stage = "done"
self:report("error")
self:leave()
end
function ArenaState:enter()
local game, spec = self.game, self.spec
game.linkNet = spec.session
game.linkSession = true
if spec.role ~= "spectator" then
local packed, packErr = ArenaBoot.packOwnParty(game, spec)
if not packed then return self:fail(packErr) end
end
local opts, optsErr = ArenaBoot.battleOpts(spec)
if not opts then return self:fail(optsErr) end
local battle, why
if spec.role == "spectator" then
battle, why = LinkBattle.newSpectator(game, spec.session, opts)
elseif spec.role == "guest" then
battle, why = LinkBattle.newGuest(game, spec.session, opts)
else
battle, why = LinkBattle.newHost(game, spec.session, opts)
end
if not battle then return self:fail(why) end
self.battle = battle
self.stage = "running"
game.stack:push(battle)
end
function ArenaState:update(_dt)
if self.stage ~= "running" then return end
if self.game.stack:top() ~= self then return end
self.stage = "done"
local battle = self.battle
local result = (battle and battle.result) or "ended"
if battle and Runtime.wants("link.battle_ended") then
Runtime.emit("link.battle_ended", {
result = result,
myParty = battle.playerParty,
theirParty = battle.enemyParty,
peerName = self.spec.peerName,
role = self.spec.role,
})
end
self.battle = nil
self:report(result)
self:leave()
end
function ArenaState:draw()
local G = love.graphics
G.setColor(0, 0, 0, 1)
G.rectangle("fill", 0, 0, 160, 144)
G.setColor(1, 1, 1, 1)
end
return ArenaState
+2 -10
View File
@@ -1,6 +1,6 @@
-- The START menu (engine/menus/start_menu.asm): entries appear as they
-- become usable -- POKéDEX once Oak gives it, POKéMON once you have any,
-- SAVE with a confirmation, plus ITEM / OPTION / LINK / QUIT. The built
-- SAVE with a confirmation, plus ITEM / OPTION / QUIT. The built
-- item list runs through the ui.start_menu.items hook before the menu
-- opens, so mods insert or remove rows without patching this file.
@@ -142,14 +142,6 @@ function StartMenu.new(game)
Screens.push(game, "OptionsMenu", { onCancel = reopen })
end })
-- LINK needs a party
if #game.save.party > 0 then
table.insert(items, { label = Strings("LINK"), onSelect = function()
local LinkState = require("src.link.LinkState")
game.stack:push(LinkState.new(game))
end })
end
-- the manager's pause-menu entry (18-mod-manager-ux): gated on at least
-- one discovered mod so a vanilla install's menu is unchanged
local status = game.modStatus
@@ -184,7 +176,7 @@ function StartMenu.new(game)
-- (engine/menus/draw_start_menu.asm), so START closes it back to the
-- overworld -- unlike most menus, whose masks omit PAD_START.
--
-- item count isn't fixed: POKéDEX/LINK/MODS come and go with save state,
-- item count isn't fixed: POKéDEX/MODS come and go with save state,
-- and mods can append their own rows through the hook above, so the
-- double-spaced box (the original's style) can grow past the 18-tile
-- canvas. Cap it at however many rows actually fit and scroll the rest,
+137
View File
@@ -0,0 +1,137 @@
local ArenaBoot = require("src.online.ArenaBoot")
local Chrome = require("src.ui.gen2.Chrome")
local LinkBattle2 = require("src.link.LinkBattle2")
local Logger = require("src.core.Logger")
local Runtime = require("src.mods.Runtime")
local Strings = require("src.core.Strings")
local ArenaState = {}
ArenaState.__index = ArenaState
ArenaState.isOpaque = true
ArenaState.screenId = "Gen2ArenaState"
local HOLD_FRAMES = 300
function ArenaState:wantsFillScale() return true end
function ArenaState:drawsWidescreen() return true end
function ArenaState.new(game, spec)
local self = setmetatable({}, ArenaState)
self.game = game
self.spec = spec
self.stage = "boot"
self.battle = nil
self.frames = 0
self.finished = false
self.message = nil
return self
end
function ArenaState:report(result)
if self.finished then return end
self.finished = true
local onDone = self.spec and self.spec.onDone
if onDone then pcall(onDone, result) end
end
function ArenaState:leave()
local game = self.game
game.linkNet = nil
game.linkSession = nil
if game.stack and game.stack:top() == self then game.stack:pop() end
if type(game.returnToLauncher) == "function" then
game.returnToLauncher({ tab = "online" })
end
end
function ArenaState:fail(reason)
Logger.error("arena2: battle could not start (%s)", tostring(reason))
self.stage = "failed"
self.frames = 0
self.message = tostring(reason or Strings("The battle could not start."))
end
function ArenaState:enter()
local game, spec = self.game, self.spec
game.linkNet = spec.session
game.linkSession = true
if spec.role ~= "spectator" then
local packed, packErr = ArenaBoot.packOwnParty(game, spec)
if not packed then return self:fail(packErr) end
end
local opts, optsErr = ArenaBoot.battleOpts(spec)
if not opts then return self:fail(optsErr) end
local battle, why
if spec.role == "spectator" then
battle, why = LinkBattle2.newSpectator(game, spec.session, opts)
elseif spec.role == "guest" then
battle, why = LinkBattle2.newGuest(game, spec.session, opts)
else
battle, why = LinkBattle2.newHost(game, spec.session, opts)
end
if not battle then return self:fail(why) end
self.battle = battle
self.stage = "running"
game.stack:push(battle)
end
function ArenaState:update(_dt)
if self.stage == "failed" then
self.frames = self.frames + 1
local input = self.game.input
local dismissed = input and (input:wasPressed("a") or input:wasPressed("b")
or input:wasPressed("start"))
if dismissed or self.frames >= HOLD_FRAMES then
self.stage = "done"
self:report("error")
self:leave()
end
return
end
if self.stage ~= "running" then return end
if self.game.stack:top() ~= self then return end
self.stage = "done"
local battle = self.battle
local result = (battle and battle.result) or "ended"
if battle and Runtime.wants("link.battle_ended") then
Runtime.emit("link.battle_ended", {
result = result,
myParty = battle.playerParty,
theirParty = battle.enemyParty,
peerName = self.spec.peerName,
role = self.spec.role,
})
end
self.battle = nil
self:report(result)
self:leave()
end
function ArenaState:drawPanel()
Chrome.clear()
if self.message then
Chrome.textbox(0, 12, 18, 4)
Chrome.printWrapped(self.message, 1, 13, 18, 4)
end
end
function ArenaState:draw()
self:drawPanel()
end
function ArenaState:drawWidescreen(winW, winH)
local G = love.graphics
Chrome.letterbox(winW, winH)
local scale = Chrome.fitScale(winW, winH)
G.push()
G.translate(Chrome.fitOrigin(winW, winH, scale))
G.scale(scale, scale)
self:drawPanel()
G.pop()
end
return ArenaState
+34
View File
@@ -251,6 +251,7 @@ function BattleState.new(game, opts)
self.pokemon = data.pokemon
self.battle = opts.battle
self.onDone = opts.onDone
self.link = opts.link
-- What PlayVictoryMusic needs to know about the opponent (the class the
-- trainer belongs to); nil for a wild battle.
self.music = opts.music
@@ -2004,6 +2005,9 @@ function BattleState:playVictoryMusic()
end
function BattleState:submit(action)
if self.link and self.link.submit then
return self.link.submit(self, action)
end
self.phase = "resolving"
self:pushAll(self.battle:takeTurn(action))
self.message = nil
@@ -2018,6 +2022,9 @@ end
-- One semantic path for the native command menu and mod.battle intents.
function BattleState:chooseMenu(choice)
if self.phase ~= "menu" then return nil, "battle menu is not active" end
if self.link and self.link.menuChoice and self.link.menuChoice(self, choice) then
return true
end
if choice == "fight" then
-- CheckPlayerHasUsableMoves skips MoveSelectionScreen and uses Struggle.
local fighter = self.battle and self.battle.player
@@ -2415,6 +2422,9 @@ function BattleState:update(_dt)
end
if self.phase == "forced-switch" then
if self.link and self.link.forcedPrompt and self.link.forcedPrompt(self) then
return
end
-- Reuse the party list so the layout and controls match the start menu's.
self:openParty(true)
return
@@ -2454,6 +2464,18 @@ function BattleState:update(_dt)
return
end
if self.phase == "refuse-menu" then
if self.messageTimer > 0 then
if input:wasPressed("a") or input:wasPressed("b") then
self.messageTimer = 0
end
return
end
self.message = nil
self.phase = "menu"
return
end
if self.phase == "learn-intro" then
if self.messageTimer > 0 then
if input:wasPressed("a") or input:wasPressed("b") then
@@ -2541,6 +2563,8 @@ function BattleState:openParty(forced)
prompt = forced and "which" or "choose",
battle = true,
battleSubmenu = not forced,
party = (self.battle and self.battle.party) or nil,
save = self.save,
onCancel = function()
stack:pop()
-- A forced switch cannot be cancelled.
@@ -2577,6 +2601,9 @@ function BattleState:openParty(forced)
-- ForcePickPartyMonInBattle loops on carry: a pick the engine will not
-- take has to come back as the list again, never as the battle menu
-- with a fainted mon standing on the field.
if self.link and self.link.forcedSwitch then
return self.link.forcedSwitch(self, index)
end
if not self.battle:switch(index) then
return self:refuseSwitch(true)
end
@@ -2607,6 +2634,12 @@ function BattleState:refuseMove(text)
self.messageTimer = MESSAGE_FRAMES
end
function BattleState:refuseMenu(text)
self.phase = "refuse-menu"
self.message = text
self.messageTimer = MESSAGE_FRAMES
end
-- BattleMenu_Pack: `farcall BattlePack`, which is a different jumptable from
-- the field PACK's -- it dispatches on the item's BATTLE menu nibble and never
-- reaches a field effect. The empty world is the same guard MartMenu:enterSell
@@ -2956,6 +2989,7 @@ end
-- (engine/battle/core.asm:3269-3295, engine/menus/options_menu.asm:249-256).
function BattleState:shiftOfferAllowed()
local battle = self.battle
if self.link then return false end
if not (battle and battle.player and battle.trainer) then return false end
if #(battle.party or {}) < 2 then return false end
if (battle.player.hp or 0) <= 0 then return false end
+4
View File
@@ -45,6 +45,7 @@ local Theme = require("src.ui.kit.Theme")
local PAL = Theme.PAL
local VirtualKeyboard = require("src.ui.kit.VirtualKeyboard")
local FileBrowser = require("src.ui.kit.FileBrowser")
local Transition = require("src.ui.kit.Transition")
local Kit = {
scale = 1,
@@ -53,6 +54,7 @@ local Kit = {
focusId = nil, -- spatial-nav ring id (nil = nothing selected by pad/arrows)
VirtualKeyboard = VirtualKeyboard,
FileBrowser = FileBrowser,
Transition = Transition,
}
Kit.mouseX, Kit.mouseY = 0, 0
@@ -334,6 +336,8 @@ function Kit.beginFrame(mx, my, clicked, wheel)
if love and love.timer and love.timer.getTime then
Kit.time = love.timer.getTime()
end
Transition.update(Kit.time)
if Transition.active() then Kit.blockClicks = true end
-- Resolve any queued focus-ring movement against LAST frame's geometry.
-- Immediate mode has no geometry until the frame is built, and the ring
-- must move before widgets test themselves against it.
+125
View File
@@ -0,0 +1,125 @@
local Transition = {}
Transition.reduceMotion = false
Transition.armed = false
Transition.DURATIONS = {
["in"] = 0.12,
out = 0.09,
tab = 0.18,
push = 0.16,
pop = 0.16,
}
Transition.LAYERS = { "tabs", "online", "modal" }
local layers = {}
for i = 1, #Transition.LAYERS do
layers[Transition.LAYERS[i]] = {
active = false, kind = nil, dir = 1,
t0 = 0, dur = 0, p = 1,
from = nil, fromAt = nil, to = nil,
}
end
local function now()
if love and love.timer and love.timer.getTime then
return love.timer.getTime()
end
return 0
end
Transition.now = now
local function ease(t)
if t <= 0 then return 0 end
if t >= 1 then return 1 end
local u = 1 - t
return 1 - u * u * u
end
Transition.ease = ease
function Transition.update(t)
t = t or now()
for i = 1, #Transition.LAYERS do
local L = layers[Transition.LAYERS[i]]
if L.active then
local raw = 1
if L.dur > 0 then raw = (t - L.t0) / L.dur end
if raw >= 1 then
L.active, L.p = false, 1
elseif raw <= 0 then
L.p = 0
else
L.p = ease(raw)
end
end
end
end
function Transition.start(layer, kind, opts)
local L = layers[layer]
if not L then return false end
L.kind = kind
L.dir = (opts and opts.dir) or 1
L.from = opts and opts.from or nil
L.fromAt = opts and opts.fromAt or nil
L.to = opts and opts.to or nil
local dur = (opts and opts.duration) or Transition.DURATIONS[kind] or 0.15
if Transition.reduceMotion or not Transition.armed or dur <= 0 then
L.active, L.p, L.dur, L.t0 = false, 1, 0, 0
return false
end
L.active, L.p, L.dur, L.t0 = true, 0, dur, now()
return true
end
function Transition.get(layer)
local L = layers[layer]
if L and L.active then return L end
return nil
end
function Transition.progress(layer)
local L = layers[layer]
if not L or not L.active then return 1 end
return L.p
end
function Transition.kind(layer)
local L = layers[layer]
if L and L.active then return L.kind end
return nil
end
function Transition.dir(layer)
local L = layers[layer]
if L and L.active then return L.dir end
return 0
end
function Transition.active(layer)
if layer then
local L = layers[layer]
return (L and L.active) == true
end
for i = 1, #Transition.LAYERS do
if layers[Transition.LAYERS[i]].active then return true end
end
return false
end
function Transition.clear(layer)
local L = layers[layer]
if not L then return false end
L.active, L.p, L.dur, L.t0 = false, 1, 0, 0
L.kind, L.from, L.fromAt, L.to = nil, nil, nil, nil
return true
end
function Transition.reset()
for i = 1, #Transition.LAYERS do
Transition.clear(Transition.LAYERS[i])
end
end
return Transition
+3 -3
View File
@@ -58,7 +58,7 @@ function PatchNotes.fromCache(engine)
if text then
local ok, doc = pcall(Json.decode, text)
if ok and type(doc) == "table" then
if engine and engine ~= "0.0.0-dev" then
if engine and not engine:match("^0%.0%.0%-dev") then
if doc[engine] and nonempty(doc[engine]) then
return doc[engine], engine
end
@@ -109,7 +109,7 @@ function PatchNotes.fromRepo(engine)
if text then
local list = PatchNotes.parseRepo(text)
if #list == 0 then return nil, nil end
if engine and engine ~= "0.0.0-dev" then
if engine and not engine:match("^0%.0%.0%-dev") then
for _, row in ipairs(list) do
if row.version == engine then
return row.notes, row.version
@@ -128,7 +128,7 @@ function PatchNotes.body(Check)
local engine = (Version and Version.engine) or "?"
local notes, ver = PatchNotes.fromCheck(Check)
if notes and (engine == "0.0.0-dev" or ver == engine or ver == nil) then
if notes and (engine:match("^0%.0%.0%-dev") or ver == engine or ver == nil) then
return notes, ver or engine
end
+1 -1
View File
@@ -238,7 +238,7 @@ local function doCheck(target)
-- Unstamped dev build: the working tree always looks "newer", so never
-- pester the developer with an update (contract item, Check design).
local currentEngine = (Version and Version.engine) or "0.0.0-dev"
if currentEngine == "0.0.0-dev" then
if currentEngine:match("^0%.0%.0%-dev") then
post({ status = "uptodate", latest = rel.version })
return
end