mirror of
https://github.com/bryanthaboi/gen1recomp
synced 2026-10-25 22:46:36 -04:00
more
This commit is contained in:
+1
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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())
|
||||
@@ -0,0 +1 @@
|
||||
/node_modules/
|
||||
Generated
+53
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
cryptography==46.0.5
|
||||
@@ -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};
|
||||
@@ -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}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user