Files
ProgressQuest-cli/tests/upstream/harness.js
T
Dash 0beb550548 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.
2026-10-17 15:56:02 +02:00

190 lines
5.3 KiB
JavaScript

// Runs Progress Quest web's game logic headless, for comparing with pqcli.
//
// Usage: node harness.js UPSTREAM_DIR < requests.json > responses.json
//
// The input is a JSON list of requests; the output is a JSON list with one
// response per request. Upstream's files are loaded from UPSTREAM_DIR
// (see fetch.py) and are not part of this repository.
"use strict";
const fs = require("fs");
const path = require("path");
const vm = require("vm");
const upstreamDir = process.argv[2];
const store = {};
// Just enough browser for upstream to run without a DOM.
global.window = {
location: { href: "#" },
localStorage: {
getItem: (key) => (key in store ? store[key] : null),
setItem: (key, value) => {
store[key] = String(value);
},
removeItem: (key) => {
delete store[key];
},
},
};
Object.defineProperty(global, "navigator", {
value: { userAgent: "node" },
configurable: true,
});
global.document = null;
let expectedAlert = null;
global.alert = (message) => {
if (message === expectedAlert) return;
throw new Error("upstream alert: " + message);
};
// The clock Worker is replaced by explicit ticks, see play().
global.Worker = function () {
this.postMessage = () => {};
this.addEventListener = () => {};
};
const $ = (selector) => (selector === "#multiplayer:checked" ? { length: 0 } : null);
$.isFunction = (obj) => typeof obj === "function";
$.each = (obj, callback) => {
if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; i++) {
if (callback.call(obj[i], i, obj[i]) === false) break;
}
} else {
for (const key in obj) {
if (callback.call(obj[key], key, obj[key]) === false) break;
}
}
return obj;
};
$.ajax = () => ({ then: () => {}, done: () => {} });
global.$ = $;
for (const name of ["config.js", "main.js", "newguy.js"]) {
const file = path.join(upstreamDir, name);
vm.runInThisContext(fs.readFileSync(file, "utf8"), { filename: file });
}
let now = 0;
global.timeGetTime = () => now;
// FormCreate() tries to load the character named in the URL. There is
// none yet, so upstream alerts and gives up, which is fine here.
expectedAlert = "Error loading game";
FormCreate();
expectedAlert = null;
// Without a DOM, ListBox.rows() has no table to read. WinItem picks a row
// from it once the inventory passes 250 rows, so read the rows from game.
Inventory.rows = () =>
game.Inventory.map((row) => ({ firstChild: { innerText: row[0] } }));
function newGame(spec) {
// Mirrors the character creation form: stats.seed is the generator state
// the stats were rolled from, and the game continues from it.
stats = Object.assign({}, spec.stats, { seed: spec.seed });
traits = { Name: spec.name, Race: spec.race, Class: spec.class };
sold();
LoadGame(JSON.parse(store.roster)[spec.name]);
}
function snapshot() {
const bars = {};
for (const id of ["ExpBar", "PlotBar", "QuestBar", "EncumBar", "TaskBar"]) {
bars[id] = [game[id].position, game[id].max];
}
const statsOut = {};
for (const key of K.Stats) statsOut[key] = game.Stats[key];
return {
caption: game.kill.replace(/\.\.\.$/, ""),
level: game.Traits.Level,
act: game.act,
bars: bars,
stats: statsOut,
equipment: game.Equips,
gold: game.Inventory[0][1],
inventory: game.Inventory.slice(1),
spells: game.Spells,
quests: game.Quests,
questmonster: game.questmonster,
queue: game.queue,
rng: randseed(),
};
}
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 < ticks; i++) {
const completing = TaskBar.done();
now += 100;
clock.lasttick = now - 100;
Timer1Timer();
if (completing) {
// copy, since game's lists keep changing after this tick
snapshots.push(JSON.parse(JSON.stringify(snapshot())));
if (snapshots.length >= tasks) break;
}
}
return snapshots;
}
function call(request) {
randseed(request.rng.slice());
if (request.questmonster !== undefined) {
game.questmonster = request.questmonster;
game.questmonsterindex = request.questmonsterindex;
}
const fn = global[request.fn];
const result = fn.apply(null, request.args || []);
return { result: result, rng: randseed() };
}
function lists() {
const result = {};
for (const key of Object.keys(K)) {
if (Array.isArray(K[key])) result[key] = K[key];
}
return result;
}
// 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));