Files
ProgressQuest-cli/tests/test_mechanic.py
T
Dash 9c44d69633 random: port Alea, one generator per character
pqcli used Python's global Mersenne Twister, seeded once from the clock.
Upstream uses Johannes Baagøe's Alea, with Random(n) = uint32() % n, and
keeps the generator state with each character. The port matches the JS
output bit for bit, including UTF-16 seeding of non-BMP characters.

Each Player now owns an Alea that is pickled with the save, and
Simulation.tick draws from it. Saves without one get a fresh generator.
As upstream does, a new character's game continues from the state the
kept stat roll was made from.

The parity traces are re-recorded, since every random number changes.
2026-10-17 15:55:50 +02:00

243 lines
7.4 KiB
Python

import datetime
import pickle
import re
import pytest
from pqcli import random
from pqcli.config import PRIME_STATS, StatType
from pqcli.game_data import load_game_data
from pqcli.lingo import plural
from pqcli.mechanic import (
Player,
Simulation,
Stats,
StatsBuilder,
create_player,
impressive_guy,
level_up_time,
)
from pqcli.random import Alea
def test_level_up_time_matches_upstream() -> None:
# Math.round((20 + Math.pow(1.15, level)) * 60), pq-web 5cd39f1
assert level_up_time(1) == 1269
assert level_up_time(10) == 1443
assert level_up_time(30) == 5173
assert level_up_time(100) == 70460007
RACE = load_game_data().races[0]
CLASS = load_game_data().classes[0]
def make_simulation() -> Simulation:
data = load_game_data()
player = Player(
name="Tester",
birthday=datetime.datetime(2000, 1, 1),
race=data.races[0],
class_=data.classes[0],
stats=Stats({stat: 10 for stat in StatType}),
rng=Alea("test"),
)
return Simulation(player, data)
def test_quest_length_matches_upstream() -> None:
# QuestBar.reset(50 + Random(100)), pq-web f7d5e83
random.seed("quests")
simulation = make_simulation()
lengths = set()
for _ in range(2000):
simulation.complete_quest()
lengths.add(simulation.player.quest_book.quest_bar.max_)
assert min(lengths) == 50
assert max(lengths) == 149
def test_weighted_stat_gain_skips_hp_and_mp(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# the "favor the best stat" branch only covers prime stats, pq-web 48c42e6
monkeypatch.setattr(random, "odds", lambda chance, out_of: False)
random.seed("stats")
player = make_simulation().player
player.stats.increment(StatType.hp_max, 1000)
for _ in range(500):
player.win_stat()
assert player.stats[StatType.hp_max] == 1010
assert player.stats[StatType.mp_max] == 10
assert sum(player.stats[stat] for stat in PRIME_STATS) == 60 + 500
def fake_below(monkeypatch: pytest.MonkeyPatch, *values: int) -> None:
queue = list(values)
monkeypatch.setattr(random, "below", lambda num: queue.pop(0) % num)
def full_inventory(rows: int) -> Simulation:
simulation = make_simulation()
for i in range(rows):
simulation.player.inventory.add(f"item {i}", 1)
return simulation
def test_win_item_adds_new_item_below_cap(
monkeypatch: pytest.MonkeyPatch,
) -> None:
simulation = full_inventory(10)
random.seed("items")
simulation.player.win_item(simulation.data)
assert len(simulation.player.inventory) == 11
def test_win_item_repeats_existing_item_past_cap(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Max(250, Random(999)) < rows: add one of a random row, pq-web 3cbd386
simulation = full_inventory(300)
inventory = simulation.player.inventory
fake_below(monkeypatch, 260, 6) # rows include Gold, so 6 is "item 5"
simulation.player.win_item(simulation.data)
assert len(inventory) == 300
assert inventory[5].quantity == 2
def test_win_item_can_pick_gold_row(monkeypatch: pytest.MonkeyPatch) -> None:
simulation = full_inventory(300)
fake_below(monkeypatch, 0, 0)
simulation.player.win_item(simulation.data)
assert simulation.player.inventory.gold == 1
assert len(simulation.player.inventory) == 300
def test_win_item_past_cap_can_still_add_new_item(
monkeypatch: pytest.MonkeyPatch,
) -> None:
simulation = full_inventory(300)
random.seed("items")
fake_below(monkeypatch, 301, 0, 0, 0)
simulation.player.win_item(simulation.data)
assert len(simulation.player.inventory) == 301
def test_impressive_guy_formats() -> None:
# pq-web cea0d8c
data = load_game_data()
titles = "|".join(re.escape(title) for title in data.impressive_titles)
races = "|".join(re.escape(plural(race.name)) for race in data.races)
pattern = re.compile(
rf"the ({titles}) of the ({races})|({titles}) [A-Z][a-z]+ of [A-Z][a-z]+"
)
random.seed("guys")
guys = [impressive_guy(data) for _ in range(200)]
for guy in guys:
assert pattern.fullmatch(guy), guy
assert any(guy.startswith("the ") for guy in guys)
assert not all(guy.startswith("the ") for guy in guys)
def test_impressive_titles_match_upstream() -> None:
assert load_game_data().impressive_titles[-3:] == (
"Chancellor",
"Baroness",
"Inquistor", # sic, as upstream
)
def test_cinematic_text_matches_upstream() -> None:
random.seed("cinematics")
simulation = make_simulation()
for _ in range(60):
simulation.interplot_cinematic()
texts = [task.description for task in simulation.player.queue]
assert any("You've reached the kind protection of " in t for t in texts)
assert any(t.endswith("news!? -- Oh yes, of course") for t in texts)
def test_quest_log_keeps_100_quests() -> None:
quest_book = make_simulation().player.quest_book
for i in range(150):
quest_book.add_quest(f"quest {i}")
assert len(quest_book.quests) == 100
assert quest_book.quests[-1] == "quest 149"
def test_prologue_fills_plot_bar() -> None:
# upstream advances the plot bar on any task while act == 0
random.seed("prologue")
simulation = make_simulation()
player = simulation.player
positions = []
player.connect(
"new_task",
lambda: positions.append(
(player.task.description, player.quest_book.plot_bar.position)
),
)
while player.quest_book.act == 0:
simulation.tick(100000.0)
assert player.quest_book.plot_bar.max_ != 26 # reset by act I
assert [position for _, position in positions[:6]] == [
0,
0,
10,
16,
22,
26,
]
assert positions[5][0] == "Loading Act I"
assert positions[6][0] == "Heading to the killing fields"
def test_player_rng_survives_save_and_load() -> None:
simulation = make_simulation()
for _ in range(50):
simulation.tick(100000.0)
restored = pickle.loads(pickle.dumps(simulation.player))
assert restored.rng.state() == simulation.player.rng.state()
def test_old_save_without_rng_gets_one() -> None:
player = make_simulation().player
state = player.__dict__.copy()
del state["rng"]
restored = Player.__new__(Player)
restored.__setstate__(state)
assert isinstance(restored.rng, Alea)
def test_tick_draws_from_player_rng_only() -> None:
random.seed("global")
expected = Alea("global").uint32() % 1000
simulation = make_simulation()
before = simulation.player.rng.state()
for _ in range(50):
simulation.tick(100000.0)
assert simulation.player.rng.state() != before
assert random.below(1000) == expected
def test_new_game_replays_stat_roll_numbers() -> None:
# upstream seeds the game with the state from before the stat roll
builder = StatsBuilder(Alea("roll"))
builder.roll()
stats = builder.roll()
player = create_player("New", RACE, CLASS, stats, builder.seed)
assert player.rng.state() == builder.history[-1].seed
with random.using(player.rng):
rerolled = StatsBuilder(player.rng).roll()
assert dict(rerolled) == dict(stats)
def test_unroll_restores_previous_stats_and_generator() -> None:
builder = StatsBuilder(Alea("roll"))
first = builder.roll()
after_first = builder.history[-1].after
builder.roll()
assert builder.unroll() is first
assert builder.seed == builder.history[-1].seed
assert builder._rng.state() == after_first