Skip to content

Commit 0e84753

Browse files
Shield a boss until it can shoot back, and add a release workflow
A boss holds fire for the length of its name card. The player does not, and a ship parked underneath lands every shot: measured, 5 to 25% of the boss's hull was gone before it acted -- 25% against a stock ship at sector 1, less against a maxed one only because boss hit points scale with the guns brought to the fight. Free damage against something that cannot answer is a gap in the rules, not a reward for skill. The boss is now untouchable for that window. The constant is 2.0s, and combat widens it to cover the whole card: a shield that lapses while the boss is still holding fire only moves the gap a second later. Pods are covered too, or the same trick just relocates to BULWARK and WARDEN. It draws as a circle. A rectangle bracket already means "pods armouring the core" and corner ticks mean "venting" -- three states that change what your shots do, three shapes. test_boss_entrance walks all five bosses with a stock and a maxed loadout and requires the core and every pod to finish the entrance at full hit points. Verified against the bug: with the shield removed it reports the boss losing 14 hull before it acted. Release workflow: push a v* tag and it verifies both editions, builds NOVA.exe on Windows and a Linux binary, bundles dist/ for the calculator, and publishes the release with notes. Nothing can be released past a red suite, and what ships is exactly what the tag points at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AKLqS4PgnCXrzKHDH8Cnzy
1 parent 88c68f6 commit 0e84753

5 files changed

Lines changed: 276 additions & 0 deletions

File tree

‎.github/RELEASE_NOTES.md‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
**NOVA** — un rogue-lite spatial qui tourne sur une calculatrice NumWorks *et*
2+
sur PC. Même jeu, deux éditions : 32 Ko de tas MicroPython d'un côté, pygame de
3+
l'autre.
4+
5+
## Télécharger
6+
7+
| | |
8+
|---|---|
9+
| **`NOVA-windows.exe`** | Windows, un seul fichier. Double-clic, rien à installer. |
10+
| **`NOVA-linux`** | Linux, un seul fichier. `chmod +x NOVA-linux && ./NOVA-linux` |
11+
| **`nova-numworks.zip`** | L'édition calculatrice : les cinq fichiers à coller dans [workshop.numworks.com](https://workshop.numworks.com), plus les instructions. |
12+
13+
Depuis les sources : `pc/play.sh` (ou `pc/play.cmd`) installe et lance tout seul.
14+
15+
## Ce qu'apporte la 1.1
16+
17+
### Les boss sont devenus des combats
18+
19+
Cinq boss, cinq mécaniques, et un vocabulaire commun : **murs à un seul trou**,
20+
spirales tournantes, **peignes de rayons** qui balaient l'arène, pluie, et des
21+
**canons destructibles sur les murs** — parce qu'un combat avec un coin sûr est
22+
un combat qu'on gagne en s'y asseyant.
23+
24+
Rien n'est aléatoire : même spirale à chaque tour, toujours exactement un trou,
25+
rayon fixé là où il visait au début de la charge. Dense, mais fait pour être lu.
26+
27+
Et surtout, ils **respirent** : une tempête de 3 à 4,4 s, puis une fenêtre où
28+
tout se tait, signalée par quatre coins cyan. Sans cette pause le combat n'était
29+
pas plus dur, juste plus long — 60 secondes d'esquive à grignoter le boss entre
30+
deux frôlements.
31+
32+
| | avant | 1.1 |
33+
|---|---|---|
34+
| Dégâts pris par boss | 0,6 | **4,9** |
35+
| Combats finis sans une égratignure | 57 % | **14 %** |
36+
37+
Un boss est aussi **intouchable pendant sa carte de présentation**. Il retenait
38+
son tir ; le joueur non, et 5 à 25 % de sa coque partaient avant qu'il ait le
39+
droit de répondre.
40+
41+
### La carte est enfin un choix
42+
43+
Les nœuds spéciaux étaient posés par colonne entière : toutes les routes
44+
croisaient le marchand *et* l'atelier, et le dernier nœud avant le boss rendait
45+
la coque à neuf. Un atelier n'apparaît plus que dans **54 % des secteurs**, sur
46+
une seule branche, **jamais avant le boss** — et quand marchand et atelier sont
47+
tous les deux là, ils sont dans la même colonne : cristaux **ou** coque.
48+
49+
### Le son
50+
51+
Le tir est automatique, donc il sonne pendant toute la partie. Il est
52+
**14 dB plus discret** et tourne sur trois hauteurs, pour ne jamais rejouer deux
53+
fois le même échantillon. `M` fait le tour de trois réglages — tout, **canon
54+
coupé**, silence — parce que vouloir se passer du canon n'est pas vouloir se
55+
passer du son.
56+
57+
### Le reste
58+
59+
- **Toutes les résolutions horizontales**, sans bandes noires : 1080p,
60+
3440×1440, 1366×768. Zoom toujours entier, arène de taille fixe.
61+
- **Manettes** : sticks, croix, branchement à chaud, deux manettes en coop,
62+
clavier et manette actifs en même temps.
63+
- **Scripts d'installation** `.sh` / `.cmd` qui installent pygame-ce et lancent.
64+
- Musique chiptune synthétisée au démarrage, une boucle par secteur. Aucun
65+
fichier audio dans le dépôt.
66+
- **Une graine reproduit une partie.** Elle ne le faisait pas : la même graine
67+
gagnait puis perdait deux fois, et tous les chiffres d'équilibrage mesuraient
68+
du bruit.
69+
70+
## Difficulté mesurée
71+
72+
Sur 24 graines, avec un pilote automatique à réaction parfaite et sans mémoire
73+
des motifs — un humain est l'inverse :
74+
75+
| | Cadet | Pilote | As |
76+
|---|---|---|---|
77+
| Parties gagnées | 15/24 | 10/24 | 5/24 |
78+
79+
Détail par boss, banc de mesure et raisonnement complet dans
80+
[`pc/README.md`](../pc/README.md).
81+
82+
## Licence
83+
84+
MIT.

‎.github/workflows/release.yml‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
name: Release
2+
3+
# Tag a commit v1.1 (or any v*) and push the tag; this builds the binaries and
4+
# publishes the release. Nothing here can be run by hand against a dirty tree,
5+
# which is the point: what ships is exactly what the tag points at.
6+
on:
7+
push:
8+
tags: ["v*"]
9+
workflow_dispatch:
10+
inputs:
11+
tag:
12+
description: "Existing tag to build and publish"
13+
required: true
14+
15+
permissions:
16+
contents: write
17+
18+
jobs:
19+
# A release that fails its own test suite is worse than no release.
20+
verify:
21+
runs-on: ubuntu-latest
22+
env:
23+
SDL_VIDEODRIVER: dummy
24+
SDL_AUDIODRIVER: dummy
25+
steps:
26+
- uses: actions/checkout@v4
27+
with:
28+
ref: ${{ github.event.inputs.tag || github.ref }}
29+
- uses: actions/setup-python@v5
30+
with:
31+
python-version: "3.11"
32+
- name: Calculator edition
33+
run: |
34+
python3 tools/lint_globals.py src/nova*.py
35+
python3 tools/fiximports.py --check
36+
python3 tools/build.py
37+
git diff --exit-code -- dist/
38+
python3 tests/run_all.py
39+
- name: PC edition
40+
run: |
41+
pip install pygame-ce numpy
42+
python3 pc/tests/run_all.py
43+
44+
build:
45+
needs: verify
46+
strategy:
47+
fail-fast: false
48+
matrix:
49+
include:
50+
- os: windows-latest
51+
asset: NOVA-windows.exe
52+
built: pc/dist/NOVA.exe
53+
- os: ubuntu-latest
54+
asset: NOVA-linux
55+
built: pc/dist/NOVA
56+
runs-on: ${{ matrix.os }}
57+
steps:
58+
- uses: actions/checkout@v4
59+
with:
60+
ref: ${{ github.event.inputs.tag || github.ref }}
61+
- uses: actions/setup-python@v5
62+
with:
63+
python-version: "3.11"
64+
- name: Install
65+
run: pip install pygame-ce numpy pyinstaller
66+
- name: Draw the icon
67+
working-directory: pc
68+
run: python tools/make_icon.py
69+
- name: Build
70+
working-directory: pc
71+
run: pyinstaller --noconfirm --clean nova.spec
72+
- name: Name it for the platform
73+
shell: bash
74+
run: mv "${{ matrix.built }}" "${{ matrix.asset }}"
75+
- uses: actions/upload-artifact@v4
76+
with:
77+
name: ${{ matrix.asset }}
78+
path: ${{ matrix.asset }}
79+
80+
publish:
81+
needs: build
82+
runs-on: ubuntu-latest
83+
steps:
84+
- uses: actions/checkout@v4
85+
with:
86+
ref: ${{ github.event.inputs.tag || github.ref }}
87+
- uses: actions/download-artifact@v4
88+
with:
89+
merge-multiple: true
90+
- name: Bundle the calculator edition
91+
run: |
92+
mkdir -p nova-numworks
93+
cp dist/*.py nova-numworks/
94+
cp docs/INSTALL.md nova-numworks/
95+
zip -r nova-numworks.zip nova-numworks
96+
- name: Publish
97+
env:
98+
GH_TOKEN: ${{ github.token }}
99+
TAG: ${{ github.event.inputs.tag || github.ref_name }}
100+
run: |
101+
gh release create "$TAG" \
102+
--title "NOVA $TAG" \
103+
--notes-file .github/RELEASE_NOTES.md \
104+
NOVA-windows.exe NOVA-linux nova-numworks.zip

‎pc/nova/boss.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,17 @@ def _clamp(v, lo, hi):
194194

195195

196196
class Boss:
197+
# How long the boss is untouchable when it arrives.
198+
#
199+
# It holds fire while its name card is up, and the player does not: a
200+
# maxed ship stands under it and lands every shot for the whole entrance,
201+
# so the fight could begin with the boss already at half hull. Free damage
202+
# against something that cannot answer is not a reward for skill, it is a
203+
# gap in the rules. Combat widens this to cover the whole card, because a
204+
# shield that expires while the boss is still holding fire only moves the
205+
# gap later.
206+
SPAWN_SHIELD = 2.0
207+
197208
def __init__(self, index, sector, hp, x, y, fire_bonus=0.0, rng=None):
198209
self.index = index % BOSS_COUNT
199210
self.sector = sector
@@ -226,6 +237,7 @@ def __init__(self, index, sector, hp, x, y, fire_bonus=0.0, rng=None):
226237
self.storming = True
227238
self.burst_t = self.STORM[0][0]
228239
self.spin = self.rng.uniform(0.0, math.tau)
240+
self.shield_t = self.SPAWN_SHIELD
229241
self.stream_t = 0.0
230242
if self.index in (BULWARK, WARDEN):
231243
pod_hp = max(8, hp // 6)
@@ -255,8 +267,19 @@ def live_pods(self):
255267
def live_turrets(self):
256268
return [t for t in self.turrets if t.alive]
257269

270+
def tick_shield(self, dt):
271+
"""Runs during the entrance too, where `update` does not."""
272+
if self.shield_t > 0:
273+
self.shield_t -= dt
274+
275+
@property
276+
def invulnerable(self):
277+
return self.shield_t > 0
278+
258279
def take_hit(self, dmg):
259280
"""Returns the damage actually dealt."""
281+
if self.invulnerable:
282+
return 0
260283
if self.shielded:
261284
dmg = max(1, dmg // 4)
262285
self.hp -= dmg
@@ -267,6 +290,7 @@ def take_hit(self, dmg):
267290
def update(self, dt, combat):
268291
self.t += dt
269292
self.state_t += dt
293+
self.tick_shield(dt)
270294
if self.flash > 0:
271295
self.flash -= dt
272296

@@ -646,3 +670,11 @@ def draw(self, surf, art):
646670
pygame.draw.rect(surf, data.BLUE,
647671
(int(r[0]) - 2, int(r[1]) - 2,
648672
int(r[2]) + 4, int(r[3]) + 4), 1)
673+
if self.invulnerable:
674+
# A circle, deliberately: a bracket already means "pods armouring
675+
# the core" and corner ticks mean "venting". Three states that
676+
# change what your shots do, three shapes.
677+
rad = 23 + int(4 * abs(math.sin(self.shield_t * 5.0)))
678+
colour = data.CYAN_D if int(self.shield_t * 12) % 2 else data.BLUE
679+
pygame.draw.circle(surf, colour,
680+
(int(self.x), int(self.y)), rad, 1)

‎pc/nova/combat.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ def __init__(self, run, kind, art, audio=None):
8181
data.TOP - 20, run.fire_bonus, self.rng)
8282
self.tag = self.boss.name
8383
self.intro_t = 2.6
84+
# Cover the whole name card, not just the boss's own two seconds:
85+
# the window that needs closing is exactly the one where the boss
86+
# is holding fire, and a shield that lapses first just moves the
87+
# free damage a second later.
88+
self.boss.shield_t = max(self.boss.SPAWN_SHIELD, self.intro_t)
8489
self.audio.play("boss_warn")
8590
else:
8691
self.tag = "S%d-%d" % (sector + 1, run.node + 1)
@@ -267,6 +272,7 @@ def update(self, dt, inputs):
267272
# hold fire while the name card is up
268273
self.intro_t -= dt
269274
self.boss.move(dt, self)
275+
self.boss.tick_shield(dt)
270276
self.boss.t = 0.0
271277
else:
272278
self.boss.update(dt, self)
@@ -325,6 +331,14 @@ def collide_boss(self):
325331
hit = turret
326332
break
327333
if hit is not None:
334+
if b.invulnerable:
335+
# bounces off the entrance shield, same as the core
336+
self.audio.play("hit", 0.3, throttle=0.04)
337+
self.particles.burst(shot.x, shot.y, 2, 90,
338+
(data.BLUE,), 0.2, 1)
339+
if not shot.pierce:
340+
shot.y = -999
341+
continue
328342
hit.hp -= shot.dmg
329343
hit.flash = 0.08
330344
self.audio.play("hit", 0.5, throttle=0.03)

‎pc/tests/test_run.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,47 @@ def run_once(seed, players=1, difficulty=1):
154154
"fights": d.fights, "minutes": d.frames * DT / 60.0, "log": d.log}
155155

156156

157+
def test_boss_entrance():
158+
"""A boss must not be damageable while it is holding fire.
159+
160+
It keeps its guns quiet for the length of its name card, and the player's
161+
do not stop: a ship parked underneath landed 5 to 25% of the boss's hull
162+
before the fight began, depending on sector and loadout. Free damage
163+
against something that cannot answer is a gap in the rules, not a reward.
164+
165+
Pods count. Shielding the core and leaving BULWARK's turrets exposed would
166+
just move the same trick onto two of the five bosses.
167+
"""
168+
from nova.art import Art
169+
from nova.combat import Combat
170+
from nova.run import Run
171+
art = Art()
172+
art.load_fonts()
173+
loadouts = ({}, {data.U_SPREAD: 3, data.U_RATE: 3, data.U_DMG: 3,
174+
data.U_PIERCE: 1})
175+
for sector in range(5):
176+
for ups in loadouts:
177+
run = Run(1, 1, seed=5)
178+
run.sector = sector
179+
for u, v in ups.items():
180+
run.upgrades[u] = v
181+
c = Combat(run, data.N_BOSS, art)
182+
start = c.boss.max_hp
183+
pods = [p.hp for p in c.boss.pods]
184+
p = c.players[0]
185+
while c.intro_t > 0 and c.boss is not None:
186+
p.x = c.boss.x # parked underneath, holding fire
187+
c.update(DT, {"move0": (0, 0), "bomb": False})
188+
assert c.boss is not None, "boss died during its own entrance"
189+
assert c.boss.hp == start, (
190+
"sector %d: boss lost %d hull before it acted"
191+
% (sector + 1, start - c.boss.hp))
192+
assert [p.hp for p in c.boss.pods] == pods, (
193+
"sector %d: a pod was damaged before the boss acted" % (sector + 1))
194+
print(" boss entrance: shielded until it can answer")
195+
return True
196+
197+
157198
def test_maxed_trader():
158199
"""A fully upgraded ship must not break the shop.
159200
@@ -207,6 +248,7 @@ def main():
207248

208249
print()
209250
print("=== edge cases ===")
251+
test_boss_entrance()
210252
test_maxed_trader()
211253
print(" fully-upgraded ship: trader and salvage both handled")
212254

0 commit comments

Comments
 (0)