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,
young,
)
from pqcli.random import Alea, RngState
logger = logging.getLogger(__name__)
@@ -346,12 +347,14 @@ class Player(SignalMixin):
race: Race,
class_: Class,
stats: Stats,
rng: T.Optional[Alea] = None,
) -> None:
self.name: str = name
self.birthday: datetime.datetime = birthday
self.race: Race = race
self.class_: Class = class_
self.stats: Stats = stats
self.rng: Alea = rng or Alea()
self.elapsed = 0.0
self.exp_bar = Bar(max_=level_up_time(1))
@@ -369,6 +372,8 @@ class Player(SignalMixin):
def __setstate__(self, obj: T.Any) -> None:
self.__dict__.update(obj)
self.elapsed = obj.get("elapsed", 0.0)
if "rng" not in obj:
self.rng = Alea()
def set_task(self, task: BaseTask) -> None:
self.task = task
@@ -494,6 +499,11 @@ class Simulation:
self.last_tick = datetime.datetime.now()
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
if self.player.task is None:
@@ -963,11 +973,30 @@ def monster_task(
return KillTask(description, duration, monster=monster)
@dataclass
class _Roll:
stats: Stats
seed: RngState # generator state before the roll
after: RngState
class StatsBuilder:
def __init__(self) -> None:
self.history: T.List[Stats] = []
def __init__(self, rng: T.Optional[Alea] = None) -> None:
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:
seed = self._rng.state()
with random.using(self._rng):
values: T.Dict[StatType, int] = {
stat: 3 + random.below(6) + random.below(6) + random.below(6)
for stat in PRIME_STATS
@@ -979,21 +1008,29 @@ class StatsBuilder:
random.below(8) + values[StatType.intelligence] // 6
)
stats = Stats(values)
self.history.append(stats)
self.history.append(_Roll(stats, seed, self._rng.state()))
return stats
def unroll(self) -> Stats:
if len(self.history) > 1:
self.history.pop()
return self.history[-1]
self._rng = Alea.from_state(self.history[-1].after)
return self.history[-1].stats
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:
now = datetime.datetime.now()
random.seed(str(now))
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
+98 -3
View File
@@ -1,9 +1,104 @@
import random
import contextlib
import time
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:
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:
@@ -15,7 +110,7 @@ def choice_low(source: T.Sequence[T.Any]) -> T.Any:
def below(num: int) -> int:
return random.randint(0, num - 1)
return _current.uint32() % num
def below_low(num: int) -> int:
+5 -1
View File
@@ -124,7 +124,11 @@ class BasicUserInterface(BaseUserInterface):
break
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)
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.game_data import GameData
from pqcli.mechanic import Player, Stats, create_player
from pqcli.random import RngState
from pqcli.roster import Roster
from pqcli.ui.base import BaseUserInterface
from pqcli.ui.curses.colors import (
@@ -156,8 +157,8 @@ class CursesUserInterface(BaseUserInterface):
view.on_cancel += lambda: self._switch_to_create_char_class_view(
player_name, race, class_
)
view.on_confirm += lambda stats: self._create_character(
player_name, race, class_, stats
view.on_confirm += lambda stats, seed: self._create_character(
player_name, race, class_, stats, seed
)
self._switch_view(view)
@@ -196,9 +197,14 @@ class CursesUserInterface(BaseUserInterface):
self._view.start()
def _create_character(
self, name: str, race: Race, class_: Class, stats: Stats
self,
name: str,
race: Race,
class_: Class,
stats: Stats,
seed: RngState,
) -> None:
player = create_player(name, race, class_, stats)
player = create_player(name, race, class_, stats, seed)
self.roster.players.append(player)
if self.args.use_saves:
self.roster.save()
@@ -55,7 +55,7 @@ class ChooseCharacterStatsView(BaseView):
elif self._active_widget == 1:
self._stats = self._stats_builder.unroll()
elif self._active_widget == 2:
self.on_confirm(self._stats)
self.on_confirm(self._stats, self._stats_builder.seed)
elif self._active_widget == 3:
self.on_cancel()
else:
@@ -68,7 +68,7 @@ class ChooseCharacterStatsView(BaseView):
self._stats = self._stats_builder.unroll()
elif key == curses.KEY_F10:
self.on_confirm(self._stats)
self.on_confirm(self._stats, self._stats_builder.seed)
elif key in KEYS_CYCLE:
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.game_data import load_game_data
from pqcli.mechanic import Player, Simulation, Stats, monster_task
from pqcli.random import Alea
STATS = {
StatType.strength: 12,
@@ -28,6 +29,7 @@ def run_trace(seed: str, ticks: int = 3000) -> T.List[str]:
race=data.races[0],
class_=data.classes[0],
stats=Stats(dict(STATS)),
rng=Alea(seed),
)
random.seed(seed)
simulation = Simulation(player, data)
@@ -40,6 +42,7 @@ def run_trace(seed: str, ticks: int = 3000) -> T.List[str]:
for _ in range(ticks):
simulation.tick(100000.0)
with random.using(player.rng):
# plot cinematics and quests are rare in a short run, so force them
for _ in range(30):
simulation.interplot_cinematic()
+59
View File
@@ -1,4 +1,5 @@
import datetime
import pickle
import re
import pytest
@@ -11,9 +12,12 @@ 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:
@@ -24,6 +28,10 @@ def test_level_up_time_matches_upstream() -> None:
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(
@@ -32,6 +40,7 @@ def make_simulation() -> Simulation:
race=data.races[0],
class_=data.classes[0],
stats=Stats({stat: 10 for stat in StatType}),
rng=Alea("test"),
)
return Simulation(player, data)
@@ -181,3 +190,53 @@ def test_prologue_fills_plot_bar() -> None:
]
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
+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