This commit is contained in:
bryanthaboi
2026-10-07 17:02:29 -04:00
parent eba60c9074
commit b148773169
34 changed files with 2773 additions and 52 deletions
+1 -1
View File
@@ -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
+76 -23
View File
@@ -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.add<b.add end)
return rows
end
function Migration.preview(state,refs,version)
function Migration.preview(state,refs,version,opts)
local preview={revision=state.revision,destination=version,rows={},allowed=true}
local seen={}
local seen,overrides={},opts and opts.moves or {}
if type(refs)~="table" or #refs==0 then return nil,"Select warehouse Pokémon to preview." end
for _,ref in ipairs(refs) do
local entry=Store.at(state,ref.box,ref.slot)
if not entry or seen[entry.id] then return nil,"A selected Pokémon is missing or repeated." end
seen[entry.id]=true
local converted,lines=Migration.convert(entry,version)
preview.rows[#preview.rows+1]={id=entry.id,name=entry.display.name,original=Serializer.encode(entry),
local override=overrides[entry.id]
local converted,lines,info=Migration.convert(entry,version,{moves=override})
local row={id=entry.id,name=entry.display.name,original=Serializer.encode(entry),
converted=converted,lines=converted and lines or {lines}}
if converted and override then
preview.moves=preview.moves or {};preview.moves[entry.id]=Store.copy(override);row.recommended=true
end
if not converted and type(info)=="table" and info.moveBlock then
row.moveBlock,row.missing,row.species=true,info.missing,info.species
end
preview.rows[#preview.rows+1]=row
if not converted then preview.allowed=false end
end
return preview
end
function Migration.apply(state,refs,version,preview)
function Migration.moveBlocked(preview)
local rows={}
for _,row in ipairs(type(preview)=="table" and preview.rows or {}) do if row.moveBlock then rows[#rows+1]=row end end
return rows
end
function Migration.recommendedMoves(preview,job,version)
local Recommend=require("src.recommend.Recommend")
local moves,failed={},{}
for _,row in ipairs(Migration.moveBlocked(preview)) do
local hit=Recommend.get(job,row.species)
local keys=hit and Recommend.resolveMoves(hit,version) or {}
if #keys>0 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
+1
View File
@@ -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")
+66 -3
View File
@@ -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
+19 -3
View File
@@ -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
+106 -2
View File
@@ -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
+12
View File
@@ -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
+34 -5
View File
@@ -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
+44 -4
View File
@@ -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()
+48
View File
@@ -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
+136 -7
View File
@@ -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,
+175
View File
@@ -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
+71 -2
View File
@@ -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)
+2
View File
@@ -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"
@@ -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")
+84
View File
@@ -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()
@@ -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")
@@ -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")
@@ -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")
+180
View File
@@ -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")
+2
View File
@@ -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")
+3
View File
@@ -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 = {}
+208
View File
@@ -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())
+1
View File
@@ -0,0 +1 @@
/node_modules/
+53
View File
@@ -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"
}
}
}
+9
View File
@@ -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"
}
}
+1
View File
@@ -0,0 +1 @@
cryptography==46.0.5
+34
View File
@@ -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};
+256
View File
@@ -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("<I", len(header)) + header
mac = hmac.digest(mac_key, prefix, "sha256")
aad = hashlib.sha256(prefix).digest()
encrypted = [AESGCM(enc_key).encrypt(nonce, body, aad + key.encode("ascii"))
for key, nonce, body in chunks]
output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix=".pokestats-", dir=output.parent) as staging:
stage = Path(staging) / "snapshot"
stage.mkdir()
(stage / (generation_id + ".smg")).write_bytes(prefix + mac + b"".join(encrypted))
for name, body in sidecars.items():
(stage / name).write_bytes(body)
(stage / "provenance.json").write_bytes(canonical(provenance) + b"\n")
with Bundle(stage / (generation_id + ".smg"), key_path) as bundle:
bundle.verify()
require(not output.exists(), "Output appeared during build")
stage.rename(output)
return {"output": str(output), "commit": provenance["commit"], "branch": provenance["branch"],
"generation": generation, "species": len(index), "sets": sets,
"bundle_bytes": (output / (generation_id + ".smg")).stat().st_size,
"generated_sha256": sha256(canonical(data)), "source_sha256": provenance["source_sha256"]}
class Bundle:
def __init__(self, path, key_path):
self.path = Path(path)
self.file = self.path.open("rb")
try:
first = self.file.read(12)
require(len(first) == 12 and first[:8] == MAGIC, "Invalid bundle magic")
size = struct.unpack("<I", first[8:])[0]
require(0 < size <= MAX_MANIFEST, "Invalid manifest size")
header = self.file.read(size)
mac = self.file.read(32)
require(len(header) == size and len(mac) == 32, "Truncated manifest")
self.manifest = json.loads(header)
salt = bytes.fromhex(self.manifest["salt"])
require(len(salt) == 32, "Invalid salt")
enc_key, mac_key = derive(load_key(key_path), salt)
prefix = first + header
require(hmac.compare_digest(mac, hmac.digest(mac_key, prefix, "sha256")),
"Manifest authentication failed (wrong key or tampered bundle)")
self.aes = AESGCM(enc_key)
self.aad = hashlib.sha256(prefix).digest()
self.start = 12 + size + 32
m = self.manifest
require(m["generation"] in (1, 2, 3), "Unsupported generation")
require((m["schema"], m["cipher"], m["kdf"], m["compression"], m["manifest_auth"])
== (1, "AES-256-GCM", "HKDF-SHA256", "zlib", "HMAC-SHA256"), "Unsupported schema")
require(isinstance(m["index"], dict) and len(m["index"]) == m["species_count"], "Invalid species index")
offset, nonces, count = 0, set(), 0
for key, row in sorted(m["index"].items()):
require(key == species_id(row["name"]) and key, "Invalid species key")
require(row["offset"] == offset and integer(row["length"], 17, MAX_CHUNK)
and integer(row["decoded_length"], 1, MAX_CHUNK), "Invalid chunk bounds")
nonce = bytes.fromhex(row["nonce"])
require(len(nonce) == 12 and nonce not in nonces, "Invalid or reused nonce")
nonces.add(nonce)
offset += row["length"]
count += row["sets"]
require(offset <= MAX_BUNDLE and offset == m["payload_bytes"]
and count == m["set_count"], "Invalid payload totals")
require(self.path.stat().st_size == self.start + offset, "Truncated or trailing payload")
except BaseException:
self.file.close()
raise
def __enter__(self):
return self
def __exit__(self, *_):
self.file.close()
def query(self, species, format_id=None):
key = species_id(species)
row = self.manifest["index"].get(key)
if row is None:
return None
self.file.seek(self.start + row["offset"])
ciphertext = self.file.read(row["length"])
compressed = self.aes.decrypt(bytes.fromhex(row["nonce"]), ciphertext, self.aad + key.encode("ascii"))
decoder = zlib.decompressobj()
raw = decoder.decompress(compressed, row["decoded_length"] + 1)
require(decoder.eof and not decoder.unused_data and len(raw) == row["decoded_length"]
and sha256(raw) == row["sha256"], "Invalid decoded chunk")
payload = json.loads(raw)
require(payload["species"] == row["name"], "Species mismatch")
if format_id is not None:
prefix = "gen" + str(self.manifest["generation"])
require(format_id.startswith(prefix) and re.fullmatch(r"gen[123][a-z0-9]+", format_id) is not None,
"Use a full format ID matching the bundle generation")
payload["formats"] = {format_id[4:]: payload["formats"][format_id[4:]]} \
if format_id[4:] in payload["formats"] else {}
return payload
def verify(self):
data = {}
for key, row in self.manifest["index"].items():
payload = self.query(key)
prefix = "gen" + str(self.manifest["generation"])
require(sorted(prefix + tier for tier in payload["formats"]) == row["formats"], "Format index mismatch")
require(validate({payload["species"]: payload["formats"]}, self.manifest["generation"])
== row["sets"], "Set count mismatch")
data[payload["species"]] = payload["formats"]
p = self.manifest["provenance"]
require(sha256(canonical(data)) == p["source_normalized_sha256"], "Source round trip mismatch")
for name, digest in self.manifest["sidecars"].items():
require(re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", name) is not None, "Invalid sidecar path")
require(sha256((self.path.parent / name).read_bytes()) == digest, "Sidecar mismatch: " + name)
require(json.loads((self.path.parent / "provenance.json").read_bytes()) == p, "Provenance sidecar mismatch")
return {"generation": self.manifest["generation"], "species": len(data),
"sets": self.manifest["set_count"], "commit": p["commit"],
"source_sha256": p["source_sha256"], "verified": True}
+1
View File
@@ -483,6 +483,7 @@ function App.update(dt)
-- directly in App.draw() via Kit.beginFrame. Tile animation (water,
-- flowers) still needs ticking so the Map tab isn't static.
TileRenderer.tick()
if S and S.recommendJob then Ops.pollRecommendedMoves(S) end
PadInput.update(dt)
local notches = PadInput.takeWheel()
if notches ~= 0 then
+7 -2
View File
@@ -92,9 +92,9 @@ local function drawSection(S, Kit, x, y, w, h, report, issues)
if report.errors > 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")
+116
View File
@@ -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
+138
View File
@@ -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()
+147
View File
@@ -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()