pqw: import and export web saves

A .pqw file is what Progress Quest web's roster page downloads: base64
of one character's JSON. pqcli.pqw converts between that and Player,
keeping the generator state, so a character carries on exactly as it
would have where it came from. Saves from before 2022, which were not
UTF-8, are read as Latin-1, and upstream's two spell renames are merged
the way its loader does. Online realm fields are dropped with a
warning.

The upstream tests check both directions against the real JS: a save
made by upstream, at a task boundary or mid-task, is imported and
played on for 3000 tasks next to upstream; then the same the other way.
This commit is contained in:
Dash committed 2026-10-17 15:56:02 +02:00
1 parent fced929d77
commit 0beb550548
5 files changed
+586 -4

No files matched your search

+12
View File
@@ -146,6 +146,10 @@ def play(
data: GameData, spec: dict[str, Any], ticks: int, tasks: int
) -> list[dict[str, Any]]:
"""Play pqcli the way the harness plays upstream: 100 ms ticks."""
return run(new_simulation(data, spec), ticks, tasks)
def new_simulation(data: GameData, spec: dict[str, Any]) -> Simulation:
race = next(race for race in data.races if race.name == spec["race"])
class_ = next(c for c in data.classes if c.name == spec["class"])
seed = cast(RngState, tuple(spec["seed"]))
@@ -159,6 +163,14 @@ def play(
)
simulation = Simulation(player, data)
simulation.tick(0) # sets up the Prologue, as upstream's sold() does
return simulation
def run(
simulation: Simulation, ticks: int, tasks: int
) -> list[dict[str, Any]]:
"""Keep playing in 100 ms ticks, snapshotting after each task."""
player = simulation.player
snapshots = []
for _ in range(ticks):
completing = player.task_bar.done
+38 -3
View File
@@ -114,8 +114,26 @@ function snapshot() {
function play(request) {
newGame(request.spec);
return run(request.ticks, request.tasks);
}
// Play a game to some point, then return its save, as .pqw files hold it.
function exportGame(request) {
newGame(request.spec);
run(request.ticks, request.tasks);
SaveGame();
return JSON.parse(store.roster)[request.spec.name];
}
// Load a save and keep playing it.
function resume(request) {
LoadGame(JSON.parse(JSON.stringify(request.game)));
return run(request.ticks, request.tasks);
}
function run(ticks, tasks) {
const snapshots = [];
for (let i = 0; i < request.ticks; i++) {
for (let i = 0; i < ticks; i++) {
const completing = TaskBar.done();
now += 100;
clock.lasttick = now - 100;
@@ -123,7 +141,7 @@ function play(request) {
if (completing) {
// copy, since game's lists keep changing after this tick
snapshots.push(JSON.parse(JSON.stringify(snapshot())));
if (snapshots.length >= request.tasks) break;
if (snapshots.length >= tasks) break;
}
}
return snapshots;
@@ -148,7 +166,24 @@ function lists() {
return result;
}
const handlers = { play: play, call: call, lists: lists };
// The roster page's .pqw encoding, from roster.js.
function encode(request) {
return btoa(unescape(encodeURIComponent(JSON.stringify(request.game))));
}
function decode(request) {
return JSON.parse(decodeURIComponent(escape(atob(request.text))));
}
const handlers = {
encode: encode,
decode: decode,
play: play,
export: exportGame,
resume: resume,
call: call,
lists: lists,
};
const requests = JSON.parse(fs.readFileSync(0, "utf8"));
const responses = requests.map((request) => handlers[request.cmd](request));
process.stdout.write(JSON.stringify(responses));