diff --git a/src/core/game3/battle/init.lua b/src/core/game3/battle/init.lua index 3f329e01..2bc58b87 100644 --- a/src/core/game3/battle/init.lua +++ b/src/core/game3/battle/init.lua @@ -133,7 +133,10 @@ local function seq_push(text, wait, id) -- pokefirered/src/battle_controller_pokedude.c:209 HandlePokedudeVoiceoverEtc Pokedude.event(st, "printstring", st.pdActor, id) end - Ui.pushTimed(tostring(text or ""), tonumber(wait) or (AnimSeq.isMoveUsedId(id) and 0 or 64)) + text = tostring(text or "") + -- pokeemerald/src/battle_message.c:3005 + if text:sub(-2) == "\\p" and not (st and (st.link or st.pokedude or (st.kinds and st.kinds.recordedLink))) then return Ui.push(text) end + Ui.pushTimed(text, tonumber(wait) or (AnimSeq.isMoveUsedId(id) and 0 or 64)) end local function push_msgs(list) @@ -1653,21 +1656,39 @@ local function handle_player_faint(opts) }) end +local function wild_victory_song(st, awards) + if not st or not st.wild or st.pokedude or st.link or st._wildVictorySong then return end + if not (awards and #awards > 0) then return end + local lead = State.battler and State.battler(st, 0) or st.player + if not (lead and lead.mon and (tonumber(lead.mon.hp) or 0) > 0) then return end + st._wildVictorySong = true + stop_low_hp_song() + local Audio = require("src.core.game3.audio") + local bp = BattleProfile.of(st) + if bp.music then + -- pokeemerald/src/battle_script_commands.c:3363 + Audio.playSong(BattleProfile.victorySong(bp, Battle.songInfo(st))) + else + -- pokefirered/src/battle_script_commands.c:3219 + local id = Audio.role("victoryWild") + if id then Audio.playSong(id) end + end +end + local function begin_trainer_win(st) Battle._pendingEnd = "win" local Audio = require("src.core.game3.audio") - local role, fallback - if st and st.wild then - role, fallback = "victoryWild", 311 - else - role, fallback = Trainers.getVictoryMusicRole(st and st.trainerId) - end local bp = BattleProfile.of(st) - if bp.music then - -- pokeemerald/src/battle_main.c:4983 - Audio.playSong(BattleProfile.victorySong(bp, Battle.songInfo(st))) - -- pokefirered/src/battle_script_commands.c:3216 - elseif not st.pokedude then Audio.playSong(Audio.role(role) or fallback) end + -- pokeemerald/src/battle_main.c:5011 + if st and not st.wild then + if bp.music then + -- pokeemerald/src/battle_main.c:4983 + Audio.playSong(BattleProfile.victorySong(bp, Battle.songInfo(st))) + elseif not st.pokedude then + local role, fallback = Trainers.getVictoryMusicRole(st.trainerId) + Audio.playSong(Audio.role(role) or fallback) + end + end local pname = st.playerName local twoTrainers = st.trainerB ~= nil @@ -1872,6 +1893,7 @@ local function handle_enemy_faint(opts) end end + wild_victory_song(st, awards) local hooks = choice_hooks() if st.pokedude then for _, entry in ipairs(awards) do @@ -3104,6 +3126,7 @@ function D.faintStep() and Experience.awardFoe(st, foe, { trainer = not st.wild }) or {} -- pokefirered/src/battle_util.c:1181 State.opponentSwitchInResetSentPokes(st, foe) + wild_victory_song(st, awards) if D.presentAwards(awards) then return end end end diff --git a/src/core/game3/link/record_mix.lua b/src/core/game3/link/record_mix.lua index af5aa6e9..795be2c9 100644 --- a/src/core/game3/link/record_mix.lua +++ b/src/core/game3/link/record_mix.lua @@ -26,6 +26,11 @@ local MODULES = { oldMan = "src.core.game3.rse.old_man", dewfordTrend = "src.core.game3.rse.dewford_trend", lilycoveLady = "src.core.game3.rse.lilycove_lady", + daycareMail = "src.core.game3.rse.daycare_mail_mix", + tower = "src.core.game3.rse.frontier.tower", + gift = "src.core.game3.rse.record_mixing_gift", + apprentice = "src.core.game3.rse.frontier.apprentice", + rankingHall = "src.core.game3.rse.frontier.ranking_hall", } local function system(name) @@ -74,29 +79,37 @@ function RecordMix.fromWire(v) end -- pokeemerald/src/record_mixing.c:220 PrepareExchangePacket -function RecordMix.packet(session) +function RecordMix.packet(session, multiplayerId) session = sessionOf(session) + local sess = type(session) == "table" and session or {} + local MixUtil = require("src.core.game3.rse.record_mix_util") local out = { - version = type(session) == "table" and session.version or Family.activeVersion(), - trainerId = tonumber(type(session) == "table" and (session.trainerId or session.id)) or 0, - name = type(session) == "table" and (session.name or session.playerName) or nil, + version = sess.version or Family.activeVersion(), + trainerId = tonumber(sess.trainerId or sess.id) or 0, + linkTrainerId = MixUtil.sessionLinkTrainerId(sess), + language = MixUtil.GAME_LANGUAGE, + name = sess.name or sess.playerName, } - local tvData = call(system("tv"), "mixExport", session) + out.secretBases = call(system("secretBase"), "mixExport", session) + local Tv = system("tv") + pcall(function() Tv.deactivateAllNormalShows(session) end) + local tvData = call(Tv, "mixExport", session) if type(tvData) == "table" then out.tvShows, out.pokeNews = tvData.tvShows, tvData.pokeNews end - out.secretBases = call(system("secretBase"), "mixExport", session) out.oldMan = call(system("oldMan"), "mixExport", session) - out.dewfordTrends = call(system("dewfordTrend"), "mixExport", session) local lady = system("lilycoveLady") - out.lilycoveLady = lady and deep(call(lady, "state", session)) or nil + out.lilycoveLady = call(lady, "mixExport", session) + if out.lilycoveLady == nil then out.lilycoveLady = deep(call(lady, "state", session)) end + out.dewfordTrends = call(system("dewfordTrend"), "mixExport", session) + out.daycareMail = call(system("daycareMail"), "mixExport", session) + out.battleTowerRecord = call(system("tower"), "mixExport", session) + out.giftItem = call(system("gift"), "mixExport", session, tonumber(multiplayerId) or 0) or 0 + out.apprentices = call(system("apprentice"), "mixExport", session) + out.hallRecords = call(system("rankingHall"), "mixExport", session) return deep(out) end -local function tvModule() - return system("tv") -end - -- pokeemerald/src/record_mixing.c:253 ReceiveExchangePacket function RecordMix.receive(session, packets, myIndex, logger) session = sessionOf(session) @@ -104,12 +117,12 @@ function RecordMix.receive(session, packets, myIndex, logger) for i, p in ipairs(packets or {}) do players[i] = type(p) == "table" and deep(p) or {} end myIndex = tonumber(myIndex) or 1 local applied = {} - local sb = system("secretBase") + local randSum = call(system("daycareMail"), "randSum", players[1] and players[1].tvShows) or 0 local bases = {} for i, p in ipairs(players) do bases[i] = p.secretBases or {} end - local _, okS = call(sb, "mixImport", session, bases) + local _, okS = call(system("secretBase"), "mixImport", session, bases, myIndex) applied.secretBases = okS - local Tv = tvModule() + local Tv = system("tv") local tvPlayers = {} for i, p in ipairs(players) do tvPlayers[i] = { tvShows = p.tvShows or {}, pokeNews = p.pokeNews or {}, trainerId = p.trainerId } @@ -118,14 +131,20 @@ function RecordMix.receive(session, packets, myIndex, logger) applied.tvShows = okTv local _, okNews = call(Tv, "receivePokeNews", session, tvPlayers, myIndex) applied.pokeNews = okNews - local oldMan = system("oldMan") - local _, okOld = call(oldMan, "mixImport", players, session, myIndex) + local _, okOld = call(system("oldMan"), "mixImport", players, session, myIndex) applied.oldMan = okOld if not okOld then rse().missing("oldMan", "ReceiveOldManData", logger) end local trends = {} for i, p in ipairs(players) do trends[i] = p.dewfordTrends or {} end local _, okDew = call(system("dewfordTrend"), "mixImport", trends, session) applied.dewfordTrends = okDew + local _, okMail = call(system("daycareMail"), "mixImport", players, session, myIndex, randSum) + applied.daycareMail = okMail + local _, okTower = call(system("tower"), "mixImport", players, session, myIndex) + applied.battleTower = okTower + local gift, okGift = call(system("gift"), "mixImport", players, session, myIndex) + applied.giftItem = okGift + applied.gift = gift local lady = system("lilycoveLady") local _, okLady = call(lady, "mixImport", players, session, myIndex) if not okLady then @@ -134,7 +153,10 @@ function RecordMix.receive(session, packets, myIndex, logger) rse().missing("lilycoveLady", "ReceiveLilycoveLadyData", logger) end applied.lilycoveLady = okLady - rse().missing("recordMixing", "ReceiveDaycareMailData/ReceiveBattleTowerData/ReceiveGiftItem/ReceiveApprenticeData/ReceiveRankingHallRecords", logger) + local _, okApp = call(system("apprentice"), "mixImport", players, session, myIndex) + applied.apprentices = okApp + local _, okHall = call(system("rankingHall"), "mixImport", players, session, myIndex) + applied.hallRecords = okHall RecordMix.last = { players = #players, myIndex = myIndex, applied = applied } return applied end @@ -178,7 +200,7 @@ function RecordMix.playerSpotTriggered(ctx, adapters) local spot = L.getVar(ctx, LB.VAR_0x8005) -- pokeemerald/src/record_mixing.c:321 varSet(ctx, session, "VAR_TEMP_MIXED_RECORDS", 1) - local mine = RecordMix.packet(session) + local mine = RecordMix.packet(session, spot) mine.spot = spot lk:send({ type = RecordMix.MSG.PACKET, spot = spot, packet = RecordMix.toWire(mine) }) RecordMix.state = "mixing" @@ -217,7 +239,13 @@ function RecordMix.playerSpotTriggered(ctx, adapters) packets[i] = row.packet if row.mine then myIndex = i end end - RecordMix.receive(session, packets, myIndex, adapters and adapters.log) + local applied = RecordMix.receive(session, packets, myIndex, adapters and adapters.log) + local gift = applied and applied.gift + if type(gift) == "table" and (tonumber(gift.item) or 0) ~= 0 then + -- pokeemerald/src/record_mixing.c:976 + if adapters and adapters.setStringVar then adapters.setStringVar(1, tostring(gift.from or "")) end + if ctx.stringVars then ctx.stringVars[1] = tostring(gift.from or "") end + end -- pokeemerald/src/record_mixing.c:333 flagSet(session, "FLAG_SYS_MIX_RECORD") message("gText_RecordMixingComplete") diff --git a/src/core/game3/profiles/emerald/save.lua b/src/core/game3/profiles/emerald/save.lua index 48f1ec61..12295c33 100644 --- a/src/core/game3/profiles/emerald/save.lua +++ b/src/core/game3/profiles/emerald/save.lua @@ -34,5 +34,6 @@ return { { name = "frontier", module = "src.core.game3.rse.frontier.util" }, { name = "trainerHill", module = "src.core.game3.rse.trainer_hill" }, { name = "apprentice", module = "src.core.game3.rse.frontier.apprentice" }, + { name = "recordMixingGift", module = "src.core.game3.rse.record_mixing_gift" }, }, } diff --git a/src/core/game3/rse/daycare_mail_mix.lua b/src/core/game3/rse/daycare_mail_mix.lua new file mode 100644 index 00000000..b9e44c29 --- /dev/null +++ b/src/core/game3/rse/daycare_mail_mix.lua @@ -0,0 +1,162 @@ +local Rse = require("src.core.game3.rse.init") +local MixUtil = require("src.core.game3.rse.record_mix_util") + +local DaycareMail = {} + +-- pokeemerald/include/constants/global.h:34 +DaycareMail.DAYCARE_MON_COUNT = 2 +-- pokeemerald/src/record_mixing.c:41 +DaycareMail.NUM_SWAP_COMBOS = 3 + +-- pokeemerald/src/record_mixing.c:152 +local SWAP_3P = { { 0, 1 }, { 1, 2 }, { 2, 0 } } +-- pokeemerald/src/record_mixing.c:159 +local SWAP_4P = { { 0, 1, 2, 3 }, { 0, 2, 1, 3 }, { 0, 3, 2, 1 } } + +local function sessionOf(sess) + return sess or Rse.session() +end + +local function daycare() + return require("src.core.game3.daycare") +end + +local function blankMail() + return { message = { itemId = 0 }, otName = "", monName = "", gameLanguage = 0, monLanguage = 0 } +end + +local function itemOf(mail) + return tonumber(type(mail) == "table" and type(mail.message) == "table" and mail.message.itemId) or 0 +end + +local function heldItem(mon) + return tonumber(mon and (mon.heldItem or mon.item)) or 0 +end + +-- pokeemerald/src/record_mixing.c:1366 +function DaycareMail.mixExport(sess) + sess = sessionOf(sess) + local Daycare = daycare() + local dc = Daycare.stateOf(sess) + local out = { mail = {}, numDaycareMons = 0, cantHoldItem = {} } + local stored = dc and dc.mail or {} + -- pokeemerald/src/daycare.c:122 + for i = 1, DaycareMail.DAYCARE_MON_COUNT do + local m = stored[i] + out.mail[i] = type(m) == "table" and MixUtil.deep(m) or blankMail() + if type(out.mail[i].message) ~= "table" then out.mail[i].message = { itemId = 0 } end + local mon = dc and Daycare.mon(dc, i) + if mon and Daycare.speciesOf(mon) ~= 0 then + out.numDaycareMons = out.numDaycareMons + 1 + out.cantHoldItem[i] = heldItem(mon) ~= 0 + else + out.cantHoldItem[i] = true + end + end + return out +end + +-- pokeemerald/src/record_mixing.c:743 +function DaycareMail.randSum(tvShows) + local sum = 0 + if type(tvShows) == "table" then + for i = 0, 7 do + local show = tvShows[i] + if type(show) == "table" then + sum = sum + MixUtil.num(show.kind) + ((show.active == true or show.active == 1) and 1 or 0) + end + end + end + return sum % 256 +end + +local function recordOf(players, i) + local p = players[i + 1] + local r = type(p) == "table" and p.daycareMail or nil + if type(r) ~= "table" then + r = { mail = { blankMail(), blankMail() }, numDaycareMons = 0, cantHoldItem = { true, true } } + if type(p) == "table" then p.daycareMail = r end + end + r.mail = r.mail or {} + for j = 1, DaycareMail.DAYCARE_MON_COUNT do + if type(r.mail[j]) ~= "table" then r.mail[j] = blankMail() end + end + r.cantHoldItem = r.cantHoldItem or {} + return r +end + +-- pokeemerald/src/record_mixing.c:724 +local function swap(players, idxs, s1, s2) + local a, b = idxs[s1 + 1], idxs[s2 + 1] + local ra, rb = recordOf(players, a[1]), recordOf(players, b[1]) + local tmp = ra.mail[a[2] + 1] + ra.mail[a[2] + 1] = rb.mail[b[2] + 1] + rb.mail[b[2] + 1] = tmp +end + +-- pokeemerald/src/record_mixing.c:760 +function DaycareMail.mixImport(players, sess, myIndex, randSum) + sess = sessionOf(sess) + players = players or {} + local Rng = require("src.core.game3.rng") + local oldSeed = Rng.Random2() + Rng.SeedRng2(MixUtil.linkTrainerId(players[1]) % 0x10000) + local count = #players + local canHold = {} + for i = 0, count - 1 do + canHold[i] = { false, false } + local r = recordOf(players, i) + local n = MixUtil.num(r.numDaycareMons) + for j = 1, math.min(n, DaycareMail.DAYCARE_MON_COUNT) do + local cant = r.cantHoldItem[j] + if not (cant == true or cant == 1) then canHold[i][j] = true end + end + end + local idxs, numCanHold = {}, 0 + for i = 0, count - 1 do + local r = recordOf(players, i) + local c0, c1 = canHold[i][1], canHold[i][2] + if c0 or c1 then numCanHold = numCanHold + 1 end + if c0 and not c1 then + idxs[#idxs + 1] = { i, 0 } + elseif c1 and not c0 then + idxs[#idxs + 1] = { i, 1 } + elseif c0 and c1 then + local item1, item2 = itemOf(r.mail[1]), itemOf(r.mail[2]) + local slot + if (item1 == 0 and item2 == 0) or (item1 ~= 0 and item2 ~= 0) then + slot = Rng.Random2() % 2 + elseif item1 ~= 0 then + slot = 0 + else + slot = 1 + end + idxs[#idxs + 1] = { i, slot } + end + end + local tableId = (tonumber(randSum) or 0) % DaycareMail.NUM_SWAP_COMBOS + if numCanHold == 2 then + swap(players, idxs, 0, 1) + elseif numCanHold == 3 then + swap(players, idxs, SWAP_3P[tableId + 1][1], SWAP_3P[tableId + 1][2]) + elseif numCanHold == 4 then + local row = SWAP_4P[tableId + 1] + swap(players, idxs, row[1], row[2]) + swap(players, idxs, row[3], row[4]) + end + local mine = recordOf(players, (tonumber(myIndex) or 1) - 1) + local dc = daycare().stateOf(sess) + if dc then + dc.mail = dc.mail or {} + for j = 1, DaycareMail.DAYCARE_MON_COUNT do + local m = mine.mail[j] + dc.mail[j] = itemOf(m) ~= 0 and MixUtil.deep(m) or nil + end + end + Rng.SeedRng(oldSeed) + return true +end + +Rse.register("daycareMail", DaycareMail) + +return DaycareMail diff --git a/src/core/game3/rse/frontier/apprentice.lua b/src/core/game3/rse/frontier/apprentice.lua index 260eedfe..723fba25 100644 --- a/src/core/game3/rse/frontier/apprentice.lua +++ b/src/core/game3/rse/frontier/apprentice.lua @@ -655,6 +655,84 @@ local function easyChatType() end easyChatType() +local function mixUtil() + return require("src.core.game3.rse.record_mix_util") +end + +-- pokeemerald/src/record_mixing.c:1062 +function Apprentice.mixExport(sess) + sess = sess or Rse.session() + local MixUtil = mixUtil() + local src = Apprentice.saved(sess) + local saveId = tonumber(Apprentice.player(sess).saveId) or 0 + local dst = { Apprentice.copy(src[1]), Apprentice.newSaved() } + dst[2].playerName = "" + local mine = MixUtil.sessionLinkTrainerId(sess) + local slots = Apprentice.COUNT - 1 + local oldId, numOld, mixId, numMix = 0, 0, 0, 0 + for i = 0, 1 do + local id = (i + saveId) % slots + 1 + if (src[id + 1].playerName or "") ~= "" then + local tid = MixUtil.getTrainerId(src[id + 1].playerId) + if tid ~= mine then numMix, mixId = numMix + 1, id end + if tid == mine then numOld, oldId = numOld + 1, id end + end + end + if numMix == 0 and numOld ~= 0 then numMix, mixId = numOld, oldId end + if numMix == 1 then + dst[2] = Apprentice.copy(src[mixId + 1]) + elseif numMix == 2 then + if rng().Random2() > 0x3333 then + dst[2] = Apprentice.copy(src[saveId + 1 + 1]) + else + dst[2] = Apprentice.copy(src[((saveId + 1) % slots + 1) + 1]) + end + end + return dst +end + +-- pokeemerald/src/record_mixing.c:1155 +local function alreadySaved(a, saved) + local MixUtil = mixUtil() + for i = 1, Apprentice.COUNT do + if MixUtil.getTrainerId(a.playerId) == MixUtil.getTrainerId(saved[i].playerId) + and (tonumber(a.number) or 0) == (tonumber(saved[i].number) or 0) then + return true + end + end + return false +end + +-- pokeemerald/src/record_mixing.c:1169 +function Apprentice.mixImport(players, sess, myIndex) + sess = sess or Rse.session() + local partner = mixUtil().partner(players or {}, tonumber(myIndex) or 1) + local mix = type(partner) == "table" and partner.apprentices or nil + if type(mix) ~= "table" then return false end + local saved = Apprentice.saved(sess) + local p = Apprentice.player(sess) + local slots = Apprentice.COUNT - 1 + local num, which = 0, 0 + for i = 0, 1 do + local a = mix[i + 1] + if type(a) == "table" and (a.playerName or "") ~= "" and not alreadySaved(a, saved) then + num, which = num + 1, i + end + end + local saveId = tonumber(p.saveId) or 0 + if num == 1 then + saved[saveId + 1 + 1] = Apprentice.copy(mix[which + 1]) + p.saveId = (saveId + 1) % slots + elseif num == 2 then + for i = 0, 1 do + local idx = (bit.bxor(i, 1) + saveId) % slots + 1 + saved[idx + 1] = Apprentice.copy(mix[i + 1]) + end + p.saveId = (saveId + 2) % slots + end + return true +end + Rse.register("apprentice", Apprentice) return Apprentice diff --git a/src/core/game3/rse/frontier/ranking_hall.lua b/src/core/game3/rse/frontier/ranking_hall.lua new file mode 100644 index 00000000..4acb336d --- /dev/null +++ b/src/core/game3/rse/frontier/ranking_hall.lua @@ -0,0 +1,174 @@ +local Rse = require("src.core.game3.rse.init") +local D = require("src.core.game3.rse.frontier.trainers") +local Util = require("src.core.game3.rse.frontier.util") +local MixUtil = require("src.core.game3.rse.record_mix_util") + +local Hall = {} + +local RH = Util.RANKING_HALL +local M = D.MODE +local COUNT = Util.HALL_RECORDS_COUNT + +local function lvlModes() + return D.LVL_MODE_COUNT +end + +local function new1P() + return { id = { 0, 0, 0, 0 }, name = "", language = 0, winStreak = 0 } +end + +local function new2P() + return { id1 = { 0, 0, 0, 0 }, id2 = { 0, 0, 0, 0 }, name1 = "", name2 = "", language = 0, winStreak = 0 } +end + +function Hall.saved(sess) + sess = sess or Rse.session() + if type(sess.hallRecords1P) ~= "table" or type(sess.hallRecords2P) ~= "table" then + Util.clearRankingHallRecords(sess) + end + for i = 1, Util.HALL_FACILITIES_COUNT do + sess.hallRecords1P[i] = sess.hallRecords1P[i] or {} + for j = 1, lvlModes() do + sess.hallRecords1P[i][j] = sess.hallRecords1P[i][j] or {} + for k = 1, COUNT do sess.hallRecords1P[i][j][k] = sess.hallRecords1P[i][j][k] or new1P() end + end + end + for j = 1, lvlModes() do + sess.hallRecords2P[j] = sess.hallRecords2P[j] or {} + for k = 1, COUNT do sess.hallRecords2P[j][k] = sess.hallRecords2P[j][k] or new2P() end + end + return sess.hallRecords1P, sess.hallRecords2P +end + +local function streak(r) + return tonumber(type(r) == "table" and r.winStreak) or 0 +end + +-- pokeemerald/src/record_mixing.c:1116 +function Hall.mixExport(sess) + sess = sess or Rse.session() + local f = Util.frontier(sess) + local id = MixUtil.trainerIdBytes(sess) + local name = tostring(sess.name or sess.playerName or "") + local out = { onePlayer = {}, twoPlayers = {} } + for i = 1, Util.HALL_FACILITIES_COUNT do + out.onePlayer[i] = {} + for j = 1, lvlModes() do + out.onePlayer[i][j] = { id = MixUtil.deep(id), language = MixUtil.GAME_LANGUAGE, name = name, winStreak = 0 } + end + end + for j = 1, lvlModes() do + out.twoPlayers[j] = { + language = MixUtil.GAME_LANGUAGE, id1 = MixUtil.deep(id), + id2 = MixUtil.deep(f.opponentTrainerIds and f.opponentTrainerIds[j] or { 0, 0, 0, 0 }), + name1 = name, name2 = tostring(f.opponentNames and f.opponentNames[j] or ""), winStreak = 0, + } + end + for lvl = 0, lvlModes() - 1 do + local one = function(hall) return out.onePlayer[hall + 1][lvl + 1] end + one(RH.TOWER_SINGLES).winStreak = Util.get2(f.towerRecordWinStreaks, M.SINGLES, lvl) + one(RH.TOWER_DOUBLES).winStreak = Util.get2(f.towerRecordWinStreaks, M.DOUBLES, lvl) + one(RH.TOWER_MULTIS).winStreak = Util.get2(f.towerRecordWinStreaks, M.MULTIS, lvl) + one(RH.DOME).winStreak = Util.get2(f.domeRecordWinStreaks, M.SINGLES, lvl) + one(RH.PALACE).winStreak = Util.get2(f.palaceRecordWinStreaks, M.SINGLES, lvl) + one(RH.ARENA).winStreak = Util.get1(f.arenaRecordStreaks, lvl) + one(RH.FACTORY).winStreak = Util.get2(f.factoryRecordWinStreaks, M.SINGLES, lvl) + one(RH.PIKE).winStreak = Util.get1(f.pikeRecordStreaks, lvl) + one(RH.PYRAMID).winStreak = Util.get1(f.pyramidRecordStreaks, lvl) + out.twoPlayers[lvl + 1].winStreak = Util.get2(f.towerRecordWinStreaks, M.LINK_MULTIS, lvl) + end + return out +end + +local function tid(bytes) + return MixUtil.getTrainerId(bytes) +end + +-- pokeemerald/src/record_mixing.c:1207 +local function getNewHallRecords(players, myIndex, sess) + local saved1, saved2 = Hall.saved(sess) + local partners = {} + for i = 1, #players do + if i ~= myIndex then + local h = players[i] and players[i].hallRecords + partners[#partners + 1] = type(h) == "table" and h or {} + end + if #partners == COUNT then break end + end + local n = #players - 1 + local mix1, mix2 = {}, {} + for i = 1, Util.HALL_FACILITIES_COUNT do + mix1[i] = {} + for j = 1, lvlModes() do + local dst = {} + for k = 1, COUNT * 2 do dst[k] = new1P() end + for k = 1, COUNT do dst[k] = MixUtil.deep(saved1[i][j][k]) end + for k = 1, n do + local rec = partners[k] and partners[k].onePlayer and partners[k].onePlayer[i] and partners[k].onePlayer[i][j] + rec = type(rec) == "table" and rec or new1P() + local repeats = 0 + for l = 1, COUNT do + if tid(dst[l].id) == tid(rec.id) then + repeats = repeats + 1 + if streak(dst[l]) < streak(rec) then dst[l] = MixUtil.deep(rec) end + end + end + if repeats == 0 then dst[k + COUNT] = MixUtil.deep(rec) end + end + mix1[i][j] = dst + end + end + for j = 1, lvlModes() do + local dst = {} + for k = 1, COUNT * 2 do dst[k] = new2P() end + for k = 1, COUNT do dst[k] = MixUtil.deep(saved2[j][k]) end + for k = 1, n do + local rec = partners[k] and partners[k].twoPlayers and partners[k].twoPlayers[j] + rec = type(rec) == "table" and rec or new2P() + local repeats = 0 + for l = 1, COUNT do + if tid(dst[l].id1) == tid(rec.id1) and tid(dst[l].id2) == tid(rec.id2) then + repeats = repeats + 1 + if streak(dst[l]) < streak(rec) then dst[l] = MixUtil.deep(rec) end + end + end + if repeats == 0 then dst[k + COUNT] = MixUtil.deep(rec) end + end + mix2[j] = dst + end + return mix1, mix2 +end + +-- pokeemerald/src/record_mixing.c:1286 +local function fillWinStreakRecords(playerRecords, mixRecords) + for i = 1, COUNT do + local highest, highestId = 0, nil + for j = 1, COUNT * 2 do + if streak(mixRecords[j]) > highest then + highestId, highest = j, streak(mixRecords[j]) + end + end + if highestId then + playerRecords[i] = MixUtil.deep(mixRecords[highestId]) + mixRecords[highestId].winStreak = 0 + end + end +end + +-- pokeemerald/src/record_mixing.c:1355 +function Hall.mixImport(players, sess, myIndex) + sess = sess or Rse.session() + players = players or {} + local mix1, mix2 = getNewHallRecords(players, tonumber(myIndex) or 1, sess) + -- pokeemerald/src/record_mixing.c:1342 + local saved1, saved2 = Hall.saved(sess) + for i = 1, Util.HALL_FACILITIES_COUNT do + for j = 1, lvlModes() do fillWinStreakRecords(saved1[i][j], mix1[i][j]) end + end + for j = 1, lvlModes() do fillWinStreakRecords(saved2[j], mix2[j]) end + return true +end + +Rse.register("rankingHall", Hall) + +return Hall diff --git a/src/core/game3/rse/frontier/tower.lua b/src/core/game3/rse/frontier/tower.lua index d546e719..607ae3e2 100644 --- a/src/core/game3/rse/frontier/tower.lua +++ b/src/core/game3/rse/frontier/tower.lua @@ -643,6 +643,96 @@ function Tower.doTowerBattle(ctx, adapters, sess) }) end +-- pokeemerald/include/constants/global.h:97 +local PLAYER_NAME_LENGTH = 7 + +local function nameChar(s, i) + return tostring(s or ""):sub(i + 1, i + 1) +end + +local function idByte(rec, i) + return tonumber(type(rec.trainerId) == "table" and rec.trainerId[i + 1]) or 0 +end + +-- pokeemerald/src/battle_tower.c:1311 +function Tower.putNewRecord(sess, newRecord) + local f = Util.frontier(sess) + local records = f.towerRecords + local count = Util.BATTLE_TOWER_RECORD_COUNT + for i = 1, count do + if type(records[i]) ~= "table" then records[i] = {} end + end + local function streak(i) return tonumber(records[i + 1].winStreak) or 0 end + local i = 0 + while i < count do + local rec, k, j = records[i + 1], 0, 0 + while j < 4 and idByte(rec, j) == idByte(newRecord, j) do j = j + 1 end + if j == 4 then + while k < PLAYER_NAME_LENGTH do + if nameChar(rec.name, j) ~= nameChar(newRecord.name, j) then break end + if nameChar(newRecord.name, j) == "" then + k = PLAYER_NAME_LENGTH + break + end + k = k + 1 + end + end + if k == PLAYER_NAME_LENGTH then break end + i = i + 1 + end + if i < count then + records[i + 1] = Util.deepCopy(newRecord) + return i + end + for s = 0, count - 1 do + if streak(s) == 0 then + records[s + 1] = Util.deepCopy(newRecord) + return s + end + end + local slotValues, slotIds = { streak(0) }, { 0 } + for s = 1, count - 1 do + local j = 1 + while j <= #slotValues do + if streak(s) < slotValues[j] then + slotValues, slotIds = { streak(s) }, { s } + j = 1 + break + elseif streak(s) > slotValues[j] then + break + end + j = j + 1 + end + if j == #slotValues + 1 then + slotValues[#slotValues + 1] = streak(s) + slotIds[#slotIds + 1] = s + end + end + local pick = slotIds[(D.rng().Random() % #slotIds) + 1] + records[pick + 1] = Util.deepCopy(newRecord) + return pick +end + +-- pokeemerald/src/record_mixing.c:1396 +function Tower.mixExport(sess) + local rec = Util.deepCopy(Util.frontier(sess).towerPlayer or {}) + return rec +end + +-- pokeemerald/src/record_mixing.c:650 +function Tower.mixImport(players, sess, myIndex) + sess = sess or Rse.session() + local MixUtil = require("src.core.game3.rse.record_mix_util") + local partner = MixUtil.partner(players or {}, tonumber(myIndex) or 1) + local rec = type(partner) == "table" and partner.battleTowerRecord or nil + if type(rec) ~= "table" then return false end + rec = Util.deepCopy(rec) + local me = players[tonumber(myIndex) or 1] + if type(me) == "table" then me.battleTowerRecord = rec end + Tower.putNewRecord(sess, rec) + return true +end + Rse.register("tower", Tower) return Tower diff --git a/src/core/game3/rse/lilycove_lady.lua b/src/core/game3/rse/lilycove_lady.lua index 6fe929db..c997eb8e 100644 --- a/src/core/game3/rse/lilycove_lady.lua +++ b/src/core/game3/rse/lilycove_lady.lua @@ -339,6 +339,42 @@ function Lady.contestLadyTvData(sess) } end +function Lady.mixExport(sess) + local MixUtil = require("src.core.game3.rse.record_mix_util") + return MixUtil.deep(Lady.state(sess)) +end + +-- pokeemerald/src/lilycove_lady.c:577 +local function quizClearQuestionForRecordMix(prev, sess) + local l = Lady.state(sess) + local count = #data().quizQuestions + local prevId = tonumber(prev.quiz and prev.quiz.prevQuestionId) or count + if prevId < count and l.id == Lady.QUIZ then + local q = l.quiz + for _ = 1, 4 do + if prevId ~= q.questionId then break end + q.questionId = Town.random() % count + end + if prevId == q.questionId then q.questionId = (q.questionId + 1) % count end + q.prevQuestionId = prevId + end +end + +-- pokeemerald/src/record_mixing.c:682 +function Lady.mixImport(players, sess, myIndex) + sess = Town.session(sess) + local MixUtil = require("src.core.game3.rse.record_mix_util") + local partner = MixUtil.partner(players or {}, tonumber(myIndex) or 1) + local src = type(partner) == "table" and partner.lilycoveLady or nil + if type(src) ~= "table" or src.id == nil then return false end + local mine = Lady.state(sess) + local prev = mine.id == Lady.QUIZ and MixUtil.deep(mine) or nil + sess.lilycoveLady = MixUtil.deep(src) + Lady.resetForRecordMix(sess) + if prev then quizClearQuestionForRecordMix(prev, sess) end + return true +end + local SaveSections = require("src.core.game3.save_sections") -- pokeemerald/src/new_game.c:199 SaveSections.register("lilycoveLady", SaveSections.fields({ "lilycoveLady" }, function(sess) Lady.init(sess) end)) diff --git a/src/core/game3/rse/old_man.lua b/src/core/game3/rse/old_man.lua index a8db3038..614e06dd 100644 --- a/src/core/game3/rse/old_man.lua +++ b/src/core/game3/rse/old_man.lua @@ -361,6 +361,31 @@ function OldMan.mixExport(sess) return deep(OldMan.state(sess)) end +-- pokeemerald/src/mauville_old_man.c:858 +local function sanitizeReceived(m, language) + if m.id ~= OldMan.STORYTELLER or language ~= 1 then return end + for i = 1, OldMan.NUM_STORYTELLER_TALES do + if (tonumber(m.gameStatIDs and m.gameStatIDs[i]) or 0) ~= 0 then + m.language = m.language or {} + m.language[i] = Town.GAME_LANGUAGE + end + end +end + +-- pokeemerald/src/record_mixing.c:629 +function OldMan.mixImport(players, sess, myIndex) + sess = Town.session(sess) + local MixUtil = require("src.core.game3.rse.record_mix_util") + local partner = MixUtil.partner(players or {}, tonumber(myIndex) or 1) + local m = type(partner) == "table" and partner.oldMan or nil + if type(m) ~= "table" or m.id == nil then return false end + m = MixUtil.deep(m) + sanitizeReceived(m, tonumber(partner.language) or Town.GAME_LANGUAGE) + sess.oldMan = m + OldMan.resetFlag(sess) + return true +end + local SaveSections = require("src.core.game3.save_sections") -- pokeemerald/src/new_game.c:191 SaveSections.register("oldMan", SaveSections.fields({ "oldMan" }, function(sess) OldMan.set(sess) end)) diff --git a/src/core/game3/rse/record_mix_util.lua b/src/core/game3/rse/record_mix_util.lua new file mode 100644 index 00000000..efe1bc4b --- /dev/null +++ b/src/core/game3/rse/record_mix_util.lua @@ -0,0 +1,77 @@ +local MixUtil = {} + +-- pokeemerald/src/record_mixing.c:130 +local ORDERS = { + [2] = { { 1, 0 } }, + [3] = { { 1, 2, 0 }, { 2, 0, 1 } }, + [4] = { + { 1, 0, 3, 2 }, { 3, 0, 1, 2 }, { 2, 0, 3, 1 }, + { 1, 3, 0, 2 }, { 2, 3, 0, 1 }, { 3, 2, 0, 1 }, + { 1, 2, 3, 0 }, { 2, 3, 1, 0 }, { 3, 2, 1, 0 }, + }, +} +MixUtil.ORDERS = ORDERS + +-- pokeemerald/include/constants/global.h:20 +MixUtil.LANGUAGE_JAPANESE = 1 +MixUtil.GAME_LANGUAGE = 2 + +function MixUtil.deep(v) + if type(v) ~= "table" then return v end + local out = {} + for k, x in pairs(v) do out[k] = MixUtil.deep(x) end + return out +end + +function MixUtil.num(v) + return tonumber(v) or 0 +end + +-- pokeemerald/src/link.c:326 +function MixUtil.linkTrainerId(p) + if type(p) ~= "table" then return 0 end + local full = tonumber(p.linkTrainerId) + if full then return full end + return (tonumber(p.trainerId) or 0) % 0x10000 +end + +function MixUtil.sessionLinkTrainerId(sess) + if type(sess) ~= "table" then return 0 end + local tid = math.floor(tonumber(sess.trainerId or sess.playerId) or 0) % 0x10000 + local sid = math.floor(tonumber(sess.secretId) or 0) % 0x10000 + return tid + sid * 0x10000 +end + +function MixUtil.trainerIdBytes(sess) + local full = MixUtil.sessionLinkTrainerId(sess) + return { full % 256, math.floor(full / 256) % 256, math.floor(full / 65536) % 256, math.floor(full / 16777216) % 256 } +end + +-- pokeemerald/src/new_game.c:72 +function MixUtil.getTrainerId(bytes) + if type(bytes) ~= "table" then return tonumber(bytes) or 0 end + return MixUtil.num(bytes[1]) + MixUtil.num(bytes[2]) * 0x100 + MixUtil.num(bytes[3]) * 0x10000 + + MixUtil.num(bytes[4]) * 0x1000000 +end + +-- pokeemerald/src/record_mixing.c:604 +function MixUtil.shuffle(players) + local count = #players + local out = {} + local rows = ORDERS[count] + if not rows then + for i = 1, count do out[i] = i end + return out + end + local row = rows[(MixUtil.linkTrainerId(players[1]) % #rows) + 1] + for i = 1, count do out[i] = row[i] + 1 end + return out +end + +function MixUtil.partner(players, myIndex) + local idx = MixUtil.shuffle(players) + local from = idx[myIndex] or myIndex + return players[from], from +end + +return MixUtil diff --git a/src/core/game3/rse/record_mixing_gift.lua b/src/core/game3/rse/record_mixing_gift.lua new file mode 100644 index 00000000..a3756962 --- /dev/null +++ b/src/core/game3/rse/record_mixing_gift.lua @@ -0,0 +1,116 @@ +local Rse = require("src.core.game3.rse.init") + +local Gift = {} + +local function sessionOf(sess) + return sess or Rse.session() +end + +local function blank() + return { unk0 = 0, quantity = 0, itemId = 0, checksum = 0 } +end + +function Gift.state(sess) + sess = sessionOf(sess) + if type(sess.recordMixingGift) ~= "table" then sess.recordMixingGift = blank() end + return sess.recordMixingGift +end + +-- pokeemerald/src/mystery_event_script.c:103 +function Gift.checksum(g) + local item = tonumber(g.itemId) or 0 + return (tonumber(g.unk0) or 0) % 256 + (tonumber(g.quantity) or 0) % 256 + item % 256 + math.floor(item / 256) % 256 +end + +-- pokeemerald/src/mystery_event_script.c:115 +local function isValid(g) + local sum = Gift.checksum(g) + return (tonumber(g.unk0) or 0) ~= 0 and (tonumber(g.quantity) or 0) ~= 0 and (tonumber(g.itemId) or 0) ~= 0 + and sum ~= 0 and sum == (tonumber(g.checksum) or 0) +end + +-- pokeemerald/src/mystery_event_script.c:130 +function Gift.clear(sess) + sess = sessionOf(sess) + sess.recordMixingGift = blank() +end + +-- pokeemerald/src/mystery_event_script.c:135 +function Gift.set(unk, quantity, itemId, sess) + sess = sessionOf(sess) + if (tonumber(unk) or 0) == 0 or (tonumber(quantity) or 0) == 0 or (tonumber(itemId) or 0) == 0 then + Gift.clear(sess) + return + end + local g = Gift.state(sess) + g.unk0, g.quantity, g.itemId = tonumber(unk), tonumber(quantity), tonumber(itemId) + g.checksum = Gift.checksum(g) +end + +-- pokeemerald/src/mystery_event_script.c:150 +function Gift.take(sess) + sess = sessionOf(sess) + local g = Gift.state(sess) + if not isValid(g) then + Gift.clear(sess) + return 0 + end + local itemId = tonumber(g.itemId) or 0 + g.quantity = g.quantity - 1 + if g.quantity == 0 then Gift.clear(sess) else g.checksum = Gift.checksum(g) end + return itemId +end + +-- pokeemerald/src/record_mixing.c:246 +function Gift.mixExport(sess, multiplayerId) + if (tonumber(multiplayerId) or 0) ~= 0 then return 0 end + return Gift.take(sess) +end + +local function constant(sess, kind, name) + local Constants = require("src.core.game3.constants") + local ok, v = pcall(function() return Constants.of(Constants.versionOf(sess)):require(kind, name) end) + return ok and v or nil +end + +local function pcHas(sess, item) + local okS, Storage = pcall(require, "src.core.game3.storage") + local storage = okS and Storage.ensure(sess) or nil + for _, entry in ipairs(storage and storage.items or {}) do + if (tostring(entry.id) == tostring(item) or tonumber(entry.id) == item) and (tonumber(entry.qty) or 0) >= 1 then + return true + end + end + return false +end + +-- pokeemerald/src/record_mixing.c:969 +function Gift.mixImport(players, sess, myIndex) + sess = sessionOf(sess) + players = players or {} + local multiplayerId = (tonumber(myIndex) or 1) - 1 + local p0 = players[1] or {} + local item = tonumber(p0.giftItem) or 0 + local ItemsData = require("src.core.game3.items_data") + if multiplayerId == 0 or item == 0 or ItemsData.pocketOf(item) ~= "KEY_ITEMS" then return nil end + local Bag = require("src.core.game3.bag") + sess.bag = sess.bag or Bag.new() + local got = not Bag.has(sess.bag, item, 1) and not pcHas(sess, item) and Bag.add(sess.bag, item, 1) + if got then + pcall(Rse.setVar, "VAR_TEMP_RECORD_MIX_GIFT_ITEM", item, sess) + if item == constant(sess, "items", "ITEM_EON_TICKET") then + pcall(Rse.setFlag, "FLAG_ENABLE_SHIP_SOUTHERN_ISLAND", true, sess) + end + return { item = item, from = p0.name or "" } + end + pcall(Rse.setVar, "VAR_TEMP_RECORD_MIX_GIFT_ITEM", 0, sess) + return { item = 0 } +end + +local SaveSections = require("src.core.game3.save_sections") +-- pokeemerald/include/global.h:1070 +SaveSections.register("recordMixingGift", SaveSections.fields({ "recordMixingGift" }, function(sess) Gift.clear(sess) end)) + +Rse.register("recordMixingGift", Gift) + +return Gift diff --git a/src/core/game3/rse/secret_base.lua b/src/core/game3/rse/secret_base.lua index 9ed110f4..e70212c3 100644 --- a/src/core/game3/rse/secret_base.lua +++ b/src/core/game3/rse/secret_base.lua @@ -1181,14 +1181,249 @@ function SB.setUpFieldMove(ctx) return { ok = true, action = "secret_power", secretPowerScript = label } end -function SB.mixExport(sess) - return {} +local function copyBase(b) + local out = {} + for k, v in pairs(b) do + if type(v) == "table" then + local t = {} + for k2, v2 in pairs(v) do + if type(v2) == "table" then + local u = {} + for k3, v3 in pairs(v2) do u[k3] = v3 end + t[k2] = u + else + t[k2] = v2 + end + end + out[k] = t + else + out[k] = v + end + end + return out end -function SB.mixImport(sess, records) +-- pokeemerald/src/record_mixing.c:222 +function SB.mixExport(sess) + sess = session(sess) + pcall(SB.setPlayerParty, sess) + local out = {} + for i, b in ipairs(SB.bases(sess)) do out[i] = copyBase(b) end + return out +end + +local function on(v) + return v == true or v == 1 +end + +local function nameChars(s) + return tostring(s or ""):sub(1, SB.PLAYER_NAME_LENGTH) +end + +local function sameTrainerId(a, b) + for i = 1, SB.TRAINER_ID_LENGTH do + if (tonumber(a.trainerId[i]) or 0) ~= (tonumber(b.trainerId[i]) or 0) then return false end + end + return true +end + +-- pokeemerald/src/secret_base.c:1383 +local function belongToSamePlayer(a, b) + return (a.gender or 0) == (b.gender or 0) and sameTrainerId(a, b) and nameChars(a.trainerName) == nameChars(b.trainerName) +end + +-- pokeemerald/src/secret_base.c:1519 +local function belongsToPlayer(b, sess) + if (b.secretBaseId or 0) == 0 then return false end + if b.gender ~= playerGender(sess) then return false end + if not sameTrainerId(b, { trainerId = trainerIdBytes(sess) }) then return false end + return nameChars(b.trainerName) == nameChars(sess.name or sess.playerName) +end + +-- pokeemerald/src/secret_base.c:1395 +local function indexFromId(saved, id) + for i = 0, SB.COUNT - 1 do + if saved[i + 1].secretBaseId == id then return i end + end + return -1 +end + +-- pokeemerald/src/secret_base.c:1336 +local function saveBase(saved, idx, b) + saved[idx + 1] = normalize(copyBase(b)) + saved[idx + 1].registryStatus = SB.NEW +end + +-- pokeemerald/src/secret_base.c:1432 +local function trySaveFriendsBase(saved, b) + if (b.secretBaseId or 0) == 0 then return 0 end + local index = indexFromId(saved, b.secretBaseId) + if index == 0 then return 0 end + if index ~= -1 then + if on(saved[index + 1].toRegister) then return 0 end + if saved[index + 1].registryStatus ~= SB.NEW or on(b.toRegister) then + saveBase(saved, index, b) + return index + end + return 0 + end + for i = 1, SB.COUNT - 1 do + if saved[i + 1].secretBaseId == 0 then + saveBase(saved, i, b) + return i + end + end + for i = 1, SB.COUNT - 1 do + if saved[i + 1].registryStatus == SB.UNREGISTERED and not on(saved[i + 1].toRegister) then + saveBase(saved, i, b) + return i + end + end + return 0 +end + +-- pokeemerald/src/secret_base.c:1486 +local function sortByRegistryStatus(saved) + for i = 1, SB.COUNT - 2 do + for j = i + 1, SB.COUNT - 1 do + local a, b = saved[i + 1], saved[j + 1] + if (a.registryStatus == SB.UNREGISTERED and b.registryStatus == SB.REGISTERED) + or (a.registryStatus == SB.NEW and b.registryStatus ~= SB.NEW) then + saved[i + 1], saved[j + 1] = b, a + end + end + end +end + +-- pokeemerald/src/secret_base.c:1509 +local function trySaveFriendsBases(saved, mixer, status) + for i = 1, SB.COUNT - 1 do + if mixer[i + 1].registryStatus == status then trySaveFriendsBase(saved, mixer[i + 1]) end + end +end + +-- pokeemerald/src/secret_base.c:1549 +local function deleteFirstOldBaseFromPlayer(mixers, sess) + local done = { false, false, false } + for i = 1, SB.COUNT do + for m = 1, 3 do + if not done[m] and belongsToPlayer(mixers[m][i], sess) then + clearBase(mixers[m][i]) + done[m] = true + end + end + if done[1] and done[2] and done[3] then break end + end +end + +-- pokeemerald/src/secret_base.c:1595 +local function clearDuplicateOwned(b, list, idx) + for i = 1, SB.COUNT do + local other = list[i] + if other.secretBaseId ~= 0 and belongToSamePlayer(b, other) then + if idx == 0 then + clearBase(other) + return false + end + if (b.numSecretBasesReceived or 0) > (other.numSecretBasesReceived or 0) then + clearBase(other) + return false + end + other.toRegister = b.toRegister + clearBase(b) + return true + end + end return false end +-- pokeemerald/src/secret_base.c:1627 +local function clearDuplicateOwnedBases(saved, a, b, c) + for i = 1, SB.COUNT - 1 do + local mine = saved[i + 1] + if mine.secretBaseId ~= 0 then + if mine.registryStatus == SB.REGISTERED then mine.toRegister = 1 end + if not clearDuplicateOwned(mine, a, i) then + if not clearDuplicateOwned(mine, b, i) then clearDuplicateOwned(mine, c, i) end + end + end + end + for i = 0, SB.COUNT - 1 do + if a[i + 1].secretBaseId ~= 0 then + a[i + 1].battledOwnerToday = 0 + if not clearDuplicateOwned(a[i + 1], b, i) then clearDuplicateOwned(a[i + 1], c, i) end + end + end + for i = 0, SB.COUNT - 1 do + if b[i + 1].secretBaseId ~= 0 then + b[i + 1].battledOwnerToday = 0 + clearDuplicateOwned(b[i + 1], c, i) + end + if c[i + 1].secretBaseId ~= 0 then c[i + 1].battledOwnerToday = 0 end + end +end + +-- pokeemerald/src/secret_base.c:1696 +local function saveRecordMixBases(saved, mixers, sess) + deleteFirstOldBaseFromPlayer(mixers, sess) + clearDuplicateOwnedBases(saved, mixers[1], mixers[2], mixers[3]) + -- pokeemerald/src/secret_base.c:1684 + for i = 1, SB.COUNT do + for m = 1, 3 do + local b = mixers[m][i] + if on(b.toRegister) then + trySaveFriendsBase(saved, b) + clearBase(b) + end + end + end + for m = 1, 3 do trySaveFriendsBase(saved, mixers[m][1]) end + for m = 1, 3 do trySaveFriendsBases(saved, mixers[m], SB.REGISTERED) end + for m = 1, 3 do trySaveFriendsBases(saved, mixers[m], SB.UNREGISTERED) end +end + +local function mixerList(list) + local out = {} + for i = 1, SB.COUNT do + local b = type(list) == "table" and list[i] or nil + out[i] = normalize(type(b) == "table" and copyBase(b) or nil) + out[i].secretBaseId = tonumber(out[i].secretBaseId) or 0 + out[i].registryStatus = tonumber(out[i].registryStatus) or 0 + end + return out +end + +-- pokeemerald/src/secret_base.c:1731 +function SB.mixImport(sess, records, myIndex) + sess = session(sess) + local okF, received = pcall(flag, "FLAG_RECEIVED_SECRET_POWER", sess) + if not (okF and received) then return true end + records = records or {} + local count = #records + local slots = {} + for i = 0, 3 do slots[i] = i < count and records[i + 1] or nil end + local linkIdx = (tonumber(myIndex) or 1) - 1 + local mixers = {} + for k = 1, 3 do mixers[k] = mixerList(slots[(linkIdx + k) % 4]) end + local saved = SB.bases(sess) + saveRecordMixBases(saved, mixers, sess) + for i = 1, SB.COUNT - 1 do + if on(saved[i + 1].toRegister) then + saved[i + 1].registryStatus = SB.REGISTERED + saved[i + 1].toRegister = 0 + end + end + sortByRegistryStatus(saved) + for i = 1, SB.COUNT - 1 do + if saved[i + 1].registryStatus == SB.NEW then saved[i + 1].registryStatus = SB.UNREGISTERED end + end + local mine = saved[1] + if mine.secretBaseId ~= 0 and (mine.numSecretBasesReceived or 0) ~= 0xFFFF then + mine.numSecretBasesReceived = (mine.numSecretBasesReceived or 0) + 1 + end + return true +end + function SB.reset() SB._curId = 0 SB._inFriendBase = false diff --git a/src/core/game3/scripting/text_ir.lua b/src/core/game3/scripting/text_ir.lua index 64d24cc6..19a28c9d 100644 --- a/src/core/game3/scripting/text_ir.lua +++ b/src/core/game3/scripting/text_ir.lua @@ -315,10 +315,28 @@ local function gendered_ph(name, ctx) return by.male end +-- pokeemerald/src/string_util.c:428 +local function live(field, ctx) + local v = ctx and ctx[field] + if v ~= nil then return v end + local provider = TextIR._provider + if not provider then return nil end + return provider(field, TextIR.dialect(ctx and ctx.dialect), ctx) +end + +local function player_name(ctx) + return live("playerName", ctx) or "PLAYER" +end + +local function string_var(n, ctx) + local sv = live("stringVars", ctx) + return (type(sv) == "table" and sv[n]) or "" +end + local function rival_name(ctx) local v = gendered_ph("RIVAL", ctx) if v ~= nil then return v end - return (ctx and ctx.rivalName) or "RIVAL" + return live("rivalName", ctx) or "RIVAL" end local function expand_seg(seg, ctx) @@ -329,12 +347,11 @@ local function expand_seg(seg, ctx) local dyn = ctx and ctx.dynamic return (dyn and dyn[seg.n]) or "" elseif t == "player" then - return (ctx and ctx.playerName) or "PLAYER" + return player_name(ctx) elseif t == "rival" then return rival_name(ctx) elseif t == "strvar" then - local sv = ctx and ctx.stringVars - return (sv and sv[seg.n]) or "" + return string_var(seg.n, ctx) elseif t == "tag" then return seg.tag elseif t == "bph" then @@ -350,19 +367,16 @@ local function expand_seg(seg, ctx) local code = tonumber(seg.code) local name = seg.name if code == 0x01 or name == "PLAYER" then - return (ctx and ctx.playerName) or "PLAYER" + return player_name(ctx) end if code == 0x06 or name == "RIVAL" then return rival_name(ctx) end if code and code >= 0x02 and code <= 0x04 then - local sv = ctx and ctx.stringVars - return (sv and sv[code - 1]) or "" + return string_var(code - 1, ctx) end if name == "STR_VAR_1" or name == "STR_VAR_2" or name == "STR_VAR_3" then - local n = tonumber(name:sub(-1)) or 1 - local sv = ctx and ctx.stringVars - return (sv and sv[n]) or "" + return string_var(tonumber(name:sub(-1)) or 1, ctx) end if name and (name == "FONT_MALE" or name == "FONT_FEMALE" or name == "FONT_NORMAL" or name:find("^COLOR") or name:find("^SHADOW") or name:find("^HIGHLIGHT") or name:find("^BG")) then diff --git a/src/core/game3/scripting/trainers.lua b/src/core/game3/scripting/trainers.lua index c527d296..72700a73 100644 --- a/src/core/game3/scripting/trainers.lua +++ b/src/core/game3/scripting/trainers.lua @@ -222,6 +222,28 @@ function Trainers.foeFromId(trainerId) } end +local DIALOG_TEXT_KEYS = { + intro = "introTextKey", defeat = "defeatTextKey", + victory = "victoryTextKey", notEnough = "notEnoughTextKey", +} + +local function live_dialogs(t) + local d = t.dialogs or {} + local Sp = package.loaded["src.core.game3.scripting.space"] + local vm = Sp and Sp.vm + if not (vm and vm.getText) then return d end + local out + for field, keyName in pairs(DIALOG_TEXT_KEYS) do + local key = t[keyName] + local ir = key and vm:getText(key) + if type(ir) == "table" then + out = out or setmetatable({}, { __index = d }) + out[field] = ir + end + end + return out or d +end + --- ROM-derived trainer presentation info (class / name / pic / partySize / dialogs). -- opts.rivalName replaces the placeholder "TERRY" of the rival and champion -- classes when provided. @@ -247,7 +269,7 @@ function Trainers.info(trainerId, opts) ai = t.ai, items = t.items, party = t.party, - dialogs = t.dialogs, + dialogs = live_dialogs(t), } -- pokefirered/src/battle_message.c:2078 names these classes by the player's @@ -263,7 +285,7 @@ end --- Get dialog texts table: { intro, defeat, victory, notEnough } function Trainers.dialogs(trainerId) local t = Trainers.get(trainerId) - return t and t.dialogs or {} + return t and live_dialogs(t) or {} end --- FRLG intro string pieces for a trainer battle. diff --git a/src/save_convert/gen3_port/rse.lua b/src/save_convert/gen3_port/rse.lua index bd140cf8..4febd7a5 100644 --- a/src/save_convert/gen3_port/rse.lua +++ b/src/save_convert/gen3_port/rse.lua @@ -531,6 +531,26 @@ section({ name = "trainerHill", fields = {}, template = { "trainerHill", "traine section({ name = "apprentice", fields = {}, template = { "playerApprentice", "apprentices" } }) section({ name = "lilycoveLady", fields = {}, template = { "lilycoveLady" } }) section({ name = "oldMan", fields = {}, template = { "oldMan" } }) + +-- pokeemerald/include/global.h:746 +section({ + name = "recordMixingGift", + fields = { "recordMixingGift" }, + read = function(x) + local b = x.L.RSE.sb1.recordMixingGift + return { recordMixingGift = { checksum = u32(x.sb1, b), unk0 = u8(x.sb1, b + 4), quantity = u8(x.sb1, b + 5), + itemId = u16(x.sb1, b + 6) } } + end, + write = function(x, v) + local b = x.L.RSE.sb1.recordMixingGift + local g = type(v.recordMixingGift) == "table" and v.recordMixingGift or {} + x.w1:fill(b, 16, 0) + x.w1:w32(b, num(g.checksum) % U32) + x.w1:w8(b + 4, num(g.unk0) % 256) + x.w1:w8(b + 5, num(g.quantity) % 256) + x.w1:w16(b + 6, num(g.itemId) % 65536) + end, +}) section({ name = "lottery", fields = {}, vars = true }) local function context(codec, blocks, w1, w2) diff --git a/src/ui/game3/message.lua b/src/ui/game3/message.lua index 2fd01051..1740d8a2 100644 --- a/src/ui/game3/message.lua +++ b/src/ui/game3/message.lua @@ -41,6 +41,21 @@ TextIR.setContextProvider(function(kind, dialect, ctx) local s = liveSession() return s and s.gender or nil end + if kind == "playerName" then + local s = liveSession() + local name = s and (s.name or s.playerName) + return (type(name) == "string" and name ~= "") and name or nil + end + if kind == "rivalName" or kind == "stringVars" then + local Sp = package.loaded["src.core.game3.scripting.space"] + local vm = Sp and Sp.vm + if not vm then return nil end + if kind == "stringVars" then return vm.ctx and vm.ctx.stringVars end + local a = vm.adapters + local r = a and a.rivalName + if type(r) == "function" then r = r() end + return r or (vm.ctx and vm.ctx.rivalName) + end if kind ~= "placeholders" or not dialect.placeholders then return nil end local GameVersion = require("src.core.GameVersion") local s = liveSession() diff --git a/tests/drivers/em_story_seg4.lua b/tests/drivers/em_story_seg4.lua index 1d1dd671..00b7ce32 100644 --- a/tests/drivers/em_story_seg4.lua +++ b/tests/drivers/em_story_seg4.lua @@ -919,7 +919,18 @@ BEATS[#BEATS + 1] = { id = "champion", run = function(game) local track = labelWatch(labels) local rivalShot, birchShot = false, false local ratingText - local settle = { limit = 120000, onBattleFrame = battleShot(game, "26_wallace_battle"), + local wallaceShot, kudos = battleShot(game, "26_wallace_battle"), nil + local function onBattleFrame(st, phase) + wallaceShot(st, phase) + local M = require("src.ui.game3.message") + if not kudos and M.isOpen() and messageText():find("Kudos to you", 1, true) then + if M.isTyping() then M.skipReveal() end + kudos = messageText() + d.note("wallace defeat: " .. kudos) + d.shot(game, "26b_wallace_kudos") + end + end + local settle = { limit = 120000, onBattleFrame = onBattleFrame, watch = function() mug() track() @@ -950,6 +961,9 @@ BEATS[#BEATS + 1] = { id = "champion", run = function(game) d.check(d.ran("EverGrandeCity_ChampionsRoom_EventScript_EnterRoom"), "the player walks up to Wallace on entry") d.check(seen.key == "champion", "Wallace's mugshot battle transition (" .. tostring(seen.key) .. ")") d.check(lastBattleWon(tid("TRAINER_WALLACE")), "Champion Wallace battle won through the battle UI") + local pname = S.session().name or "" + d.check(kudos ~= nil and pname ~= "" and kudos:find("Kudos to you, " .. pname .. "!", 1, true) ~= nil, + "Wallace's defeat line names the player (" .. tostring(kudos) .. ")") d.check(labels.EverGrandeCity_ChampionsRoom_EventScript_MayAdvice and rivalShot, "May runs in after the battle") local dexCaught = 0 for _ in pairs(S.session().dex.caught or {}) do dexCaught = dexCaught + 1 end diff --git a/tests/drivers/game3_wild_exp_flow.lua b/tests/drivers/game3_wild_exp_flow.lua new file mode 100644 index 00000000..a3d742c7 --- /dev/null +++ b/tests/drivers/game3_wild_exp_flow.lua @@ -0,0 +1,188 @@ +local U = require("tests.drivers.util") +local DIR = os.getenv("POKEPORT_SHOT_DIR") or "/tmp/game3_wild_exp_flow" + +return function(game) + io.stdout:setvbuf("line") + local fails = 0 + local function result(ok, label) + print((ok and "PASS " or "FAIL ") .. label) + if not ok then fails = fails + 1 end + return ok + end + local function finish() + print(string.format("[driver] game3_wild_exp_flow: %d failure(s)", fails)) + love.event.quit(fails == 0 and 0 or 1) + U.wait(10) + end + + for _ = 1, 900 do + if game.phase == "boot" and game.boot then break end + U.wait(1) + end + game:_handleBootAction({ action = "new_game", name = "BRENDAN", gender = 0 }) + U.wait(240) + + local Runtime = require("src.core.game3.runtime") + local Party = require("src.core.game3.party") + local BattleBridge = require("src.core.game3.battle_bridge") + local Battle = require("src.core.game3.battle") + local Ui = require("src.core.game3.battle.ui") + local Message = require("src.ui.game3.message") + local StatGrowth = require("src.ui.game3.stat_growth") + local Anim = require("src.core.game3.battle.anim") + local Audio = require("src.core.game3.audio") + local Map = require("src.core.game3.map") + local Encounters = require("src.core.game3.encounters") + local Pokemon = require("src.core.game3.pokemon") + local Experience = require("src.core.game3.battle.experience") + local Constants = require("src.core.game3.constants") + local session = Runtime.getSession() + if not result(session ~= nil, "field session exists") then return finish() end + local C = Constants.of(Constants.versionOf(session)) + print("[driver] version " .. tostring(C.game)) + Encounters.onStep = function() return nil end + if C.game == "emerald" then + Map.load(nil, game, "EM_ROUTE101", { x = 7, y = 13, facing = "down" }) + else + Map.load(nil, game, "FR_ROUTE1", { x = 10, y = 20, facing = "down" }) + end + U.wait(40) + + local MUDKIP = C.species.byName.SPECIES_MUDKIP + local learnLv, learnMove + for lv = 6, 40 do + local mv = Pokemon.movesLearnedAt(MUDKIP, lv) + if mv and #mv > 0 then learnLv, learnMove = lv, mv[1] break end + end + if not result(learnLv ~= nil, "Mudkip learnset has a move between LV6 and LV40 (" .. tostring(learnLv) .. ")") then + return finish() + end + session.party = {} + Party.giveMon(session, MUDKIP, learnLv - 1, "") + local mon = session.party[1] + local mv = function(n) return C.moves.byName["MOVE_" .. n] end + mon.moves = {} + for _, n in ipairs({ "TACKLE", "GROWL", "PROTECT", "WATER_GUN", "SCRATCH" }) do + if mv(n) ~= learnMove and #mon.moves < 4 then mon.moves[#mon.moves + 1] = mv(n) end + end + mon.pp = { 35, 40, 10, 10 } + mon.maxPp = { 35, 40, 10, 10 } + mon.exp = Experience.expForLevel(mon, learnLv) - 1 + print(string.format("[driver] Mudkip LV%d exp %d, learns %s at LV%d", mon.level, mon.exp, + tostring(C:name("moves", learnMove, "MOVE_")), learnLv)) + + local VS_WILD = C.songs.byName.MUS_VS_WILD + local VICTORY_WILD = C.songs.byName.MUS_VICTORY_WILD + local outcome + local ok = BattleBridge.startWild(Runtime._mod, game, + { species = C.species.byName.SPECIES_ZIGZAGOON, level = 5 }, { done = function(r) outcome = r end }) + if not result(ok == true, "wild Zigzagoon battle started") then return finish() end + for _ = 1, 3000 do + if Battle.isActive() then break end + U.wait(1) + end + if not result(Battle.isActive(), "battle active") then return finish() end + + local f = 0 + local ev = {} + local function mark(name) + if not ev[name] then + ev[name] = f + print(string.format("[driver] f=%d %s song=%s", f, name, tostring(Audio._currentSong and Audio._currentSong.id))) + end + end + local songAt = {} + local waitF = 0 + local statShot, expShot, learnShot = false, false, false + local gainedOpenFrames, gainedPrinted = 0, false + local endedWhileGained = false + local foeLowered = false + while (Battle.isActive() or outcome == nil) and f < 30000 do + f = f + 1 + local song = Audio._currentSong and Audio._currentSong.id + local page = Message.isOpen() and Message.currentPage() or "" + local phase = Battle._phase + if song == VICTORY_WILD then mark("victory_song") end + if page:find("fainted") then + mark("fainted_open") + songAt.fainted = songAt.fainted or song + if Message.isWaiting() then mark("fainted_waiting") end + end + if page:find("EXP%. Points") then + mark("gained_open") + songAt.gained = songAt.gained or song + gainedOpenFrames = gainedOpenFrames + 1 + if Message.isWaiting() then + gainedPrinted = true + mark("gained_printed") + if not expShot then + expShot = U.still(game, DIR .. "/01_gained_exp.png") + end + end + if phase == "ending" or phase == "fade_out" then endedWhileGained = true end + elseif ev.gained_open and not ev.gained_closed then + mark("gained_closed") + end + if page:find("grew to") then mark("grew_open") end + if page:find("trying to") or page:find("learn") then mark("learn_text") end + if phase == "ending" or phase == "fade_out" then mark("battle_ending") end + + if StatGrowth.isOpen() then + mark("stat_box") + if not statShot then + U.wait(20) + statShot = U.still(game, DIR .. "/02_level_up_box.png") + end + U.wait(30) + U.tap(game, "a") + elseif Ui.choiceActive() then + mark("learn_prompt") + if not learnShot then + U.wait(10) + learnShot = U.still(game, DIR .. "/03_learn_prompt.png") + end + U.wait(20) + local lg = Ui.log() + local last = tostring(lg[#lg] or "") + U.tap(game, last:find("Stop") and "a" or "b") + elseif phase == "command" and Ui._mode == "menu" and not Anim.busy() then + local st = Battle.getState() + if st and st.enemy and st.enemy.mon and not foeLowered then + st.enemy.mon.hp = 1 + foeLowered = true + end + U.wait(10) U.tap(game, "a") U.wait(10) U.tap(game, "a") U.wait(4) + elseif Message.isWaiting() and Ui.dialogPending() then + waitF = waitF + 1 + if waitF >= 90 then + waitF = 0 + U.tap(game, "a") + else + U.wait(1) + end + else + waitF = 0 + U.wait(1) + end + end + + result(outcome == "win", "battle outcome win (" .. tostring(outcome) .. ")") + result(ev.fainted_waiting ~= nil and ev.gained_open ~= nil and ev.gained_open - ev.fainted_waiting >= 90, + "fainted!\\p waits for a button press before getexp (" .. tostring(ev.fainted_waiting) .. " -> " .. tostring(ev.gained_open) .. ")") + result(songAt.fainted == VS_WILD, "MUS_VS_WILD still playing on the fainted message (" .. tostring(songAt.fainted) .. ")") + result(ev.victory_song ~= nil and ev.gained_open ~= nil and ev.victory_song <= ev.gained_open, + "MUS_VICTORY_WILD starts before the gained EXP text (" .. tostring(ev.victory_song) .. " <= " .. tostring(ev.gained_open) .. ")") + result(songAt.gained == VICTORY_WILD, "MUS_VICTORY_WILD playing under the gained EXP text") + result(gainedPrinted, "gained EXP text printed in full") + result(gainedOpenFrames >= 60, "gained EXP text on screen " .. gainedOpenFrames .. " frames") + result(not endedWhileGained, "battle did not start ending while the gained EXP text was up") + result(ev.grew_open ~= nil and ev.grew_open > (ev.gained_open or math.huge), "grew to LV text after the gained EXP text") + result(ev.stat_box ~= nil and ev.stat_box >= (ev.grew_open or math.huge), "level-up stat box shown after grew to LV") + result(ev.learn_prompt ~= nil and ev.learn_prompt > (ev.stat_box or math.huge), "move-learn prompt after the level-up box") + result(ev.battle_ending ~= nil and ev.battle_ending > (ev.learn_prompt or math.huge) + and ev.battle_ending > (ev.gained_closed or math.huge), "battle ends after the EXP / level / learn flow") + result(expShot and statShot and learnShot, "gained / level-up / learn prompt shots") + local m = session.party[1] + result(m and m.level == learnLv, "Mudkip reached LV" .. learnLv .. " (" .. tostring(m and m.level) .. ")") + return finish() +end diff --git a/tests/emerald_player_placeholder_test.lua b/tests/emerald_player_placeholder_test.lua new file mode 100644 index 00000000..bba2528d --- /dev/null +++ b/tests/emerald_player_placeholder_test.lua @@ -0,0 +1,120 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path + +local T = require("tests.harness") +local check, eq = T.check, T.eq + +local GameVersion = require("src.core.GameVersion") +local Profile = require("src.core.game3.profile") +Profile.reset() +GameVersion.set("emerald") + +local Dataset = require("src.core.game3.dataset") +local cache = Dataset.cache() +local function loadRel(rel) + local src = cache and cache.read and cache:read(rel) + if type(src) ~= "string" then return nil end + local chunk = load(src, "@" .. rel, "t", { setmetatable = setmetatable, pairs = pairs, ipairs = ipairs }) + local ok, v = pcall(chunk) + return ok and v or nil +end +local manifest = loadRel("data/generated/gba/pokemon/battle/manifest.lua") +local text = manifest and manifest.layout == "rse" and loadRel("data/generated/gba/scripts/text.lua") +if type(text) ~= "table" then + print("emerald_player_placeholder_test: skipped (no Emerald cache; set POKEPORT_IDENTITY)") + os.exit(0) +end + +require("src.import.gba.versions").select("emerald") +local C = require("src.core.game3.constants").of("emerald") +local TextIR = require("src.core.game3.scripting.text_ir") +local Trainers = require("src.core.game3.scripting.trainers") +local Space = require("src.core.game3.scripting.space") +local Runtime = require("src.core.game3.runtime") +local Message = require("src.ui.game3.message") +local BattleText = require("src.core.game3.battle.battle_text") + +local placeholders = loadRel("data/generated/gba/text/placeholders.lua") +local liveProvider = TextIR._provider +TextIR.setContextProvider(function(kind, dialect, ctx) + if kind == "placeholders" then return placeholders end + return liveProvider(kind, dialect, ctx) +end) + +local sess = { version = "emerald", name = "QUINCY", gender = 0, trainerId = 1, money = 0, party = {} } +local prevSession, prevVm = Runtime.session, Space.vm +Runtime.session = sess +Space.vm = { + ctx = { stringVars = { "ZIGZAGOON", "SECOND", "" } }, + adapters = {}, + getText = function(_, key) return text[key] end, +} + +local function shown(t, opts) + Message.show(t, opts or { frame = "battle", battle = true }) + local s = table.concat(Message._pages or {}, " ") + Message.close() + return (s:gsub("[\n\f\v]", " ")) +end + +local function has(s, needle) return type(s) == "string" and s:find(needle, 1, true) ~= nil end + +local WALLACE = C.trainers and C.trainers.TRAINER_WALLACE or 335 +local baked = Trainers.get(WALLACE).dialogs.defeat +check(has(baked, "Kudos to you, PLAYER!"), "cache dialogs.defeat is the flattened import string") + +local d = Trainers.dialogs(WALLACE) +eq(type(d.defeat), "table", "Wallace defeat resolves to IR through the live script text") +local wallace = shown(d.defeat) +check(has(wallace, "Kudos to you, QUINCY!"), "Wallace defeat names the player: " .. wallace) +check(not has(wallace, "PLAYER"), "Wallace defeat has no literal PLAYER") +check(has(shown(Trainers.info(WALLACE).dialogs.intro), "QUINCY"), "Wallace intro via Trainers.info names the player") + +for _, tid in ipairs({ 269, 519, 520, 529, 664, 804 }) do + local dl = Trainers.dialogs(tid) + for _, field in ipairs({ "intro", "defeat" }) do + local ir = dl[field] + if type(ir) == "table" then + local s = shown(ir) + local wantsPlayer = false + for _, seg in ipairs(ir) do if seg.t == "player" then wantsPlayer = true end end + if wantsPlayer then + check(has(s, "QUINCY") and not has(s, "PLAYER"), ("trainer %d %s names the player: %s"):format(tid, field, s)) + end + end + end +end + +check(has(TextIR.toPlain(d.defeat), "QUINCY"), "toPlain without ctx falls back to the live player") +check(has(TextIR.toPlain(d.defeat, { playerName = "ZED" }), "ZED"), "explicit ctx playerName still wins") + +local rivalIr, strIr +for _, ir in pairs(text) do + if type(ir) == "table" then + for _, seg in ipairs(ir) do + if not rivalIr and seg.t == "rival" then rivalIr = ir end + if not strIr and seg.t == "strvar" and seg.n == 1 then strIr = ir end + end + end + if rivalIr and strIr then break end +end +if rivalIr then + check(has(shown(rivalIr), "MAY"), "{RIVAL} is MAY for a male player") + sess.gender = 1 + check(has(shown(rivalIr), "BRENDAN"), "{RIVAL} is BRENDAN for a female player") + sess.gender = 0 +end +if strIr then + check(has(shown(strIr), "ZIGZAGOON"), "{STR_VAR_1} reads the live script string var") +end + +local vs = shown(BattleText.get("STRINGID_PLAYERGOTMONEY", { playerName = "QUINCY", buff1 = "9900" })) +check(has(vs, "QUINCY") and has(vs, "9900"), "battle prize string names the player: " .. vs) +local money = shown(require("src.core.game3.battle.prize").moneyMessage(sess.name, 9900)) +check(has(money, "QUINCY got"), "prize money message names the player: " .. money) + +Space.vm = nil +eq(Trainers.dialogs(WALLACE).defeat, baked, "without a script vm the cache string is returned unchanged") + +Runtime.session, Space.vm = prevSession, prevVm +TextIR.setContextProvider(liveProvider) +T.finish("emerald_player_placeholder_test") diff --git a/tests/emerald_record_mix_test.lua b/tests/emerald_record_mix_test.lua new file mode 100644 index 00000000..ad45c0a2 --- /dev/null +++ b/tests/emerald_record_mix_test.lua @@ -0,0 +1,337 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") + +local T = require("tests.harness") +T.verbose = true +local check, eq = T.check, T.eq + +local GameVersion = require("src.core.GameVersion") +local Profile = require("src.core.game3.profile") +Profile.reset() +GameVersion.set("emerald") + +local Dataset = require("src.core.game3.dataset") +local emCache = Dataset.cache() +local meta = emCache and emCache.read and emCache:read("data/generated/gba/meta.json") +if not (type(meta) == "string" and meta:find('"version":"emerald"', 1, true)) then + print("emerald_record_mix_test: skipped (no Emerald cache; set POKEPORT_IDENTITY)") + os.exit(0) +end +require("src.import.gba.versions").select("emerald") +local Pokemon = require("src.core.game3.pokemon") +Pokemon.install(nil) + +local Json = require("src.link.Json") +local Wire = require("src.link.Wire") +local SaveData = require("src.core.SaveData") +local Schema = require("src.core.game3.save_schema_firered") +local Flags = require("src.core.game3.scripting.flags") +local Space = require("src.core.game3.scripting.space") +local Rse = require("src.core.game3.rse.init") +local RecordMix = require("src.core.game3.link.record_mix") +local MixUtil = require("src.core.game3.rse.record_mix_util") +local OldMan = require("src.core.game3.rse.old_man") +local Lady = require("src.core.game3.rse.lilycove_lady") +local SB = require("src.core.game3.rse.secret_base") +local Tv = require("src.core.game3.rse.tv") +local Dex = require("src.core.game3.dex") +local Daycare = require("src.core.game3.daycare") +local DaycareMail = require("src.core.game3.rse.daycare_mail_mix") +local Gift = require("src.core.game3.rse.record_mixing_gift") +local FUtil = require("src.core.game3.rse.frontier.util") +local Apprentice = require("src.core.game3.rse.frontier.apprentice") +local Bag = require("src.core.game3.bag") +local EM = require("src.core.game3.constants").of("emerald") + +local ORANGE_MAIL = EM:require("items", "ITEM_ORANGE_MAIL") +local EON_TICKET = EM:require("items", "ITEM_EON_TICKET") +local SEEDOT = EM:require("species", "SPECIES_SEEDOT") +local MUDKIP = EM:require("species", "SPECIES_MUDKIP") + +local stores = {} +local function use(s) + Space.store = stores[s] +end + +local function makePlayer(idx, name, tid, gender) + local s = Schema.newGame({ version = "emerald", name = name, rngSeed = tid }) + s.trainerId, s.secretId, s.gender = tid, 0, gender + s.party = { { species = MUDKIP, level = 20, nickname = "", heldItem = 0 } } + s.dex = s.dex or Dex.new() + Dex.setSeen(s.dex, SEEDOT) + Dex.setSeen(s.dex, MUDKIP) + stores[s] = Flags.newStore() + use(s) + Rse.setFlag("FLAG_RECEIVED_SECRET_POWER", true, s) + + local b = SB.base(s, 0) + b.secretBaseId, b.trainerName, b.gender, b.language = 10 * idx + 1, name, gender, 2 + b.trainerId = MixUtil.trainerIdBytes(s) + + local m = OldMan.set(s) + if m.id == OldMan.STORYTELLER then + m.gameStatIDs[1], m.trainerNames[1], m.statValues[1] = 1, name, 100 * idx + elseif m.id == OldMan.GIDDY then + m.randomWords[1] = 100 + idx + elseif m.id == OldMan.BARD then + m.playerName = name + end + + local l = Lady.init(s) + local which = l.quiz or l.favor or l.contest + which.playerName = name + + local dc = Daycare.stateOf(s) + Daycare.setMon(dc, 1, { species = SEEDOT, level = 5, heldItem = 0 }) + dc.mail = {} + + local f = FUtil.frontier(s) + f.towerPlayer = { trainerId = MixUtil.trainerIdBytes(s), name = name, winStreak = 20 * idx, lvlMode = 0, + facilityClass = 1, party = { { species = MUDKIP, level = 50 } }, language = 2 } + FUtil.set2(f.towerRecordWinStreaks, 0, 0, 20 * idx) + FUtil.set2(f.towerRecordWinStreaks, 3, 0, 5 * idx) + + local a = Apprentice.saved(s)[1] + a.playerName, a.playerId, a.number, a.id, a.lvlMode = name, MixUtil.trainerIdBytes(s), idx, idx, 1 + return s +end + +local function giveShow(s) + local saved = Tv.random + Tv.random = function() return 0 end + Tv.onBattleEnd(s, { playerMon1Species = MUDKIP, caughtMonSpecies = SEEDOT, caughtMonNick = "ACORN", + catchAttempts = { [3] = 2 }, lastUsedItem = EM:require("items", "ITEM_POKE_BALL"), lastOpponentSpecies = SEEDOT }, + Tv.B_OUTCOME_CAUGHT, {}) + Tv.random = saved +end + +local function wire(packet, spot) + local msg = Wire.sanitize(Json.decode(Json.encode({ type = RecordMix.MSG.PACKET, spot = spot, + packet = RecordMix.toWire(packet) }))) + return msg and RecordMix.fromWire(msg.packet) or nil +end + +local function mix(players) + local packets, wired = {}, {} + for i, s in ipairs(players) do + use(s) + packets[i] = RecordMix.packet(s, i - 1) + wired[i] = wire(packets[i], i - 1) + end + local applied = {} + for i, s in ipairs(players) do + local list = {} + for j = 1, #players do list[j] = j == i and packets[i] or wired[j] end + use(s) + applied[i] = RecordMix.receive(s, list, i) + end + return applied, packets, wired +end + +local function hasBase(s, id) + for i = 2, SB.COUNT do + if SB.base(s, i - 1).secretBaseId == id then return SB.base(s, i - 1) end + end + return nil +end + +local function towerRecordNamed(s, name) + for _, r in ipairs(FUtil.frontier(s).towerRecords) do + if r.name == name then return r end + end + return nil +end + +local function apprenticeNamed(s, name) + for i, a in ipairs(s.apprentices or {}) do + if i > 1 and a.playerName == name then return a end + end + return nil +end + +local function tvHasCaught(s) + for i = 0, Tv.TV_SHOWS_COUNT - 1 do + local show = s.tvShows[i] + if show and show.kind == Tv.TVSHOW_POKEMON_TODAY_CAUGHT and show.active == true then return true end + end + return false +end + +local function reload(s) + local save = SaveData.decode(SaveData.encode(Schema.toSaveTable(s))) + return Schema.fromSaveTable(save) +end + +local function giftVar(s) + use(s) + return Rse.var("VAR_TEMP_RECORD_MIX_GIFT_ITEM", s) +end + +print("[test] 1. ShufflePlayerIndices (pokeemerald/src/record_mixing.c:604)") +do + local two = MixUtil.shuffle({ { trainerId = 5 }, {} }) + eq(two[1] .. two[2], "21", "2 players swap") + local three0 = MixUtil.shuffle({ { linkTrainerId = 4 }, {}, {} }) + eq(three0[1] .. three0[2] .. three0[3], "231", "3 players, even leader id: 0<-1, 1<-2, 2<-0") + local three1 = MixUtil.shuffle({ { linkTrainerId = 7 }, {}, {} }) + eq(three1[1] .. three1[2] .. three1[3], "312", "3 players, odd leader id: 0<-2, 1<-0, 2<-1") + local four = MixUtil.shuffle({ { linkTrainerId = 9 * 3 + 4 }, {}, {}, {} }) + eq(four[1] .. four[2] .. four[3] .. four[4], "3412", "4 players use sPlayerIdxOrders_4Player[id % 9]") +end + +print("[test] 2. two players mix every record") +do + local A = makePlayer(1, "MAY", 1116, 1) + local B = makePlayer(2, "BRENDAN", 2228, 0) + giveShow(B) + local mail = { message = { itemId = ORANGE_MAIL, words = { 1, 2, 3, 4, 5, 6, 7, 8, 9 }, playerName = "MAY", + trainerId = 1116, species = SEEDOT }, otName = "MAY", monName = "SEEDOT" } + Daycare.stateOf(A).mail[1] = mail + Gift.set(1, 1, EON_TICKET, A) + eq(OldMan.current(A), OldMan.STORYTELLER, "MAY has the storyteller") + eq(OldMan.current(B), OldMan.GIDDY, "BRENDAN has Giddy") + eq(Lady.id(A), Lady.QUIZ, "MAY has the quiz lady") + eq(Lady.id(B), Lady.FAVOR, "BRENDAN has the favor lady") + + local applied, packets, wired = mix({ A, B }) + eq(packets[1].giftItem, EON_TICKET, "multiplayer id 0 sends its record mixing gift") + eq(packets[2].giftItem, 0, "other players send no gift") + check(wired[2] and wired[2].daycareMail and wired[2].apprentices and wired[2].hallRecords + and wired[2].battleTowerRecord and wired[2].lilycoveLady and wired[2].oldMan, "every record crosses the wire") + for _, key in ipairs({ "secretBases", "tvShows", "pokeNews", "oldMan", "dewfordTrends", "daycareMail", + "battleTower", "giftItem", "lilycoveLady", "apprentices", "hallRecords" }) do + check(applied[1][key] and applied[2][key], "both sides ran the " .. key .. " receiver") + end + + eq(OldMan.current(A), OldMan.GIDDY, "MAY now has BRENDAN's Giddy") + eq(A.oldMan.randomWords[1], 102, "with BRENDAN's tale words") + eq(OldMan.current(B), OldMan.STORYTELLER, "BRENDAN now has MAY's storyteller") + eq(B.oldMan.trainerNames[1], "MAY", "with MAY's story") + eq(B.oldMan.alreadyRecorded, false, "ResetMauvilleOldManFlag ran") + + eq(Lady.id(A), Lady.FAVOR, "MAY now has the favor lady") + eq(A.lilycoveLady.favor.playerName, "BRENDAN", "carrying BRENDAN's data") + eq(A.lilycoveLady.favor.state, Lady.STATE_READY, "ResetLilycoveLadyForRecordMix ran") + eq(Lady.id(B), Lady.QUIZ, "BRENDAN now has the quiz lady") + eq(B.lilycoveLady.quiz.playerName, "MAY", "carrying MAY's quiz") + + local bb = hasBase(A, 21) + check(bb ~= nil, "MAY registered BRENDAN's secret base") + eq(bb and bb.trainerName, "BRENDAN", "owned by BRENDAN") + eq(bb and bb.registryStatus, SB.UNREGISTERED, "NEW is cleared back to UNREGISTERED") + check(hasBase(B, 11) ~= nil, "BRENDAN registered MAY's secret base") + eq(SB.base(A, 0).numSecretBasesReceived, 1, "numSecretBasesReceived counts the mix") + + check(tvHasCaught(A), "BRENDAN's Pokemon Today show airs on MAY's TV") + + eq(Daycare.stateOf(A).mail[1], nil, "MAY's daycare mail swapped away") + local bm = Daycare.stateOf(B).mail[1] + eq(bm and bm.message.itemId, ORANGE_MAIL, "BRENDAN's daycare SEEDOT now carries MAY's mail") + eq(bm and bm.otName, "MAY", "with MAY as the writer") + + local tr = towerRecordNamed(A, "BRENDAN") + eq(tr and tr.winStreak, 40, "BRENDAN's tower record joined MAY's towerRecords") + eq(towerRecordNamed(B, "MAY") and towerRecordNamed(B, "MAY").winStreak, 20, "and MAY's joined BRENDAN's") + + check(Bag.has(B.bag, EON_TICKET, 1), "BRENDAN received MAY's EON TICKET") + eq(giftVar(B), EON_TICKET, "VAR_TEMP_RECORD_MIX_GIFT_ITEM holds it") + use(B) + check(Rse.flag("FLAG_ENABLE_SHIP_SOUTHERN_ISLAND", B), "FLAG_ENABLE_SHIP_SOUTHERN_ISLAND is set") + check(not Bag.has(A.bag, EON_TICKET, 1), "the sender keeps no copy") + eq(Gift.state(A).quantity, 0, "the one-use gift is spent") + + local ap = apprenticeNamed(A, "BRENDAN") + check(ap ~= nil, "MAY saved BRENDAN's apprentice") + eq(A.playerApprentice.saveId, 1, "playerApprentice.saveId advanced") + check(apprenticeNamed(B, "MAY") ~= nil, "BRENDAN saved MAY's apprentice") + + local h = A.hallRecords1P[1][1][1] + eq(h.name, "BRENDAN", "MAY's Ranking Hall shows BRENDAN") + eq(h.winStreak, 40, "with his tower singles streak") + eq(B.hallRecords1P[1][1][1].name, "MAY", "BRENDAN's Ranking Hall shows MAY") + eq(A.hallRecords2P[1][1].name1 .. "/" .. A.hallRecords2P[1][1].winStreak, "BRENDAN/10", "link multi hall row filled") + + print("[test] 3. the mixed records survive save and reload") + local A2, B2 = reload(A), reload(B) + eq(A2.oldMan and A2.oldMan.id, OldMan.GIDDY, "old man") + eq(A2.lilycoveLady and A2.lilycoveLady.favor and A2.lilycoveLady.favor.playerName, "BRENDAN", "Lilycove lady") + check(hasBase(A2, 21) ~= nil, "secret base registry") + check(tvHasCaught(A2), "TV show") + local bm2 = Daycare.stateOf(B2).mail[1] + eq(bm2 and bm2.message.itemId, ORANGE_MAIL, "daycare mail") + eq(towerRecordNamed(A2, "BRENDAN") and towerRecordNamed(A2, "BRENDAN").winStreak, 40, "tower record") + check(Bag.has(B2.bag, EON_TICKET, 1), "gift item") + check(apprenticeNamed(A2, "BRENDAN") ~= nil, "apprentice") + eq(A2.playerApprentice and A2.playerApprentice.saveId, 1, "apprentice save id") + eq(A2.hallRecords1P[1][1][1].name, "BRENDAN", "ranking hall") + local g2 = reload(A) + eq(g2.recordMixingGift and g2.recordMixingGift.itemId, 0, "record mixing gift block") +end + +print("[test] 4. three players follow pret's partner table") +do + local A = makePlayer(1, "MAY", 1116, 1) + local B = makePlayer(2, "BRENDAN", 2228, 0) + local C = makePlayer(3, "CHRIS", 3340, 0) + giveShow(C) + local mail = { message = { itemId = ORANGE_MAIL, words = { 9, 9, 9, 9, 9, 9, 9, 9, 9 }, playerName = "MAY", + trainerId = 1116, species = SEEDOT }, otName = "MAY", monName = "SEEDOT" } + Daycare.stateOf(A).mail[1] = mail + Gift.set(1, 2, EON_TICKET, A) + eq(OldMan.current(C), OldMan.BARD, "CHRIS has the bard") + eq(Lady.id(C), Lady.CONTEST, "CHRIS has the contest lady") + local order = MixUtil.shuffle({ { linkTrainerId = MixUtil.sessionLinkTrainerId(A) }, {}, {} }) + eq(order[1] .. order[2] .. order[3], "231", "MAY's even id picks {1, 2, 0}") + local _, packets = mix({ A, B, C }) + local randSum = DaycareMail.randSum(packets[1].tvShows) + + eq(OldMan.current(A), OldMan.GIDDY, "MAY <- BRENDAN's Giddy") + eq(OldMan.current(B), OldMan.BARD, "BRENDAN <- CHRIS's bard") + eq(B.oldMan.playerName, "CHRIS", "the bard keeps CHRIS's song credit") + eq(OldMan.current(C), OldMan.STORYTELLER, "CHRIS <- MAY's storyteller") + eq(Lady.id(A), Lady.FAVOR, "MAY <- favor lady") + eq(Lady.id(B), Lady.CONTEST, "BRENDAN <- contest lady") + eq(B.lilycoveLady.contest.playerName, "CHRIS", "with CHRIS's contest data") + eq(Lady.id(C), Lady.QUIZ, "CHRIS <- quiz lady") + eq(C.lilycoveLady.quiz.playerName, "MAY", "with MAY's quiz") + + check(hasBase(A, 21) and hasBase(A, 31), "MAY registered both partners' bases") + check(hasBase(B, 11) and hasBase(B, 31), "BRENDAN registered both partners' bases") + check(hasBase(C, 11) and hasBase(C, 21), "CHRIS registered both partners' bases") + + check((tvHasCaught(A) and 1 or 0) + (tvHasCaught(B) and 1 or 0) == 1, "CHRIS's show airs on exactly one partner's TV (tv.c:3449)") + + local swap = ({ { 0, 1 }, { 1, 2 }, { 2, 0 } })[randSum % 3 + 1] + local holder = (swap[1] == 0 and swap[2]) or (swap[2] == 0 and swap[1]) or 0 + local holders = {} + for i, s in ipairs({ A, B, C }) do + local m = Daycare.stateOf(s).mail[1] + if m and m.message.itemId == ORANGE_MAIL then holders[#holders + 1] = i - 1 end + end + eq(#holders, 1, "MAY's mail exists exactly once after the swap") + eq(holders[1], holder, "sDaycareMailSwapIds_3Player[" .. (randSum % 3) .. "] decides who holds it") + + eq(towerRecordNamed(A, "BRENDAN") and towerRecordNamed(A, "BRENDAN").winStreak, 40, "MAY <- BRENDAN's tower record") + eq(towerRecordNamed(B, "CHRIS") and towerRecordNamed(B, "CHRIS").winStreak, 60, "BRENDAN <- CHRIS's tower record") + eq(towerRecordNamed(C, "MAY") and towerRecordNamed(C, "MAY").winStreak, 20, "CHRIS <- MAY's tower record") + check(towerRecordNamed(A, "CHRIS") == nil, "MAY does not take CHRIS's tower record") + + check(Bag.has(B.bag, EON_TICKET, 1) and Bag.has(C.bag, EON_TICKET, 1), "both partners get the leader's gift") + + check(apprenticeNamed(A, "BRENDAN") ~= nil, "MAY <- BRENDAN's apprentice") + check(apprenticeNamed(B, "CHRIS") ~= nil, "BRENDAN <- CHRIS's apprentice") + check(apprenticeNamed(C, "MAY") ~= nil, "CHRIS <- MAY's apprentice") + + local hall = A.hallRecords1P[1][1] + eq(hall[1].name .. "/" .. hall[1].winStreak, "CHRIS/60", "MAY's hall ranks CHRIS first") + eq(hall[2].name .. "/" .. hall[2].winStreak, "BRENDAN/40", "then BRENDAN") + local hallC = C.hallRecords1P[1][1] + eq(hallC[1].name .. "/" .. hallC[2].name, "BRENDAN/MAY", "CHRIS ranks BRENDAN over MAY") + + local C2 = reload(C) + eq(C2.oldMan.id, OldMan.STORYTELLER, "CHRIS's old man survives reload") + eq(C2.hallRecords1P[1][1][1].name, "BRENDAN", "and his hall records") + check(hasBase(C2, 11) and hasBase(C2, 21), "and both bases") +end + +T.finish("emerald_record_mix")