Files
ProgressQuest-cli/pqcli/game_data.py
T
Dash c0c6c8aeba mechanic: move word lists to data files
The word lists (monsters, spells, equipment, races, classes, titles and
so on) now live in pqcli/data/*.json instead of pqcli/config.py. A
GameData object loaded at startup is passed to the simulation and UIs.

Users can replace any list by putting NAME.json in
$XDG_CONFIG_HOME/pqcli/data/ or a directory given with --data-dir.
--dump-data DIR copies the built-in files out for editing. Malformed
files are reported and pqcli exits before starting the UI.

Combat task descriptions come from the new combat_verbs list, with
{target} standing for the enemy. The built-in list holds only "Executing
{target}", and a single verb consumes no random roll, so stock gameplay
is unchanged. Recorded traces in tests/fixtures pin this down.

Monster, Race and Class stay in pqcli.config so existing saves still
load.

Resolves #3
2026-10-17 15:55:41 +02:00

250 lines
7.8 KiB
Python

import json
import sys
import typing as T
from dataclasses import dataclass, fields
from importlib import resources
from pathlib import Path
from pqcli.config import (
Class,
EquipmentPreset,
Modifier,
Monster,
Race,
StatType,
)
TARGET_PLACEHOLDER = "{target}"
class DataError(Exception):
pass
@dataclass(frozen=True)
class GameData:
spells: T.Tuple[str, ...]
offense_attrib: T.Tuple[Modifier, ...]
defense_attrib: T.Tuple[Modifier, ...]
offense_bad: T.Tuple[Modifier, ...]
defense_bad: T.Tuple[Modifier, ...]
shields: T.Tuple[EquipmentPreset, ...]
armors: T.Tuple[EquipmentPreset, ...]
weapons: T.Tuple[EquipmentPreset, ...]
specials: T.Tuple[str, ...]
item_attrib: T.Tuple[str, ...]
item_ofs: T.Tuple[str, ...]
boring_items: T.Tuple[str, ...]
monsters: T.Tuple[Monster, ...]
races: T.Tuple[Race, ...]
classes: T.Tuple[Class, ...]
titles: T.Tuple[str, ...]
impressive_titles: T.Tuple[str, ...]
combat_verbs: T.Tuple[str, ...]
DATA_FILES = tuple(field.name for field in fields(GameData))
class _Context:
def __init__(self, path: str) -> None:
self.path = path
def error(self, message: str) -> DataError:
return DataError(f"{self.path}: {message}")
def _check_keys(
ctx: _Context, where: str, obj: T.Any, keys: T.Sequence[str]
) -> T.Dict[str, T.Any]:
if not isinstance(obj, dict):
raise ctx.error(f"{where} must be an object")
missing = [key for key in keys if key not in obj]
if missing:
raise ctx.error(f"{where} is missing {', '.join(map(repr, missing))}")
extra = [key for key in obj if key not in keys]
if extra:
raise ctx.error(f"{where} has unknown {', '.join(map(repr, extra))}")
return obj
def _str(ctx: _Context, where: str, value: T.Any) -> str:
if not isinstance(value, str):
raise ctx.error(f"{where} must be a string")
return value
def _int(ctx: _Context, where: str, value: T.Any) -> int:
if isinstance(value, bool) or not isinstance(value, int):
raise ctx.error(f"{where} must be an integer")
return value
def _stat(ctx: _Context, where: str, value: T.Any) -> StatType:
_str(ctx, where, value)
for stat in StatType:
if value.lower() in (stat.value.lower(), stat.name):
return stat
allowed = ", ".join(stat.value for stat in StatType)
raise ctx.error(f"{where}: unknown stat {value!r} (use one of {allowed})")
def _parse_str(ctx: _Context, where: str, item: T.Any) -> str:
return _str(ctx, where, item)
def _parse_verb(ctx: _Context, where: str, item: T.Any) -> str:
verb = _str(ctx, where, item)
if TARGET_PLACEHOLDER not in verb:
raise ctx.error(f"{where} must contain {TARGET_PLACEHOLDER}")
return verb
def _parse_modifier(ctx: _Context, where: str, item: T.Any) -> Modifier:
obj = _check_keys(ctx, where, item, ["name", "quality"])
return Modifier(
_str(ctx, f"{where}.name", obj["name"]),
_int(ctx, f"{where}.quality", obj["quality"]),
)
def _parse_preset(ctx: _Context, where: str, item: T.Any) -> EquipmentPreset:
obj = _check_keys(ctx, where, item, ["name", "quality"])
return EquipmentPreset(
_str(ctx, f"{where}.name", obj["name"]),
_int(ctx, f"{where}.quality", obj["quality"]),
)
def _parse_monster(ctx: _Context, where: str, item: T.Any) -> Monster:
obj = _check_keys(ctx, where, item, ["name", "level", "item"])
loot = obj["item"]
return Monster(
_str(ctx, f"{where}.name", obj["name"]),
_int(ctx, f"{where}.level", obj["level"]),
None if loot is None else _str(ctx, f"{where}.item", loot),
)
def _parse_attrs(ctx: _Context, where: str, value: T.Any) -> T.List[StatType]:
if not isinstance(value, list):
raise ctx.error(f"{where} must be a list of stats")
return [_stat(ctx, f"{where}[{i}]", stat) for i, stat in enumerate(value)]
def _parse_race(ctx: _Context, where: str, item: T.Any) -> Race:
obj = _check_keys(ctx, where, item, ["name", "attr"])
return Race(
_str(ctx, f"{where}.name", obj["name"]),
_parse_attrs(ctx, f"{where}.attr", obj["attr"]),
)
def _parse_class(ctx: _Context, where: str, item: T.Any) -> Class:
obj = _check_keys(ctx, where, item, ["name", "attr"])
return Class(
_str(ctx, f"{where}.name", obj["name"]),
_parse_attrs(ctx, f"{where}.attr", obj["attr"]),
)
_PARSERS: T.Dict[str, T.Callable[[_Context, str, T.Any], T.Any]] = {
"spells": _parse_str,
"offense_attrib": _parse_modifier,
"defense_attrib": _parse_modifier,
"offense_bad": _parse_modifier,
"defense_bad": _parse_modifier,
"shields": _parse_preset,
"armors": _parse_preset,
"weapons": _parse_preset,
"specials": _parse_str,
"item_attrib": _parse_str,
"item_ofs": _parse_str,
"boring_items": _parse_str,
"monsters": _parse_monster,
"races": _parse_race,
"classes": _parse_class,
"titles": _parse_str,
"impressive_titles": _parse_str,
"combat_verbs": _parse_verb,
}
assert set(_PARSERS) == set(DATA_FILES)
def _parse(name: str, path: str, text: str) -> T.Tuple[T.Any, ...]:
ctx = _Context(path)
try:
value = json.loads(text)
except json.JSONDecodeError as ex:
raise ctx.error(f"invalid JSON: {ex}") from ex
if not isinstance(value, list):
raise ctx.error("must be a list")
if not value:
raise ctx.error("must not be empty")
parser = _PARSERS[name]
return tuple(parser(ctx, f"[{i}]", item) for i, item in enumerate(value))
def _builtin_dir() -> T.Any:
return resources.files("pqcli") / "data"
def _read_text(path: T.Any, display: str) -> str:
try:
return T.cast(str, path.read_text(encoding="utf-8"))
except UnicodeDecodeError as ex:
raise DataError(f"{display}: not valid UTF-8: {ex}") from ex
except OSError as ex:
raise DataError(f"{display}: cannot read: {ex}") from ex
def load_game_data(override_dir: T.Optional[Path] = None) -> GameData:
"""Load the built-in word lists, replacing any that override_dir has.
A missing override_dir is fine. Each <name>.json in it replaces the
whole built-in list of that name.
"""
overrides: T.Set[str] = set()
if override_dir is not None and override_dir.exists():
if not override_dir.is_dir():
raise DataError(f"{override_dir}: not a directory")
for path in sorted(override_dir.glob("*.json")):
if path.stem in DATA_FILES:
overrides.add(path.stem)
else:
print(
f"pqcli: ignoring {path}: not a known data file "
f"(known: {', '.join(DATA_FILES)})",
file=sys.stderr,
)
values: T.Dict[str, T.Tuple[T.Any, ...]] = {}
for name in DATA_FILES:
if name in overrides:
assert override_dir is not None
source: T.Any = override_dir / f"{name}.json"
display = str(source)
else:
source = _builtin_dir() / f"{name}.json"
display = f"<built-in>/{name}.json"
values[name] = _parse(name, display, _read_text(source, display))
return GameData(**values)
def dump_builtin_data(target_dir: Path) -> T.List[Path]:
"""Copy the built-in data files to target_dir, never overwriting."""
targets = [target_dir / f"{name}.json" for name in DATA_FILES]
existing = [path for path in targets if path.exists()]
if existing:
raise DataError(
"refusing to overwrite "
+ ", ".join(str(path) for path in existing)
)
target_dir.mkdir(parents=True, exist_ok=True)
for name, path in zip(DATA_FILES, targets):
source = _builtin_dir() / f"{name}.json"
path.write_text(
_read_text(source, f"<built-in>/{name}.json"), encoding="utf-8"
)
return targets