Files
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

183 lines
5.7 KiB
Python

"""Run upstream's JS and pqcli side by side, producing comparable data."""
import datetime
import json
import shutil
import subprocess
from pathlib import Path
from typing import Any, cast
from pqcli.config import EquipmentType, Monster, StatType
from pqcli.game_data import GameData
from pqcli.lingo import to_roman
from pqcli.mechanic import (
BaseTask,
Player,
PlotTask,
Simulation,
Stats,
)
from pqcli.random import Alea, RngState
from .fetch import cache_dir, is_fetched
HARNESS = Path(__file__).parent / "harness.js"
# pqcli data file -> upstream K.* list
LISTS = {
"spells": "Spells",
"offense_attrib": "OffenseAttrib",
"defense_attrib": "DefenseAttrib",
"offense_bad": "OffenseBad",
"defense_bad": "DefenseBad",
"shields": "Shields",
"armors": "Armors",
"weapons": "Weapons",
"specials": "Specials",
"item_attrib": "ItemAttrib",
"item_ofs": "ItemOfs",
"boring_items": "BoringItems",
"monsters": "Monsters",
"races": "Races",
"classes": "Klasses",
"titles": "Titles",
"impressive_titles": "ImpressiveTitles",
}
def unavailable_reason() -> str | None:
if shutil.which("node") is None:
return "node is not installed"
if not is_fetched(cache_dir()):
return (
f"upstream is not fetched into {cache_dir()}; "
"run `just fetch-upstream`"
)
return None
def run_harness(requests: list[dict[str, Any]]) -> list[Any]:
result = subprocess.run(
["node", str(HARNESS), str(cache_dir())],
input=json.dumps(requests),
capture_output=True,
text=True,
check=True,
)
return json.loads(result.stdout)
def encode_list(name: str, data: GameData) -> list[str]:
"""Write a pqcli word list the way upstream's config.js spells it."""
items: list[Any] = list(getattr(data, name))
if name in {"offense_attrib", "defense_attrib"}:
return [f"{item.name}|{item.quality:+d}" for item in items]
if name in {"offense_bad", "defense_bad", "shields", "armors", "weapons"}:
return [f"{item.name}|{item.quality}" for item in items]
if name == "monsters":
return [encode_monster(item) for item in items]
if name in {"races", "classes"}:
return [
item.name + "|" + ",".join(stat.value for stat in item.attr)
for item in items
]
return items
def encode_monster(monster: Monster | None) -> str:
if monster is None:
return ""
return f"{monster.name}|{monster.level}|{monster.item or '*'}"
def make_spec(seed: str, race: str, class_: str) -> dict[str, Any]:
rng = Alea(seed)
stats = {}
for stat in ["STR", "CON", "DEX", "INT", "WIS", "CHA"]:
stats[stat] = 3 + sum(rng.uint32() % 6 for _ in range(3))
stats["HP Max"] = rng.uint32() % 8 + stats["CON"] // 6
stats["MP Max"] = rng.uint32() % 8 + stats["INT"] // 6
return {
"name": "Tester",
"race": race,
"class": class_,
"stats": stats,
"seed": list(rng.state()),
}
def _queue_entry(task: BaseTask) -> str:
kind = "plot" if isinstance(task, PlotTask) else "task"
text = "Loading" if isinstance(task, PlotTask) else task.description
return f"{kind}|{task.duration // 1000}|{text}"
def snapshot(player: Player) -> dict[str, Any]:
assert player.task is not None
bars = {
"ExpBar": player.exp_bar,
"PlotBar": player.quest_book.plot_bar,
"QuestBar": player.quest_book.quest_bar,
"EncumBar": player.inventory.encum_bar,
"TaskBar": player.task_bar,
}
return {
"caption": player.task.description,
"level": player.level,
"act": player.quest_book.act,
"bars": {name: [bar.position, bar.max_] for name, bar in bars.items()},
"stats": {stat.value: player.stats[stat] for stat in StatType},
"equipment": {
kind.value: player.equipment[kind] or "" for kind in EquipmentType
},
"gold": player.inventory.gold,
"inventory": [[item.name, item.quantity] for item in player.inventory],
"spells": [
[spell.name, to_roman(spell.level)] for spell in player.spell_book
],
"quests": list(player.quest_book.quests),
"questmonster": encode_monster(player.quest_book.monster),
"queue": [_queue_entry(task) for task in player.queue],
"rng": list(player.rng.state()),
}
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"]))
player = Player(
name=spec["name"],
birthday=datetime.datetime(2000, 1, 1),
race=race,
class_=class_,
stats=Stats({StatType(k): v for k, v in spec["stats"].items()}),
rng=Alea.from_state(seed),
)
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
simulation.tick(100)
if completing:
snapshots.append(snapshot(player))
if len(snapshots) >= tasks:
break
return snapshots