mirror of
https://github.com/rr-/pq-cli.git
synced 2026-11-12 22:40:36 -07:00
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:
5 files changed
+586
-4
No files matched your search
@@ -0,0 +1,133 @@
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from upstream import bridge
|
||||
|
||||
from pqcli.config import StatType
|
||||
from pqcli.game_data import load_game_data
|
||||
from pqcli.mechanic import Player, Simulation, Stats
|
||||
from pqcli.pqw import PqwError, decode, encode, export_game, import_game
|
||||
from pqcli.random import Alea
|
||||
|
||||
DATA = load_game_data()
|
||||
|
||||
|
||||
def played(ticks: int, seed: str = "pqw") -> Simulation:
|
||||
player = Player(
|
||||
name="Exportée",
|
||||
birthday=datetime.datetime(2020, 2, 2, 12, 0, 0),
|
||||
race=DATA.races[5],
|
||||
class_=DATA.classes[7],
|
||||
stats=Stats({stat: 9 for stat in StatType}),
|
||||
rng=Alea(seed),
|
||||
)
|
||||
simulation = Simulation(player, DATA)
|
||||
simulation.tick(0)
|
||||
for _ in range(ticks):
|
||||
simulation.tick(100)
|
||||
return simulation
|
||||
|
||||
|
||||
def test_encode_decode_round_trip() -> None:
|
||||
game = {"Traits": {"Name": "Zoë 🐉"}, "act": 3}
|
||||
assert decode(encode(game)) == game
|
||||
|
||||
|
||||
def test_decode_ignores_whitespace() -> None:
|
||||
text = encode({"Traits": {"Name": "x"}})
|
||||
assert decode("\n".join(text[i : i + 10] for i in range(0, 99, 10)))
|
||||
|
||||
|
||||
def test_decode_reads_old_latin1_saves() -> None:
|
||||
# before 2022 the web version stored base64 of the raw JS string
|
||||
raw = json.dumps({"Traits": {"Name": "Jürgen"}}, ensure_ascii=False)
|
||||
text = base64.b64encode(raw.encode("latin-1")).decode()
|
||||
assert decode(text)["Traits"]["Name"] == "Jürgen"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text",
|
||||
[
|
||||
"not base64 at all!",
|
||||
base64.b64encode(b"not json").decode(),
|
||||
base64.b64encode(b"[1, 2]").decode(),
|
||||
base64.b64encode(b'{"act": 1}').decode(),
|
||||
],
|
||||
)
|
||||
def test_decode_rejects_garbage(text: str) -> None:
|
||||
with pytest.raises(PqwError, match="not a .pqw file"):
|
||||
decode(text)
|
||||
|
||||
|
||||
def test_import_reports_missing_fields() -> None:
|
||||
game = export_game(played(500).player, DATA)
|
||||
del game["ExpBar"]
|
||||
with pytest.raises(PqwError, match="ExpBar"):
|
||||
import_game(game, DATA)
|
||||
|
||||
|
||||
def test_import_rejects_bad_numbers() -> None:
|
||||
game = export_game(played(500).player, DATA)
|
||||
game["Traits"]["Level"] = "lots"
|
||||
with pytest.raises(PqwError, match="Traits.Level"):
|
||||
import_game(game, DATA)
|
||||
|
||||
|
||||
def test_import_warns_about_online_play() -> None:
|
||||
game = export_game(played(500).player, DATA)
|
||||
game["online"] = {"realm": "Alpaquil", "passkey": 1}
|
||||
game["motto"] = "hi"
|
||||
_player, warnings = import_game(game, DATA)
|
||||
assert len(warnings) == 2
|
||||
assert "online" in warnings[0]
|
||||
|
||||
|
||||
def test_import_merges_renamed_spells() -> None:
|
||||
game = export_game(played(500).player, DATA)
|
||||
game["Spells"] = [
|
||||
["Inoculate", "II"],
|
||||
["Slime Finger", "I"],
|
||||
["Innoculate", "III"],
|
||||
]
|
||||
player, _warnings = import_game(game, DATA)
|
||||
assert [(s.name, s.level) for s in player.spell_book] == [
|
||||
("Slime Finger", 1),
|
||||
("Inoculate", 5),
|
||||
]
|
||||
|
||||
|
||||
def test_import_keeps_unknown_race_and_class() -> None:
|
||||
game = export_game(played(500).player, DATA)
|
||||
game["Traits"]["Race"] = "Moon Wombat"
|
||||
player, _warnings = import_game(game, DATA)
|
||||
assert player.race.name == "Moon Wombat"
|
||||
assert player.race.attr == []
|
||||
|
||||
|
||||
def test_export_of_unplayed_character() -> None:
|
||||
player = Player(
|
||||
name="Fresh",
|
||||
birthday=datetime.datetime(2020, 2, 2),
|
||||
race=DATA.races[0],
|
||||
class_=DATA.classes[0],
|
||||
stats=Stats({stat: 9 for stat in StatType}),
|
||||
)
|
||||
game = export_game(player, DATA)
|
||||
assert game["kill"] == "Loading...."
|
||||
assert len(game["queue"]) == 5
|
||||
assert player.task is None # exported a copy
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ticks", [0, 1, 25, 3000, 40000, 123457])
|
||||
def test_round_trip_keeps_playing_identically(ticks: int) -> None:
|
||||
original = played(ticks)
|
||||
copy, _warnings = import_game(
|
||||
decode(encode(export_game(original.player, DATA))), DATA
|
||||
)
|
||||
assert bridge.snapshot(copy) == bridge.snapshot(original.player)
|
||||
resumed = Simulation(copy, DATA)
|
||||
assert bridge.run(resumed, 10**8, 2000) == bridge.run(
|
||||
original, 10**8, 2000
|
||||
)
|
||||
+50
-1
@@ -13,11 +13,13 @@ from pqcli import random
|
||||
from pqcli.game_data import DATA_FILES, load_game_data
|
||||
from pqcli.lingo import generate_name
|
||||
from pqcli.mechanic import (
|
||||
Simulation,
|
||||
impressive_guy,
|
||||
monster_task,
|
||||
named_monster,
|
||||
special_item,
|
||||
)
|
||||
from pqcli.pqw import decode, encode, export_game, import_game
|
||||
from pqcli.random import Alea, RngState
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
@@ -133,7 +135,14 @@ def test_game_matches_upstream(seed: str, race: str, class_: str) -> None:
|
||||
[{"cmd": "play", "spec": spec, "ticks": ticks, "tasks": tasks}]
|
||||
)
|
||||
ours = bridge.play(DATA, spec, ticks, tasks)
|
||||
assert len(ours) == len(theirs) == tasks
|
||||
assert len(ours) == tasks
|
||||
assert_same_games(ours, theirs)
|
||||
|
||||
|
||||
def assert_same_games(
|
||||
ours: list[dict[str, Any]], theirs: list[dict[str, Any]]
|
||||
) -> None:
|
||||
assert len(ours) == len(theirs)
|
||||
for i, (mine, upstream) in enumerate(zip(ours, theirs)):
|
||||
if mine != upstream:
|
||||
previous = [snap["caption"] for snap in ours[max(0, i - 5) : i]]
|
||||
@@ -150,3 +159,43 @@ def test_game_matches_upstream(seed: str, race: str, class_: str) -> None:
|
||||
for key, (ours_value, theirs_value) in fields.items()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# (tasks, ticks): stop right after a task, or in the middle of one
|
||||
SAVE_POINTS = [(3000, 10**9), (10**9, 123457), (10**9, 1)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tasks,ticks", SAVE_POINTS)
|
||||
def test_import_from_upstream_plays_on_identically(
|
||||
tasks: int, ticks: int
|
||||
) -> None:
|
||||
spec = bridge.make_spec("import", "Eel Man", "Inner Mason")
|
||||
(game,) = bridge.run_harness(
|
||||
[{"cmd": "export", "spec": spec, "ticks": ticks, "tasks": tasks}]
|
||||
)
|
||||
(text, theirs) = bridge.run_harness(
|
||||
[
|
||||
{"cmd": "encode", "game": game},
|
||||
{"cmd": "resume", "game": game, "ticks": 10**9, "tasks": 3000},
|
||||
]
|
||||
)
|
||||
player, warnings = import_game(decode(text), DATA)
|
||||
assert warnings == []
|
||||
ours = bridge.run(Simulation(player, DATA), 10**9, 3000)
|
||||
assert_same_games(ours, theirs)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tasks,ticks", SAVE_POINTS)
|
||||
def test_export_to_upstream_plays_on_identically(
|
||||
tasks: int, ticks: int
|
||||
) -> None:
|
||||
spec = bridge.make_spec("export", "Panda Man", "Robot Monk")
|
||||
simulation = bridge.new_simulation(DATA, spec)
|
||||
bridge.run(simulation, ticks, tasks)
|
||||
text = encode(export_game(simulation.player, DATA))
|
||||
(game,) = bridge.run_harness([{"cmd": "decode", "text": text}])
|
||||
(theirs,) = bridge.run_harness(
|
||||
[{"cmd": "resume", "game": game, "ticks": 10**9, "tasks": 3000}]
|
||||
)
|
||||
ours = bridge.run(simulation, 10**9, 3000)
|
||||
assert_same_games(ours, theirs)
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
Reference in new issue
Block a user