Skip to content

M4 iteration 1: the module engine - #48

Merged
Dibujaron merged 33 commits into
mainfrom
feat/m4-modules
Jul 28, 2026
Merged

M4 iteration 1: the module engine#48
Dibujaron merged 33 commits into
mainfrom
feat/m4-modules

Conversation

@Dibujaron

Copy link
Copy Markdown
Owner

Iteration 1 of M4: the module engine, whole. A ship's interior is now a hull plus swappable modules, with exterior parts on hull mount points — and the Mockingbird's current deck is expressed as her default loadout, unchanged tile for tile.

Design: docs/modules.md. Plan: docs/superpowers/plans/2026-07-24-m4-module-engine.md.

The central idea

The bake works on text, not parsed cells. Resolving a loadout splices each module's authored character-blocks into the hull's authored rows and re-runs the ordinary deck-plan parse. That is what keeps "the map is the single source of truth" true after a refit: consoles, the mooring tile, docking ports and hold capacity all keep falling out of glyphs with no derivation logic of the bake's own. Install a cockpit and the helm console exists because the module drew the glyph.

loadout.resolve is 38 lines with no map logic in it.

What landed

  • Slot digits in the deck-plan parser — a hex digit in a tile's SW corner, mirroring the NE colour digit. A stamp never overwrites SW (hull-owned); it does overwrite NE (a module owns its own colour).
  • hull.gleam / module.gleam / part.gleam — the authored documents and their registries. A hull keeps its deck rows as raw text so a refit re-stamps from the authored map rather than trying to un-stamp the previous fit.
  • loadout.gleam — the overlay stamp (void = passthrough), the pooled-tag validator (sum(provides) >= sum(requires), plus one module per slot, every non-void cell on that slot's digit, mount kind/size), and the bake. No reachability or geometry analysis anywhere.
  • Per-ship fits in the sim — the single shared ShipClass is gone; all 17 former state.class sites take their own ship's fit.
  • Flight stats out of constants and into data — pooled engine thrust and torque over the fit's total mass. The Mockingbird's default fit masses 120.0 and flies at exactly the pre-M4 40.0 / 180.0.
  • The Mockingbird carved into five slots (cockpit, forward_crew, commons, aft_crew, hold) plus two alternates, with a golden test against a frozen pre-M4 copy of her map.
  • The refit verb — docked-only and free this iteration, with refit_result and ship_fit pushes and a station composite rebuild.
  • Schemas for module and part documents; docs/modules.md and DESIGN.md brought in line with what shipped.

Testing

327 Gleam tests, 29 harness tests. The ones worth knowing about:

  • default_loadout_reproduces_the_authored_deck_test compares whole parsed DeckPlans against a frozen pre-M4 map. The frozen fixture's blob hash is the pre-carve hull's, so there is no hand-touched reference.
  • a_module_supplies_its_own_wall_console_test uses an edge console glyph — the version of the test that fails if the stamp only writes tile centres.
  • The refusal tests prove "nothing committed" from the client's observable message stream, not by reaching into actor state.
  • The harness case for a refit compares the served deck rows before and after and asserts the diff is confined to exactly the patched tile's 3×3 block minus its hull-owned SW corner.

Two things to look at

The slot perimeter rule. A slot's perimeter is a double edge shared with hull structure, and collision and rendering both OR the two facing edges — so the hull's side is a floor on what a module can do, never a ceiling. The rule is: author the hull side open and draw the perimeter's walls and doors in the module. The Mockingbird could not follow it, because blanking a corridor's slot-facing wall changes that hull tile's own authored edge and fails the golden test. Consequence: her corridor-side walls are permanent and her forward_crew doors are fixed at the two positions the corridor already opens. Written up in docs/deckplan-format.md; the Sparrow and Finch should be authored to the rule instead.

Reason strings come in two groups. loadout.resolve returns loadout refusals (the player asked for an illegal fit) and content errors (a data file is wrong). Keeping them distinct matters: folding mount_bad_size into mount_too_small would tell a player "that engine is too big for that mount" when the truth is someone typed "size": "xl" in a JSON file.

Follow-ups (not blocking)

  1. rebuild_space's panic path is now fallible for dock and refit, but undock/despawn still keep the old space on failure — fine, since they only remove moorings.
  2. shipclass.decode has no production caller; retained as the encode/decode contract that pins the welcome / ship_fit wire shape.
  3. server/modules/test_fixture/ ships in the content tree because module_dir has no env override — wants DH_MODULE_DIR.
  4. The Consol patch engine has mass: 0.0, back-solved so her default fit reproduces the pre-M4 constants. Worth a rebalance in iteration 2 — the one part where mass demonstrably doesn't matter is the engine, which is the part the design uses to explain that mass matters.
  5. docs/M3.1-RESULTS.md still calls berth_blocked unreachable.

Not in this iteration

Iteration 2: the Sparrow and Finch hulls, hull mount geometry, standalone exterior part sprite exports, client-side sprite layering, the engineering-bay module.
Iteration 3: the refit loop — shipyard stations, a refit console you walk to, per-station catalogs and prices, charging the wallet, the Godot refit UI.

🤖 Generated with Claude Code

Dibujaron and others added 30 commits July 24, 2026 14:07
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… a declaration

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Parts + hull metadata (6) and the sim rewiring (7) now precede the
Mockingbird carve (8), and shipclass.load survives as a shim until the sim
resolves fits. Previously the build was red from task 3 through task 8.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rows were 10 chars (must be a multiple of 3, one block per tile) and put
the helm glyph in the tile centre, where it is not a console -- h is an
edge glyph. Caught by the task 3 implementer's test run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ranches (#M4)

Addresses the task 3 review: load_all's duplicate-id rejection and three of
four validate branches (duplicate slot id, out-of-range digit, non-positive
mass) were implemented but unverified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d patch shapes (#M4)

Addresses the task 4 review: load_all's catch-all Error(_) skip silently
discarded a genuine per-hull directory that failed to read. is_directory now
separates a stray file (skip) from a real read failure (propagate). Adds
load_all coverage for both registries plus true-ragged, negative-origin and
zero-size patch rejection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…test (#M4)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e reason vocabulary (#M4)

The eight-of-nine block copy had zero coverage: every existing test still
passed against a stamp that wrote only the tile centre. `h` is an EDGE glyph,
so the one behaviour the module engine exists to provide was the one behaviour
untested.

- a_module_supplies_its_own_wall_console_test: a hull with no helm of its own
  plus a module drawing `h` on a bay tile's east wall; the resolved class has
  the console, and the same hull with a blank module has none.
- the_stamp_writes_edges_in_the_right_orientation_test: an asymmetric patch
  (door on north only) pins the row/column mapping of copy_tile.
- stamp_owns_the_ne_color_and_the_hull_owns_the_sw_slot_test: the NE colour
  digit is module-owned, the SW slot digit hull-owned.

Ratify mount_bad_size / part_bad_size / patch_bad_deck as CONTENT errors
rather than folding them into player-facing refusals — a broken data file
should not read as a legal refit refusal. resolve now documents the complete
reason vocabulary split into refusals and content errors, with one test per
content reason pinning the exact string.

Also: distinctive cargo fallback (7) so derived-vs-fallback capacity is
discriminating; tests for a patch running off the grid edge and for zero_mass;
check_tags sorts tags so a multi-deficit message names a deterministic one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The task 5 review found mount_bad_size/part_bad_size/patch_bad_deck emitted
but absent from the plan's refit_result list. Ratified rather than folded:
they mean a data file is wrong, not that the player asked for something
illegal, and disguising a content bug as a refit refusal is worse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ata (#M4)

Adds the two Rijay engine parts and the Mockingbird's (and the harness
fixture's) hull metadata (mass/provides/requires/mounts/default_loadout) so
loadout.resolve has something to resolve ahead of the M4 sim rewiring. The
uncarved Mockingbird resolves with zero modules at exactly the pre-M4 flight
constants (accel 40.0, turn_rate 180.0); swapping in the stock engine trades
turn for thrust (55.0/160.0). Extends ship_class.schema.json (additive only)
to describe these fields, which hull.gleam has decoded since Task 5 but no
real document had used yet.
Addresses the task 6 review: nothing verified the fixture hull every pytest
test spawns from actually resolves at the flight constants test_m1_flight.py
depends on. A typo there would have surfaced only as a mystifying harness
failure after the sim rewiring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… (#M4)

Every ship now carries its own `loadout.Fit`, resolved at spawn from the
hull/module/part registries the server loads at boot. `sim.State` holds
`fits: List(#(Int, loadout.Fit))` keyed by ship id; every consumer of the
old world-wide `state.class` looks its ship's fit up through `fit_for` and
skips the ship when there is none, so a missing fit can never crash a tick.
The docked-ship composite stitches each ship's OWN resolved plan.

`ship.step` takes the ship's resolved `shipclass.Flight`; the `main_accel`
and `turn_rate` globals are gone. The Task 3 shim (`shipclass.load`,
`load_with`, `default_shim_flight`) is deleted — a `ShipClass` is now only
ever produced by `loadout.resolve` (via `from_plan`) or decoded from the
wire, which makes "a ship is its resolved fit" true rather than aspirational.

DH_SHIP_CLASS keeps its name but now means "one extra hull document to load
into the registry and spawn from", which is what the pytest harness wants.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… (#M4)

Addresses the task 7 review. Replacing shipclass_test wholesale dropped 17
cases; most were obsolete or covered elsewhere, but five guarded live rules
with no other test: unknown cargo.handling, a missing cargo block, and
dock_standoff read/default on hull documents, plus both branches of the
decoder's own copy of the pallet-derive rule (independent of from_plan's).

Also drops sim_test's duplicate of fit.sim_args and corrects handle_trade's
missing-fit reason from ship_not_docked to no_fit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#M4)

Her hand-authored three-deck interior is now a 96.0-tonne hull plus five
authored overlay modules that stamp back exactly what was there before —
`mockingbird_test` compares the resolved default fit against the frozen
pre-M4 map tile for tile, and 143 of her tiles now come from modules.

Slots (SW-corner digit): cockpit, forward_crew, commons, aft_crew on the
Upper deck; hold on the Lower. Fixed hull structure keeps no digit: every
corridor, both stair columns and their landings, the whole Mezzanine with
its two Q docking ports, the Lower bow-ramp band, the master's cabin off
the mess and the aft engineering space.

Emptying a slot strips its partitions, decor, colour and wall consoles and
reduces its shell to plain wall, so the helm exists because the cockpit
module draws `h` and the 60-unit hold capacity because the break-bulk
module draws 60 `p`. Perimeter edges belonging to HULL tiles are left as
authored — canon has the hull own the corridors and guarantee connectivity
by authoring, and moving them would move a door.

Two alternates ship alongside: passenger staterooms in `forward_crew` and
a bunkerage hold that clears the forward pallets, dropping derived capacity
to 27. Mass bookkeeping is unchanged where it matters: 96.0 + 24.0 of
default modules + a massless Consol patch = the same 120.0, so she still
flies at exactly 40.0 / 180.0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…of the module test

The task 8 review adjudicated the perimeter question: both collision and
rendering OR the two facing edges, so the hull's side is a floor on what a
module can do, never a ceiling -- and hull-side OPEN is strictly more
permissive than a hull-side door, which still forbids a doorless opening.
Records why this could not be retrofitted to the Mockingbird and that her
forward_crew doors are permanently fixed as a result, plus the consequence
that a slot's consoles belong to whichever module draws them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…panicking (#M4)

`rebuild_space` asserts on `composite.build`, so a refit that made the station
un-stitchable would drop every connected player. The previous justification —
that a stamp only writes into the hull's own authored rows, so a refit cannot
change the footprint — was wrong: `place_ships` derives a ship's offset from
`plan.spawn_deck` / `plan.spawn_tile`, and those are re-derived from the
STAMPED map. A module drawing a docking port or an `s` spawn glyph inside its
own slot resolves cleanly, relocates the mooring tile and shifts the whole hull
relative to its berth. Nothing enforces "no module carries a spawn glyph"; it
is a content invariant that happens to hold.

`handle_refit` now pre-flights the composite against the prospective fits and
routes a build failure through the same `fail` closure as every other refusal,
so the verb stays total and atomicity is preserved by construction.
`rebuild_space` and the dock/undock paths are untouched.

Also corrects the `refit_result` doc block: `no_fit` is an internal condition,
not a loadout refusal, so it moves to CONTENT ERRORS; `unknown_hull` carries an
id suffix; the "all but the first N verbatim" claim was wrong for
`unknown_hull:<id>` too, since `resolve` takes an already-looked-up hull.

Tests: a refit that would break the composite is refused and leaves the fit
untouched, driven by a testbed hull under `test/fixtures/` rather than a
spawn-glyph module in real content. The cargo test now asserts a second refusal
after the transfer lands, so both terms of the capacity sum are pinned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four end-to-end cases over the real wire: welcome carries flight stats, a
refit installs a module and the served deck actually changes (the diff is
confined to the patched tile's 3x3 block minus the hull-owned SW corner),
refit while flying is refused, and an unknown module is refused.

Adds one slot to the harness fixture hull and a zero-mass bunkroom module
so the fixture's pinned 40/180 flight behaviour is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t shipped (#M4)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…icking (#M4)

`rebuild_space` asserted on `composite.build`, justified by a comment claiming
world validation and berth spacing make failure impossible for authored
layouts. That justification died with M4: before this branch every ship shared
one `ShipClass`, so a station's build outcome was uniform, but a fit is now
DURABLE and per-ship. `handle_refit` pre-flights the build against the station
the ship is docked at — and that guarantee expires the moment the docked set
changes. A hull refitted with the berth line to herself, or flown to another
station entirely, reaches `rebuild_space` wearing a footprint nothing ever
pre-flighted there, and the assertion drops every connected player.

`rebuild_space` splits in two. `try_rebuild_space` returns the composite's own
reason; the paths that ADD a mooring — dock and login — take the `Result` and
refuse with it (`dock_result` already publishes `berth_blocked`, and a login
already refuses by reason). Undock, despawn and the pre-flighted refit commit
only ever REMOVE a mooring, so they cannot create a collision and keep the
`rebuild_space` wrapper, which holds the old space on the failure that cannot
happen to them. The stale comment is replaced by what is actually true.

The test drives a fixture station whose two berths sit one tile apart — far
tighter than any station in `worlds/` — and a fixture hull whose `shell` slot
plates over her fore and aft rows: Bo undocks, Ada refits alone (legal, and the
pre-flight passes), and Bo's return is refused with `berth_blocked` rather than
taking the actor down. Berth assignment is seed-random, so the fixture is
symmetric by construction: the plated hull overlaps her neighbour from either
side. A second test pins the sim's `unknown_hull:<id>` on the login path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…re (#M4)

`from_plan` and `ship_class_decoder` each spelled out "pallet count if the map
draws any, else the authored fallback". Two copies of one rule that must agree:
change one and `encode`/`decode` silently stops round-tripping, and since the
decoder has no production caller nothing would notice at runtime. Extracted to
`effective_capacity`, called by both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`loadout_test` pins reason strings by exact literal so they cannot drift, but
seven of the vocabulary published in `protocol.gleam`'s wire block and
`docs/modules.md` were never asserted anywhere: `loadout_wrong_hull`,
`module_wrong_slot`, `mount_not_on_hull`, `unknown_part`, `duplicate_mount`,
`mount_wrong_kind` and `invalid_hull_plan`. Each gets a test in the existing
style; the wrong-kind case uses a part small enough to fit the mount, so it
cannot pass as `mount_too_small` by accident, and the wrong-hull-plan case
proves the AUTHORED rows are what failed to parse, not the stamped ones.

Also makes `every_shipped_module_resolves_in_its_slot` non-vacuous: it builds
the swapped loadout with `list.map`, which only REPLACES, so a module whose
slot has no default entry would resolve the default fit and pass without ever
being installed. All five Mockingbird slots have defaults today; the assertion
says so out loud instead of relying on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`decode.float` rejects `4` and accepts only `4.0`, and the schemas cannot make
up the difference: they type these fields as `number`, and JSON Schema draft-06
defines `integer` as any number with a zero fractional part, so `4.0` is an
integer to a validator and "float-spelled only" is not expressible. The
schemas' "must be written with a decimal point" warnings were promising an
enforcement that does not exist, so the decoders meet the schemas instead.

`hull.number_decoder` takes either spelling and is shared by hull, module and
part — the same way `tags_decoder` already is — and the schema descriptions now
say what is true.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…one (#M4)

The schema validates `server/shipclasses/*.json`, which since M4 are HULL
documents decoded by `hull.gleam` — but its title said "Ship class document"
and its description pointed authors at `shipclass.gleam`, whose decoder
requires a `flight` block a hull document must never carry. Renamed to
`hull.schema.json`, with the title, `$id` and description naming
`hull_decoder` and spelling out how a hull differs from the resolved class the
bake produces. The `slot` definition now says that digit and id uniqueness is
enforced by `hull.validate` rather than by the schema, since draft-06 cannot
express uniqueness across array items.

`data_schema_test`'s constants and test names follow. Its handling-value case
was asserting on an M2-era document that `additionalProperties` would have
rejected anyway; it now uses a minimal hull document and a passing control, so
the refusal is about `handling`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dibujaron and others added 3 commits July 25, 2026 13:38
…acity (#M4)

Two small things that misled a reader.

`deckplan-format.md` claimed "any future hold module must redraw that `c`, or
the ship will not resolve". False: `shipclass.validate` requires only a HELM
console. A hold module that forgets its `c` resolves fine and simply leaves the
crew with nowhere to work cargo, which is what `docs/modules.md` already says.

`mockingbird.json` authored `cargo.capacity: 40` while her default fit resolves
to 60 from pallet glyphs. The field is now a fallback that only fires for a hull
whose plan draws no pallets, so 40 was a number no code path would ever read
and every reader would take for the truth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…solved

An engine with mass 0.0 was the one part on the ship where mass demonstrably
did not matter -- and it is the part the design uses to explain that mass
matters. The Consol patch now masses 8.0 and the heavier Rijay original 12.0,
so the swap trades weight as well as thrust.

Neither the old 120.0 total nor the old 40.0/180.0 feel was a tuned number;
both were back-solved from ship.gleam's pre-M4 constants. So nothing is
back-solved now: masses say what each thing is, engines say what they push
with, and accel = thrust/mass falls out (39.06 u/s^2 and 171.9 deg/s on the
default fit, 53.03 and 151.5 with the original). Retuning is four numbers in
two JSON files.

Flight assertions move to a tolerance -- comparing a derived quotient with
float equality only worked because the old divisors were powers of two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Slot membership hides in each tile's SW corner, which is unreadable in the raw
JSON. This paints the digit into each tile so the modular regions read against
the ship's own walls and doors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Dibujaron
Dibujaron merged commit 222ab4e into main Jul 28, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant