94ad6b34290ffa258e2b7f3fa50cbdd72d3c222a
25
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
94ad6b3429 |
Stop drawing bullets past the edge of sight -- but never sooner than is safe
ci / verify (push) Successful in 54s
Bullets were drawn everywhere on screen, including out in the black past the edge of sight: shots that could not reach you, streaking across the dark. They now fade out beyond sight, under one condition that bounds the whole change: no enemy bullet may hit you without having been on screen, at full strength, for at least WARNING_TIME (0.75s) first. So each enemy bullet is drawn out to the edge of sight or to however far it could close in that window with you running straight at it -- its own speed, plus acceleration over the window, plus yours -- whichever is further, and fades over 80 units beyond. With today's content the edge of sight always wins: the fastest enemy bullet is 300 u/s, so head-on it is on screen 0.83s before it lands. The speed term is what keeps that true when something faster is added. Player shots fade at the edge of sight however fast they are, since they cannot hurt you; a Sniper's 1500 u/s shot was the commonest thing crossing the dark. Shadow does not hide a bullet. One behind a wall within sight range is still drawn over the darkness, because you can step round the corner into it, and hiding it would be exactly the invisible hit the condition rules out. The guarantee is tested the hard way: every emitter in the content, and bullets far faster than any of them, simulated head-on against a player running at them, measuring how long each was fully drawn before it landed. The rule's weakenings -- no speed term, forgetting the player's own approach, ignoring acceleration -- each fail it. The connect screen's backdrop passes no eye and draws everything, as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
6e872e2cf5 |
HUD and connect screen, second pass
ci / verify (push) Successful in 53s
At 331 of 1500 health the bar read as nearly full. The pack's bar sprite is light all the way through, so the EMPTY part was the brightest thing on it. Every bar now has a dark track inside its frame, scaled with the bar's height -- a fixed inset left the thin experience bar four pixels of track and six of white -- so empty reads as empty. Health carries its number inside the bar, and a damage trail: the part the last hit took stays in a darker red for a moment and then drains, so a hit reads as an amount rather than as a bar that is suddenly shorter. Two quick hits read as one, measured from where the first began. The trail was pale at first and the number vanished against it; darker fixed that. Experience flashes and shows "+N xp" on a kill, summed across a burst so it does not flicker. Every block of HUD text sits on a dark panel, since the floor art is busy enough to beat a text shadow. Centre banners for the moments that change what you should do: a level gained, a boss changing phase (not the phase it was found in), and the escape channel -- which was a second bar in the corner and is now one bar where you are looking, with the key to keep holding. Party members you cannot see get a marker at the edge of sight pointing the way to them. Empty inventory slots step back, so the blank parchment square is no longer the most prominent thing in the row. The connect screen opens on a real boss pattern behind its panel -- the bosses' own emitters, fired into a pool and drawn by the game's own bullet renderer, dimmed. It is primed with five seconds of ticks: every pattern starts at the centre of the screen, which is where the panel is, so unprimed it showed nothing for its first second or two. The title is spaced and underlined, Credits is on the screen itself, Join says Enter works and has focus, the port is validated before it can fail, and "connecting" animates so a slow attempt does not look frozen. The boss-bar logic is pulled out into track_boss() so it can be tested: it waits for the fog to light the boss, stays for the fight, has to be seen again in the next dungeon -- every dungeon's boss is actor 1, so the id alone would have carried "seen" across -- and announces only real phase changes. screenshot.tscn captures the escape banner by progress rather than by frame count, because the channel is server ticks and a completed escape teleports the player out mid-sequence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
19d2295bec |
A UI pass: prompts that tell the truth, and a HUD that does not collide
ci / verify (push) Successful in 53s
Prompts named the default keys whatever was bound. Every action is rebindable, but the HUD said "WASD move", "LMB fire", "hold F" and "1-4 use", the quartermaster said "E talk" and the upgrade screen "Close (E)". All of them now go through Settings.short_label(), and a test rebinds fire, escape, interact and a slot key and checks each prompt follows. The HUD's top-left was a developer's status line -- name, instance id, health as text, frame rate -- with the boss bar drawn over its right end and the level-up notice sharing a row with the escape bar. It is now name and level, where you are, health with its numbers, experience, escape and level-ups, stacked by a running cursor so nothing has a fixed row to collide on. Instance and fps moved behind F1, which is who they were for. Arrival protection is a centred banner rather than a note beside the health bar, because it is the one moment the rules change. A hit used to tint the whole screen red, which in a bullet hell dims the bullets at exactly the moment you are among them. It is an edge now, and the same edge pulses below a third of your health. New: a message feed above the inventory for what the server just confirmed -- items taken, used and dropped, levels gained -- and party health top-right in a dungeon, where the hub shows who is online. The pickup prompt now also names the dungeon under your feet when you stand on its ring, in the same order the server resolves that key: loot first, then the entrance. The boss bar used to announce the boss to anyone within the actor interest radius -- which passes through walls -- so a player in the entrance room was told the Warden was near. It waits until the fog has actually lit the boss once, then stays for the fight. Permadeath was announced only by the roster screen appearing. The client records its own death from the server's event and the roster says who fell, at what level, and where. The connect screen lost a name field that did nothing for a person -- it only named the characters bots create -- gained a proper panel, remembers the last server, and shows the protocol number a mismatch refusal will quote. And screenshot.tscn ran as the player's real account against their real character store, walking their character into dungeons for pictures -- with health now carried between runs, a stray hit would have stuck. It has its own store and account now, like every diagnostic, and a seeded character. The real store was checked: nothing it carries changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
855669a665 |
Fog lit by rays: shadows cut through tiles, and walls stay visible
ci / verify (push) Successful in 52s
The fog asked one question per tile -- can I see its centre? -- and drew or skipped the whole tile on the answer. Every shadow was a staircase, tiles snapped in and out as you walked, and a wall beside you vanished whenever its centre happened to sit behind its neighbour. Visibility casts rays from the eye instead: a fixed sweep, plus two aimed either side of every wall corner, which is what makes each shadow edge a straight line rather than a smear between rays. The view now draws terrain and actors first and then darkness over whatever the rays did not reach, so a shadow runs straight through a tile and an enemy rounding a corner appears a slice at a time. Bullets are a child node and are still drawn over the dark; telegraphs are drawn after it, as they must be. "A bit less strict" is three specific things. A wall face you can see is lit 26 units in, measured perpendicular to the face; shadows end in a short fade rather than a line; and sight fades over its last 64 units rather than stopping at a circle. Still hard fog -- nothing remembered. The first version measured that depth along the ray, and a screenshot caught what no test had: a wall you look along got a sliver, so the wall beside you in a tunnel looked missing -- the old fog's own complaint. Perpendicular depth fixes it, and light stops the instant a ray steps back into the open, so it never rounds a corner, never reaches the far side of a one-tile wall, and never leaves a stepped wall through its other face. Two tiles touching only at a corner are closed. About half a millisecond a frame. Measured against the old fog in the same scene: hub 68 fps against 62, dungeon 240 against 240. The hub's own low rate predates this. MapGrid gains a revision counter so the corner list is rebuilt only when the map changes or the eye crosses a tile. screenshot.tscn now captures a dungeon, a tunnel and the tunnel with F1's overlay, which draws where each ray stopped -- and makes SHOT_DIR rather than failing every capture when it does not exist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|
|
a6084c1a8d |
Tell the roster the truth about a wounded character
ci / verify (push) Successful in 52s
Health survives a run now, so a character sitting on the select screen can be nearly dead -- and the screen was still printing only the ceiling. "level 11, 2000 hp" for a character on 160 is a lie told at the exact moment it matters most, because the next thing that happens is the player walks it into a dungeon under permadeath. The roster message carries current hit points alongside the maximum, and the screen shows a fraction only when it means something: a healthy character still reads "1300 hp" rather than "1300/1300". The field is inserted mid-record, which shifts every field after it, so the protocol version goes to 10 -- a mismatched client would read the active flag out of the middle of a health value. tools/screenshot.gd had no capture for this screen, which is how a UI change to it could have shipped unlooked-at. It now shows a made-up roster of three characters -- whole, badly hurt, nearly dead -- for the same reason the upgrade screen is fed a made-up offer: the point is the layout, not a dungeon run. Getting there needs the player walked off the quartermaster first, or the game scene re-opens the upgrade screen over the top of it every frame. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4aeb6d229d |
Health survives a door, and experience needs you to be there
Arriving anywhere set health to maximum, so a run cost nothing: walk in, take what you could, walk out, walk back in whole. Current health now lives on the character record for the same reason the inventory does -- SimWorld builds a blank player on every instance change, so anything that must survive a door has to be written down -- and it is banked at every transfer, every disconnect, and on every item transaction, since a potion moves an item and hit points in one action. The hub is where that damage is paid off, at 10% of maximum per second: about ten seconds from nothing, fast enough not to be a chore and slow enough to be a decision before walking back in. The dungeon rate is unchanged, so waiting one out is still not a plan. Regeneration is a property of the world now rather than a constant. adopt() no longer touches current health. It is called mid-session by on_choose_upgrade, and reading health from the record there would snap a player back to whatever was last written to disk; recompute_max_hp still clamps downwards so Glass Cannon takes the hit points above the new ceiling with it. ServerRuntime._adopt_character stays the one place a record becomes a live player. Experience is still undivided, but now only among players within XP_AWARD_RADIUS of the kill -- ten percent past what a player can see, so anyone who watched it die is inside and someone parked in the entrance room is not. The death events carry a position for this, and deliberately do not put it on the wire: encode_events still writes the id alone. diag_progression covers the whole chain end to end -- wounded in a dungeon, escaped to the hub with the wound intact, healing there, and walking back in still hurt -- because a unit test cannot transfer a peer. Both halves were mutation-tested against it; the unit suite alone missed the arrival heal entirely. The Proving Grounds is recorded in the roadmap as a debugging tool that does not ship: it pays full experience for a boss with 8% health. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
ef28970654 |
Close three holes an unmodified client could walk through
One account is now one session. Two peers signed in as the same account each got their own player built from the SAME character record, so both held a copy of one bag and whichever wrote last won: drop the single potion from both copies and two potions hit the floor. Reconciling two live mirrors of one record cannot be made to work, so the handshake refuses the second connection -- the newcomer, not the incumbent, or claiming an account would be a way to kick anyone off the server. A muzzle offset is a teleport and the bullet pool never sees it: it samples geometry once per tick. Forward that is 19px, but Doubleshot adds up to 45px sideways, wider than a 32px wall plus the 6px hitbox holding you off it. Six Doubleshots, stand flush against a wall, fire along it, and live shots came out the far side. Shots now start from the shooter when geometry stands in the way, which keeps the count honest. Bullet-spawn events carry the firing actor's exact position, and they were filtered by a flat 2900-unit radius against an 800-unit actor cull. A depth-1 map's diagonal is about 2521, so that radius culled nothing: every enemy that fired was pinpointed for every player, through fog, on terrain the client was deliberately not streamed. The test is now per bullet -- can THIS one reach ground you can see -- which keeps the guarantee the wide radius existed for and drops the radar. Found while fixing the above: a rejection never reached the client. The RPC is queued and only leaves on the frame's flush, so kicking in the same call threw the reason away, and _on_server_disconnected overwrote last_error anyway. Both were true of protocol mismatch too, which is the one message a player most needs. Smoke asserts the reason arrives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
61680d00d7 |
Style the rest of the interface: panels, sliders, scrollbars, cards
ci / verify (push) Successful in 48s
Buttons were the easy half. This is everything else. Dialogs sit on panels now rather than being text over a dimmed world -- the pause menu, character select, upgrades, settings and credits all root through UiTheme.dialog_panel(). The panel stylebox is the pack's frame MODULATED DARK. The set is cream throughout, which is right for buttons and wrong for a dialog over a dark dungeon; tinting keeps the pixel border and the corner shape and lets every label the game already draws in light colours stay readable, instead of recolouring every label in five screens to suit the art. Sliders and scrollbars are the pack's too, section headings sit on its banner ribbon, and an upgrade card's rarity is now its frame rather than a word on it -- three choices are compared at a glance and a colour reads faster than a label. Two bugs of the same shape, and neither was findable without looking at the screen. A Slider and a ScrollBar take their THICKNESS from the stylebox's minimum size, which for a StyleBoxTexture is its content margins. Mine were zero, so both resolved the correct stylebox, reported the correct texture, and drew a groove zero pixels tall. A probe confirmed the theme was resolving perfectly while the track was invisible. Tests now assert every slider and scrollbar stylebox has a non-zero minimum, and it is gotcha 7 in CLAUDE.md. The first attempt at the slider groove also used the pack's HOLLOW bar sprite, whose middle is transparent -- tinting it dark left an outline and nothing else. It uses the solid one. tools/screenshot.tscn gained the upgrade screen, which it has to stage: the game scene owns that screen's visibility and re-asserts it every frame, so setting `visible` lasted exactly one frame, and standing the player at the NPC client-side lasted until reconciliation pulled them back. It now moves the player on both sides and holds it until the shot. That tool has caught four bugs the automated gates all passed. check.sh clean, 468 tests, SMOKE PASS, all four diagnostics green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
132646f6c3 |
Rework the UI on Crusenho's pack; credit it in the game, not just the repo
ci / verify (push) Successful in 48s
Crusenho's Complete UI Essential Pack is CC BY 4.0 -- redistributable and
commercial-friendly, confirmed from the License.txt the pack itself ships --
so unlike the two bdragon packs a subset is committed: twelve PNGs, 48 KB,
under assets/sprites/ui/. Only what is used, because each committed PNG costs
a Godot .import sidecar and a directory nothing references is one nobody
prunes.
UiTheme builds a Theme in code from it -- button states, panels, line edits --
and every screen roots itself through UiTheme.themed_root(). The HUD's bars are
the pack's frame with a tinted fill, drawn as three horizontal slices because
Godot's nine-patch lives on nodes and the HUD is drawn rather than built from
controls. Inventory slots use the pack's slot art at exactly twice the source
size; a non-integer scale on a 1px border reads as a wobble along every edge.
The credits screen is the other half of the request and it is a licence
obligation, not a nicety: two packs are now CC BY, which asks for attribution
"in any reasonable manner", and a markdown file in a source repo is not
reasonable for someone who downloaded a build. Settings -> Credits shows every
source with its terms and a link to the licence text. test_credits.gd asserts
CREDITS.md and docs/ASSETS.md name every entry, so the three cannot drift.
Two things found by actually looking at the screen, which is the point:
- The FIRST version of this styled nothing. A Control inherits its theme from
Control ANCESTORS only, and the chain breaks at the first plain Node or
CanvasLayer -- which is every screen here. get_window().theme set the
property, changed nothing, and read as correct. check.sh, 458 tests and a
clean smoke run all passed with the entire interface unstyled. The theme
test now instantiates every screen and asks what its buttons resolve.
- The settings screen showed Fire bound to the right mouse button, because
the test suite was writing the player's real user://settings.cfg --
rebinding calls save() and nothing had redirected the path. Settings.path
is now redirectable, the fixture points it at a scratch file, and a test
asserts the default is still the player's own.
tools/screenshot.tscn is what found both. It boots the client windowed and
saves the menus, the HUD, settings and credits. Manual, needs a display, and
the only thing in the project that can tell you the interface rendered.
check.sh clean, 460 tests, SMOKE PASS, all four diagnostics green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
872922e9e2 |
Fix boss jitter; add a settings screen for controls and volume
ci / verify (push) Successful in 48s
The jitter had two causes, and the larger one is embarrassing: boss_state() handed back the newest snapshot raw while players and enemies both went through the interpolator. The boss therefore stepped at the 20Hz snapshot rate instead of the frame rate. Invisible for as long as every boss stood still, and the first one that moved looked broken. It is interpolated now -- but not across an instance change, where the previous snapshot describes a different fight in a different room and lerping to it would fling the new boss across the map for a frame. The smaller cause was server-side: a CHASE boss corrects by the SIGN of its distance error, so at the standoff the sign flipped every tick and the boss vibrated a couple of pixels at 60Hz. It has a dead band now. The settings screen covers rebindable controls and volume, reachable from both the main menu and the in-game menu. Bindings are stored as physical keycodes -- following key position, the choice setup_input_map.gd already made -- and labelled back through the active layout so an AZERTY player reads the letter on the key their fingers are on. A rebind replaces every event on the action rather than the first, because an action that kept its alternates would still answer to the key you just moved away from. A key already in use is refused and the clash is named. Reset restores what the PROJECT shipped, captured once before anything overrides it -- captured later it would restore the last session's choice, which is the thing being undone. Effects play on an SFX bus created at runtime, so both sliders are real mixer settings rather than a number multiplied into every play() call. Worth recording: the test suite AND the smoke test both passed while a client logged twelve engine errors on every startup. ConfigFile.get_value(s, k, null) does not mean "no default" -- it means the key is absent and no default was given, and the engine logs an error per action. Nothing caught it because the smoke refutations matched SCRIPT ERROR and friends, and a plain ERROR: is none of those. It surfaced from running the client and reading the output. smoke.sh now asserts no plain engine errors either, excluding by name the one line Godot prints on every clean exit, and reintroducing the bug makes it fail. One mutation caught nothing and should not have: the early return in linear_to_db_clamped was dead code, since the clamp beneath it already prevents negative infinity. Removed rather than left looking tested. check.sh clean, 439 tests, SMOKE PASS (23 assertions), all four diagnostics green, and a real client boots with zero engine errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
4a98cf4b0e |
The Cantor gets its own portal; bullets stop strobing
ci / verify (push) Successful in 48s
A dungeon now names the boss arena it ends in, and the arena decides the boss. Three entrances in the hub: Warden's Descent, The Choir Vault, Proving Grounds. That replaces the seed coin-flip from the last commit, which was the wrong call. Which boss you are about to fight is the single thing a player decides before walking into a dungeon, and rolling it for them makes that decision unavailable. Only the Proving Grounds still leaves its arena open, because a harness you re-enter every couple of minutes wants whichever fight comes up first -- and it is the one place where "either will do" is true. test_every_boss_has_a_dungeon_that_reaches_it is the check that matters here: adding a boss and forgetting to give it a way in is now a failing test rather than a boss nobody meets. The smoke test asserts all three portals open over a real socket -- bots pick theirs by account id, so a run exercises each. Bullets no longer animate. The sheet's eight frames are a COLOUR cycle rather than a shape change, so running it had every bullet on screen strobing through a palette in unison, which with a few hundred in the air is exactly as hard to look at as it sounds. Each bullet holds one frame and turns slowly instead, offset by its own id so a ring of twenty does not rotate as one rigid wheel. The rate is slow enough that nothing completes a full turn inside its own lifetime, so it reads as drift rather than spin, and a test pins that against the longest-lived bullet in the game. The renderer slices four textures at startup now instead of thirty-two. check.sh clean, 414 tests, SMOKE PASS (22 assertions), all four diagnostics green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
42568a40ad |
Fix: every zone change was handing the player a blank character
ci / verify (push) Successful in 48s
SimWorld knows nothing about characters, so Instance.add_peer builds a fresh SimPlayer -- level 1, base stats, empty inventory. Something has to give that player back its character, and only character SELECT ever did. Every portal into a dungeon and every escape back to the hub therefore reset the player's level, experience, upgrades and bag. The record on disk stayed correct throughout, which is what made it read as a display glitch: the level shown was 1 because the level being played really was 1, and the first kill's experience grant partially repaired it, so the numbers appeared to come and go. _place now adopts, through a single _adopt_character that every transfer runs. That let _enter_world_as drop its adopt/reset/adopt dance -- three lines that existed only because reset_for_instance clobbered the health adopt had just computed -- and let the level-up path derive maximum health through recompute_max_hp instead of keeping a second copy of the formula. diag_upgrades now walks hub -> dungeon -> hub after taking an upgrade and asserts level, experience, upgrades, damage, maximum health and inventory all survive each leg. With the fix reverted it reports exactly what was described: level 1, no upgrades, base damage, empty bag. One of the new checks compared health against a formula fed the player's own level, which agrees with itself even when the level is wrong; it compares against the character record instead. Also drops "(every upgrade)" from the choice cards. That a flat bonus rides along with all of them is a design principle, not something a player needs told -- the number is enough. check.sh clean, 409 tests, SMOKE PASS, all four diagnostics green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
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>
|
||
|
|
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>
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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. |
||
|
|
7ef972e3b3 |
Hub character swapping, XP percentage, passive health regeneration
ci / verify (push) Successful in 47s
Character swapping is hub-only, and refused by the SERVER rather than merely greyed out in the menu. Allowing it inside a dungeon would be an instant, uninterruptible exit from danger -- strictly better than the one-second escape channel, which would make that channel pointless. Creating a character in a dungeon is refused for the same reason. Both are covered by diag_progression. Health regenerates at 0.5% of MAXIMUM per second. A percentage rather than a flat rate so it does not become irrelevant at level 15: a capped character regains 1.2 hp/s against a level 1's 0.5, and both take about 200 seconds to heal from nothing. No out-of-combat gate -- at this rate it cannot out-heal anything actually shooting at you, and a trickle that never stops is easier to reason about than a timer players have to learn. A fractional carry is needed because a tick heals well under one hit point, so truncating each tick would heal exactly nothing; there is a test for that specifically. The XP bar now states the percentage and the level it leads to, since a bar answers "how far" vaguely and a number answers it exactly. Recorded the two Stage 3 answers: 4 inventory slots, and the boss's food item is player-instanced now so the mechanism gets exercised rather than deferred. Writing the swap guard's test caught my own mistake: the first version created its spare character through the store, which has no opinion about where you are, so it bypassed the guard it was meant to prove and left a stray character behind that broke a later assertion. 200 tests. check.sh, test.sh, smoke.sh and both diagnostics pass. |
||
|
|
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. |
||
|
|
801f328093 |
Fix idle/run animation flicker and bullet cell bleed; add F1 hitbox overlay
ci / verify (push) Successful in 46s
The ship looked like it played both animations at once because it effectively did. is_moving() read the tail of `pending`, which _reconcile drains on every snapshot -- so it returned false about 20 times a second and the sprite alternated between the run and idle strips. Now reads the last sampled input, which survives the queue being emptied. Bullets were still wrong for a reason my earlier ASCII check could not see: I had dumped alpha only, and this pack animates as a colour shimmer over a fixed silhouette, so identical-looking frames told me nothing. The actual defect was that several sprites overflow their 16px cell and bleed into the neighbour below -- the first "heavy" I picked dragged in a solid slice of the sprite above it. Cells are re-picked to ones with empty borders in every frame, and build_local_assets.py now asserts that rather than trusting the choice, so a future pick that bleeds fails at build time instead of looking like a rendering bug. Added a hitbox overlay on F1 (src/view/debug_draw.gd). It draws what the simulation actually collides against over what is drawn: player hitbox against sprite radius, the muzzle point, enemy and boss radii, aggro rings, every live bullet's radius, and terrain outlined by which of the three flags each tile sets -- so a pit that stops feet but not bullets looks different from a wall. It deliberately ignores fog, since hiding half the evidence would defeat the point, and it reads every number from SimConfig/MapGrid/Content rather than keeping its own copies, or it would just confirm its own mistakes. Nearly every "that looked wrong" report in this project has been art and simulation disagreeing, and each took a round trip to diagnose. This makes that class of bug visible directly. 163 tests. check.sh, test.sh and smoke.sh pass. |
||
|
|
afe76c22ce |
Fix bullet atlas, animation speed, sprite flip, fire rate, silent shots
ci / verify (push) Successful in 46s
Bullet sprites were wrong because I read the pack's layout backwards. Each of
its 8 PNGs is one ANIMATION FRAME, and a column within a file is a COLOUR
variant -- so walking columns cycled the palette while the shape sat still, and
the rows I picked were in a region of larger multi-cell sprites, which is the
scattered debris that showed on screen. tools/build_local_assets.py now composes
a proper atlas: 8 frames across, one row per SimConfig.KIND_*.
Animations ran at whatever the machine's framerate was, because they counted
_process calls. At 240fps that is four times too fast on its own, before the
requested halving. Both actor and bullet animation are now driven by elapsed
seconds, so they look the same on any machine.
The knight shifted sideways instead of mirroring: a Rect2 with negative width
does not flip, the draw call normalises it, so the sprite kept its orientation
and jumped right by its own width. Mirrored through the canvas transform about
the sprite's centre instead.
Shots were silent when a bullet spawned inside an enemy. The sound rode on
BULLET_SPAWN, which is deliberately suppressed for a bullet resolved and removed
within the same tick -- so no event, no sound, and this got more likely the
closer you stood. Firing is now its own event (PLAYER_FIRED): the shot happened
whether or not a bullet survived to be replicated. Protocol 3 -> 4, because
inserting mid-enum shifts the wire value of every event after it.
Fire cooldown 7 -> 14 ticks (4.3 shots/sec).
Licence question 2, confirmed verbatim: the paid tier DOES grant commercial use
("You may use these assets in personal, commercial or non-commercial
projects"), but its next sentence still forbids redistribution "no matter how
much you modify it". Paying moves these from local-only-and-non-commercial to
local-only-and-commercial; it never makes them committable. Only replacing them
with permissively licensed art does that.
Question 3: CREDITS.md now credits every pack, including the two whose licences
do not require it and the unused ones in considering_dont_use_yet/.
158 tests. check.sh, test.sh and smoke.sh pass.
|
||
|
|
e1f9fa8096 |
Unlock menu after failed join; remove contact damage; cover clean disconnects
ci / verify (push) Successful in 45s
A failed connection left the connect buttons disabled forever. _show_menu() returned early when a menu already existed, so the set_busy(false) that re-enables them never ran on the way back from _abort_connect -- the player was locked out of both joining and hosting with no way out but a restart. Also guards against a second attempt stacking on an in-flight one. Contact damage is gone as a concept: nothing in this game hurts you by touching it, because every threat should be a bullet you can see and dodge. The Stalker walked at you and dealt contact damage and nothing else, so it now carries a point-blank shotgun instead -- five pellets, 62 degrees, 18-tick lifetime for about 78px of reach, so it still has to close and leaves nothing lingering. tests/unit/test_content.gd asserts every hostile enemy has an emitter, so a new enemy cannot quietly reintroduce the mechanic. The menu's "Disconnect" DOES get the anti-cheese protection -- verified, not assumed. It calls Net.shutdown(), the socket closes, and the server takes the same linkdead path as a SIGKILL, because the channel is keyed on the socket closing rather than on how it closed. Made permanent: a --leave-after bot flag and two smoke assertions covering the polite exit alongside the hard kill, so a future "clean leave" message that skipped the channel would fail a test. The menu now says so out loud in a dungeon -- the mechanic only reads as fair if the cost is known before clicking. 103 tests, 12 smoke assertions; check.sh, test.sh and smoke.sh all pass. |
||
|
|
f70de1b825 |
Fix permanent input-timing desync; no i-frames; UI respawn; guard dead joins
ci / verify (push) Successful in 45s
The real cause of the ship/bullet separation, which the previous commit only half-addressed. The server dropped inputs past a lead of 12 while the client only re-synced past 16, so a client whose lead drifted into 13-16 had every input silently rejected while believing its timing was fine. The server coasted on held_input and then stopped; the client kept predicting. The two separated permanently and the reconciler fought it every snapshot -- "shoved around". It needed two independent clocks to drift, hence "only after some time", and nothing in the loop could notice, hence "then persists". The listen-server diagnostic could never reproduce it: one process, one physics tick, lead constant by construction. Two defences: INPUT_MAX_LEAD (40) is now far wider than the client's correction band (3..20), asserted by tests/unit/test_input_lead.gd so narrowing it fails a test; and an ack-stall detector re-syncs when last_input_tick stops advancing, which catches the whole class regardless of cause -- lead alone cannot, because a wrong lead looks normal from the client. diag_prediction.gd now injects a +14 tick drift and exits non-zero unless the gap recovers. Also: - No invulnerability frames. Every bullet that touches a player lands; i-frames made dense patterns safer than sparse ones, which inverts the genre. Measured: a stationary player survives ~13.6s of the Warden's opening phase, ~17.5s drifting. spawn_grace remains the only invulnerable state. - Death is exited with a HUD button, disabled for the first 3s. The lockout is enforced in SimWorld, not just by graying the button -- a client that ignores its own UI still waits. The interact key no longer respawns. - Joining a server that is not there no longer drops the player into an empty lobby they cannot act in. Net.join() only creates an ENet object; the game scene now waits for the server to actually place us in an instance, with an 8s timeout, and headless runs exit non-zero instead of idling. Protocol 2 -> 3. 98 tests; check.sh, test.sh and smoke.sh all pass. |
||
|
|
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.
|
||
|
|
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 |