Commit Graph

8 Commits

Author SHA1 Message Date
claude e0c1e0d5c6 Stage 5: bosses that move, attacks that warn, and a second boss
ci / verify (push) Successful in 49s
Boss movement is a property of the PHASE, not of the boss -- a fight that
stands still and then starts hunting you is one boss with two phases. Four
modes (STATIC, ORBIT, CHASE, WAYPOINTS) handled generically in
SimWorld._move_boss, so a boss that moves is still data. BossDef.stationary
is gone rather than kept beside the phases: a flag claiming the boss stood
still while a phase walked around would be a second source of truth and the
wrong one, so moves() is derived.

CHASE holds a distance instead of closing, because a boss standing on top of
you is a boss whose bullets cannot be read. Waypoints are fractions of the
arena so one phase works in rooms of different sizes. Every mode is speed
clamped in one place -- ORBIT computes an absolute destination and would
otherwise snap onto its circle on the first tick -- and movement slides
against geometry so a boss cannot walk through the pillars its own arena was
designed around.

The room clamp moved to after movement, where it is finally load-bearing. It
was a no-op while every boss stood still, which is exactly when an invariant
is cheapest to establish: boss rooms deliberately do not lock, so walking out
is always an escape, and that only holds if the boss cannot follow.

TelegraphedStrikeEmitter marks spots and fills them a moment later. The moment
between is the feature: a burst at your feet is a coin flip, the same burst
with a second of notice is a question. It stays stateless like every other
emitter -- they are shared resources and two bosses of the same kind must not
stomp each other -- so strike positions are derived from the volley number and
a test asserts the burst lands where the marker promised. Markers are drawn
through fog and through walls, unlike everything else in the view, because a
warning you cannot see is an unavoidable hit with extra steps.

The Cantor of the Vault fights in the choir vault: static, then a four-corner
circuit, then a chase, then orbiting while marking. It exists to prove the
format stretched, and a test asserts it uses both new mechanisms.

Which boss a run has now comes from its SEED rather than its depth. Depth is a
dev flag nothing in play raises, so the arena was keyed to something no player
can change and the second boss was unreachable in an actual game.

Two things found while finishing:

  - tools/export_content.gd had a hand-maintained boss list and had already
    gone stale, silently not writing the Cantor. Content.ALL_ENEMIES and
    ALL_BOSSES now feed the export tool, the renderer and five tests that each
    kept their own copy.
  - diag_loot failed intermittently after another diagnostic. Taking over from
    the bot cleared its input queue but not its HELD input, so a starved server
    coasted on the bot's last movement vector for half a second and walked the
    player off the item it had been placed on. The press arrived correctly,
    which is why "the press reached the simulation" passed while everything it
    should have caused failed.

check.sh clean, 409 tests, SMOKE PASS (19 assertions), all four diagnostics
green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 16:32:12 +02:00
claude b351bc2d55 Stage 4: upgrades, and a quartermaster to spend them at
ci / verify (push) Successful in 48s
Every level banks one choice. Choices queue, and are spent at an NPC in the
hub: walk to it, press E, take one of three weighted options. Seven upgrades,
all data — split shot, glass cannon, spread, sniper, doubleshot, poison,
eraser — and SimWorld gained no per-upgrade branch to run any of them.

The four ambiguities in the brief were settled with the user first, since
each changes what gets written:

  damage      base x (1 + sum additive) x product multiplicative. The flat
              +5% every upgrade carries, spread's -10%, doubleshot's -50%
              and glass cannon's +100% pool; sniper multiplies on top, so
              two snipers is 4x and not +200%.
  glass       half the LEVELLED maximum, multiplying if taken twice, so the
              price does not fade to a rounding error by level 15.
  poison      independent stacks, not a refresh.
  split       +/-45 degrees from the original heading.

Independent poison stacks sound expensive and are not: every dose lasts the
same number of ticks, so doses expire in the order they were added, the
pending expiries are a plain FIFO, and PoisonTrack only ever looks at its
front. O(1) per actor per tick however many are live.

Stats are derived from the upgrade list and never stored, the way level is
derived from experience -- a saved stat cannot disagree with the upgrades
that produced it. Upgrade riders (split charges, poison, erase chance) travel
on the bullet instead, because a shot in flight has to keep what it was fired
with rather than gaining Poison because the shooter just took it.

Two invariants this collided with, both now pinned:

  - bullet speed gained a ceiling. Wall collision samples once per tick, so
    anything over a tile per tick tunnels; two snipers asked for 2480 u/s
    against a 1920 threshold, and a tunnelling bullet looks like a bullet.
  - BULLET_INTEREST_RADIUS rose to 2900, because an upgraded player shot is
    now the longest-travelling bullet in the game. test_interest measured
    the worst case from static content, which upgrades quietly invalidated.

Choosing is intent checked three ways: a choice must be owed, the index must
name one of the three options the SERVER put on the table, and the player
must be standing at the NPC. The offer is rolled once and persisted, so
closing the screen is not a reroll and neither is a crash.

tools/diag_upgrades.tscn covers level -> banked choice -> refused in a
dungeon and refused across the room -> taken at the NPC -> new stats ->
on disk. Bots never walk to the quartermaster, so the smoke test cannot.

Known gap recorded in the roadmap: at PLAYER_BULLET_DAMAGE = 6, the +5% the
first upgrade carries rounds back to 6 and visibly does nothing. It comes out
right in aggregate, but the fix is a balance edit across content.gd and so is
the user's call.

check.sh clean, 357 tests, SMOKE PASS (18 assertions), all four diagnostics
green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 15:34:58 +02:00
claude a943aa19f6 Add a second dungeon: the Proving Grounds, a test harness you walk into
ci / verify (push) Successful in 47s
Two labelled portals now stand side by side in the hub. The Proving Grounds
runs the same generator, the same rooms, the same enemies and the same
four-phase Warden -- enemies at a fifth health, the boss at 288 instead of
3600, and trash dropping potions 80% of the time instead of 8%. A manual pass
over loot, the inventory, dropping and every boss phase takes a couple of
minutes rather than a quarter of an hour.

It is multipliers over the shared content rather than a parallel copy: a
duplicated Content would drift the first time anything was tuned, and
"identical but easier" would quietly stop being true. And it is a portal
rather than a launch flag, so the two can be compared back to back without
restarting the server -- which is most of the point.

Which dungeon you enter is resolved from the player's server-side position,
and PORTAL_USED carries the answer. There is deliberately no client message
that names a dungeon: one would let any client ask for the generous loot table
and bring the results back to the hub. Instance matching compares dungeon ids
too, so walking into one entrance can never drop you into the other's run on
timing alone.

SimWorld.portals replaces portal_pos/portal_enabled, enter_instance carries
the portal list and the dungeon id (the client needs the latter to scale the
boss bar's ceiling the way the server scaled the boss), and Protocol.VERSION
goes to 7.

Also pins what happens when two players reach for one item on the same tick:
exactly one gets it -- the loop is sequential and the pickup erases the entity
before the next player looks. The tie-break is join order rather than distance,
which is arbitrary rather than designed, so it is recorded as such.

Stale doc fixed while here: MapGen.build() still claimed the client rebuilds
the map from the seed, which has not been true since map streaming landed and
is the opposite of the rule.

check.sh clean, 288 tests, SMOKE PASS (18 assertions, both dungeon kinds
opened over a real socket), all three diagnostics green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 21:34:41 +02:00
claude 050b8251a7 Stage 3: inventory, ground loot, and two loot visibilities
ci / verify (push) Successful in 48s
Four always-on-screen slots, items as data, and loot tables on enemies and
bosses. Health potions drop rarely from trash and always from the Warden;
the Warden also drops a Warden's Ration, one per living player, which does
nothing at all.

The ration is not filler. Player-instanced loot is a separate code path from
shared loot -- a distinct entity per owner, filtered per peer in the snapshot
encoder -- and the cheapest way to keep that path honest is to have something
in the game that exercises it on every boss kill.

Item actions ride the input frame rather than becoming new client messages.
InputFrame gained BTN_USE, BTN_DROP and a slot byte, which buys the packet-loss
redundancy, the replay guard on last_input_tick, ordering against movement on
the same tick, and a rate limit of one action per tick -- all of which a
separate RPC would have needed bolted back on. The cost is that anything in
the frame which must not repeat has to be edge-triggered, since frames are
resent and a starved server coasts on the last one it holds.

Instanced loot is enforced in NetCodec.encode_snapshot, beside the actor
interest radius: a peer is never told another player's copy exists. Hiding it
client-side would have been the same mistake as relying on fog to hide enemies.

Inventories live on the character and are written to the store on every
transaction, so a crash between "picked it up" and "wrote it down" cannot lose
or duplicate an item. Anything dropped becomes world-shared whatever it was
before, and a potion used at full health is refused rather than spent.

tools/diag_loot.tscn covers drop -> snapshot -> pick up -> persist -> use ->
drop plus both visibilities on the wire, for the same reason diag_progression
exists: bots are poor shots and almost never produce a drop. It asserts each
input frame was actually consumed, after an early version silently dropped its
first press and every later check passed for the wrong reason.

check.sh clean, 266 tests, SMOKE PASS, all three diagnostics green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 21:16:15 +02:00
claude d8197885ca Hide dead characters, suggest names, make the XP bar live
ci / verify (push) Successful in 46s
The roster sent to clients now contains living characters only. Retirement stays
server-side bookkeeping for archival; from the player's side a dead character is
simply gone, and listing it offers a choice that cannot be taken.

The XP bar only moved on a level-up or a character swap because it read the
character roster, which is re-sent only when the SET of characters changes.
Experience now rides the snapshot -- four bytes on a message already going out
at 20Hz -- and the server mirrors each grant into the world immediately rather
than only when a level is crossed.

The create field starts with a suggested name instead of blank, and offers
another after each creation.

Two bugs found while testing, both mine:

The first was a bad patch of my own: a change meant for the snapshot decoder
also matched inside decode_characters, which then read a four-byte field its
encoder never wrote and ran off the end of every packet. This is precisely the
"encodes but decodes wrong" failure the codec tests exist to catch, and it was
caught within a minute of the test being written.

Chasing that exposed a real robustness gap: StreamPeerBuffer.get_utf8_string()
pushes an engine error and returns garbage when the buffer is short, so a
truncated or hostile character/roster packet produced error spam instead of
degrading. Both decoders now bounds-check every field, with tests that slice
each packet at many lengths and assert it degrades rather than inventing
entries -- the same guarantee the input decoder already had.

206 tests. check.sh, test.sh, smoke.sh and both diagnostics pass.
2026-09-04 01:12:53 +02:00
claude 4765bbce28 Stage 2: accounts, characters, permadeath, levels and experience
ci / verify (push) Successful in 47s
Identity is shaped like Steamworks so swapping to it is one subclass and no
schema change: the client presents an opaque ticket, the server validates it
into a stable 64-bit account id, and nothing downstream sees anything else.
LocalAuthProvider takes any ticket at face value -- insecure on purpose, and
labelled as such everywhere, because the point is the shape rather than the
security. Do not ship it.

Characters persist as JSON keyed by account. Account ids are written as decimal
strings because they are 64-bit and JSON numbers are doubles, which would
silently round them. A corrupt store aborts the server rather than starting
empty: starting empty looks like it worked and then saves over every character
on the first level-up.

Levels 1-15, +10 max health each, level DERIVED from lifetime experience rather
than stored beside it, so a hand-edited save cannot produce a level 12 character
with a level 3's experience. Experience is shared undivided across everyone
alive in the instance -- splitting it would make bringing a friend cost you
progress. A level-up heals by what it added, so gaining one mid-fight is relief
rather than a bar that moved further from full.

Death is permanent and unbinds the character entirely: no "return to the hub as
the character who just died", because the run is over. The record is retired,
never deleted. The five-character cap counts LIVING characters only -- counting
the dead would lock a player out of their own account after five deaths.

Verified by tools/diag_progression.tscn, which drives the real server through
kill -> xp -> level -> health and death -> retire -> roster. The bot smoke test
cannot cover that: bots are poor shots and rarely kill anything. Writing it
caught two real ordering bugs -- the death event was dispatched before the
payload that tells the player they died, and the dead character stayed bound to
the peer.

Also added --account and --store so several clients and test runs can coexist
on one machine. The smoke test now uses a scratch store; without it a rerun
resumed the previous run's characters and "a character was created" quietly
stopped being true.

193 tests. check.sh, test.sh, smoke.sh, diag_progression and diag_prediction
all pass.
2026-09-04 00:44:34 +02:00
claude 005679f1b5 Fix bullet/ship desync; rework death, escape, arrival and hub awareness
(1) Bullets appeared to trail the ship. Two independent causes, measured with
the new tools/diag_prediction.gd rather than guessed at:
  - ServerRuntime ticked before ClientRuntime, so input sampled on frame N was
    not consumed until frame N+1, leaving the drawn ship a constant one tick
    (4.00px at 240 u/s) ahead of the authoritative one that bullets spawn from.
    ClientRuntime now sets process_physics_priority = -10. Gap on a listen
    server: 4.00px -> 0.10px mean, 0.30px worst.
  - PLAYER_MUZZLE_OFFSET was PLAYER_RADIUS + 6 = 12px against a 13px drawn
    ship, so bullets were born inside the sprite. Regression from the previous
    commit's hitbox shrink; it now derives from PLAYER_VISUAL_RADIUS.

(2) No more timed respawn. A downed player stays down until they ask for the
hub (E), which is an ordinary input -- the server has no "revive me" message.

(3) Escape channel 3s -> 1s, and damage no longer cancels it. An interruptible
channel makes killing the process strictly better than using the button, so a
dropped connection now runs the same channel: the player stays in the world as
linkdead, still killable, and is only released once it completes. Instances
refuse to close while a linkdead body is resolving, or a solo drop would delete
it on the next tick and hand the exploit straight back.

(4) Escape opens an in-game menu: return to hub (routed through the same held-
escape channel, not a new message), disconnect, quit.

(5) Server pushes a roster so the hub shows who is online and which dungeon
they are in. Entering a dungeon grants 2s arrival protection -- invulnerable
AND weapons-cold, since invulnerability alone would make the spawn a free
firing position -- flagged in the snapshot and drawn on every protected ship.

(6) Cleared dungeons hold the party 30s (was 5s) with a visible countdown.

(7) The hub's grey circle was a 100k-HP target dummy that read as scenery. Now
drawn as a bullseye so its purpose is legible.

Protocol version 1 -> 2. 91 tests (was 78); smoke.sh gains a bot that is
SIGKILLed mid-dungeon to prove the disconnect path end to end. check.sh,
test.sh and smoke.sh all pass.
2026-09-03 18:43:19 +02:00
claude c4beeae38f Initial commit: Transcience MVP
Top-down twin-stick bullet-hell, Godot 4.7, server-authoritative dedicated
server with client-side prediction. Clients send input only; the server
resolves every hit for both players and enemies (no PvP).

- SimWorld: whole simulation as plain RefCounted objects (no nodes, no
  physics server), ~0.24ms/tick at peak load -- runs headless for free and
  drives 78 tests in under a second
- BulletPool: struct-of-arrays bullet storage, replicated as spawn/despawn
  events rather than per-tick state
- Emitter framework (Ring/AimedSpread/WallGap/ArcSweep) shared by trash
  enemies and bosses -- a new boss is data in src/content/content.gd, no
  simulation changes
- The Warden of the Fold: stationary 4-phase boss built entirely on that
  format
- Lobby hub with a portal into on-demand dungeon instances; one process
  hosts the hub plus every concurrent dungeon
- Emergency escape: 3s server-owned channel, cancelled by damage
- tools/check.sh, test.sh (GUT), smoke.sh (real server + bot clients over
  ENet), bench.gd; git hooks wired to the same scripts
- docs/ARCHITECTURE.md, NETCODE.md, WORKFLOW.md, ROADMAP.md
2026-09-03 16:03:57 +02:00