Commit graph spaceship/tests
Author SHA1 Message Date
Loïc Wisniewski
aabd5804cf #148: the main theme plays, and keeps playing
"All the time" is the whole of the requirement and it decides the shape.
The theme has to survive the main menu becoming the game, the game becoming
a planet surface, and the surface becoming the game again - so the player
cannot live in any of those scenes, because change_scene_to_file() frees
whichever one is current.

It is parented to the tree root instead, beside the GameState autoload,
which is the same trick for the same reason. Music.ensure() is idempotent by
node name, so each scene asks for the music on the way up and gets the one
already playing rather than a second copy over the top of it. Two call
sites cover everything: MainMenu, and GameController - which is the root
script of BOTH the space scene and the planet surface.

Done this way rather than as an autoload deliberately: project.godot carries
the developer's own uncommitted window settings, and adding music should not
drag those into a commit.

It is also PROCESS_MODE_ALWAYS. Since #128 a dialog stops the tree, and a
soundtrack that cuts out every time someone reads a line is worse than no
soundtrack.

The theme ships as MP3 rather than the 59MB WAV that was sitting in the
project root. Same 5m36s, 6.7MB, encoded with LAME at 160k - 89% smaller,
which is the difference between a repository that carries its music and one
that carries a WAV forever in its history. The original file is untouched
and still there, untracked, so nothing is lost.

The test checks CONTINUITY rather than "a sound started": the same player,
never a second one, through two scene changes and a dialog.
2026-08-29 13:05:21 +02:00
Loïc Wisniewski
2206b55873 #145, #144, #143: a quest that could not be finished, and two boxes that overhung
#145 - the extractor quest could never be completed. Extractors are placed
onto a planet from space and live in GameState.extractors, with their own
position, stock and id; they are not in `buildings`. So the goal asked
has_building_of_type("extractor"), which is false however many the player
builds. Not the death the reporter guessed at - the quest was simply asking
a question that had no yes.

structure_exists() is the question anything wanting "does the player have
one of these" should ask, and the quest model asks it now. The regression
test builds every structure quest's target THE WAY THE GAME BUILDS IT and
watches the goal flip, rather than knowing which array each one lands in -
the knowing is what got this wrong in the first place.

#144 - the build menu's status line is anchored to the top of the panel and
its grid to the panel's centre, so the gap between the two is half the panel
height. It was a written-down constant, correct only at 1280 tall; at 1080
the grid started four pixels ABOVE the status line and drew through it.

Nothing about that geometry is written against a screen size any more. The
band is measured from the header that is actually there, the pitch closes up
to fit it (never below a row's own height, which is what makes overlap
impossible rather than unlikely), and the column count rises until the rows
fit - so a short window gets a wide grid instead of one running off the
bottom. Ten structures now lay out as 5 columns at 900px tall, 4 at 1080, 3
at 1280 and 2 at 1600, clearing the header at every one, which is what the
test walks.

#143 - a hangar caption is "Gas Bomber\n2 available": two lines before
autowrap and three after it in a narrow column, at a size fixed in the
scene. Labels do not clip by default, so the third line was drawn over the
icon of the row below. The size is fitted to the box the layout works out,
and the box clips - the first keeps it legible, the second keeps it correct
however the vessel names grow.

Its box also overhung by exactly HANGAR_LABEL_GAP: the layout subtracted one
gap where it needed two, above the caption and below it. Two pixels, but two
pixels inside the next row's icon.
2026-08-29 12:51:09 +02:00
Loïc Wisniewski
90c34f6c74 #141, #142: the chain teaches research, and asks for the pile before the build
Two gaps between the first quests, both of the same kind: a step the chain
assumed rather than asked for.

#142 - "collect 5 ore" was followed straight by "build the mothership",
which costs twenty. The first thing the chain ever asked for was four times
what it had just taught, with nothing in between saying so. A stockpile
quest sits between them now.

#141 - the crafter quest's brief said "spend ore on the Shipyards doctrine,
then put a ship crafter down", which is two unrelated things off one line:
open a menu the player has never opened, and place a structure. Researching
Shipyards is its own quest now, and it is the one that says where the
research tree lives. Every later quest that needs a doctrine still only
mentions it in passing - once is enough.

That needed a goal kind for researching a doctrine, which the model did not
have.

Both are pinned as properties rather than as positions in a list. The chain
must ask for at least as much ore as the first structure it wants costs; and
the first structure that is gated behind a doctrine must have that doctrine
as a quest of its own. Only the first - demanding one per building would
double the chain for no teaching.

One thing that fell out of writing those: the pile is exactly the starting
fleet's hold. An interceptor carries 20 and the mothership costs 20, and
before the mothership exists there is nowhere to bank anything, so the hold
is the only store there is. It fits with no slack at all, so there is now an
assertion saying so - a pile larger than the hold would be a quest that
cannot be finished, and nothing else would have caught it.
2026-08-28 15:47:38 +02:00
Loïc Wisniewski
970eafc1de #140: the objective moves behind a button next to the map's
The standing tracker #132 put on the HUD sat in the right-hand column, on
top of the dev wave button - and there is no free strip of that edge to move
it to, because everything down it is already something. So the objective
stops living on the HUD: a quest button beside the map's, and a panel when
you press it.

The panel shows the current objective, its progress, and the road behind it.
Deliberately NOT what comes next - the chain is meant to open the game up a
step at a time (#133), and a list of everything still to come is that
discovery given away on the first press.

It owns its own button, the way WorldMap owns the map button rather than
being wired through BuildController, and it hides that button during the
prologue for the same reason and in the same two places (at _ready as well
as in _process, since #128 means a dialog stops the tree before the first
_process frame ever comes).

Both scenes carry it. On a planet surface there is no map button to sit
beside, so it goes under the planet's name.

The test now checks the button is beside the map's and clear of the wave
button by rectangle rather than by eye, which is the bug the ticket reported.
2026-08-28 15:37:13 +02:00
Loïc Wisniewski
ac4b96ef49 #139: the prologue's debris belt keeps off the screen too
The belt was laid out with its own arithmetic - a centre BELT_AHEAD in front
of the fleet, rocks spread BELT_DEPTH either side of it - so its near edge
reached back to within a screen of the convoy. At the aspect it was
eyeballed on that was just outside the view; at a wider one, or any zoom
out, rocks appeared in plain sight.

#126 already answered this question for the rest of the game, so the answer
is borrowed rather than restated: AsteroidField's keep-out rect and its
push-out-through-the-nearest-edge placement are public now, and the belt
runs through them. That also means the two cannot drift apart later.

The distance moves as well, 2200 to 2900. The clamp alone would work, but it
would work by pressing every stray rock onto the boundary line; with the
belt properly out in front, the clamp has almost nothing left to do and the
belt still looks laid out ahead rather than pinned to the edge of the
screen. Measured: nearest rock 1551 units out, against a keep-out that
reaches 1100.
2026-08-28 15:37:11 +02:00
Loïc Wisniewski
1ece202731 #138: structures and doctrines cost more than rocks, and more the deeper they are
Both halves of the ticket, in the shape vessels have used since gas bombers
and laser ships: a definition carries an ore figure and, when it wants more
than ore, a dictionary that IS the price. `building_costs()` and
`skill_costs()` answer it the way `product_costs()` already did, so there is
one question with one answer whatever is being bought, and every payment
path now goes through `spend_resources` rather than `spend`.

Seven of ten structures and thirteen of seventeen doctrines now want gas,
energy or dark matter alongside their rocks, and the totals climb with tree
depth - structures 16/31/58/62/96 by depth, doctrines 15/30/73/96/143/258/530.

The constraint that shaped the table is REACHABILITY, and it is the reason
some prices stayed ore-only. Energy exists only where a gas burner stands,
and a gas burner needs Power Systems - so nothing at or before that depth
may be priced in energy, or it is a wall with no way through. Dark matter is
rarer still and appears once, on the deepest node in the game. The extractor
stays rocks-only despite its depth allowing gas, because the quest chain
reaches it before it has sent the player for any.

test_progressive_costs checks those as properties rather than transcribing
the numbers, so rebalancing a price costs nothing while breaking the shape
fails: nothing unreachable at its own depth, prices rising with depth, the
ore field agreeing with the dictionary (repairs quote the field alone, since
a repair drone carries ore and nothing else), and the quest chain never
asking for a structure it has not funded.

Two things cleaned up on the way:

- The per-building cost constants in GameState are gone. Nothing but tests
  used them, and they were a second copy of a number the definitions already
  hold - exactly what would drift once a price gained a resource.
- research_for_building() returns a display NAME, and the id-keyed half of
  the tree wants an id. That mix-up is silent - skill_depth() of a name finds
  no node and answers 0, so a deep structure reads as an ungated one, which
  is how the first draft of the test let an unreachable price through.
  research_node_for_building() is the id, and the name is expressed from it.

PriceTag no longer rebuilds its labels when re-told the same price; it
refreshes the held figures instead. The craft menu re-states prices every
frame and the build menu now does too, so that was a dozen Labels being torn
down and rebuilt sixty times a second.
2026-08-28 15:30:30 +02:00
Loïc Wisniewski
ea25318201 #136, #137, #134: one build grid that cannot stack, one way to show a price
#136 - the gas burner drawn on top of the transport hub. The build menu's
rows were a written-down list of tops read with `tops[mini(row, size - 1)]`,
and with every structure researched the menu wanted a fourth row where the
list had three, so the last entry was placed on the third row's centre slot.
A list that silently stacks whatever will not fit in it is not a layout.
Rows are computed now, from a first top and a pitch that tightens to fit and
is floored at the row's own height - so two rows cannot occupy the same
space however far the catalog grows - and the column count widens rather
than adding a row the band cannot hold. Ten structures now sit in four
columns of three rows. The test unlocks the whole tree and checks every pair
of entries for overlap, which is the shape of the bug rather than a
coordinate that happens to be right today.

#137 - the game had three answers to "what does this cost". The build menu
drew a NeonNumber under each option, the craft menu drew glyph strips, and
the ship lab spelled prices out inside the caption. PriceTag is that answer
once: the resource's glyph, then what you HOLD over what it COSTS. It
replaces all three, plus the info popup and the skill panel, and
VesselPriceStrip - which was the same idea for one menu - goes with them.
The build menu also gains the resource bar the other two already had, in the
same place and at the same size, and the craft menu's - the only one not
running the full width of its panel - now matches. The bar drops its LOCAL
column where there is no building to report on, for the same reason #95
dropped it where it only repeated BANKED.

#134 - the skill panel's explanation and its price were both centred in the
same band, so a long description landed on the number. The text is the left
of the band now and the tag is the right. The shortfall it used to spell out
in a sentence - one per case, ore or research points - is the red on the tag
instead, and research points ride along as an entry of their own, so the tag
says WHICH thing is missing without a line of prose per possibility.

One trap worth recording: PriceTag and ResourceBar read the GameState
autoload, and an `extends SceneTree` test script that so much as names such
a class - or preloads the file - fails to compile, because GameState is not
an identifier in that context. GDScript then caches the failure, so the
scene the test instantiates gets a Control with no script where its resource
bar should be. The tests compare script PATHS, as strings.
2026-08-28 15:05:20 +02:00
Loïc Wisniewski
710dcbf230 #130, #135: a vessel's death takes a moment, and leaves an interceptor
#130 asked for the hull to break away from the fleet, drift a few metres,
and go up further off with something more explosive than a light animation.

The wreck is a separate node rather than the Ship staying alive to animate,
and that is the load-bearing part. A dying Ship still in the tree is still
an Area2D in the player_ships group with live weapon components - it would
keep firing, keep being fired at, keep counting as a target, and keep
colliding on the way out. So the ship's own lifecycle is untouched: it
leaves the fleet, emits destroyed, and is freed on exactly the frame it
always was. What is left behind is scenery, and the test pins that.

The explosion at the end was also the quieter of the two the game has.
Killing an enemy was made deliberately loud by #87 while losing one of your
own kept the base class's twelve-particle default - the reward louder than
the loss. It is now the bigger of the two.

#135 is the same scene from the other end: the prologue's convoy flies its
escorts on the corners of the box, which is the exposed part of it, and the
debris belt can take all four. With no interceptor left, the only hulls the
vortex could spare were holds, and the player flew out of the prologue in a
carrier. Two guards now: one interceptor is put back into the convoy at the
top of the vortex, where a vessel appearing among ten being thrown around a
black hole is not something anyone will catch; and the handover checks the
TYPES it hands over rather than only the count - one surviving carrier is
also a fleet of one, and it is not the fleet a new game starts with.
2026-08-28 15:05:02 +02:00
Loïc Wisniewski
397983b611 #129 (#131, #132, #133): a quest chain, and a World Ship out of reach
Sixteen quests that walk a new game through the things it contains: ore,
the mothership, exploration, a yard and a second hull, planetfall and
conquest, extractors, gas, freight, power, the lab, a new vessel type, an
outpost, and a survey.

The chain turns on the second quest. The fleet builds the mothership to
call the World Ship and hears nothing - and the array is not at fault. The
breach put them further out than that signal will carry, so every quest
after it is a search that has to be flown rather than transmitted.

Three pieces:

  Quests.gd  the chain, as data and nothing else. Two rules it obeys:
             nothing may ask for something unreachable - research points
             come only from conquering a planet, so every doctrine that
             costs them sits after the conquest quest - and a quest names
             one goal, with the research it implies said in the brief
             rather than split into an errand of its own.
  GameState  how far along the chain the player is, and the answer to each
             goal kind. Progress latches for free: the chain is walked one
             quest at a time, so collecting five ore and spending it cannot
             un-complete the quest that asked for five - by then nothing is
             asking. It lives here so a quest finished on a planet surface
             is still finished after take-off.
  the HUD    QuestTracker, the standing objective, in the wave alert's
             idiom; QuestDirector, which reads the lines out.

Lines are QUEUED by GameState and drained by the director rather than
emitted at a banner, because quests advance in the autoload, which runs on
a planet surface too - where a line emitted straight at a banner would be
lost. Both scenes now carry a banner, a tracker and a director, so a quest
finished underground is announced underground.

The chain is armed by reset_new_game(), the same gate the prologue uses -
the only door into a real game - so instantiating a scene is not by itself
a reason to start briefing somebody.
2026-08-28 14:41:03 +02:00
Loïc Wisniewski
1e89780bd7 #128: a dialog stops the game until it is acknowledged
While a line is on the banner the whole tree is paused, which freezes the
fleet, the enemies and GameState's own production and wave clocks, and stops
input reaching any node but the banner. So a press while a dialog is up
acknowledges it and does nothing else - it cannot also steer the fleet or
open a menu on the way through.

The pause is owned by NarrationBanner rather than by its callers: the thing
that must not get out of step is "a line is showing" and "the game is
stopped", and the banner is the only code that knows the first. It releases
on the last line, on clear(), and on leaving the tree - a scene swapped out
mid-line must not leave the game stopped with nothing left to start it.

Two consequences worth naming. A beat's deadline in the prologue is now game
time, so it can no longer run out from under an unread line; it remains the
way past a beat the player will not finish. And "the prologue leaves you
movement and nothing else" was applied by _process on the first frame, which
no longer comes - the prologue opens ON a dialog - so BuildController and
WorldMap apply it at _ready too.
2026-08-28 14:40:46 +02:00
Loïc Wisniewski
902d6922e7 #127: dialogs wait for the reader, and say who is talking
A line now stays until it is DISMISSED - there is no timer under it at all.
A prologue whose lines expire on their own reads at the game's pace rather
than at the reader's, and that was most of what made the opening feel
rushed even after #125 slowed the beats down.

The beats that are about the guide talking already hold while the banner is
speaking, so dismissing a line is what advances the prologue. A player who
never clicks is still never stuck: each beat's own deadline is the way out,
and the test pins that the deadline sits past the floor.

A dismissed line eats the press, so clicking to continue does not also steer
the fleet on the way through.

The speaker's name sits in a notch cut out of the top border at the left,
the way a label is riveted to a panel - the frame is one polygon, so the
fill and the stroke cannot disagree about where that notch is. Everything up
to the breach is the convoy's controller; after it there is nobody left to
talk to, so the closing lines are the ship's own log, which is the point
being made.

The tick strip along the bottom is gone. It read as an underline attached to
the last line rather than as part of the frame. What replaces it is a
blinking caret at the bottom right - the one thing that says a click is what
moves this on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 20:57:56 +02:00
Loïc Wisniewski
ddc3de595e #126: asteroids are only created off screen
The guard was a 420px bubble around the fleet, which is smaller than the
window - so a rock could and did materialise in plain sight, several hundred
pixels away but well inside the view. "Not on the fleet" was never the rule
the player notices; "not in view" is.

The keep-out area is now the visible world rect, grown by a margin so a rock
placed just outside does not drift into view on the frame it was created.
The old bubble survives as a FLOOR under it: what to use when the viewport
cannot be measured - no camera yet on a scene's first frame, or a headless
run with no window - and for a heavily zoomed-in view where the screen is
narrower than the distance a rock needs to be safe.

Candidates inside are pushed out through the nearest edge rather than
dropped, so the field keeps its density around the fleet instead of thinning
out exactly where the player is looking.

Measured: keep-out 2200x2200 over a 1920 screen, nearest asteroid 1899 units
out where it used to be allowed at 420.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 20:57:55 +02:00
Loïc Wisniewski
eb17b3d607 Add the .uid files for the newest tests
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 13:25:31 +02:00
Loïc Wisniewski
d455361328 #125: slow the prologue down, and give the breach a real black hole
Five things, all of them "this happens too fast or looks too thin".

PACING. Every beat had a completion test that could be satisfied while the
guide was still mid-sentence, which is why everything landed seconds after
the thing before it. Beats now have a FLOOR as well as a deadline - a
minimum length - and a beat whose point is the guide talking will not end
while a line is still on screen. One rule in one place rather than tuning
each condition. The run is 3400px instead of 1500, which at cruise was about
four seconds, and a held line is 6.5s instead of 4.5.

THE CONVOY. Escorts at the four corners of a box, holds packed in the middle
of it, roughly three times the spread of the fleet's own wedge - this is
freight under guard, not a fighting formation. CONVOY and CONVOY_FORMATION
pair index-for-index, which is all it takes for spawn_ships to seat the guns
on the outside, and the test pins that every hold sits further in than the
furthest-out escort.

THE DIALOG. Cut corners, brackets holding the short edges open, a tick strip
along the bottom and a caret in the corner - geometry rather than art, so it
scales with the banner and costs nothing.

THE BREACH. It was a few lines turning around a point. It is now two halves
driven by one number: DimensionalBreach, a growing black hole out in the
world built on BlackHoleVisual's idiom - empty core, counter-rotating arc
segments hotter toward the centre - with infalling streamers it does not
have, because a planet's disc is steady and this one has to say "now"; and
breach.gdshader over the top, bending the ACTUAL SCENE around it. Ships,
rocks and the starfield all get dragged in, which is most of what makes a
hole in space read as a hole rather than as a decal drawn on one.

Inside the horizon the shader samples nothing at all - the same decision
BlackHoleVisual makes, for the same reason: a black hole with something
visible in the middle stops being one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 13:25:10 +02:00
Loïc Wisniewski
ec7a8f1f17 Cover the last batch of tickets with tests
The previous ten tickets shipped with almost no coverage - only #109, #113,
#115 and #117 touched a test. That was a gap, not a decision: everything
before it had its behaviour pinned, and #116 in particular added a whole
subsystem nothing was watching.

Four new files and one extended case:

  test_spacial_workshop        #116 - the repair economy end to end: the
                               cost of a repair scales with the damage, the
                               workshop asks logistics for that ore and
                               stops at its buffer, a drone spends when it
                               LEAVES so two cannot double-charge one wall,
                               and a target levelled in flight sends its
                               load home rather than swallowing it
  test_desktop_controls        #118/#121 - both driven through the real
                               routing, because the routing is what was
                               missing; a test calling handle_mouse_button()
                               itself would have passed against both reports
  test_enemy_bomber            #114 - it flies, it faces where it flies, its
                               bombs arrive, and it bombs structures with no
                               fleet in sight
  test_menu_polish             #111/#112/#119/#120 - what each panel shows
  test_wave_system (case G)    #110 - the dev button, and that it does not
                               exist outside dev mode

One assertion worth recording: the craft caption is checked as an EXACT
match against the vessel's display name rather than by hunting for resource
words in it. The first version failed on "Gas Bomber" - a vessel named after
a resource, and always will be. What #111 removed was the price spelled out
underneath, not the name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 12:10:31 +02:00
Loïc Wisniewski
39ad050dc6 #117: the crafter and the lab animate only while they are working
A factory whose cranes swing over an empty cradle reads as working on
nothing, and a lab sweeping instruments over a vessel it is not studying
reads the same way.

The CLOCK stops rather than the drawing. Every moving element is a NeonPiece
whose position is pushed from that clock, so a frozen clock leaves the
building standing with its cranes parked; stopping apply_animation() instead
would leave them wherever the last frame happened to put them, which is not
the same thing as idle.

The state is read by tree lookup rather than through the bare `GameState`
identifier. These visual scripts are reached through a BuildingDefinition's
visual_scene, which is preloaded while the definition catalog is still
loading - early enough that the autoload is not in the global class table
yet, and a bare reference fails to COMPILE the whole file. That failure also
hung test_building_redraw_cost, because a visual that never built its pieces
left a null crane being dereferenced every frame.

Both animation tests now say what they mean: the redraw-cost test queues an
order first, because its point is that animating is cheap rather than that
it happens unprompted, and the crafter test additionally pins that an idle
crafter really is still.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 21:58:59 +02:00
Loïc Wisniewski
1a2a27b6e8 #113: a wave converges on the nearest structure from any distance
A wave is an attack on what the player has built, not a hazard sitting in
space waiting to be flown near. Wave enemies now ignore the detection radius
their components otherwise respect and close on the nearest building from
wherever they arrived.

The flag is per INSTANCE rather than per definition: the same chaser is a
wave when the director launches it and a garrison when a map author places
it, and a planet garrison that chased the player across the system would be
a very different thing from what the editor is used to place. The test pins
both halves - a wave chaser 6000px out closed on a crafter, and an
identically built one nobody sent held its station.

What it fights on the way is unchanged: EnemyTargets already prefers a
vessel inside 520px over a building further off, and already searches the
player_ships group, so the fleet and an outpost's garrison both count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 21:38:09 +02:00
Loïc Wisniewski
7a78d1b1a6 #115: the lancer winds up once, and leaves hard
One back and forth rather than two and a half, over 0.85s rather than 1.2.
The tell is a recoil and a spring; repeating it turned a wind-up into a
fidget and the player stopped reading it as "about to happen".

And the charge is 1650px/s, up from 950, which read as a fast drift rather
than as the thing the whole wind-up was promising.

The test counts how many times the wind-up crosses its anchor: one back and
forth crosses exactly once, two would cross three times.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 21:38:08 +02:00
Loïc Wisniewski
457ae7023c #109: garrison vessels wander their post instead of walking a ring
Two things were wrong and the second was the real one. A ring is not what a
picket does - but each step around it was shorter than Ship's arrive_radius,
so every frame the vessel SNAPPED onto its target and zeroed its velocity,
and Ship only turns to face a heading while it is actually moving. The
result slid around the outpost pointing whichever way it had spawned.

So: real destinations, far enough apart to be flown to. The vessel picks a
point somewhere in its patrol area, flies there, pauses, and picks another.
A leg is never shorter than wander_min_leg, which is what keeps the
snap-and-stop from coming straight back, and a leg that cannot be reached
times out rather than stranding the picket.

Breaking off a fight now starts a fresh leg from where the fight ended,
rather than flying back to a waypoint chosen before it began.

Measured: 485px of wander, 46-271px from its post, up to 690px/s, pointing
where it was going for 88% of its travel. The test measures that facing as a
FRACTION rather than a worst instant - a vessel just given a new heading is
legitimately still swinging onto it at its own rotation_speed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 21:38:07 +02:00
Loïc Wisniewski
209169206b #108: the prologue - the convoy, the ambush, and the breach
Landed as one piece across #122, #123 and #124: the frame is useless without
a script, and the script has nowhere to run without the frame.

The director drives the REAL scene rather than being a scene of its own. It
wants the real fleet, the real enemies and the real asteroid field, and it
makes "then the game starts" cost nothing - the director stops steering,
clears the flag, and what is left running is the ordinary game.

The script is a list of beats, each with a deadline as well as its own
completion test, so a beat whose condition is never met - a player who
refuses to fly at the ambush - runs out rather than wedging the prologue.
skip() ends the whole thing at any point and lands in exactly the state
playing it through does, so the game has one way to start rather than two
that have to be kept in step. The test pins that equivalence.

"No action button available, only movements" is enforced where the buttons
are decided, not by the director reaching in: the ability controllers set
their own visibility every frame and would have put them straight back, and
the map button belongs to the map. The joystick, the pivot and the fader
stay.

The convoy is ten hulls where a new game's fleet is one, so it needs an
explicit fleet-size override - a plain number rather than a fake doctrine,
because nothing about the player's own progression should be bent to fit a
cutscene. It is carriers rather than transporters: transporters belong to
hubs and are not fleet-eligible, so a convoy of them would be silently
filtered down to its escorts.

The ambush is chasers and only chasers. "Easy enough" is the requirement,
and a sniper against a convoy the player cannot yet fight properly is
neither easy nor a good first impression.

The breach takes the wheel through one check in GameController, covering the
stick, the keyboard and the released-again path together - three separate
gates would be three chances for one of them to keep steering. The shake is
written to camera.offset, never to its position, because GameController owns
where the camera is and would smooth a shake away.

The vessel that survives is chosen as the interceptor rather than whichever
happened to be first in the list, and the prologue ends on exactly a new
game's opening position.

Also: PlanetAtmosphereController no longer spawns the opening fleet while
the prologue is arming, and the tests that are about what the GAME starts
from now say so by turning the prologue off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 20:59:12 +02:00
Loïc Wisniewski
d8982fb800 Add the .uid files Godot generated for the new scripts
Godot writes one beside every script and the repo tracks them; these are the
ones for the files added across #99-#107 and the ability seam.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 19:23:50 +02:00
Loïc Wisniewski
b50597b757 Garrison laser vessels and gas bombers, on a shared ability seam
#105 shipped with interceptors as the only garrisonable vessel, and the
reason was a design fault rather than a decision: firing the laser and the
gas bomb lived in the player's fleet controllers, so a stationed laser would
have held its position and never shot.

The shot is now a thing in the world rather than something a controller
draws. LaserBeamEffect and GasBombEffect are self-contained, self-animating
and self-freeing, added to the tree root - the same shape EnemyBigLaserBeam
and ArtilleryShell already had on the enemy side - and they outlive the
vessel that fired them.

Above them sits one seam, on VesselCooldownFillComponent: ability_range(),
ability_ammo_resource(), ability_ammo_cost() and fire_ability(). A beam and a
bomb are now the same shape of thing to whoever pulls the trigger, so the
garrison AI never names an ability - the test asserts that literally, by
reading the AI's own source. A third armed vessel needs to implement the
seam and nothing else.

Ammunition is the CALLER's business, deliberately: the fleet spends from its
hold, a garrison from its outpost's store, and neither concern belongs in
the component. A garrison with an empty magazine holds fire rather than
falling back on the fleet's hold.

Outpost storage: 50 gas per stationed bomber and 50 energy per stationed
laser, off the VESSEL definitions, so an outpost never learns which vessel
needs what. It is a capacity, not a stock - hub transporters fill it exactly
as they fill a gas burner's buffer, because building_logistics_deficit now
takes the larger of the definition's static buffer and the garrison's
magazine. Every other resource is capped at zero: an outpost is not a depot,
and ore parked at one has nothing to do there and no transporter coming for
it.

Also restores two properties the Godot editor dropped from
GasBurnerBuildingDefinition.tres when it re-saved the file:
collected_outputs and capabilities. Without the first, nothing ever collects
the burner's energy - test_gas_burner caught it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 19:23:35 +02:00
Loïc Wisniewski
0c595d775d #106/#107 (#98a/#98b): the Planning Center
Landed together: a board of standing orders nothing acts on is a notepad,
and a planner with nowhere to read the plan from has nothing to do.

The targets are GLOBAL, not per building. There is one plan for the whole
game, and a second planning centre is another place to read and edit it
rather than a competing one - per-building targets would let two centres
hold contradictory orders and tell the crafters to satisfy both.

The census is the harder half. Every vessel is counted exactly once wherever
it happens to be: flying in the fleet, parked in the hangar, stationed at an
outpost, or in the air between any two of those. Miss the in-flight ones and
the planner queues a replacement for a vessel that is merely en route. The
fleet is the one place GameState cannot see, so Fleet pushes its composition
on every change, alongside the cargo capacity and distorder count that
already went that way.

Three rules keep the planner from fighting the player. It tops up and never
rewrites, so an order placed by hand stays exactly where it is. Everything
counts, including what is already queued, or it re-queues the same shortfall
every tick and buries the player in hulls. And nothing happens without a
planning centre - though the plan is remembered when one is destroyed, so
rebuilding picks up where it left off.

Vessels on order are counted separately from the census on purpose: they do
not exist yet, so the readout can say "4 of 6, 2 building" rather than
claiming six. The per-row note says where the vessels actually are, because
a plan can look unmet while everything it asked for is sitting at an
outpost.

The build menu goes to three columns past eight entries. #54 already
tightened its rows to 223px to fit a fourth, explicitly to avoid a
scrollable menu, and a ninth structure needs a fifth row that will not fit
at any spacing a button, a label and a cost can share. Three columns take
nine into three rows - fewer than it uses today, not more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:21:52 +02:00
Loïc Wisniewski
bd0cc842ff #105 (#97g): garrison vessels patrol and defend their outpost
A garrisoned vessel circles its post, engages what comes into range, and
goes back to circling. It steers by writing move_target and lets Ship's own
steering fly it, so it accelerates, banks and brakes exactly like a vessel
in the formation.

It is not in Fleet.ships. It does not follow the formation, does not count
against max_fleet_size(), and does not move when the player flies away -
which is the whole point of stationing it. A garrison that ate a fleet slot
would mean stationing a vessel cost the player a place in their own wedge.

The leash is the load-bearing part. Enemies are judged from the POST rather
than from the vessel, so a garrison that has drifted out to its leash cannot
see further still and be walked away from its outpost one enemy at a time,
and an engagement is clamped to a circle around the post rather than to a
straight line, so it still manoeuvres while pinned.

The orbit starts from where the vessel actually is rather than a random
bearing. A random one makes the first move_target a point on the far side of
the circle, and the vessel flies straight across the middle - through the
outpost it is guarding.

EnemyTargets.nearest_ship now searches the player_ships GROUP instead of
Fleet.ships: an enemy that ignored the guns shooting at it, because they
were not in the player's formation, would let a garrison hold a position
without ever being fought back.

The laser vessel is deliberately not garrisonable. Firing the laser lives in
LaserController, a fleet ability driven by the laser button, rather than in
VesselLaserComponent - so a garrisoned laser would hold its position and
never shoot, and offering a vessel that cannot fight is worse than a short
list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:13:47 +02:00
Loïc Wisniewski
8122e280b7 #104 (#97f): outposts are crewed automatically from the mothership
Vessels come from the HANGAR and never from the fleet. The fleet is what the
player is flying, and taking a vessel out of it to crew a building would be
the game undoing the player's own arrangement. "Whenever available" means
"when one is sitting at the mothership", and an outpost asking for a hull
nobody has built simply waits.

They travel, down the same vessel_deliveries path a finished crafter order
already uses to reach the mothership, rather than teleporting.

What is already in the air counts against the shortfall. Without that the
dispatcher sends the whole hangar to fill one berth, because the crew does
not grow until the first arrival lands.

Two directions, not one: lowering the order flies the surplus home rather
than stranding it, and a vessel whose outpost is levelled under it lands in
the hangar instead of being destroyed - the same rule a transporter
mid-delivery already follows with its cargo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:13:46 +02:00
Loïc Wisniewski
3c632276af #102/#103 (#97d/#97e): the outpost, and the garrison you compose for it
Landed together because an outpost you cannot crew is decoration, and a
garrison menu with no building to hang it off is nothing at all.

#102, the building. Researched at "Forward Bases", two prerequisites deep
and cheaper than any mid-game structure - the ticket asks for early, and the
answer to waves attacking your buildings should arrive at about the time the
waves can hurt them. It is drawn as a watch mast on a tripod with two open
berths, deliberately the least machinery of any structure here: it makes
nothing and refines nothing, it holds a position. 120hp, more than a
crafter, because being shot at is its job.

#103, the garrison. Two separate things live on its record, and conflating
them was the first thing to get wrong: the ORDER, what the player asked for,
and the CREW, who is actually there. This menu edits the order only; #104
fills the crew. Keeping the order as a wish is what lets an outpost ask for
a hull nobody has built yet and wait for it, rather than the request
evaporating - and it is why the panel shows "here / ordered" instead of one
number.

"Only ships that attack can be assigned" is expressed as capabilities rather
than a list of types, so a new armed vessel is eligible the day it is added
and an unarmed one never is by accident. Today that means the interceptor
and the laser vessel; a shield, a hauler and an engine block cannot hold a
position however much one might want them to.

Two berths by default, and Garrison Bays raises it at outposts ALREADY
standing - the same shape as freight_yards for hubs, because a bonus that
only reached new buildings would mean demolishing and rebuilding to collect
it.

The panel builds its own rows from the catalog rather than from nodes placed
in the scene, so adding an armed vessel later needs no scene surgery.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:02:20 +02:00
Loïc Wisniewski
3debcd7edd #101 (#97c): waves attack buildings
Every enemy component used to ask the same question - which player SHIP is
nearest - so a structure was scenery they flew straight through. A Building
is also a plain Node2D, so there was nothing to collide with even if one had
tried.

EnemyTargets answers the question once, for pursuit, standoff, the lancer's
lunge and the ranged weapon together, so they cannot drift into disagreeing
about it. It keeps one thumb on the scale: a vessel inside 520px always
wins, even against a nearer building, or the fleet could not defend anything
by interposing itself and an enemy would fly past the ships shooting it.

Buildings get a hitbox - an Area2D in a group, the same shape as a Ship or
an asteroid, so EnemyProjectile needed one extra branch rather than a new
concept. It applies damage on a tick rather than on area_entered: entry
fires once, which is right for a rammer that dies on the first strike, but
would let a sniper that drifted inside sit there scratch-free for ever. On a
tick, an enemy parked on a structure is besieging it.

What one strike costs is data on the enemy definition, for the same reason
dies_on_contact is - a lancer arriving at 950px/s is not a sniper drifting
into a wall.

Also clears the asteroids in test_enemy_projectile_damage. set_process(false)
only stops NEW asteroids; the ones already placed keep drifting, and one
wandering into the shield vessel parked at (2000, 0) spent its single charge
so the projectile fired next killed it. That failed about one parallel suite
run in thirty and never in isolation. Clearing them is what the other combat
tests already do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:53:17 +02:00
Loïc Wisniewski
26f3728546 #100 (#97b): waves replace the roaming enemy spawner
The old spawner put one enemy on the map every 3-6 seconds for ever. There
was no rhythm to the danger and nothing to prepare for. Enemies now arrive
in waves: a long quiet, a warning, an attack, and quiet again once the wave
is dead.

The clock lives in GameState, because Main.tscn is destroyed on every trip
to a planet surface and a wave must not be cancelled, nor restarted from
zero, by a landing. The spawning does not: GameState cannot see the world,
so EnemySpawner reads the phase and puts the enemies in it. That also
settles what happens when a wave turns while the player is on a surface -
the wave is waiting in space when they come back.

EnemySpawner keeps its name deliberately. It is still the thing that puts
enemies in the world, and 88 test files already say
`get_node("EnemySpawner").set_process(false)` to ask for a quiet map.

Strength is a function of the FLEET, per the ticket, with the wave number as
a slower second term so a player who never grows still meets escalation. The
budget is spent most-expensive-first, so a large wave is heavier rather than
merely longer, and min_strength keeps the first waves to chasers whatever
the arithmetic says.

"No timer should be visible" is enforced rather than merely observed:
GameState offers wave_warning_pulse(), a 0..1 throb, and no
remaining-seconds accessor at all, so the alert cannot drift into being a
countdown. The test pins both - that no such accessor exists, and that the
alert's own text contains no digits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:37:42 +02:00
Loïc Wisniewski
c221584848 #99 (#97a): buildings have health, and can be destroyed
Nothing can attack a structure that cannot be hurt, so this is the floor the
rest of #97 stands on.

max_hp is per definition - an extractor bolted to a rock is not the
mothership - and the current value lives on the RECORD in GameState, not on
the Building node. Main.tscn is torn down and rebuilt on every trip to a
planet surface, so damage kept on a node would heal itself the moment the
player landed.

A record written before this carries no hp field at all. That reads as
undamaged rather than as zero, or an existing save would open on a field of
wrecks.

The mothership cannot be destroyed - it floors at 1hp. The bank, the hangar
and research all live there and a wiped fleet rebuilds there, so losing it
would be a soft-lock with no route back. Keyed on the "bank" capability
rather than on the type: whatever building holds the bank is the one that
cannot be lost.

Destroying a hub retires its transporters. Everything else keyed on a
building id already copes with the building being gone - a transporter
mid-delivery puts its cargo back rather than destroying it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:37:40 +02:00
Loïc Wisniewski
88b53abf52 Retry a launch that came up and then said nothing
The same interop launch flake that produced exit 127 with an empty log has a
second face: a non-zero exit after the engine banner and not one line more.
test_fleet_growth went that way once in six full-suite runs and passed alone
every time.

The line between a flake and a failure is output, not the exit code and not
the banner - every test says something before it stops, its own FAIL
verdict, a SCRIPT ERROR, an engine complaint. A log holding nothing but the
banner is a run that did not happen, so it is retried and, if it stays
silent, reported as NOSTART rather than as a test that failed. A
deterministic silent crash still fails all three attempts.

The check reads grep's output rather than its exit status: `grep -q -v` does
not agree across implementations - ugrep, which is what `grep` resolves to
on the WSL box, reports success for an empty file and failure for a file
full of matching lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 11:52:19 +02:00
Loïc Wisniewski
890e1c812f #96: the lancer, a rammer that announces itself
A second suicide vessel alongside the chaser, but where the chaser simply
beelines, this one is a rhythm the player can read. It drifts in from beyond
lock_range at half speed, LOCKS a point, rocks 30px back and forth along the
charge axis for 1.2s, draws a thin line down its trajectory for the last
300ms, and only then crosses the gap in one straight 950px/s run before
coasting to a stop and starting over.

The lock is what makes the wind-up worth anything: nothing re-reads the
fleet once it begins, so the charge goes where the target WAS and stepping
off the line works. Same bargain as EnemyBigLaserBeam.

EnemyLungeComponent is a Node2D rather than a plain Node because the
trajectory line has to be drawn somewhere, and top_level keeps that drawing
in world space while the hull it hangs off spins to face the lock.

The roaming spawner gets a 25% lancer share. It is the only place the player
meets an enemy off an authored map, so without that the lancer would exist
only in the setup editor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 11:52:18 +02:00
Loïc Wisniewski
5f1b5a9b29 #95: stop printing the bank twice in the research menu
The research table's LOCAL column was not merely equal to BANKED, it was the
same store: research lives on the mothership, the mothership is also the
bank, and building_inventory() redirects any bank-capable building to
bank_inventory itself. ResourceBar now drops LOCAL wherever it resolves to
the bank and lets the remaining two columns share the width. Anywhere else -
a crafter, a lab, the transfer panel - the column is untouched.

The "Research" heading went with it. It named a panel you can only reach by
pressing the research button, and it cost the tree canvas 128px, which the
table and the tree have now taken back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 11:35:14 +02:00
Loïc Wisniewski
85c9bd9d8d Stop the suite failing on things that are not test failures
Two sources of random red on WSL, neither of them a test.

A launch that never happened. The Godot binary is a Windows executable
started across the interop boundary, and with six going at once that start
occasionally just fails: exit 127, an empty log, no engine banner, nothing
run. It was indistinguishable from a failure and moved between tests from
run to run. Anything that actually ran got as far as printing the banner, so
the two are easy to tell apart; a start that produced no banner is retried
up to three times and reported as NOSTART if it never comes up. Retries are
printed, never hidden - a test that needed one is saying the machine is
struggling even where it went on to pass.

The working tree as a run directory. WSL ran the suite in place, so six
Godot processes wrote .godot and created and deleted files under
PlanetSetups/ inside the live repo, alongside git and whatever else was
using it. Single tests failed at random under that - a delete that did not
take, a scene that would not instantiate - never the same one twice. WSL now
copies to %LOCALAPPDATA% first, the same isolation Termux already gets from
its own copy, on a native NTFS path so the Windows Godot still reads it at
full speed. Costs about 10s a run and leaves the repo untouched.

Four consecutive clean 124/124 runs since.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:30:18 +02:00
Loïc Wisniewski
12bd5eb7bf Clip the cooldown fill with Geometry2D, not by hand
Every cooling vessel logged "Invalid polygon data, triangulation failed"
once per frame, for as long as its cooldown ran - 16 of them in a single
test_laser_vessel run. The fill was drawn anyway, so nothing looked wrong,
but it buried real errors in test logs.

_filled_hull cut each hull edge against the fill line itself, which is
Sutherland-Hodgman, and Sutherland-Hodgman is only sound for a CONVEX
subject. Hulls are not: the gas bomber has a tail notch and the distorder a
waist. On those it stitched the pieces back together with a zero-width
bridge, and a polygon with a zero-width bridge is not one the triangulator
accepts.

Geometry2D.intersect_polygons does the clipping properly and returns a list
of polygons, which is also the truer answer - a notched hull genuinely fills
as two puddles until the level rises past the notch. A weld-and-area guard
stays in front of the renderer for repeated vertices and slivers.

Zero triangulation errors across the vessel tests now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:30:05 +02:00
Loïc Wisniewski
c8d4731232 #90: give the laser an ignition instead of a blink
The shot was one flat line that appeared at full length and faded. Nothing
happened at the ship, nothing moved, and a hit looked exactly like a miss.

The blade now runs out of the hull over the first 30% of the flash on a
cubic ease-out, holds white-hot, then falls away, with a hum in its width,
a bright cap at the leading end, a flare at the emitter, and sparks off the
hull that fired it. MuzzleSparkVFX takes an optional colour so the laser
throws cyan where the ordinary gun (#87) keeps its gold.

The extra light is a white-hot rod down the middle at 42% of the width,
deliberately not a wider halo: the note at BEAM_WIDTH_SCALE records that the
drawn beam spans exactly what the beam hits, and a halo past that would put
the "it visibly missed" complaint back the other way round.

The damage is untouched - dealt in full, along the whole beam, on the frame
of the shot. A beam that had to arrive would change how the weapon plays,
which the ticket did not ask for, so the test pins it down: a target at the
far end of the range must be hit before the drawn blade has visibly reached
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:29:55 +02:00
Loïc Wisniewski
01f053e01a #92: shrink the confirm popup to the window it is in
The card is authored at a fixed 704x1200 around a portrait phone. On a
window wider than it is tall the stretched canvas is about 1080 units high,
so the card sat at y=-60 and hung over both edges - and the confirm and
cancel buttons are anchored to its own bottom edge, so they went off screen
with it. The popup was not merely ugly on a desktop, it was a dead end:
nothing on it could be pressed.

NeonPanel now fits itself to the viewport and re-fits when the window
changes. It only ever shrinks; growing the card would stretch proportions
that are tuned rather than fluid. The fitting adjusts offsets around a
fixed centre, so it applies only to a card pinned to a point - a panel
stretched between different anchors already follows its parent and is left
alone.

Verified across portrait, 16:9, ultrawide and small-laptop shapes, on the
card and on both buttons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:29:44 +02:00
Loïc Wisniewski
4883197ef3 #93: drive the research menu with a mouse, and close the hole under it
Two separate faults, both reported on the same ticket.

The menu was inert under a mouse. Input reaches the tree as raw events
through BuildController._unhandled_input, because a Control that consumes
them through the GUI system swallows the drag, and the tree only ever read
touch events. That was invisible while the project set
emulate_touch_from_mouse and every click arrived as a synthetic touch; with
that setting gone, nothing selects and nothing scrolls, and there is no
other way to research anything on a desktop. The press and the pan are now
the behaviour, with touch and mouse as two ways in to them. Motion pans only
while the button is held - a hovering pointer must not drag the tree out
from under the reader - and the wheel scrolls, which is the first thing a
desktop player reaches for.

The panel also still had the buy button's hole in it. That button went away
in #83 and nothing moved down into the space, leaving ~180px of dead panel
below the readout while the tree above it stayed cramped. The readout moves
down 154px and the canvas takes the height: 1190px to 1344px.

The test drives clicks through BuildController rather than into the view,
since the missing piece was the routing - calling the view directly would
have passed against the bug as reported.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:29:34 +02:00
Loïc Wisniewski
03b1c85569 #91: keep the gas source behind the fleet
On a gas planet the vents were drawn on top of the player. SurfaceFeatures
is added after Fleet in PlanetSurface.tscn, so at the default z_index of 0
the vent field won the tie purely by being the later sibling, and the fleet
flew under its own gas source.

Same one-line fix, for the same reason, as #50's capture rings and the
rocks on a rocky surface. It goes on the field rather than on children
because this one draws in its own _draw().

The test checks the rule instead of the node that was wrong: every drawing
feature, on every surface profile that has features, must end up behind the
fleet - 22 of them today, and any added later without editing the test.
Enemies are excluded and the test says why; they are combatants and share
the fleet's plane on purpose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 23:29:21 +02:00
Loïc Wisniewski
b1d179ec9c Add a WSL path to the test runner
run_tests.sh only knew one way to reach a Godot: `proot-distro login ubuntu`
and the arm64 build at /root. On WSL there is no container and no arm64
binary, so the suite could not be run from a desktop at all.

The platform is detected from /proc/version, and decides two things. Which
Godot: on WSL a Linux `godot` on PATH if there is one, otherwise the newest
Windows build already sitting on /mnt/c - the *_console.exe specifically,
since the plain .exe is a GUI-subsystem binary that detaches from the
terminal and would leave every test log empty. And whether to copy the
project first: that copy exists to get off Android's FUSE bridge, and a
Windows Godot already reads C: natively, so WSL runs in place.

_run_inside.sh now takes the binary as its first argument instead of
hardcoding it, and run_tests.sh calls it directly where there is no
container to enter. The scheduling, timeouts and verdicts are unchanged and
shared by both paths.

120/120 on WSL in 48s with JOBS=6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:40:44 +02:00
Loïc Wisniewski
6616952660 Skip the touch-device case where there is no touchscreen
test_desktop_hides_touch_controls failed outright on any machine whose
headless DisplayServer reports no touchscreen. Godot on Android answers
yes, so case C ran on the phone; Godot on Windows answers no, so the test
bailed before reaching cases A and B and reported a failure that said
nothing about the game.

Case C now runs where a touchscreen is reported and is skipped where none
is, which leaves A and B - and the static guard on _note_keyboard's list
and touchscreen check - exercised everywhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:36:57 +02:00
Loïc Wisniewski
c193b9614b Track the uid Godot generated for test_aim_assist
Every other test script's .uid is tracked; this one was written after its
commit landed. Left untracked it would keep showing up in git status and
would be regenerated with a different id on another machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 12:47:04 +02:00
Loïc Wisniewski
5f3615026d #89: the default gun bends onto a target near the aimed heading
Aiming with a thumb on a stick is never exact, and at 950 units a couple of
degrees is a long miss. A shot fired roughly toward an enemy now goes at it.

28 degrees of cone, chosen so that pointing at something hits it while the
shot still visibly goes where the player pointed. Past about 45 the gun starts
picking its own targets, which is the behaviour #80 deliberately removed.

Enemies only, never asteroids. Snapping onto a rock while the player is trying
to hit the thing shooting at them would be the assist working against them,
and rocks are large and stationary enough to hit on purpose.

The aim guide is drawn through the SAME helper the gun fires through, so the
dotted line bends with the shot. A guide showing the raw stick heading while
the bolt took an assisted one would be the same class of mismatch #86 removed
between the guide's length and a bolt's actual reach - and this one would have
been harder to notice, because the line would look right most of the time.

Range 620 -> 950, the other half of the ticket.

Also swept the project root again: Godot keeps resurrecting pre-#84 copies of
moved files there from its own cache. None this time.

Suite 120/120.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 03:19:52 +02:00
Loïc Wisniewski
3f5e7ac80b Implement #85, #86, #87 and #88
#85 - on a computer the sticks and the pivot pair are dead weight: WASD
steers, the mouse aims, Q/E pivot. All four go together the first time a
keyboard or a mouse is used, and only where there is no touchscreen. A touch
device with a keyboard attached keeps everything, because a finger is still a
way to play it.

#86 - the default gun shoots faster (500 -> 850) and further (320 -> 620).

The interesting half is that a bolt now STOPS where the gun's range ends.
Projectile lifetime is derived from fire_range at launch rather than set
independently, so the distance a shot covers and the distance the aim guide
draws are one number. They were two, and they disagreed: the guide promised
320 while a bolt actually flew speed * lifetime = 750, so shots landed on
things well past the line saying where they would stop.

#87 - the ship that fires throws sparks, so a shot is visibly attached to the
vessel it came from; with a whole fleet shooting there was nothing tying the
two together, which started mattering once the player had to aim. Enemy deaths
gained a shockwave ring and roughly double the debris - a kill used to read as
a small red puff that was easy to miss in a fight.

#88 - everything moves 1.5x faster: fleet cruise, every vessel's max_speed and
acceleration, and every enemy's. Scaling the enemies too keeps it a change of
TEMPO rather than a difficulty cut, and scaling acceleration alongside speed
keeps the handling from #77's tuning intact.

Three tests carried assertions that were really about one balance pass:
test_enemy_mothership pinned a literal 35.0 and now compares against the
chaser, which is what "never reaches ramming speed" actually means;
test_planet_atmosphere_transition flew far enough at the new speed to reach
the landed garrison, and now clears it, since that test is about the handoff
and not about a fight it never meant to pick.

Also repaired real data loss, caught only because test_gas_burner failed:
Godot had silently re-serialised GasBurnerBuildingDefinition.tres and dropped
collected_outputs, capabilities, map_radius and map_phase, and had recreated a
stale copy of it at the pre-#84 root path. Without collected_outputs no
transporter ever collects a burner's energy. Both files restored from git.

It had also resurrected pre-#84 SNAPSHOTS of Main.tscn, Planet.tscn and
lunar_surface.gdshader at their old root paths - the root Main.tscn still
carried 27 references to the flat layout. They are dead weight the engine
never loads, since project.godot and every preload point into the folders, but
they are exactly the kind of thing that wastes an hour later when someone
edits the wrong copy. Deleted, and the root is now down to ui_theme.tres,
icon.svg, web_shell.html and the two config files.

Suite 119/119.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 02:20:12 +02:00
Loïc Wisniewski
2f161621c3 #84: move 215 root files into a folder hierarchy
Everything lived in one flat directory, so a diff gave no clue what area it
touched and finding the file you wanted meant knowing its exact name. Twelve
folders now, grouped by what a thing IS rather than what type it is - a
vessel's definition, its component scenes and its upgrade resources sit
together, because that is the set you read when you change one of them.

  buildings  core  controllers  enemies  logistics  planets
  resources  shaders  surface  ui  vessels  vfx  world

332 files moved and every res:// reference rewritten - 306 files touched,
including the autoload and main-scene paths in project.godot. Nothing renamed:
this is purely where files sit, so the diff is a pile of pure moves and no
behaviour is buried in it. ui_theme.tres, icon.svg and web_shell.html stay at
the root, where Godot and the export preset expect them.

Verified three ways, because a suite alone would not have caught all of it:
the full 117 pass, the web export builds clean, and the game renders.

The export is why that matters. The FIRST export after the move failed with
"Attempt to open script 'res://GameState.gd'" - the old path - while
project.godot on disk plainly said res://core/GameState.gd. That is this
project's documented Android FUSE mtime trap: Godot served a cached copy of a
file whose content had changed. A `touch` fixed it. Nothing in the test suite
would have found that, because the suite runs from a copy on a native
filesystem - the one place the bug cannot happen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:44:14 +02:00
Loïc Wisniewski
baac11ae8b Implement #80, #81, #82 and #83
#80 - desktop combat. The mouse aims, the left button fires, and the number
keys pick the weapon: 1 the fleet's guns, 2 and up the abilities. The twin
sticks are built for thumbs; on a desktop they mean dragging a virtual stick
with a pointer that could simply BE the crosshair.

The pointer path hands its heading to FireStickController rather than driving
weapons itself, so exactly one thing in the scene aims a gun, and a held stick
wins - a finger on it is a deliberate act, a mouse resting somewhere is not.
Abilities gained fire_at(), which aims and fires in one call instead of
pretending to be a press-drag-release touch.

It is gated on the absence of a touchscreen, because Godot emulates a mouse
button from every touch and without that a tap on a phone would fire the guns.
The gate has an explicit override, and not for tidiness: headless reports a
touchscreen, so with the automatic answer alone the aiming could never be
exercised by a test and the only covered behaviour would be the guard.

#81 - the bottom-left control hint is gone, from both scenes and from
GameController.

#82 - a chaser is destroyed by the impact when it reaches a vessel. It used to
pass through the wreck untouched and line up another pass, which made a
ramming enemy strictly better than any other. Data-driven per enemy
(dies_on_contact), because a sniper that happens to drift into a ship has not
spent itself doing it - and routed through take_damage, so it dies exactly as
it dies to gunfire. The enemy is told about the impact BEFORE the ship
resolves its own damage, since resolving it can free the ship.

#83 - the research tree's buy button is gone; tapping a node twice buys it.
Deliberately "the same node twice" rather than a timed double-tap: no window
to miss on a slow phone, no accidental purchase while browsing, and the first
tap always does something visible, so the second is never a blind commitment.
The status line now says "Tap again to research" - with the button gone it is
the only thing distinguishing an affordable node from an unaffordable one.

test_enemy_collision asserted the exact behaviour #82 reversed, and now checks
the ram costs both. Suite 117/117.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 18:49:47 +02:00
Loïc Wisniewski
54287d27ea Implement #77, #78 and #79
#77 - the Distorder becomes the Energy Propulser. Renamed everywhere the
player reads it, reskinned to match: a symmetric hull flaring into an engine
skirt instead of the lopsided warped lump, an engine bell with a thrust plume
instead of the wobbling ring glyph, and energy's own yellow instead of purple,
since energy is what it burns. The internal id stays "distorder" - it is the
capability string, the unlock key and the component scene name, and renaming
the wiring would be a large mechanical change with no player-visible effect.

Its speed ceiling now grows with the LOGARITHM of how many fly. Each one used
to add a flat 1.0, so a fleet that could afford six simply drove at six times
cruise and the vessel stopped being a choice. Base-2 makes the rule one
sentence - every doubling adds another 1.0 - and leaves two propulsers worth
exactly what they were worth before, so nothing already built is devalued.

#78 - a gas burner can be paused. It converts gas the player may want for
bombs into energy they may not need yet, and the only way to stop it was to
demolish it. The action is data-driven like every other: the visual's
ActionProvider lists "pause", and any converter can adopt it. Pause is the
burner's PRIMARY action, so it is one tap rather than a menu; the button shows
what it will DO, bars while running and a play triangle while stopped; and the
building itself goes visibly cold, because a paused building that looked
identical to a running one would be a setting to remember rather than see.
Conversion progress is left where it was rather than reset, so switching off
and on again does not discard a part-finished unit.

#79 - picking something up says so. A "+N" with the resource's own mark rises
and fades where it happened. Built from the SAME glyph the resource tables and
transfer rows use, so a material added to the catalog later cannot end up with
one mark in the HUD and a different one in the world. A pickup a full hold
REFUSES raises nothing.

Two Godot traps cost real time here and are now written down where the next
person will hit them:

  - a `#` comment inside a .tres [resource] block is not a comment. It parses
    as part of the preceding value and silently blanks the property after it.
    One in DistorderVesselDefinition.tres left definition_id empty, which
    surfaced as "<blank> did not resolve its definition" in a test far from
    the edit. The note lives in VesselDefinition.gd now.
  - a global class_name is not reliably resolvable, INCLUDING a script's own
    inside its own static function. ResourcePickupVFX.new() failed to compile
    when reached through a preview scene, taking Rock and the gas vents with
    it. It has no class_name now and constructs itself by path.

Suite 114/114.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:38:30 +02:00
Loïc Wisniewski
b999031e6a Recentre a released stick, slow extractors again, and unbreak the lab layout
Both sticks float - the base jumps to wherever the finger lands, so a player
does not have to hit an exact spot on a screen they cannot feel. Releasing put
only the KNOB back onto the base and left the base parked wherever that touch
had been, so an idle stick sat off-centre in a different place every time and
read as a control still in use. Release now returns base and knob to the
middle of the rect.

Extractors produce at 0.5 ore per second, halved again: at 1.0 they still
outran the rest of the economy. An extractor now pays back its 25-ore cost in
fifty seconds rather than twelve.

The ship lab and the crafter were both overlapping themselves. Enlarging the
shared resource table to 136px (so it could breathe and show bigger type)
pushed its bottom edge 34px into the vessel row beneath it in one and the
status text in the other - my regression, visible immediately on a real
screen and not at all in any test. The lab's second row of upgrade cards was
also already running into the detail text below it, by 13px, before any of
this. Both menus are re-spaced end to end rather than nudged, since moving one
collision into another is how the second one got there.

Two tests carried waits tuned to an extraction rate that has now changed
twice. Both derive them from EXTRACTOR_RATE instead, so the next balance pass
does not report a broken extractor that is working perfectly - the same fix
test_extractor_income already needed one round ago, in the file next to it.

Suite 112/112.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 13:31:46 +02:00
Loïc Wisniewski
4cc9c417ca Asteroids can be shot apart for the ore inside them
Asteroids were pure hazard: the only thing one could ever do was cost you a
ship. They now take damage like anything else and break open, dropping ore the
fleet can fly over and collect - the one resource reachable in open space
without conquering anything, and something for the new fire stick to do out
there.

Two ways to destroy one, deliberately not equivalent. Shot apart yields the
ore; flown into does not - the rock shatters against a hull rather than being
mined, and a ship or a shield charge has just been spent on it. Without that
split a shield vessel could farm asteroids by ramming them.

Asteroid.take_damage() matches Enemy's contract exactly, so every weapon that
already knew how to hurt an enemy hurts an asteroid through the same branch
rather than a second special case: bolts, the laser sweep, and the gas bomb's
blast all pick both groups up together.

Dropped ore carries the asteroid's own drift, so it reads as debris rather
than as a spawned pickup, and it expires after a minute. Rock grew an optional
lifetime for that, defaulting to 0 (never) so hand-placed surface rocks are
unchanged - a planet surface is bounded and authored, open space is not, and a
field shot up and abandoned would otherwise leak nodes for the session. Rock's
header note that asteroids and rocks are never on screen together is no longer
true and now says so; the hexagon-versus-lump shape language matters more now
that they sit side by side, not less.

Five weapon tests silenced the asteroid field but never cleared what it had
already placed. That was harmless while asteroids were untouchable and is not
any more - two of them assert exact hit counts, and a drifting rock in the
beam would have made those fail intermittently.

Suite 111/111.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 10:27:40 +02:00
Loïc Wisniewski
0fb8f9300b Twin-stick combat: aim and fire with a second stick, MOBA-style ability ring
The fleet's guns picked their own targets and fired on their own, so combat
happened whether or not the player did anything - there was nothing to aim and
nothing to decide, and the right half of the screen held nothing at all.
Firing is now an action. VesselWeaponComponent no longer seeks a target; it
shoots along a heading, and the only thing that supplies one is the new
right-hand stick via FireStickController. A fleet arriving from another scene
starts with its guns silent, which is the correct resting state.

A subtle dashed guide runs from the fleet along the aimed heading while the
stick is held, out to the guns' OWN range - read from a weapon that is
actually aboard, so the line can never promise reach the fleet does not have.

Layout: the two sticks take the bottom corners, the pivot pair is centred
between them at the bottom edge, and the laser and gas bomb sit on an arc up
and left of the fire stick where a thumb already is. Those four are now round
- IconButton grew a round_frame mode, and the in-world action controls use it
while panel and menu buttons stay square, so the two kinds of button read as
different kinds of thing.

Worth recording, because it cost a screenshot to find: a script-declared
property set BEFORE the `script = ExtResource(...)` line in a .tscn is
silently dropped. Godot's parser has no idea the property exists yet. The
round frames were all correctly set in both scenes and none of them applied.

Also fixed researtch_lab_garrison.tres, which was authored under the void
profile - that is the black hole surface, so a lab garrison was spawning on
black holes.

Suite 110/110. test_enemy_combat's whole premise was auto-fire; it now holds
the real stick toward the enemy rather than poking weapon components, so it
exercises the path the player uses. test_fire_stick is new and covers the
change itself: an idle stick leaves a target parked well inside range
untouched, which is precisely what the old gun would never have done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 01:29:21 +02:00
Loïc Wisniewski
8ba2cca6b6 Sharpen fleet handling, widen the laser, and fix the map editor's file identity
The fleet had a second of inertia in it: every vessel's acceleration sat at
roughly its own top speed, so flipping the stick while cruising took 0.58s to
take effect and let go coasted 222 units, while the formation anchor the ships
chase turns instantly. Acceleration is now ~3x top speed, chosen by measuring
rather than taste - past 3x the reversal time stops improving because
max_speed becomes the limit. Measured after: 0.18s and 23 units.
test_fleet_agility guards all three numbers and proves the bounds discriminate
by re-running the same measurement at the old value.

The laser reaches 1450 instead of 950 and strikes 38 units either side instead
of 22. Its drawn width is now derived from the hit width: the two were
independent numbers that had drifted, so the beam killed things it visibly
missed. LaserController's aim range also stopped hardcoding a second copy of
950. Tapping the laser button without dragging used to do nothing at all - the
press was claimed, the aim begun, then thrown away on release. It now fires at
the nearest enemy in range; dragging still aims precisely. That lives in the
shared aimed-ability controller, so the gas bomb gains it too.

Planets sat one per ~29 chunks - some 35,000 units of nothing between them.
Now one per ~12, measured over 14,641 chunks. The two thresholds moved by
different amounts deliberately: every SPACE-biome planet is a gas giant, so
lowering both equally would have tripled the gas-giant share as well. This
changes how often you find a planet, not what you find. Extractors produce
half as fast to match, so expanding is what grows the economy.

The research screen gave its tree 386px of 1280 and the text below it 520.
The text is now at the bottom, the tree has 550, and the resource table has
room and legible type - and its LOCAL column is no longer hidden behind the
close button.

Two map-editor bugs turned out to be one cause: a setup's id and its FILENAME
are not the same thing, and both save and delete rebuilt the path from the id.
ResearchLabSetup.tres holds setup_id "research_lab_1", so deleting it looked
for a file that does not exist and silently refused - which read as "the first
item in the list cannot be deleted" - while saving it wrote a second file
under the derived name and left the original behind, so the pool served the
stale twin. A third bug hid behind those: rescan() re-read through Godot's
resource cache and handed back pre-save values even when the file on disk was
correct. All three present to a player as "my change was not saved".

Research labs no longer grant a point of their own; what conquering one is
worth comes entirely from its authored map, as asked. The shipped lab map
carries 1 so the balance is unchanged - only where it is configured moved.

Suite 109/109. Four tests changed because the behaviour they pinned changed:
the joystick coast is now derived from cruise_speed^2/(2*acceleration) rather
than a constant tuned to one acceleration; the extractor wait derives from
EXTRACTOR_RATE; the research-point test asserts the delegation; and the black
hole test no longer assumes nobody will ever author a void-profile map.
test_planet_setups and test_planet_map_assignment stopped depending on which
maps happen to be in the content folder, which was making them fail when maps
were added or deleted elsewhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 09:55:11 +02:00