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

+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