mirror of
https://github.com/rr-/pq-cli.git
synced 2026-11-12 22:40:36 -07:00
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.
190 lines
5.3 KiB
JavaScript
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));
|