From b1487731693a44838b80293e8f1bbc536e547fac Mon Sep 17 00:00:00 2001 From: bryanthaboi Date: Wed, 7 Oct 2026 17:02:29 -0400 Subject: [PATCH] more --- docs/updater.md | 2 +- src/box/Migration.lua | 99 +++++-- src/import/BoxPanel.lua | 1 + src/import/BoxTools.lua | 69 ++++- src/import/BoxUI.lua | 22 +- src/import/OnlinePanel.lua | 108 +++++++- src/import/online/TradeScreen.lua | 12 + src/online/Convert.lua | 39 ++- src/online/union/BattlePrepModel.lua | 48 +++- src/online/union/TradePrepModel.lua | 48 ++++ src/online/xgen/Rentals.lua | 143 +++++++++- src/recommend/Recommend.lua | 175 ++++++++++++ src/ui/union/prep/OpenTrade.lua | 73 ++++- src/update/Check.lua | 2 + tests/engine/box_migration_recommend_test.lua | 136 ++++++++++ tests/engine/recommend_client_test.lua | 84 ++++++ .../save_editor_recommended_moveset.lua | 211 +++++++++++++++ tests/engine/time_capsule_recommend_test.lua | 203 ++++++++++++++ tests/engine/union_rental_recommend_test.lua | 192 +++++++++++++ tests/engine/union_trade_recommend_test.lua | 180 ++++++++++++ tests/engine/update_check_tests.lua | 2 + tests/support/union_prep_driver.lua | 3 + tools/build_pokestats.py | 208 ++++++++++++++ tools/pokestats/.gitignore | 1 + tools/pokestats/package-lock.json | 53 ++++ tools/pokestats/package.json | 9 + tools/pokestats/requirements.txt | 1 + tools/pokestats/validate.cjs | 34 +++ tools/pokestats_bundle.py | 256 ++++++++++++++++++ tools/save-editor/App.lua | 1 + tools/save-editor/InspectorBody.lua | 9 +- tools/save-editor/Ops.lua | 116 ++++++++ tools/test_pokestats.py | 138 ++++++++++ tools/test_pokestats_bundle.py | 147 ++++++++++ 34 files changed, 2773 insertions(+), 52 deletions(-) create mode 100644 src/recommend/Recommend.lua create mode 100644 tests/engine/box_migration_recommend_test.lua create mode 100644 tests/engine/recommend_client_test.lua create mode 100644 tests/engine/save_editor_recommended_moveset.lua create mode 100644 tests/engine/time_capsule_recommend_test.lua create mode 100644 tests/engine/union_rental_recommend_test.lua create mode 100644 tests/engine/union_trade_recommend_test.lua create mode 100644 tools/build_pokestats.py create mode 100644 tools/pokestats/.gitignore create mode 100644 tools/pokestats/package-lock.json create mode 100644 tools/pokestats/package.json create mode 100644 tools/pokestats/requirements.txt create mode 100644 tools/pokestats/validate.cjs create mode 100644 tools/pokestats_bundle.py create mode 100644 tools/test_pokestats.py create mode 100644 tools/test_pokestats_bundle.py diff --git a/docs/updater.md b/docs/updater.md index 15beb16f..aa75f383 100644 --- a/docs/updater.md +++ b/docs/updater.md @@ -76,7 +76,7 @@ mounted or deleted as stale; the launcher directs the player to a full package. Each tagged release `vX.Y.Z` carries the existing per-platform archives (`gen1recomp-X.Y.Z-macos.zip`, `-windows.zip`, `-linux-x86_64.AppImage`, `-linux-arm64.AppImage`, `-linux.flatpak`, -`-android.apk`, `-ios.ipa`, `-switch.zip`, Xbox and +`-android.apk`, `-ios.ipa`, `-switch.zip`, `-ps4.pkg`, Xbox and PortMaster archives) plus two assets the updater itself consumes: - `gen1recomp-X.Y.Z.love` - the payload, matched by the exact pattern diff --git a/src/box/Migration.lua b/src/box/Migration.lua index b87f6ed5..1b3723ef 100644 --- a/src/box/Migration.lua +++ b/src/box/Migration.lua @@ -136,7 +136,7 @@ local function expAt(data,species,level) end return require("src.pokemon.Growth").expForLevel(data.pokemon[species].growthRate,level) end -function Migration.convert(entry,version) +function Migration.convert(entry,version,opts) local from,to=Catalog.get(entry.version),Catalog.get(version) local g1,g2=entry.generation,GameVersion.generation(version) if not g2 or g1==g2 then return nil,"Choose a different generation; same-generation Pokémon can be withdrawn directly." end @@ -171,23 +171,39 @@ function Migration.convert(entry,version) if not targetItem then return nil,"The destination cannot represent this held item." end end end - local moves,pps,ups={},{},{} - for _,move in ipairs(mon.moves or {}) do - local key=type(move)=="table" and (move.moveId or move.id or move.move or move.name) or move - if key and key~=0 then - if not from.moves[key] then return nil,"A move is unknown in the source game." end - local id=mapped(to.moves,label(from.moves[key],key)) - local row=id and (g2==3 and to.battleMoves[id] or to.moves[id]) - if not row or not row.pp then return nil,"The destination cannot represent move "..label(from.moves[key],key).."." end - local detail=d.moveDetails[#pps+1] - local bonus=detail and detail.ppUps or 0 - local max=row.pp+bonus*(g2==3 and math.floor(row.pp/5) or math.min(7,math.floor(row.pp/5))) - local pp=clamp(detail and detail.pp or max,0,max) - moves[#moves+1]=g2==3 and id or {id=id,pp=pp,ppUps=bonus,maxPp=max} - pps[#pps+1],ups[#ups+1]=pp,bonus + local moves,pps,ups,missing={},{},{},{} + local override=opts and opts.moves + if override~=nil then + if type(override)~="table" or #override==0 then return nil,"The replacement moveset is empty." end + local seen={} + for _,id in ipairs(override) do + local row=g2==3 and to.battleMoves[id] or g2<3 and to.moves[id] + if seen[id] or not row or not row.pp then return nil,"The replacement moveset does not fit the destination." end + seen[id]=true + moves[#moves+1]=g2==3 and id or {id=id,pp=row.pp,ppUps=0,maxPp=row.pp} + pps[#pps+1],ups[#ups+1]=row.pp,0 + end + else + for _,move in ipairs(mon.moves or {}) do + local key=type(move)=="table" and (move.moveId or move.id or move.move or move.name) or move + if key and key~=0 then + if not from.moves[key] then return nil,"A move is unknown in the source game." end + local id=mapped(to.moves,label(from.moves[key],key)) + local row=id and (g2==3 and to.battleMoves[id] or to.moves[id]) + if not row or not row.pp then + missing[#missing+1]=tostring(label(from.moves[key],key)) + else + local detail=d.moveDetails[#pps+#missing+1] + local bonus=detail and detail.ppUps or 0 + local max=row.pp+bonus*(g2==3 and math.floor(row.pp/5) or math.min(7,math.floor(row.pp/5))) + local pp=clamp(detail and detail.pp or max,0,max) + moves[#moves+1]=g2==3 and id or {id=id,pp=pp,ppUps=bonus,maxPp=max} + pps[#pps+1],ups[#ups+1]=pp,bonus + end + end end end - if #moves>4 then return nil,"The destination can represent at most four moves." end + if #moves+#missing>4 then return nil,"The destination can represent at most four moves." end local out={species=species,nickname=nickname,ot=ot,otName=ot,otId=clamp(mon.otId,0,65535), level=clamp(d.level,1,100),moves=moves,isEgg=mon.isEgg or nil,item=targetItem,traded=mon.traded, pokerus=g2>=2 and clamp(mon.pokerus,0,255) or nil} @@ -315,7 +331,18 @@ function Migration.convert(entry,version) fact("EXP",oldExp and math.floor(oldExp)~=experience and (math.floor(oldExp).." → "..experience) or experience,oldExp and math.floor(oldExp)~=experience and "new" or "kept") if targetItem and g2>1 and g1==3 or targetItem and g1==2 and g2==3 then fact("Held item",label(to.items[targetItem],targetItem),"kept") end fact("HP","Restored to full","new") - note("Moves and held item mapped by exact name; current PP is capped to destination maximum, PP Ups retained.") + if #missing>0 then + return nil,"The destination cannot represent move "..missing[1]..".",{moveBlock=true,missing=missing, + species=tostring(g2==3 and to.names and to.names[species] or def.name or species)} + end + if override then + local names={} + for _,id in ipairs(override) do names[#names+1]=tostring(label(to.moves[id],id)) end + fact("Moves",table.concat(names,", "),"new") + note("Moves replaced with the recommended set at full PP, no PP Ups. Held item mapped by exact name.") + else + note("Moves and held item mapped by exact name; current PP is capped to destination maximum, PP Ups retained.") + end if g2==1 then note("Friendship, Pokérus, gender and met data remain only in the archive.") end return out,lines end @@ -326,26 +353,52 @@ function Migration.natureSteps(exp) table.sort(rows,function(a,b) return a.add0 then + moves[row.id]={} + for i=1,math.min(4,#keys) do moves[row.id][i]=keys[i] end + else failed[#failed+1]=row.name end + end + return moves,failed +end +function Migration.apply(state,refs,version,preview,opts) if type(preview)~="table" or preview.revision~=state.revision or preview.destination~=version then return nil,"Storage changed. Preview the migration again." end - local fresh,why=Migration.preview(state,refs,version) + local fresh,why=Migration.preview(state,refs,version,{moves=opts and opts.moves or preview.moves}) if not fresh then return nil,why end if not fresh.allowed then return nil,"The destination cannot represent every selected Pokémon." end if Serializer.encode(preview)~=Serializer.encode(fresh) then return nil,"The preview changed. Review it again." end diff --git a/src/import/BoxPanel.lua b/src/import/BoxPanel.lua index f1eeb061..5204d1c6 100644 --- a/src/import/BoxPanel.lua +++ b/src/import/BoxPanel.lua @@ -98,6 +98,7 @@ function BoxPanel.update(imp, dt) if not s then return end checkDisk(s, dt) s.artTime = (s.artTime or 0) + math.max(0, dt or 0) + if s.migrationFetch then require("src.import.BoxTools").update(s) end if s.animationEntry ~= s.inspect or s.animation and s.animation.released then animateInspection(s) end Animation.update(s.animation, dt) local Showcase = require("src.box.Showcase") diff --git a/src/import/BoxTools.lua b/src/import/BoxTools.lua index f85711ba..f1a9a8c6 100644 --- a/src/import/BoxTools.lua +++ b/src/import/BoxTools.lua @@ -20,6 +20,62 @@ Tools.CONTROL_ICONS = { ["stage-slot"] = "save", ["stage-background"] = "paintbr local ACTION_ICONS = { ["migration-preview"] = "eye", ["exports-folder"] = "folder", arrange = "arrow-up-down", ["apply-marks"] = "check", compare = "arrow-left-right", ["stage-wallpaper"] = "paintbrush", ["stage-flip"] = "arrow-left-right" } +local FETCH_ERRORS = { offline = "Couldn't reach the server. Moves unchanged.", + server = "The server has no movesets right now. Moves unchanged.", too_many = "Too many Pokémon at once." } + +function Tools.fetchRecommended(s, refs) + local Migration = require("src.box.Migration") + local preview = s.migrationPreview + local rows = Migration.moveBlocked(preview) + if #rows == 0 then return false end + local names = {} + for _, row in ipairs(rows) do names[#names + 1] = row.species end + local generation = require("src.core.GameVersion").generation(preview.destination) + s.migrationFetch = { preview = preview, refs = refs, + job = require("src.recommend.Recommend").request(generation, names) } + s.notice, s.noticeKind = "Getting recommended moves…", "info" + return true +end + +function Tools.askRecommended(imp, s, refs) + local rows = require("src.box.Migration").moveBlocked(s.migrationPreview) + if #rows == 0 or s.migrationFetch then return false end + local game = require("src.core.GameVersion").info(s.migrationPreview.destination).label + UI.confirm(imp, "Replace moves?", "Some moves don't exist in " .. game .. ". Replace them with the recommended moveset" + .. (#rows > 1 and "s" or "") .. "?", "Replace", function() Tools.fetchRecommended(s, refs) end) + return true +end + +function Tools.update(s) + local fetch = s and s.migrationFetch + if not fetch then return end + local Recommend = require("src.recommend.Recommend") + local status, reason = Recommend.poll(fetch.job) + if status == "pending" then return end + s.migrationFetch = nil + if fetch.preview ~= s.migrationPreview then return end + if status ~= "ok" then + s.notice, s.noticeKind = FETCH_ERRORS[reason] or "Couldn't get recommended moves.", "error" + return + end + local Migration = require("src.box.Migration") + local version = fetch.preview.destination + local moves, failed = Migration.recommendedMoves(fetch.preview, fetch.job, version) + if not s.migrationMoves or s.migrationMoves.version ~= version then s.migrationMoves = { version = version, moves = {} } end + local any = false + for id, keys in pairs(moves) do s.migrationMoves.moves[id], any = keys, true end + if any then + local fresh, why = Migration.preview(s.service.state, fetch.refs, version, { moves = s.migrationMoves.moves }) + s.migrationPreview = fresh + if not fresh then s.notice, s.noticeKind = why, "error"; return end + end + if #failed > 0 then + s.notice, s.noticeKind = "No recommended moveset for " .. table.concat(failed, ", ") .. ".", "error" + else + s.notice, s.noticeKind = "Recommended moves set. Check them before converting.", "ok" + end +end + local function search(s, query, sort) local cache = s._toolsSearch if not cache or cache.state ~= s.service.state or cache.size >= 8 then @@ -252,13 +308,20 @@ function Tools.draw(imp, s, x, y, w, m, api) if #versions>0 then s.migrationVersion=s.migrationVersion or versions[1] cycle("migration-destination","Destination",versions,s.migrationVersion,function(value) - s.migrationVersion,s.migrationPreview=value,nil + s.migrationVersion,s.migrationPreview,s.migrationMoves=value,nil,nil end) button("migration-preview","Review "..#refs.." selected",function() - local preview,why=Migration.preview(s.service.state,refs,s.migrationVersion) + local saved=s.migrationMoves and s.migrationMoves.version==s.migrationVersion and s.migrationMoves.moves or nil + local preview,why=Migration.preview(s.service.state,refs,s.migrationVersion,{moves=saved}) s.migrationPreview=preview;if not preview then s.notice,s.noticeKind=why,"error" end + if preview then Tools.askRecommended(imp,s,refs) end end,#refs==0);nextRow() local preview=s.migrationPreview + if preview and #Migration.moveBlocked(preview)>0 then + button("migration-recommend",s.migrationFetch and "Getting recommended moves…" or "Use recommended moves",function() + Tools.askRecommended(imp,s,refs) + end,s.migrationFetch~=nil,nil,nil,false,"award");nextRow() + end if preview then local options, ready = {}, 0 for i, row in ipairs(preview.rows) do @@ -284,7 +347,7 @@ function Tools.draw(imp, s, x, y, w, m, api) end button("migration-apply","Convert selected",function() operation(function() return s.service:migrate(refs,s.migrationVersion,preview) end,"Migration saved with archived originals.") - s.migrationPreview=nil + s.migrationPreview,s.migrationMoves=nil,nil end,not preview.allowed or preview.revision~=s.service.state.revision or s.diskStale);nextRow() end else text("Import both games first.",true) end diff --git a/src/import/BoxUI.lua b/src/import/BoxUI.lua index f7531f66..0225a32d 100644 --- a/src/import/BoxUI.lua +++ b/src/import/BoxUI.lua @@ -84,6 +84,10 @@ function UI.open(imp, title, options, current, apply) end end end +function UI.confirm(imp, title, message, label, action) + UI.open(imp, title, {}, nil) + imp._boxPopup.message, imp._boxPopup.confirm = message, { label = label, action = action } +end function UI.help(imp, key, x, y, size, message) UI.button(imp, x, y, size, size, "help-" .. key, "", function() UI.open(imp, HELP_TITLES[key] or key, {}, nil) @@ -146,7 +150,8 @@ function UI.keypressed(imp, key) if not p then return false end if key == "escape" then UI.close(imp) elseif key == "return" or key == "kpenter" then - if p.message then UI.close(imp) else choose(imp, p, filtered(p)[p.index]) end + if p.confirm then UI.close(imp); p.confirm.action() + elseif p.message then UI.close(imp) else choose(imp, p, filtered(p)[p.index]) end elseif not p.message and (key == "up" or key == "down" or key == "home" or key == "end") then local rows = filtered(p) if key == "home" then p.index = 1 elseif key == "end" then p.index = #rows @@ -168,7 +173,7 @@ function UI.drawPopup(imp, m) local width = math.min((p.message and 390 or 460) * m.s, m.W - 2 * m.pad) local searchable = not p.message and #p.options > 8 local inner = width - 2 * pad - local content = p.message and Kit.wrapHeight("small", Strings(p.message), inner, 6) + local content = p.message and Kit.wrapHeight("small", Strings(p.message), inner, 6) + (p.confirm and gap + row or 0) or math.max(row, math.min(7, #p.options) * (row + gap) - gap) local x, y, w, h = view().modalPanel(m, width, 2 * pad + row + gap + content + (searchable and row + gap or 0), { scrim = .76, slide = true }) if Kit.press(0, 0, m.W, m.H) and not Kit.hit(x, y, w, h) then UI.close(imp); return end @@ -177,7 +182,18 @@ function UI.drawPopup(imp, m) Kit.textBold("button", Strings(Kit.ellipsize("button", p.title, w - 2 * pad - row - gap)), x + pad, y + pad + (row - Kit.textHeight("button")) / 2, PAL.heading) local bx, by, bw = x + pad, y + pad + row + gap, w - 2 * pad - if p.message then Kit.textWrapped("small", Strings(p.message), bx, by, bw, PAL.text, 6); return end + if p.message then + Kit.textWrapped("small", Strings(p.message), bx, by, bw, PAL.text, 6) + if p.confirm then + local cw, cy = (bw - gap) / 2, y + h - pad - row + UI.button(imp, bx, cy, cw, row, "popup-cancel", "Cancel", function() UI.close(imp) end, { icon = "x" }) + UI.button(imp, bx + cw + gap, cy, cw, row, "popup-confirm", p.confirm.label, function() + if imp._boxPopup ~= p then return end + UI.close(imp); p.confirm.action() + end, { face = "invert", icon = "check", ring = true }) + end + return + end if searchable then local query = Kit.textfield("box-chooser-filter", bx, by, bw, row, p.query, Strings("Find an option")) if query ~= p.query then p.query, p.index, p.scroll, p.reveal = query, 1, 0, true end diff --git a/src/import/OnlinePanel.lua b/src/import/OnlinePanel.lua index a94315b7..d8daf5fa 100644 --- a/src/import/OnlinePanel.lua +++ b/src/import/OnlinePanel.lua @@ -1135,6 +1135,7 @@ function OnlinePanel.tradeSetSide(imp, side, entry) tr.sides[side] = entry tr.picks[side] = nil tr.plan, tr.lines, tr.convertLines = nil, nil, nil + OnlinePanel.tradeRecommendReset(imp) tr.handles[side] = nil if entry then OnlinePanel.tradeOpen(imp, side) end return true @@ -1170,6 +1171,7 @@ function OnlinePanel.tradePick(imp, side, ref) tr.picks[side] = ref end tr.plan, tr.lines, tr.convertLines = nil, nil, nil + OnlinePanel.tradeRecommendReset(imp) return tr.picks[side] end @@ -1243,6 +1245,7 @@ function OnlinePanel.tradeMode(imp, mode) tr.mode = mode tr.plan, tr.lines, tr.convertLines = nil, nil, nil tr.status, tr.statusOk = nil, false + OnlinePanel.tradeRecommendReset(imp) return true end @@ -1268,6 +1271,10 @@ function OnlinePanel.tradeRun(imp, fn) if g1.generation ~= 1 or g2.generation ~= 2 then return nil, Strings("Those two games can't trade.") end + local g2Key = OnlinePanel.tradeRecommendKey(imp) + local override = tr.recommendMoves + local opts = override and override.key == g2Key and { moves = override.moves } or nil + tr.moveRefusal = nil local gen2Data, err = Trade.withDataset(g2.version, function(d) return d end) if not gen2Data then return nil, tostring(err) end local out, why @@ -1277,7 +1284,7 @@ function OnlinePanel.tradeRun(imp, fn) local fromData = (fromGen == 1) and gen1Data or gen2Data local toData = (toGen == 1) and gen1Data or gen2Data local lines, legal = Convert.preview(packed, fromGen, toGen, fromData, - toData) + toData, toGen == 1 and opts or nil) tr.convertLines[#tr.convertLines + 1] = { toGen = toGen, lines = lines, ok = legal ~= false } if toGen == 2 then @@ -1285,8 +1292,11 @@ function OnlinePanel.tradeRun(imp, fn) if mon then return mon end return nil, report end - local mon, reason = Convert.toGen1(packed, gen2Data, gen1Data) + local mon, reason = Convert.toGen1(packed, gen2Data, gen1Data, opts) if mon then return mon end + if reason == "move_too_new" then + tr.moveRefusal = { key = g2Key, species = packed.species, version = g1.version } + end return nil, reason end) return true @@ -1365,6 +1375,13 @@ function OnlinePanel.tradePreview(imp) end end tr.status, tr.statusOk = said, false + tr.recommendAsk = nil + local refusal = tr.moveRefusal + if refusal and not tr.recommendJob and not (tr.recommendTried and tr.recommendTried == refusal.key) then + refusal.said = said + tr.recommendAsk = refusal + tr.status = Strings("Some moves can't come along. Replace them with the recommended moveset?") + end return false end tr.plan = plan @@ -1405,6 +1422,7 @@ function OnlinePanel.tradeConfirm(imp) end tr.plan, tr.lines, tr.convertLines = nil, nil, nil tr.picks = {} + OnlinePanel.tradeRecommendReset(imp) tr.handles = {} st.slotRead, st.converted, st.convertWant = nil, nil, nil OnlinePanel.tradeOpen(imp, "a") @@ -1421,6 +1439,91 @@ function OnlinePanel.tradeConfirm(imp) return true end +OnlinePanel.recommendClient = nil + +function OnlinePanel.tradeRecommendKey(imp) + local tr = OnlinePanel.tradeState(imp) + local a = OnlinePanel.tradeSideView(imp, "a") + local b = OnlinePanel.tradeSideView(imp, "b") + local A, B = a and a.handle, b and b.handle + if not (A and B) then return nil end + local side = (A.generation == 2) and "a" or (B.generation == 2) and "b" or nil + local key = side and OnlinePanel.tradePickKey(imp, side) + if not key then return nil end + local entry = tr.sides[side] or {} + return ("%s|%s|%s|%s"):format(side, tostring(entry.version), tostring(entry.slotId), key) +end + +function OnlinePanel.tradeRecommendReset(imp) + local tr = OnlinePanel.tradeState(imp) + if tr.recommendJob then + pcall(require("src.recommend.Recommend").cancel, tr.recommendJob.job) + end + tr.recommendAsk, tr.recommendJob, tr.recommendMoves, tr.recommendTried = nil, nil, nil, nil + tr.moveRefusal = nil +end + +local RECOMMEND_FAIL = { + offline = "Couldn't reach the server for a recommended moveset.", + server = "The server has no recommended moveset right now.", + canceled = "The recommended moveset request was cancelled.", +} + +local function recommendFailed(tr, ask, text) + tr.recommendJob = nil + tr.status, tr.statusOk = Strings("%s %s", Strings(text), tostring(ask.said or "")), false +end + +function OnlinePanel.tradeRecommendAnswer(imp, yes) + local tr = OnlinePanel.tradeState(imp) + local ask = tr.recommendAsk + if not ask then return false end + tr.recommendAsk = nil + tr.recommendTried = ask.key + if not yes then + tr.status, tr.statusOk = tostring(ask.said or tr.status or ""), false + return true + end + local Recommend = require("src.recommend.Recommend") + local job = Recommend.request(1, ask.species, { client = OnlinePanel.recommendClient }) + tr.recommendJob = { job = job, ask = ask } + tr.status, tr.statusOk = Strings("Getting the recommended moveset..."), true + OnlinePanel.pumpTradeRecommend(imp) + return true +end + +function OnlinePanel.pumpTradeRecommend(imp) + local tr = OnlinePanel.tradeState(imp) + local pending = tr.recommendJob + if not pending then return nil end + local Recommend = require("src.recommend.Recommend") + local state, why = Recommend.poll(pending.job) + if state == "pending" then return state end + local ask = pending.ask + if state ~= "ok" then + recommendFailed(tr, ask, RECOMMEND_FAIL[why] or RECOMMEND_FAIL.offline) + return state + end + local entry = Recommend.get(pending.job, ask.species) + if not entry then + recommendFailed(tr, ask, "There's no recommended moveset for that POKéMON.") + return "missing" + end + local ok, keys = pcall(Recommend.resolveMoves, entry, ask.version) + if not ok or type(keys) ~= "table" or #keys == 0 then + recommendFailed(tr, ask, "The recommended moveset doesn't fit this game.") + return "missing" + end + tr.recommendJob = nil + if OnlinePanel.tradeRecommendKey(imp) ~= ask.key then return "stale" end + tr.recommendMoves = { key = ask.key, moves = keys } + if OnlinePanel.tradeModalPreview(imp) then return "ok" end + if tr.status == nil or tr.statusOk then + recommendFailed(tr, ask, "The recommended moveset doesn't fit this game.") + end + return "refused" +end + -- ------- the preview / confirm modal function OnlinePanel.tradeChangeLines(plan, convertLines) @@ -4344,6 +4447,7 @@ function OnlinePanel.update(imp, dt) pcall(imp.pumpOnlineCartInstall, imp) end OnlinePanel.pumpRemoteTrade(imp, dt) + OnlinePanel.pumpTradeRecommend(imp) if OnlinePanel._tourClosed then st.status, st.statusOk = OnlinePanel._tourClosed, false OnlinePanel._tourClosed = nil diff --git a/src/import/online/TradeScreen.lua b/src/import/online/TradeScreen.lua index 22326373..7dab89e7 100644 --- a/src/import/online/TradeScreen.lua +++ b/src/import/online/TradeScreen.lua @@ -111,6 +111,18 @@ local function localTrade(imp, x, y, w, m) cy = cy + Kit.textWrapped("small", tostring(tr.status), x, cy, w, tr.statusOk and PAL.green or PAL.yellow, 3) + tiny end + if tr.recommendAsk then + local half = math.floor((w - gap) / 2) + LV().btn(imp, x, cy, half, rowH, "online-trade-recommend-yes", + Strings("Yes"), + { kind = "primary", font = "small", + action = function() OnlinePanel.tradeRecommendAnswer(imp, true) end }) + LV().btn(imp, x + half + gap, cy, w - half - gap, rowH, + "online-trade-recommend-no", Strings("No"), + { kind = "ghost", font = "small", + action = function() OnlinePanel.tradeRecommendAnswer(imp, false) end }) + cy = cy + rowH + tiny + end return cy - y end diff --git a/src/online/Convert.lua b/src/online/Convert.lua index bac4e8ae..883a7dbc 100644 --- a/src/online/Convert.lua +++ b/src/online/Convert.lua @@ -265,7 +265,27 @@ function Convert.refusalFor(mon, gen2Data, gen1Data) return nil end -function Convert.toGen1(mon, gen2Data, gen1Data) +function Convert.withMoves(mon, moves, gen1Data) + if type(mon) ~= "table" or type(moves) ~= "table" then return mon, false end + local list, seen = {}, {} + for _, id in ipairs(moves) do + if #list < 4 and not seen[id] and gen1Data and gen1Data.moves and gen1Data.moves[id] then + seen[id] = true + list[#list + 1] = { id = id, ppUps = 0 } + end + end + if #list == 0 then return mon, false end + local out = {} + for k, v in pairs(mon) do out[k] = v end + out.moves = list + return out, true +end + +function Convert.toGen1(mon, gen2Data, gen1Data, opts) + local replaced, before = false, mon + if type(opts) == "table" and opts.moves then + mon, replaced = Convert.withMoves(mon, opts.moves, gen1Data) + end local reason, info = Convert.refusalFor(mon, gen2Data, gen1Data) if reason then return nil, reason, info end @@ -321,6 +341,15 @@ function Convert.toGen1(mon, gen2Data, gen1Data) } end + if replaced then + local was = {} + for _, mv in ipairs(before.moves or {}) do was[#was + 1] = mv.id end + local now = {} + for _, mv in ipairs(moves) do now[#now + 1] = mv.id end + entry(report.changed, "moves", "MOVES REPLACED WITH THE RECOMMENDED SET", + { from = was, to = now }) + 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", @@ -416,9 +445,9 @@ local function previewLines(mon, report, reason, info) return lines, true end -local function convertOne(mon, toGen, fromData, toData) +local function convertOne(mon, toGen, fromData, toData, opts) if toGen == 1 then - local out, second, info = Convert.toGen1(mon, fromData, toData) + local out, second, info = Convert.toGen1(mon, fromData, toData, opts) if out then return out, second end return nil, second, info or {} end @@ -427,8 +456,8 @@ local function convertOne(mon, toGen, fromData, toData) 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) +function Convert.preview(mon, fromGen, toGen, fromData, toData, opts) + local out, second, info = convertOne(mon, toGen, fromData, toData, opts) if out then return previewLines(mon, second) end return previewLines(mon, nil, second, info) end diff --git a/src/online/union/BattlePrepModel.lua b/src/online/union/BattlePrepModel.lua index f1362f6b..8de168ed 100644 --- a/src/online/union/BattlePrepModel.lua +++ b/src/online/union/BattlePrepModel.lua @@ -6,6 +6,7 @@ local Project = require("src.online.xgen.Project") local Rentals = require("src.online.xgen.Rentals") local TradeConvert = require("src.online.xgen.TradeConvert") local Identity = require("src.online.xgen.Identity") +local Recommend = require("src.recommend.Recommend") local Model = {} Model.__index = Model @@ -56,6 +57,7 @@ local TEXT = { sub_level = "Level {level}.", rental_tag = "RENTAL", rental_info = "A rental Pokémon. It is only for this battle.", + rentals_loading = "Getting the rental Pokémon…", leave_out = "It stays out of this battle.", move_pick = "Choose a move for its place.", @@ -161,6 +163,8 @@ function Model.new(opts) self.gameplayMods = opts.gameplayMods == true self.rulesOverride = opts.rules self.ownedSource = opts.owned + self.recommendOpts = opts.recommend + self.rentalRev = 0 self.step = "rules" self.cursor = 1 self.view = nil @@ -248,16 +252,49 @@ function Model:reset() end function Model:rebuildRules() + self:cancelRentals() self.unsupported = {} self.rentalSet = { rentals = {}, excluded = {} } + self.rentalRev = self.rentalRev + 1 local target = self:target() if not target or target.ruleset == "native" or type(self.data) ~= "table" then return end if self.data.generation == target.gen and self.data.generation <= 2 then local Table = require("src.battle.g3u.Table") for _, row in ipairs(Table.unsupportedMoves(self.data)) do self.unsupported[#self.unsupported + 1] = row.id end end + local ruleset = Policy.ruleset(target.ruleset) + local names = Rentals.speciesNames(target.ruleset, self.data) + if not ruleset or #names == 0 then return self:settleRentals(nil, "unknown_ruleset") end + self.rentalJob = Recommend.request(ruleset.gen, names, self.recommendOpts) + self.rentalSet.pending = true + self:pollRentals() +end + +function Model:settleRentals(sets, why) + local target = self:target() self.rentalSet = Rentals.build(target.ruleset, self.data, { unsupported = self.unsupported, - legacyPresent = target.legacyPresent }) + legacyPresent = target.legacyPresent, sets = sets }) + self.rentalSet.offline = why + self.rentalRev = self.rentalRev + 1 + self.memo, self.memoCount, self.pageMemo = nil, nil, nil +end + +function Model:pollRentals() + local job = self.rentalJob + if not job then return end + local status, result = Recommend.poll(job) + if status == "pending" then return end + self.rentalJob = nil + if status == "ok" then + self:settleRentals(result, nil) + else + self:settleRentals(nil, result or "offline") + end +end + +function Model:cancelRentals() + if self.rentalJob then Recommend.cancel(self.rentalJob) end + self.rentalJob = nil end function Model:entries() @@ -320,7 +357,7 @@ function Model:teamKey() end function Model:report(withSize) - local key = (withSize and "1" or "0") .. self:teamKey() .. self:prepKey() + local key = (withSize and "1" or "0") .. self.rentalRev .. self:teamKey() .. self:prepKey() self.memo = self.memo or {} local hit = self.memo[key] if hit then return hit.r, hit.list end @@ -619,6 +656,7 @@ function Model:enterConfirm(notice) end function Model:discard() + self:cancelRentals() self.team = {} self.owned = {} self.final = nil @@ -672,6 +710,7 @@ function Model:handlePrepEvent(e) end function Model:poll() + self:pollRentals() local p = self.prep if p then for _, e in ipairs(p:poll()) do self:handlePrepEvent(e) end @@ -855,6 +894,7 @@ function Model:pageSubstitute() local detail = copy(sb.detail or {}) detail.national = detail.national or (sb.view and sb.view.national) local lines = { Messages.text(sb.code or "bad_record", detail, names), say("sub_intro", { name = wantName }) } + if self.rentalSet.pending then lines[#lines + 1] = say("rentals_loading") end local owned, rentals = self:replacementOptions(e) local items = {} for _, row in ipairs(owned) do @@ -1003,7 +1043,7 @@ function Model:pageView() end end items[#items + 1] = item("back", TEXT.item_back) - return { title = TEXT.title_rentals, lines = {}, items = items } + return { title = TEXT.title_rentals, lines = self.rentalSet.pending and { say("rentals_loading") } or {}, items = items } elseif v.kind == "mon" then return { title = TEXT.title_mon, lines = v.lines, items = {}, pager = true } elseif v.kind == "size" then @@ -1027,7 +1067,7 @@ end function Model:page() local v = self.view local key = TradeConvert.canonical({ self.step, v and v.kind, v and v.lines and #v.lines, self.subIndex, - self.moveIndex, self.outcome }) .. self:teamKey() .. self:prepKey() + self.moveIndex, self.outcome, self.rentalRev }) .. self:teamKey() .. self:prepKey() local raw = self.pageMemo and self.pageMemo.key == key and self.pageMemo.page if not raw then raw = self:rawPage() diff --git a/src/online/union/TradePrepModel.lua b/src/online/union/TradePrepModel.lua index e8b63035..3874a686 100644 --- a/src/online/union/TradePrepModel.lua +++ b/src/online/union/TradePrepModel.lua @@ -268,6 +268,54 @@ function Model:moveOptions(slot) return opts and opts[tonumber(slot) or -1] or nil end +function Model:movesBlocked() + local m = self.mine + local opts = m and m.report and m.report.options and m.report.options.moves + return opts ~= nil and next(opts) ~= nil +end + +function Model:recommendTarget() + local m = self.mine + if not (m and self:movesBlocked()) then return nil end + local src = Model.datasetFor(self.version) + local dst, dstUsed = Model.datasetFor(self.peerVersion) + local view = src and dst and Project.read(m.rec, src) + local sp = view and dst.species[view.national] + if not sp then return nil end + return { generation = dst.generation, species = sp.name, version = dstUsed, national = view.national, + level = view.level, slots = math.min(#view.moves, Policy.MAX_MOVES) } +end + +function Model.recommendedMoves(entry, dst, dstVersion, national, level) + local keys = require("src.recommend.Recommend").resolveMoves(entry, dstVersion) + local legal = Datasets.learnSources(dst, national, level) + local out, dropped, seen = {}, {}, {} + for _, key in ipairs(keys) do + local id = dst.localToMove[key] + if id and dst.moves[id] and legal[id] and not seen[id] then + seen[id] = true + out[#out + 1] = id + else + dropped[#dropped + 1] = key + end + end + return out, dropped +end + +function Model:applyRecommended(entry, target) + local m = self.mine + target = target or self:recommendTarget() + if not (m and target) then return nil, "not_blocked" end + local dst = Model.datasetFor(target.version) + if not dst then return nil, "missing_import" end + local moves, dropped = Model.recommendedMoves(entry, dst, target.version, target.national, target.level) + if #moves == 0 then return nil, "none_legal", dropped end + for slot = 1, Policy.MAX_MOVES do + m.adjust.moves[slot] = slot <= target.slots and moves[slot] or nil + end + return self:refresh(), nil, dropped +end + function Model:stagedMoves() local out = {} if not self.mine then return out end diff --git a/src/online/xgen/Rentals.lua b/src/online/xgen/Rentals.lua index 5719393a..775aa09e 100644 --- a/src/online/xgen/Rentals.lua +++ b/src/online/xgen/Rentals.lua @@ -1,6 +1,8 @@ local Datasets = require("src.online.xgen.Datasets") +local Identity = require("src.online.xgen.Identity") local Policy = require("src.online.xgen.Policy") local Project = require("src.online.xgen.Project") +local Recommend = require("src.recommend.Recommend") local Rentals = {} @@ -9,6 +11,13 @@ Rentals.LEVEL = 50 Rentals.IV = 20 Rentals.PERSONALITY = 150 +-- src/battle_script_commands.c:8909 +Rentals.HIDDEN_POWER_TYPES = { "FIGHTING", "FLYING", "POISON", "GROUND", "ROCK", "BUG", "GHOST", "STEEL", + "FIRE", "WATER", "GRASS", "ELECTRIC", "PSYCHIC", "ICE", "DRAGON", "DARK" } + +-- src/battle_script_commands.c:8898 +local HP_BITS = { "hp", "atk", "def", "spe", "spa", "spd" } + Rentals.DEFINITIONS = { ["g3u-gen1"] = { { type = "NORMAL", species = 128, moves = { 70, 33, 59, 87 } }, @@ -78,6 +87,13 @@ local function hasType(types, want) return false end +function Rentals.moveCode(species, move, ruleset, data, unsupported) + if move > ruleset.moveMax or not data.moves[move] then return "move_not_in_ruleset" end + if unsupported and unsupported[move] then return "move_unsupported" end + if not Datasets.learnable(data, species, move, Rentals.LEVEL) then return "move_not_legal" end + return nil +end + function Rentals.validate(def, ruleset, data, unsupported) local sp = data.species[def.species] if def.species > ruleset.dexMax or not sp then return "species_not_in_ruleset" end @@ -86,18 +102,104 @@ function Rentals.validate(def, ruleset, data, unsupported) for _, move in ipairs(def.moves) do if seen[move] then return "rental_duplicate_move", { move = move } end seen[move] = true - if move > ruleset.moveMax or not data.moves[move] then return "move_not_in_ruleset", { move = move } end - if unsupported and unsupported[move] then return "move_unsupported", { move = move } end - if not Datasets.learnable(data, def.species, move, Rentals.LEVEL) then return "move_not_legal", { move = move } end + local code = Rentals.moveCode(def.species, move, ruleset, data, unsupported) + if code then return code, { move = move } end end if #def.moves < 1 or #def.moves > Policy.MAX_MOVES then return "rental_bad_moves" end return nil end +function Rentals.speciesNames(rulesetId, data) + local out = {} + for _, def in ipairs(Rentals.DEFINITIONS[rulesetId] or {}) do + local sp = type(data) == "table" and data.species[def.species] + if sp then out[#out + 1] = sp.name end + end + return out +end + +function Rentals.hiddenPowerType(ivs) + local bits = 0 + for i, k in ipairs(HP_BITS) do bits = bits + (math.floor(tonumber(ivs[k]) or 0) % 2) * 2 ^ (i - 1) end + return Rentals.HIDDEN_POWER_TYPES[math.floor(bits * 15 / 63) + 1] +end + +function Rentals.hiddenPowerIvs(want, from) + local ivs = {} + for _, k in ipairs(HP_BITS) do + local v = math.floor(tonumber(type(from) == "table" and from[k]) or 31) + ivs[k] = math.max(0, math.min(31, v)) + end + if Rentals.hiddenPowerType(ivs) == want then return ivs end + local best, bestCount + for bits = 0, 63 do + if Rentals.HIDDEN_POWER_TYPES[math.floor(bits * 15 / 63) + 1] == want then + local count = 0 + for i = 0, 5 do count = count + math.floor(bits / 2 ^ i) % 2 end + if not best or count > bestCount then best, bestCount = bits, count end + end + end + if not best then return nil end + for i, k in ipairs(HP_BITS) do ivs[k] = math.floor(best / 2 ^ (i - 1)) % 2 == 1 and 31 or 30 end + return ivs +end + +local function moveIndex(data) + local index = {} + for id, mv in pairs(data.moves) do + if mv.key then + if index[mv.key] == nil then index[mv.key] = id else index[mv.key] = false end + end + end + return index +end + +local function resolveMove(index, name) + local hp = Recommend.hiddenPowerType(name) + local token = hp and "HIDDENPOWER" or Identity.normalize(tostring(name or "")) + if token and Recommend.RENAMED[token] and index[Recommend.RENAMED[token]] then token = Recommend.RENAMED[token] end + return token and index[token] or nil, hp +end + +function Rentals.fromSet(def, entry, ruleset, data, unsupported, index) + local set = type(entry) == "table" and (entry.set or entry) or nil + if type(set) ~= "table" or type(set.moves) ~= "table" or not data.species[def.species] then return nil, {} end + index = index or moveIndex(data) + local moves, dropped, seen, hpType = {}, {}, {}, nil + for _, name in ipairs(set.moves) do + if type(name) == "table" then name = name[1] end + local id, hp = resolveMove(index, name) + local code + if not id then + code = "move_unknown" + elseif seen[id] then + code = "rental_duplicate_move" + elseif #moves >= Policy.MAX_MOVES then + code = "rental_bad_moves" + elseif hp and not Rentals.hiddenPowerIvs(hp, set.ivs) then + code = "hidden_power_type" + else + code = Rentals.moveCode(def.species, id, ruleset, data, unsupported) + end + if code then + dropped[#dropped + 1] = { move = tostring(name), id = id or nil, code = code } + else + seen[id] = true + moves[#moves + 1] = id + if hp then hpType = hp end + end + end + if #moves == 0 then return nil, dropped end + local out = { type = def.type, species = def.species, moves = moves } + if hpType then out.ivs = Rentals.hiddenPowerIvs(hpType, set.ivs) end + return out, dropped +end + function Rentals.build(rulesetId, data, opts) opts = opts or {} local ruleset = Policy.ruleset(rulesetId) - local out = { version = Rentals.VERSION, ruleset = rulesetId, rentals = {}, excluded = {} } + local out = { version = Rentals.VERSION, ruleset = rulesetId, rentals = {}, excluded = {}, dropped = {}, + padded = {}, defaulted = {} } local defs = Rentals.DEFINITIONS[rulesetId] if not ruleset or not defs then out.excluded[1] = { code = "unknown_ruleset", detail = { ruleset = rulesetId } } @@ -111,13 +213,40 @@ function Rentals.build(rulesetId, data, opts) for k, v in pairs(opts.unsupported or {}) do if v == true then unsupported[tonumber(k) or k] = true elseif tonumber(v) then unsupported[tonumber(v)] = true end end - for index, def in ipairs(defs) do + local sets = type(opts.sets) == "table" and opts.sets or nil + local byKey = sets and moveIndex(data) or nil + for index, base in ipairs(defs) do + local def, source = base, "default" + if sets then + local sp = data.species[base.species] + local entry = sp and sets[Recommend.key(sp.name)] + local made, dropped = Rentals.fromSet(base, entry, ruleset, data, unsupported, byKey) + for _, d in ipairs(dropped) do + d.index, d.type, d.species = index, base.type, base.species + out.dropped[#out.dropped + 1] = d + end + if made then + def, source = made, "recommended" + local have = {} + for _, m in ipairs(made.moves) do have[m] = true end + for _, m in ipairs(base.moves) do + if #made.moves < Policy.MAX_MOVES and not have[m] and not Rentals.moveCode(base.species, m, ruleset, data, unsupported) then + have[m] = true + made.moves[#made.moves + 1] = m + out.padded[#out.padded + 1] = { index = index, type = base.type, species = base.species, id = m } + end + end + else + out.defaulted[#out.defaulted + 1] = { index = index, type = base.type, species = base.species, + code = entry and "no_legal_moves" or "no_set" } + end + end local code, detail = Rentals.validate(def, ruleset, data, unsupported) if code then out.excluded[#out.excluded + 1] = { index = index, type = def.type, species = def.species, code = code, detail = detail } else local ivs, evs = {}, {} - for _, k in ipairs(Policy.STAT_ORDER) do ivs[k], evs[k] = Rentals.IV, 0 end + for _, k in ipairs(Policy.STAT_ORDER) do ivs[k], evs[k] = def.ivs and def.ivs[k] or Rentals.IV, 0 end local view = { gen = 3, national = def.species, level = Rentals.LEVEL, ivs = ivs, evs = evs, personality = Rentals.PERSONALITY, nature = Rentals.PERSONALITY % 25, otId = 0, otSecretId = 0, abilityNum = 0, item = 0, moves = {} } @@ -126,7 +255,7 @@ function Rentals.build(rulesetId, data, opts) record.nickname = data.species[def.species].name local disclosed = {} for _, move in ipairs(def.moves) do disclosed[#disclosed + 1] = Project.moveRecord(data, move, 0) end - out.rentals[#out.rentals + 1] = { rental = true, index = index, type = def.type, national = def.species, + out.rentals[#out.rentals + 1] = { rental = true, index = index, source = source, type = def.type, national = def.species, name = data.species[def.species].name, types = Project.copy(data.species[def.species].types), level = Rentals.LEVEL, record = record, moves = disclosed, stats = { hp = record.maxHp, atk = record.atk, def = record.def, spAtk = record.spAtk, diff --git a/src/recommend/Recommend.lua b/src/recommend/Recommend.lua new file mode 100644 index 00000000..25286e87 --- /dev/null +++ b/src/recommend/Recommend.lua @@ -0,0 +1,175 @@ +local Recommend = {} + +Recommend.PATH = "/recommend/gen" +Recommend.FETCH_SECONDS = 15 +Recommend.MAX_BATCH = 64 + +Recommend.RENAMED = { + FEINTATTACK = "FAINTATTACK", + HIGHJUMPKICK = "HIJUMPKICK", + SMELLINGSALTS = "SMELLINGSALT", +} + +local RENAMED = Recommend.RENAMED +local store = {} + +function Recommend.reset() + store = {} +end + +function Recommend.key(name) + local s = tostring(name or ""):gsub("\226\153\130", "m"):gsub("\226\153\128", "f") + return (s:lower():gsub("[^a-z0-9]", "")) +end + +local function moveToken(name) + return (tostring(name or ""):upper():gsub("[^A-Z0-9]", "")) +end + +local function bucket(generation) + store[generation] = store[generation] or {} + return store[generation] +end + +function Recommend.cached(generation, species) + local hit = bucket(generation)[Recommend.key(species)] + if hit == nil then return nil end + return hit or nil, hit == false +end + +local function remember(generation, keys, sets) + local b = bucket(generation) + for _, k in ipairs(keys) do + local entry = type(sets) == "table" and sets[k] or nil + if type(entry) == "table" and type(entry.set) == "table" and type(entry.set.moves) == "table" then + b[k] = entry + else + b[k] = false + end + end +end + +function Recommend.request(generation, species, opts) + opts = opts or {} + generation = tonumber(generation) + if generation ~= 1 and generation ~= 2 and generation ~= 3 then + return { status = "error", reason = "bad_generation" } + end + local list = type(species) == "table" and species or { species } + local keys, seen, out = {}, {}, {} + for _, name in ipairs(list) do + local k = Recommend.key(name) + if k ~= "" and not seen[k] then + seen[k] = true + local hit = bucket(generation)[k] + if hit == nil then + keys[#keys + 1] = k + elseif hit then + out[k] = hit + end + end + end + if #keys == 0 then + return { status = "ok", generation = generation, result = out } + end + if #keys > Recommend.MAX_BATCH then + return { status = "error", reason = "too_many" } + end + local client = opts.client + if not client then + local okS, SyncClient = pcall(require, "src.sync.SyncClient") + if not okS then return { status = "error", reason = "offline" } end + local okN, made = pcall(SyncClient.new, { transport = opts.transport }) + if not okN then return { status = "error", reason = "offline" } end + client = made + end + local okR, handle = pcall(client.send, client, "GET", Recommend.PATH .. generation, nil, { + noAuth = true, maxSeconds = Recommend.FETCH_SECONDS, + params = { species = table.concat(keys, ",") }, + }) + if not okR or handle == nil then + return { status = "error", reason = "offline" } + end + return { status = "pending", generation = generation, client = client, handle = handle, + keys = keys, result = out } +end + +function Recommend.poll(job) + if type(job) ~= "table" then return "error", "offline" end + if job.status == "ok" then return "ok", job.result end + if job.status == "error" then return "error", job.reason end + local ok, res = pcall(job.client.poll, job.client, job.handle) + if not ok or type(res) ~= "table" then res = { status = "error" } end + if res.status == "pending" then return "pending" end + pcall(job.client.release, job.client, job.handle) + job.handle = nil + if res.status ~= "ok" or type(res.data) ~= "table" or type(res.data.sets) ~= "table" then + job.status = "error" + job.reason = (tonumber(res.code) or 0) >= 400 and "server" or "offline" + return "error", job.reason + end + remember(job.generation, job.keys, res.data.sets) + for _, k in ipairs(job.keys) do + local hit = bucket(job.generation)[k] + if hit then job.result[k] = hit end + end + job.status = "ok" + return "ok", job.result +end + +function Recommend.cancel(job) + if type(job) ~= "table" or job.handle == nil then return end + local transport = job.client and job.client.transport + if transport and transport.cancel then pcall(transport.cancel, transport, job.handle) end + pcall(job.client.release, job.client, job.handle) + job.handle = nil + job.status, job.reason = "error", "canceled" +end + +function Recommend.get(job, species) + return type(job) == "table" and type(job.result) == "table" and job.result[Recommend.key(species)] or nil +end + +local function moveIndex(version) + local data = require("src.box.Catalog").get(version) + local index = {} + for key, value in pairs(data.moves or {}) do + local name = type(value) == "table" and (value.name or value.id) or value + local token = moveToken(name) + if token ~= "" and key ~= 0 then + if index[token] == nil then index[token] = key else index[token] = false end + end + end + return index, data +end + +function Recommend.hiddenPowerType(name) + local t = tostring(name or ""):match("^[Hh]idden [Pp]ower%s+(%a+)") + return t and t:upper() or nil +end + +function Recommend.resolveMoves(entry, version) + local set = type(entry) == "table" and (entry.set or entry) or {} + local index = moveIndex(version) + local keys, missing, seen = {}, {}, {} + local hpType + for _, name in ipairs(set.moves or {}) do + if type(name) == "table" then name = name[1] end + local token = moveToken(name) + local hp = Recommend.hiddenPowerType(name) + if hp then + token, hpType = "HIDDENPOWER", hp + end + token = RENAMED[token] and index[RENAMED[token]] ~= nil and RENAMED[token] or token + local key = index[token] + if key and not seen[key] then + seen[key] = true + keys[#keys + 1] = key + elseif not key then + missing[#missing + 1] = tostring(name) + end + end + return keys, missing, hpType +end + +return Recommend diff --git a/src/ui/union/prep/OpenTrade.lua b/src/ui/union/prep/OpenTrade.lua index e792bbc2..2cd32a5a 100644 --- a/src/ui/union/prep/OpenTrade.lua +++ b/src/ui/union/prep/OpenTrade.lua @@ -32,6 +32,13 @@ local TEXT = { changes = "Permanent changes:", blocked = "It can't be offered yet:", pick_move = "Choose a move {species} can learn in {game}, or remove the move.", + recommend_ask = "Some moves can't come along. Replace them with the recommended moveset?", + recommend_wait = "Getting the recommended moveset…", + recommend_offline = "Couldn't reach the server for a recommended moveset.", + recommend_server = "The server has no recommended moveset right now.", + recommend_missing = "There's no recommended moveset for {species}.", + recommend_none = "None of the recommended moves can come along.", + recommend_partial = "Some recommended moves can't come along. Fix the rest by hand.", move_row = "{type} Power {power} PP {pp}", evolves = "{species} may evolve when it arrives.", wait_offer = "Waiting for {name} to offer a Pokémon…", @@ -62,6 +69,7 @@ local TEXT = { item_offer = "Offer", item_moves = "Fix moves", + item_recommend = "Use recommended moveset", item_back = "Back", item_trade = "Trade", item_change = "Change", @@ -149,6 +157,8 @@ function Ctl.new(opts) self.done = false self.sentOffer = nil self.myData = Model.datasetFor(self.version) + self.recommendClient = opts.recommendClient + self.recommend = nil return self end @@ -271,12 +281,68 @@ function Ctl:pageOffer() else lines[#lines + 1] = say("blocked") for _, l in ipairs(self:blockLines(r, self.myData)) do lines[#lines + 1] = l end - if r.options and r.options.moves and next(r.options.moves) then items[#items + 1] = item("moves", TEXT.item_moves) end + local rec = self:recommendState() + if rec and rec.note then lines[#lines + 1] = rec.note end + if r.options and r.options.moves and next(r.options.moves) then + if rec and rec.state == "pending" then + lines[#lines + 1] = say("recommend_wait") + elseif not rec then + lines[#lines + 1] = say("recommend_ask") + items[#items + 1] = item("recommend", TEXT.item_recommend) + end + items[#items + 1] = item("moves", TEXT.item_moves) + end end items[#items + 1] = item("back", TEXT.item_back) return { title = TEXT.title_offer, lines = lines, items = items, pager = self.pager } end +function Ctl:recommendState() + local rec = self.recommend + if rec and rec.mine == self.model.mine then return rec end + return nil +end + +function Ctl:startRecommend() + if self:recommendState() then return false end + local target = self.model:recommendTarget() + if not target then return false end + local Recommend = require("src.recommend.Recommend") + self.recommend = { mine = self.model.mine, target = target, state = "pending", + job = Recommend.request(target.generation, target.species, { client = self.recommendClient }) } + self:pumpRecommend() + return true +end + +function Ctl:pumpRecommend() + local rec = self.recommend + if not (rec and rec.state == "pending") then return end + if rec.mine ~= self.model.mine then + require("src.recommend.Recommend").cancel(rec.job) + self.recommend = nil + return + end + local Recommend = require("src.recommend.Recommend") + local status, why = Recommend.poll(rec.job) + if status == "pending" then return end + rec.state = "done" + if status ~= "ok" then + rec.note = say(why == "server" and "recommend_server" or "recommend_offline") + return + end + local entry = Recommend.get(rec.job, rec.target.species) + if not entry then + rec.note = say("recommend_missing", { species = rec.target.species }) + return + end + local report, code = self.model:applyRecommended(entry, rec.target) + if not report then + rec.note = say(code == "none_legal" and "recommend_none" or "recommend_offline") + elseif not report.ok then + rec.note = say("recommend_partial") + end +end + function Ctl:moveSlot() local m = self.model.mine local opts = m and m.report and m.report.options and m.report.options.moves @@ -506,6 +572,7 @@ function Ctl:poll(dt) for _, e in ipairs(p:poll()) do self:handlePrep(e) end end for _, e in ipairs(self.txn:pump(dt or 0)) do self:handleTxn(e) end + self:pumpRecommend() if self.step ~= "closed" and self.step ~= "trading" and self.step ~= "done" and p then self:receivePeer() if (self.step == "wait") and self:canConfirm() then self:go("confirm") end @@ -535,6 +602,8 @@ function Ctl:choose(it) if self.step == "moves" then self:go("offer") else self:go("pick") end elseif id == "moves" then self:go("moves") + elseif id == "recommend" then + self:startRecommend() elseif id == "move" then self.model:stage(it.arg.slot, it.arg.move) self:go("offer") @@ -589,7 +658,7 @@ end function Open.controller(game, room, prep, opts) opts = opts or {} return Ctl.new({ game = game, prep = prep, version = opts.version, adapter = opts.adapter, roomId = opts.roomId, - opponent = opts.opponent or Open.opponent(room, prep) }) + opponent = opts.opponent or Open.opponent(room, prep), recommendClient = opts.recommendClient }) end local function isActivity(v) diff --git a/src/update/Check.lua b/src/update/Check.lua index 9d481e1a..2f2e958e 100644 --- a/src/update/Check.lua +++ b/src/update/Check.lua @@ -43,6 +43,8 @@ local function fullAssetName(version, osName, arch, port) return "gen1recomp-" .. version .. "-xbox-uwp.zip" elseif osName == "NX" then return "gen1recomp-" .. version .. "-switch.zip" + elseif osName == "PS4" then + return "gen1recomp-" .. version .. "-ps4.pkg" elseif osName == "Linux" and (port == "flatpak" or (type(os.getenv) == "function" and os.getenv("FLATPAK_ID"))) then return "gen1recomp-" .. version .. "-linux.flatpak" diff --git a/tests/engine/box_migration_recommend_test.lua b/tests/engine/box_migration_recommend_test.lua new file mode 100644 index 00000000..6a452d20 --- /dev/null +++ b/tests/engine/box_migration_recommend_test.lua @@ -0,0 +1,136 @@ +package.path="./?.lua;./?/init.lua;"..package.path +love=love or require("tests.love_stub") +local T=require("tests.harness") +local Migration=require("src.box.Migration") +local Store=require("src.box.Store") +local Catalog=require("src.box.Catalog") +local Serializer=require("src.core.SaveSerializer") +local CacheFs=require("src.import.CacheFs") +local GameVersion=require("src.core.GameVersion") +local Recommend=require("src.recommend.Recommend") +local Tools=require("src.import.BoxTools") +local UI=require("src.import.BoxUI") +local oldRead,tables=CacheFs.readAt,{} +local function put(v,p,value) tables[GameVersion.cachePrefix(v)..p]=Serializer.encode(value) end +for _,v in ipairs(GameVersion.ORDER) do + local gen=GameVersion.generation(v) + if gen==3 then + local root="data/generated/gba/pokemon/" + put(v,root.."names.lua",{[25]="PIKACHU",[277]="TREECKO"}) + put(v,root.."national.lua",{toNational={[25]=25,[277]=252},toSpecies={[25]=25,[252]=277}}) + put(v,root.."meta.lua",{[25]={genderRatio=127,growthRate=0},[277]={genderRatio=31,growthRate=0}}) + put(v,root.."stats.lua",{[25]={hp=35,atk=55,def=40,spe=90,spa=50,spd=50},[277]={hp=40,atk=45,def=35,spe=70,spa=65,spd=55}}) + put(v,root.."move_names.lua",{[84]="THUNDERSHOCK",[57]="SURF",[354]="PSYCHO BOOST"}) + put(v,root.."battle_moves.lua",{moves={[84]={pp=30},[57]={pp=15},[354]={pp=5}}}) + put(v,root.."abilities.lua",{[25]={9},[277]={65}}) + put(v,"data/generated/gba/items/pack.lua",{items={[68]={name="RARE CANDY"}}}) + else + put(v,"data/generated/pokemon.lua",{PIKACHU={id="PIKACHU",name="PIKACHU",dex=25,growthRate="MEDIUM_FAST",genderRatio=127,catchRate=190, + baseStats={hp=35,attack=55,defense=30,speed=90,special=50,specialAttack=50,specialDefense=40}}}) + put(v,"data/generated/moves.lua",{THUNDERSHOCK={name="THUNDERSHOCK",pp=30},SURF={name="SURF",pp=15}}) + put(v,"data/generated/items.lua",{RARE_CANDY={name="RARE CANDY"}}) + end +end +CacheFs.readAt=function(path) return tables[path] end;Catalog.reset() +local function entry(id,species,moves) + local mon={species=species,nickname="PIKA",ot="ALICE",otName="ALICE",otId=42,level=50,exp=125500, + personality=12345,otSecretId=3,pp={15,5},ppBonusesPacked=0,moves=moves, + ivs={hp=31,atk=31,def=20,spe=20,spa=20,spd=20},evs={hp=84,atk=84,def=84,spe=84,spa=84,spd=84}} + return {id=id,version="emerald",generation=3,mon=mon,display=Catalog.describe("emerald",mon),tags="original"} +end +local state=Store.new();state.nextId=4 +state.boxes[1].mons[1]=entry(1,25,{84,354}) +state.boxes[1].mons[2]=entry(2,277,{84}) +state.boxes[1].mons[3]=entry(3,25,{354}) +local refs={{box=1,slot=1},{box=1,slot=2},{box=1,slot=3}} + +local out,why,info=Migration.convert(state.boxes[1].mons[1],"gold") +T.eq(out,nil,"a move the destination lacks still blocks plain conversion") +T.check(tostring(why):find("PSYCHO BOOST",1,true)~=nil,"block reason names the missing move") +T.check(type(info)=="table" and info.moveBlock==true,"move-only block is flagged") +T.same(info and info.missing,{"PSYCHO BOOST"},"block lists the missing moves") +T.eq(info and info.species,"PIKACHU","block carries the destination species name") + +local preview=assert(Migration.preview(state,refs,"gold")) +T.eq(preview.allowed,false,"blocked rows keep the preview from applying") +T.eq(preview.rows[1].moveBlock,true,"preview row flags the move-only block") +T.eq(preview.rows[2].moveBlock,nil,"species block is not offered a move replacement") +T.eq(preview.rows[3].moveBlock,true,"a mon with no representable move is still a move-only block") +T.eq(#Migration.moveBlocked(preview),2,"moveBlocked lists both move-only rows") + +local forced=assert(Migration.convert(state.boxes[1].mons[1],"gold",{moves={"SURF","THUNDERSHOCK"}})) +T.same(forced.moves,{{id="SURF",pp=15,ppUps=0,maxPp=15},{id="THUNDERSHOCK",pp=30,ppUps=0,maxPp=30}}, + "override replaces the moveset at full PP with no PP Ups") +local plain=assert(Migration.convert(entry(9,25,{84}),"gold")) +T.same(forced.dvs,plain.dvs,"override keeps the computed DVs") +T.eq(Migration.convert(state.boxes[1].mons[1],"gold",{moves={"PSYCHO_BOOST"}}),nil,"override must name destination moves") +T.eq(Migration.convert(state.boxes[1].mons[1],"gold",{moves={}}),nil,"empty override refuses") + +local sent,serverSets={},{} +local fake={send=function(_,method,path,_,opts) sent[#sent+1]={method=method,path=path,species=opts.params.species};return #sent end, + poll=function() return {status="ok",data={sets=serverSets}} end,release=function() end} +local offline=false +package.loaded["src.sync.SyncClient"]={new=function() + if offline then return {send=function() return nil end,poll=function() return {status="error"} end,release=function() end} end + return fake +end} + +local imp={} +local s={service={state=state},migrationPreview=preview} +T.check(Tools.askRecommended(imp,s,refs),"move-only rows open the replace prompt") +T.check(imp._boxPopup and imp._boxPopup.confirm~=nil,"prompt is a yes/cancel confirmation") +T.check(imp._boxPopup and imp._boxPopup.message:find("Gold",1,true)~=nil,"prompt names the destination game") +UI.keypressed(imp,"escape") +T.eq(s.migrationFetch,nil,"cancel fetches nothing") +T.eq(#sent,0,"cancel sends no request") +T.eq(s.migrationPreview.rows[1].converted,nil,"cancel leaves the row blocked") + +Recommend.reset() +offline=true +Tools.askRecommended(imp,s,refs);UI.keypressed(imp,"return") +Tools.update(s) +T.eq(s.migrationFetch,nil,"offline fetch finishes") +T.eq(s.noticeKind,"error","offline shows an error notice") +T.eq(s.migrationPreview.rows[1].converted,nil,"offline keeps the row blocked") +T.eq(s.migrationPreview.allowed,false,"offline keeps the preview unappliable") + +Recommend.reset() +offline=false +serverSets={pikachu={generation=2,species="pikachu",set={moves={"Surf","Thundershock","Psycho Boost"},ivs={hp=31}}}} +Tools.askRecommended(imp,s,refs);UI.keypressed(imp,"return") +T.eq(#sent,1,"several rows share one batch request") +T.eq(sent[1].path,"/recommend/gen2","request asks for the destination generation") +T.eq(sent[1].species,"pikachu","request names each species once") +Tools.update(s) +local rows=s.migrationPreview.rows +T.check(rows[1].converted~=nil,"recommended moves convert the first move-only row") +T.check(rows[3].converted~=nil,"recommended moves convert the second move-only row") +T.eq(rows[1].recommended,true,"row records that the recommended set was used") +T.same(rows[1].converted.moves,{{id="SURF",pp=15,ppUps=0,maxPp=15},{id="THUNDERSHOCK",pp=30,ppUps=0,maxPp=30}}, + "recommended set resolves to native destination moves") +T.same(rows[1].converted.dvs,plain.dvs,"recommended set does not apply its IVs") +T.eq(rows[2].converted,nil,"unrelated block stays blocked") + +local single={{box=1,slot=1}} +local p1=assert(Migration.preview(state,single,"gold",{moves={[1]={"SURF","THUNDERSHOCK"}}})) +T.eq(p1.allowed,true,"override preview is appliable") +local applied=assert(Migration.apply(state,single,"gold",p1)) +T.same(applied.boxes[1].mons[1].mon.moves,p1.rows[1].converted.moves,"apply uses the overridden moves") +T.eq(applied.boxes[1].mons[1].archives[1].mon.moves[2],354,"archive keeps the original moveset") +local tampered=Store.copy(p1);tampered.moves[1]={"SURF"} +T.check(not Migration.apply(state,single,"gold",tampered),"edited override invalidates the preview") + +Recommend.reset() +serverSets={} +s.migrationPreview=assert(Migration.preview(state,single,"gold")) +s.migrationMoves=nil +Tools.askRecommended(imp,s,single);UI.keypressed(imp,"return") +Tools.update(s) +T.eq(s.noticeKind,"error","missing recommendation shows an error notice") +T.check(tostring(s.notice):find("PIKA",1,true)~=nil,"notice names the Pokémon with no recommendation") +T.eq(s.migrationPreview.rows[1].converted,nil,"missing recommendation keeps the row blocked") + +package.loaded["src.sync.SyncClient"]=nil +Recommend.reset() +CacheFs.readAt=oldRead;Catalog.reset() +T.finish("Box migration recommended moves") diff --git a/tests/engine/recommend_client_test.lua b/tests/engine/recommend_client_test.lua new file mode 100644 index 00000000..09f7f62f --- /dev/null +++ b/tests/engine/recommend_client_test.lua @@ -0,0 +1,84 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local Serializer = require("src.core.SaveSerializer") +local GameVersion = require("src.core.GameVersion") +local CacheFs = require("src.import.CacheFs") +local Catalog = require("src.box.Catalog") +local Recommend = require("src.recommend.Recommend") + +local tables = {} +local function put(version, path, value) + tables[GameVersion.cachePrefix(version) .. path] = Serializer.encode(value) +end +put("red", "data/generated/pokemon.lua", { SNORLAX = { dex = 143, name = "SNORLAX" } }) +put("red", "data/generated/moves.lua", { + BODY_SLAM = { name = "BODY SLAM", index = 34 }, + SELFDESTRUCT = { name = "SELFDESTRUCT", index = 120 }, + EARTHQUAKE = { name = "EARTHQUAKE", index = 89 }, +}) +put("emerald", "data/generated/gba/pokemon/names.lua", { [3] = "VENUSAUR" }) +put("emerald", "data/generated/gba/pokemon/move_names.lua", { + [0] = "-", [79] = "SLEEP POWDER", [202] = "GIGA DRAIN", [237] = "HIDDEN POWER", + [185] = "FAINT ATTACK", [136] = "HI JUMP KICK", +}) +CacheFs.readAt = function(path) return tables[path] end +Catalog.reset() + +local function fakeClient(reply) + local c = { sent = {}, polls = 0 } + function c:send(method, path, body, opts) + self.sent[#self.sent + 1] = { method = method, path = path, opts = opts } + return #self.sent + end + function c:poll() + self.polls = self.polls + 1 + if self.polls < 2 then return { status = "pending" } end + return reply + end + function c:release() end + return c +end + +T.eq(Recommend.key("NIDORAN♂"), "nidoranm", "male symbol maps to the showdown id") +T.eq(Recommend.key("NIDORAN♀"), "nidoranf", "female symbol maps to the showdown id") +T.eq(Recommend.key("MR.MIME"), "mrmime", "punctuation is stripped") + +local client = fakeClient({ status = "ok", code = 200, data = { sets = { + snorlax = { generation = 1, species = "Snorlax", + set = { moves = { "Self-Destruct", "Body Slam", "Earthquake", "Hyper Beam" } } }, +} } }) +local job = Recommend.request(1, { "SNORLAX", "MEW" }, { client = client }) +T.eq(job.status, "pending", "uncached species start a request") +T.eq(client.sent[1].path, "/recommend/gen1", "request hits the generation path") +T.eq(client.sent[1].opts.params.species, "snorlax,mew", "species ids are batched") +T.check(client.sent[1].opts.noAuth == true, "request needs no sync account") +T.eq(Recommend.poll(job), "pending", "first poll is pending") +local status, result = Recommend.poll(job) +T.eq(status, "ok", "second poll finishes") +T.check(result.snorlax ~= nil and result.mew == nil, "known species returned, unknown omitted") + +local keys, missing = Recommend.resolveMoves(Recommend.get(job, "SNORLAX"), "red") +T.eq(table.concat(keys, ","), "SELFDESTRUCT,BODY_SLAM,EARTHQUAKE", "gen 1 names resolve to native keys") +T.eq(table.concat(missing, ","), "Hyper Beam", "moves the cart lacks are reported") + +local again = Recommend.request(1, "Snorlax", { client = fakeClient({ status = "error" }) }) +T.eq(again.status, "ok", "cached species resolve without a request") +local none = Recommend.request(1, "Mew", { client = fakeClient({ status = "error" }) }) +T.eq(none.status, "ok", "species the server lacks are cached as absent") +T.check(Recommend.get(none, "Mew") == nil, "absent species has no set") + +local k3, m3, hp = Recommend.resolveMoves({ moves = { + "Sleep Powder", "Giga Drain", "Hidden Power Fire", "Feint Attack" } }, "emerald") +T.eq(table.concat(k3, ","), "79,202,237,185", "gen 3 names and renamed moves resolve to move ids") +T.eq(#m3, 0, "nothing missing in gen 3") +T.eq(hp, "FIRE", "hidden power type is reported") + +local down = Recommend.request(3, "Venusaur", { client = fakeClient({ status = "error", code = 503 }) }) +Recommend.poll(down) +local s2, why = Recommend.poll(down) +T.eq(s2, "error", "server error surfaces") +T.eq(why, "server", "503 reads as a server failure") +T.eq(Recommend.request(4, "Venusaur").status, "error", "unknown generation refused") + +T.finish() diff --git a/tests/engine/save_editor_recommended_moveset.lua b/tests/engine/save_editor_recommended_moveset.lua new file mode 100644 index 00000000..47a3df9d --- /dev/null +++ b/tests/engine/save_editor_recommended_moveset.lua @@ -0,0 +1,211 @@ +package.path = package.path .. ";./?.lua;./?/init.lua;./tools/save-editor/?.lua;./tools/save-editor/panels/?.lua" +love = require("tests.love_stub") +local T = require("tests.harness") +local Ops = require("Ops") +local Recommend = require("src.recommend.Recommend") +local Catalog = require("src.box.Catalog") + +local catalogs = {} +Catalog.get = function(version) + return catalogs[version] or { moves = {} } +end + +local function fakeClient(sets, opts) + opts = opts or {} + local c = { sent = {}, polls = 0, released = 0 } + function c.send(self, method, path, _, req) + self.sent[#self.sent + 1] = { method = method, path = path, species = req.params.species } + if opts.offline then return nil end + return #self.sent + end + function c.poll(self) + self.polls = self.polls + 1 + if self.polls < (opts.pendingPolls or 2) then return { status = "pending" } end + if opts.code then return { status = "error", code = opts.code } end + return { status = "ok", data = { sets = sets } } + end + function c.release(self) + self.released = self.released + 1 + end + return c +end + +local function tick(S, limit) + for _ = 1, limit or 10 do + if not S.recommendJob then return end + Ops.pollRecommendedMoves(S) + end +end + +local g1moves = { + TACKLE = { id = "TACKLE", name = "TACKLE", pp = 35 }, + BODY_SLAM = { id = "BODY_SLAM", name = "BODY SLAM", pp = 15 }, + EARTHQUAKE = { id = "EARTHQUAKE", name = "EARTHQUAKE", pp = 10 }, + HYPER_BEAM = { id = "HYPER_BEAM", name = "HYPER BEAM", pp = 5 }, + REST = { id = "REST", name = "REST", pp = 10 }, + AMNESIA = { id = "AMNESIA", name = "AMNESIA", pp = 20 }, +} +catalogs.red = { moves = g1moves } + +local snorlax = { id = "SNORLAX", name = "SNORLAX" } +local S1 = { + version = "red", + save = { generation = 1, version = "red", party = {}, player = { name = "RED", id = 1 } }, + data = { pokemon = { SNORLAX = snorlax, MR_MIME = { id = "MR_MIME", name = "MR.MIME" } }, moves = g1moves }, +} +local lax = { + species = "SNORLAX", level = 50, + moves = { { id = "TACKLE", pp = 3, ppUps = 3, maxPp = 56 }, { id = "REST", pp = 1, maxPp = 10 }, + { id = "AMNESIA", pp = 0, maxPp = 20 }, { id = "TACKLE", pp = 2, maxPp = 35 } }, + dvs = { attack = 15, defense = 15, speed = 15, special = 15, hp = 15 }, +} +S1.save.party[1] = lax +S1.editingMon = lax + +Recommend.reset() +S1.recommendClient = fakeClient({ + snorlax = { generation = 1, species = "Snorlax", + set = { moves = { "Body Slam", "Earthquake", "Hyper Beam", "Splash Dance" } } }, +}) +T.eq(Ops.recommendState(S1, lax), "ready", "Gen 1 Snorlax starts ready") +T.eq(Ops.recommendMoves(S1, lax), "pending", "Gen 1 request goes async") +T.eq(S1.status, "Fetching recommended moveset...", "pending status line") +T.eq(Ops.recommendState(S1, lax), "pending", "button shows pending while fetching") +T.eq(S1.recommendClient.sent[1].path, "/recommend/gen1", "Gen 1 endpoint") +T.eq(S1.recommendClient.sent[1].species, "snorlax", "species key sent") +T.eq(S1.dirty, nil, "nothing changes before the reply") +local undoBefore = #(S1.undoStack or {}) +tick(S1) +T.eq(S1.recommendJob, nil, "job finished") +T.eq(lax.moves[1].id, "BODY_SLAM", "Gen 1 slot 1 Body Slam") +T.eq(lax.moves[2].id, "EARTHQUAKE", "Gen 1 slot 2 Earthquake") +T.eq(lax.moves[3].id, "HYPER_BEAM", "Gen 1 slot 3 Hyper Beam") +T.eq(lax.moves[4], nil, "Gen 1 slot 4 cleared") +T.eq(lax.moves[1].pp, 15, "Body Slam PP is its max") +T.eq(lax.moves[1].ppUps, nil, "old PP Ups do not carry over") +T.eq(lax.moves[3].pp, 5, "Hyper Beam PP is its max") +T.eq(lax.dvs.attack, 15, "Gen 1 DVs untouched without Hidden Power") +T.eq(S1.dirty, true, "apply dirties the save") +T.eq(#S1.undoStack, undoBefore + 1, "apply records one undo entry") +T.check(tostring(S1.status):find("Applied recommended moveset to SNORLAX (3 moves)", 1, true) ~= nil, + "status names the species and count: " .. tostring(S1.status)) +T.check(tostring(S1.status):find("Splash Dance", 1, true) ~= nil, "status lists the skipped move") +require("History").undo(S1) +T.eq(S1.save.party[1].moves[1].id, "TACKLE", "undo restores the old moves") + +local lax2 = S1.save.party[1] +S1.editingMon = lax2 +local sends = #S1.recommendClient.sent +T.eq(Ops.recommendMoves(S1, lax2), "ok", "cached set applies synchronously") +T.eq(#S1.recommendClient.sent, sends, "cached set sends no request") +T.eq(lax2.moves[1].id, "BODY_SLAM", "cached set applied") + +local mime = { species = "MR_MIME", level = 30, moves = { { id = "TACKLE", pp = 35 } }, + dvs = { attack = 15, defense = 15, speed = 15, special = 15, hp = 15 } } +S1.save.party[2], S1.editingMon = mime, mime +S1.recommendClient = fakeClient({}) +T.eq(Ops.recommendMoves(S1, mime), "pending", "Mr. Mime request goes async") +T.eq(S1.recommendClient.sent[1].species, "mrmime", "ROM name MR.MIME keys as mrmime") +tick(S1) +T.eq(mime.moves[1].id, "TACKLE", "no set leaves moves alone") +T.eq(Ops.recommendState(S1, mime), "none", "button disables once the species has no set") +T.eq(Ops.recommendMoves(S1, mime), false, "disabled button is a no-op") + +Recommend.reset() +S1.recommendClient = fakeClient({}, { offline = true }) +Ops.recommendMoves(S1, lax2) +T.eq(S1.status, "Recommended moveset unavailable (offline)", "offline status line") +S1.recommendClient = fakeClient({}, { code = 500 }) +Ops.recommendMoves(S1, lax2) +tick(S1) +T.eq(S1.status, "Recommended moveset unavailable (server)", "server error status line") +T.eq(Ops.recommendState(S1, lax2), "ready", "a failed fetch can be retried") + +local g2moves = { + TACKLE = { id = "TACKLE", name = "TACKLE", pp = 35 }, + HIDDEN_POWER = { id = "HIDDEN_POWER", name = "HIDDEN POWER", pp = 15 }, + THUNDERBOLT = { id = "THUNDERBOLT", name = "THUNDERBOLT", pp = 15 }, +} +catalogs.gold = { moves = g2moves } +local jolteon = { id = "JOLTEON", name = "JOLTEON", dex = 135, index = 135, types = { "ELECTRIC" }, + baseStats = { hp = 65, attack = 65, defense = 60, speed = 130, specialAttack = 110, specialDefense = 95 }, + growthRate = "MEDIUM_FAST", genderRatio = 31 } +local S2 = { + version = "gold", + save = { generation = 2, version = "gold", party = {}, player = { name = "GOLD", id = 1 } }, + data = { pokemon = { JOLTEON = jolteon }, moves = g2moves }, +} +local jolt = { species = "JOLTEON", level = 50, moves = { { id = "TACKLE", pp = 35 } }, + dvs = { attack = 15, defense = 15, speed = 15, special = 15, hp = 15 }, + statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 } } +S2.save.party[1], S2.editingMon = jolt, jolt +S2.recommendClient = fakeClient({ + jolteon = { generation = 2, species = "Jolteon", + set = { moves = { "Thunderbolt", "Hidden Power Ice" }, ivs = { atk = 26, def = 24 } } }, +}, { pendingPolls = 1 }) +T.eq(Ops.recommendMoves(S2, jolt), "ok", "Gen 2 immediate reply applies in the same call") +T.eq(jolt.moves[1].id, "THUNDERBOLT", "Gen 2 slot 1 Thunderbolt") +T.eq(jolt.moves[2].id, "HIDDEN_POWER", "Gen 2 slot 2 Hidden Power") +T.eq(jolt.dvs.attack, 13, "Gen 2 Atk DV is iv/2") +T.eq(jolt.dvs.defense, 12, "Gen 2 Def DV is iv/2") +T.eq(jolt.dvs.speed, 15, "Gen 2 missing Spe IV counts as 31") +T.eq(jolt.dvs.special, 15, "Gen 2 Special DV follows SpA") +T.eq(jolt.dvs.hp, 11,"Gen 2 HP DV derived from the others") + +local g3names = { [33] = "TACKLE", [34] = "BODY SLAM", [89] = "EARTHQUAKE", [237] = "HIDDEN POWER", + [156] = "REST" } +catalogs.firered = { moves = g3names } +local g3moves = {} +for id, name in pairs(g3names) do + g3moves[id] = { id = name, name = name, moveId = id, + pp = ({ [33] = 35, [34] = 15, [89] = 10, [237] = 15, [156] = 10 })[id] } +end +local S3 = { + version = "firered", + save = { generation = 3, version = "firered", party = {}, player = { name = "RED", id = 1 } }, + data = { pokemon = { [143] = { name = "SNORLAX", speciesId = 143 } }, moves = g3moves }, +} +local lax3 = { species = 143, speciesId = 143, level = 50, moves = { 33, 156, 0, 0 }, + pp = { 1, 2 }, maxPp = { 35, 10 }, ivs = { hp = 31, atk = 31, def = 31, spa = 31, spd = 31, spe = 31 }, + evs = { hp = 0, atk = 0, def = 0, spa = 0, spd = 0, spe = 0 }, item = 5, nature = 3 } +S3.save.party[1], S3.editingMon = lax3, lax3 +S3.recommendClient = fakeClient({ + snorlax = { generation = 3, species = "Snorlax", + set = { moves = { "Body Slam", "Earthquake", "Hidden Power Ghost" }, + ivs = { hp = 31, atk = 31, def = 30, spa = 31, spd = 30, spe = 31 }, + evs = { hp = 252 }, item = "Leftovers", nature = "Adamant" } }, +}) +T.eq(Ops.recommendMoves(S3, lax3), "pending", "Gen 3 request goes async") +T.eq(S3.recommendClient.sent[1].path, "/recommend/gen3", "Gen 3 endpoint") +tick(S3) +T.eq(lax3.moves[1], 34, "Gen 3 slot 1 Body Slam native id") +T.eq(lax3.moves[2], 89, "Gen 3 slot 2 Earthquake native id") +T.eq(lax3.moves[3], 237, "Gen 3 slot 3 Hidden Power native id") +T.eq(lax3.moves[4], nil, "Gen 3 slot 4 cleared") +T.eq(lax3.pp[1], 15, "Gen 3 Body Slam PP max") +T.eq(lax3.pp[3], 15, "Gen 3 Hidden Power PP max") +T.eq(lax3.pp[4], nil, "Gen 3 slot 4 PP cleared") +T.eq(lax3.ivs.def, 30, "Hidden Power Def IV applied") +T.eq(lax3.ivs.spd, 30, "Hidden Power SpD IV applied") +T.eq(lax3.ivs.atk, 31, "other IVs kept at the set's values") +T.eq(lax3.evs.hp, 0, "EVs untouched") +T.eq(lax3.item, 5, "item untouched") +T.eq(lax3.nature, 3, "nature untouched") + +local egg = { species = 143, speciesId = 143, level = 5, isEgg = true, moves = { 33, 0, 0, 0 }, pp = { 35 } } +S3.save.party[2], S3.editingMon = egg, egg +T.eq(Ops.recommendState(S3, egg), "egg", "Gen 3 egg is not eligible") +T.eq(Ops.recommendMoves(S3, egg), false, "Gen 3 egg is a no-op") +T.eq(egg.moves[1], 33, "egg moves unchanged") + +S3.editingMon = lax3 +Recommend.reset() +S3.recommendClient = fakeClient({ + snorlax = { generation = 3, species = "Snorlax", set = { moves = { "Tackle" } } }, +}) +Ops.recommendMoves(S3, lax3) +S3.editingMon = egg +tick(S3) +T.eq(lax3.moves[1], 34, "a reply after the selection changed is dropped") + +T.finish("save_editor_recommended_moveset") diff --git a/tests/engine/time_capsule_recommend_test.lua b/tests/engine/time_capsule_recommend_test.lua new file mode 100644 index 00000000..793fd892 --- /dev/null +++ b/tests/engine/time_capsule_recommend_test.lua @@ -0,0 +1,203 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local Convert = require("src.online.Convert") +local Recommend = require("src.recommend.Recommend") +local Mon = require("src.battle.gen2.Mon") +local Stats = require("src.pokemon.Stats") +local Growth = require("src.pokemon.Growth") + +local gen1Data = { + pokemon = { + TESTMON = { id = "TESTMON", name = "TESTMON", index = 1, dex = 1, types = { "GRASS", "POISON" }, + baseStats = { hp = 45, attack = 49, defense = 49, speed = 45, special = 65 }, + catchRate = 45, growthRate = "MEDIUM_SLOW" }, + }, + moves = { + TACKLE = { id = "TACKLE", name = "TACKLE", index = 33, pp = 35 }, + VINE_WHIP = { id = "VINE_WHIP", name = "VINE WHIP", index = 22, pp = 10 }, + BODY_SLAM = { id = "BODY_SLAM", name = "BODY SLAM", index = 34, pp = 15 }, + }, +} + +local gen2Data = { + pokemon = { + TESTMON = { id = "TESTMON", name = "TESTMON", types = { "GRASS", "POISON" }, + baseStats = { hp = 45, attack = 49, defense = 49, speed = 45, specialAttack = 65, specialDefense = 65 }, + growthRate = "GROWTH_MEDIUM_SLOW", genderRatio = 0x1f }, + growthRates = { + GROWTH_MEDIUM_SLOW = { numerator = 6, denominator = 5, squared = -15, linear = 100, constant = 140 }, + }, + }, + moves = { + TACKLE = { id = "TACKLE", name = "TACKLE", pp = 35 }, + VINE_WHIP = { id = "VINE_WHIP", name = "VINE WHIP", pp = 10 }, + BODY_SLAM = { id = "BODY_SLAM", name = "BODY SLAM", pp = 15 }, + GIGA_DRAIN = { id = "GIGA_DRAIN", name = "GIGA DRAIN", pp = 5 }, + SWEET_SCENT = { id = "SWEET_SCENT", name = "SWEET SCENT", pp = 20 }, + }, + items = {}, +} + +local DVS = { attack = 15, defense = 14, speed = 13, special = 12 } + +local function gen2Mon(moves) + local dvs = {} + for k, v in pairs(DVS) do dvs[k] = v end + dvs.hp = Mon.hpDV(dvs) + local statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 } + local stats = Mon.stats(gen2Data.pokemon.TESTMON.baseStats, dvs, 30, statExp) + return { species = "TESTMON", level = 30, + experience = Mon.experienceForLevel(Mon.growthFor(gen2Data, "GROWTH_MEDIUM_SLOW"), 30), + dvs = dvs, statExp = statExp, stats = stats, hp = stats.hp, maxHp = stats.hp, + happiness = 128, pokerus = 0, caughtLevel = 5, moves = moves, + nickname = "SPROUT", ot = "GOLD", otName = "GOLD", otId = 4321, traded = false, isEgg = false } +end + +local function gen1Mon() + local def = gen1Data.pokemon.TESTMON + local dvs = {} + for k, v in pairs(DVS) do dvs[k] = v end + dvs.hp = Mon.hpDV(dvs) + local statExp = { hp = 0, attack = 0, defense = 0, speed = 0, special = 0 } + local stats = Stats.calc(def, 30, dvs, statExp) + return { species = "TESTMON", level = 30, exp = Growth.expForLevel(def.growthRate, 30), dvs = dvs, + statExp = statExp, stats = stats, hp = stats.hp, catchRate = 45, + moves = { { id = "TACKLE", pp = 35, ppUps = 0 } }, nickname = "SPROUT", ot = "RED", otId = 1234 } +end + +local illegal = gen2Mon({ { id = "TACKLE", pp = 35, maxPp = 35 }, { id = "GIGA_DRAIN", pp = 5, maxPp = 5 } }) + +do + local out, reason = Convert.toGen1(illegal, gen2Data, gen1Data) + T.check(out == nil and reason == "move_too_new", "a Gen 2 move refuses the Time Capsule trade") + local fixed, report = Convert.toGen1(illegal, gen2Data, gen1Data, + { moves = { "BODY_SLAM", "SWEET_SCENT", "VINE_WHIP", "BODY_SLAM" } }) + T.check(fixed ~= nil, "the recommended moveset lets it through") + local ids = {} + for _, mv in ipairs(fixed and fixed.moves or {}) do ids[#ids + 1] = mv.id end + T.eq(table.concat(ids, ","), "BODY_SLAM,VINE_WHIP", "moves Gen 1 lacks and duplicates are dropped") + T.eq(fixed and fixed.moves[1].pp, 15, "replacement moves start at full PP") + T.eq(fixed and fixed.moves[1].ppUps, 0, "and no PP Ups") + local said = false + for _, row in ipairs(report and report.changed or {}) do + if row.kind == "moves" then said = true end + end + T.check(said, "the conversion report lists the replaced moveset") + T.eq(#illegal.moves, 2, "the source record is not modified") + local lines, legal = Convert.preview(illegal, 2, 1, gen2Data, gen1Data, { moves = { "BODY_SLAM" } }) + T.check(legal and table.concat(lines, "|"):find("RECOMMENDED", 1, true) ~= nil, "the preview uses the replacement") + local still, why = Convert.toGen1(illegal, gen2Data, gen1Data, { moves = { "GIGA_DRAIN" } }) + T.check(still == nil and why == "move_too_new", "a set with no Gen 1 move keeps the refusal") +end + +local OnlinePanel = require("src.import.OnlinePanel") + +package.loaded["src.box.Catalog"] = { get = function() return { moves = gen1Data.moves } end } + +local planned = {} +package.loaded["src.online.Trade"] = { + withDataset = function(version, fn) + return fn(version == "red" and gen1Data or gen2Data) + end, + plan = function(req) + local monA, monB = req.from.party[req.fromIndex], req.to.party[req.toIndex] + local intoB, whyB = req.convert(monA, req.from.generation, req.to.generation) + if not intoB then return nil, whyB end + local intoA, whyA = req.convert(monB, req.to.generation, req.from.generation) + if not intoA then return nil, whyA end + planned[#planned + 1] = { a = intoA, b = intoB } + return { sides = { { role = "a", handle = req.from, sent = monA, received = intoA }, + { role = "b", handle = req.to, sent = monB, received = intoB } }, warnings = {} } + end, +} + +local function fakeClient(sets, opts) + opts = opts or {} + local c = { sent = {}, polls = 0 } + function c:send(method, path, _, req) + if opts.offline then error("no transport") end + self.sent[#self.sent + 1] = { method = method, path = path, species = req and req.params and req.params.species } + return #self.sent + end + function c:poll() + self.polls = self.polls + 1 + if self.polls <= (opts.pendingPolls or 0) then return { status = "pending" } end + return { status = "ok", data = { sets = sets } } + end + function c:release() end + return c +end + +local function setup(mon) + local imp = {} + local tr = OnlinePanel.tradeState(imp) + local a = { version = "gold", slotId = "s1", label = "GOLD" } + local b = { version = "red", slotId = "s2", label = "RED" } + tr.sides.a, tr.sides.b = a, b + tr.handles.a = { entry = a, handle = { version = "gold", generation = 2, party = { mon } } } + tr.handles.b = { entry = b, handle = { version = "red", generation = 1, party = { gen1Mon() } } } + tr.picks.a, tr.picks.b = 1, 1 + return imp, tr +end + +local SETS = { testmon = { generation = 1, species = "Testmon", + set = { moves = { "Body Slam", "Hyper Beam", "Vine Whip" } } } } + +do + Recommend.reset() + local client = fakeClient(SETS, { pendingPolls = 1 }) + OnlinePanel.recommendClient = client + local imp, tr = setup(illegal) + T.check(not OnlinePanel.tradeModalPreview(imp), "the Gen 2 move stops the preview") + T.eq(tr.status, "Some moves can't come along. Replace them with the recommended moveset?", "the player is asked") + T.check(tr.recommendAsk ~= nil, "a yes/no prompt is up") + T.check(OnlinePanel.tradeRecommendAnswer(imp, true), "yes starts the fetch") + T.eq(client.sent[1] and client.sent[1].path, "/recommend/gen1", "it asks for the Gen 1 set") + T.eq(client.sent[1] and client.sent[1].species, "testmon", "for the species being sent") + T.check(tr.recommendJob ~= nil and OnlinePanel.tradeModal(imp) == nil, "the fetch is still pending") + OnlinePanel.pumpTradeRecommend(imp) + T.check(tr.recommendJob == nil, "the update loop finishes the fetch") + T.check(OnlinePanel.tradeModal(imp) ~= nil, "the trade preview opens with the recommended moves") + local got = planned[#planned] + local ids = {} + for _, mv in ipairs(got and got.b.moves or {}) do ids[#ids + 1] = mv.id end + T.eq(table.concat(ids, ","), "BODY_SLAM,VINE_WHIP", "Red receives the recommended Gen 1 moves") + OnlinePanel.tradeModalClose(imp) + OnlinePanel.tradePick(imp, "a", 1) + T.check(tr.recommendMoves == nil, "changing the pick drops the override") +end + +do + Recommend.reset() + OnlinePanel.recommendClient = fakeClient(SETS) + local imp, tr = setup(illegal) + OnlinePanel.tradeModalPreview(imp) + T.check(OnlinePanel.tradeRecommendAnswer(imp, false), "no is accepted") + T.check(tr.status:find("MOVE ILLEGAL IN GEN 1", 1, true) ~= nil, "no keeps the old refusal: " .. tostring(tr.status)) + OnlinePanel.tradeModalPreview(imp) + T.check(tr.recommendAsk == nil, "the question is not asked twice for the same mon") +end + +do + Recommend.reset() + OnlinePanel.recommendClient = fakeClient({}, { offline = true }) + local imp, tr = setup(illegal) + OnlinePanel.tradeModalPreview(imp) + OnlinePanel.tradeRecommendAnswer(imp, true) + T.check(tr.recommendJob == nil and OnlinePanel.tradeModal(imp) == nil, "offline does not open the preview") + T.check(tr.status:find("Couldn't reach the server", 1, true) ~= nil + and tr.status:find("MOVE ILLEGAL IN GEN 1", 1, true) ~= nil, "offline gives a short reason and the refusal: " .. tr.status) +end + +do + Recommend.reset() + local eggMon = gen2Mon({ { id = "GIGA_DRAIN", pp = 5 } }) + eggMon.isEgg = true + OnlinePanel.recommendClient = fakeClient(SETS) + local imp, tr = setup(eggMon) + OnlinePanel.tradeModalPreview(imp) + T.check(tr.recommendAsk == nil, "other refusals never ask about moves") +end + +T.finish("time_capsule_recommend") diff --git a/tests/engine/union_rental_recommend_test.lua b/tests/engine/union_rental_recommend_test.lua new file mode 100644 index 00000000..27feb438 --- /dev/null +++ b/tests/engine/union_rental_recommend_test.lua @@ -0,0 +1,192 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local F = require("tests.engine._xgen_fixture") +local Datasets = require("src.online.xgen.Datasets") +local Rentals = require("src.online.xgen.Rentals") +local Recommend = require("src.recommend.Recommend") +local Model = require("src.online.union.BattlePrepModel") + +local function ids(list) + local out = {} + for i, m in ipairs(list) do out[i] = tostring(type(m) == "table" and (m.id or m.move) or m) end + return table.concat(out, ",") +end + +local function fakeClient(reply, pending) + local c = { sent = {}, polls = 0, released = 0 } + function c:send(method, path, body, opts) + self.sent[#self.sent + 1] = { method = method, path = path, body = body, opts = opts } + return #self.sent + end + function c:poll() + self.polls = self.polls + 1 + if self.polls <= (pending or 1) then return { status = "pending" } end + return reply + end + function c:release() self.released = self.released + 1 end + return c +end + +local function find(pg, id, label) + for i, it in ipairs(pg.items) do + if it.id == id and (label == nil or it.label:find(label, 1, true)) then return i, it end + end + return nil +end + +local function pick(m, id, label) + local i = find(m:page(), id, label) + assert(i, "no item " .. id .. " " .. tostring(label) .. " on " .. tostring(m:page().step)) + m.cursor = i + m:input("a") +end + +local saved = Rentals.DEFINITIONS["g3u-gen1"] +local savedGen3 = Rentals.DEFINITIONS["g3u-gen3"] +Rentals.DEFINITIONS["g3u-gen1"] = { + { type = "GRASS", species = 1, moves = { 33, 45 } }, + { type = "ELECTRIC", species = 25, moves = { 84, 45 } }, +} + +do + local red = F.data("red") + local sets = { bulbasaur = { generation = 1, species = "Bulbasaur", + set = { moves = { "Body Slam", "Psychic", "Vine Whip", "Splash" } } } } + local out = Rentals.build("g3u-gen1", red, { sets = sets }) + T.eq(#out.rentals, 2, "both rentals build with a partial recommendation map") + local bulba, pika = out.rentals[1], out.rentals[2] + T.eq(bulba.source, "recommended", "a species with a set uses it") + T.eq(ids(bulba.record.moves), "34,22,33,45", "legal recommended moves lead, the rest is filled from the rental's own legal moves") + T.eq(ids(bulba.moves), ids(bulba.record.moves), "the disclosed moves are the record's moves") + T.eq(#out.dropped, 2, "two recommended moves are dropped") + T.eq(out.dropped[1].move .. ":" .. out.dropped[1].code, "Psychic:move_not_legal", "an unlearnable move is dropped and reported") + T.eq(out.dropped[2].move .. ":" .. out.dropped[2].code, "Splash:move_unknown", "a move the data lacks is dropped and reported") + T.eq(out.dropped[1].species, 1, "a drop names the rental species") + T.eq(#out.padded, 2, "the filled moves are reported") + T.eq(pika.source, "default", "a species without a set keeps its own moves") + T.eq(ids(pika.record.moves), "84,45", "the default rental moves are unchanged") + T.eq(out.defaulted[1] and out.defaulted[1].code, "no_set", "the missing set is reported") + T.eq(bulba.record.ivs.hp, Rentals.IV, "a set without hidden power keeps the flat rental IVs") + + local none = Rentals.build("g3u-gen1", red, { sets = { bulbasaur = { set = { moves = { "Psychic", "Splash" } } } } }) + T.eq(ids(none.rentals[1].record.moves), "33,45", "a set with no legal move leaves the rental's own moves") + T.eq(none.defaulted[1] and none.defaulted[1].code, "no_legal_moves", "the unusable set is reported") + T.eq(#none.dropped, 2, "every unusable move is reported") + + local plain = Rentals.build("g3u-gen1", red) + T.eq(ids(plain.rentals[1].record.moves), "33,45", "no recommendation map builds the defined rentals") + T.eq(#plain.dropped, 0, "nothing is dropped without a map") +end + +do + for _, want in ipairs(Rentals.HIDDEN_POWER_TYPES) do + local ivs = Rentals.hiddenPowerIvs(want, { hp = 31, atk = 31, def = 31, spe = 31, spa = 31, spd = 31 }) + T.eq(ivs and Rentals.hiddenPowerType(ivs), want, "hidden power IVs reach " .. want) + end + local fire = { hp = 31, atk = 30, def = 31, spe = 30, spa = 30, spd = 31 } + T.eq(Rentals.hiddenPowerType(fire), "FIRE", "the usual hidden power fire spread is fire") + T.eq(ids({ Rentals.hiddenPowerIvs("FIRE", fire).atk }), "30", "matching set IVs are kept as given") + T.eq(Rentals.hiddenPowerIvs("NORMAL", fire), nil, "hidden power can't be normal") + + local raw = F.raw("emerald") + raw.moveNames[237] = "HIDDEN POWER" + raw.battleMoves.moves[237] = { type = 0, power = 1, pp = 15, accuracy = 100, priority = 0, effect = 0 } + raw.tmhm.machines[9] = 237 + for internal, row in pairs(raw.tmhm.learnsets) do + if raw.national.toNational[internal] == 1 then row.lo = row.lo + 2 ^ 9 end + end + local emerald = assert(Datasets.build("emerald", raw)) + Rentals.DEFINITIONS["g3u-gen3"] = { { type = "GRASS", species = 1, moves = { 33 } } } + local all31 = { hp = 31, atk = 31, def = 31, spe = 31, spa = 31, spd = 31 } + local out = Rentals.build("g3u-gen3", emerald, { sets = { bulbasaur = { set = { + moves = { "Hidden Power Fire", "Giga Drain" }, ivs = all31 } } } }) + local r = out.rentals[1] + T.eq(ids(r.record.moves), "237,202,33", "hidden power resolves to its move") + T.eq(Rentals.hiddenPowerType(r.record.ivs), "FIRE", "the rental's IVs give the set's hidden power type") + T.check(r.record.ivs.hp ~= Rentals.IV, "a hidden power rental does not keep the flat IVs") + local bad = Rentals.build("g3u-gen3", emerald, { sets = { bulbasaur = { set = { moves = { "Hidden Power Normal" } } } } }) + T.eq(bad.dropped[1] and bad.dropped[1].code, "hidden_power_type", "an impossible hidden power type is dropped") + Rentals.DEFINITIONS["g3u-gen3"] = savedGen3 +end + +local emerald = F.data("emerald") +local function mon3(national, level, moves) + return { species = emerald.nationalToLocal[national], level = level, personality = 7, otId = 1, otSecretId = 0, + otName = "MAY", ivs = { hp = 1, atk = 1, def = 1, spe = 1, spa = 1, spd = 1 }, evs = {}, moves = moves } +end +local RULES = { ruleset = "g3u", gen = 3, moveGen = 1, dexMax = 151, moveMax = 165, gens = { 3, 1 } } +local function source() + return { generation = 3, party = { mon3(252, 20, { 33, 345 }), mon3(25, 20, { 84, 45 }) }, boxes = {} } +end + +do + Recommend.reset() + local client = fakeClient({ status = "ok", code = 200, data = { sets = { + bulbasaur = { generation = 1, species = "Bulbasaur", set = { moves = { "Vine Whip", "Body Slam", "Growl", "Splash" } } }, + } } }, 2) + local m = Model.new({ version = "emerald", gen = 3, data = emerald, owned = source(), rules = RULES, + recommend = { client = client } }) + T.eq(client.sent[1] and client.sent[1].path, "/recommend/gen1", "the prep asks for the ruleset generation's sets") + T.eq(client.sent[1] and client.sent[1].opts.params.species, "bulbasaur,pikachu", "every rental species goes in one request") + T.check(m.rentalSet.pending == true, "rentals wait for the recommendations") + pick(m, "rentals") + local pg = m:page() + T.eq(#pg.items, 1, "no rental is offered while the request is pending") + T.check(table.concat(pg.lines, " "):find("Getting the rental", 1, true) ~= nil, "the wait is shown") + m:input("b") + m:poll() + T.check(m.rentalSet.pending == true, "still pending after one poll") + m:poll() + T.check(not m.rentalSet.pending, "the poll loop settles the rentals") + T.eq(client.released, 1, "the request handle is released") + local rental = m.rentalSet.rentals[1] + T.eq(rental and rental.source, "recommended", "the settled rental uses the recommended set") + T.eq(rental and ids(rental.record.moves), "22,34,45,33", "the rental's moves come from the set, legal ones only") + pick(m, "rentals") + T.eq(#m:page().items, 3, "both rentals are listed once settled") + T.check(table.concat(m:page().info or {}, " "):find("VINE WHIP", 1, true) ~= nil, "the rental list shows the recommended moves") + m:input("b") + + pick(m, "continue") + pick(m, "continue") + T.eq(m.step, "substitute", "treecko needs a substitute") + pick(m, "swap_rental", "RENTAL BULBASAUR") + local r = m:report(false) + T.check(r.ok, "the team with the rental is valid") + local team = r.result and r.result.team or {} + T.check(team[1] and team[1].rental == true, "the rental fills the slot") + T.eq(team[1] and ids(team[1].moves), "22,34,45,33", "the record that is disclosed carries the recommended moves") + local Wire = require("src.online.union.BattleSession").wireRecord(team[1] or {}) + T.eq(ids(Wire.moves), "22,34,45,33", "the wire record sent to the peer carries the same moves") + + m:discard() + m:poll() + T.eq(#m.rentalSet.rentals, 0, "a discarded prep never rebuilds rentals") +end + +do + Recommend.reset() + local client = fakeClient({ status = "error", code = 503 }, 0) + local m = Model.new({ version = "emerald", gen = 3, data = emerald, owned = source(), rules = RULES, + recommend = { client = client } }) + T.check(not m.rentalSet.pending, "a failed request settles at once") + T.eq(m.rentalSet.offline, "server", "the failure reason is kept") + T.eq(ids(m.rentalSet.rentals[1].record.moves), "33,45", "an unreachable server keeps the defined rental moves") + T.eq(#m.rentalSet.rentals, 2, "every rental stays available offline") +end + +do + Recommend.reset() + local client = fakeClient({ status = "ok", code = 200, data = { sets = {} } }, 5) + local m = Model.new({ version = "emerald", gen = 3, data = emerald, owned = source(), rules = RULES, + recommend = { client = client } }) + local key = m:page().step .. #m.rentalSet.rentals + m:poll() + m:discard() + T.eq(m.rentalJob, nil, "discarding cancels the request") + T.eq(key, "rules0", "nothing is offered before the request ends") +end + +Rentals.DEFINITIONS["g3u-gen1"] = saved +T.finish("union_rental_recommend") diff --git a/tests/engine/union_trade_recommend_test.lua b/tests/engine/union_trade_recommend_test.lua new file mode 100644 index 00000000..7984c731 --- /dev/null +++ b/tests/engine/union_trade_recommend_test.lua @@ -0,0 +1,180 @@ +package.path = "./?.lua;./?/init.lua;" .. package.path +love = love or require("tests.love_stub") +local T = require("tests.harness") +local F = require("tests.engine._xgen_fixture") +local U = require("tests.engine._union_trade_fixture") +local Model = require("src.online.union.TradePrepModel") +local Txn = require("src.online.union.TradeTxn") +local Open = require("src.ui.union.prep.OpenTrade") +local Recommend = require("src.recommend.Recommend") + +love.timer.getTime = function() return U.CLOCK.t end +local fixtures = { red = F.data("red"), gold = F.data("gold"), emerald = F.data("emerald") } +Model.datasetSource = function(version) return fixtures[version] end + +package.loaded["src.box.Catalog"] = { + get = function(version) return { moves = F.raw(version).moves } end, +} + +local function fakeClient(sets, opts) + opts = opts or {} + local c = { sent = {}, polls = 0 } + function c:send(method, path, _, req) + if opts.offline then error("no transport") end + self.sent[#self.sent + 1] = { method = method, path = path, species = req and req.params and req.params.species } + return #self.sent + end + function c:poll() + self.polls = self.polls + 1 + if self.polls <= (opts.pendingPolls or 0) then return { status = "pending" } end + if opts.serverError then return { status = "error", code = 503 } end + return { status = "ok", data = { sets = sets } } + end + function c:release() end + return c +end + +local function text(pg) + return table.concat(pg.lines or {}, " | ") +end + +local function has(ctl, id) + for _, it in ipairs(ctl:page().items) do + if it.id == id then return true end + end + return false +end + +local function pick(ctl, id) + local pg = ctl:page() + for i, it in ipairs(pg.items) do + if it.id == id then + ctl.cursor = i + ctl:input("a") + return true + end + end + return false +end + +local function run(w, a, b, n) + for _ = 1, n or 3 do + w:pump() + a:poll(0) + b:poll(0) + end +end + +local function bulba(moves) + local mon = U.bulba2() + mon.moves = moves + return mon +end + +local function goldGame(mon) + local g = U.g2game() + g.save.party = { mon } + return g +end + +local SETS = { + bulbasaur = { generation = 1, species = "Bulbasaur", + set = { moves = { "Razor Leaf", "Body Slam", "Toxic", "Sleep Powder" }, level = 100 } }, +} + +do + Recommend.reset() + Txn.reset() + local w, ra, rb = U.pair("red", "gold") + local ga = U.g1game() + local gb = goldGame(bulba({ { id = "TACKLE", pp = 35, maxPp = 35 }, { id = "GIGA_DRAIN", pp = 5, maxPp = 5 } })) + local client = fakeClient(SETS, { pendingPolls = 1 }) + local a = Open.controller(ga, ra, ra:prep(), { version = "red" }) + local b = Open.controller(gb, rb, rb:prep(), { version = "gold", recommendClient = client }) + run(w, a, b) + pick(a, "mon"); pick(a, "offer") + run(w, a, b) + pick(b, "mon") + local pg = b:page() + T.eq(pg.step, "offer", "the Gold side previews its Bulbasaur for Red") + T.check(text(pg):find("Some moves can't come along. Replace them with the recommended moveset?", 1, true) ~= nil, + "a blocked move asks about the recommended moveset: " .. text(pg)) + T.check(has(b, "recommend"), "the offer page has a recommended-moveset choice") + T.check(has(b, "moves"), "the per-slot fix is still offered") + T.check(pick(b, "recommend"), "the player takes the recommended moveset") + T.eq(#client.sent, 1, "one request goes to the server") + T.eq(client.sent[1].path, "/recommend/gen1", "the request asks for the destination generation") + T.eq(client.sent[1].species, "bulbasaur", "for the offered species") + T.check(text(b:page()):find("Getting the recommended moveset", 1, true) ~= nil, "the page says it is fetching") + T.check(not has(b, "recommend"), "the choice is one-shot while the fetch runs") + run(w, a, b) + pg = b:page() + T.check(has(b, "offer"), "the recommended moves clear the block: " .. text(pg)) + local staged = b.model:stagedMoves() + T.eq(#staged, 2, "both existing slots are filled") + T.eq(staged[1] and staged[1].move, 34, "slot 1 takes Body Slam (Razor Leaf is not learnable in Red at this level)") + T.eq(staged[2] and staged[2].move, 92, "slot 2 takes Toxic") + pick(b, "offer") + run(w, a, b, 5) + T.eq(a.step, "confirm", "the Red side accepts the adjusted offer (" .. tostring(a.closedText) .. ")") + T.eq(b.step, "confirm", "both seats agree") + T.check(a.model.peer and a.model.peer.digest16 == b.model.mine.digest16, + "the receiver re-derives the same offer digest from the sent adjustments") + pick(a, "trade") + run(w, a, b) + pick(b, "trade") + run(w, a, b, 6) + T.eq(a.step, "done", "the trade finishes (" .. tostring(a.closedText) .. ")") + local got = ga.save.party[1] + T.eq(got.species, "BULBASAUR", "Red holds the Bulbasaur") + local ids = {} + for _, mv in ipairs(got.moves or {}) do ids[#ids + 1] = mv.id end + T.eq(table.concat(ids, ","), "BODY_SLAM,TOXIC", "it arrives with the recommended moves") +end + +do + Recommend.reset() + local a = Model.new({ version = "gold", peerVersion = "red", + owned = { { ref = { where = "party", index = 1 }, + rec = bulba({ { id = "GIGA_DRAIN", pp = 5 }, { id = "SWEET_SCENT", pp = 20 } }) } } }) + local r = a:choose(1) + T.check(not r.ok and a:movesBlocked(), "two Gen 2 moves block the offer") + local target = a:recommendTarget() + T.eq(target and target.species, "BULBASAUR", "the target is the destination species") + T.eq(target and target.generation, 1, "in the destination generation") + local report = a:applyRecommended({ set = { moves = { "Body Slam", "Surf" } } }, target) + T.check(report and not report.ok, "a short legal set leaves the other slot blocked") + T.eq(a.mine.adjust.moves[1], 34, "the legal recommended move fills slot 1") + T.eq(a.mine.adjust.moves[2], nil, "the unlearnable Surf is dropped and slot 2 stays with the per-slot flow") + T.check(a:moveOptions(2) ~= nil, "slot 2 still offers per-slot replacements") + local none, code = a:applyRecommended({ set = { moves = { "Surf", "Hyper Beam" } } }, target) + T.check(none == nil and code == "none_legal", "a set with no legal move changes nothing") +end + +do + Recommend.reset() + Txn.reset() + local w, ra, rb = U.pair("red", "gold") + local ga = U.g1game() + local gb = goldGame(bulba({ { id = "TACKLE", pp = 35, maxPp = 35 }, { id = "GIGA_DRAIN", pp = 5, maxPp = 5 } })) + local a = Open.controller(ga, ra, ra:prep(), { version = "red" }) + local b = Open.controller(gb, rb, rb:prep(), { version = "gold", recommendClient = fakeClient({}, { offline = true }) }) + run(w, a, b) + pick(b, "mon") + pick(b, "recommend") + local pg = b:page() + T.check(text(pg):find("Couldn't reach the server", 1, true) ~= nil, "offline says why: " .. text(pg)) + T.check(not has(b, "recommend") and has(b, "moves"), "offline keeps the per-slot flow and does not ask again") + T.check(not has(b, "offer"), "the offer stays blocked") + + local c = fakeClient({}, {}) + b.recommendClient = c + pick(b, "back") + pick(b, "mon") + T.check(has(b, "recommend"), "choosing the mon again offers the recommended moveset again") + pick(b, "recommend") + T.check(text(b:page()):find("There's no recommended moveset for BULBASAUR", 1, true) ~= nil, + "a species the server has no set for says so: " .. text(b:page())) +end + +T.finish("union_trade_recommend") diff --git a/tests/engine/update_check_tests.lua b/tests/engine/update_check_tests.lua index af016341..b9527a05 100644 --- a/tests/engine/update_check_tests.lua +++ b/tests/engine/update_check_tests.lua @@ -55,6 +55,8 @@ eq(Check.fullAssetName("1.4.2", "Linux", "aarch64", "rg34xxsp"), "gen1recomp-1.4.2-rg34xxsp-stockos64-mod.zip", "RG34XXSP package mapping") eq(Check.fullAssetName("1.4.2", "iOS", "arm64"), "gen1recomp++-1.4.2-ios.ipa", "iOS package mapping") +eq(Check.fullAssetName("1.4.2", "PS4", "x64"), + "gen1recomp-1.4.2-ps4.pkg", "PS4 package mapping") eq(Check.fullAssetName("not-a-version", "Android", "arm64"), nil, "invalid full-package version rejected") diff --git a/tests/support/union_prep_driver.lua b/tests/support/union_prep_driver.lua index 348e9238..e998971a 100644 --- a/tests/support/union_prep_driver.lua +++ b/tests/support/union_prep_driver.lua @@ -57,6 +57,9 @@ function D.run(game, cfg) end, { version = version, opponent = cfg.opponent, gameplayMods = false }) ok(screen ~= nil and model ~= nil, "the prep screen opens") U.wait(10) + local deadline = love.timer.getTime() + 20 + while model and model.rentalSet.pending and love.timer.getTime() < deadline do U.wait(1) end + ok(model and not model.rentalSet.pending, "the rentals settle") local function labels(pg) local out = {} diff --git a/tools/build_pokestats.py b/tools/build_pokestats.py new file mode 100644 index 00000000..8b539cac --- /dev/null +++ b/tools/build_pokestats.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +import argparse +from contextlib import ExitStack +import json +from pathlib import Path +import subprocess +import sys +import tempfile + +from pokestats_bundle import Bundle, ROOT, canonical, require, sha256, write_bundle + +DEFAULT_DATASET = ROOT.parent / "pokeserver/recommendations" +KIND = "pokestats_recommendations" +LABEL = "Recommended" +BUCKET = "recommended" +NOTICE = ( + "Recommendations selected from usage statistics and factual game mechanics.\n" + "Statistics: Smogon University, accessed through pkmn/smogon.\n" + "https://www.smogon.com/stats/ https://github.com/pkmn/smogon\n" + "Compatibility validation and factual data: Pokemon Showdown via @pkmn/sim.\n" + "https://github.com/pkmn/ps https://github.com/smogon/pokemon-showdown\n" + "Includes recommendations for species without usable usage statistics.\n" + "No authored analysis movesets are included.\n" +) + + +def selected_set(species, row, generation): + require(isinstance(row, dict), "Invalid selected set") + require(isinstance(row.get("moves"), list) and all(isinstance(m, str) for m in row["moves"]), + "Expected a single selected move combination") + fields = {"moves": row["moves"], "level": row["level"], "ivs": row["ivs"]} + if row["species"] != species: + fields["species"] = row["species"] + if generation >= 2 and row.get("item"): + fields["item"] = row["item"] + if generation >= 2 and row.get("gender"): + fields["gender"] = row["gender"] + if generation == 3: + fields.update({name: row[name] for name in ("ability", "nature", "evs")}) + if any(move in ("Return", "Frustration") for move in row["moves"]): + fields["happiness"] = row["happiness"] + return fields + + +def read_sources(source): + data, receipts = {}, {} + for generation in (1, 2, 3): + name = "gen%d.json" % generation + raw = (Path(source) / name).read_bytes() + require(len(raw) <= 16 * 1024 * 1024, "Source file too large") + document = json.loads(raw) + require(document.get("schemaVersion") == 1 and document.get("generation") == generation + and document.get("status") == "completed", "Expected a completed generation export") + records = document.get("pokemon") + require(isinstance(records, dict) and records, "No recommendations in " + name) + data[generation] = {} + for species, record in records.items(): + require(record.get("species") == species and record.get("generation") == generation, + "Recommendation identity mismatch") + data[generation][species] = selected_set(species, record["set"], generation) + receipts[generation] = {"file": name, "sha256": sha256(raw)} + return data, receipts + + +def validate_sets(data, node="node"): + result = subprocess.run([node, str(ROOT / "tools/pokestats/validate.cjs")], + input=canonical({"generations": data}), stdout=subprocess.PIPE, + stderr=subprocess.PIPE, timeout=120) + require(result.returncode == 0, result.stderr.decode().strip() or "Recommendation validation failed") + return json.loads(result.stdout) + + +class Recommendations: + def __init__(self, directory, key_file): + self.directory = Path(directory).resolve() + self.stack = ExitStack() + try: + raw = (self.directory / "dataset.json").read_bytes() + require(len(raw) <= 32768, "Dataset manifest too large") + self.manifest = json.loads(raw) + require(self.manifest.get("schema") == 1 and self.manifest.get("kind") == KIND, + "Unsupported recommendation dataset") + entries = self.manifest.get("generations") + require(isinstance(entries, dict) and set(entries) == {"1", "2", "3"}, "Expected all three generations") + self.bundles = {} + for generation in (1, 2, 3): + entry = entries[str(generation)] + filename = "gen%d/gen%d.smg" % (generation, generation) + require(entry["bundle"] == filename, "Invalid bundle path") + bundle = self.stack.enter_context(Bundle(self.directory / filename, key_file)) + require(bundle.manifest["generation"] == generation + and bundle.manifest["provenance"].get("kind") == KIND + and bundle.manifest["provenance"].get("dataset_id") == self.manifest["dataset_id"], + "Bundle identity mismatch") + require(sha256((self.directory / filename).read_bytes()) == entry["sha256"], "Bundle hash mismatch") + verified = bundle.verify() + require(verified["species"] == entry["species"] and verified["sets"] == entry["species"], + "Recommendation count mismatch") + for species in bundle.manifest["index"]: + payload = bundle.query(species) + require(set(payload) == {"species", "formats"} + and set(payload["formats"]) == {BUCKET} + and set(payload["formats"][BUCKET]) == {LABEL}, + "Expected recommendations only") + self.bundles[generation] = bundle + except BaseException: + self.stack.close() + raise + + def __enter__(self): + return self + + def __exit__(self, *_): + self.stack.close() + + def get(self, generation, species): + require(generation in self.bundles, "Unsupported generation") + payload = self.bundles[generation].query(species) + if payload is None: + return None + fields = payload["formats"][BUCKET][LABEL] + return {"generation": generation, "species": payload["species"], + "set": {"species": payload["species"], **fields}} + + def species(self, generation): + require(generation in self.bundles, "Unsupported generation") + return [row["name"] for row in self.bundles[generation].manifest["index"].values()] + + def verify(self): + return {"verified": True, "dataset_id": self.manifest["dataset_id"], + "generations": {str(g): len(self.species(g)) for g in self.bundles}, + "recommendations": sum(len(self.species(g)) for g in self.bundles)} + + +def build(source, output, key_file, node="node"): + output = Path(output).resolve() + require(not output.exists(), "Output already exists; use a new dataset directory") + data, receipts = read_sources(source) + validator = validate_sets(data, node) + dataset_id = sha256(canonical(data)) + output.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix=".pokestats-", dir=output.parent) as temporary: + stage = Path(temporary) / "dataset" + stage.mkdir() + manifest = {"schema": 1, "kind": KIND, "dataset_id": dataset_id, "generations": {}} + for generation in (1, 2, 3): + records = {species: {BUCKET: {LABEL: fields}} for species, fields in data[generation].items()} + provenance = {"kind": KIND, "dataset_id": dataset_id, "generation": generation, + "branch": None, "commit": None, "editorial_sets_used": False, + "recommendations_only": True, "source": receipts[generation], + "source_sha256": receipts[generation]["sha256"], + "source_normalized_sha256": sha256(canonical(records)), + "importer_sha256": sha256(Path(__file__).read_bytes()), + "validator": validator} + sidecars = {"ATTRIBUTION.txt": NOTICE.encode(), "pokemon-showdown.LICENSE.txt": + (ROOT / "tools/pokestats/node_modules/@pkmn/sim/LICENSE").read_bytes()} + upstream_license = ROOT.parent / "smogon/LICENSE" + if upstream_license.is_file(): + sidecars["pkmn-smogon.LICENSE.txt"] = upstream_license.read_bytes() + write_bundle(records, provenance, sidecars, stage / ("gen%d" % generation), key_file, generation) + filename = "gen%d/gen%d.smg" % (generation, generation) + manifest["generations"][str(generation)] = { + "bundle": filename, "sha256": sha256((stage / filename).read_bytes()), + "species": len(records)} + (stage / "dataset.json").write_bytes(canonical(manifest) + b"\n") + with Recommendations(stage, key_file) as reader: + summary = reader.verify() + for generation in (1, 2, 3): + for species, fields in data[generation].items(): + require(reader.get(generation, species)["set"] == {"species": species, **fields}, + "Recommendation round trip mismatch") + require(not output.exists(), "Output appeared during build") + stage.rename(output) + return {"output": str(output), **summary} + + +def main(): + parser = argparse.ArgumentParser(description="Package and read selected Pokestats recommendations") + commands = parser.add_subparsers(dest="command", required=True) + builder = commands.add_parser("build") + builder.add_argument("--source", type=Path, default=ROOT.parent / "pokestats/recommendations/output") + builder.add_argument("--out", type=Path, default=DEFAULT_DATASET) + builder.add_argument("--key-file", type=Path, required=True) + builder.add_argument("--node", default="node") + for name in ("verify", "query"): + command = commands.add_parser(name) + command.add_argument("--dataset", type=Path, default=DEFAULT_DATASET) + command.add_argument("--key-file", type=Path, required=True) + if name == "query": + command.add_argument("--generation", type=int, choices=(1, 2, 3), required=True) + command.add_argument("--species", required=True) + args = parser.parse_args() + try: + if args.command == "build": + result = build(args.source, args.out, args.key_file, args.node) + else: + with Recommendations(args.dataset, args.key_file) as reader: + result = reader.verify() if args.command == "verify" else reader.get(args.generation, args.species) + require(result is not None, "No recommendation for this species") + print(json.dumps(result, indent=2, ensure_ascii=False)) + return 0 + except Exception as error: + print("error: " + (str(error) or type(error).__name__), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/pokestats/.gitignore b/tools/pokestats/.gitignore new file mode 100644 index 00000000..2ccbe465 --- /dev/null +++ b/tools/pokestats/.gitignore @@ -0,0 +1 @@ +/node_modules/ diff --git a/tools/pokestats/package-lock.json b/tools/pokestats/package-lock.json new file mode 100644 index 00000000..1fabf0b3 --- /dev/null +++ b/tools/pokestats/package-lock.json @@ -0,0 +1,53 @@ +{ + "name": "gen1recomp-pokestats", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gen1recomp-pokestats", + "version": "1.0.0", + "dependencies": { + "@pkmn/sim": "0.10.11" + } + }, + "node_modules/@pkmn/sets": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@pkmn/sets/-/sets-5.2.0.tgz", + "integrity": "sha512-WHAW6bZu0WCo/3b+LTW7BE0fBhYZnWH1lCXIu2an8/wTiZ/+s1eTcQYj7W/YQKxWT5dm/La03AZ6CeqdJip9CQ==", + "license": "MIT", + "dependencies": { + "@pkmn/types": "^4.0.0" + } + }, + "node_modules/@pkmn/sim": { + "version": "0.10.11", + "resolved": "https://registry.npmjs.org/@pkmn/sim/-/sim-0.10.11.tgz", + "integrity": "sha512-v45jqIgFnEB3hc7e97QlWDnfkIef6mfZ0WjWHWw+f21cYyksGVLzxIEulR9xuOazeszA+ycDx6O6B3JOJAS5Xg==", + "license": "MIT", + "dependencies": { + "@pkmn/sets": "^5.2.0", + "@pkmn/streams": "^1.1.0", + "ts-chacha20": "^1.2.0" + } + }, + "node_modules/@pkmn/streams": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@pkmn/streams/-/streams-1.1.0.tgz", + "integrity": "sha512-Pp9k7gt4xd76DqTU+LQEUyzuaauM4bV1MlehVtX1NS7o9zMFtjw50MfaEQ62dIMZyzxNjsJGoLGBeY1iSYLsRA==", + "license": "MIT" + }, + "node_modules/@pkmn/types": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@pkmn/types/-/types-4.0.0.tgz", + "integrity": "sha512-gR2s/pxJYEegek1TtsYCQupNR3d5hMlcJFsiD+2LyfKr4tc+gETTql47tWLX5mFSbPcbXh7f4+7txlMIDoZx/g==", + "license": "MIT" + }, + "node_modules/ts-chacha20": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/ts-chacha20/-/ts-chacha20-1.2.0.tgz", + "integrity": "sha512-PTyPoWYHc2we8P2NTn5hpYG211popWbkjiw+k63xqjeMrx9pPtXSclz9F3fu0Tpr+vfR1xxcQFwsEkh1cXMLqw==", + "license": "MIT" + } + } +} diff --git a/tools/pokestats/package.json b/tools/pokestats/package.json new file mode 100644 index 00000000..43e8a553 --- /dev/null +++ b/tools/pokestats/package.json @@ -0,0 +1,9 @@ +{ + "name": "gen1recomp-pokestats", + "private": true, + "version": "1.0.0", + "description": "Build-time validator for recommendations derived from usage statistics", + "dependencies": { + "@pkmn/sim": "0.10.11" + } +} diff --git a/tools/pokestats/requirements.txt b/tools/pokestats/requirements.txt new file mode 100644 index 00000000..ed207ea0 --- /dev/null +++ b/tools/pokestats/requirements.txt @@ -0,0 +1 @@ +cryptography==46.0.5 diff --git a/tools/pokestats/validate.cjs b/tools/pokestats/validate.cjs new file mode 100644 index 00000000..21daa810 --- /dev/null +++ b/tools/pokestats/validate.cjs @@ -0,0 +1,34 @@ +const fs = require('node:fs'); +const path = require('node:path'); +const {createRequire} = require('node:module'); +const dependencies = createRequire(path.join(__dirname, 'package.json')); +const {Dex, TeamValidator} = dependencies('@pkmn/sim'); +const packageRoot = path.resolve(path.dirname(dependencies.resolve('@pkmn/sim')), '../../..'); +const version = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8')).version; + +function validate(request) { + if (version !== '0.10.11') throw new Error('Unexpected simulator version'); + if (!request.generations || Object.keys(request.generations).sort().join(',') !== '1,2,3') throw new Error('Expected three generations'); + let count = 0; + for (const generation of [1,2,3]) { + const dex = Dex.forGen(generation), validator = new TeamValidator(`gen${generation}ubers`); + for (const [species, fields] of Object.entries(request.generations[generation])) { + const definition = dex.species.get(species); + if (!definition.exists || definition.gen > generation) throw new Error('Species unavailable in generation'); + const set = {species, ability:generation === 3 ? definition.abilities[0] : 'No Ability', + nature:'Serious', evs:Object.fromEntries(['hp','atk','def','spa','spd','spe'].map(s=>[s,generation<3?252:0])), + item:'', ...fields}; + if (new Set(set.moves.map(Dex.toID)).size !== set.moves.length) throw new Error('Duplicate moves'); + if (dex.species.get(set.species).baseSpecies !== definition.baseSpecies) throw new Error('Species override mismatch'); + const errors = validator.validateSet(structuredClone(set)); + if (errors) throw new Error(`Gen ${generation} ${species}: ${errors.join('; ')}`); + count++; + } + } + return {name:'@pkmn/sim',version,validated:count,scope:'generation singles set legality'}; +} +if (require.main === module) { + try {process.stdout.write(JSON.stringify(validate(JSON.parse(fs.readFileSync(0,'utf8'))))+'\n');} + catch (error) {process.stderr.write(error.message+'\n');process.exitCode=1;} +} +module.exports = {validate}; diff --git a/tools/pokestats_bundle.py b/tools/pokestats_bundle.py new file mode 100644 index 00000000..8925b239 --- /dev/null +++ b/tools/pokestats_bundle.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +import hashlib +import hmac +import json +import os +from pathlib import Path +import re +import struct +import tempfile +import zlib + +try: + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.ciphers.aead import AESGCM + from cryptography.hazmat.primitives.kdf.hkdf import HKDF +except ImportError: + raise SystemExit("Install tools/pokestats/requirements.txt in a virtual environment.") + +ROOT = Path(__file__).resolve().parents[1] +MAGIC = b"G1RSMG01" +MAX_MANIFEST = 2 * 1024 * 1024 +MAX_CHUNK = 1024 * 1024 +MAX_BUNDLE = 64 * 1024 * 1024 +STAT_KEYS = {"hp", "atk", "def", "spa", "spd", "spe"} +FIELDS = {"moves", "item", "nature", "ability", "evs", "ivs", "level", "species", "gender", "happiness"} + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def canonical(value): + return json.dumps(value, sort_keys=True, separators=(",", ":"), + ensure_ascii=False, allow_nan=False).encode("utf-8") + + +def sha256(body): + return hashlib.sha256(body).hexdigest() + + +def species_id(name): + return re.sub(r"[^a-z0-9]", "", name.lower()) + + +def options(value): + return value if isinstance(value, list) else [value] + + +def integer(value, low, high): + return type(value) is int and low <= value <= high + + +def validate(data, generation=3): + require(generation in (1, 2, 3), "Supported generations are 1, 2 and 3") + require(isinstance(data, dict) and data, "Expected a nonempty generation sets object") + seen = set() + count = 0 + for species, formats in data.items(): + require(isinstance(species, str) and species_id(species), "Invalid species name") + key = species_id(species) + require(key not in seen, "Species ID collision: " + key) + seen.add(key) + require(isinstance(formats, dict) and formats, "Invalid formats for " + species) + for tier, sets in formats.items(): + require(re.fullmatch(r"[a-z0-9]+", tier) is not None, "Invalid tier: " + tier) + require(isinstance(sets, dict) and sets, "Empty or invalid sets") + for name, row in sets.items(): + require(isinstance(name, str) and name, "Invalid set name") + require(isinstance(row, dict) and set(row) <= FIELDS, "Unknown set fields: " + name) + moves = row.get("moves") + require(isinstance(moves, list) and 1 <= len(moves) <= 4, "Invalid move slots") + for slot in moves: + require(options(slot) and all(isinstance(m, str) and m for m in options(slot)), + "Invalid move alternatives") + for field in ("item", "nature", "ability"): + if field in row: + require(options(row[field]) and all(isinstance(v, str) and v + for v in options(row[field])), "Invalid " + field) + for field, maximum in (("evs", 255), ("ivs", 31)): + for spread in options(row[field]) if field in row else []: + require(isinstance(spread, dict) and set(spread) <= STAT_KEYS, + "Invalid " + field + " spread") + require(all(integer(v, 0, maximum) for v in spread.values()), + "Invalid " + field + " value") + if field == "evs" and generation == 3: + require(sum(spread.values()) <= 510, "EV total exceeds 510") + if "level" in row: + require(options(row["level"]) and all(integer(v, 1, 100) + for v in options(row["level"])), "Invalid level") + if "species" in row: + require(isinstance(row["species"], str) and species_id(row["species"]), "Invalid species override") + if "gender" in row: + require(row["gender"] in ("M", "F", "N"), "Invalid gender") + if "happiness" in row: + require(integer(row["happiness"], 0, 255), "Invalid happiness") + count += 1 + return count + + +def load_key(path): + key = Path(path).read_bytes() + require(len(key) == 32, "Key must be a raw 32-byte file") + return key + + +def derive(key, salt): + material = HKDF(algorithm=hashes.SHA256(), length=64, salt=salt, + info=b"gen1recomp/smogon/v1").derive(key) + return material[:32], material[32:] + + +def write_bundle(data, provenance, sidecars, output, key_path, generation): + output = Path(output).resolve() + require(not output.exists(), "Output directory already exists; use a new snapshot directory") + generation_id = "gen" + str(generation) + sets = validate(data, generation) + salt = os.urandom(32) + enc_key, mac_key = derive(load_key(key_path), salt) + chunks, index, offset = [], {}, 0 + for species in sorted(data, key=species_id): + payload = {"species": species, "formats": data[species]} + raw = canonical(payload) + compressed = zlib.compress(raw, 9) + require(len(raw) <= MAX_CHUNK and len(compressed) + 16 <= MAX_CHUNK, "Species chunk too large") + nonce = os.urandom(12) + key = species_id(species) + index[key] = {"name": species, "formats": sorted(generation_id + tier for tier in data[species]), + "sets": sum(len(rows) for rows in data[species].values()), + "offset": offset, "length": len(compressed) + 16, + "decoded_length": len(raw), "sha256": sha256(raw), "nonce": nonce.hex()} + chunks.append((key, nonce, compressed)) + offset += len(compressed) + 16 + require(all(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", name) and isinstance(body, bytes) + for name, body in sidecars.items()), "Invalid sidecar") + require(not set(sidecars) & {generation_id + ".smg", "provenance.json"}, "Reserved sidecar name") + manifest = {"schema": 1, "generation": generation, "cipher": "AES-256-GCM", + "kdf": "HKDF-SHA256", "salt": salt.hex(), "compression": "zlib", + "manifest_auth": "HMAC-SHA256", "species_count": len(index), "set_count": sets, + "payload_bytes": offset, "index": index, "provenance": provenance, + "sidecars": {name: sha256(body) for name, body in sidecars.items()}} + header = canonical(manifest) + require(len(header) <= MAX_MANIFEST and offset <= MAX_BUNDLE, "Bundle exceeds size limit") + prefix = MAGIC + struct.pack(" 0 then text(report.errors .. " saved value errors. Check the red fields.", PAL.red) end - local function button(label, fn, kind, help, id) + local function button(label, fn, kind, help, id, enabled) local issue = id and issues.fields[id] - local opts = { kind = kind, font = "small", invalid = issue ~= nil } + local opts = { kind = kind, font = "small", invalid = issue ~= nil, enabled = enabled } local buttonH = Kit.buttonHeight(label, inner, opts) if help then buttonH = Touch.action(S, Kit, label, help, fn, cx, cy, inner, kind, issue) @@ -484,6 +484,11 @@ local function drawSection(S, Kit, x, y, w, h, report, issues) button("Reset to learnset", function() Ops.resetMoves(S, mon) end) + local recommend = Ops.recommendState(S, mon) + button(({ pending = "Fetching recommended moveset...", none = "No recommended moveset" })[recommend] + or "Use recommended moveset", function() + Ops.recommendMoves(S, mon) + end, "accent", nil, nil, recommend == "ready") button("Max all PP", function() Ops.maxAllPpUps(S, mon) end, "good") diff --git a/tools/save-editor/Ops.lua b/tools/save-editor/Ops.lua index fca0c94b..a205f51b 100644 --- a/tools/save-editor/Ops.lua +++ b/tools/save-editor/Ops.lua @@ -903,6 +903,122 @@ function Ops.resetMoves(S, mon) :format(speciesName(S, mon.species), mon.level, #learned)) end +local function recommendSpecies(S, mon) + return speciesName(S, mon.speciesId or mon.species) +end + +function Ops.recommendState(S, mon) + if not mon then return "none" end + local g = Gen.ofState(S) + if g == 3 and (mon.isEgg or mon.egg) then return "egg" end + if S.recommendJob and S.recommendJob.mon == mon then return "pending" end + local _, absent = require("src.recommend.Recommend").cached(g, recommendSpecies(S, mon)) + return absent and "none" or "ready" +end + +function Ops.recommendMoves(S, mon) + local state = Ops.recommendState(S, mon) + if state == "egg" then return Ops.say(S, "Eggs cannot use a recommended moveset", "info") end + if state == "pending" then return Ops.say(S, "Fetching recommended moveset...", "info") end + if state == "none" then + return Ops.say(S, mon and ("No recommended moveset for " .. recommendSpecies(S, mon)) + or "Select a Pokemon first", "info") + end + local Recommend = require("src.recommend.Recommend") + if S.recommendJob then Recommend.cancel(S.recommendJob.job) end + local g, name = Gen.ofState(S), recommendSpecies(S, mon) + S.recommendJob = { + job = Recommend.request(g, name, { client = S.recommendClient }), + mon = mon, species = name, + } + local status = Ops.pollRecommendedMoves(S) + if status == "pending" then Ops.say(S, "Fetching recommended moveset...", "info") end + return status +end + +function Ops.pollRecommendedMoves(S) + local pending = S.recommendJob + if not pending then return nil end + local Recommend = require("src.recommend.Recommend") + local status, reason = Recommend.poll(pending.job) + if status == "pending" then return "pending" end + S.recommendJob = nil + if status ~= "ok" then + Ops.say(S, ("Recommended moveset unavailable (%s)"):format(tostring(reason or "offline")), "warn") + return "error" + end + if S.editingMon ~= pending.mon then + Ops.say(S, "Recommended moveset dropped: the selected Pokemon changed", "info") + return "stale" + end + local entry = Recommend.get(pending.job, pending.species) + if not entry then + Ops.say(S, "No recommended moveset for " .. pending.species, "info") + return "none" + end + return Ops.setRecommendedMoves(S, pending.mon, entry) and "ok" or "error" +end + +function Ops.setRecommendedMoves(S, mon, entry) + if not mon or type(entry) ~= "table" then return false end + local g = Gen.ofState(S) + if g == 3 and (mon.isEgg or mon.egg) then + return Ops.say(S, "Eggs cannot use a recommended moveset", "info") + end + local Recommend = require("src.recommend.Recommend") + local ok, keys, missing, hpType = pcall(Recommend.resolveMoves, entry, Gen.versionOf(S.save, S.version)) + if not ok then return Ops.say(S, "Could not read the recommended moveset") end + local usable = {} + for _, key in ipairs(keys) do + if #usable < 4 and (g == 3 and tonumber(key) or Ops.moveUsable(S, key)) then + usable[#usable + 1] = key + elseif #usable < 4 then + missing[#missing + 1] = tostring(key) + end + end + if #usable == 0 then + return Ops.say(S, "None of the recommended moves exist in this game") + end + for slot = 1, 4 do MonOps.clearMove(mon, slot) end + mon.moves = {} + local count = 0 + for _, key in ipairs(usable) do + local slot = count + 1 + if pcall(MonOps.setMove, S.data, mon, slot, key) then + count = slot + local max = MonOps.calcMaxPp(MonOps.getBasePp(S.data, mon, slot), MonOps.getPpUps(mon, slot), g) + MonOps.setPp(S.data, mon, slot, max, g) + else + MonOps.clearMove(mon, slot) + missing[#missing + 1] = tostring(key) + end + end + local extra = "" + if hpType then + local ivs = type(entry.set) == "table" and type(entry.set.ivs) == "table" and entry.set.ivs or {} + local function iv(k) + return clamp(math.floor(tonumber(ivs[k]) or 31), 0, 31) + end + if g == 3 then + mon.ivs = mon.ivs or {} + for _, k in ipairs({ "atk", "def", "spa", "spd", "spe" }) do mon.ivs[k] = iv(k) end + MonOps.setIv(S.data, mon, "hp", iv("hp"), g) + extra = "; IVs set for Hidden Power " .. hpType + else + mon.dvs = mon.dvs or {} + for _, pair in ipairs({ { "attack", "atk" }, { "defense", "def" }, { "speed", "spe" }, { "special", "spa" } }) do + MonOps.setDv(S.data, mon, pair[1], math.floor(iv(pair[2]) / 2), g) + end + extra = "; DVs set for Hidden Power " .. hpType + end + end + if #missing > 0 then + extra = extra .. "; skipped " .. table.concat(missing, ", ") + end + return Ops.mark(S, ("Applied recommended moveset to %s (%d moves)%s") + :format(recommendSpecies(S, mon), count, extra)) +end + function Ops.healMon(S, mon) if not mon then return false end if mon.hp == mon.stats.hp and not mon.status then diff --git a/tools/test_pokestats.py b/tools/test_pokestats.py new file mode 100644 index 00000000..25949d0d --- /dev/null +++ b/tools/test_pokestats.py @@ -0,0 +1,138 @@ +import copy +import json +import os +from pathlib import Path +import tempfile +import unittest + +import build_pokestats as tool +from pokestats_bundle import Bundle, canonical, sha256, validate + + +class RecommendationsTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.source = self.root / "source" + self.source.mkdir() + self.key = self.root / "key" + self.key.write_bytes(os.urandom(32)) + self.documents = {} + for generation in (1, 2, 3): + fields = {"species": "Venusaur", "name": "Venusaur", "level": 100, + "moves": ["Razor Leaf", "Body Slam", "Sleep Powder", "Swords Dance"], + "item": "Leftovers" if generation > 1 else "", "ability": "Overgrow", + "nature": "Adamant", "gender": "M", "happiness": 255, + "ivs": dict.fromkeys(("hp", "atk", "def", "spa", "spd", "spe"), 30 if generation == 2 else 31), + "evs": {"hp": 4, "atk": 252, "def": 0, "spa": 0, "spd": 0, "spe": 252}} + self.documents[generation] = {"schemaVersion": 1, "generation": generation, + "status": "completed", "pokemon": {"Venusaur": { + "species": "Venusaur", "generation": generation, "set": fields, + "evidence": {"excluded": "PRIVATE_EVIDENCE_FIXTURE"}, + "alternatives": ["PRIVATE_ALTERNATIVES_FIXTURE"], + "construction": "PRIVATE_DECISIONS_FIXTURE"}}} + self.write_sources() + + def write_sources(self): + for generation, document in self.documents.items(): + (self.source / ("gen%d.json" % generation)).write_text(json.dumps(document)) + + def pack(self, name="dataset"): + output = self.root / name + result = tool.build(self.source, output, self.key) + self.assertEqual(result["recommendations"], 3) + return output + + def test_recommendations_only_round_trip(self): + output = self.pack() + with tool.Recommendations(output, self.key) as reader: + self.assertEqual(reader.verify()["recommendations"], 3) + for generation in (1, 2, 3): + actual = reader.get(generation, "VENUSAUR") + expected = tool.selected_set("Venusaur", self.documents[generation]["pokemon"]["Venusaur"]["set"], generation) + self.assertEqual(actual["set"], {"species": "Venusaur", **expected}) + self.assertEqual(reader.species(generation), ["Venusaur"]) + self.assertIsNone(reader.get(generation, "Pikachu")) + with Bundle(output / ("gen%d/gen%d.smg" % (generation, generation)), self.key) as bundle: + self.assertEqual(set(bundle.query("Venusaur")), {"species", "formats"}) + for file in output.rglob("*"): + if file.is_file(): + for marker in (b"PRIVATE_EVIDENCE_FIXTURE", b"PRIVATE_ALTERNATIVES_FIXTURE", b"PRIVATE_DECISIONS_FIXTURE"): + self.assertNotIn(marker, file.read_bytes()) + self.assertNotIn(b"Sleep Powder", (output / "gen3/gen3.smg").read_bytes()) + self.assertFalse(any("key" in file.name for file in output.rglob("*"))) + + def test_wrong_key(self): + output = self.pack() + wrong = self.root / "wrong" + wrong.write_bytes(os.urandom(32)) + with self.assertRaisesRegex(ValueError, "authentication failed"): + tool.Recommendations(output, wrong) + + def test_tampered_ciphertext_even_with_updated_public_hash(self): + output = self.pack() + bundle = output / "gen1/gen1.smg" + body = bytearray(bundle.read_bytes()) + body[-1] ^= 1 + bundle.write_bytes(body) + manifest = json.loads((output / "dataset.json").read_bytes()) + manifest["generations"]["1"]["sha256"] = sha256(body) + (output / "dataset.json").write_bytes(canonical(manifest)) + with self.assertRaises(Exception): + tool.Recommendations(output, self.key) + + def test_partial_export_and_illegal_set_are_rejected(self): + self.documents[1]["status"] = "running" + self.write_sources() + with self.assertRaisesRegex(ValueError, "completed"): + self.pack() + self.documents[1]["status"] = "completed" + self.documents[1]["pokemon"]["Venusaur"]["set"]["moves"] = ["Spacial Rend"] + self.write_sources() + with self.assertRaises(ValueError): + self.pack() + self.assertFalse((self.root / "dataset").exists()) + + def test_duplicate_moves_and_path_traversal_are_rejected(self): + original = copy.deepcopy(self.documents) + self.documents[3]["pokemon"]["Venusaur"]["set"]["moves"] = ["Razor Leaf", "Razor Leaf"] + self.write_sources() + with self.assertRaisesRegex(ValueError, "Duplicate"): + self.pack() + self.documents = original + self.write_sources() + output = self.pack() + manifest = json.loads((output / "dataset.json").read_bytes()) + manifest["generations"]["1"]["bundle"] = "../outside.smg" + (output / "dataset.json").write_bytes(canonical(manifest)) + with self.assertRaisesRegex(ValueError, "bundle path"): + tool.Recommendations(output, self.key) + + def test_return_happiness_and_cosmetic_species_schema(self): + fields = self.documents[3]["pokemon"]["Venusaur"]["set"] + fields["moves"][1] = "Frustration" + fields["happiness"] = 0 + self.write_sources() + output = self.pack() + with tool.Recommendations(output, self.key) as reader: + self.assertEqual(reader.get(3, "Venusaur")["set"]["happiness"], 0) + data = {"Unown": {"recommended": {"Recommended": { + "moves": ["Hidden Power Psychic"], "species": "Unown-X", "gender": "N", "happiness": 0}}}} + self.assertEqual(validate(data, 2), 1) + for field, value in (("species", 1), ("gender", "other"), ("happiness", True), ("happiness", 256)): + broken = copy.deepcopy(data) + broken["Unown"]["recommended"]["Recommended"][field] = value + with self.assertRaises(ValueError): + validate(broken, 2) + + def test_existing_snapshot_is_not_overwritten(self): + output = self.pack() + before = (output / "gen1/gen1.smg").read_bytes() + with self.assertRaisesRegex(ValueError, "already exists"): + tool.build(self.source, output, self.key) + self.assertEqual((output / "gen1/gen1.smg").read_bytes(), before) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/test_pokestats_bundle.py b/tools/test_pokestats_bundle.py new file mode 100644 index 00000000..827b3eb1 --- /dev/null +++ b/tools/test_pokestats_bundle.py @@ -0,0 +1,147 @@ +import copy +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +from cryptography.exceptions import InvalidTag + +import pokestats_bundle as bundle_tool + + +DATA = { + "Samplemon": {"ou": {"Sample set": { + "moves": ["Sample Move", "Other Move", "Hidden Power Ice", "Last Move"], + "item": "Sample Item", "nature": "Bold", "ability": "Sample Ability", + "evs": {"hp": 252, "def": 252, "spa": 4}, + "ivs": {"atk": 30}}}, "lc": {"Little set": {"moves": ["Sample Move"], "level": 5}}}, + "Othermon": {"uu": {"Other set": {"moves": ["Other Move"], "ivs": {"atk": 0}}}}, +} + + +class BundleTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + self.key = self.root / "key" + self.key.write_bytes(bytes(range(32))) + self.output = self.root / "snapshot" + self.pack(self.output) + self.path = self.output / "gen3.smg" + + def pack(self, output, data=DATA, generation=3): + digest = bundle_tool.sha256(bundle_tool.canonical(data)) + provenance = {"kind": "pokestats_recommendations", "branch": "main", "commit": "a" * 40, + "source_sha256": digest, "source_normalized_sha256": digest} + return bundle_tool.write_bundle(data, provenance, {"fixture.txt": b"Synthetic test fixture\n"}, + output, self.key, generation) + + def test_lossless_round_trip_and_provenance(self): + with bundle_tool.Bundle(self.path, self.key) as bundle: + self.assertEqual(bundle.verify()["sets"], 3) + self.assertEqual(bundle.manifest["provenance"]["branch"], "main") + self.assertEqual(bundle.manifest["provenance"]["source_sha256"], bundle_tool.sha256(bundle_tool.canonical(DATA))) + self.assertEqual(bundle.query("SAMPLE-MON")["formats"], DATA["Samplemon"]) + self.assertEqual(bundle.query("Samplemon", "gen3ou")["formats"], {"ou": DATA["Samplemon"]["ou"]}) + self.assertEqual(bundle.query("Samplemon", "gen3ubers")["formats"], {}) + self.assertIsNone(bundle.query("Missingmon")) + + def test_query_reads_only_selected_chunk(self): + with bundle_tool.Bundle(self.path, self.key) as bundle: + real_file = bundle.file + with patch.object(bundle, "file", wraps=real_file) as counted: + bundle.query("Samplemon") + counted.read.assert_called_once_with(bundle.manifest["index"]["samplemon"]["length"]) + + def test_no_moves_in_cleartext(self): + self.assertNotIn(b"Sample Move", self.path.read_bytes()) + self.assertNotIn(b"Hidden Power Ice", self.path.read_bytes()) + + def test_wrong_key_rejected(self): + wrong = self.root / "wrong" + wrong.write_bytes(b"x" * 32) + with self.assertRaisesRegex(ValueError, "authentication"): + bundle_tool.Bundle(self.path, wrong) + + def test_manifest_tamper_rejected(self): + body = self.path.read_bytes().replace(b'"set_count":3', b'"set_count":4', 1) + self.path.write_bytes(body) + with self.assertRaisesRegex(ValueError, "authentication"): + bundle_tool.Bundle(self.path, self.key) + + def test_ciphertext_tamper_rejected(self): + body = bytearray(self.path.read_bytes()) + with bundle_tool.Bundle(self.path, self.key) as bundle: + body[bundle.start] ^= 1 + self.path.write_bytes(body) + with bundle_tool.Bundle(self.path, self.key) as bundle: + with self.assertRaises(InvalidTag): + bundle.query("Othermon") + + def test_truncation_and_trailing_bytes_rejected(self): + original = self.path.read_bytes() + for body in (b"", original[:12], original[:-1], original + b"x"): + with self.subTest(length=len(body)): + self.path.write_bytes(body) + with self.assertRaises(ValueError): + bundle_tool.Bundle(self.path, self.key) + + def test_sidecars_must_match(self): + (self.output / "fixture.txt").write_bytes(b"changed") + with bundle_tool.Bundle(self.path, self.key) as bundle: + with self.assertRaisesRegex(ValueError, "Sidecar mismatch"): + bundle.verify() + + def test_provenance_must_match(self): + (self.output / "provenance.json").write_text("{}") + with bundle_tool.Bundle(self.path, self.key) as bundle: + with self.assertRaisesRegex(ValueError, "Provenance sidecar"): + bundle.verify() + + def test_existing_output_not_overwritten(self): + original = self.path.read_bytes() + with self.assertRaisesRegex(ValueError, "already exists"): + self.pack(self.output) + self.assertEqual(self.path.read_bytes(), original) + + def test_fresh_encryption_per_build(self): + other = self.root / "other" + self.pack(other) + self.assertNotEqual(self.path.read_bytes(), (other / "gen3.smg").read_bytes()) + with bundle_tool.Bundle(other / "gen3.smg", self.key) as bundle: + self.assertEqual(bundle.query("Samplemon")["formats"], DATA["Samplemon"]) + + def test_schema_errors_rejected(self): + for field, value in (("moves", []), ("moves", [[], "x"]), ("evs", {"hp": 256}), + ("evs", {"hp": 255, "atk": 255, "spe": 1}), + ("ivs", {"hp": True}), ("ivs", {"hp": 32}), + ("level", 101), ("unknown", 1), ("item", [])): + with self.subTest(field=field, value=value): + data = copy.deepcopy(DATA) + data["Samplemon"]["ou"]["Sample set"][field] = value + with self.assertRaises(ValueError): + bundle_tool.validate(data) + with self.assertRaisesRegex(ValueError, "collision"): + bundle_tool.validate({"Othermon": DATA["Othermon"], "Other-mon": DATA["Othermon"]}) + + def test_older_generations_preserve_stat_conventions(self): + older = {"Samplemon": {"ou": {"Older set": { + "moves": ["Sample Move", "Other Move", "Last Move"], + "ivs": {"atk": 26, "def": 26}, + "evs": {key: 252 for key in bundle_tool.STAT_KEYS}}}}} + for generation in (1, 2): + with self.subTest(generation=generation): + output = self.root / ("gen%d" % generation) + self.pack(output, older, generation) + with bundle_tool.Bundle(output / ("gen%d.smg" % generation), self.key) as bundle: + self.assertEqual(bundle.verify()["generation"], generation) + self.assertEqual(bundle.query("Samplemon", "gen%dou" % generation)["formats"], older["Samplemon"]) + with self.assertRaisesRegex(ValueError, "matching the bundle"): + bundle.query("Samplemon", "gen3ou") + with self.assertRaisesRegex(ValueError, "EV total"): + bundle_tool.validate(older, generation=3) + + +if __name__ == "__main__": + unittest.main()