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.
This commit is contained in:
Dash committed 2026-10-17 15:55:50 +02:00
1 parent f1cdff8140
commit 9c44d69633
11 files changed
+5721 -5432

No files matched your search

+45 -8
View File
@@ -30,6 +30,7 @@ from pqcli.lingo import (
special, special,
young, young,
) )
from pqcli.random import Alea, RngState
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -346,12 +347,14 @@ class Player(SignalMixin):
race: Race, race: Race,
class_: Class, class_: Class,
stats: Stats, stats: Stats,
rng: T.Optional[Alea] = None,
) -> None: ) -> None:
self.name: str = name self.name: str = name
self.birthday: datetime.datetime = birthday self.birthday: datetime.datetime = birthday
self.race: Race = race self.race: Race = race
self.class_: Class = class_ self.class_: Class = class_
self.stats: Stats = stats self.stats: Stats = stats
self.rng: Alea = rng or Alea()
self.elapsed = 0.0 self.elapsed = 0.0
self.exp_bar = Bar(max_=level_up_time(1)) self.exp_bar = Bar(max_=level_up_time(1))
@@ -369,6 +372,8 @@ class Player(SignalMixin):
def __setstate__(self, obj: T.Any) -> None: def __setstate__(self, obj: T.Any) -> None:
self.__dict__.update(obj) self.__dict__.update(obj)
self.elapsed = obj.get("elapsed", 0.0) self.elapsed = obj.get("elapsed", 0.0)
if "rng" not in obj:
self.rng = Alea()
def set_task(self, task: BaseTask) -> None: def set_task(self, task: BaseTask) -> None:
self.task = task self.task = task
@@ -494,6 +499,11 @@ class Simulation:
self.last_tick = datetime.datetime.now() self.last_tick = datetime.datetime.now()
def tick(self, elapsed: float = 100.0) -> None: def tick(self, elapsed: float = 100.0) -> None:
# each character plays on its own generator, saved along with it
with random.using(self.player.rng):
self._tick(elapsed)
def _tick(self, elapsed: float) -> None:
self.player.elapsed += elapsed self.player.elapsed += elapsed
if self.player.task is None: if self.player.task is None:
@@ -963,11 +973,30 @@ def monster_task(
return KillTask(description, duration, monster=monster) return KillTask(description, duration, monster=monster)
@dataclass
class _Roll:
stats: Stats
seed: RngState # generator state before the roll
after: RngState
class StatsBuilder: class StatsBuilder:
def __init__(self) -> None: def __init__(self, rng: T.Optional[Alea] = None) -> None:
self.history: T.List[Stats] = [] self._rng = rng or Alea()
self.history: T.List[_Roll] = []
@property
def seed(self) -> RngState:
"""Generator state the current stats were rolled from.
Like upstream, a new character's game continues from this state,
so its first rolls replay the stat roll's numbers.
"""
return self.history[-1].seed
def roll(self) -> Stats: def roll(self) -> Stats:
seed = self._rng.state()
with random.using(self._rng):
values: T.Dict[StatType, int] = { values: T.Dict[StatType, int] = {
stat: 3 + random.below(6) + random.below(6) + random.below(6) stat: 3 + random.below(6) + random.below(6) + random.below(6)
for stat in PRIME_STATS for stat in PRIME_STATS
@@ -979,21 +1008,29 @@ class StatsBuilder:
random.below(8) + values[StatType.intelligence] // 6 random.below(8) + values[StatType.intelligence] // 6
) )
stats = Stats(values) stats = Stats(values)
self.history.append(stats) self.history.append(_Roll(stats, seed, self._rng.state()))
return stats return stats
def unroll(self) -> Stats: def unroll(self) -> Stats:
if len(self.history) > 1: if len(self.history) > 1:
self.history.pop() self.history.pop()
return self.history[-1] self._rng = Alea.from_state(self.history[-1].after)
return self.history[-1].stats
def create_player( def create_player(
name: str, race: Race, class_: Class, stats: Stats name: str,
race: Race,
class_: Class,
stats: Stats,
seed: T.Optional[RngState] = None,
) -> Player: ) -> Player:
now = datetime.datetime.now()
random.seed(str(now))
player = Player( player = Player(
birthday=now, name=name, race=race, class_=class_, stats=stats birthday=datetime.datetime.now(),
name=name,
race=race,
class_=class_,
stats=stats,
rng=Alea.from_state(seed) if seed is not None else None,
) )
return player return player
+98 -3
View File
@@ -1,9 +1,104 @@
import random import contextlib
import time
import typing as T import typing as T
RngState = T.Tuple[float, float, float, int]
_2_POW_32 = 0x100000000
_2_POW_MINUS_32 = 2.3283064365386963e-10
def _to_uint32(value: float) -> float:
# JS `value >>> 0` for the non-negative values Mash produces
return float(int(value) % _2_POW_32)
def _mash() -> T.Callable[[str], float]:
n = float(0xEFC8249D)
def mash(data: str) -> float:
nonlocal n
# JS strings are UTF-16, and charCodeAt() returns code units
units = data.encode("utf-16-le")
for i in range(0, len(units), 2):
n += units[i] | (units[i + 1] << 8)
h = 0.02519603282416938 * n
n = _to_uint32(h)
h -= n
h *= n
n = _to_uint32(h)
h -= n
n += h * _2_POW_32
return _to_uint32(n) * _2_POW_MINUS_32
return mash
class Alea:
"""Johannes Baagøe's Alea PRNG (MIT), as used by Progress Quest web.
Ported bit for bit so that games seeded alike play out alike. Saves
pickle this class by import path, so it must stay in this module.
"""
def __init__(self, *args: str) -> None:
if not args:
args = (str(int(time.time() * 1000)),)
mash = _mash()
self._s0 = mash(" ")
self._s1 = mash(" ")
self._s2 = mash(" ")
self._c = 1
for arg in args:
self._s0 -= mash(arg)
if self._s0 < 0:
self._s0 += 1
self._s1 -= mash(arg)
if self._s1 < 0:
self._s1 += 1
self._s2 -= mash(arg)
if self._s2 < 0:
self._s2 += 1
@classmethod
def from_state(cls, state: RngState) -> "Alea":
rng = cls.__new__(cls)
rng._s0, rng._s1, rng._s2, rng._c = state
return rng
def state(self) -> RngState:
return (self._s0, self._s1, self._s2, self._c)
def random(self) -> float:
t = 2091639 * self._s0 + self._c * _2_POW_MINUS_32
self._s0 = self._s1
self._s1 = self._s2
self._c = int(t)
self._s2 = t - self._c
return self._s2
def uint32(self) -> int:
return int(self.random() * _2_POW_32)
_current = Alea()
def seed(source: str) -> None: def seed(source: str) -> None:
random.seed(source.encode()) global _current
_current = Alea(source)
@contextlib.contextmanager
def using(rng: Alea) -> T.Iterator[None]:
"""Draw from rng instead of the shared generator inside the block."""
global _current
previous = _current
_current = rng
try:
yield
finally:
_current = previous
def choice(source: T.Sequence[T.Any]) -> T.Any: def choice(source: T.Sequence[T.Any]) -> T.Any:
@@ -15,7 +110,7 @@ def choice_low(source: T.Sequence[T.Any]) -> T.Any:
def below(num: int) -> int: def below(num: int) -> int:
return random.randint(0, num - 1) return _current.uint32() % num
def below_low(num: int) -> int: def below_low(num: int) -> int:
+5 -1
View File
@@ -124,7 +124,11 @@ class BasicUserInterface(BaseUserInterface):
break break
player = create_player( player = create_player(
name=name, race=race, class_=class_, stats=stats name=name,
race=race,
class_=class_,
stats=stats,
seed=stats_builder.seed,
) )
self.roster.players.append(player) self.roster.players.append(player)
if auto_play and self.confirm( if auto_play and self.confirm(
+10 -4
View File
@@ -9,6 +9,7 @@ from pqcli import lingo
from pqcli.config import Class, Race from pqcli.config import Class, Race
from pqcli.game_data import GameData from pqcli.game_data import GameData
from pqcli.mechanic import Player, Stats, create_player from pqcli.mechanic import Player, Stats, create_player
from pqcli.random import RngState
from pqcli.roster import Roster from pqcli.roster import Roster
from pqcli.ui.base import BaseUserInterface from pqcli.ui.base import BaseUserInterface
from pqcli.ui.curses.colors import ( from pqcli.ui.curses.colors import (
@@ -156,8 +157,8 @@ class CursesUserInterface(BaseUserInterface):
view.on_cancel += lambda: self._switch_to_create_char_class_view( view.on_cancel += lambda: self._switch_to_create_char_class_view(
player_name, race, class_ player_name, race, class_
) )
view.on_confirm += lambda stats: self._create_character( view.on_confirm += lambda stats, seed: self._create_character(
player_name, race, class_, stats player_name, race, class_, stats, seed
) )
self._switch_view(view) self._switch_view(view)
@@ -196,9 +197,14 @@ class CursesUserInterface(BaseUserInterface):
self._view.start() self._view.start()
def _create_character( def _create_character(
self, name: str, race: Race, class_: Class, stats: Stats self,
name: str,
race: Race,
class_: Class,
stats: Stats,
seed: RngState,
) -> None: ) -> None:
player = create_player(name, race, class_, stats) player = create_player(name, race, class_, stats, seed)
self.roster.players.append(player) self.roster.players.append(player)
if self.args.use_saves: if self.args.use_saves:
self.roster.save() self.roster.save()
@@ -55,7 +55,7 @@ class ChooseCharacterStatsView(BaseView):
elif self._active_widget == 1: elif self._active_widget == 1:
self._stats = self._stats_builder.unroll() self._stats = self._stats_builder.unroll()
elif self._active_widget == 2: elif self._active_widget == 2:
self.on_confirm(self._stats) self.on_confirm(self._stats, self._stats_builder.seed)
elif self._active_widget == 3: elif self._active_widget == 3:
self.on_cancel() self.on_cancel()
else: else:
@@ -68,7 +68,7 @@ class ChooseCharacterStatsView(BaseView):
self._stats = self._stats_builder.unroll() self._stats = self._stats_builder.unroll()
elif key == curses.KEY_F10: elif key == curses.KEY_F10:
self.on_confirm(self._stats) self.on_confirm(self._stats, self._stats_builder.seed)
elif key in KEYS_CYCLE: elif key in KEYS_CYCLE:
self._active_widget = (self._active_widget + 1) % 4 self._active_widget = (self._active_widget + 1) % 4
+1789 -1787
View File
File diff suppressed because it is too large. Load diff
+1775 -1775
View File
File diff suppressed because it is too large. Load diff
+1847 -1852
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -6,6 +6,7 @@ from pqcli import random
from pqcli.config import StatType from pqcli.config import StatType
from pqcli.game_data import load_game_data from pqcli.game_data import load_game_data
from pqcli.mechanic import Player, Simulation, Stats, monster_task from pqcli.mechanic import Player, Simulation, Stats, monster_task
from pqcli.random import Alea
STATS = { STATS = {
StatType.strength: 12, StatType.strength: 12,
@@ -28,6 +29,7 @@ def run_trace(seed: str, ticks: int = 3000) -> T.List[str]:
race=data.races[0], race=data.races[0],
class_=data.classes[0], class_=data.classes[0],
stats=Stats(dict(STATS)), stats=Stats(dict(STATS)),
rng=Alea(seed),
) )
random.seed(seed) random.seed(seed)
simulation = Simulation(player, data) simulation = Simulation(player, data)
@@ -40,6 +42,7 @@ def run_trace(seed: str, ticks: int = 3000) -> T.List[str]:
for _ in range(ticks): for _ in range(ticks):
simulation.tick(100000.0) simulation.tick(100000.0)
with random.using(player.rng):
# plot cinematics and quests are rare in a short run, so force them # plot cinematics and quests are rare in a short run, so force them
for _ in range(30): for _ in range(30):
simulation.interplot_cinematic() simulation.interplot_cinematic()
+59
View File
@@ -1,4 +1,5 @@
import datetime import datetime
import pickle
import re import re
import pytest import pytest
@@ -11,9 +12,12 @@ from pqcli.mechanic import (
Player, Player,
Simulation, Simulation,
Stats, Stats,
StatsBuilder,
create_player,
impressive_guy, impressive_guy,
level_up_time, level_up_time,
) )
from pqcli.random import Alea
def test_level_up_time_matches_upstream() -> None: def test_level_up_time_matches_upstream() -> None:
@@ -24,6 +28,10 @@ def test_level_up_time_matches_upstream() -> None:
assert level_up_time(100) == 70460007 assert level_up_time(100) == 70460007
RACE = load_game_data().races[0]
CLASS = load_game_data().classes[0]
def make_simulation() -> Simulation: def make_simulation() -> Simulation:
data = load_game_data() data = load_game_data()
player = Player( player = Player(
@@ -32,6 +40,7 @@ def make_simulation() -> Simulation:
race=data.races[0], race=data.races[0],
class_=data.classes[0], class_=data.classes[0],
stats=Stats({stat: 10 for stat in StatType}), stats=Stats({stat: 10 for stat in StatType}),
rng=Alea("test"),
) )
return Simulation(player, data) return Simulation(player, data)
@@ -181,3 +190,53 @@ def test_prologue_fills_plot_bar() -> None:
] ]
assert positions[5][0] == "Loading Act I" assert positions[5][0] == "Loading Act I"
assert positions[6][0] == "Heading to the killing fields" 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
+88
View File
@@ -0,0 +1,88 @@
import pickle
import pytest
from pqcli import random
from pqcli.random import Alea
# First outputs and resulting state of upstream's JS Alea(seed).uint32()
VECTORS = {
"alpha": (
[3768637044, 756790225, 3147161637, 1726577786, 279486812],
(0.732755669625476, 0.4020002172328532, 0.06507309433072805, 368555),
),
"": (
[715789690, 2091287642, 486307, 3866591158, 760028033],
(
0.00011322717182338238,
0.9002609080635011,
0.17695781611837447,
1018452,
),
),
"1697543210123": (
[3110287155, 2843160345, 1794106530, 2361562046, 1804721005],
(
0.41772297816351056,
0.5498440111987293,
0.42019435320980847,
1384612,
),
),
# Mash hashes UTF-16 code units, so the dragon counts as two
"Ünïcødé 🐉": (
[4162081648, 198215584, 541466577, 6418444, 2254846220],
(
0.12607001163996756,
0.0014944104477763176,
0.5249972967430949,
96530,
),
),
}
@pytest.mark.parametrize("seed", VECTORS)
def test_alea_matches_upstream(seed: str) -> None:
first, state = VECTORS[seed]
rng = Alea(seed)
assert [rng.uint32() for _ in first] == first
assert rng.state() == state
def test_alea_state_round_trip() -> None:
rng = Alea("alpha")
rng.uint32()
copy = Alea.from_state(rng.state())
assert [copy.uint32() for _ in range(100)] == [
rng.uint32() for _ in range(100)
]
def test_alea_pickles_with_its_state() -> None:
rng = Alea("alpha")
rng.uint32()
copy = pickle.loads(pickle.dumps(rng))
assert copy.state() == rng.state()
def test_unseeded_alea_differs() -> None:
assert Alea().state() != Alea("alpha").state()
def test_below_is_uint32_modulo() -> None:
# Random(n) = seed.uint32() % n
random.seed("alpha")
assert [random.below(1000) for _ in range(5)] == [
n % 1000 for n in VECTORS["alpha"][0]
]
def test_using_restores_previous_generator() -> None:
random.seed("outer")
expected = Alea("outer").uint32() % 1000
inner = Alea("inner")
with random.using(inner):
random.below(10)
assert inner.state() != Alea("inner").state()
assert random.below(1000) == expected