Skip to content

Commit 59540cd

Browse files
Make a seed reproduce a run, and give the sector-2 boss teeth
Adding a per-sector breakdown to the balance bench turned up two things. A seed reproduced nothing The same seed, run three times in one process: won, lost at sector 4, lost at sector 5. Boss patterns and enemy behaviour drew from the module-level `random` rather than from the run's own generator, so nothing about a fight was reproducible. Nothing crashed and no test failed -- the game just was not deterministic, which means the balance figures in the previous commit were reporting run-to-run noise as if it were the effect of a change, and a seed in a bug report reproduced nothing. Every roll that decides how a fight goes now comes from the run's RNG: boss drift, spiral phase, wall gaps, rain, beam comb order, turret sides, pod and turret stagger, enemy station, drift and fire cadence, pickup scatter. Particles, shake and the starfield stay on the global generator deliberately -- they cannot change an outcome, and threading a seeded generator through the particle pool would be churn for no property gained. test_determinism.py fingerprints a patrol and all five bosses, driven twice from one seed with a fixed input weave, with the global generator deliberately disturbed in between so a leaked roll cannot accidentally line up. Its first version passed with the bug reintroduced: it traced only the sector-2 boss, and HIVE MOTHER has no walls, so a wall's roll leaking changed nothing it could see. Tracing all five, the same injected bug fails SENTINEL, BULWARK and WARDEN. Its sanity check -- a different seed must change the trace -- also had to be loosened from one alternate seed to several. LANCE's opening rolls exactly one die, its drift direction, so four seeds in a row can legitimately produce an identical fight. Requiring one specific seed to differ was testing the seed rather than the game. HIVE MOTHER was the weakest of the five Per sector, at PILOT: SENTINEL 2.3 hull, HIVE MOTHER 1.0, LANCE 3.2, BULWARK 5.1, WARDEN 6.3 -- and half of HIVE MOTHER's fights ended without a scratch, on the boss most players meet second. Escorts alone are not a pattern: they are enemies, and enemies are the thing a player is already good at. Its wall guns are up from the first phase, escorts come down the flanks as well as out of the bays, and it keeps firing while they close instead of waiting its turn. Also corrected: the map figures in the READMEs were measured before REST_CHANCE went from 0.45 to 0.55 and did not match the shipped code. On the code as it stands: 0.54 repair bays a sector, 54% of sectors offering one, 12% forcing it. The win-rate table in pc/README.md still carries the figures measured on the non-deterministic build. A 24-seed run on this build is in flight and those numbers land in the next commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AKLqS4PgnCXrzKHDH8Cnzy
1 parent 0ef6446 commit 59540cd

8 files changed

Lines changed: 236 additions & 58 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ spirales, peignes de rayons, escortes, modules destructibles et canons sur les
3737
murs de l'arène. Les motifs sont denses mais jamais aléatoires : ils sont faits
3838
pour être lus, et chaque boss ménage une **fenêtre** où tout se tait, le temps
3939
de riposter. La carte, elle, est un vrai choix — un atelier de réparation n'est
40-
proposé que dans 42 % des secteurs, sur une seule branche, jamais avant le boss.
40+
proposé que dans 54 % des secteurs, sur une seule branche, jamais avant le boss.
4141
Zoom toujours entier, arène de taille fixe quelle que soit la résolution, et
4242
20 effets sonores synthétisés au démarrage (aucun fichier audio) —
4343
[à écouter](docs/nova-sound-demo.wav). Le tir étant automatique, `M` fait le

‎pc/README.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -260,9 +260,9 @@ Les nœuds spéciaux sont maintenant posés **un par un**, pas par colonne :
260260

261261
| | avant | maintenant |
262262
|---|---|---|
263-
| Ateliers par secteur | 1,87 | **0,41** |
264-
| Secteurs qui en proposent un | 100 % | **42 %** |
265-
| Toutes les routes en croisent un | 100 % | **11 %** |
263+
| Ateliers par secteur | 1,87 | **0,54** |
264+
| Secteurs qui en proposent un | 100 % | **54 %** |
265+
| Toutes les routes en croisent un | 100 % | **12 %** |
266266
| Atelier juste avant le boss | 100 % | **0 %** |
267267

268268
Quand les deux sont présents, **le marchand et l'atelier sont dans la même

‎pc/nova/boss.py‎

Lines changed: 43 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -90,13 +90,13 @@ def draw(self, surf, top):
9090
class Pod:
9191
"""A turret bolted to a boss. Armours the core while it lives."""
9292

93-
def __init__(self, offset_x, offset_y, hp):
93+
def __init__(self, offset_x, offset_y, hp, rng):
9494
self.ox = offset_x
9595
self.oy = offset_y
9696
self.hp = hp
9797
self.max_hp = hp
9898
self.flash = 0.0
99-
self.fire_t = random.uniform(0.5, 1.6)
99+
self.fire_t = rng.uniform(0.5, 1.6)
100100
self.w = 11
101101
self.h = 7
102102
self.x = 0.0
@@ -123,14 +123,14 @@ class Turret:
123123

124124
ARM = 1.05
125125

126-
def __init__(self, side, y, hp):
126+
def __init__(self, side, y, hp, rng):
127127
self.side = side # -1 left wall, +1 right wall
128128
self.y = y
129129
self.hp = hp
130130
self.max_hp = hp
131131
self.flash = 0.0
132132
self.t = 0.0
133-
self.fire_t = self.ARM + random.uniform(0.15, 0.8)
133+
self.fire_t = self.ARM + rng.uniform(0.15, 0.8)
134134
self.w = 9
135135
self.h = 11
136136

@@ -194,17 +194,22 @@ def _clamp(v, lo, hi):
194194

195195

196196
class Boss:
197-
def __init__(self, index, sector, hp, x, y, fire_bonus=0.0):
197+
def __init__(self, index, sector, hp, x, y, fire_bonus=0.0, rng=None):
198198
self.index = index % BOSS_COUNT
199199
self.sector = sector
200200
self.fire_bonus = fire_bonus
201+
# Every roll a boss makes comes from here. Left on the module-level
202+
# `random`, a seeded run was not reproducible at all: the same seed
203+
# won once and lost the next two times, and every balance figure
204+
# measured against it was noise wearing a number's clothes.
205+
self.rng = rng or random
201206
self.hp = hp
202207
self.max_hp = hp
203208
self.x = x
204209
self.y = y
205210
self.t = 0.0
206211
self.fire_t = 1.2
207-
self.drift = random.choice((-1, 1)) * 34.0
212+
self.drift = self.rng.choice((-1, 1)) * 34.0
208213
self.flash = 0.0
209214
self.phase = 0
210215
self.w = 37
@@ -220,11 +225,12 @@ def __init__(self, index, sector, hp, x, y, fire_bonus=0.0):
220225
self.cycle = 0 # alternates attacks within a phase
221226
self.storming = True
222227
self.burst_t = self.STORM[0][0]
223-
self.spin = random.uniform(0.0, math.tau)
228+
self.spin = self.rng.uniform(0.0, math.tau)
224229
self.stream_t = 0.0
225230
if self.index in (BULWARK, WARDEN):
226231
pod_hp = max(8, hp // 6)
227-
self.pods = [Pod(-34, 5, pod_hp), Pod(34, 5, pod_hp)]
232+
self.pods = [Pod(-34, 5, pod_hp, self.rng),
233+
Pod(34, 5, pod_hp, self.rng)]
228234

229235
# -- helpers ----------------------------------------------------------
230236
@property
@@ -406,7 +412,7 @@ def wall(self, combat, gap=56, speed=90, step=15):
406412
stop shooting and go somewhere -- and it asks it in a way nobody can
407413
misread.
408414
"""
409-
hole = random.uniform(data.PLAY_L + gap, data.PLAY_R - gap)
415+
hole = self.rng.uniform(data.PLAY_L + gap, data.PLAY_R - gap)
410416
x = data.PLAY_L + 6
411417
while x < data.PLAY_R - 6:
412418
if abs(x - hole) > gap / 2:
@@ -421,9 +427,9 @@ def rain(self, combat, count, speed=122):
421427
Pressure rather than a threat: it takes space away without ever
422428
chasing anyone, which is what makes the aimed attacks land."""
423429
for _ in range(count):
424-
x = random.uniform(data.PLAY_L + 8, data.PLAY_R - 8)
430+
x = self.rng.uniform(data.PLAY_L + 8, data.PLAY_R - 8)
425431
combat.shots.append(ent.EnemyBullet(
426-
x, data.TOP + 2, random.uniform(-16, 16), speed, 2))
432+
x, data.TOP + 2, self.rng.uniform(-16, 16), speed, 2))
427433
combat.audio.play("enemy_shoot", 0.5, throttle=0.08)
428434

429435
def deploy_turrets(self, combat, phase, hp=None):
@@ -433,9 +439,10 @@ def deploy_turrets(self, combat, phase, hp=None):
433439
self.deployed = phase
434440
arena = data.BOT - data.TOP
435441
hp = hp or max(6, self.max_hp // 11)
436-
side = random.choice((-1, 1))
442+
side = self.rng.choice((-1, 1))
437443
for f, s in ((0.34, side), (0.60, -side)):
438-
self.turrets.append(Turret(s, data.TOP + arena * f, hp))
444+
self.turrets.append(
445+
Turret(s, data.TOP + arena * f, hp, self.rng))
439446
combat.audio.play("boss_warn", 0.45)
440447

441448
def cast_beams(self, combat, comb=False):
@@ -448,14 +455,15 @@ def cast_beams(self, combat, comb=False):
448455
start = data.PLAY_L + span * 0.13
449456
step = (span * 0.74) / (n - 1)
450457
order = list(range(n))
451-
if random.random() < 0.5:
458+
if self.rng.random() < 0.5:
452459
order.reverse()
453460
for i, slot in enumerate(order):
454461
self.beams.append(Beam(start + slot * step, width,
455462
delay=i * 0.32))
456463
elif self.phase >= 1:
457464
self.beams.append(Beam(tx, width))
458-
self.beams.append(Beam(tx + random.choice((-78, 78)), width))
465+
self.beams.append(
466+
Beam(tx + self.rng.choice((-78, 78)), width))
459467
else:
460468
self.beams.append(Beam(tx, width))
461469
combat.audio.play("boss_warn", 0.5)
@@ -498,27 +506,38 @@ def act_sentinel(self, dt, combat):
498506
self.spray(combat, self.scaled(5, 7), 0.17, 134)
499507

500508
def act_hive(self, dt, combat):
501-
"""The bays are the threat, and from phase 1 the walls are too."""
509+
"""The bays are the threat, and the walls are armed from the start.
510+
511+
Measured as the weakest of the five by a distance -- 1.0 hull a fight
512+
and half of them finished without a scratch, on the boss most players
513+
meet second. The reason is that escorts alone are not a pattern: they
514+
are enemies, and enemies are the thing a player is already good at.
515+
So they come down the flanks as well as out of the bays, the wall guns
516+
are up from the first phase, and the boss keeps firing while they
517+
close instead of politely waiting its turn.
518+
"""
519+
self.deploy_turrets(combat, 0)
502520
self.spawn_t -= dt
503521
if self.spawn_t <= 0:
504-
self.spawn_t = (3.4, 2.6, 1.9)[self.phase]
522+
self.spawn_t = (3.0, 2.4, 1.8)[self.phase]
505523
wave = (3, 4, 5)[self.phase]
506524
for i in range(wave):
507-
kind = data.RUSHER_ID if (i % 2 and self.phase) else data.GRUNT_ID
525+
kind = data.RUSHER_ID if i % 2 else data.GRUNT_ID
508526
combat.spawn_escort(kind,
509527
self.x + (i - (wave - 1) / 2.0) * 18,
510528
self.y + 12)
529+
if self.phase >= 1:
530+
# down the flanks, where nobody is looking
531+
for x in (data.PLAY_L + 26, data.PLAY_R - 26):
532+
combat.spawn_escort(data.RUSHER_ID, x, data.TOP + 8)
511533
combat.audio.play("jump", 0.5)
512-
if self.phase >= 1:
513-
self.deploy_turrets(combat, 1)
514534
self.fire_t -= dt
515535
if self.fire_t <= 0:
516-
self.fire_t = self.fire_rate() * 1.2
536+
self.fire_t = self.fire_rate()
517537
self.cycle += 1
538+
self.rain(combat, self.scaled(5, 9))
518539
if self.cycle % 2:
519-
self.rain(combat, self.scaled(4, 7))
520-
else:
521-
self.spray(combat, self.scaled(3, 5), 0.28, 108)
540+
self.spray(combat, self.scaled(3, 5), 0.26, 112)
522541

523542
def act_lance(self, dt, combat):
524543
"""Charge, telegraph, fire -- one beam, then two, then a comb that

‎pc/nova/combat.py‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ def __init__(self, run, kind, art, audio=None):
7878
# One boss per sector, cycling once the campaign is behind you.
7979
self.boss = boss_mod.Boss(sector, sector, self.boss_hp(),
8080
(data.PLAY_L + data.PLAY_R) / 2,
81-
data.TOP - 20, run.fire_bonus)
81+
data.TOP - 20, run.fire_bonus, self.rng)
8282
self.tag = self.boss.name
8383
self.intro_t = 2.6
8484
self.audio.play("boss_warn")
@@ -120,7 +120,7 @@ def spawn_wave(self, dt):
120120
self.budget -= data.ENEMY_COST[kind]
121121
hp = data.ENEMY_HP[kind] + self.run.sector
122122
x = self.rng.uniform(data.PLAY_L + 24, data.PLAY_R - 24)
123-
e = ent.Enemy(kind, x, data.TOP - 14, hp, self.run.sector)
123+
e = ent.Enemy(kind, x, data.TOP - 14, hp, self.run.sector, self.rng)
124124
self.enemies.append(e)
125125
gap = max(0.28, 0.85 - self.run.sector * 0.06) * self.rng.uniform(0.6, 1.4)
126126
self.spawn_t = gap
@@ -130,7 +130,7 @@ def spawn_escort(self, kind, x, y):
130130
arrive in numbers and the boss is the real fight."""
131131
hp = max(1, data.ENEMY_HP[kind] + self.run.sector - 1)
132132
e = ent.Enemy(kind, max(data.PLAY_L + 12, min(data.PLAY_R - 12, x)),
133-
y, hp, self.run.sector)
133+
y, hp, self.run.sector, self.rng)
134134
e.score = data.ENEMY_SCORE[kind] // 2
135135
self.enemies.append(e)
136136
self.particles.burst(x, y, 10, 90, (data.WHITE, data.ORANGE), 0.3)
@@ -152,19 +152,21 @@ def kill_enemy(self, e):
152152
if big:
153153
self.flash.pop(data.WHITE, 0.55)
154154
for _ in range(14):
155-
self.pickups.append(ent.Pickup(e.x + self.rng.uniform(-24, 24),
156-
e.y + self.rng.uniform(-10, 10)))
155+
self.pickups.append(ent.Pickup(
156+
e.x + self.rng.uniform(-24, 24),
157+
e.y + self.rng.uniform(-10, 10), rng=self.rng))
157158
else:
158159
n = 1 + (self.rng.random() < 0.45)
159160
for _ in range(n):
160-
self.pickups.append(ent.Pickup(e.x, e.y))
161+
self.pickups.append(ent.Pickup(e.x, e.y, rng=self.rng))
161162
# A hull patch every eleventh kill sounds modest until you count
162163
# the kills: at eighteen a fight it healed more than the fight
163164
# cost, so a sector's worth of patrols left the ship *fuller* than
164165
# it started and nothing that happened on the way to the boss
165166
# could matter. Measured at 0.09: -0.13 hull per patrol.
166167
if self.rng.random() < 0.03:
167-
self.pickups.append(ent.Pickup(e.x, e.y, kind=1))
168+
self.pickups.append(ent.Pickup(e.x, e.y, kind=1,
169+
rng=self.rng))
168170

169171
def hurt_player(self, p):
170172
if p.invuln > 0:
@@ -368,7 +370,8 @@ def kill_boss(self):
368370
drag=1.2, gravity=40)
369371
for _ in range(16):
370372
self.pickups.append(ent.Pickup(b.x + self.rng.uniform(-30, 30),
371-
b.y + self.rng.uniform(-10, 10)))
373+
b.y + self.rng.uniform(-10, 10),
374+
rng=self.rng))
372375
# escorts do not outlive their carrier
373376
for e in list(self.enemies):
374377
self.kill_enemy(e)

‎pc/nova/entities.py‎

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,12 @@ class Pickup:
6161

6262
__slots__ = ("x", "y", "vx", "vy", "kind", "life")
6363

64-
def __init__(self, x, y, kind=0):
64+
def __init__(self, x, y, kind=0, rng=None):
6565
self.x = x
6666
self.y = y
67-
self.vx = random.uniform(-24, 24)
68-
self.vy = random.uniform(-40, -8)
67+
rng = rng or random
68+
self.vx = rng.uniform(-24, 24)
69+
self.vy = rng.uniform(-40, -8)
6970
self.kind = kind # 0 crystal, 1 hull
7071
self.life = 11.0
7172

@@ -183,18 +184,20 @@ def take_hit(self):
183184

184185
class Enemy:
185186
__slots__ = ("x", "y", "kind", "hp", "max_hp", "t", "fire_t", "anchor",
186-
"drift", "w", "h", "flash", "phase", "score")
187+
"drift", "w", "h", "flash", "phase", "score", "rng")
187188

188-
def __init__(self, kind, x, y, hp, sector):
189+
def __init__(self, kind, x, y, hp, sector, rng=None):
189190
self.kind = kind
190191
self.x = x
191192
self.y = y
192193
self.hp = hp
193194
self.max_hp = hp
194-
self.t = random.uniform(0, 6.0)
195-
self.fire_t = random.uniform(0.4, 1.8)
196-
self.anchor = data.TOP + 40 + random.uniform(0, (data.BOT - data.TOP) * 0.28)
197-
self.drift = random.choice((-1, 1)) * random.uniform(30, 62)
195+
self.rng = rng or random
196+
self.t = self.rng.uniform(0, 6.0)
197+
self.fire_t = self.rng.uniform(0.4, 1.8)
198+
self.anchor = data.TOP + 40 + self.rng.uniform(
199+
0, (data.BOT - data.TOP) * 0.28)
200+
self.drift = self.rng.choice((-1, 1)) * self.rng.uniform(30, 62)
198201
self.flash = 0.0
199202
self.phase = 0
200203
self.score = data.ENEMY_SCORE[kind]
@@ -256,7 +259,7 @@ def wants_to_fire(self, dt, haste=1.0):
256259
rate *= max(0.32, 1.0 - (self.t - 40.0) * 0.022)
257260
self.fire_t -= dt
258261
if self.fire_t <= 0:
259-
self.fire_t = rate * random.uniform(0.75, 1.3)
262+
self.fire_t = rate * self.rng.uniform(0.75, 1.3)
260263
return True
261264
return False
262265

‎pc/tests/run_all.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
HERE = os.path.dirname(os.path.abspath(__file__))
99
SUITE = [("full runs", "test_run.py"),
10+
("determinism", "test_determinism.py"),
1011
("sound catalogue", "test_audio.py"),
1112
("gamepad mapping", "test_gamepad.py"),
1213
("frame budget", "test_perf.py")]

0 commit comments

Comments
 (0)