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
+5741 -5452

No files matched your search

+55 -18
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,37 +973,64 @@ 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:
values: T.Dict[StatType, int] = {
stat: 3 + random.below(6) + random.below(6) + random.below(6)
for stat in PRIME_STATS
}
values[StatType.hp_max] = (
random.below(8) + values[StatType.condition] // 6
)
values[StatType.mp_max] = (
random.below(8) + values[StatType.intelligence] // 6
)
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
}
values[StatType.hp_max] = (
random.below(8) + values[StatType.condition] // 6
)
values[StatType.mp_max] = (
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