354 Commits

Author SHA1 Message Date
abe8c8f448 stand the expansion button on the ground it buys
The button leaves the header bar for the world, centered on the columns
the next purchase unlocks and panning with them until it leaves the view.
Those columns now take a lighter tint than the locked ground behind them,
so the boundary between the two says how far one purchase reaches.

Nothing in the code reported a scroll: the camera knew it had moved and
told only the hover. A ViewScrolledEvent says it now, which is what a
widget keeping a place in the world rather than on the screen needs. The
panels want none of it -- they are placed against the screen and stay put
as the world moves under them -- so the button is placed after their pass
and never joins the rectangles they step around.

Its face is two lines, "Expand" over the cost. That makes the cost a
display of its own, so it carries the building-block tooltip the way every
other item value does, on hover alone since the click buys the expansion.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-18 22:55:06 +02:00
8292bc6134 stand the expansion button on the ground it buys
The button leaves the header bar and floats over the world, centered on
the columns the next purchase unlocks. Being a place in the world rather
than on the screen, it pans away with those tiles; nothing needs to bring
it back, since the leftmost pan centers the buildable edge and the
columns lie just left of it.

Those columns now take a tint of their own, more transparent than the
locked ground behind them, so the boundary between the two says how far
one purchase reaches. The tinted area itself went undocumented until now,
and it has no left edge: expansions are unbounded, so the tint runs as far
left as the view can see, which is what made "the middle of the greyed
region" answerable only for the next expansion's slice.

The button's face becomes two lines, "Expand" over the cost. That makes
the cost a display of its own, so it carries the item tooltip the way
every other item value does instead of the whole button standing in for
it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-18 22:34:21 +02:00
b51a9c5c77 give the keyboard back when a modal closes
Hiding the layer takes the focus off whatever stood on it and leaves the
window with no focus widget at all, so the world view answered no keys
afterwards: Escape opened the menu once, closed it, and then did nothing.
Each modal now notes what held the keyboard when it opened and gives it
back on the way out -- the world view for the first of a stack, the modal
beneath for one above it, which was dead the same way.

The window's own focus guard now also catches the focus going nowhere,
rather than only landing on the wrong widget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-18 22:11:04 +02:00
b7fe5cfcf2 put the caret in the name prompt's line edit
The layer asked the modal's focusWidget() what to focus, but hosting a
modal reparents it into a scroll area and a reparent clears the focus its
constructor set, so the answer was always nothing and the dialog itself
took the keyboard. Modals now name their initial focus widget outright,
which survives the hosting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-18 22:05:28 +02:00
1ffacae2fb put the modal up in the same frame as the dim behind it
The layer was shown and painted before the modal it hosts was placed, so
opening one -- the layout dialog from the shipyard panel, say -- painted a
frame of bare dim over the world before the dialog appeared, which reads
as a black flash. The modal is now placed and shown while the layer is
still hidden, and showing the layer paints both at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-18 21:57:47 +02:00
44ec3c51f1 explain an item wherever the UI names one
Every display naming an item now shows that item's production tooltip, not
only the selection panel's chips: the header block stock, the total cost of
a building multi-selection, the remaining scrap of a debris selection, the
icons of every recipe summary, and a blueprint card's cost.

ItemTooltip moves out of the selection panel and takes an
ItemTooltipContext of its own -- an item is named all over the UI, and the
panel's context carries visuals and a debug flag no tooltip reads. A
recipe line wraps each icon with its amount so the pair can be pointed at
as one statement, and attaches tooltips only where asked: a module button
and the item tooltip itself draw the same line and stay silent.

Hover only wherever the display sits on something the player clicks, whose
click is not free to explain. Note that a recipe line on an option button
can no longer be transparent to the mouse -- Qt never looks inside a
transparent widget for the cursor -- so it relies on press propagation to
keep picking the option.

The header block stock loses world.building_blocks_tooltip, showing what
every other block icon shows instead. The Expand button keeps no tooltip:
its cost is painted into its face with nowhere to hang one, and the button
is due to be removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-18 21:41:34 +02:00
dcc801f017 explain an item wherever the UI names one
An item icon names an item, so the player can ask what it is wherever it
is drawn: a chip, a value stated as an amount plus a bare inline icon, or
an icon in a recipe summary. All three now show the item production
tooltip, with the amount beside the icon as part of the target.

An icon says nothing only where its display already carries a tooltip of
its own, since a display can only show one -- the build buttons, and the
module selection buttons, which are described controls whether or not a
given module fills its description in. On a clickable host the tooltip is
hover-only: the click belongs to the control.

The header bar's block stock loses its configured description in favour
of the production tooltip every other block icon shows, so
world.building_blocks_tooltip goes with REQ-UI-BLOCKS-TOOLTIP. It was the
only place stating what blocks are spent on, which no producer list says.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-18 17:26:23 +02:00
099d614395 show a tooltip on click, and keep it up until the pointer leaves
One tooltip mechanism for the whole UI: a Tooltip popup plus a
TooltipTrigger event filter, replacing every setToolTip call. Qt's own
tooltip cannot do what REQ-UI-TOOLTIP-TRIGGER and REQ-UI-TOOLTIP-DISMISS
ask for -- it times out, hides on the first mouse move, cannot be hovered
and cannot be brought up by a click.

A tooltip now appears the moment an element with no click action of its
own is clicked, and it is placed with its top-left corner on the pointer,
so the pointer can move onto the tooltip and hold it open. The pointer is
polled while a tooltip is up rather than tracked through enter and leave
events, whose order depends on which widget the pointer crosses first.

The item chip's children become transparent to the mouse. They were
taking the chip's enter and leave events, so its tooltip only ever
appeared when the pointer rested on the chip's padding.

The modal header's close button loses its "Close" tooltip: a close button
in a header says that by being one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-18 14:52:02 +02:00
1554d99739 show an icon's tooltip the moment it is clicked
An icon that displays a value and does nothing when clicked is free to use
the click for "tell me what this is", which beats waiting out the hover
delay and is the obvious thing to try. Buttons whose click already acts
keep hover only, so one gesture never both acts and explains.

Tooltips also stop timing out and become hoverable themselves, so a
tooltip listing several recipe lines can be read at any pace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-18 14:05:23 +02:00
0e64f45a5e forward-declare GameConfig as the struct it is
Command.h announced it as a class while GameConfig.h defines a struct, which
MSVC reports as C4099 in every translation unit that sees both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-18 12:31:10 +02:00
fb65cbabf4 close a dialog when the player clicks beside it
REQ-UI-DIALOG-DISMISS asks that a dialog be put away by clicking away from
it, the gesture that already puts the selection panel away. The layer is
the widget those clicks land on, so this is where it belongs -- and the
three dialogs that take the gesture already say so through isDismissible(),
which Q has been using since the base class arrived.

A left press and a left release must both land beyond the open modal's
rectangle. Where the click landed is what decides, never that the layer
received it: a click on an inert part of a dialog -- a label, the gap
between two controls -- propagates up from the widget that ignored it and
arrives here with a position inside the modal, which is not a dismissal. A
drag begun inside the dialog never reaches the layer at all, the widget it
began on keeping the release, so no gesture ends by discarding the dialog it
was made in.

Every click on the layer is consumed either way, dismissing or not: the
window behind a modal takes no input, and closing the modal does not turn
that click into one for whatever lies under it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-18 12:16:57 +02:00
9b88deff69 give the escape menu and the name prompts the game's own frame
The last dialogs wearing system chrome were the three QMessageBoxes -- the
escape menu, the game-over and win screens -- and the two QInputDialogs that
name a blueprint. They are the ones the player meets at the sharpest moments
of a run, and they looked like alerts from the operating system.

MessageDialog and NameInputDialog replace them on the layer. The message
dialog names its buttons by the index addButton hands back and reports which
was clicked, with Escape standing for a button the caller nominates rather
than for a dismissal of its own -- Continue in the escape menu, as it was
explicitly set to before, and Quit on the two state screens, which is where
the reject role sent it. Neither dialog takes Q or a click outside: every
button is a decision, and a half-typed name is work in progress
(REQ-UI-DIALOG-DISMISS).

Two things the layer needed for the nested case: it now tracks the scroll
area each modal is shown in, so a modal opened with no anchor centers on the
one it was opened from rather than on the window -- which is where the
Create Blueprint prompt belongs (REQ-UI-PANEL-MODAL) -- and findFor() walks
a widget's parents to the layer, so the blueprint panel buried in the layout
dialog can open a modal without every widget in between carrying a pointer.

The three error boxes stay system dialogs on purpose: config load, config
reload, and blueprint file load all report a failure that may leave nothing
to draw on (REQ-UI-MODAL-CHROME).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-18 12:14:31 +02:00
f03003194f host the modals in the window instead of in windows of their own
Every dialog was an OS window with a dim widget behind it in the game
window. That is why a click beside one could not reach it: Qt drops mouse
events for a window a modal blocks, so the dim -- a child of the blocked
window -- never saw the press.

ModalLayer is that dim grown up: still a child of the main window covering
its rect, but it now hosts the open modal, places it, and runs its event
loop, so it is the widget the clicks beside a modal land on. It keeps a
stack, paints one dim however many modals are open, and a hold keeps it up
while one modal hands over to the next. ModalDimOverlay and ModalDimScope
are gone; ModalLayerHold replaces the scope at the sites that still open a
system message box.

ModalDialog is what a dialog inherits in place of the window it lost: the
panel background, the drawn header with its optional close button, and the
dismissal gestures. isDismissible() governs Q and, next, the click outside
-- one predicate because both gestures reach the same dialogs. Its default
refuses, so a dialog opts in. requestDismiss() is what a gesture asks for,
and ShipLayoutDialog overrides it with the one-step ladder its Q handler
used to spell out, which is now reached by both. DialogDismiss.h folded into
the base.

The four dialogs the player meets keep their contents unchanged and lose
their title bars: the recipe/schematic selection, blueprint selection, ship
layout, and schematic choice dialogs. Placement moved with them --
placeOnSelectionPanel became getSelectionPanelAnchor, and the layer centers
on that rectangle in window coordinates, with no global mapping and no
frame height to guess at.

Two things followed from dropping OS modality: the focus guard that hands
focus back to the game world now also asks the layer whether a modal is
open, and closeEvent refuses to close the window while one is, since its
nested loop runs over widgets the window owns.

The escape menu, the game-over and win screens, and the two name dialogs are
still system dialogs; they are dimmed by a layer hold until they are
converted next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-18 12:07:25 +02:00
7327343b2a draw every modal the player meets while playing
The dialogs were OS windows with a dim widget behind them, which is why a
click beside one could not reach them: Qt drops mouse events for a window a
modal blocks, so the dim -- a child of the game window -- never sees the
press that REQ-UI-DIALOG-DISMISS now asks it to act on.

REQ-UI-MODAL-CHROME states the rule the fix follows: a modal is drawn by the
game on the dim, with no title bar, no window border, no window-manager
close, and no way for the player to move it, resize it, or drag it off the
window. Each draws its own header, as the blueprint selection dialog already
does; content too large for the window scrolls inside the modal instead of
hanging past the edge. The exception is failure reporting -- a config or
blueprint file that will not load is still a system message box, since it
must reach the player when there is nothing left to draw on.

The requirements that described the old chrome follow: the blueprint dialog
loses its window-manager close, the escape menu, game-over and win screens
and both name dialogs say they are drawn, and REQ-UI-MODAL-DIM says the dim
is the surface a modal sits on rather than a layer kept in step with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-18 11:54:54 +02:00
da3f417464 let a click outside a dialog dismiss it
A dialog was left by Q, Escape, or its own Cancel; a click beside it did
nothing, though that is exactly the gesture that already puts the selection
panel away. REQ-UI-DIALOG-DISMISS now covers both ways out, for the same
three dialogs and to the same effect, and the layout configuration dialog
backs out one step at a time under the click as it does under Q -- so a
mis-aimed click during module placement costs a re-click, not the session.

Three things the bare "clicks outside" left open are settled: the press and
the release must both land outside, so a drag begun in the dialog cannot end
by discarding it; the click is spent on the dismissal and does not reach what
it landed on; and the name-entry dialogs refuse it as they refuse Q, a
half-typed name being work in progress.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-18 09:29:02 +02:00
01aa1a08b0 stop the layout dialog opening for a shipyard with no schematic
Clearing a shipyard with "(None)" opened the layout configuration dialog on a
grid of no cells. The auto-open guard asked only whether the chosen id differs
from the current one, and the "(None)" option carries the empty id, which
differs from every schematic; ShipLayoutDialog then found no ship def and
derived a 0x0 grid. Predates this branch -- 698dd4d, 2026-07-13.

The question all three sites were answering by hand is now one:
findLayoutShipDef() returns the ship to configure a layout against, or nullptr
when no schematic is set, the id names no ship, or the ship defines no grid.
The auto-open path and the LayoutDialogRequestedEvent handler now ask it
before opening, and ShipyardContent asks it instead of spelling the same test
out for the preview and the Configure button.

The event handler was reachable only through a button ShipyardContent already
disables, so guarding it changes nothing today; it is guarded because the
dialog's precondition belongs to the dialog's entry, not to the widget that
happens to be the only caller.

REQ-MOD-UI-AUTO-DIALOG said "differs" and left clearing implicit, which is the
reading the code took. It now says clearing opens nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-17 22:50:14 +02:00
5968e5f40a dismiss a dialog with Q, and refuse to dismiss the drop dialog
Three dialogs take Q as a second way out beside Escape: the recipe/schematic
selection dialog and the blueprint selection dialog close outright, and the
layout configuration dialog steps out one level per press -- the module being
placed, then remove mode, then the session. Both of those mode exits now go
through the handler the Remove button uses, extracted from a lambda into
onRemoveButtonClicked(), so the key and the button cannot leave different
state behind.

The schematic choice dialog goes the other way and declines reject(). It had
no close button but Escape still closed it, and the caller then applied
choiceIndex 0 -- awarding whichever option happened to be first. Refusing
reject() covers Escape, Alt+F4, and the window manager together, since all
three funnel through it. It is also the only dialog whose dismissal would
strand state: the poll that opened it does not reopen it while the choices
stay pending, so a drop dismissed is a drop lost.

The key itself is spelled once in DialogDismiss.h rather than in three key
handlers. It stays out of the ControlAction table on purpose: that table
answers what an input does in the player's current situation, and a dialog
has none -- it holds focus and takes the key whatever the world is doing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-17 22:31:59 +02:00
1e24b87640 let Q dismiss a dialog, and stop the drop dialog being dismissed at all
Q backs the player out in the game world, so it backs them out of a dialog
too: the recipe/schematic selection dialog, the blueprint selection dialog,
and the layout configuration dialog, where it is Cancel. In that last one it
steps out one level at a time as it does in the world -- a selected module,
then remove mode, then the session -- so one press never both leaves a mode
and discards the changes.

The schematic choice dialog goes the other way: it is now stated to be
undismissable, which it was already built to be (no close button) but was not
written down. Escape currently closes it and the handler then applies the
default choiceIndex of 0, awarding the first option the player never picked;
saying no way out exists but choosing is what closes that.

Q stays an ordinary character in the two dialogs that take a typed name. The
Escape bullet named only the blueprint dialog and was narrower than what
Escape has always done, so it now covers dismissible dialogs generally.

Requirements only; no code yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-17 22:22:05 +02:00
b0fa0da813 clear the selection with Q, and drop it on entering a build mode
Implements the requirements committed in 731b887.

Q becomes a three-way branch in the action table, which is the layer that owns
what an input does: ExitMode while a mode is active, the new ClearSelection
while something is selected, EnterDeconstruct otherwise. The three partition
the situations between them, so resolution stays first-match-wins over
available actions and the handler never re-derives the precedence -- which is
why ClearSelection gets its own event rather than joining ModeCancel on Q.

GameWorldView clears the selection at each of the three events that enter a
mode; those are the only ways in, whichever button or key the player used. No
clear is needed where ModeCancel falls through to deconstruct mode: Q resolves
to ClearSelection while anything is selected, so there is nothing left by then.

The Selection context's Q row reads "Clear selection", sits last as the row
that hands the context back does everywhere, and carries the exit badge
styling -- one key that backs out should look the same wherever it appears.
Requirements follow that last point in REQ-UI-CONTROLS-CARD and
REQ-UI-CONTROLS-CONTENT.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-17 21:49:33 +02:00
731b8874c9 let Q clear the selection, and keep selection and build mode apart
Q backed out of a build mode or toggled deconstruct mode; it now clears the
selection as its middle case. That needed a rule for a selection held while a
build mode is active, a state the code allows today: clearAll() is called only
by a click or drag that hit nothing, so a selection survives into builder,
blueprint, and deconstruct mode.

Make the two mutually exclusive instead, matching what the controls panel
already shows: entering any build mode clears the selection. The blueprint
gestures read the selection before the mode entry clears it, so C and Ctrl+C
lose nothing.

Drops the accuracy carve-out that had C / Ctrl+C merely omitted from the build
contexts on the strength of a surviving selection -- with no selection there,
they are unavailable rather than omitted, and the list is the three cases its
intro claims.

Requirements only; no code yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-17 21:31:35 +02:00
392a2b8d00 keep the selection panel half a tile off what it describes
The panel had one distance for the view edges, the widgets it steps around,
and the selection alike, so it stood eight pixels from a building and touched
it outright along the top. The gap from the selection is now its own value,
half a tile, and it is horizontal only -- the top edges stay level.

It is sampled where the tile size is known, in the same moment as the anchor
rectangle, and travels with it: a rectangle frozen in one moment has no
meaningful distance to a tile size measured in another. chooseSide now takes
the gap in place of the margin, the band having already taken the margin off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-17 16:24:14 +02:00
abaccc45b5 give the selection panel its own gap from what it describes
The panel kept one distance for everything: the view edges, the widgets it
steps around, and the selection itself. Beside a building that read as
touching it -- eight pixels to the side and nothing at all above, the top
edges flush. Split the two apart: an edge margin as before, and a selection
gap of half a tile, horizontal only, frozen with the anchor rectangle it is
measured from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-17 15:53:30 +02:00
b2148f00ac stop hovering when the cursor points at no tile
A cursor resting on a panel or outside the window kept whatever it last
pointed at: the ghost, the tunnel preview, the deconstruct tint all stayed
put, because "not hovering" was not a state the build mode could hold. Make
both ghost tiles optional, clear the hover with them, and re-derive it when
the cursor comes back or a mode is entered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-17 15:19:22 +02:00
9490a96e12 let the hover follow a view that scrolls under a still cursor
Only the selection box was refreshed while the camera panned, so the ghost,
its validity, the resolved tunnel end and the deconstruct hover all kept the
tile of the last mouse move. Give the whole hover update one entry point and
run it from the pan step as well, for a cursor that is over the world.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-17 14:54:34 +02:00
fd0c246bc0 show the tunnel end the click would actually place
The controls panel header read the type builder mode was entered with, so
tunnel mode always said Tunnel Entry even where the ghost had resolved to an
exit. Feed the header the same effective type the ghost and placement already
use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-17 13:26:50 +02:00
9c275e283c give every recipe one shape: a list of output groups
Implements REQ-MAT-OUTPUT-GROUP. A recipe had two shapes -- outputs produced
together, or outputs of which exactly one happened -- and every rule over them
was written twice, selected by `building == ReprocessingPlant`: sizing a
buffer, deciding whether a cycle fits, resolving what a cycle makes, costing an
item. RecipeDef now holds output groups, each a weight and a list of items, and
a cycle yields exactly one group. One group is the ordinary recipe, so the old
two cases are the same shape with one and with several, and all four rules
collapse to one expression apiece with no building-type test left.

rollReprocessingOutput becomes rollOutputGroup, where a single group returns
without drawing or testing eligibility. That early-out is load-bearing twice
over. Drawing there would consume entropy for every ordinary recipe and shift
every later random outcome; and eligibility must not apply either, since
implicit unlocking is demand-derived, so an ordinary recipe's output can be
producible while nothing yet calls for it -- testing it would stop the building
producing rather than gate a drop. Past the early-out a group is eligible only
when all of its items are unlocked, being produced whole.

Threat follows the recipe's shape rather than the building, and the per-unit
value now divides by the group's amount as well as its odds. That moves no
number today: every item resolved through this path has amount 1, which is why
the threat expectations are untouched.

Config keeps `outputs = [...]` as the single-group form, so only the two
reprocessing recipes change shape. The recipe summary gains "/" between groups
and keeps "+" within one, which also fixes the plant reading as though a cycle
produced all of its items at once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-17 12:47:09 +02:00
41c45d73ce cut the reprocessing plant entry back to what is still its own
Everything it used to specify moved out as the output-group merge generalised
it: the pick is REQ-MAT-OUTPUT-GROUP, the all-outcomes gate REQ-MAT-CYCLE, the
buffers REQ-MAT-OUTPUT-BUFFER, the eligible set REQ-LOCK-OUTPUT-POOL, the
recipe control REQ-BLD-AUTO-RECIPE. Restating them here only invited the two
to drift apart.

What is left is what config cannot say: why the building exists -- the
value-preserving counterpart to smelting scrap down, and the only path to
voidsteel -- plus the one rule that really is specific to it, that reprocessing
recipes take no part in the implicit unlock traversal, which until now was only
implied by REQ-LOCK-IMPLICIT naming the other three building types.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-17 10:51:54 +02:00
f39e4f3506 name the output pool restriction for what it restricts
It is no longer about reprocessing: any recipe with several output groups is
subject to it, and the plant is only the building that happens to have one
(REQ-MAT-OUTPUT-GROUP). REQ-LOCK-REPROCESSING-POOL becomes REQ-LOCK-OUTPUT-POOL.
Four citations, all in docs -- no code cites it.

Also corrects a line REQ-LOCK-IMPLICIT still carried from before smelters had a
recipe control: their recipes are not "never shown in any UI dropdown" any more,
they are simply never gated, so the dialog offers all of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-17 10:32:30 +02:00
3c63205c6c make every recipe one shape: a list of output groups
Deterministic recipes could have several outputs produced together; a
probabilistic one could have several outputs of which exactly one happened.
Two shapes meant two rules everywhere -- sizing a buffer, deciding whether a
cycle fits, resolving what a cycle makes -- each written as a branch on
whether the building was a reprocessing plant.

New REQ-MAT-OUTPUT-GROUP merges them. A recipe has one or more output groups,
each with a weight and a list of items; a cycle produces exactly one group,
and the items within it together. One group is the ordinary recipe and is
always chosen, so the old deterministic and probabilistic cases are the same
shape with one group and with several -- and every rule downstream is written
over groups, needing no branch at all. Config keeps outputs = [...] as the
single-group form, so only the two reprocessing recipes change shape.

It also lets an outcome yield several items, which was unrepresentable, and
fixes a display bug on the way: the recipe summary drew all outputs as one
combined yield, so a plant read as if a cycle made all four items. Groups are
now separated by "/" and the items within one by "+".

REQ-LOCK-REPROCESSING-POOL now restricts the choice between groups rather
than the pool of output items, and says why that distinction is load-bearing:
implicit unlocking is demand-derived, so an ordinary recipe's output can be
producible while nothing yet calls for it. Testing eligibility there would not
gate a drop, it would stop the building producing at all -- so a recipe with
one group, having no choice to restrict, is never tested. A group is eligible
only if all of its items are unlocked, since they are produced together.

Requirements only; the implementation follows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
2026-08-17 09:46:31 +02:00
a1c567715e make selection box sub-tile aware 2026-08-14 22:51:05 +02:00
1cf7c264c1 draw selection rect only if mouse has moved 2026-08-14 22:20:35 +02:00
d36e59fd26 make selection box use the selection or deconstruct color to indicate the mode 2026-08-14 21:49:55 +02:00
9bbade2420 make recipe selection and buffers of smelter and reprocessing plant behave like other buildings, except that a recipe may be chosen automatically 2026-08-12 23:24:31 +02:00
4ee6438405 allow the probabilistic recipe output of the reprocessing plant to yield more than 1 item of a type per cycle 2026-08-12 22:10:29 +02:00
0b859bd1a4 start the next production cycle in the tick the last one completed 2026-08-12 21:27:25 +02:00
dd061082fb fix issue where status light was flickering to yellow for one tick between cycles 2026-08-12 21:26:09 +02:00
b6b6acb99c ask the icon cache for the inline block icon 2026-08-12 21:22:40 +02:00
7e670fc01c state the debris scrap value with the item icon, not the word 2026-08-12 21:21:47 +02:00
28656bea7e name recipes after their process, not their product 2026-08-12 21:14:33 +02:00
bd8fe5157e add border around recipes where several of them stack 2026-08-12 08:30:10 +02:00
a3fddf63cc give a recipe line its own row for its identity 2026-08-11 22:07:15 +02:00
1ddd830476 fix requirements: let the layout preview state that it shows color alone 2026-08-11 22:07:02 +02:00
f255224ccd show recipes visually instead of describing them in text 2026-08-11 21:47:35 +02:00
3274df91d4 open selection panel modals on the panel 2026-08-11 21:21:12 +02:00
d1da4b2937 color item icon backgrounds in the UI from visuals.toml 2026-08-11 21:18:52 +02:00
37378e3c1b allow to move the selection panel via mouse drag 2026-08-09 22:09:41 +02:00
37a7a7499a update item box color to yield better contrast 2026-08-09 21:34:31 +02:00
5794a51c9a draw boxes around item icons 2026-08-09 21:20:47 +02:00
09858f4b62 keep an auto-recipe building's summary between its cycles 2026-08-09 20:41:48 +02:00
ca49b9dbec let the world renderer hold the simulation by const reference 2026-08-09 20:37:15 +02:00
79650ae211 make EntityAdmin::hasAll const 2026-08-09 20:37:09 +02:00
c0b009c548 place the selection panel beside what it describes 2026-08-09 20:36:58 +02:00
7ae5f8c4dc fix selection panel contents 2026-08-07 22:01:10 +02:00
9f574fa05e fix issue where the selection panel sometimes collapses to very small size 2026-08-07 21:17:53 +02:00
e5d689c8d8 document the player-input design in architecture.md 2026-08-07 20:43:12 +02:00
0b8f5eccd6 scroll the controls panel's rows instead of clipping them 2026-08-07 20:42:56 +02:00
40415e663b put the controls panel in the corner and let it step around the build bar 2026-08-07 20:42:41 +02:00
7a098edf72 mark the exit row of the controls panel red on its chips instead of its label 2026-08-07 19:52:51 +02:00
365641feb7 size the controls panel to the rows it is actually showing 2026-08-07 19:38:10 +02:00
4b5e288632 record that additive selection is offered only once something is selected 2026-08-07 19:10:26 +02:00
cab7244b00 divide the always-available rows in the General context too 2026-08-07 19:10:04 +02:00
980fad272f show the available controls in a panel over the world 2026-08-07 18:48:01 +02:00
68a43a7d98 resolve mouse gestures through the action table too 2026-08-07 18:47:43 +02:00
1f754de431 resolve keyboard shortcuts through one action table instead of a switch 2026-08-07 18:47:29 +02:00
f61f0bf761 add requirements for a context-sensitive controls panel floating over the game world 2026-08-07 18:47:15 +02:00
075e44d295 give the selection cards their own parts instead of label blobs 2026-08-07 18:43:23 +02:00
2289277e12 split the selection panel into one card per kind of selection 2026-08-07 18:38:55 +02:00
cc0ef856e3 make asteroid smaller 2026-08-06 22:14:02 +02:00
603f8063e9 float the selection panel over the game world instead of a side column 2026-08-06 22:06:42 +02:00
3b37b0ecf8 target single-building transfers by hovering, not by footprint coincidence 2026-08-06 20:31:51 +02:00
98deab932a let any blueprint transfer configuration, not just single-building ones (if the orientation matches) 2026-08-06 20:29:20 +02:00
08d8b0dd90 re-cover copy-settings through single-building blueprints 2026-08-06 19:44:55 +02:00
9a3b6c10d6 fix bug where selecting the same layout for a shipyard discarded the current progress and buffers 2026-08-06 19:18:45 +02:00
cd31af2611 remove the Shift copy-building-settings gesture 2026-08-06 19:09:11 +02:00
fd6a7c5815 rekey the temporary blueprint to C, add V to re-place it 2026-08-06 19:07:13 +02:00
18cfe238f6 move blueprints out of the sidebar into Ctrl+C / Ctrl+V dialogs 2026-08-06 19:03:59 +02:00
2cbcf1554f add the ASCII-only source rule and the visual-verification note to CLAUDE.md 2026-08-06 08:28:57 +02:00
c1af58d80c float the build buttons as a horizontal bar over the game world and show key bindings inside build buttons 2026-08-06 08:28:40 +02:00
f39fe9a118 move the debug stats panel out of the renderer 2026-08-05 22:14:55 +02:00
e5dcb9de5f extract WorldRenderer 2026-08-05 22:14:55 +02:00
dc83add5c6 move two placement queries out of the view into PlacementRules 2026-08-05 22:14:54 +02:00
3a1951559d share the world shapes both views (game and balancing) draw identically 2026-08-05 22:14:54 +02:00
26d7448492 extract BuildModeController, fixing a silent blueprint exit 2026-08-05 22:14:54 +02:00
202f583067 extract SelectionController 2026-08-05 22:14:54 +02:00
d5ab44b9bf move the remaining hotkeys into the InputMapper 2026-08-05 22:14:53 +02:00
bb1ffab8fc fix a bug where view continues to pan when window lost focus while panning 2026-08-05 22:14:53 +02:00
e1445fe508 move the already-event-driven hotkeys into the InputMapper 2026-08-05 22:14:53 +02:00
caa810f66d move pan input into an InputMapper 2026-08-05 22:14:53 +02:00
f6df95abb2 extract the scroll position into WorldCamera 2026-08-05 22:14:52 +02:00
fa9dbd62ad use WorldCoordinates in ArenaView too 2026-08-05 22:14:52 +02:00
2af09d9eb1 extract the world<->widget transform into WorldCoordinates 2026-08-05 22:14:52 +02:00
4f7fdb8a4c add tone, critique and class layout rules to CLAUDE.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JcReq7hVk4KUPhTDKWAG7K
2026-08-05 22:14:37 +02:00
949937d2c2 re-use PlacementFixture in BuildingTests 2026-08-05 07:53:08 +02:00
4c166bf47f depend on the registry instead of DebrisSystem in the AI path 2026-08-05 07:25:59 +02:00
60260540cd make deconstruction its own system 2026-08-05 07:10:46 +02:00
1f4503176b make construction its own system (extracted from BuildingSystem) 2026-08-05 06:57:12 +02:00
fd85e8e10a free the buffer setup and belt registration from BuildingSystem 2026-08-05 06:55:13 +02:00
114a43b205 make BuildingSystem stateless: FactoryState becomes a parameter 2026-08-05 06:50:11 +02:00
d87d063b10 move the placement rules and the config-dependent queries off BuildingSystem 2026-08-05 06:49:49 +02:00
537597c854 delete the unused getAllBeltTiles and BeltTileInfo 2026-08-05 06:49:28 +02:00
9c3be0fbd0 extract the production rules as free functions over config and building 2026-08-05 06:49:15 +02:00
58b94223f7 migrate every factory query off BuildingSystem onto the free functions 2026-08-05 06:46:13 +02:00
1fb63cce4e move the asteroid width bound into FactoryState 2026-08-05 06:45:33 +02:00
0b7e94b4e4 drop CombatSystem's unused BuildingSystem parameter 2026-08-05 06:45:16 +02:00
0408336cf9 depend on factory data instead of BuildingSystem in the AI path 2026-08-05 06:44:49 +02:00
46932e4abf move FactoryState ownership out of BuildingSystem to Simulation 2026-08-05 06:43:52 +02:00
0edea5d961 gather the factory's world data into FactoryState 2026-08-05 06:43:30 +02:00
60cc187d92 add BuildingGrid to manage tile occupancy 2026-08-04 18:26:01 +02:00
3990351a16 share BuildingSystem's free functions instead of copying them 2026-08-04 18:24:38 +02:00
bd344e4fbe add FieldSelectionPanel for extracting the ships/stations/debris selection 2026-08-04 18:23:28 +02:00
64c344c3a3 correct the belt subsystem interface description in architecture.md 2026-08-04 18:12:02 +02:00
02c7fed9b4 allow "auto" for named local lambdas and iterator types via claude.md 2026-08-04 18:11:37 +02:00
5d4a975384 cover unlock state in the determinism tests 2026-08-04 18:11:03 +02:00
475df0e5fd extract unlock state from Simulation to UnlockState class 2026-08-04 18:10:43 +02:00
61634f6fd2 move the shared TOML helpers into the utility namespace to avoid name collisions 2026-08-04 18:05:52 +02:00
c8ff7da345 extract load methods into their own files 2026-08-04 18:05:27 +02:00
d664ab54cc extract shared TOML helpers into TomlHelpers.h/.cpp 2026-08-04 18:02:23 +02:00
906000b0e9 drop the dead payloads from the state-change events 2026-08-03 22:02:47 +02:00
d9ef6aa728 make HeaderBar read the tick, artifacts and boss wave from the simulation instead of event 2026-08-03 22:02:19 +02:00
b44a85685e make BlueprintPanel read the block stock from the simulation instead of event 2026-08-03 22:02:05 +02:00
edd1c31785 remove duplicate findModuleDef from ShipLayoutPreview 2026-08-03 22:00:25 +02:00
785ce3ebfe remove duplicate findBuildingDef from BuildingSystem 2026-08-03 21:14:13 +02:00
7017f8b4dc route the win-path restart through ResetCommand 2026-08-03 21:13:43 +02:00
77a842f884 dedupe AttackExecutor and RepairExecutor via executeOrbitAndAssign 2026-08-03 21:13:24 +02:00
d5ba72d554 add ModalPauseScope for the pause-around-modal idiom 2026-08-03 21:11:52 +02:00
83177729e9 extract MainWindow::reloadConfig 2026-08-03 21:11:02 +02:00
a8e933b7f2 drop EntityAdmin::add in favour of addComponent 2026-08-03 21:09:18 +02:00
e02e323cb2 share a single ItemIconCache across the UI 2026-08-03 21:08:19 +02:00
b4e622daa5 extract the shared Centroid helper into ai/Centroid.h 2026-08-03 21:06:06 +02:00
1150985c1f share one loadTestConfig() helper across the tests 2026-08-03 21:05:28 +02:00
594c3b93c5 make HeaderBar read block stock and expansion cost from the simulation 2026-08-03 21:04:43 +02:00
932b57720c dedupe tunnel lookup and key tunnel tiles by QPoint 2026-08-03 21:02:06 +02:00
ca727bef35 extract Simulation::initializeSubsystems to remove duplicate code 2026-08-03 20:59:00 +02:00
553a7e0701 add findShipDef/findModuleDef/findRecipeDef to config structs and re-use them in the rest of the code base 2026-08-03 20:57:31 +02:00
af6828c348 remove duplicate findBuildingDef from GameWorldView 2026-08-03 20:50:03 +02:00
3671e1d7e6 fix splitter filters being lost when rotating in place and add test 2026-08-03 20:49:10 +02:00
370a3036c1 fix issue where HP bar is drawn below belts 2026-08-02 15:57:44 +02:00
f60f111ccc move claude project files into git repo 2026-08-02 15:57:26 +02:00
2518f4f14a Make railgun S/M/L distinct via barrel count (1/2/3) 2026-07-23 21:31:26 +02:00
561c95d0dd make belt darker so that there is more contrast to the item icons 2026-07-23 21:30:48 +02:00
b4be06ed5e make item icons larger 2026-07-23 21:30:18 +02:00
d1051607b2 add spacing between items in header bar 2026-07-23 21:20:56 +02:00
79b79ab7c3 Show building_block icon in header stock, expand button and build costs 2026-07-23 21:13:37 +02:00
0bba7686e6 Add item icon art for all 41 item types 2026-07-23 20:56:38 +02:00
f766ae4a86 Allow to draw produced-item icons in recipe dialog and game world 2026-07-23 20:54:04 +02:00
8b71fe1a03 Rename ship/station scrap drop entities to "debris" 2026-07-23 20:51:05 +02:00
11daa61714 Add refund-percentage tooltip to Deconstruct button 2026-07-23 20:46:39 +02:00
60d6767d93 draw building icons in the game world 2026-07-23 20:45:50 +02:00
7c1455b8a0 add icons for build buttons 2026-07-22 21:44:20 +02:00
e20a0bba67 Rename Demolish to Deconstruct 2026-07-22 21:40:42 +02:00
b2ce20e6ad Add deconstruction queue 2026-07-22 21:37:56 +02:00
a9082c57f3 Implement config-driven unlock groups so that multiple things can be unlocked at once (including buildings) 2026-07-22 21:34:59 +02:00
a8a6a04f1e Highlight tunnel connections in green when selected 2026-07-21 21:16:21 +02:00
a75222f111 Never rotate tunnels in place 2026-07-21 21:14:27 +02:00
9b63af6ccb Unify tunnel build mode into a single Tunnel button 2026-07-21 21:12:06 +02:00
4ca5b332cd Snap belt-drag end tile to a building's input edge 2026-07-21 21:09:07 +02:00
0c4eb480be Update belt drag L-path immediately on rotate 2026-07-21 21:06:54 +02:00
b2c1ea34fd Implement deferred L-shaped belt drag placement 2026-07-21 21:05:31 +02:00
1cbc695bc5 Add building status light 2026-07-20 22:19:58 +02:00
c7ecce6ac4 make empty layout cells pulse while module is selected 2026-07-20 22:15:26 +02:00
be475e2836 allow multi-select for ships/stations, mixable with scrap 2026-07-20 21:03:11 +02:00
9622fa4345 Use std::optional instead of sentinel values for absent data 2026-07-20 20:27:20 +02:00
1cdafb7bcd draw glyph for output port at the target tile during build mode 2026-07-19 22:30:03 +02:00
f205136e21 show tooltip for artifacts in the header bar 2026-07-19 22:16:04 +02:00
b708e1b29d Add demolish-mode vignette 2026-07-19 21:51:12 +02:00
c76ba07bc7 Correct top game speed step to 10x in requirements 2026-07-19 21:37:47 +02:00
b2e7e4897a Add paused-state vignette border to game world view 2026-07-19 21:34:56 +02:00
c21af63e84 deselect build tool when it becomes unaffordable and fix stale enabled button 2026-07-19 21:20:59 +02:00
a4267a4760 Fix bug where restart fast-forwarded the new run by the time spent in a modal dialog 2026-07-19 21:19:36 +02:00
d412c69f82 Prefix all getters with "get" 2026-07-19 21:17:38 +02:00
08752aeced fix building blocks tooltip 2026-07-19 21:14:31 +02:00
d465671cfc draw beam wider at the source than at the target 2026-07-14 21:52:05 +02:00
76812ba0c5 show "+ installed modules" in ship selection dialog tooltip 2026-07-14 21:23:05 +02:00
9c1e948ab4 show selected ship's current behavior in sidebar panel 2026-07-14 21:19:14 +02:00
23ff406101 display faction in selected object name for hq and defence stations 2026-07-14 21:00:02 +02:00
2ea0cb815d fix issue where items on building's output port were not counted to output display in building panel 2026-07-14 20:50:43 +02:00
876b344b20 fix issue where splitters were draggable during placement like belts 2026-07-14 20:35:47 +02:00
cd75492796 Reveal port items in a thin margin at machine edges 2026-07-14 20:32:27 +02:00
486296feee Allow direct output-to-input port coupling between adjacent buildings 2026-07-14 20:23:24 +02:00
c9f14970a1 Animate items entering building input ports 2026-07-14 20:21:25 +02:00
6a8c456aa1 Animate items emerging from building output ports 2026-07-14 20:18:47 +02:00
af8a2224c0 fix issue where items were accepted by a belt from opposite travel direction 2026-07-14 20:15:39 +02:00
4b149d97a7 dim the game behind dialogs and escape menu 2026-07-13 22:11:21 +02:00
6a8b6acd3b make panning faster 2026-07-13 21:44:12 +02:00
3ce6e6d599 Tint not-yet-buildable asteroid area 2026-07-13 21:42:59 +02:00
80a2622267 Redefine camera scroll as view center 2026-07-13 21:31:52 +02:00
535d4f8f24 allow to (multi) select scrap 2026-07-13 21:09:01 +02:00
8c4fb78fc9 Allow creating blueprints from construction sites 2026-07-13 20:50:13 +02:00
698dd4d13d auto-open layout dialog on manual schematic change 2026-07-13 20:47:37 +02:00
9d28175b17 Always show shipyard layout preview and Configure button, disabled until schematic selected 2026-07-13 20:45:03 +02:00
ac4d56764c Draw a thin border around each right-sidebar panel 2026-07-13 20:44:47 +02:00
69fe607157 Show total building block cost in multi-selection panel (relevant for temporary blueprint) 2026-07-13 20:31:40 +02:00
92e896b973 fix tooltip text rendering issue 2026-07-12 21:56:59 +02:00
dd7c997816 Auto-process smelter and reprocessing plant (no recipe selection) 2026-07-12 21:35:23 +02:00
ad3e73fdd8 Add tooltip for building blocks in header bar 2026-07-12 21:24:11 +02:00
177809fe1a Add tooltips for building buttons and module selection buttons 2026-07-12 21:21:14 +02:00
69f655d179 Rename UI "Blocks" labels to "Building Blocks" 2026-07-12 21:17:10 +02:00
271855781c add tag based version info to exe 2026-07-12 09:16:49 +02:00
5f6ecbf6c8 implement visual feedback for copy building settings with shift + click gesture 2026-07-09 21:33:24 +02:00
cd966daba9 fix issue where shipyard produces ships with modules without requiring items for the modules if the ship's layout was never changed 2026-07-09 21:29:04 +02:00
88bc4f2170 fix bug where selecting the same ship again in a shipyard clears the layout and resets the progress 2026-07-09 21:05:01 +02:00
4986c1bac8 implement copy building settings with shift + click gesture 2026-07-09 20:29:20 +02:00
e110e7e413 Add T hotkey for temporary blueprint from selection 2026-07-09 20:25:15 +02:00
6274b5ce60 List unlocked recipes with tooltips in schematic choice dialog instead of unlocked item names 2026-07-09 20:22:17 +02:00
16ed6a5695 Show schematic id for all three types of drops in choice dialog 2026-07-09 20:18:38 +02:00
13dacab274 remove save and load buttons for factory blueprints to mirror the ship-layout blueprints 2026-07-09 20:15:30 +02:00
2ddf13238c Fix Salvage Bay drop-off not working by adding config-driven buffer capacity 2026-07-09 20:11:12 +02:00
cc38bf95fa make repair_tool less op 2026-07-09 07:20:06 +02:00
b58bcfe272 turn around salvage bay in config 2026-07-09 07:19:48 +02:00
de1ebb8a5f increase scroll speed across contest zone 2026-07-08 22:25:12 +02:00
24c18f8ac6 render HP bar below the HQ 2026-07-08 21:39:11 +02:00
2cedd5d433 fix issue where building selection outline is hidden by other buildings drawn later 2026-07-08 21:29:44 +02:00
f11db0c072 Add demolish box-drag interaction 2026-07-08 21:04:59 +02:00
808e0c6a7b Fix recipe button unclickable on construction site during play 2026-07-08 20:46:26 +02:00
e4ea4ca4b4 Refresh selected-building panel when paused player commands drain 2026-07-08 20:45:41 +02:00
fef22b9f86 Fix shipyard layout preview/button not showing until re-selection 2026-07-08 20:45:25 +02:00
cdf89ce0dd Restructure balancing docs into docs/balancing/ 2026-07-08 20:33:51 +02:00
e8786c3922 implement cost formula for asteroid expansion 2026-07-08 20:33:19 +02:00
fc622670d2 continue first full balancing round 2026-07-08 20:33:11 +02:00
e32d384c99 fix bug where balancing matches did not finish until enemy hq was destroyed 2026-07-08 20:31:33 +02:00
24e0999d8a show total time in balancing target arenas 2026-07-08 20:31:14 +02:00
fcaee000fa continue first full balancing round 2026-07-08 20:30:45 +02:00
751ef27a7b show team EHP in balancing target 2026-07-08 20:29:41 +02:00
3c1376828c implement logging of arena states 2026-07-08 20:29:33 +02:00
bd0675db66 continue first full balancing round 2026-07-08 20:29:23 +02:00
5b86b15c71 Fix ThreatCostCalculator: per-unit division, scrap fallback, fixpoint, staggered-recipe max 2026-07-08 20:28:31 +02:00
c6db4bf24a first full balancing round 2026-07-08 20:27:25 +02:00
6ea0655eaf implement unlock dependencies 2026-07-03 08:35:45 +02:00
58d4586d00 update concept.md 2026-07-02 21:36:42 +02:00
18e732ae99 Remove schematic upgrades and module and ship levels 2026-07-02 21:32:36 +02:00
81b1c7a66b Derive ship scrap drop from threat 2026-07-02 21:30:38 +02:00
5bc581bcb8 write replays next to the executable 2026-07-01 22:43:04 +02:00
0a7e9a34ef Add artifact win condition (#3)
Reviewed-on: #3
Co-authored-by: Malte Langkabel <malte.langkabel@gmail.com>
Co-committed-by: Malte Langkabel <malte.langkabel@gmail.com>
2026-07-01 20:27:29 +00:00
d74ba5bfad Replay: deterministic record & playback (#4)
Add deterministic record/playback for a run.

Recording captures `(seed, config hash, ordered tick-tagged commands)` and re-simulates on playback — no state snapshots. `DotaFactory.exe --replay <file>` re-plays a recorded run view-only with manual speed/pause.

Reviewed-on: #4
Co-authored-by: Malte Langkabel <malte.langkabel@gmail.com>
Co-committed-by: Malte Langkabel <malte.langkabel@gmail.com>
2026-07-01 19:20:08 +00:00
cf68ac2862 Fix selected construction site border not being drawn (#2)
Fixes a bug where the selected construction site was not drawn with a border.

Reviewed-on: #2
Co-authored-by: Malte Langkabel <malte.langkabel@gmail.com>
Co-committed-by: Malte Langkabel <malte.langkabel@gmail.com>
2026-06-29 20:09:10 +00:00
b301db2008 increase refund rate because altering the factory shall not be punishing 2026-06-23 22:11:30 +02:00
31a8915b0f update keyboard shortcuts 2026-06-23 22:09:26 +02:00
f818c90af0 draw ghost in semi-transparent building color 2026-06-23 21:29:38 +02:00
577927ef70 fix bug where splitter filters were not taken over to blueprint 2026-06-23 21:08:46 +02:00
d271d65678 allow to set the recipe already for construction sites 2026-06-22 22:15:56 +02:00
e5017ab3c5 fix issue where construction sites could be placed outside of game world and add tests 2026-06-22 21:13:01 +02:00
59688e6532 fix issue where scrolling while drawing selection box did not update selection box size 2026-06-22 21:10:38 +02:00
c7218c7c1e Replace recipe/schematic dropdowns with button and selection dialog 2026-06-22 21:07:36 +02:00
c43225b6fa fix issue where beams were also disappearing while the game was paused 2026-06-21 22:09:25 +02:00
405206211e fix issue where right-click did not exit demolish mode 2026-06-21 21:56:40 +02:00
3d577a11db fix bug where recipe combo stayed visible when construction site was selected 2026-06-21 21:54:15 +02:00
a472ec196c cargo component refactoring 2026-06-21 21:49:46 +02:00
665060bcd2 Merge pull request 'fix issue where repair behavior targets enemy HQ in balancing target' (#1) from fix_repair_targeting into master
Reviewed-on: #1
2026-06-19 19:44:28 +00:00
4818997164 fix issue where repair behavior targets enemy HQ in balancing target 2026-06-19 21:36:04 +02:00
9573b9789a change repair_tool application and add beams for salvager and repair_tool 2026-06-19 21:15:47 +02:00
7924e037aa increase asteroid size 2026-06-18 21:48:43 +02:00
c371b43a6d make repair ships standby with rest of fleet if there is no one to repair (instead of advancing towards the enemy stations) 2026-06-18 21:45:15 +02:00
abab2bbb6e make repair ships not retreat if someone needs help 2026-06-17 22:40:58 +02:00
313fed02ca fix range of repair tool in config 2026-06-17 22:39:10 +02:00
b95eaaaded fix repair tool targeting 2026-06-17 21:52:47 +02:00
41c8ed2938 draw debug lines to repair and salvage behavior targets 2026-06-17 21:41:35 +02:00
7f4ea93a70 show accumulated threat for teams in balancing target 2026-06-17 21:29:02 +02:00
1a682fdb79 make drone movement look more spaceship-like 2026-06-17 20:51:45 +02:00
e0e11b7933 fix mutually canceling orbits 2026-06-17 20:50:31 +02:00
0cf3d64983 allow custom orbit rotations directions 2026-06-17 20:36:11 +02:00
1324a320e2 fix issue where ships cancel their attacks and advance if on low health in balancing target 2026-06-16 22:17:09 +02:00
5219b227c5 improve targeting rules config 2026-06-16 21:51:45 +02:00
0e02d9ec4a make sensor range semi transparent in debug draw mode 2026-06-16 21:51:26 +02:00
74615f5293 add debug draw mode for balancing target 2026-06-16 21:47:41 +02:00
bd2391876c draw debug lines to target 2026-06-16 21:38:58 +02:00
ac97652c60 make ships claim targets 2026-06-16 21:18:28 +02:00
4153b7e2f5 make ships orbit their targets 2026-06-15 21:37:47 +02:00
6b7c3df64a advance towards enemy buildings 2026-06-15 20:52:43 +02:00
e8dd73bcb0 refactor AI system 2026-06-15 09:16:56 +02:00
8451f5a281 fix missed code paths for artificially reduced splitter throughput 2026-06-14 14:50:23 +02:00
0a1b58442c add verification scripts for ship layouts and recipes 2026-06-14 14:24:18 +02:00
997a7778e0 first iteration of fable 5 on ship and module grids and recipes 2026-06-14 14:23:53 +02:00
282ace4c11 fix bug where splitters reduce belt throughput, even if one side is blocked 2026-06-14 14:03:50 +02:00
1ea1cc59fb show threat rate in debug output 2026-06-14 13:39:10 +02:00
123c544423 move ui panels to the right 2026-06-14 13:07:25 +02:00
10c5ad678f derive threat cost dynamically 2026-06-13 22:47:46 +02:00
3716c2b734 show implicitly unlocked items in schematic unlock dialog 2026-06-13 18:19:25 +02:00
5317f35198 switch to using own event system 2026-06-13 17:52:22 +02:00
ed17664ef1 fix bug where game simulation continues while dialog is shown 2026-06-13 14:31:03 +02:00
49f7129bd5 schematic selection dialog 2026-06-13 14:19:51 +02:00
1641189b75 explicit recipe unlocking 2026-06-12 17:15:06 +02:00
54a6056b77 implicit item locking 2026-06-12 16:14:21 +02:00
69b35d2bfc fix config 2026-06-10 22:37:46 +02:00
af96b95f61 allow to unlock modules when destroying defence stations 2026-06-10 22:37:38 +02:00
aad094f842 allow to configure when which schematic gets unlockable 2026-06-10 21:09:03 +02:00
26857e8414 throw if modules are referenced that don't exist in config 2026-06-09 23:34:30 +02:00
510e37c37b fix issue where upgrade modules are not working properly 2026-06-09 23:34:29 +02:00
121cd5407f add more modules 2026-06-09 23:31:58 +02:00
7c663e29a6 fix threat accumulation config 2026-06-09 21:46:01 +02:00
c64d31fa46 pause threat accumulation during quiet windows 2026-06-07 23:21:22 +02:00
f097e9a25f add live ship stats panel 2026-06-07 22:06:37 +02:00
37a70ea321 add ship stats panel to ship layout dialog 2026-06-06 22:45:50 +02:00
8dad554800 show threat budget in debug text overlay 2026-06-06 21:07:18 +02:00
6b95619806 add units in config files 2026-06-06 20:46:36 +02:00
66cf9ae23a change 4x speed to 10x speed for testing 2026-06-06 12:01:41 +02:00
ef17b0ce42 reduce size of building surface masks 2026-06-06 12:00:38 +02:00
eeaa309c08 fix balancing config 2026-06-05 20:30:38 +02:00
7669245229 use meters in config 2026-06-05 20:09:20 +02:00
4e3e3ac715 replace combined stateUpdated signal with individual events 2026-06-05 18:27:46 +02:00
9677133c54 add event system and use it to propagate to print traces 2026-06-05 17:18:17 +02:00
abc261c03a add tracing for performance profiling 2026-06-05 16:38:36 +02:00
17e9913c98 wrap UI strings with tr() 2026-06-05 16:31:54 +02:00
900b5fdec1 remove documentation for already implemented change 2026-06-05 16:20:20 +02:00
3e19e44f24 store ship module layout in shipyard blueprint 2026-06-04 21:48:45 +02:00
42b51cc6f4 remove unnecessary modules for first playtest 2026-06-04 21:27:29 +02:00
15d8fa4f2c add boss wave counter and countdown to the title bar 2026-06-03 22:44:56 +02:00
b5185b0906 boss waves 2026-06-03 22:14:31 +02:00
457fc47c75 draw HP bars below ships 2026-06-03 20:47:48 +02:00
090dc64bc4 allow one target per repair tool module 2026-06-02 22:24:40 +02:00
64f7c9dcc1 add tests for salvager range and cooldown 2026-06-02 21:39:05 +02:00
f921f00a0d add collection rate for salvager modules and respect collection range of each of these modules 2026-06-02 21:28:31 +02:00
9d0a60a93b define ship roles via added modules and allow multiple weapons 2026-06-01 23:05:21 +02:00
f363f7a67c documentation 2026-05-27 17:03:13 +02:00
9e36c13635 fix tests 2026-05-25 21:13:16 +02:00
25ff3c56c5 move ecs related code to own folder 2026-05-25 09:10:54 +02:00
8ad7530740 fix ships to be drawn facing the correct direction in ArenaView 2026-05-25 07:56:03 +02:00
fa714335dc don't touch velocity in other systems and don't use snapTarget 2026-05-25 07:50:33 +02:00
0cd0529468 split MovementSystem to MovementIntentSystem and DynamicBodySystem 2026-05-25 07:25:54 +02:00
f5f4453e2c rename behavior components 2026-05-23 08:26:10 +02:00
b57299fd2a rename EntityId to BuildingId 2026-05-23 07:53:56 +02:00
dc4ebd5f2d fix crash 2026-05-22 22:11:56 +02:00
ea79d76953 unify Weapon and StationWeapon components 2026-05-22 22:06:30 +02:00
bd488db8ef remove unused building HP 2026-05-22 21:34:21 +02:00
4e3dc51981 fix stations were not drawn 2026-05-22 21:15:26 +02:00
9d20048705 cleanup 2026-05-22 20:45:10 +02:00
ca07cbaf0e switch to ECS architecture 2026-05-22 20:31:39 +02:00
c18c4e4804 implement claude feedback for EntityAdmin 2026-05-22 07:55:43 +02:00
cc2cca2442 add EntityAdmin wrapper 2026-05-22 06:34:57 +02:00
dc344df457 add entt dependency 2026-05-22 06:34:42 +02:00
452c26c8b3 split off MovementSystem and AiSystem from ShipSystem 2026-05-20 22:26:45 +02:00
34c6dea505 change to physics based ship movement 2026-05-20 19:44:46 +02:00
d397b9969a ship layout blueprints 2026-05-19 21:41:19 +02:00
d08bf5d37b implement ship modules 2026-05-18 08:54:26 +02:00
b59e392461 add requirements for ship modules 2026-05-17 22:51:44 +02:00
c0b6f8f778 balancing 2026-05-17 20:55:32 +02:00
3ef32ee6ba allow to inspect balancing arena 2026-05-04 09:18:39 +02:00
afd8cd28fa allow to re-load the config for the balancing app via button 2026-05-03 21:00:36 +02:00
6405ad6b3f allow to re-start arenas 2026-05-03 20:48:13 +02:00
55b42a03d9 balancing arenas can be started individually 2026-05-03 20:33:33 +02:00
426870158c also require defence station to be destroyed for balancing fight to finish 2026-05-03 11:27:16 +02:00
4eaae5d940 indicate balancing winner better 2026-05-03 11:23:24 +02:00
a4427f7f67 add balancing tool target 2026-05-03 11:17:54 +02:00
5153129909 add requirements for ship balancing tool 2026-05-03 10:35:46 +02:00
b30addab3d implement 4 items on belt tile 2026-05-01 22:37:03 +02:00
0ce7cd7ae8 requirements for up to 4 items on belt tile 2026-05-01 20:56:05 +02:00
1e7f602865 implement delete button per blueprint 2026-04-29 23:02:07 +02:00
ad49daa1f6 change requirements to allow to delete blueprint even if not enough resources are present 2026-04-29 22:47:09 +02:00
3f0e05fe7a implement tunnel entry-exit-switching 2026-04-29 22:06:53 +02:00
51333f752b add requirement for tunnel entry-exit-switching 2026-04-29 22:02:25 +02:00
2770bf96be allow to rotate buildings in place 2026-04-29 21:51:04 +02:00
7e0104e9b8 fix config data paths 2026-04-29 21:32:32 +02:00
f37ed0507c requirements for building in-place rotation 2026-04-29 21:23:04 +02:00
b0320ac117 implement sensor range requirements 2026-04-29 21:11:35 +02:00
58b7cda21c balancing 2026-04-28 22:43:11 +02:00
633 changed files with 77387 additions and 8302 deletions

184
.claude/CLAUDE.md Normal file
View File

@@ -0,0 +1,184 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Interaction
* ONLY modify code or other files if explicitly asked to do so
* keep the tone professional, brief and to the point — brevity applies to prose and
preamble, not to the substance of an objection or a design rationale
* be critical: where there is a concrete technical reason to disagree, name it once;
if the user reaffirms, proceed with their call without re-litigating
## Project Overview
Dota Factory is a single-player game that blends a Factorio-style factory builder with
DOTA-style wave defence. The player builds a factory on an asteroid — mining ores,
transporting materials over belts and splitters, and crafting through a config-defined
production tree — to supply shipyards that produce autonomous combat ships. Those ships
fight off endless enemy waves advancing from the right. See `docs/concept.md` for the full design.
## Project Structure
* the project root and the git repository root are the same directory
* project requirements can be found at `docs/requirements.md`
* architecture decisions can be found at `docs/architecture.md`
* game content design (ship/module roster, layout grids, footprint gating) can be found at `docs/content_design.md`
* replay/determinism design can be found at `docs/replay_design.md`
* balancing rules, targets, tuned numbers, process, and history live under `docs/balancing/`
Requirements carry stable `REQ-<AREA>-<NAME>` ids. They are cited throughout the code in
comments — when changing behavior, find the governing REQ id first and
keep the citation accurate.
## Coding Guidelines
* avoid duplicate code
* when planning a change, weigh the long-term maintainability of the codebase instead of
defaulting to the lowest-effort patch — but no speculative generality: never build or
prepare for functionality we may never need. If the maintainable solution is much larger
than the request, say so and let the user decide the scope.
* class layout: static members first, then non-static; within each, public, then protected,
then private (Qt `slots:`/`signals:` are ordinary non-static access groups). Inside an
access group the order is: nested types, static constants, aliases, methods, fields,
friends. Out-of-line method definitions follow the declaration order. Applies to new
classes and to files being edited anyway — don't reorder existing headers just to comply.
* do not use the "auto" keyword, with two exceptions:
* **named local lambdas** — a lambda's type is unnameable, and `std::function`
is not an acceptable substitute in per-tick code because it adds a heap
allocation and an indirect call
* **iterator types** — `auto it = m_buildings.find(id)` is allowed where
spelling the iterator out adds length without adding information
* everywhere else the type is written out; in particular `auto` is not used
for plain values, return values, or range-for element types
* use Qt utility data types (like QPoint, QVector3D, QString, etc.)
* wrap strings that appear in the UI with Qt's "tr()"
* use the EventManager/EventHandler instead of defining own signals and slots
* use std::optional if a variable can be "not set"
* start the name of a getter method with "get"
* don't use abbreviations, except very common ones ("s" for seconds, "min", "max", etc.)
* if a variable holds a value that has a unit or if a function returns a value that has a unit, append that unit to the name (e.g. "m_shipVelocity_mps", "getAcceleration_mpss()")
* always enclose scopes in braces
* keep source files ASCII-only. A non-ASCII character needed at runtime (a glyph in a
UI string, a symbol drawn on a widget) is written as its code point with a comment
naming it (`const QChar shiftGlyph(0x21E7); // U+21E7 UPWARDS WHITE ARROW`), never
as a literal character: MSVC 2017 does not read the sources as UTF-8 by default and
silently mangles them. Never round-trip a source file through
`Get-Content`/`Set-Content` either: Windows PowerShell reads it as ANSI and writes
it back double-encoded with a BOM. Use the Edit/Write tools.
## Build
Requires CMake 3.14.4+, a C++17 compiler, and Qt 5 (developed against Qt 5.12.3,
MSVC 2017 x64; `Qt5_DIR` is cached in `build/CMakeCache.txt`). Needs Qt components
Widgets, Network, Multimedia, Charts, Svg, plus OpenGL.
External dependencies vendored under `src/external/`:
* **toml++** — reading TOML config files
* **tinyexpr** — evaluating formula strings from config files
* **EnTT** — entity registry backing the ship/station/debris simulation
* **Catch2** — test framework
Configure and build (a configured `build/` tree already exists):
```sh
cmake -S . -B build # configure (multi-config VS generator)
cmake --build build --config Debug # all targets
cmake --build build --config Debug --target DotaFactory_test
```
Targets: `DotaFactory` (app), `DotaFactory_lib`, `DotaFactory_ui`, `DotaFactory_test`,
`DotaFactory_balancing`. Executables land in `build/DotaFactory/<Config>/{app,balancing}/`.
**Adding a source file requires editing CMake.** Every directory under `src/` has its own
`CMakeLists.txt` listing files explicitly in `HDRS`/`SRCS` (or `TEST_FILES` for tests) —
there is no globbing. A new file that is not registered simply will not compile.
Config data is not copied: `CONFIG_DIR` is a compile definition pointing at
`bin/app/data/config` for the app and balancing tool, and `bin/test/data/config` for
tests (a separate fixture set). On Windows the build also junctions `bin/*/data` into the
output directories and copies the Qt DLLs.
Run the app: `build/DotaFactory/Debug/app/DotaFactory.exe`, optionally
`--replay <file>` for view-only playback of a recorded run.
**Visual verification is the user's job.** Screen-capturing the app window does not
work here: `CopyFromScreen` and `PrintWindow` both return a blank white client area
even while the app is running and rendering normally, because the capture cannot read
the composited surface of the `QOpenGLWidget`-backed window. A blank capture therefore
says nothing about whether the UI works, so do not read one as a regression and do not
try to work around it. To check a UI change: build, run the tests, launch the app, and
ask the user to look at it. Redirecting the process's stdout/stderr to a file does
work and is worth checking for Qt warnings.
## Tests
Catch2, single executable, links `lib` only — no QApplication, no display.
```sh
build/DotaFactory/Debug/app/DotaFactory_test.exe # all
build/DotaFactory/Debug/app/DotaFactory_test.exe "[belt],[building]" # by tag
build/DotaFactory/Debug/app/DotaFactory_test.exe "BeltSystem: *" # by name pattern
build/DotaFactory/Debug/app/DotaFactory_test.exe --reporter compact
```
Common tags: `[building] [belt] [behavior] [blueprint] [modules] [config] [wave] [combat]
[replay] [determinism] [ship] [debris] [threat] [unlock]`.
`src/test/SimulationTestAccess.h` is a friend-struct backdoor to `Simulation`'s private
mutators; tests use it instead of duplicating the command path. It lives under `src/test`
and is deliberately off the lib/ui/app include path.
## Verification Tools
Python scripts in `tools/` read the real configs and are the first check
after config edits (see `docs/balancing/process.md`):
* `verify_recipes.py` — recipe-tree closure, visuals coverage, orphan items
* `verify_layouts.py` — module footprint gating per hull layout
* `threat_report.py` — item/module/ship threat values, ratios, belt feasibility
The `DotaFactory_balancing` target runs parallel arena simulations from
`bin/balancing/data/balancing.toml` for combat-stat tuning.
## Architecture
See `docs/architecture.md` for the full write-up. Highlights and the
invariants that are easy to break:
* Strict simulation/presentation split, enforced at the CMake target level: `lib`
(sim + config, Qt Core/Gui only — no QtWidgets), `ui` (QtWidgets + QOpenGLWidget),
`app` (thin main), `test` (Catch2 against `lib`).
* Fixed 30 Hz tick simulation, 60 FPS render, accumulator-driven; game speed is a
tick-rate multiplier. All sim quantities are in ticks, never wall-clock seconds.
* The tick order in `Simulation::tick()` is load-bearing for determinism — see the
Tick Order section of `architecture.md` before reordering systems.
* **Command chokepoint:** every sim mutation during play flows through
`Simulation::apply(const Command&)` (see `sim/Command.h`, `CommandManager`), so runs can
be recorded and replayed. Commands reference stable ids (`BuildingId`, tile coords,
choice indices) — never raw `entt::entity` handles. UI code must not call sim mutators
directly. Determinism is checksummed (`StateChecksum`) and covered by
`DeterminismTest` / `ReplayPlaybackTest`.
* Config is loaded once at startup, formulas compiled once via tinyexpr, immutable
afterwards; malformed config aborts startup rather than failing mid-game. Restart
reloads config from disk (REQ-CFG-RELOAD).
* **The sim uses EnTT for ships, stations, debris, and module child entities**, wrapped by
`core/EntityAdmin` (registry, factory methods, `forEach<Ts...>` views). Components live
in `lib/ecs/component/`, systems in `lib/ecs/system/`. Note: `architecture.md`'s
"Ships" and "Why Not ECS" sections still describe the earlier
`std::optional<Component>` design and are stale on this point; the code is authoritative.
Buildings and the belt subsystem stay outside the entity model.
* Ship AI is score-based, not fixed-priority: `AiSystem` runs evaluate → select → execute
phases over per-behavior evaluator/executor pairs in `lib/ecs/system/ai/`. Evaluators and
executors never mutate the world; world mutation lives in `CombatSystem`,
`SalvagerSystem`, `RepairSystem`, `MovementIntentSystem`.
* Belt subsystem is behind a narrow port-level interface (`tryPutItem` / `tryTakeItem` /
`clearTiles` / `tick` / `forEachVisualItem`); per-tile implementation now, swappable
later. No other system asks "what is on tile X".
* All inter-widget and sim→UI communication goes through the `EventManager`/`EventHandler`
singleton in `lib/eventsystem/` (events in `lib/eventsystem/event/`). The sim itself
stays free of EventManager for determinism — it buffers `BeamFiredEvent`s in a vector
that the UI drains each frame and re-emits.
* State-change events are *refresh signals*, not carriers of truth: a widget re-reads the
value from `Simulation` rather than caching the event payload.

View File

@@ -0,0 +1,28 @@
---
name: bug
description: Investigate a reported bug, find and explain its root cause, and propose a fix — without implementing anything
argument-hint: <description of the buggy behavior>
disable-model-invocation: true
---
A bug has been reported:
$ARGUMENTS
Investigate it and propose a solution. **Do not implement anything** — no edits, no new files, no fixes applied. The goal of this pass is understanding and a proposal the user can approve first.
Work through it like this:
1. **Pin down expected vs. actual.** Restate what the behavior should be and what it actually is. If the report is ambiguous about the conditions that trigger it, note your assumptions explicitly.
2. **Find the relevant code.** Search for the subsystem(s) involved (Grep/Glob, then Read the actual files). Don't reason from memory or from names alone — read the implementation that runs in this case.
3. **Trace the real execution path.** Follow the data/control flow step by step for the specific failing scenario. For the tick-based simulation, that means tracing the relevant systems in tick order, including the per-tick progress/cap arithmetic where it matters. Use the project's actual constants (tick rate, belt speed, etc.) rather than hand-waving.
4. **State the root cause precisely.** Name the exact mechanism, citing `file:line`. Explain *why* it produces the observed symptom — connect the cause to the visible effect concretely (e.g. "single-slot output serializes to one item per full-tile traversal, so items land ~1 tile apart"). Confirm it explains the specific trigger conditions in the report.
5. **Propose a solution.** Describe the change and where it would go (`file:line`), reusing existing patterns in the codebase. If the symptom has more than one contributing path, say so. If the fix involves a design or balance trade-off (correctness vs. throughput, lossless vs. capped, a visual side effect, etc.), surface it as a decision for the user — give a recommendation, but ask before assuming which behavior they want.
6. **Stop and hand back.** End with the proposal and any open questions. Offer to implement (and to add tests) only once the user has chosen a direction.
Keep the write-up grounded in what the code actually does — quote the lines that matter. Adhere to the repository's coding guidelines and architecture notes (see `.claude/CLAUDE.md`) when describing any proposed change.

View File

@@ -0,0 +1,91 @@
---
name: C++ Pro
description: Expert C++ developer specializing in modern C++20/23, systems programming, and high-performance computing. Masters template metaprogramming, zero-overhead abstractions, and low-level optimization with emphasis on safety and efficiency.
triggers:
- C++
- C++17
- C++20
- C++23
- modern C++
- template metaprogramming
- systems programming
- performance optimization
- SIMD
- memory management
- CMake
role: specialist
scope: implementation
output-format: code
---
# C++ Pro
Senior C++ developer with deep expertise in modern C++20/23, systems programming, high-performance computing, and zero-overhead abstractions.
## Role Definition
You are a senior C++ engineer with 15+ years of systems programming experience. You specialize in modern C++20/23, template metaprogramming, performance optimization, and building production-grade systems with emphasis on safety, efficiency, and maintainability. You follow C++ Core Guidelines and leverage cutting-edge language features.
## When to Use This Skill
- Building high-performance C++ applications
- Implementing template metaprogramming solutions
- Optimizing memory-critical systems
- Developing concurrent and parallel algorithms
- Creating custom allocators and memory pools
- Systems programming and embedded development
## Core Workflow
1. **Analyze architecture** - Review build system, compiler flags, performance requirements
2. **Design with concepts** - Create type-safe interfaces using C++20 concepts
3. **Implement zero-cost** - Apply RAII, constexpr, and zero-overhead abstractions
4. **Verify quality** - Run sanitizers, static analysis, and performance benchmarks
5. **Optimize** - Profile, measure, and apply targeted optimizations
## Reference Guide
Load detailed guidance based on context:
| Topic | Reference | Load When |
|-------|-----------|-----------|
| Modern C++ Features | `references/modern-cpp.md` | C++20/23 features, concepts, ranges, coroutines |
| Template Metaprogramming | `references/templates.md` | Variadic templates, SFINAE, type traits, CRTP |
| Memory & Performance | `references/memory-performance.md` | Allocators, SIMD, cache optimization, move semantics |
| Concurrency | `references/concurrency.md` | Atomics, lock-free structures, thread pools, coroutines |
| Build & Tooling | `references/build-tooling.md` | CMake, sanitizers, static analysis, testing |
## Constraints
### MUST DO
- Follow C++ Core Guidelines
- Use concepts for template constraints
- Apply RAII universally
- Do not use `auto`
- Prefer `std::unique_ptr` and `std::shared_ptr`
- Write const-correct code
- Use forward declarations in header files if possible
- Use descriptive functions names and variable names instead of writing comments
### MUST NOT DO
- Use raw `new`/`delete` (prefer smart pointers)
- Ignore compiler warnings
- Use C-style casts (use static_cast, etc.)
- Mix exception and error code patterns inconsistently
- Write non-const-correct code
- Use `using namespace std` in headers
- Ignore undefined behavior
- Skip move semantics for expensive types
- Write lots of comments
## Output Templates
When implementing C++ features, provide:
1. Header file with interfaces and templates
2. Implementation file (when needed)
3. CMakeLists.txt updates (if applicable)
4. Test file demonstrating usage
## Knowledge Reference
C++20/23, concepts, ranges, coroutines, modules, template metaprogramming, SFINAE, type traits, CRTP, smart pointers, custom allocators, move semantics, RAII, SIMD, atomics, lock-free programming, CMake, Conan, sanitizers, clang-tidy, cppcheck, Catch2, GoogleTest

View File

@@ -0,0 +1,443 @@
# Build Systems and Tooling
> Reference for: C++ Pro
> Load when: CMake, sanitizers, static analysis, testing frameworks, CI/CD
## Modern CMake
```cmake
cmake_minimum_required(VERSION 3.20)
project(MyProject VERSION 1.0.0 LANGUAGES CXX)
# Set C++ standard
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
# Export compile commands for tools
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
# Compiler warnings
if(MSVC)
add_compile_options(/W4 /WX)
else()
add_compile_options(-Wall -Wextra -Wpedantic -Werror)
endif()
# Create library target
add_library(mylib
src/mylib.cpp
include/mylib.h
)
target_include_directories(mylib
PUBLIC
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
$<INSTALL_INTERFACE:include>
PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
)
target_compile_features(mylib PUBLIC cxx_std_20)
# Create executable
add_executable(myapp src/main.cpp)
target_link_libraries(myapp PRIVATE mylib)
# Dependencies with FetchContent
include(FetchContent)
FetchContent_Declare(
fmt
GIT_REPOSITORY https://github.com/fmtlib/fmt.git
GIT_TAG 10.1.1
)
FetchContent_MakeAvailable(fmt)
target_link_libraries(mylib PUBLIC fmt::fmt)
# Testing
enable_testing()
add_subdirectory(tests)
# Install rules
include(GNUInstallDirs)
install(TARGETS mylib myapp
EXPORT MyProjectTargets
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
)
install(DIRECTORY include/
DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}
)
```
## Sanitizers
```cmake
# AddressSanitizer (ASan) - memory errors
set(CMAKE_CXX_FLAGS_ASAN
"-g -O1 -fsanitize=address -fno-omit-frame-pointer"
CACHE STRING "Flags for ASan build"
)
# UndefinedBehaviorSanitizer (UBSan)
set(CMAKE_CXX_FLAGS_UBSAN
"-g -O1 -fsanitize=undefined -fno-omit-frame-pointer"
CACHE STRING "Flags for UBSan build"
)
# ThreadSanitizer (TSan) - data races
set(CMAKE_CXX_FLAGS_TSAN
"-g -O1 -fsanitize=thread -fno-omit-frame-pointer"
CACHE STRING "Flags for TSan build"
)
# MemorySanitizer (MSan) - uninitialized reads
set(CMAKE_CXX_FLAGS_MSAN
"-g -O1 -fsanitize=memory -fno-omit-frame-pointer"
CACHE STRING "Flags for MSan build"
)
# Usage: cmake -DCMAKE_BUILD_TYPE=ASAN ..
```
## Static Analysis
```yaml
# .clang-tidy configuration
---
Checks: >
*,
-fuchsia-*,
-google-*,
-llvm-*,
-modernize-use-trailing-return-type,
-readability-identifier-length
WarningsAsErrors: '*'
CheckOptions:
- key: readability-identifier-naming.ClassCase
value: CamelCase
- key: readability-identifier-naming.FunctionCase
value: lower_case
- key: readability-identifier-naming.VariableCase
value: lower_case
- key: readability-identifier-naming.ConstantCase
value: UPPER_CASE
- key: readability-identifier-naming.MemberCase
value: lower_case
- key: readability-identifier-naming.MemberSuffix
value: '_'
- key: modernize-use-nullptr.NullMacros
value: 'NULL'
```
```bash
# Run clang-tidy
clang-tidy src/*.cpp -p build/
# Run cppcheck
cppcheck --enable=all --std=c++20 --suppress=missingInclude src/
# Run include-what-you-use
include-what-you-use -std=c++20 src/main.cpp
```
## Testing with Catch2
```cpp
#include <catch2/catch_test_macros.hpp>
#include <catch2/benchmark/catch_benchmark.hpp>
#include "mylib.h"
TEST_CASE("Vector operations", "[vector]") {
std::vector<int> vec{1, 2, 3};
SECTION("push_back") {
vec.push_back(4);
REQUIRE(vec.size() == 4);
REQUIRE(vec.back() == 4);
}
SECTION("pop_back") {
vec.pop_back();
REQUIRE(vec.size() == 2);
REQUIRE(vec.back() == 2);
}
}
TEST_CASE("Exception handling", "[exceptions]") {
REQUIRE_THROWS_AS(risky_function(), std::runtime_error);
REQUIRE_THROWS_WITH(risky_function(), "error message");
}
TEST_CASE("Floating point", "[math]") {
REQUIRE_THAT(compute_value(),
Catch::Matchers::WithinAbs(3.14, 0.01));
}
BENCHMARK("Vector creation") {
return std::vector<int>(1000);
};
BENCHMARK("Vector fill") {
std::vector<int> vec(1000);
for (int i = 0; i < 1000; ++i) {
vec[i] = i;
}
return vec;
};
```
## Testing with GoogleTest
```cpp
#include <gtest/gtest.h>
#include <gmock/gmock.h>
#include "calculator.h"
class CalculatorTest : public ::testing::Test {
protected:
void SetUp() override {
calc = std::make_unique<Calculator>();
}
void TearDown() override {
calc.reset();
}
std::unique_ptr<Calculator> calc;
};
TEST_F(CalculatorTest, Addition) {
EXPECT_EQ(calc->add(2, 3), 5);
EXPECT_EQ(calc->add(-1, 1), 0);
}
TEST_F(CalculatorTest, Division) {
EXPECT_DOUBLE_EQ(calc->divide(10, 2), 5.0);
EXPECT_THROW(calc->divide(10, 0), std::invalid_argument);
}
// Parameterized tests
class AdditionTest : public ::testing::TestWithParam<std::tuple<int, int, int>> {};
TEST_P(AdditionTest, ValidAddition) {
auto [a, b, expected] = GetParam();
Calculator calc;
EXPECT_EQ(calc.add(a, b), expected);
}
INSTANTIATE_TEST_SUITE_P(
AdditionSuite,
AdditionTest,
::testing::Values(
std::make_tuple(1, 2, 3),
std::make_tuple(-1, -2, -3),
std::make_tuple(0, 0, 0)
)
);
// Mock objects
class MockDatabase : public Database {
public:
MOCK_METHOD(void, connect, (const std::string&), (override));
MOCK_METHOD(std::string, query, (const std::string&), (override));
MOCK_METHOD(void, disconnect, (), (override));
};
TEST(ServiceTest, UsesDatabase) {
MockDatabase mock_db;
EXPECT_CALL(mock_db, connect("localhost"))
.Times(1);
EXPECT_CALL(mock_db, query("SELECT *"))
.WillOnce(::testing::Return("result"));
Service service(mock_db);
service.process();
}
```
## Performance Profiling
```cpp
// Benchmark with Google Benchmark
#include <benchmark/benchmark.h>
static void BM_VectorPush(benchmark::State& state) {
for (auto _ : state) {
std::vector<int> vec;
for (int i = 0; i < state.range(0); ++i) {
vec.push_back(i);
}
benchmark::DoNotOptimize(vec);
}
}
BENCHMARK(BM_VectorPush)->Range(8, 8<<10);
static void BM_VectorReserve(benchmark::State& state) {
for (auto _ : state) {
std::vector<int> vec;
vec.reserve(state.range(0));
for (int i = 0; i < state.range(0); ++i) {
vec.push_back(i);
}
benchmark::DoNotOptimize(vec);
}
}
BENCHMARK(BM_VectorReserve)->Range(8, 8<<10);
BENCHMARK_MAIN();
```
```bash
# Profiling with perf (Linux)
perf record -g ./myapp
perf report
# Profiling with Instruments (macOS)
instruments -t "Time Profiler" ./myapp
# Valgrind callgrind
valgrind --tool=callgrind ./myapp
kcachegrind callgrind.out.*
# Memory profiling
valgrind --tool=massif ./myapp
ms_print massif.out.*
```
## Conan Package Manager
```python
# conanfile.txt
[requires]
fmt/10.1.1
spdlog/1.12.0
catch2/3.4.0
[generators]
CMakeDeps
CMakeToolchain
[options]
fmt:header_only=True
```
```cmake
# CMakeLists.txt with Conan
cmake_minimum_required(VERSION 3.20)
project(MyProject)
find_package(fmt REQUIRED)
find_package(spdlog REQUIRED)
find_package(Catch2 REQUIRED)
add_executable(myapp src/main.cpp)
target_link_libraries(myapp
PRIVATE
fmt::fmt
spdlog::spdlog
)
add_executable(tests test/main.cpp)
target_link_libraries(tests
PRIVATE
Catch2::Catch2WithMain
)
```
```bash
# Install dependencies
conan install . --output-folder=build --build=missing
cd build
cmake .. -DCMAKE_TOOLCHAIN_FILE=conan_toolchain.cmake
cmake --build .
```
## CI/CD with GitHub Actions
```yaml
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
compiler: [gcc, clang, msvc]
build_type: [Debug, Release]
steps:
- uses: actions/checkout@v3
- name: Install dependencies
run: |
pip install conan
conan install . --output-folder=build --build=missing
- name: Configure
run: |
cmake -B build -DCMAKE_BUILD_TYPE=${{ matrix.build_type }}
- name: Build
run: cmake --build build --config ${{ matrix.build_type }}
- name: Test
run: ctest --test-dir build -C ${{ matrix.build_type }}
sanitizers:
runs-on: ubuntu-latest
strategy:
matrix:
sanitizer: [asan, ubsan, tsan]
steps:
- uses: actions/checkout@v3
- name: Build with sanitizer
run: |
cmake -B build -DCMAKE_BUILD_TYPE=${{ matrix.sanitizer }}
cmake --build build
- name: Run tests
run: ctest --test-dir build
static-analysis:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Run clang-tidy
run: |
cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
clang-tidy src/*.cpp -p build/
- name: Run cppcheck
run: cppcheck --enable=all --error-exitcode=1 src/
```
## Quick Reference
| Tool | Purpose | Command |
|------|---------|---------|
| CMake | Build system | `cmake -B build && cmake --build build` |
| Conan | Package manager | `conan install . --build=missing` |
| ASan | Memory errors | `-fsanitize=address` |
| UBSan | Undefined behavior | `-fsanitize=undefined` |
| TSan | Data races | `-fsanitize=thread` |
| clang-tidy | Static analysis | `clang-tidy src/*.cpp` |
| cppcheck | Static analysis | `cppcheck --enable=all src/` |
| Catch2 | Unit testing | `TEST_CASE("name") { REQUIRE(...); }` |
| GoogleTest | Unit testing | `TEST(Suite, Name) { EXPECT_EQ(...); }` |
| Google Benchmark | Performance | `BENCHMARK(func)->Range(...)` |
| Valgrind | Memory profiler | `valgrind --tool=memcheck ./app` |

View File

@@ -0,0 +1,440 @@
# Concurrency and Parallel Programming
> Reference for: C++ Pro
> Load when: Atomics, lock-free structures, thread pools, parallel algorithms, coroutines
## Atomics and Memory Ordering
```cpp
#include <atomic>
#include <thread>
// Basic atomics
std::atomic<int> counter{0};
std::atomic<bool> flag{false};
// Memory ordering
void producer(std::atomic<int>& data, std::atomic<bool>& ready) {
data.store(42, std::memory_order_relaxed);
ready.store(true, std::memory_order_release); // Release barrier
}
void consumer(std::atomic<int>& data, std::atomic<bool>& ready) {
while (!ready.load(std::memory_order_acquire)) { // Acquire barrier
std::this_thread::yield();
}
int value = data.load(std::memory_order_relaxed);
}
// Compare-and-swap
bool try_acquire_lock(std::atomic<bool>& lock) {
bool expected = false;
return lock.compare_exchange_strong(expected, true,
std::memory_order_acquire,
std::memory_order_relaxed);
}
// Fetch-and-add
int increment_counter(std::atomic<int>& counter) {
return counter.fetch_add(1, std::memory_order_relaxed);
}
```
## Lock-Free Data Structures
```cpp
#include <atomic>
#include <memory>
// Lock-free stack
template<typename T>
class LockFreeStack {
struct Node {
T data;
Node* next;
Node(const T& value) : data(value), next(nullptr) {}
};
std::atomic<Node*> head_{nullptr};
public:
void push(const T& value) {
Node* new_node = new Node(value);
new_node->next = head_.load(std::memory_order_relaxed);
while (!head_.compare_exchange_weak(new_node->next, new_node,
std::memory_order_release,
std::memory_order_relaxed)) {
// Retry with updated head
}
}
bool pop(T& result) {
Node* old_head = head_.load(std::memory_order_relaxed);
while (old_head &&
!head_.compare_exchange_weak(old_head, old_head->next,
std::memory_order_acquire,
std::memory_order_relaxed)) {
// Retry
}
if (old_head) {
result = old_head->data;
delete old_head; // Note: ABA problem exists
return true;
}
return false;
}
};
// Lock-free queue (single producer, single consumer)
template<typename T, size_t Size>
class SPSCQueue {
std::array<T, Size> buffer_;
alignas(64) std::atomic<size_t> head_{0};
alignas(64) std::atomic<size_t> tail_{0};
public:
bool push(const T& item) {
size_t head = head_.load(std::memory_order_relaxed);
size_t next_head = (head + 1) % Size;
if (next_head == tail_.load(std::memory_order_acquire)) {
return false; // Queue full
}
buffer_[head] = item;
head_.store(next_head, std::memory_order_release);
return true;
}
bool pop(T& item) {
size_t tail = tail_.load(std::memory_order_relaxed);
if (tail == head_.load(std::memory_order_acquire)) {
return false; // Queue empty
}
item = buffer_[tail];
tail_.store((tail + 1) % Size, std::memory_order_release);
return true;
}
};
```
## Thread Pool
```cpp
#include <thread>
#include <queue>
#include <mutex>
#include <condition_variable>
#include <functional>
#include <future>
class ThreadPool {
std::vector<std::thread> workers_;
std::queue<std::function<void()>> tasks_;
std::mutex queue_mutex_;
std::condition_variable condition_;
bool stop_ = false;
public:
ThreadPool(size_t num_threads) {
for (size_t i = 0; i < num_threads; ++i) {
workers_.emplace_back([this] {
while (true) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(queue_mutex_);
condition_.wait(lock, [this] {
return stop_ || !tasks_.empty();
});
if (stop_ && tasks_.empty()) {
return;
}
task = std::move(tasks_.front());
tasks_.pop();
}
task();
}
});
}
}
~ThreadPool() {
{
std::unique_lock<std::mutex> lock(queue_mutex_);
stop_ = true;
}
condition_.notify_all();
for (auto& worker : workers_) {
worker.join();
}
}
template<typename F, typename... Args>
auto enqueue(F&& f, Args&&... args)
-> std::future<typename std::invoke_result_t<F, Args...>> {
using return_type = typename std::invoke_result_t<F, Args...>;
auto task = std::make_shared<std::packaged_task<return_type()>>(
std::bind(std::forward<F>(f), std::forward<Args>(args)...)
);
std::future<return_type> result = task->get_future();
{
std::unique_lock<std::mutex> lock(queue_mutex_);
if (stop_) {
throw std::runtime_error("enqueue on stopped ThreadPool");
}
tasks_.emplace([task]() { (*task)(); });
}
condition_.notify_one();
return result;
}
};
```
## Parallel STL Algorithms
```cpp
#include <algorithm>
#include <execution>
#include <vector>
#include <numeric>
void parallel_algorithms_demo() {
std::vector<int> vec(1'000'000);
std::iota(vec.begin(), vec.end(), 0);
// Parallel sort
std::sort(std::execution::par, vec.begin(), vec.end());
// Parallel for_each
std::for_each(std::execution::par_unseq, vec.begin(), vec.end(),
[](int& x) { x *= 2; });
// Parallel transform
std::vector<int> result(vec.size());
std::transform(std::execution::par, vec.begin(), vec.end(),
result.begin(), [](int x) { return x * x; });
// Parallel reduce
int sum = std::reduce(std::execution::par, vec.begin(), vec.end());
// Parallel transform_reduce (map-reduce)
int sum_of_squares = std::transform_reduce(
std::execution::par,
vec.begin(), vec.end(),
0,
std::plus<>(),
[](int x) { return x * x; }
);
}
```
## Synchronization Primitives
```cpp
#include <mutex>
#include <shared_mutex>
#include <condition_variable>
// Mutex types
std::mutex mtx;
std::recursive_mutex rec_mtx;
std::timed_mutex timed_mtx;
std::shared_mutex shared_mtx;
// RAII locks
void exclusive_access() {
std::lock_guard<std::mutex> lock(mtx);
// Critical section
}
void unique_lock_example() {
std::unique_lock<std::mutex> lock(mtx);
// Can unlock and relock
lock.unlock();
// Do some work
lock.lock();
}
// Reader-writer lock
class SharedData {
mutable std::shared_mutex mutex_;
std::string data_;
public:
std::string read() const {
std::shared_lock<std::shared_mutex> lock(mutex_);
return data_;
}
void write(std::string new_data) {
std::unique_lock<std::shared_mutex> lock(mutex_);
data_ = std::move(new_data);
}
};
// Condition variable
class Queue {
std::queue<int> queue_;
std::mutex mutex_;
std::condition_variable cv_;
public:
void push(int value) {
{
std::lock_guard<std::mutex> lock(mutex_);
queue_.push(value);
}
cv_.notify_one();
}
int pop() {
std::unique_lock<std::mutex> lock(mutex_);
cv_.wait(lock, [this] { return !queue_.empty(); });
int value = queue_.front();
queue_.pop();
return value;
}
};
// std::scoped_lock - multiple mutexes
std::mutex mtx1, mtx2;
void transfer(Account& from, Account& to, int amount) {
std::scoped_lock lock(from.mutex, to.mutex); // Deadlock-free
from.balance -= amount;
to.balance += amount;
}
```
## Async and Futures
```cpp
#include <future>
// std::async
auto future = std::async(std::launch::async, []() {
return expensive_computation();
});
// Get result (blocks until ready)
auto result = future.get();
// Promise and future
void producer(std::promise<int> promise) {
int value = compute_value();
promise.set_value(value);
}
void consumer(std::future<int> future) {
int value = future.get();
}
std::promise<int> promise;
std::future<int> future = promise.get_future();
std::thread producer_thread(producer, std::move(promise));
std::thread consumer_thread(consumer, std::move(future));
// Packaged task
std::packaged_task<int(int, int)> task([](int a, int b) {
return a + b;
});
std::future<int> task_future = task.get_future();
std::thread task_thread(std::move(task), 5, 3);
int sum = task_future.get(); // 8
task_thread.join();
```
## Coroutine-Based Concurrency
```cpp
#include <coroutine>
#include <optional>
// Async task coroutine
template<typename T>
struct AsyncTask {
struct promise_type {
std::optional<T> value;
std::exception_ptr exception;
AsyncTask get_return_object() {
return AsyncTask{
std::coroutine_handle<promise_type>::from_promise(*this)
};
}
std::suspend_never initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
void return_value(T v) {
value = std::move(v);
}
void unhandled_exception() {
exception = std::current_exception();
}
};
std::coroutine_handle<promise_type> handle;
AsyncTask(std::coroutine_handle<promise_type> h) : handle(h) {}
~AsyncTask() { if (handle) handle.destroy(); }
T get() {
if (!handle.done()) {
handle.resume();
}
if (handle.promise().exception) {
std::rethrow_exception(handle.promise().exception);
}
return *handle.promise().value;
}
};
// Usage
AsyncTask<int> async_compute() {
co_return 42;
}
```
## Quick Reference
| Primitive | Use Case | Performance |
|-----------|----------|-------------|
| std::atomic | Simple shared state | Lock-free |
| std::mutex | Exclusive access | Kernel call |
| std::shared_mutex | Read-heavy workload | Better than mutex |
| Lock-free structures | High contention | Best throughput |
| Thread pool | Task parallelism | Avoid thread overhead |
| Parallel STL | Data parallelism | Automatic scaling |
| std::async | Simple async tasks | Thread pool |
| Coroutines | Async I/O | Minimal overhead |
## Memory Ordering Guide
| Ordering | Guarantees | Use Case |
|----------|-----------|----------|
| relaxed | No synchronization | Counters |
| acquire | Load barrier | Consumer |
| release | Store barrier | Producer |
| acq_rel | Both | RMW operations |
| seq_cst | Total order | Default |

View File

@@ -0,0 +1,400 @@
# Memory Management & Performance
> Reference for: C++ Pro
> Load when: Custom allocators, SIMD, cache optimization, move semantics, memory pools
## Smart Pointers
```cpp
#include <memory>
// unique_ptr - exclusive ownership
auto create_resource() {
return std::make_unique<Resource>("data");
}
// shared_ptr - reference counting
std::shared_ptr<Data> shared = std::make_shared<Data>(42);
std::weak_ptr<Data> weak = shared; // Non-owning reference
// Custom deleters
auto file_deleter = [](FILE* fp) { if (fp) fclose(fp); };
std::unique_ptr<FILE, decltype(file_deleter)> file(
fopen("data.txt", "r"),
file_deleter
);
// enable_shared_from_this
class Node : public std::enable_shared_from_this<Node> {
public:
std::shared_ptr<Node> get_shared() {
return shared_from_this();
}
};
```
## Custom Allocators
```cpp
#include <memory>
#include <vector>
// Pool allocator for fixed-size objects
template<typename T, size_t PoolSize = 1024>
class PoolAllocator {
struct Block {
alignas(T) std::byte data[sizeof(T)];
Block* next;
};
Block pool_[PoolSize];
Block* free_list_ = nullptr;
public:
using value_type = T;
PoolAllocator() {
// Initialize free list
for (size_t i = 0; i < PoolSize - 1; ++i) {
pool_[i].next = &pool_[i + 1];
}
pool_[PoolSize - 1].next = nullptr;
free_list_ = &pool_[0];
}
T* allocate(size_t n) {
if (n != 1 || !free_list_) {
throw std::bad_alloc();
}
Block* block = free_list_;
free_list_ = free_list_->next;
return reinterpret_cast<T*>(block->data);
}
void deallocate(T* p, size_t n) {
if (n != 1) return;
Block* block = reinterpret_cast<Block*>(p);
block->next = free_list_;
free_list_ = block;
}
};
// Usage
std::vector<int, PoolAllocator<int>> vec;
// Arena allocator - bump allocator
class Arena {
std::byte* buffer_;
size_t size_;
size_t offset_ = 0;
public:
Arena(size_t size) : size_(size) {
buffer_ = new std::byte[size];
}
~Arena() {
delete[] buffer_;
}
template<typename T>
T* allocate(size_t n = 1) {
size_t alignment = alignof(T);
size_t space = size_ - offset_;
void* ptr = buffer_ + offset_;
if (std::align(alignment, sizeof(T) * n, ptr, space)) {
offset_ = size_ - space + sizeof(T) * n;
return static_cast<T*>(ptr);
}
throw std::bad_alloc();
}
void reset() {
offset_ = 0;
}
};
```
## Move Semantics
```cpp
#include <utility>
#include <algorithm>
class Buffer {
size_t size_;
char* data_;
public:
// Constructor
Buffer(size_t size) : size_(size), data_(new char[size]) {}
// Destructor
~Buffer() { delete[] data_; }
// Copy constructor
Buffer(const Buffer& other) : size_(other.size_), data_(new char[size_]) {
std::copy(other.data_, other.data_ + size_, data_);
}
// Copy assignment
Buffer& operator=(const Buffer& other) {
if (this != &other) {
delete[] data_;
size_ = other.size_;
data_ = new char[size_];
std::copy(other.data_, other.data_ + size_, data_);
}
return *this;
}
// Move constructor
Buffer(Buffer&& other) noexcept
: size_(other.size_), data_(other.data_) {
other.size_ = 0;
other.data_ = nullptr;
}
// Move assignment
Buffer& operator=(Buffer&& other) noexcept {
if (this != &other) {
delete[] data_;
size_ = other.size_;
data_ = other.data_;
other.size_ = 0;
other.data_ = nullptr;
}
return *this;
}
};
// Perfect forwarding
template<typename T>
void wrapper(T&& arg) {
process(std::forward<T>(arg)); // Preserves lvalue/rvalue
}
```
## SIMD Optimization
```cpp
#include <immintrin.h> // AVX/AVX2
#include <cstring>
// Vectorized sum using AVX2
float simd_sum(const float* data, size_t size) {
__m256 sum_vec = _mm256_setzero_ps();
size_t i = 0;
// Process 8 floats at a time
for (; i + 8 <= size; i += 8) {
__m256 vec = _mm256_loadu_ps(&data[i]);
sum_vec = _mm256_add_ps(sum_vec, vec);
}
// Horizontal sum
alignas(32) float temp[8];
_mm256_store_ps(temp, sum_vec);
float result = 0.0f;
for (int j = 0; j < 8; ++j) {
result += temp[j];
}
// Handle remaining elements
for (; i < size; ++i) {
result += data[i];
}
return result;
}
// Vectorized multiply-add
void fma_operation(float* result, const float* a, const float* b,
const float* c, size_t size) {
for (size_t i = 0; i + 8 <= size; i += 8) {
__m256 va = _mm256_loadu_ps(&a[i]);
__m256 vb = _mm256_loadu_ps(&b[i]);
__m256 vc = _mm256_loadu_ps(&c[i]);
// result[i] = a[i] * b[i] + c[i]
__m256 vr = _mm256_fmadd_ps(va, vb, vc);
_mm256_storeu_ps(&result[i], vr);
}
}
```
## Cache-Friendly Design
```cpp
// Structure of Arrays (SoA) - better cache locality
struct ParticlesAoS {
struct Particle {
float x, y, z;
float vx, vy, vz;
};
std::vector<Particle> particles;
};
struct ParticlesSoA {
std::vector<float> x, y, z;
std::vector<float> vx, vy, vz;
void update_positions(float dt) {
// All x coordinates are contiguous - better cache usage
for (size_t i = 0; i < x.size(); ++i) {
x[i] += vx[i] * dt;
y[i] += vy[i] * dt;
z[i] += vz[i] * dt;
}
}
};
// Cache line padding to avoid false sharing
struct alignas(64) CacheLinePadded {
std::atomic<int> counter;
char padding[64 - sizeof(std::atomic<int>)];
};
// Prefetching
void process_with_prefetch(const int* data, size_t size) {
for (size_t i = 0; i < size; ++i) {
// Prefetch data for next iteration
if (i + 8 < size) {
__builtin_prefetch(&data[i + 8], 0, 1);
}
// Process current data
process(data[i]);
}
}
```
## Memory Pool
```cpp
#include <vector>
#include <memory>
template<typename T, size_t ChunkSize = 256>
class MemoryPool {
struct Chunk {
alignas(T) std::byte data[sizeof(T) * ChunkSize];
};
std::vector<std::unique_ptr<Chunk>> chunks_;
std::vector<T*> free_list_;
size_t current_chunk_offset_ = ChunkSize;
public:
T* allocate() {
if (!free_list_.empty()) {
T* ptr = free_list_.back();
free_list_.pop_back();
return ptr;
}
if (current_chunk_offset_ >= ChunkSize) {
chunks_.push_back(std::make_unique<Chunk>());
current_chunk_offset_ = 0;
}
Chunk* chunk = chunks_.back().get();
T* ptr = reinterpret_cast<T*>(
&chunk->data[sizeof(T) * current_chunk_offset_++]
);
return ptr;
}
void deallocate(T* ptr) {
free_list_.push_back(ptr);
}
template<typename... Args>
T* construct(Args&&... args) {
T* ptr = allocate();
new (ptr) T(std::forward<Args>(args)...);
return ptr;
}
void destroy(T* ptr) {
ptr->~T();
deallocate(ptr);
}
};
```
## Copy Elision and RVO
```cpp
// Return Value Optimization (RVO)
std::vector<int> create_vector() {
std::vector<int> vec{1, 2, 3, 4, 5};
return vec; // RVO applies, no copy/move
}
// Named Return Value Optimization (NRVO)
std::string build_string(bool condition) {
std::string result;
if (condition) {
result = "condition true";
} else {
result = "condition false";
}
return result; // NRVO may apply
}
// Guaranteed copy elision (C++17)
struct NonMovable {
NonMovable() = default;
NonMovable(const NonMovable&) = delete;
NonMovable(NonMovable&&) = delete;
};
NonMovable create() {
return NonMovable{}; // Guaranteed no copy/move in C++17
}
auto obj = create(); // OK in C++17
```
## Alignment and Memory Layout
```cpp
#include <cstddef>
// Control alignment
struct alignas(64) CacheAligned {
int data[16];
};
// Check alignment
static_assert(alignof(CacheAligned) == 64);
// Aligned allocation
void* aligned_alloc_wrapper(size_t alignment, size_t size) {
void* ptr = nullptr;
if (posix_memalign(&ptr, alignment, size) != 0) {
throw std::bad_alloc();
}
return ptr;
}
// Placement new with alignment
alignas(32) std::byte buffer[sizeof(Data)];
Data* obj = new (buffer) Data();
obj->~Data(); // Manual destruction needed
```
## Quick Reference
| Technique | Use Case | Benefit |
|-----------|----------|---------|
| Smart Pointers | Ownership management | Memory safety |
| Move Semantics | Avoid copies | Performance |
| Custom Allocators | Specialized allocation | Speed + control |
| SIMD | Parallel computation | 4-8x speedup |
| SoA Layout | Sequential access | Cache efficiency |
| Memory Pools | Frequent alloc/dealloc | Reduced fragmentation |
| Alignment | SIMD/cache optimization | Performance |
| RVO/NRVO | Return objects | Zero-copy |

View File

@@ -0,0 +1,307 @@
# Modern C++20/23 Features
> Reference for: C++ Pro
> Load when: Using C++20/23 features, concepts, ranges, coroutines, modules
## Concepts and Constraints
```cpp
#include <concepts>
// Define custom concepts
template<typename T>
concept Numeric = std::integral<T> || std::floating_point<T>;
template<typename T>
concept Hashable = requires(T a) {
{ std::hash<T>{}(a) } -> std::convertible_to<std::size_t>;
};
template<typename T>
concept Container = requires(T c) {
typename T::value_type;
typename T::iterator;
{ c.begin() } -> std::same_as<typename T::iterator>;
{ c.end() } -> std::same_as<typename T::iterator>;
{ c.size() } -> std::convertible_to<std::size_t>;
};
// Use concepts for function constraints
template<Numeric T>
T add(T a, T b) {
return a + b;
}
// Concept-based overloading
template<std::integral T>
void process(T value) {
std::cout << "Processing integer: " << value << '\n';
}
template<std::floating_point T>
void process(T value) {
std::cout << "Processing float: " << value << '\n';
}
```
## Ranges and Views
```cpp
#include <ranges>
#include <vector>
#include <algorithm>
// Ranges-based algorithms
std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
// Filter, transform, take - all lazy evaluation
auto result = numbers
| std::views::filter([](int n) { return n % 2 == 0; })
| std::views::transform([](int n) { return n * n; })
| std::views::take(3);
// Copy to vector only when needed
std::vector<int> materialized(result.begin(), result.end());
// Custom range adaptor
auto is_even = [](int n) { return n % 2 == 0; };
auto square = [](int n) { return n * n; };
auto pipeline = std::views::filter(is_even)
| std::views::transform(square);
auto processed = numbers | pipeline;
```
## Coroutines
```cpp
#include <coroutine>
#include <iostream>
#include <memory>
// Generator coroutine
template<typename T>
struct Generator {
struct promise_type {
T current_value;
auto get_return_object() {
return Generator{std::coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_always initial_suspend() { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
std::suspend_always yield_value(T value) {
current_value = value;
return {};
}
void return_void() {}
void unhandled_exception() { std::terminate(); }
};
std::coroutine_handle<promise_type> handle;
Generator(std::coroutine_handle<promise_type> h) : handle(h) {}
~Generator() { if (handle) handle.destroy(); }
bool move_next() {
handle.resume();
return !handle.done();
}
T current_value() {
return handle.promise().current_value;
}
};
// Usage
Generator<int> fibonacci() {
int a = 0, b = 1;
while (true) {
co_yield a;
auto next = a + b;
a = b;
b = next;
}
}
// Async coroutine
#include <future>
struct Task {
struct promise_type {
Task get_return_object() {
return Task{std::coroutine_handle<promise_type>::from_promise(*this)};
}
std::suspend_never initial_suspend() { return {}; }
std::suspend_never final_suspend() noexcept { return {}; }
void return_void() {}
void unhandled_exception() {}
};
std::coroutine_handle<promise_type> handle;
};
Task async_operation() {
std::cout << "Starting async work\n";
co_await std::suspend_always{};
std::cout << "Resuming async work\n";
}
```
## Three-Way Comparison (Spaceship)
```cpp
#include <compare>
struct Point {
int x, y;
// Auto-generate all comparison operators
auto operator<=>(const Point&) const = default;
};
// Custom spaceship operator
struct Version {
int major, minor, patch;
std::strong_ordering operator<=>(const Version& other) const {
if (auto cmp = major <=> other.major; cmp != 0) return cmp;
if (auto cmp = minor <=> other.minor; cmp != 0) return cmp;
return patch <=> other.patch;
}
bool operator==(const Version& other) const = default;
};
```
## Designated Initializers
```cpp
struct Config {
std::string host = "localhost";
int port = 8080;
bool ssl_enabled = false;
int timeout_ms = 5000;
};
// C++20 designated initializers
Config cfg {
.host = "example.com",
.port = 443,
.ssl_enabled = true
// timeout_ms uses default
};
```
## Modules (C++20)
```cpp
// math.cppm - module interface
export module math;
export namespace math {
template<typename T>
T add(T a, T b) {
return a + b;
}
class Calculator {
public:
int multiply(int a, int b);
};
}
// Implementation
module math;
int math::Calculator::multiply(int a, int b) {
return a * b;
}
// Usage in other files
import math;
int main() {
auto result = math::add(5, 3);
math::Calculator calc;
auto product = calc.multiply(4, 7);
}
```
## constexpr Enhancements
```cpp
#include <string>
#include <vector>
#include <algorithm>
// C++20: constexpr std::string and std::vector
constexpr auto compute_at_compile_time() {
std::vector<int> vec{1, 2, 3, 4, 5};
std::ranges::reverse(vec);
return vec[0]; // Returns 5
}
constexpr int value = compute_at_compile_time();
// constexpr virtual functions (C++20)
struct Base {
constexpr virtual int get_value() const { return 42; }
constexpr virtual ~Base() = default;
};
struct Derived : Base {
constexpr int get_value() const override { return 100; }
};
```
## std::format (C++20)
```cpp
#include <format>
#include <iostream>
int main() {
std::string msg = std::format("Hello, {}!", "World");
// Positional arguments
auto text = std::format("{1} {0}", "World", "Hello");
// Formatting options
double pi = 3.14159265;
auto formatted = std::format("Pi: {:.2f}", pi); // "Pi: 3.14"
// Custom types
struct Point { int x, y; };
}
// Custom formatter
template<>
struct std::formatter<Point> {
constexpr auto parse(format_parse_context& ctx) {
return ctx.begin();
}
auto format(const Point& p, format_context& ctx) const {
return std::format_to(ctx.out(), "({}, {})", p.x, p.y);
}
};
```
## Quick Reference
| Feature | C++17 | C++20 | C++23 |
|---------|-------|-------|-------|
| Concepts | - | ✓ | ✓ |
| Ranges | - | ✓ | ✓ |
| Coroutines | - | ✓ | ✓ |
| Modules | - | ✓ | ✓ |
| Spaceship | - | ✓ | ✓ |
| std::format | - | ✓ | ✓ |
| std::expected | - | - | ✓ |
| std::print | - | - | ✓ |
| Deducing this | - | - | ✓ |

View File

@@ -0,0 +1,360 @@
# Template Metaprogramming
> Reference for: C++ Pro
> Load when: Variadic templates, SFINAE, type traits, CRTP, compile-time programming
## Variadic Templates
```cpp
#include <iostream>
#include <utility>
// Fold expressions (C++17)
template<typename... Args>
auto sum(Args... args) {
return (args + ...); // Unary right fold
}
template<typename... Args>
void print(Args&&... args) {
((std::cout << args << ' '), ...); // Binary left fold
std::cout << '\n';
}
// Recursive variadic template
template<typename T>
void log(T&& value) {
std::cout << value << '\n';
}
template<typename T, typename... Args>
void log(T&& first, Args&&... rest) {
std::cout << first << ", ";
log(std::forward<Args>(rest)...);
}
// Parameter pack expansion
template<typename... Types>
struct TypeList {
static constexpr size_t size = sizeof...(Types);
};
template<typename... Args>
auto make_tuple_advanced(Args&&... args) {
return std::tuple<std::decay_t<Args>...>(std::forward<Args>(args)...);
}
```
## SFINAE and if constexpr
```cpp
#include <type_traits>
// SFINAE with std::enable_if (older style)
template<typename T>
std::enable_if_t<std::is_integral_v<T>, T>
double_value(T value) {
return value * 2;
}
template<typename T>
std::enable_if_t<std::is_floating_point_v<T>, T>
double_value(T value) {
return value * 2.0;
}
// Modern: if constexpr (C++17)
template<typename T>
auto process(T value) {
if constexpr (std::is_integral_v<T>) {
return value * 2;
} else if constexpr (std::is_floating_point_v<T>) {
return value * 2.0;
} else {
return value;
}
}
// Detection idiom
template<typename T, typename = void>
struct has_serialize : std::false_type {};
template<typename T>
struct has_serialize<T, std::void_t<decltype(std::declval<T>().serialize())>>
: std::true_type {};
template<typename T>
constexpr bool has_serialize_v = has_serialize<T>::value;
// Use with if constexpr
template<typename T>
void save(const T& obj) {
if constexpr (has_serialize_v<T>) {
obj.serialize();
} else {
// Default serialization
}
}
```
## Type Traits
```cpp
#include <type_traits>
// Custom type traits
template<typename T>
struct remove_all_pointers {
using type = T;
};
template<typename T>
struct remove_all_pointers<T*> {
using type = typename remove_all_pointers<T>::type;
};
template<typename T>
using remove_all_pointers_t = typename remove_all_pointers<T>::type;
// Conditional types
template<bool Condition, typename T, typename F>
struct conditional_type {
using type = T;
};
template<typename T, typename F>
struct conditional_type<false, T, F> {
using type = F;
};
// Compile-time type selection
template<size_t N>
struct best_integral_type {
using type = std::conditional_t<N <= 8, uint8_t,
std::conditional_t<N <= 16, uint16_t,
std::conditional_t<N <= 32, uint32_t, uint64_t>>>;
};
// Check for member functions
template<typename T, typename = void>
struct has_reserve : std::false_type {};
template<typename T>
struct has_reserve<T, std::void_t<decltype(std::declval<T>().reserve(size_t{}))>>
: std::true_type {};
```
## CRTP (Curiously Recurring Template Pattern)
```cpp
// Static polymorphism with CRTP
template<typename Derived>
class Shape {
public:
double area() const {
return static_cast<const Derived*>(this)->area_impl();
}
void draw() const {
static_cast<const Derived*>(this)->draw_impl();
}
};
class Circle : public Shape<Circle> {
double radius_;
public:
Circle(double r) : radius_(r) {}
double area_impl() const {
return 3.14159 * radius_ * radius_;
}
void draw_impl() const {
std::cout << "Drawing circle\n";
}
};
class Rectangle : public Shape<Rectangle> {
double width_, height_;
public:
Rectangle(double w, double h) : width_(w), height_(h) {}
double area_impl() const {
return width_ * height_;
}
void draw_impl() const {
std::cout << "Drawing rectangle\n";
}
};
// CRTP for mixin capabilities
template<typename Derived>
class Printable {
public:
void print() const {
std::cout << static_cast<const Derived*>(this)->to_string() << '\n';
}
};
class User : public Printable<User> {
std::string name_;
public:
User(std::string name) : name_(std::move(name)) {}
std::string to_string() const {
return "User: " + name_;
}
};
```
## Template Template Parameters
```cpp
#include <vector>
#include <list>
#include <deque>
// Template template parameter
template<typename T, template<typename, typename> class Container>
class Stack {
Container<T, std::allocator<T>> data_;
public:
void push(const T& value) {
data_.push_back(value);
}
T pop() {
T value = data_.back();
data_.pop_back();
return value;
}
size_t size() const {
return data_.size();
}
};
// Usage with different containers
Stack<int, std::vector> vector_stack;
Stack<int, std::deque> deque_stack;
Stack<int, std::list> list_stack;
```
## Compile-Time Computation
```cpp
#include <array>
// Compile-time factorial
constexpr int factorial(int n) {
return n <= 1 ? 1 : n * factorial(n - 1);
}
constexpr int fact_5 = factorial(5); // Computed at compile time
// Compile-time prime checking
constexpr bool is_prime(int n) {
if (n < 2) return false;
for (int i = 2; i * i <= n; ++i) {
if (n % i == 0) return false;
}
return true;
}
// Generate compile-time array of primes
template<size_t N>
constexpr auto generate_primes() {
std::array<int, N> primes{};
int count = 0;
int candidate = 2;
while (count < N) {
if (is_prime(candidate)) {
primes[count++] = candidate;
}
++candidate;
}
return primes;
}
constexpr auto first_10_primes = generate_primes<10>();
```
## Expression Templates
```cpp
// Lazy evaluation with expression templates
template<typename E>
class VecExpression {
public:
double operator[](size_t i) const {
return static_cast<const E&>(*this)[i];
}
size_t size() const {
return static_cast<const E&>(*this).size();
}
};
class Vec : public VecExpression<Vec> {
std::vector<double> data_;
public:
Vec(size_t n) : data_(n) {}
double operator[](size_t i) const { return data_[i]; }
double& operator[](size_t i) { return data_[i]; }
size_t size() const { return data_.size(); }
// Evaluate expression template
template<typename E>
Vec& operator=(const VecExpression<E>& expr) {
for (size_t i = 0; i < size(); ++i) {
data_[i] = expr[i];
}
return *this;
}
};
// Binary operation expression
template<typename E1, typename E2>
class VecSum : public VecExpression<VecSum<E1, E2>> {
const E1& lhs_;
const E2& rhs_;
public:
VecSum(const E1& lhs, const E2& rhs) : lhs_(lhs), rhs_(rhs) {}
double operator[](size_t i) const {
return lhs_[i] + rhs_[i];
}
size_t size() const { return lhs_.size(); }
};
// Operator overload
template<typename E1, typename E2>
VecSum<E1, E2> operator+(const VecExpression<E1>& lhs,
const VecExpression<E2>& rhs) {
return VecSum<E1, E2>(static_cast<const E1&>(lhs),
static_cast<const E2&>(rhs));
}
// Usage: a = b + c + d (no temporaries created!)
```
## Quick Reference
| Technique | Use Case | Performance |
|-----------|----------|-------------|
| Variadic Templates | Variable arguments | Zero overhead |
| SFINAE | Conditional compilation | Compile-time |
| if constexpr | Type-based branching | Zero overhead |
| CRTP | Static polymorphism | No vtable cost |
| Expression Templates | Lazy evaluation | Eliminates temps |
| Type Traits | Type introspection | Compile-time |
| Fold Expressions | Parameter pack ops | Optimal |
| Template Specialization | Type-specific impl | Zero overhead |

View File

@@ -0,0 +1,12 @@
---
name: requirements
description: Update the Dota Factory requirements document with new or changed requirements
argument-hint: <description of new requirement>
disable-model-invocation: true
---
Read `docs/requirements.md`, then help the user update the requirements with the following change:
$ARGUMENTS
Ask any clarifying questions if the request is ambiguous, or flag any conflicts with existing requirements before making changes. Do not make changes to the requirements file before the answers are clear.

4
.gitignore vendored
View File

@@ -1 +1,5 @@
/build/
# local Claude Code config (machine-specific; .mcp.json holds credentials)
/.mcp.json
/.claude/settings.local.json

View File

@@ -4,17 +4,27 @@ message(STATUS "Using CMake ${CMAKE_VERSION}")
include(cmake/add_files.cmake)
include(cmake/create_source_groups.cmake)
include(cmake/version.cmake)
# Project ----------------------------------------------------------------------
project(DotaFactory)
# Product identity — anything that depends on the product/project name is defined
# here so it lives in a single place and can change in the future. These values
# feed the build targets in src/CMakeLists.txt and the Windows version resource
# (see cmake/version.rc.in).
set(PRODUCT_NAME "DotaFactory") # internal name and executable base name
set(PRODUCT_DISPLAY_NAME "Dota Factory") # human-readable product / file description
set(PRODUCT_COMPANY "TODO: company") # placeholder
set(PRODUCT_COPYRIGHT "TODO: copyright") # placeholder
project(${PRODUCT_NAME})
set(CMAKE_BUILD_TYPE_INIT "Release")
# Qt ---------------------------------------------------------------------------
find_package(Qt5 COMPONENTS Widgets Network Multimedia Charts REQUIRED)
find_package(Qt5 COMPONENTS Widgets Network Multimedia Charts Svg REQUIRED)
if(Qt5Widgets_FOUND)
message(STATUS "Found Qt ${Qt5Widgets_VERSION_STRING}")
@@ -54,6 +64,7 @@ function(COPY_QT_BINARIES TARGET_DIR IS_DEBUG)
configure_file("${QT_BINARY_DIR}/Qt5Network${SUFFIX}.dll" "${TARGET_DIR}/Qt5Network${SUFFIX}.dll" COPYONLY)
configure_file("${QT_BINARY_DIR}/Qt5Widgets${SUFFIX}.dll" "${TARGET_DIR}/Qt5Widgets${SUFFIX}.dll" COPYONLY)
configure_file("${QT_BINARY_DIR}/Qt5Multimedia${SUFFIX}.dll" "${TARGET_DIR}/Qt5Multimedia${SUFFIX}.dll" COPYONLY)
configure_file("${QT_BINARY_DIR}/Qt5Svg${SUFFIX}.dll" "${TARGET_DIR}/Qt5Svg${SUFFIX}.dll" COPYONLY)
endfunction(COPY_QT_BINARIES)

View File

@@ -0,0 +1,98 @@
[[building]]
id = "belt"
tooltip = "Transports items one tile at a time in the direction it faces."
cost = 2
player_placeable = true
construction_time_seconds = 0.2
surface_mask = ["A>"]
[[building]]
id = "splitter"
tooltip = "Splits an incoming item stream between two outputs, with optional per-output filters."
cost = 3
player_placeable = true
construction_time_seconds = 0.5
surface_mask = ["<A>"]
[[building]]
id = "tunnel_entry"
tooltip = "Sends items underground so belts can cross. Places an entry, or an exit when it would connect to a matching entry under the cursor."
cost = 5
player_placeable = true
construction_time_seconds = 0.5
surface_mask = ["A>"]
[[building]]
id = "tunnel_exit"
tooltip = "Receives items from a matching tunnel entry and pushes them onward."
cost = 5
player_placeable = true
construction_time_seconds = 0.5
surface_mask = ["A>"]
[[building]]
id = "miner"
tooltip = "Extracts a selected ore from the asteroid; every tile yields any ore."
cost = 15
player_placeable = true
construction_time_seconds = 1
surface_mask = [
"A>",
]
[[building]]
id = "smelter"
tooltip = "Melts ore or scrap into basic materials. No recipe selection needed."
cost = 20
player_placeable = true
construction_time_seconds = 1
surface_mask = [
"AA",
" v",
]
[[building]]
id = "assembler"
tooltip = "Crafts a selected recipe from the production tree into intermediate or final parts."
cost = 35
player_placeable = true
construction_time_seconds = 1
surface_mask = [
"AA ",
"AA>",
]
[[building]]
id = "reprocessing_plant"
tooltip = "Consumes scrap and yields one random higher-tier product per cycle."
cost = 40
player_placeable = true
construction_time_seconds = 1
surface_mask = [
"AAA ",
"AAA>",
"AAA ",
]
[[building]]
id = "shipyard"
tooltip = "Builds autonomous combat ships from a selected schematic and module layout."
cost = 60
player_placeable = true
construction_time_seconds = 1
surface_mask = [
"AAAS>",
"AAAS ",
]
[[building]]
id = "salvage_bay"
tooltip = "Drop-off point where salvage ships unload collected scrap onto belts."
cost = 25
player_placeable = true
construction_time_seconds = 1
output_buffer_capacity = 20
surface_mask = [
"<AAS",
" AAS",
]

View File

@@ -0,0 +1,254 @@
# modules.toml
#
# Production tree v2: all weapons are railguns for now — the implementation
# (instant damage, no projectile, no ammunition) stays as-is and the beam
# visual reads as a tracer round. Lasers are reserved for a future distinct
# weapon type (see docs/content_design.md, "Production tree v2 — Weapons").
# Combat stats are placeholders until the arena balancing pass;
# production_time_seconds values come from the numbers pass.
#
# Unlock gating is defined in unlocks.toml, not here (REQ-LOCK-EXPLICIT): a
# module id granted by an unlock group starts locked and is awarded via a
# defence station drop; ids absent from unlocks.toml (railgun_s) start unlocked.
#
# Surface mask footprint ladder — footprints gate which hulls can mount a
# module, purely through geometry (see ships.toml for the matching hull
# grids):
#
# 1x1 railgun_s, salvager, repair_tool fits every hull, incl. drones
# 1x2 maneuvering_thrusters, sensor_booster,
# armor_plates frigate and up
# 1x3 afterburner frigate and up (eats most of a frigate)
# L-shape weapon_stabilizer, weapon_primer,
# weapon_upgrade frigate and up
# 2x2 railgun_m, drone_bay cruiser and up (no 2x2 area on s hulls)
# 3x3 railgun_l battleship and up (no 3x3 area on m hulls)
# 2x6 drone_hangar carrier only
# -----------------------------------------------------------------------------
# Weapons
# -----------------------------------------------------------------------------
[[module]]
id = "railgun_s"
tooltip = "Small railgun. Fast-firing, short range, low damage; fits any hull."
surface_mask = ["O"]
materials = [{item = "railgun_s_module", amount = 1}]
production_time_seconds = 1
fill_color = "#FF8040"
glyph = "Rs"
[module.weapon]
damage = 2
attack_range_m = 50
attack_rate_hz = 2.0
[[module]]
id = "railgun_m"
tooltip = "Medium railgun. Higher damage at longer range; needs a 2x2 slot."
surface_mask = [
"OO",
"OO"]
materials = [{item = "railgun_m_module", amount = 1}]
production_time_seconds = 3
fill_color = "#FF8040"
glyph = "Rm"
[module.weapon]
damage = 14
attack_range_m = 70
attack_rate_hz = 1.5
[[module]]
id = "railgun_l"
tooltip = "Large railgun. Heavy damage at long range; needs a 3x3 slot."
surface_mask = [
"OOO",
"OOO",
"OOO"]
materials = [{item = "railgun_l_module", amount = 1}]
production_time_seconds = 4
fill_color = "#FF8040"
glyph = "Rl"
[module.weapon]
damage = 52
attack_range_m = 100
attack_rate_hz = 0.8
# -----------------------------------------------------------------------------
# Utility tools
# -----------------------------------------------------------------------------
[[module]]
id = "salvager"
tooltip = "Collects scrap from wrecks and stores it in the ship's cargo hold."
surface_mask = ["O"]
materials = [{item = "salvager_module", amount = 1}]
production_time_seconds = 1
fill_color = "#AACC44"
glyph = "Sv"
[module.salvage]
collection_range_m = 60
cargo_capacity = 20
collection_rate_hz = 0.5
[[module]]
id = "repair_tool"
tooltip = "Repairs damaged friendly ships and defence stations within range."
surface_mask = ["O"]
materials = [{item = "repair_tool_module", amount = 1}]
production_time_seconds = 1
fill_color = "#66CCFF"
glyph = "Rp"
[module.repair]
repair_rate_hz = 1
repair_amount_hp = 4
repair_range_m = 80
# -----------------------------------------------------------------------------
# Propulsion
# -----------------------------------------------------------------------------
[[module]]
id = "afterburner"
tooltip = "Greatly boosts top speed and forward acceleration."
surface_mask = ["OOO"]
materials = [{item = "afterburner_module", amount = 1}]
production_time_seconds = 1
fill_color = "#40A0FF"
glyph = "Ab"
[module.movement]
multiplied_speed_mps = 1.6
added_main_acceleration_mpss = 60
[[module]]
id = "maneuvering_thrusters"
tooltip = "Improves top speed and lateral/braking acceleration."
surface_mask = ["OO"]
materials = [{item = "maneuvering_thrusters_module", amount = 1}]
production_time_seconds = 1
fill_color = "#40A0FF"
glyph = "Mt"
[module.movement]
multiplied_speed_mps = 1.2
added_maneuvering_acceleration_mpss = 10
# -----------------------------------------------------------------------------
# Defense & sensors
# -----------------------------------------------------------------------------
[[module]]
id = "armor_plates"
tooltip = "Adds a large flat bonus to the ship's hit points."
surface_mask = ["OO"]
materials = [{item = "armor_plates_module", amount = 1}]
production_time_seconds = 1
fill_color = "#808080"
glyph = "A"
[module.health]
added_hp = 1200
[[module]]
id = "sensor_booster"
tooltip = "Extends the ship's sensor range."
surface_mask = ["OO"]
materials = [{item = "sensor_booster_module", amount = 1}]
production_time_seconds = 1
fill_color = "#40A0FF"
glyph = "S"
[module.sensor]
added_sensor_range_m = 50
# -----------------------------------------------------------------------------
# Weapon modifiers
# -----------------------------------------------------------------------------
[[module]]
id = "weapon_upgrade"
tooltip = "Increases the damage of all weapons on the ship."
surface_mask = [
"OO",
"OX",
]
materials = [{item = "weapon_upgrade_module", amount = 1}]
production_time_seconds = 2
fill_color = "#FF4040"
glyph = "Wu"
[module.weapon]
multiplied_damage = 1.2
[[module]]
id = "weapon_primer"
tooltip = "Increases the fire rate of all weapons on the ship."
surface_mask = [
"OO",
"OX",
]
materials = [{item = "weapon_primer_module", amount = 1}]
production_time_seconds = 2
fill_color = "#FF4040"
glyph = "Wp"
[module.weapon]
multiplied_attack_rate_hz = 1.2
[[module]]
id = "weapon_stabilizer"
tooltip = "Extends weapon range at the cost of some fire rate."
surface_mask = [
"OO",
"OX",
]
materials = [{item = "weapon_stabilizer_module", amount = 1}]
production_time_seconds = 1
fill_color = "#FF4040"
glyph = "Ws"
[module.weapon]
multiplied_attack_range_m = 1.3
multiplied_attack_rate_hz = 0.8
# -----------------------------------------------------------------------------
# Drone modules
#
# Footprint-only placeholders: the drone launching capability is not
# implemented yet, so these modules define no capability section.
# -----------------------------------------------------------------------------
[[module]]
id = "drone_bay"
tooltip = "Drone launch bay (capability not yet implemented)."
surface_mask = [
"OO",
"OO"]
materials = [{item = "drone_bay_module", amount = 1}]
production_time_seconds = 3
fill_color = "#CC66FF"
glyph = "Db"
[[module]]
id = "drone_hangar"
tooltip = "Large drone hangar (capability not yet implemented)."
surface_mask = [
"OOOOOO",
"OOOOOO"]
materials = [{item = "drone_hangar_module", amount = 1}]
production_time_seconds = 6
fill_color = "#9933CC"
glyph = "Dh"

View File

@@ -0,0 +1,444 @@
# recipes.toml
#
# Production tree v2 (structure in docs/content_design.md, numbers with
# derivations in docs/balancing/derived.md). Quantities and durations are tuned so that every
# fitted ship lands on the threat-cost ladder and the ratio curve is
# realized: tier 1 ratios are 1:1, tier 2 ratios are 2:3, tier 3+ ratios
# are deliberately strange.
#
# A recipe id names the process, not the product — the display name shown in
# the UI is derived from it — so an item and the recipe that makes it never
# carry the same name.
#
# Input chain per game phase — each phase transition adds exactly one new
# base input:
#
# early iron_ore + copper_ore minable on every asteroid tile (the
# asteroid is an M-type body — its bulk
# rock IS ore)
# mid + quartz geode deposits in expansion territory
# (deposit gating pending — see action
# item 5 in docs/balancing/README.md;
# until then quartz mines anywhere)
# late + voidsteel battle-forged: ONLY from reprocessing
# salvaged scrap, so capital production
# requires combat
#
# Run tools/verify_recipes.py and tools/threat_report.py after editing.
# -----------------------------------------------------------------------------
# Mining (tier 0)
# -----------------------------------------------------------------------------
[[recipe]]
id = "iron_mining"
building = "miner"
inputs = []
outputs = [{item = "iron_ore", amount = 1}]
duration_seconds = 1.0
[[recipe]]
id = "copper_mining"
building = "miner"
inputs = []
outputs = [{item = "copper_ore", amount = 1}]
duration_seconds = 1.0
[[recipe]]
id = "quartz_mining"
building = "miner"
inputs = []
outputs = [{item = "quartz", amount = 1}]
duration_seconds = 2.0
# -----------------------------------------------------------------------------
# Smelting (tier 1) — one recipe per input item; ratios are 1:1 with miners.
# -----------------------------------------------------------------------------
[[recipe]]
id = "iron_smelting"
building = "smelter"
inputs = [{item = "iron_ore", amount = 1}]
outputs = [{item = "iron_ingot", amount = 1}]
duration_seconds = 1.0
[[recipe]]
id = "copper_smelting"
building = "smelter"
inputs = [{item = "copper_ore", amount = 1}]
outputs = [{item = "copper_ingot", amount = 1}]
duration_seconds = 1.0
[[recipe]]
id = "quartz_reduction"
building = "smelter"
inputs = [{item = "quartz", amount = 1}]
outputs = [{item = "silicon", amount = 1}]
duration_seconds = 2.0
# Scrap smelting: the safe, boring sink. Deliberately value-losing (4 threat
# of scrap becomes a 2-threat ingot) — reprocessing is the value-preserving
# path.
[[recipe]]
id = "scrap_smelting"
building = "smelter"
inputs = [{item = "scrap", amount = 1}]
outputs = [{item = "iron_ingot", amount = 1}]
duration_seconds = 1.0
# -----------------------------------------------------------------------------
# Reprocessing — the only source of voidsteel (battle-forged; formed when
# weapon plasma anneals hull metal in the violence of ship destruction).
# Weights are authored for the fully unlocked pool state; the pool
# renormalizes over implicitly unlocked items early game.
# -----------------------------------------------------------------------------
[[recipe]]
id = "scrap_reprocessing"
building = "reprocessing_plant"
inputs = [{item = "scrap", amount = 4}]
duration_seconds = 4.0
[[recipe.output_group]]
probability = 0.3
items = [{item = "iron_ingot", amount = 1}]
[[recipe.output_group]]
probability = 0.3
items = [{item = "copper_ingot", amount = 1}]
[[recipe.output_group]]
probability = 0.2
items = [{item = "silicon", amount = 1}]
[[recipe.output_group]]
probability = 0.2
items = [{item = "voidsteel", amount = 1}]
# -----------------------------------------------------------------------------
# Tier 2 — early intermediates (clean ratios, ~2:3)
# -----------------------------------------------------------------------------
[[recipe]]
id = "steel_rolling"
building = "assembler"
inputs = [{item = "iron_ingot", amount = 2}]
outputs = [{item = "steel_plate", amount = 1}]
duration_seconds = 3.0
[[recipe]]
id = "wire_drawing"
building = "assembler"
inputs = [{item = "copper_ingot", amount = 1}]
outputs = [{item = "copper_wire", amount = 2}]
duration_seconds = 1.0
[[recipe]]
id = "coil_winding"
building = "assembler"
inputs = [{item = "copper_wire", amount = 2}]
outputs = [{item = "copper_coil", amount = 1}]
duration_seconds = 1.5
# Depth-3 chain (ore -> ingot -> plate -> block) is the factory's
# doubling-time knob; see the block economy rules in docs/balancing/rules.md.
# unlocked_at_start: building blocks appear in no schematic's materials, so the
# implicit item graph can never reach this recipe (REQ-LOCK-IMPLICIT).
[[recipe]]
id = "block_pressing"
building = "assembler"
unlocked_at_start = true
inputs = [{item = "steel_plate", amount = 2}]
outputs = [{item = "building_block", amount = 4}]
duration_seconds = 2.0
# -----------------------------------------------------------------------------
# Tier 3 — mid intermediates (strange ratios begin; need quartz)
# -----------------------------------------------------------------------------
[[recipe]]
id = "chip_etching"
building = "assembler"
inputs = [{item = "silicon", amount = 1}, {item = "copper_wire", amount = 2}]
outputs = [{item = "control_chip", amount = 1}]
duration_seconds = 5.0
[[recipe]]
id = "capacitor_assembly"
building = "assembler"
inputs = [{item = "copper_coil", amount = 2}, {item = "silicon", amount = 1}]
outputs = [{item = "capacitor_bank", amount = 1}]
duration_seconds = 5.0
# The quality gate for m+ hulls: a deliberately long-running recipe
# (time-heavy archetype).
[[recipe]]
id = "steel_hardening"
building = "assembler"
inputs = [{item = "steel_plate", amount = 3}]
outputs = [{item = "hardened_steel", amount = 1}]
duration_seconds = 12.0
[[recipe]]
id = "ceramic_firing"
building = "assembler"
inputs = [{item = "quartz", amount = 2}]
outputs = [{item = "ceramic_plate", amount = 1}]
duration_seconds = 4.0
[[recipe]]
id = "drive_assembly"
building = "assembler"
inputs = [
{item = "steel_plate", amount = 2},
{item = "copper_coil", amount = 2},
{item = "control_chip", amount = 1},
]
outputs = [{item = "drive_unit", amount = 1}]
duration_seconds = 8.0
# -----------------------------------------------------------------------------
# Tier 4 — late intermediates (need voidsteel)
# -----------------------------------------------------------------------------
[[recipe]]
id = "voidsteel_forging"
building = "assembler"
inputs = [{item = "voidsteel", amount = 1}, {item = "hardened_steel", amount = 1}]
outputs = [{item = "voidsteel_plate", amount = 1}]
duration_seconds = 8.0
[[recipe]]
id = "core_assembly"
building = "assembler"
inputs = [
{item = "voidsteel", amount = 2},
{item = "capacitor_bank", amount = 1},
{item = "control_chip", amount = 1},
]
outputs = [{item = "capital_core", amount = 1}]
duration_seconds = 10.0
# -----------------------------------------------------------------------------
# Shortcut recipes — drop-only assembler recipes, gated by unlock groups in
# unlocks.toml (REQ-LOCK-EXPLICIT). Pure rewards: item threat stays defined by
# the base (expensive) path via the max rule, so shortcuts give real factory
# efficiency without shifting any balance. Named "direct_*" after the base
# process they skip a step of, so the reward reads as what it is.
# -----------------------------------------------------------------------------
[[recipe]]
id = "direct_rolling"
building = "assembler"
inputs = [{item = "iron_ore", amount = 3}]
outputs = [{item = "steel_plate", amount = 1}]
duration_seconds = 2.0
[[recipe]]
id = "direct_etching"
building = "assembler"
inputs = [{item = "quartz", amount = 2}]
outputs = [{item = "control_chip", amount = 1}]
duration_seconds = 4.0
[[recipe]]
id = "direct_hardening"
building = "assembler"
inputs = [{item = "iron_ingot", amount = 4}]
outputs = [{item = "hardened_steel", amount = 1}]
duration_seconds = 8.0
# -----------------------------------------------------------------------------
# Ship hulls
# -----------------------------------------------------------------------------
[[recipe]]
id = "drone_framing"
building = "assembler"
inputs = [{item = "iron_ingot", amount = 1}]
outputs = [{item = "drone_hull", amount = 1}]
duration_seconds = 1.0
[[recipe]]
id = "frigate_framing"
building = "assembler"
inputs = [{item = "steel_plate", amount = 2}, {item = "copper_wire", amount = 1}]
outputs = [{item = "frigate_hull", amount = 1}]
duration_seconds = 2.0
[[recipe]]
id = "destroyer_framing"
building = "assembler"
inputs = [{item = "steel_plate", amount = 3}, {item = "copper_coil", amount = 2}]
outputs = [{item = "destroyer_hull", amount = 1}]
duration_seconds = 4.0
[[recipe]]
id = "cruiser_framing"
building = "assembler"
inputs = [{item = "hardened_steel", amount = 2}, {item = "control_chip", amount = 2}]
outputs = [{item = "cruiser_hull", amount = 1}]
duration_seconds = 6.0
[[recipe]]
id = "battlecruiser_framing"
building = "assembler"
inputs = [
{item = "hardened_steel", amount = 3},
{item = "control_chip", amount = 2},
{item = "drive_unit", amount = 1},
]
outputs = [{item = "battlecruiser_hull", amount = 1}]
duration_seconds = 8.0
[[recipe]]
id = "battleship_framing"
building = "assembler"
inputs = [
{item = "voidsteel_plate", amount = 3},
{item = "drive_unit", amount = 1},
{item = "control_chip", amount = 2},
]
outputs = [{item = "battleship_hull", amount = 1}]
duration_seconds = 10.0
[[recipe]]
id = "dreadnought_framing"
building = "assembler"
inputs = [
{item = "voidsteel_plate", amount = 5},
{item = "capital_core", amount = 1},
{item = "drive_unit", amount = 2},
]
outputs = [{item = "dreadnought_hull", amount = 1}]
duration_seconds = 12.0
[[recipe]]
id = "carrier_framing"
building = "assembler"
inputs = [
{item = "voidsteel_plate", amount = 5},
{item = "capital_core", amount = 1},
{item = "drive_unit", amount = 2},
]
outputs = [{item = "carrier_hull", amount = 1}]
duration_seconds = 12.0
# -----------------------------------------------------------------------------
# Module prefabs
# -----------------------------------------------------------------------------
[[recipe]]
id = "railgun_s_kit"
building = "assembler"
inputs = [{item = "copper_coil", amount = 1}]
outputs = [{item = "railgun_s_module", amount = 1}]
duration_seconds = 1.0
[[recipe]]
id = "salvager_kit"
building = "assembler"
inputs = [{item = "steel_plate", amount = 1}, {item = "copper_wire", amount = 2}]
outputs = [{item = "salvager_module", amount = 1}]
duration_seconds = 2.0
[[recipe]]
id = "repair_tool_kit"
building = "assembler"
inputs = [{item = "steel_plate", amount = 1}, {item = "copper_wire", amount = 2}]
outputs = [{item = "repair_tool_module", amount = 1}]
duration_seconds = 2.0
# Material-heavy, fast: the armor archetype.
[[recipe]]
id = "armor_plates_kit"
building = "assembler"
inputs = [{item = "steel_plate", amount = 4}]
outputs = [{item = "armor_plates_module", amount = 1}]
duration_seconds = 3.0
[[recipe]]
id = "maneuvering_thrusters_kit"
building = "assembler"
inputs = [{item = "steel_plate", amount = 1}, {item = "copper_coil", amount = 1}]
outputs = [{item = "maneuvering_thrusters_module", amount = 1}]
duration_seconds = 2.0
[[recipe]]
id = "sensor_booster_kit"
building = "assembler"
inputs = [{item = "copper_wire", amount = 2}, {item = "copper_coil", amount = 1}]
outputs = [{item = "sensor_booster_module", amount = 1}]
duration_seconds = 2.0
[[recipe]]
id = "afterburner_kit"
building = "assembler"
inputs = [{item = "copper_coil", amount = 2}, {item = "steel_plate", amount = 1}]
outputs = [{item = "afterburner_module", amount = 1}]
duration_seconds = 3.0
[[recipe]]
id = "weapon_stabilizer_kit"
building = "assembler"
inputs = [{item = "steel_plate", amount = 1}, {item = "copper_coil", amount = 1}]
outputs = [{item = "weapon_stabilizer_module", amount = 1}]
duration_seconds = 2.0
[[recipe]]
id = "weapon_primer_kit"
building = "assembler"
inputs = [{item = "capacitor_bank", amount = 1}, {item = "copper_coil", amount = 1}]
outputs = [{item = "weapon_primer_module", amount = 1}]
duration_seconds = 4.0
[[recipe]]
id = "weapon_upgrade_kit"
building = "assembler"
inputs = [{item = "control_chip", amount = 1}, {item = "copper_coil", amount = 1}]
outputs = [{item = "weapon_upgrade_module", amount = 1}]
duration_seconds = 4.0
[[recipe]]
id = "railgun_m_kit"
building = "assembler"
inputs = [
{item = "capacitor_bank", amount = 1},
{item = "steel_plate", amount = 2},
{item = "copper_coil", amount = 1},
]
outputs = [{item = "railgun_m_module", amount = 1}]
duration_seconds = 4.0
[[recipe]]
id = "drone_bay_kit"
building = "assembler"
inputs = [
{item = "control_chip", amount = 1},
{item = "steel_plate", amount = 2},
{item = "copper_coil", amount = 1},
]
outputs = [{item = "drone_bay_module", amount = 1}]
duration_seconds = 4.0
[[recipe]]
id = "railgun_l_kit"
building = "assembler"
inputs = [
{item = "capacitor_bank", amount = 1},
{item = "hardened_steel", amount = 2},
{item = "ceramic_plate", amount = 1},
]
outputs = [{item = "railgun_l_module", amount = 1}]
duration_seconds = 6.0
[[recipe]]
id = "drone_hangar_kit"
building = "assembler"
inputs = [
{item = "voidsteel_plate", amount = 1},
{item = "control_chip", amount = 2},
{item = "drive_unit", amount = 1},
]
outputs = [{item = "drone_hangar_module", amount = 1}]
duration_seconds = 10.0

View File

@@ -0,0 +1,311 @@
# ships.toml
#
# First real-content iteration: ship ids and layout grids are the designed
# content; stats, materials, and production times are placeholders until the
# recipe and balancing passes.
#
# Unlock gating is defined in unlocks.toml, not here (REQ-LOCK-EXPLICIT): a ship
# id granted by an unlock group starts locked and is awarded via a defence
# station drop; ids absent from unlocks.toml (drone, frigate) start unlocked.
#
# Size classes:
# xs drone 1 cell — exactly one 1x1 module
# s frigate, destroyer no 2x2 area anywhere: only 1x1/1x2/1x3/L modules fit
# m cruiser, battlecruiser 2x2 areas (m guns, drone bays) but no 3x3 area
# l battleship four m guns, or exactly one 3x3 l gun at heavy
# opportunity cost
# xl dreadnought, carrier dreadnought fits three l guns but no drone
# hangar; carrier fits one drone hangar (2x6)
# but no l gun (its deck rows are broken up
# by elevator shafts)
[[ship]]
id = "drone"
layout = ["O"]
default_modules = [{type = "railgun_s", x = 0, y = 0, rotation = "east"}]
[ship.schematic]
materials = [{item = "drone_hull", amount = 1}]
production_time_seconds = 1
[ship.health]
hp = 60
[ship.movement]
speed_mps = 45
main_acceleration_mpss = 60
maneuvering_acceleration_mpss = 30
angular_acceleration_radpss = 12
max_rotation_speed_radps = 6
[ship.sensor]
sensor_range_m = 150
# Frigate — 5 cells in a plus shape. Holds a couple of small guns plus at
# most one 1x2 support (every 1x2 placement crosses the center cell), or one
# L-shaped weapon modifier, or an afterburner spanning the full center line.
[[ship]]
id = "frigate"
layout = [
"XOX",
"OOO",
"XOX",
]
default_modules = [
{type = "railgun_s", x = 1, y = 0, rotation = "east"},
{type = "railgun_s", x = 2, y = 1, rotation = "east"},
{type = "maneuvering_thrusters", x = 0, y = 1, rotation = "east"},
]
[ship.schematic]
materials = [{item = "frigate_hull", amount = 1}]
production_time_seconds = 2
[ship.health]
hp = 300
[ship.movement]
speed_mps = 35
main_acceleration_mpss = 45
maneuvering_acceleration_mpss = 22
angular_acceleration_radpss = 8
max_rotation_speed_radps = 4
[ship.sensor]
sensor_range_m = 200
# Destroyer — 8 cells: a long gun deck with three turret bumps on top.
# Still no 2x2 area, so it packs more small guns than a frigate but can never
# mount medium hardware.
[[ship]]
id = "destroyer"
layout = [
"OXOXO",
"OOOOO",
]
default_modules = [
{type = "railgun_s", x = 0, y = 0, rotation = "east"},
{type = "railgun_s", x = 2, y = 0, rotation = "east"},
{type = "railgun_s", x = 4, y = 0, rotation = "east"},
{type = "armor_plates", x = 0, y = 1, rotation = "east"},
{type = "sensor_booster", x = 3, y = 1, rotation = "east"},
]
[ship.schematic]
materials = [{item = "destroyer_hull", amount = 1}]
production_time_seconds = 3
[ship.health]
hp = 550
[ship.movement]
speed_mps = 30
main_acceleration_mpss = 35
maneuvering_acceleration_mpss = 18
angular_acceleration_radpss = 6
max_rotation_speed_radps = 3
[ship.sensor]
sensor_range_m = 220
# Cruiser — 12 cells with notched corners. Fits at most two 2x2 m guns
# (stacked through the middle), leaving the four side cells for small
# supports; no 3x3 area exists for an l gun.
[[ship]]
id = "cruiser"
layout = [
"XOOX",
"OOOO",
"OOOO",
"XOOX",
]
default_modules = [
{type = "railgun_m", x = 0, y = 1, rotation = "east"},
{type = "railgun_m", x = 2, y = 1, rotation = "east"},
{type = "armor_plates", x = 1, y = 0, rotation = "east"},
{type = "maneuvering_thrusters", x = 1, y = 3, rotation = "east"},
]
[ship.schematic]
materials = [{item = "cruiser_hull", amount = 1}]
production_time_seconds = 4
[ship.health]
hp = 1500
[ship.movement]
speed_mps = 24
main_acceleration_mpss = 25
maneuvering_acceleration_mpss = 12
angular_acceleration_radpss = 4
max_rotation_speed_radps = 2
[ship.sensor]
sensor_range_m = 250
# Battlecruiser — 16 cells: a wide bow split into two gun cheeks, tapering
# toward the stern. Fits three 2x2 m guns (two in the cheeks, one through
# the middle) with small support slots left over; the split bow and tapered
# stern leave no 3x3 area for an l gun and no 2x6 area for a drone hangar.
[[ship]]
id = "battlecruiser"
layout = [
"OOXXOO",
"OOOOOO",
"XOOOOX",
"XXOOXX",
]
default_modules = [
{type = "railgun_m", x = 0, y = 0, rotation = "east"},
{type = "railgun_m", x = 4, y = 0, rotation = "east"},
{type = "railgun_m", x = 2, y = 1, rotation = "east"},
{type = "armor_plates", x = 2, y = 3, rotation = "east"},
{type = "railgun_s", x = 1, y = 2, rotation = "east"},
{type = "railgun_s", x = 4, y = 2, rotation = "east"},
]
[ship.schematic]
materials = [{item = "battlecruiser_hull", amount = 1}]
production_time_seconds = 5
[ship.health]
hp = 2400
[ship.movement]
speed_mps = 20
main_acceleration_mpss = 20
maneuvering_acceleration_mpss = 10
angular_acceleration_radpss = 3
max_rotation_speed_radps = 1.5
[ship.sensor]
sensor_range_m = 260
# Battleship — 24 cells: a broadside hull with notched flanks on every other
# row. Fits four 2x2 m guns (two per gun deck) with the bow, stern, and flank
# cells left for supports. All 3x3 placements crowd the center columns, so at
# most ONE l gun fits — and mounting it blocks every m gun mount, leaving
# only narrow support strips. The notched rows are never adjacent-and-full,
# so no 2x6 drone hangar fits.
[[ship]]
id = "battleship"
layout = [
"XOOOOX",
"OOOOOO",
"XOOOOX",
"OOOOOO",
"XOOOOX",
]
default_modules = [
{type = "railgun_l", x = 1, y = 0, rotation = "east"},
{type = "railgun_m", x = 1, y = 3, rotation = "east"},
{type = "railgun_m", x = 3, y = 3, rotation = "east"},
{type = "weapon_stabilizer", x = 4, y = 1, rotation = "east"},
{type = "railgun_s", x = 4, y = 0, rotation = "east"},
{type = "railgun_s", x = 0, y = 1, rotation = "east"},
]
[ship.schematic]
materials = [{item = "battleship_hull", amount = 1}]
production_time_seconds = 6
[ship.health]
hp = 6300
[ship.movement]
speed_mps = 15
main_acceleration_mpss = 14
maneuvering_acceleration_mpss = 7
angular_acceleration_radpss = 2
max_rotation_speed_radps = 1
[ship.sensor]
sensor_range_m = 280
# Dreadnought — 36 cells: the main battery deck is split into three 3x3 gun
# slots by structural spacer columns, so exactly three l guns fit side by
# side (or m guns / supports in unused slots). The spacers cap every
# horizontal run at 5 cells, so the 2x6 drone hangar can never fit — carriers
# stay the only hangar hull. Bow and stern strips hold supports.
[[ship]]
id = "dreadnought"
layout = [
"XXXOOOOOXXX",
"OOOXOOOXOOO",
"OOOXOOOXOOO",
"OOOXOOOXOOO",
"XXOOXXXOOXX",
]
default_modules = [
{type = "railgun_l", x = 0, y = 1, rotation = "east"},
{type = "railgun_l", x = 4, y = 1, rotation = "east"},
{type = "railgun_l", x = 8, y = 1, rotation = "east"},
{type = "armor_plates", x = 3, y = 0, rotation = "east"},
{type = "armor_plates", x = 5, y = 0, rotation = "east"},
{type = "armor_plates", x = 2, y = 4, rotation = "east"},
{type = "armor_plates", x = 7, y = 4, rotation = "east"},
{type = "railgun_s", x = 7, y = 0, rotation = "east"},
]
[ship.schematic]
materials = [{item = "dreadnought_hull", amount = 1}]
production_time_seconds = 8
[ship.health]
hp = 24000
[ship.movement]
speed_mps = 10
main_acceleration_mpss = 8
maneuvering_acceleration_mpss = 4
angular_acceleration_radpss = 1
max_rotation_speed_radps = 0.5
[ship.sensor]
sensor_range_m = 300
# Carrier — 37 cells: the top flight deck (rows 0-1) is the only place wide
# enough for the 2x6 drone hangar, and exactly one fits. The middle deck row
# is broken up by elevator shafts (the X cells) so no 3x3 l gun can ever fit;
# the lower decks hold supports and 2x2 point-defense m guns.
[[ship]]
id = "carrier"
layout = [
"XOOOOOOOOX",
"OOOOOOOOOO",
"OOXOOXOOXO",
"XOOOOOOOOX",
"XXXOOOOXXX",
]
default_modules = [
{type = "drone_hangar", x = 2, y = 0, rotation = "east"},
{type = "railgun_m", x = 3, y = 2, rotation = "east"},
{type = "railgun_m", x = 6, y = 2, rotation = "east"},
{type = "armor_plates", x = 0, y = 1, rotation = "east"},
{type = "armor_plates", x = 8, y = 1, rotation = "east"},
{type = "sensor_booster", x = 3, y = 4, rotation = "east"},
]
[ship.schematic]
materials = [{item = "carrier_hull", amount = 1}]
production_time_seconds = 8
[ship.health]
hp = 24000
[ship.movement]
speed_mps = 10
main_acceleration_mpss = 8
maneuvering_acceleration_mpss = 4
angular_acceleration_radpss = 1
max_rotation_speed_radps = 0.5
[ship.sensor]
sensor_range_m = 350

View File

@@ -0,0 +1,38 @@
# stations.toml
#
# Combat-pass anchors (see docs/balancing/targets.md, "Combat anchors"):
# a fresh player defence station holds one early parity wave unaided; the
# enemy station at level 0 matches the player station exactly and scales
# with the push level x. Station scrap drops stay authored (pushing rewards
# are tuned independently of ship production costs, REQ-RES-DEBRIS-DROP).
[hq]
surface_mask = [
"AAA",
"AAA",
"AAA",
]
hp_formula = "5000"
[player_station]
surface_mask = [
"SS",
"SS",
]
level = 1
hp_formula = "3000"
damage_formula = "25"
range_m_formula = "120"
fire_rate_hz_formula = "1"
scrap_drop_formula = "40"
[enemy_station]
surface_mask = [
"SS",
"SS",
]
hp_formula = "3000 + 1500*x"
damage_formula = "25 + 12*x"
range_m_formula = "120"
fire_rate_hz_formula = "1.0 + 0.1*x"
scrap_drop_formula = "40 + 30*x"

View File

@@ -0,0 +1,140 @@
# Unlock groups (REQ-LOCK-EXPLICIT, REQ-DEF-SCHEMATIC-DROP).
#
# Each [[unlock]] is a group of ships/modules/buildings/recipes awarded together
# from a single defence station drop. Anything NOT granted by any group is
# available from game start. `station_level` gates when a group becomes eligible;
# `requires` lists prerequisite unlock-group ids (REQ-LOCK-PREREQ).
#
# Most entries below are single-item groups that reproduce the previous per-item
# progression. The salvage_operations and reprocessing groups are the grouped
# unlocks: they lock the salvager module + salvage bay, and the reprocessing
# plant, from game start.
# --- Grouped unlocks -------------------------------------------------------
[[unlock]]
id = "salvage_operations"
station_level = 1
modules = ["salvager"]
buildings = ["salvage_bay"]
[[unlock]]
id = "reprocessing"
station_level = 2
buildings = ["reprocessing_plant"]
# --- Ships -----------------------------------------------------------------
[[unlock]]
id = "destroyer"
station_level = 0
ships = ["destroyer"]
[[unlock]]
id = "cruiser"
station_level = 2
ships = ["cruiser"]
[[unlock]]
id = "battlecruiser"
station_level = 4
requires = ["cruiser"]
ships = ["battlecruiser"]
[[unlock]]
id = "battleship"
station_level = 6
requires = ["battlecruiser"]
ships = ["battleship"]
[[unlock]]
id = "dreadnought"
station_level = 8
requires = ["battleship"]
ships = ["dreadnought"]
[[unlock]]
id = "carrier"
station_level = 9
requires = ["battleship"]
ships = ["carrier"]
# --- Modules ---------------------------------------------------------------
[[unlock]]
id = "repair_tool"
station_level = 0
modules = ["repair_tool"]
[[unlock]]
id = "armor_plates"
station_level = 0
modules = ["armor_plates"]
[[unlock]]
id = "maneuvering_thrusters"
station_level = 1
modules = ["maneuvering_thrusters"]
[[unlock]]
id = "sensor_booster"
station_level = 1
modules = ["sensor_booster"]
[[unlock]]
id = "railgun_m"
station_level = 2
modules = ["railgun_m"]
[[unlock]]
id = "afterburner"
station_level = 2
modules = ["afterburner"]
[[unlock]]
id = "weapon_stabilizer"
station_level = 3
modules = ["weapon_stabilizer"]
[[unlock]]
id = "weapon_upgrade"
station_level = 4
modules = ["weapon_upgrade"]
[[unlock]]
id = "weapon_primer"
station_level = 4
modules = ["weapon_primer"]
[[unlock]]
id = "drone_bay"
station_level = 5
modules = ["drone_bay"]
[[unlock]]
id = "railgun_l"
station_level = 6
requires = ["railgun_m"]
modules = ["railgun_l"]
[[unlock]]
id = "drone_hangar"
station_level = 9
modules = ["drone_hangar"]
# --- Assembler recipes -----------------------------------------------------
[[unlock]]
id = "direct_rolling"
station_level = 1
recipes = ["direct_rolling"]
[[unlock]]
id = "direct_etching"
station_level = 2
recipes = ["direct_etching"]
[[unlock]]
id = "direct_hardening"
station_level = 2
recipes = ["direct_hardening"]

View File

@@ -0,0 +1,380 @@
# visuals.toml
#
# Rendering-only configuration, loaded once at startup by the UI.
# The simulation does not read this file.
#
# Every BuildingType, ItemType, ship role, and station type must have an
# entry here; missing or malformed entries abort startup (see architecture.md
# "Visual Parameters").
#
# Colors are "#rrggbb" or "#rrggbbaa" (alpha optional, last byte).
# -----------------------------------------------------------------------------
# Tile backgrounds
# -----------------------------------------------------------------------------
[tiles.asteroid]
fill = "#4a4038"
[tiles.space]
fill = "#0a0a15"
# -----------------------------------------------------------------------------
# Buildings
#
# One section per BuildingType enum value. `fill` colors every footprint tile
# of the building; `outline` is drawn around the building's bounding box;
# `glyph` is a short label drawn centered on the building.
# -----------------------------------------------------------------------------
[buildings.hq]
fill = "#2e5fb8"
outline = "#ffffff"
glyph = "HQ"
[buildings.miner]
fill = "#6b4a2c"
outline = "#ffffff"
glyph = "M"
[buildings.smelter]
fill = "#b85a1e"
outline = "#ffffff"
glyph = "Sm"
[buildings.assembler]
fill = "#3a6fa8"
outline = "#ffffff"
glyph = "A"
[buildings.reprocessing_plant]
fill = "#6a3a8a"
outline = "#ffffff"
glyph = "R"
[buildings.shipyard]
fill = "#385870"
outline = "#ffffff"
glyph = "Y"
[buildings.salvage_bay]
fill = "#b8a23a"
outline = "#ffffff"
glyph = "Sb"
[buildings.belt]
fill = "#1a1a1a"
outline = "#7a7a7a"
glyph = ""
[buildings.splitter]
fill = "#7a7a5a"
outline = "#9a9a7a"
glyph = ""
[buildings.tunnel_entry]
fill = "#4a6a5a"
outline = "#8aaa9a"
glyph = "Te"
[buildings.tunnel_exit]
fill = "#5a6a4a"
outline = "#9aaa8a"
glyph = "Tx"
# -----------------------------------------------------------------------------
# Stations
#
# Player and enemy defence stations have their own entries rather than going
# through [buildings.*] so the two sides can be color-coded at a glance.
# -----------------------------------------------------------------------------
[stations.player]
fill = "#2e5fb8"
outline = "#ffffff"
glyph = "P"
[stations.enemy]
fill = "#b82e2e"
outline = "#ffffff"
glyph = "E"
# -----------------------------------------------------------------------------
# Items
#
# Items on belts are 10x10 squares. `fill` is the square color; `outline` is
# drawn around it. One section per ItemType.
# -----------------------------------------------------------------------------
# --- ores ---
[items.iron_ore]
fill = "#47271c"
outline = "#a65b42"
[items.copper_ore]
fill = "#deb592"
outline = "#826a56"
[items.quartz]
fill = "#4f3d66"
outline = "#9776c4"
# --- smelted basics ---
[items.iron_ingot]
fill = "#535357"
outline = "#97979e"
[items.copper_ingot]
fill = "#663810"
outline = "#b07746"
[items.silicon]
fill = "#737f99"
outline = "#373f4f"
# --- salvage loop ---
[items.scrap]
fill = "#998e81"
outline = "#574b3d"
[items.voidsteel]
fill = "#82759c"
outline = "#3f374f"
# --- basic components ---
[items.copper_wire]
fill = "#e09a50"
outline = "#3a2008"
[items.steel_plate]
fill = "#202836"
outline = "#54698c"
[items.copper_coil]
fill = "#cca287"
outline = "#755d4d"
[items.building_block]
fill = "#544724"
outline = "#a18845"
# --- advanced components ---
[items.control_chip]
fill = "#74b08b"
outline = "#42634e"
[items.capacitor_bank]
fill = "#d0a030"
outline = "#302408"
[items.hardened_steel]
fill = "#818999"
outline = "#3e4859"
[items.ceramic_plate]
fill = "#e0d8c8"
outline = "#3a3428"
[items.drive_unit]
fill = "#92a4de"
outline = "#545f80"
# --- capital components ---
[items.voidsteel_plate]
fill = "#9986b5"
outline = "#544766"
[items.capital_core]
fill = "#420c52"
outline = "#9444ab"
# --- module items ---
[items.railgun_s_module]
fill = "#bf7e7e"
outline = "#664343"
[items.railgun_m_module]
fill = "#b07474"
outline = "#593b3b"
[items.railgun_l_module]
fill = "#b07474"
outline = "#593b3b"
[items.salvager_module]
fill = "#b2cfdd"
outline = "#236137"
[items.repair_tool_module]
fill = "#0b4347"
outline = "#38868c"
[items.armor_plates_module]
fill = "#999999"
outline = "#545454"
[items.sensor_booster_module]
fill = "#a0c9f2"
outline = "#607991"
[items.maneuvering_thrusters_module]
fill = "#92b4de"
outline = "#566982"
[items.afterburner_module]
fill = "#1d2e52"
outline = "#486db5"
[items.weapon_upgrade_module]
fill = "#ff4040"
outline = "#401010"
[items.weapon_primer_module]
fill = "#e69797"
outline = "#855858"
[items.weapon_stabilizer_module]
fill = "#c78383"
outline = "#6e4848"
[items.drone_bay_module]
fill = "#cc66ff"
outline = "#331040"
[items.drone_hangar_module]
fill = "#8c689e"
outline = "#42314a"
# --- ship hulls (outline matches the ship's fleet color in [ships.*]) ---
[items.drone_hull]
fill = "#1b1b1b"
outline = "#3366ff"
[items.frigate_hull]
fill = "#1b1b1b"
outline = "#44aaff"
[items.destroyer_hull]
fill = "#1b1b1b"
outline = "#33ccaa"
[items.cruiser_hull]
fill = "#1b1b1b"
outline = "#66cc33"
[items.battlecruiser_hull]
fill = "#1b1b1b"
outline = "#cccc33"
[items.battleship_hull]
fill = "#1b1b1b"
outline = "#ff9933"
[items.dreadnought_hull]
fill = "#1b1b1b"
outline = "#ff5533"
[items.carrier_hull]
fill = "#310f42"
outline = "#8542a6"
# -----------------------------------------------------------------------------
# Ships
#
# Ships are drawn as oriented triangles/arrows. Color is keyed to schematic id.
# -----------------------------------------------------------------------------
[ships.drone]
fill = "#3366ff"
outline = "#ffffff"
[ships.frigate]
fill = "#44aaff"
outline = "#ffffff"
[ships.destroyer]
fill = "#33ccaa"
outline = "#ffffff"
[ships.cruiser]
fill = "#66cc33"
outline = "#ffffff"
[ships.battlecruiser]
fill = "#cccc33"
outline = "#ffffff"
[ships.battleship]
fill = "#ff9933"
outline = "#ffffff"
[ships.dreadnought]
fill = "#ff5533"
outline = "#ffffff"
[ships.carrier]
fill = "#cc66ff"
outline = "#ffffff"
# -----------------------------------------------------------------------------
# Laser beams (REQ-SHP-FIRING-BEAM)
# -----------------------------------------------------------------------------
[beams]
weapon_color = "#ff6600"
repair_color = "#33ff66"
salvage_color = "#33ccff"
width_px = 2
# -----------------------------------------------------------------------------
# Build / deconstruct / selection overlays
#
# All overlay colors carry an alpha channel so they composite over the
# underlying scene.
# -----------------------------------------------------------------------------
[overlays]
ghost_valid = "#ffffff44" # builder-mode ghost, placement allowed (REQ-BLD-GHOST)
ghost_invalid = "#ff000044" # builder-mode ghost, placement invalid (REQ-BLD-PLACE-VALID)
deconstruct_tint = "#ff000033" # deconstruct-mode hover tint; its RGB also draws the
# box-drag rectangle in deconstruct mode, opaque
# (REQ-UI-MULTI-SELECT, REQ-BLD-DECONSTRUCT-BOX)
tile_highlight = "#ffffff22" # tile under cursor
selected_outline = "#ffff00" # outline around currently-selected building(s), and the
# box-drag selection rectangle (REQ-UI-MULTI-SELECT)
config_transfer = "#33ccff66" # blueprint ghost over a configuration-transfer target (REQ-UI-BLUEPRINT-TRANSFER)
locked_asteroid = "#0000007f" # tint over the asteroid left of the buildable edge (not yet unlocked by expansion)
next_expansion = "#00000040" # lighter tint over the columns the next expansion unlocks (REQ-UI-LOCKED-ASTEROID)
modal_dim = "#00000099" # semi-transparent black dim behind modal dialogs/menus (REQ-UI-MODAL-DIM)
tunnel_preview = "#00ff0055" # tunnel connection preview: matched end + tiles between (REQ-BLD-TUNNEL-MODE)
# -----------------------------------------------------------------------------
# Schematic-drop toasts (REQ-UI-SCHEMATIC-TOAST)
# -----------------------------------------------------------------------------
[toast]
bg = "#000000cc"
fg = "#ffffff"
font_size = 14
# -----------------------------------------------------------------------------
# Building status light (REQ-UI-STATUS-LIGHT)
#
# Fill color per production state, drawn as a small circle in the building's
# upper-right corner, plus the constant outline color.
# -----------------------------------------------------------------------------
[status_light]
grey = "#808080" # no recipe/schematic selected
green = "#33cc33" # producing (Salvage Bay: holding scrap)
red = "#cc3333" # idle, input missing (Salvage Bay: empty)
yellow = "#e6c619" # idle, output buffer full
outline = "#000000"

View File

@@ -0,0 +1,59 @@
[world]
height_tiles = 30
refund_percentage = 100
deconstruction_time_seconds = 0.1
starting_building_blocks = 200
debris_despawn_seconds = 120
scrap_per_threat = 0.25
tile_size_m = 10
belt_speed_mps = 20
tunnel_max_distance_tiles = 10
departure_interval_seconds = 20
orbit_factor = 0.8
rally_orbit_radius_tiles = 5.0
artifact_tooltip = "Artifacts are the key to victory. Earn one by choosing the artifact reward when you destroy a set of enemy defence stations. Collect enough of them to win the game."
[regions]
asteroid_width_tiles = 40
player_buffer_width_tiles = 20
contest_zone_width_tiles = 60
enemy_buffer_width_tiles = 20
[scroll]
# View pan speed (REQ-UI-SCROLL-SPEED): slow near the asteroid, fast across the
# contest zone, with a linear ramp of the given width straddling each boundary.
pan_speed_slow_tiles_per_second = 16.0
pan_speed_fast_tiles_per_second = 32.0
pan_ramp_band_width_tiles = 16
[expansion]
columns_per_expansion_tiles = 10
# x = expansions already purchased; ~1 per cycle mid-game, decelerating
# to 2-3 cycles late (docs/balancing/derived.md).
cost_building_blocks_formula = "300 + 50*x + 10*x*x"
[push]
push_expand_columns_tiles = 10
boss_advance_seconds = 60
[targeting]
target_score_formula = "1 / (1 + x)" # x = distance / max weapon range; higher = better, clamped to >=0
overclaim_penalty_formula = "max(0.5, 1 - 0.1*x)" # x = competing claim count; multiplies score, clamped to [0,1]
target_hysteresis = 0.40 # keep current target unless a challenger beats it by >10%
[artifacts]
artifact_chance_formula = "0.05 * x" # 5% chance per station level
artifact_win_count = 5
[waves]
# Tuned against the factory-size curve (docs/balancing/targets.md, balancing
# targets): stays below the player's achievable military output early,
# crosses it around the late boundary (~cycle 15), overwhelms by ~24.
threat_rate_formula = "2*x + 0.15*x*x"
gap_min_seconds = 15
gap_max_seconds = 45
spawn_duration_seconds = 10
boss_countdown_seconds = 300
boss_threat_duration_seconds = 60
boss_quiet_before_seconds = 20
boss_quiet_after_seconds = 20

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<rect width="100" height="100" rx="22" fill="#3a6fa8"/>
<g transform="translate(13,13) scale(2.3125)" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="16" cy="16" r="6.2"/><circle cx="16" cy="16" r="2.2"/><path d="M16 5.5v3M16 23.5v3M5.5 16h3M23.5 16h3M8.6 8.6 10.7 10.7M23.4 8.6 21.3 10.7M8.6 23.4 10.7 21.3M23.4 23.4 21.3 21.3"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 499 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<rect width="100" height="100" rx="22" fill="#6a6a6a"/>
<g transform="translate(13,13) scale(2.3125)" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="4" y="10" width="24" height="12" rx="3"/><path d="M9 13 12.5 16 9 19"/><path d="M14.5 13 18 16 14.5 19"/><path d="M20 13 23.5 16 20 19"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 453 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<rect width="100" height="100" rx="22" fill="#cc3333"/>
<g transform="translate(13,13) scale(2.3125)" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M7 10h18"/><path d="M9.5 10 11 26h10l1.5-16"/><path d="M13 10V6.5h6V10"/><path d="M13.5 14v8M18.5 14v8"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 421 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<rect width="100" height="100" rx="22" fill="#2e5fb8"/>
<g transform="translate(13,13) scale(2.3125)" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="7" y="15" width="18" height="11" rx="1"/><path d="M16 15V5"/><path d="M16 6h6l-2.2 2 2.2 2h-6"/><path d="M11 19h2.5M15.5 19h2.5M20 19h1.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 456 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<rect width="100" height="100" rx="22" fill="#6b4a2c"/>
<g transform="translate(13,13) scale(2.3125)" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="9" y="5" width="14" height="8" rx="1.5"/><path d="M11 13 16 26 21 13"/><path d="M13 17.5h6"/><path d="M14.5 21.5h3"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 433 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<rect width="100" height="100" rx="22" fill="#6a3a8a"/>
<g transform="translate(13,13) scale(2.3125)" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="18" r="4"/><circle cx="20" cy="18" r="4"/><path d="M10.6 16.6 13.4 19.4M13.4 16.6 10.6 19.4M18.6 16.6 21.4 19.4M21.4 16.6 18.6 19.4"/><path d="M16 4v8"/><path d="M13 9 16 12 19 9"/><path d="M16 24v4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 528 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<rect width="100" height="100" rx="22" fill="#b8a23a"/>
<g transform="translate(13,13) scale(2.3125)" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M7 13h18l-2 13H9z"/><path d="M16 3v7"/><path d="M12 7 16 11 20 7"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 383 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<rect width="100" height="100" rx="22" fill="#3f6580"/>
<g transform="translate(13,13) scale(2.3125)" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 4c2 3 2.6 6 2.6 9.5V16h-5.2v-2.5C13.4 10 14 7 16 4z"/><circle cx="16" cy="10" r="1.3"/><path d="M13.4 15 11 18h2.4z"/><path d="M18.6 15 21 18h-2.4z"/><path d="M14.7 17 16 21 17.3 17"/><path d="M9 25h14"/><path d="M12 25v-2M20 25v-2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 554 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<rect width="100" height="100" rx="22" fill="#b85a1e"/>
<g transform="translate(13,13) scale(2.3125)" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 9h14l-2.5 8h-9z"/><path d="M8 9h16"/><path d="M11 26c-1-1.8.4-3 .4-4 .9 1 1.6 2.2 1.6 4"/><path d="M16 26c-1-2 .4-3.4.4-4.4 .9 1 1.6 2.6 1.6 4.4"/><path d="M21 26c-1-1.8.4-3 .4-4 .9 1 1.6 2.2 1.6 4"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 519 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<rect width="100" height="100" rx="22" fill="#7a7a5a"/>
<g transform="translate(13,13) scale(2.3125)" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 16h11"/><path d="M16 8v16"/><path d="M13.5 10.5 16 8 18.5 10.5"/><path d="M13.5 21.5 16 24 18.5 21.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 422 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<rect width="100" height="100" rx="22" fill="#55606f"/>
<g transform="translate(13,13) scale(2.3125)" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 4 26 8v7c0 7-5 11-10 13-5-2-10-6-10-13V8z"/><path d="M16 11v9M11.5 15.5h9"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 396 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<rect width="100" height="100" rx="22" fill="#4f7562"/>
<g transform="translate(13,13) scale(2.3125)" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 13H29"/><path d="M4 7H8Q10 7 10 9.5V21.5Q10 24 12.5 24H19.5Q22 24 22 21.5V9.5Q22 7 24 7H27"/><path d="M24.5 4.5 27 7 24.5 9.5"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 447 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="11.6 19.6 76.9 76.9" width="100" height="100">
<rect x="34" y="24" width="32" height="26" rx="5" fill="#6080c0" stroke="#182030" stroke-width="3.5"/>
<path d="M36 50 L64 50 L58 62 L42 62 Z" fill="#3a5090" stroke="#182030" stroke-width="2.5" stroke-linejoin="round"/>
<path d="M42 62 Q46 80 50 62 Q54 80 58 62 Q56 90 50 92 Q44 90 42 62 Z" fill="#ff9a3a"/>
<path d="M47 64 Q50 82 53 64 Z" fill="#ffe08a"/>
</svg>

After

Width:  |  Height:  |  Size: 468 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="10.5 11.5 79.0 79.0" width="100" height="100">
<path d="M50 16 L80 26 L80 50 Q80 74 50 86 Q20 74 20 50 L20 26 Z" fill="#808080" stroke="#202020" stroke-width="3.5" stroke-linejoin="round"/>
<path d="M50 16 L50 86" stroke="#5a5a5a" stroke-width="3"/>
<path d="M20 42 Q50 52 80 42" fill="none" stroke="#5a5a5a" stroke-width="3"/>
</svg>

After

Width:  |  Height:  |  Size: 390 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="7.1 6.1 85.9 85.9" width="100" height="100">
<path d="M50 12 L66 40 L88 60 L64 58 L60 86 L40 86 L36 58 L12 60 L34 40 Z" fill="#9fb3c9" stroke="#232a33" stroke-width="4" stroke-linejoin="round"/>
<circle cx="50" cy="40" r="7" fill="#cccc33" stroke="#232a33" stroke-width="2.5"/>
<g fill="#3a4450"><circle cx="44" cy="82" r="3.2"/><circle cx="56" cy="82" r="3.2"/></g>
</svg>

After

Width:  |  Height:  |  Size: 429 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="9.2 9.2 81.6 81.6" width="100" height="100">
<path d="M50 14 L70 44 L82 78 L60 70 L58 86 L42 86 L40 70 L18 78 L30 44 Z" fill="#9fb3c9" stroke="#232a33" stroke-width="4" stroke-linejoin="round"/>
<circle cx="50" cy="44" r="7" fill="#ff9933" stroke="#232a33" stroke-width="2.5"/>
<g fill="#3a4450"><circle cx="44" cy="82" r="3.2"/><circle cx="56" cy="82" r="3.2"/></g>
</svg>

After

Width:  |  Height:  |  Size: 429 B

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="14.9 15.9 70.2 70.2" width="100" height="100">
<g stroke="#302810" stroke-width="3.2" stroke-linejoin="round">
<path d="M50 20 L78 36 L50 52 L22 36 Z" fill="#ddc98f"/>
<path d="M50 52 L78 36 L78 66 L50 82 Z" fill="#b39a5c"/>
<path d="M50 52 L22 36 L22 66 L50 82 Z" fill="#c8b070"/>
</g>
<g stroke="#302810" stroke-width="1.8" stroke-linejoin="round">
<path d="M50 26.4 L66.8 36 L50 45.6 L33.2 36 Z" fill="#cbb772"/>
<path d="M55.3 54.7 L72.7 44.7 L72.7 63.3 L55.3 73.3 Z" fill="#a2894f"/>
<path d="M44.7 54.7 L27.3 44.7 L27.3 63.3 L44.7 73.3 Z" fill="#b7a465"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 655 B

View File

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="17.0 16.0 65.9 65.9" width="100" height="100">
<g stroke="#302408" stroke-width="3.2" stroke-linecap="round">
<line x1="37" y1="20" x2="37" y2="30"/>
<line x1="63" y1="20" x2="63" y2="30"/>
</g>
<g stroke="#302408" stroke-width="3.2" stroke-linejoin="round">
<rect x="28" y="28" width="18" height="50" rx="8" fill="#d0a030"/>
<rect x="54" y="28" width="18" height="50" rx="8" fill="#e0b040"/>
</g>
<line x1="30" y1="42" x2="44" y2="42" stroke="#6b5410" stroke-width="2.6"/>
<line x1="56" y1="42" x2="70" y2="42" stroke="#6b5410" stroke-width="2.6"/>
</svg>

After

Width:  |  Height:  |  Size: 634 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="15.5 17.5 68.9 68.9" width="100" height="100">
<circle cx="50" cy="52" r="30" fill="#b040d0" stroke="#280c30" stroke-width="4"/>
<path d="M50 26 L68 46 L50 78 L32 46 Z" fill="#d06ae8" stroke="#280c30" stroke-width="2.5" stroke-linejoin="round"/>
<circle cx="43" cy="44" r="6" fill="#f0c0f8"/>
</svg>

After

Width:  |  Height:  |  Size: 355 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="13.4 17.4 73.1 73.1" width="100" height="100">
<path d="M28 24 L72 24 L82 50 L72 84 L28 84 L18 50 Z" fill="#9fb3c9" stroke="#232a33" stroke-width="4" stroke-linejoin="round"/>
<line x1="50" y1="26" x2="50" y2="82" stroke="#4a5560" stroke-width="4"/>
<circle cx="50" cy="38" r="6" fill="#cc66ff" stroke="#232a33" stroke-width="2.5"/>
</svg>

After

Width:  |  Height:  |  Size: 395 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="15.8 15.8 68.4 68.4" width="100" height="100">
<path d="M50 20 L76 35 L76 65 L50 80 L24 65 L24 35 Z" fill="#e0d8c8" stroke="#3a3428" stroke-width="3.5" stroke-linejoin="round"/>
<path d="M50 32 L64 40 L64 60 L50 68 L36 60 L36 40 Z" fill="#efe9dd" stroke="#3a3428" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 343 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="15.5 15.5 68.9 68.9" width="100" height="100">
<rect x="20" y="20" width="60" height="60" rx="11" fill="#1f7a44" stroke="#0e3320" stroke-width="4"/>
<rect x="33" y="33" width="34" height="34" rx="5" fill="#3fbf6a" stroke="#0e3320" stroke-width="3"/>
<path d="M50 33 L50 24 M50 67 L50 76 M33 50 L24 50 M67 50 L76 50" stroke="#186036" stroke-width="4" stroke-linecap="round"/>
<circle cx="50" cy="50" r="6" fill="#c9a227" stroke="#0e3320" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 520 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0.0 2.0 100.0 100.0" width="100" height="100">
<circle cx="50" cy="52" r="42" fill="none" stroke="#3a1e08" stroke-width="2.5"/>
<circle cx="50" cy="52" r="26" fill="none" stroke="#3a1e08" stroke-width="2.5"/>
<circle cx="50" cy="52" r="34" fill="none" stroke="#cf7a2c" stroke-width="16"/>
<circle cx="50" cy="52" r="34" fill="none" stroke="#8a4e18" stroke-width="16" stroke-dasharray="3 11"/>
<circle cx="50" cy="52" r="34" fill="none" stroke="#eaa85f" stroke-width="16" stroke-dasharray="2 12" stroke-dashoffset="6"/>
</svg>

After

Width:  |  Height:  |  Size: 585 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="24.0 28.0 62.0 62.0" width="100" height="100">
<g stroke="#3a1e08" stroke-width="3.5" stroke-linejoin="round">
<path d="M28 72 L72 72 L64 54 L36 54 Z" fill="#cf7a2c"/>
<path d="M64 54 L72 72 L82 64 L74 46 Z" fill="#a85e20"/>
<path d="M36 54 L64 54 L74 46 L46 46 Z" fill="#e59a52"/>
</g>
<path d="M40 63 L60 63" stroke="#f0b878" stroke-width="3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 444 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="11.5 11.5 79.0 79.0" width="100" height="100">
<path d="M50 16 L76 28 L86 54 L70 84 L36 86 L16 58 L24 30 Z" fill="#5f8f78" stroke="#16241d" stroke-width="3.5" stroke-linejoin="round"/>
<path d="M50 16 L58 47 L24 30 Z" fill="#79a890"/>
<path d="M58 47 L70 84 L36 86 Z" fill="#4a7060"/>
<path d="M50 16 L58 47 M58 47 L86 54 M58 47 L70 84 M58 47 L36 86 M58 47 L24 30" fill="none" stroke="#2c463a" stroke-width="2.2" stroke-linecap="round"/>
<circle cx="41" cy="56" r="4" fill="#d98a3e"/>
<circle cx="62" cy="66" r="3.2" fill="#d98a3e"/>
</svg>

After

Width:  |  Height:  |  Size: 602 B

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="8.1 4.1 83.7 83.7" width="100" height="100">
<g fill="none" stroke-linecap="round">
<path d="M16 38 q17 -12 34 0 t34 0" stroke="#3a1e08" stroke-width="10"/>
<path d="M16 52 q17 -12 34 0 t34 0" stroke="#3a1e08" stroke-width="10"/>
<path d="M16 66 q17 -12 34 0 t34 0" stroke="#3a1e08" stroke-width="10"/>
<path d="M16 38 q17 -12 34 0 t34 0" stroke="#cf7a2c" stroke-width="6"/>
<path d="M16 52 q17 -12 34 0 t34 0" stroke="#d98a3e" stroke-width="6"/>
<path d="M16 66 q17 -12 34 0 t34 0" stroke="#cf7a2c" stroke-width="6"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 608 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="9.2 8.2 81.6 81.6" width="100" height="100">
<path d="M50 14 L62 40 L86 58 L64 56 L60 84 L40 84 L36 56 L14 58 L38 40 Z" fill="#9fb3c9" stroke="#232a33" stroke-width="4" stroke-linejoin="round"/>
<circle cx="50" cy="40" r="6.5" fill="#66cc33" stroke="#232a33" stroke-width="2.5"/>
<g fill="#3a4450"><circle cx="45" cy="80" r="3"/><circle cx="55" cy="80" r="3"/></g>
</svg>

After

Width:  |  Height:  |  Size: 427 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="9.2 8.2 81.6 81.6" width="100" height="100">
<path d="M50 14 L62 40 L86 58 L64 56 L60 84 L40 84 L36 56 L14 58 L38 40 Z" fill="#9fb3c9" stroke="#232a33" stroke-width="4" stroke-linejoin="round"/>
<circle cx="50" cy="40" r="6.5" fill="#33ccaa" stroke="#232a33" stroke-width="2.5"/>
</svg>

After

Width:  |  Height:  |  Size: 340 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="7.1 7.1 85.9 85.9" width="100" height="100">
<path d="M50 12 L72 42 L86 80 L62 72 L58 88 L42 88 L38 72 L14 80 L28 42 Z" fill="#9fb3c9" stroke="#232a33" stroke-width="4" stroke-linejoin="round"/>
<circle cx="50" cy="42" r="7.5" fill="#ff5533" stroke="#232a33" stroke-width="2.5"/>
<g fill="#3a4450"><circle cx="42" cy="82" r="3.4"/><circle cx="50" cy="84" r="3.4"/><circle cx="58" cy="82" r="3.4"/></g>
</svg>

After

Width:  |  Height:  |  Size: 464 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="9.2 17.2 81.6 81.6" width="100" height="100">
<path d="M40 22 L60 22 L58 42 L72 74 L28 74 L42 42 Z" fill="#4a6ad0" stroke="#101a38" stroke-width="3.5" stroke-linejoin="round"/>
<ellipse cx="50" cy="74" rx="22" ry="6" fill="#2a3a80" stroke="#101a38" stroke-width="2.5"/>
<path d="M40 22 L60 22" stroke="#8aa0e8" stroke-width="4" stroke-linecap="round"/>
<path d="M44 80 Q50 94 56 80" fill="#ffb347"/>
</svg>

After

Width:  |  Height:  |  Size: 464 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="16.9 15.9 66.2 66.2" width="100" height="100">
<rect x="22" y="52" width="56" height="26" rx="5" fill="#7a2a9a" stroke="#331040" stroke-width="3.5"/>
<line x1="22" y1="65" x2="78" y2="65" stroke="#4a1560" stroke-width="2.5"/>
<path d="M50 20 L61 42 L50 35 L39 42 Z" fill="#cc66ff" stroke="#331040" stroke-width="3" stroke-linejoin="round"/>
<line x1="50" y1="44" x2="50" y2="52" stroke="#331040" stroke-width="2.5"/>
</svg>

After

Width:  |  Height:  |  Size: 481 B

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="13.7 12.7 72.6 72.6" width="100" height="100">
<rect x="18" y="50" width="64" height="30" rx="5" fill="#5a1c7a" stroke="#260c33" stroke-width="3.5"/>
<line x1="18" y1="64" x2="82" y2="64" stroke="#3a1050" stroke-width="2.5"/>
<g fill="#b060e0" stroke="#260c33" stroke-width="2.5" stroke-linejoin="round">
<path d="M32 22 L40 38 L32 33 L24 38 Z"/>
<path d="M50 18 L58 34 L50 29 L42 34 Z"/>
<path d="M68 22 L76 38 L68 33 L60 38 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 512 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="20.8 19.8 58.3 58.3" width="100" height="100">
<path d="M50 24 L66 74 L50 64 L34 74 Z" fill="#8f9bb0" stroke="#232a33" stroke-width="4" stroke-linejoin="round"/>
<circle cx="50" cy="44" r="6" fill="#3366ff" stroke="#232a33" stroke-width="2.5"/>
</svg>

After

Width:  |  Height:  |  Size: 305 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="10.2 9.2 79.5 79.5" width="100" height="100">
<path d="M50 14 L74 84 L50 70 L26 84 Z" fill="#9fb3c9" stroke="#232a33" stroke-width="4" stroke-linejoin="round"/>
<circle cx="50" cy="40" r="7" fill="#44aaff" stroke="#232a33" stroke-width="2.5"/>
</svg>

After

Width:  |  Height:  |  Size: 304 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="15.5 15.5 68.9 68.9" width="100" height="100">
<rect x="20" y="26" width="60" height="48" rx="6" fill="#6a7280" stroke="#181c22" stroke-width="4"/>
<rect x="29" y="35" width="42" height="30" rx="3" fill="#7c8593" stroke="#181c22" stroke-width="2.5"/>
<g fill="#2a2f38">
<circle cx="26" cy="32" r="3.2"/><circle cx="74" cy="32" r="3.2"/>
<circle cx="26" cy="68" r="3.2"/><circle cx="74" cy="68" r="3.2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 481 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="24.0 28.0 62.0 62.0" width="100" height="100">
<g stroke="#2f343b" stroke-width="3.5" stroke-linejoin="round">
<path d="M28 72 L72 72 L64 54 L36 54 Z" fill="#a2a9b0"/>
<path d="M64 54 L72 72 L82 64 L74 46 Z" fill="#868d95"/>
<path d="M36 54 L64 54 L74 46 L46 46 Z" fill="#c8ced4"/>
</g>
<path d="M40 63 L60 63" stroke="#c2c7cd" stroke-width="3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 444 B

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="11.5 11.5 79.0 79.0" width="100" height="100">
<path d="M50 16 L76 28 L86 54 L70 84 L36 86 L16 58 L24 30 Z"
fill="#9a8a7a" stroke="#241c16" stroke-width="3.5" stroke-linejoin="round"/>
<path d="M50 16 L58 47 L24 30 Z" fill="#b4a692"/>
<path d="M58 47 L70 84 L36 86 Z" fill="#7a6b5c"/>
<path d="M50 16 L58 47 M58 47 L86 54 M58 47 L70 84 M58 47 L36 86 M58 47 L24 30"
fill="none" stroke="#4a3d31" stroke-width="2.2" stroke-linecap="round"/>
<circle cx="41" cy="56" r="4" fill="#c8752e"/>
<circle cx="62" cy="66" r="3.2" fill="#c8752e"/>
</svg>

After

Width:  |  Height:  |  Size: 618 B

View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="17.9 23.9 64.1 64.1" width="100" height="100">
<rect x="30" y="28" width="40" height="30" rx="5" fill="#5090e0" stroke="#142438" stroke-width="3.5"/>
<g fill="#3a6ab0" stroke="#142438" stroke-width="2.5" stroke-linejoin="round">
<path d="M34 58 L30 72 L44 72 L40 58 Z"/>
<path d="M60 58 L56 72 L70 72 L66 58 Z"/>
</g>
<g fill="#ffcf6a">
<path d="M33 72 L37 84 L41 72 Z"/>
<path d="M59 72 L63 84 L67 72 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 494 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="8.6 7.6 82.9 82.9" width="100" height="100">
<path d="M50 12 L70 40 L58 86 L42 86 L30 40 Z" fill="#dcd3f0" stroke="#40345a" stroke-width="3.2" stroke-linejoin="round"/>
<path d="M30 40 L70 40" stroke="#40345a" stroke-width="2.2"/>
<path d="M50 12 L50 86 M42 40 L42 86 M58 40 L58 86" stroke="#9d90c2" stroke-width="1.8" stroke-linecap="round"/>
<path d="M58 40 L70 40 L58 86 Z" fill="#c3b8e2"/>
<circle cx="45" cy="29" r="3" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 507 B

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="7.2 8.4 91.2 91.2" width="100" height="100">
<rect x="22" y="76" width="22" height="8" rx="2" fill="#3f454d" stroke="#20242a" stroke-width="3"/>
<rect x="12" y="24" width="32" height="52" rx="6" fill="#565d66" stroke="#20242a" stroke-width="3.5"/>
<rect x="42" y="30" width="46" height="10" rx="2" fill="#6b727b" stroke="#20242a" stroke-width="3.5"/>
<rect x="42" y="45" width="46" height="10" rx="2" fill="#6b727b" stroke="#20242a" stroke-width="3.5"/>
<rect x="42" y="60" width="46" height="10" rx="2" fill="#6b727b" stroke="#20242a" stroke-width="3.5"/>
<circle cx="88" cy="35" r="5.5" fill="#e23b2b" stroke="#20242a" stroke-width="2.5"/>
<circle cx="88" cy="50" r="5.5" fill="#e23b2b" stroke="#20242a" stroke-width="2.5"/>
<circle cx="88" cy="65" r="5.5" fill="#e23b2b" stroke="#20242a" stroke-width="2.5"/>
</svg>

After

Width:  |  Height:  |  Size: 884 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="9.2 8.2 89.6 89.6" width="100" height="100">
<rect x="22" y="68" width="20" height="8" rx="2" fill="#3f454d" stroke="#20242a" stroke-width="3"/>
<rect x="14" y="30" width="30" height="38" rx="6" fill="#565d66" stroke="#20242a" stroke-width="3.5"/>
<rect x="42" y="38" width="46" height="11" rx="2" fill="#6b727b" stroke="#20242a" stroke-width="3.5"/>
<rect x="42" y="51" width="46" height="11" rx="2" fill="#6b727b" stroke="#20242a" stroke-width="3.5"/>
<circle cx="88" cy="43.5" r="6" fill="#e23b2b" stroke="#20242a" stroke-width="2.5"/>
<circle cx="88" cy="56.5" r="6" fill="#e23b2b" stroke="#20242a" stroke-width="2.5"/>
</svg>

After

Width:  |  Height:  |  Size: 692 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="11.3 9.6 85.9 85.9" width="100" height="100">
<rect x="24" y="62" width="20" height="9" rx="2" fill="#3f454d" stroke="#20242a" stroke-width="3"/>
<rect x="16" y="34" width="30" height="30" rx="6" fill="#565d66" stroke="#20242a" stroke-width="3.5"/>
<rect x="42" y="43" width="44" height="12" rx="2" fill="#6b727b" stroke="#20242a" stroke-width="3.5"/>
<path d="M45 46.5 L84 46.5 M45 51.5 L84 51.5" stroke="#e0b12a" stroke-width="2.6" stroke-linecap="round"/>
<circle cx="86" cy="49" r="6.5" fill="#e23b2b" stroke="#20242a" stroke-width="2.5"/>
</svg>

After

Width:  |  Height:  |  Size: 610 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="9.3 10.3 78.4 78.4" width="100" height="100">
<line x1="30" y1="30" x2="66" y2="66" stroke="#2e9ba3" stroke-width="8" stroke-linecap="round"/>
<rect x="16" y="20" width="20" height="12" rx="3" fill="#1c6f76" stroke="#0f4247" stroke-width="2.5" transform="rotate(45 26 26)"/>
<path d="M64 64 L76 76 L72 80 L60 68 Z" fill="#bfe3e6" stroke="#0f4247" stroke-width="2"/>
<line x1="70" y1="30" x2="34" y2="66" stroke="#2e9ba3" stroke-width="8" stroke-linecap="round"/>
<circle cx="72" cy="28" r="9" fill="none" stroke="#2e9ba3" stroke-width="7"/>
<circle cx="32" cy="68" r="9" fill="none" stroke="#2e9ba3" stroke-width="7"/>
</svg>

After

Width:  |  Height:  |  Size: 687 B

View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="10.8 9.3 78.4 78.4" width="100" height="100">
<circle cx="50" cy="28" r="13" fill="#b2cfdd" stroke="#1c4a30" stroke-width="3.5"/>
<circle cx="50" cy="28" r="5" fill="#5f8f78"/>
<path d="M50 41 L50 56" stroke="#1c4a30" stroke-width="5" stroke-linecap="round"/>
<g fill="none" stroke="#5f8f78" stroke-width="6" stroke-linecap="round">
<path d="M50 54 L30 78"/>
<path d="M50 54 L50 82"/>
<path d="M50 54 L70 78"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 494 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="15.8 20.8 70.5 70.5" width="100" height="100">
<path d="M20 40 L44 30 L52 44 L74 34 L82 58 L60 66 L66 82 L38 78 L28 62 Z" fill="#8a8078" stroke="#241f1a" stroke-width="3.5" stroke-linejoin="round"/>
<circle cx="46" cy="52" r="4" fill="#241f1a"/>
<path d="M34 46 L44 60 M58 50 L66 62" stroke="#5f574f" stroke-width="2.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 407 B

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="23.2 8.7 71.5 71.5" width="100" height="100">
<path d="M28 76 L46 40 A28 28 0 0 1 76 56 Z" fill="#40a0ff" stroke="#102840" stroke-width="3.5" stroke-linejoin="round"/>
<line x1="52" y1="52" x2="70" y2="30" stroke="#102840" stroke-width="3.5" stroke-linecap="round"/>
<circle cx="71" cy="29" r="5" fill="#bfe0ff" stroke="#102840" stroke-width="2.5"/>
<g stroke="#bfe0ff" stroke-width="3" fill="none" stroke-linecap="round">
<path d="M78 20 a12 12 0 0 1 6 10"/>
<path d="M82 13 a20 20 0 0 1 8 17"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 576 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="12.4 14.4 75.3 75.3" width="100" height="100">
<circle cx="50" cy="52" r="33" fill="#33415e" stroke="#0e1420" stroke-width="3.5"/>
<line x1="35" y1="27" x2="52" y2="22" stroke="#0e1420" stroke-width="4" stroke-linecap="round"/>
<circle cx="50" cy="52" r="21" fill="none" stroke="#46567a" stroke-width="2.6"/>
<circle cx="42" cy="44" r="5" fill="#5d6f96"/>
</svg>

After

Width:  |  Height:  |  Size: 420 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="15.8 15.8 68.4 68.4" width="100" height="100">
<rect x="20" y="30" width="60" height="40" rx="5" fill="#8a92a0" stroke="#22262c" stroke-width="3.5"/>
<line x1="26" y1="38" x2="74" y2="38" stroke="#aab1bb" stroke-width="3" stroke-linecap="round"/>
<g fill="#2f343b">
<circle cx="30" cy="40" r="3"/><circle cx="70" cy="40" r="3"/>
<circle cx="30" cy="60" r="3"/><circle cx="70" cy="60" r="3"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 469 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="24.0 28.0 62.0 62.0" width="100" height="100">
<g stroke="#140a20" stroke-width="3.5" stroke-linejoin="round">
<path d="M28 72 L72 72 L64 54 L36 54 Z" fill="#3a2c52"/>
<path d="M64 54 L72 72 L82 64 L74 46 Z" fill="#291d3d"/>
<path d="M36 54 L64 54 L74 46 L46 46 Z" fill="#4d3a6e"/>
</g>
<path d="M40 63 L60 63" stroke="#8f6fc4" stroke-width="3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 444 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="15.5 15.5 68.9 68.9" width="100" height="100">
<rect x="20" y="28" width="60" height="44" rx="6" fill="#7a5aaa" stroke="#1c1038" stroke-width="4"/>
<line x1="26" y1="37" x2="74" y2="37" stroke="#a98fd0" stroke-width="3" stroke-linecap="round"/>
<g fill="#180c30">
<circle cx="30" cy="39" r="3"/><circle cx="70" cy="39" r="3"/>
<circle cx="30" cy="61" r="3"/><circle cx="70" cy="61" r="3"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 467 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="17.0 16.0 65.9 65.9" width="100" height="100">
<g stroke="#380e0e" stroke-width="3.2" stroke-linejoin="round">
<path d="M42 20 L58 20 L58 58 L50 72 L42 58 Z" fill="#e03838"/>
<rect x="40" y="56" width="20" height="22" rx="2" fill="#c9a227"/>
</g>
<line x1="41" y1="63" x2="59" y2="63" stroke="#7a5f14" stroke-width="2.5"/>
<line x1="41" y1="71" x2="59" y2="71" stroke="#7a5f14" stroke-width="2.5"/>
</svg>

After

Width:  |  Height:  |  Size: 471 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="7.1 7.1 85.9 85.9" width="100" height="100">
<circle cx="50" cy="50" r="32" fill="none" stroke="#300c0c" stroke-width="8"/>
<circle cx="50" cy="50" r="32" fill="none" stroke="#c03030" stroke-width="5"/>
<ellipse cx="50" cy="50" rx="32" ry="12" fill="none" stroke="#e06060" stroke-width="4"/>
<line x1="50" y1="14" x2="50" y2="86" stroke="#c03030" stroke-width="4"/>
<circle cx="50" cy="50" r="6" fill="#c03030" stroke="#300c0c" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 513 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="15.5 15.5 68.9 68.9" width="100" height="100">
<rect x="24" y="22" width="52" height="56" rx="10" fill="#7a1c1c" stroke="#3a0e0e" stroke-width="3.5"/>
<g fill="none" stroke="#ff6a6a" stroke-width="8" stroke-linejoin="round" stroke-linecap="round">
<path d="M34 52 L50 36 L66 52"/>
<path d="M34 66 L50 50 L66 66"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 389 B

View File

@@ -0,0 +1,425 @@
# balancing.toml — canonical arena suite for the combat stats pass.
#
# Ship counts are chosen so both teams have (near-)equal total threat,
# using the verified fitted threat values from tools/threat_report.py:
# drone 10.5, frigate 47, destroyer 99, cruiser 233.5,
# battlecruiser 354.5, battleship 722.5, dreadnought 1491.5,
# carrier 1436.5, glass destroyer (8 small guns) 92, repair drone 17.
# Module arrays mirror the ships' default_modules loadouts unless a
# doctrine variant is the point of the arena.
#
# Expectations: mirror matches and equal-threat cross-tier matchups should
# be near-draws (power-per-threat rule); the two-to-one arena must be a
# decisive win for the larger team. "Cruisers vs carrier" is EXPECTED to be
# a loss for the carrier until the drone-launching capability exists — the
# hangar is 224 threat of dead weight.
# --- mirrors (sanity: symmetric outcomes, fight duration in the 30-60 s band) ---
[[arena]]
name = "Mirror: drones 20v20"
height_tiles = 10
player_buffer_width_tiles = 10
contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10
[[arena.team]]
name = "Alpha"
[[arena.team.ship]]
schematic = "drone"
count = 20
modules = [
{type = "railgun_s", x = 0, y = 0, rotation = "east"},
]
[[arena.team]]
name = "Beta"
[[arena.team.ship]]
schematic = "drone"
count = 20
modules = [
{type = "railgun_s", x = 0, y = 0, rotation = "east"},
]
[[arena]]
name = "Mirror: cruisers 6v6"
height_tiles = 10
player_buffer_width_tiles = 10
contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10
[[arena.team]]
name = "Alpha"
[[arena.team.ship]]
schematic = "cruiser"
count = 6
modules = [
{type = "railgun_m", x = 0, y = 1, rotation = "east"},
{type = "railgun_m", x = 2, y = 1, rotation = "east"},
{type = "armor_plates", x = 1, y = 0, rotation = "east"},
{type = "maneuvering_thrusters", x = 1, y = 3, rotation = "east"},
]
[[arena.team]]
name = "Beta"
[[arena.team.ship]]
schematic = "cruiser"
count = 6
modules = [
{type = "railgun_m", x = 0, y = 1, rotation = "east"},
{type = "railgun_m", x = 2, y = 1, rotation = "east"},
{type = "armor_plates", x = 1, y = 0, rotation = "east"},
{type = "maneuvering_thrusters", x = 1, y = 3, rotation = "east"},
]
[[arena]]
name = "Mirror: battleships 2v2"
height_tiles = 10
player_buffer_width_tiles = 10
contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10
[[arena.team]]
name = "Alpha"
[[arena.team.ship]]
schematic = "battleship"
count = 2
modules = [
{type = "railgun_l", x = 1, y = 0, rotation = "east"},
{type = "railgun_m", x = 1, y = 3, rotation = "east"},
{type = "railgun_m", x = 3, y = 3, rotation = "east"},
{type = "weapon_stabilizer", x = 4, y = 1, rotation = "east"},
{type = "railgun_s", x = 4, y = 0, rotation = "east"},
{type = "railgun_s", x = 0, y = 1, rotation = "east"},
]
[[arena.team]]
name = "Beta"
[[arena.team.ship]]
schematic = "battleship"
count = 2
modules = [
{type = "railgun_l", x = 1, y = 0, rotation = "east"},
{type = "railgun_m", x = 1, y = 3, rotation = "east"},
{type = "railgun_m", x = 3, y = 3, rotation = "east"},
{type = "weapon_stabilizer", x = 4, y = 1, rotation = "east"},
{type = "railgun_s", x = 4, y = 0, rotation = "east"},
{type = "railgun_s", x = 0, y = 1, rotation = "east"},
]
# --- equal-threat cross-tier matchups (power-per-threat: expect near-draws) ---
[[arena]]
name = "Drone swarm vs cruisers (462 vs 467)"
height_tiles = 10
player_buffer_width_tiles = 10
contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10
[[arena.team]]
name = "Swarm"
[[arena.team.ship]]
schematic = "drone"
count = 44
modules = [
{type = "railgun_s", x = 0, y = 0, rotation = "east"},
]
[[arena.team]]
name = "Cruisers"
[[arena.team.ship]]
schematic = "cruiser"
count = 2
modules = [
{type = "railgun_m", x = 0, y = 1, rotation = "east"},
{type = "railgun_m", x = 2, y = 1, rotation = "east"},
{type = "armor_plates", x = 1, y = 0, rotation = "east"},
{type = "maneuvering_thrusters", x = 1, y = 3, rotation = "east"},
]
[[arena]]
name = "Frigates vs battleship (705 vs 723)"
height_tiles = 10
player_buffer_width_tiles = 10
contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10
[[arena.team]]
name = "Frigates"
[[arena.team.ship]]
schematic = "frigate"
count = 15
modules = [
{type = "railgun_s", x = 1, y = 0, rotation = "east"},
{type = "railgun_s", x = 2, y = 1, rotation = "east"},
{type = "maneuvering_thrusters", x = 0, y = 1, rotation = "east"},
]
[[arena.team]]
name = "Battleship"
[[arena.team.ship]]
schematic = "battleship"
count = 1
modules = [
{type = "railgun_l", x = 1, y = 0, rotation = "east"},
{type = "railgun_m", x = 1, y = 3, rotation = "east"},
{type = "railgun_m", x = 3, y = 3, rotation = "east"},
{type = "weapon_stabilizer", x = 4, y = 1, rotation = "east"},
{type = "railgun_s", x = 4, y = 0, rotation = "east"},
{type = "railgun_s", x = 0, y = 1, rotation = "east"},
]
[[arena]]
name = "Destroyers vs dreadnought (1485 vs 1492)"
height_tiles = 10
player_buffer_width_tiles = 10
contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10
[[arena.team]]
name = "Destroyers"
[[arena.team.ship]]
schematic = "destroyer"
count = 15
modules = [
{type = "railgun_s", x = 0, y = 0, rotation = "east"},
{type = "railgun_s", x = 2, y = 0, rotation = "east"},
{type = "railgun_s", x = 4, y = 0, rotation = "east"},
{type = "armor_plates", x = 0, y = 1, rotation = "east"},
{type = "sensor_booster", x = 3, y = 1, rotation = "east"},
]
[[arena.team]]
name = "Dreadnought"
[[arena.team.ship]]
schematic = "dreadnought"
count = 1
modules = [
{type = "railgun_l", x = 0, y = 1, rotation = "east"},
{type = "railgun_l", x = 4, y = 1, rotation = "east"},
{type = "railgun_l", x = 8, y = 1, rotation = "east"},
{type = "armor_plates", x = 3, y = 0, rotation = "east"},
{type = "armor_plates", x = 5, y = 0, rotation = "east"},
{type = "armor_plates", x = 2, y = 4, rotation = "east"},
{type = "armor_plates", x = 7, y = 4, rotation = "east"},
{type = "railgun_s", x = 7, y = 0, rotation = "east"},
]
[[arena]]
name = "Cruisers vs carrier (1401 vs 1437, carrier expected to lose)"
height_tiles = 10
player_buffer_width_tiles = 10
contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10
[[arena.team]]
name = "Cruisers"
[[arena.team.ship]]
schematic = "cruiser"
count = 6
modules = [
{type = "railgun_m", x = 0, y = 1, rotation = "east"},
{type = "railgun_m", x = 2, y = 1, rotation = "east"},
{type = "armor_plates", x = 1, y = 0, rotation = "east"},
{type = "maneuvering_thrusters", x = 1, y = 3, rotation = "east"},
]
[[arena.team]]
name = "Carrier"
[[arena.team.ship]]
schematic = "carrier"
count = 1
modules = [
{type = "drone_hangar", x = 2, y = 0, rotation = "east"},
{type = "railgun_m", x = 3, y = 2, rotation = "east"},
{type = "railgun_m", x = 6, y = 2, rotation = "east"},
{type = "armor_plates", x = 0, y = 1, rotation = "east"},
{type = "armor_plates", x = 8, y = 1, rotation = "east"},
{type = "sensor_booster", x = 3, y = 4, rotation = "east"},
]
[[arena]]
name = "Mixed mid vs battlecruisers (1394 vs 1418)"
height_tiles = 10
player_buffer_width_tiles = 10
contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10
[[arena.team]]
name = "Mixed"
[[arena.team.ship]]
schematic = "destroyer"
count = 7
modules = [
{type = "railgun_s", x = 0, y = 0, rotation = "east"},
{type = "railgun_s", x = 2, y = 0, rotation = "east"},
{type = "railgun_s", x = 4, y = 0, rotation = "east"},
{type = "armor_plates", x = 0, y = 1, rotation = "east"},
{type = "sensor_booster", x = 3, y = 1, rotation = "east"},
]
[[arena.team.ship]]
schematic = "cruiser"
count = 3
modules = [
{type = "railgun_m", x = 0, y = 1, rotation = "east"},
{type = "railgun_m", x = 2, y = 1, rotation = "east"},
{type = "armor_plates", x = 1, y = 0, rotation = "east"},
{type = "maneuvering_thrusters", x = 1, y = 3, rotation = "east"},
]
[[arena.team]]
name = "Battlecruisers"
[[arena.team.ship]]
schematic = "battlecruiser"
count = 4
modules = [
{type = "railgun_m", x = 0, y = 0, rotation = "east"},
{type = "railgun_m", x = 4, y = 0, rotation = "east"},
{type = "railgun_m", x = 2, y = 1, rotation = "east"},
{type = "armor_plates", x = 2, y = 3, rotation = "east"},
{type = "railgun_s", x = 1, y = 2, rotation = "east"},
{type = "railgun_s", x = 4, y = 2, rotation = "east"},
]
# --- asymmetric checks ---
[[arena]]
name = "Two to one (must be decisive)"
height_tiles = 10
player_buffer_width_tiles = 10
contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10
[[arena.team]]
name = "Six"
[[arena.team.ship]]
schematic = "frigate"
count = 6
modules = [
{type = "railgun_s", x = 1, y = 0, rotation = "east"},
{type = "railgun_s", x = 2, y = 1, rotation = "east"},
{type = "maneuvering_thrusters", x = 0, y = 1, rotation = "east"},
]
[[arena.team]]
name = "Three"
[[arena.team.ship]]
schematic = "frigate"
count = 3
modules = [
{type = "railgun_s", x = 1, y = 0, rotation = "east"},
{type = "railgun_s", x = 2, y = 1, rotation = "east"},
{type = "maneuvering_thrusters", x = 0, y = 1, rotation = "east"},
]
[[arena]]
name = "Armored vs glass destroyers (1188 vs 1196)"
height_tiles = 10
player_buffer_width_tiles = 10
contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10
[[arena.team]]
name = "Armored"
[[arena.team.ship]]
schematic = "destroyer"
count = 12
modules = [
{type = "railgun_s", x = 0, y = 0, rotation = "east"},
{type = "railgun_s", x = 2, y = 0, rotation = "east"},
{type = "railgun_s", x = 4, y = 0, rotation = "east"},
{type = "armor_plates", x = 0, y = 1, rotation = "east"},
{type = "sensor_booster", x = 3, y = 1, rotation = "east"},
]
[[arena.team]]
name = "Glass"
[[arena.team.ship]]
schematic = "destroyer"
count = 13
modules = [
{type = "railgun_s", x = 0, y = 0, rotation = "east"},
{type = "railgun_s", x = 2, y = 0, rotation = "east"},
{type = "railgun_s", x = 4, y = 0, rotation = "east"},
{type = "railgun_s", x = 0, y = 1, rotation = "east"},
{type = "railgun_s", x = 1, y = 1, rotation = "east"},
{type = "railgun_s", x = 2, y = 1, rotation = "east"},
{type = "railgun_s", x = 3, y = 1, rotation = "east"},
{type = "railgun_s", x = 4, y = 1, rotation = "east"},
]
[[arena]]
name = "Repair escort vs raw numbers (444 vs 444)"
height_tiles = 10
player_buffer_width_tiles = 10
contest_zone_width_tiles = 50
enemy_buffer_width_tiles = 10
[[arena.team]]
name = "Escorted"
[[arena.team.ship]]
schematic = "frigate"
count = 8
modules = [
{type = "railgun_s", x = 1, y = 0, rotation = "east"},
{type = "railgun_s", x = 2, y = 1, rotation = "east"},
{type = "maneuvering_thrusters", x = 0, y = 1, rotation = "east"},
]
[[arena.team.ship]]
schematic = "drone"
count = 4
modules = [
{type = "repair_tool", x = 0, y = 0, rotation = "east"},
]
[[arena.team]]
name = "Raw"
[[arena.team.ship]]
schematic = "frigate"
count = 9
modules = [
{type = "railgun_s", x = 1, y = 0, rotation = "east"},
{type = "railgun_s", x = 2, y = 1, rotation = "east"},
{type = "maneuvering_thrusters", x = 0, y = 1, rotation = "east"},
]
[[arena.team.ship]]
schematic = "drone"
count = 2
modules = [
{type = "railgun_s", x = 0, y = 0, rotation = "east"},
]
[[arena]]
name = "Station assault (2 stations + 105 vs 315)"
height_tiles = 10
player_buffer_width_tiles = 15
contest_zone_width_tiles = 40
enemy_buffer_width_tiles = 15
[[arena.team]]
name = "Fortified"
[[arena.team.ship]]
schematic = "drone"
count = 10
modules = [
{type = "railgun_s", x = 0, y = 0, rotation = "east"},
]
[[arena.team.station]]
type = "player_station"
x = 8
y = 15
level = 1
[[arena.team.station]]
type = "player_station"
x = 8
y = 45
level = 1
[[arena.team]]
name = "Swarm"
[[arena.team.ship]]
schematic = "drone"
count = 30
modules = [
{type = "railgun_s", x = 0, y = 0, rotation = "east"},
]

View File

@@ -1,63 +0,0 @@
[[recipe]]
id = "mine_iron_ore"
building = "miner"
inputs = []
outputs = [{item = "iron_ore", amount = 1}]
duration_seconds = 1.0
[[recipe]]
id = "mine_copper_ore"
building = "miner"
inputs = []
outputs = [{item = "copper_ore", amount = 1}]
duration_seconds = 1.5
[[recipe]]
id = "iron_ingot"
building = "smelter"
inputs = [{item = "iron_ore", amount = 2}]
outputs = [{item = "iron_ingot", amount = 1}]
duration_seconds = 2.0
[[recipe]]
id = "copper_ingot"
building = "smelter"
inputs = [{item = "copper_ore", amount = 2}]
outputs = [{item = "copper_ingot", amount = 1}]
duration_seconds = 2.5
[[recipe]]
id = "circuit_board"
building = "assembler"
inputs = [{item = "iron_ingot", amount = 3}, {item = "copper_ingot", amount = 2}]
outputs = [{item = "circuit_board", amount = 1}]
duration_seconds = 5.0
[[recipe]]
id = "building_blocks"
building = "assembler"
inputs = [{item = "iron_ingot", amount = 4}]
outputs = [{item = "building_block", amount = 10}]
duration_seconds = 4.0
[[recipe]]
id = "reprocessing_cycle"
building = "reprocessing_plant"
inputs = [{item = "scrap", amount = 5}]
duration_seconds = 3.0
[[recipe.outputs]]
item = "iron_ingot"
amount = 2
probability = 0.6
[[recipe.outputs]]
item = "circuit_board"
amount = 1
probability = 0.3
[[recipe.outputs]]
item = "advanced_alloy"
amount = 1
probability = 0.1

View File

@@ -1,104 +0,0 @@
[[ship]]
id = "interceptor"
available_from_start = true
[ship.schematic]
materials = [{item = "iron_ingot", amount = 3}, {item = "circuit_board", amount = 1}]
player_production_level = 3
production_time_seconds = 10
[ship.threat]
cost_formula = "5 + 1*x"
[ship.health]
hp_formula = "40 + 5*x"
[ship.movement]
speed_formula = "200 + 5*x"
[ship.combat]
damage_formula = "10 + 2*x"
attack_range_formula = "150"
attack_rate_formula = "2.0"
[ship.loot]
scrap_drop = 2
[[ship]]
id = "destroyer"
available_from_start = true
[ship.schematic]
materials = [{item = "iron_ingot", amount = 5}, {item = "circuit_board", amount = 2}]
player_production_level = 5
production_time_seconds = 20
[ship.threat]
cost_formula = "10 + 2*x"
[ship.health]
hp_formula = "120 + 15*x"
[ship.movement]
speed_formula = "120"
[ship.combat]
damage_formula = "12 + 2*x"
attack_range_formula = "250"
attack_rate_formula = "1.0"
[ship.loot]
scrap_drop = 4
[[ship]]
id = "salvage_ship"
available_from_start = true
[ship.schematic]
materials = [{item = "iron_ingot", amount = 4}]
player_production_level = 3
production_time_seconds = 10
[ship.threat]
cost_formula = "0"
[ship.health]
hp_formula = "40 + 4*x"
[ship.movement]
speed_formula = "110"
[ship.salvage]
collection_range = 50
cargo_capacity = 10
[ship.loot]
scrap_drop = 2
[[ship]]
id = "repair_ship"
available_from_start = false
[ship.schematic]
materials = [{item = "iron_ingot", amount = 4}, {item = "circuit_board", amount = 2}]
player_production_level = 3
production_time_seconds = 15
[ship.threat]
cost_formula = "0"
[ship.health]
hp_formula = "60 + 5*x"
[ship.movement]
speed_formula = "130"
[ship.repair]
repair_rate_formula = "5 + x"
repair_range_formula = "80"
[ship.loot]
scrap_drop = 2

View File

@@ -1,30 +0,0 @@
[hq]
surface_mask = [
"AAA",
"AAA",
"AAA",
]
hp_formula = "1000"
[player_station]
surface_mask = [
"SS",
"SS",
]
level = 5
hp_formula = "300 + 40*x"
damage_formula = "5 + 4*x"
range_formula = "300 + 20*x"
fire_rate_formula = "0.5 + 0.2*x"
scrap_drop_formula = "x"
[enemy_station]
surface_mask = [
"SS",
"SS",
]
hp_formula = "300 + 150*x"
damage_formula = "20 + 10*x"
range_formula = "350 + 20*x"
fire_rate_formula = "1.0 + 0.2*x"
scrap_drop_formula = "10 + 5*x"

View File

@@ -1,194 +0,0 @@
# visuals.toml
#
# Rendering-only configuration, loaded once at startup by the UI.
# The simulation does not read this file.
#
# Every BuildingType, ItemType, ship role, and station type must have an
# entry here; missing or malformed entries abort startup (see architecture.md
# "Visual Parameters").
#
# Colors are "#rrggbb" or "#rrggbbaa" (alpha optional, last byte).
# -----------------------------------------------------------------------------
# Tile backgrounds
# -----------------------------------------------------------------------------
[tiles.asteroid]
fill = "#4a4038"
[tiles.space]
fill = "#0a0a15"
# -----------------------------------------------------------------------------
# Buildings
#
# One section per BuildingType enum value. `fill` colors every footprint tile
# of the building; `outline` is drawn around the building's bounding box;
# `glyph` is a short label drawn centered on the building.
# -----------------------------------------------------------------------------
[buildings.hq]
fill = "#2e5fb8"
outline = "#ffffff"
glyph = "HQ"
[buildings.miner]
fill = "#6b4a2c"
outline = "#ffffff"
glyph = "M"
[buildings.smelter]
fill = "#b85a1e"
outline = "#ffffff"
glyph = "Sm"
[buildings.assembler]
fill = "#3a6fa8"
outline = "#ffffff"
glyph = "A"
[buildings.reprocessing_plant]
fill = "#6a3a8a"
outline = "#ffffff"
glyph = "R"
[buildings.shipyard]
fill = "#385870"
outline = "#ffffff"
glyph = "Y"
[buildings.salvage_bay]
fill = "#b8a23a"
outline = "#ffffff"
glyph = "Sb"
[buildings.belt]
fill = "#5a5a5a"
outline = "#7a7a7a"
glyph = ""
[buildings.splitter]
fill = "#7a7a5a"
outline = "#9a9a7a"
glyph = ""
[buildings.tunnel_entry]
fill = "#4a6a5a"
outline = "#8aaa9a"
glyph = "Te"
[buildings.tunnel_exit]
fill = "#5a6a4a"
outline = "#9aaa8a"
glyph = "Tx"
# -----------------------------------------------------------------------------
# Stations
#
# Player and enemy defence stations have their own entries rather than going
# through [buildings.*] so the two sides can be color-coded at a glance.
# -----------------------------------------------------------------------------
[stations.player]
fill = "#2e5fb8"
outline = "#ffffff"
glyph = "P"
[stations.enemy]
fill = "#b82e2e"
outline = "#ffffff"
glyph = "E"
# -----------------------------------------------------------------------------
# Items
#
# Items on belts are 10x10 squares. `fill` is the square color; `outline` is
# drawn around it. One section per ItemType.
# -----------------------------------------------------------------------------
[items.iron_ore]
fill = "#8a5a4a"
outline = "#201010"
[items.copper_ore]
fill = "#c47a3a"
outline = "#3a1a0a"
[items.iron_ingot]
fill = "#b0b0b8"
outline = "#202028"
[items.copper_ingot]
fill = "#d48a4a"
outline = "#402010"
[items.circuit_board]
fill = "#2ea35a"
outline = "#0a2a14"
[items.advanced_alloy]
fill = "#a06acc"
outline = "#201030"
[items.building_block]
fill = "#c8b070"
outline = "#302810"
[items.scrap]
fill = "#7a7268"
outline = "#201a14"
# -----------------------------------------------------------------------------
# Ships
#
# Ships are drawn as oriented triangles/arrows. Color is keyed to role, not
# schematic (architecture.md, "Layer Order").
# -----------------------------------------------------------------------------
[ships.player_combat]
fill = "#3366ff"
outline = "#ffffff"
[ships.salvage]
fill = "#33cc66"
outline = "#ffffff"
[ships.repair]
fill = "#66ccff"
outline = "#ffffff"
[ships.enemy]
fill = "#cc3333"
outline = "#ffffff"
# -----------------------------------------------------------------------------
# Laser beams (REQ-SHP-FIRING-BEAM)
# -----------------------------------------------------------------------------
[beams]
color = "#ff6600"
width_px = 2
# -----------------------------------------------------------------------------
# Build / demolish / selection overlays
#
# All overlay colors carry an alpha channel so they composite over the
# underlying scene.
# -----------------------------------------------------------------------------
[overlays]
ghost_valid = "#ffffff44" # builder-mode ghost, placement allowed (REQ-BLD-GHOST)
ghost_invalid = "#ff000044" # builder-mode ghost, placement invalid (REQ-BLD-PLACE-VALID)
demolish_tint = "#ff000033" # demolish-mode hover tint
selection_rect = "#00ff00" # box-drag selection rectangle (REQ-UI-MULTI-SELECT)
tile_highlight = "#ffffff22" # tile under cursor
selected_outline = "#ffff00" # outline drawn around currently-selected building(s)
# -----------------------------------------------------------------------------
# Schematic-drop toasts (REQ-UI-SCHEMATIC-TOAST)
# -----------------------------------------------------------------------------
[toast]
bg = "#000000cc"
fg = "#ffffff"
font_size = 14

View File

@@ -1,29 +0,0 @@
[world]
height_tiles = 30
refund_percentage = 75
starting_building_blocks = 1000
scrap_despawn_seconds = 30
belt_speed_tiles_per_second = 2
tunnel_max_distance = 10
departure_interval_seconds = 20
[regions]
asteroid_width = 40
player_buffer_width = 20
contest_zone_width = 60
enemy_buffer_width = 20
[expansion]
columns_per_expansion = 10
cost_building_blocks = 200
[push]
push_expand_columns = 10
scaling_factor = 1.2
[waves]
threat_rate_formula = "0.1*x - 60"
ship_level_formula = "1"
gap_min_seconds = 15
gap_max_seconds = 45
spawn_duration_seconds = 10

View File

@@ -83,6 +83,8 @@ id = "salvage_bay"
cost = 25
player_placeable = true
construction_time_seconds = 15
output_buffer_capacity = 20
tooltip = "Drop-off point for salvage ships."
surface_mask = [
"SAA",
"SAA>",

View File

@@ -0,0 +1,119 @@
[[module]]
id = "armor_plate"
tooltip = "Adds a large flat bonus to hit points."
surface_mask = ["OO"]
materials = [{item = "iron_ingot", amount = 2}]
production_time_seconds = 3
fill_color = "#808080"
glyph = "A"
[module.health]
multiplied_hp = 1.5
[[module]]
id = "sensor_booster"
surface_mask = ["O"]
materials = [{item = "circuit_board", amount = 1}]
production_time_seconds = 2
fill_color = "#40A0FF"
glyph = "S"
[module.sensor]
added_sensor_range_m = 100
[[module]]
id = "weapon_upgrade"
surface_mask = ["O"]
materials = [{item = "iron_ingot", amount = 1}, {item = "circuit_board", amount = 1}]
production_time_seconds = 4
fill_color = "#FF4040"
glyph = "W"
[module.weapon]
multiplied_damage = 1.2
[[module]]
id = "laser_cannon"
surface_mask = ["O"]
materials = [{item = "iron_ingot", amount = 1}]
production_time_seconds = 5
fill_color = "#FF8040"
glyph = "L"
[module.weapon]
damage = 2
attack_range_m = 50
attack_rate_hz = 2.0
[[module]]
id = "salvager"
surface_mask = ["OO"]
materials = [{item = "iron_ingot", amount = 2}]
production_time_seconds = 5
fill_color = "#AACC44"
glyph = "Sv"
[module.salvage]
collection_range_m = 500
cargo_capacity = 10
collection_rate_hz = 0.5
[[module]]
id = "repair_tool"
surface_mask = ["O"]
materials = [{item = "circuit_board", amount = 2}]
production_time_seconds = 5
fill_color = "#66CCFF"
glyph = "Rp"
[module.repair]
repair_rate_hz = 1
repair_amount_hp = 6
repair_range_m = 800
[[module]]
id = "weapon_primer"
surface_mask = ["O"]
materials = [{item = "iron_ingot", amount = 1}]
production_time_seconds = 4
fill_color = "#FF4040"
glyph = "Wp"
[module.weapon]
multiplied_attack_rate_hz = 1.2
[[module]]
id = "weapon_stabilizer"
surface_mask = ["O"]
materials = [{item = "iron_ingot", amount = 1}]
production_time_seconds = 4
fill_color = "#FF4040"
glyph = "Ws"
[module.weapon]
multiplied_attack_range_m = 1.5
multiplied_attack_rate_hz = 0.8
[[module]]
id = "afterburner"
surface_mask = ["O"]
materials = [{item = "iron_ingot", amount = 1}]
production_time_seconds = 2
fill_color = "#40A0FF"
glyph = "Ab"
[module.movement]
multiplied_speed_mps = 1.6
added_main_acceleration_mpss = 60
[[module]]
id = "maneuvering_thrusters"
surface_mask = ["O"]
materials = [{item = "iron_ingot", amount = 1}]
production_time_seconds = 2
fill_color = "#40A0FF"
glyph = "Mt"
[module.movement]
multiplied_speed_mps = 1.2
added_maneuvering_acceleration_mpss = 10

View File

@@ -0,0 +1,145 @@
[[recipe]]
id = "mine_iron_ore"
building = "miner"
inputs = []
outputs = [{item = "iron_ore", amount = 1}]
duration_seconds = 1.0
[[recipe]]
id = "mine_copper_ore"
building = "miner"
inputs = []
outputs = [{item = "copper_ore", amount = 1}]
duration_seconds = 1.5
[[recipe]]
id = "iron_ingot"
building = "smelter"
inputs = [{item = "iron_ore", amount = 2}]
outputs = [{item = "iron_ingot", amount = 1}]
duration_seconds = 2.0
[[recipe]]
id = "copper_ingot"
building = "smelter"
inputs = [{item = "copper_ore", amount = 2}]
outputs = [{item = "copper_ingot", amount = 1}]
duration_seconds = 2.5
[[recipe]]
id = "circuit_board"
building = "assembler"
inputs = [{item = "iron_ingot", amount = 3}, {item = "copper_ingot", amount = 2}]
outputs = [{item = "circuit_board", amount = 1}]
duration_seconds = 5.0
[[recipe]]
id = "building_blocks"
building = "assembler"
inputs = [{item = "iron_ingot", amount = 4}]
outputs = [{item = "building_block", amount = 10}]
duration_seconds = 4.0
[[recipe]]
id = "premium_circuit"
building = "assembler"
unlocked_at_start = true
inputs = [{item = "circuit_board", amount = 1}]
outputs = [{item = "premium_circuit", amount = 1}]
duration_seconds = 8.0
[[recipe]]
id = "quick_circuit"
building = "assembler"
inputs = [{item = "copper_ingot", amount = 3}]
outputs = [{item = "circuit_board", amount = 1}]
duration_seconds = 3.0
[[recipe]]
id = "advanced_circuit"
building = "assembler"
inputs = [{item = "iron_ingot", amount = 5}]
outputs = [{item = "circuit_board", amount = 1}]
duration_seconds = 6.0
[[recipe]]
id = "exotic_alloy"
building = "assembler"
inputs = [{item = "exotic_ore", amount = 2}]
outputs = [{item = "exotic_alloy", amount = 1}]
duration_seconds = 10.0
[[recipe]]
id = "reprocessing_cycle"
building = "reprocessing_plant"
inputs = [{item = "scrap", amount = 5}]
duration_seconds = 3.0
[[recipe.output_group]]
probability = 0.6
items = [{item = "iron_ingot", amount = 2}]
[[recipe.output_group]]
probability = 0.3
items = [{item = "circuit_board", amount = 1}]
[[recipe.output_group]]
probability = 0.1
items = [{item = "advanced_alloy", amount = 1}]
# -------------------------------------------------------------------
# Extra recipes for ThreatCostCalculator unit tests (fixes 6-9)
# -------------------------------------------------------------------
# Fix 6: scrap-consuming smelter recipe for iron_ingot. Because iron_ingot
# already has a scrap-free smelter recipe above, this recipe must be excluded
# from iron_ingot's threat computation.
[[recipe]]
id = "scrap_iron"
building = "smelter"
inputs = [{item = "scrap", amount = 1}]
outputs = [{item = "iron_ingot", amount = 1}]
duration_seconds = 1.0
# Fix 7: a recipe that produces 2 items per cycle. Per-unit threat must
# divide by the output amount.
# dual_wire: (duration=3.0 + iron_ore(1.0)*1) / 2 = 4.0 / 2 = 2.0 per unit.
[[recipe]]
id = "dual_wire"
building = "assembler"
inputs = [{item = "iron_ore", amount = 1}]
outputs = [{item = "dual_wire", amount = 2}]
duration_seconds = 3.0
# Fix 8: an item downstream of a reprocessing-only item (advanced_alloy).
# advanced_alloy is resolved only by the reprocessing pass; downstream_product
# can only resolve in a non-reprocessing pass that runs AFTER the reprocessing
# pass, requiring proper fixpoint iteration.
# downstream_product: 2.0 + advanced_alloy(80.0)*1 = 82.0
[[recipe]]
id = "downstream_product"
building = "assembler"
inputs = [{item = "advanced_alloy", amount = 1}]
outputs = [{item = "downstream_product", amount = 1}]
duration_seconds = 2.0
# Fix 9: two recipes producing the same staggered_item. The cheap recipe
# resolves before circuit_board is known; the expensive one requires
# circuit_board. The item must be committed only once BOTH are computable,
# so the result is max(cheap, expensive).
# staggered_item_cheap: 1.0 + iron_ore(1.0)*1 = 2.0 (resolves early)
# staggered_item_expensive: 1.0 + circuit_board(28.0)*1 = 29.0 (resolves later)
# expected: max = 29.0
[[recipe]]
id = "staggered_item_cheap"
building = "assembler"
inputs = [{item = "iron_ore", amount = 1}]
outputs = [{item = "staggered_item", amount = 1}]
duration_seconds = 1.0
[[recipe]]
id = "staggered_item_expensive"
building = "assembler"
inputs = [{item = "circuit_board", amount = 1}]
outputs = [{item = "staggered_item", amount = 1}]
duration_seconds = 1.0

View File

@@ -0,0 +1,88 @@
[[ship]]
id = "interceptor"
layout = ["XOX", "OOO", "XOX"]
default_modules = [{type = "laser_cannon", x = 1, y = 1, rotation = "east"}]
[ship.schematic]
materials = [{item = "iron_ingot", amount = 3}, {item = "circuit_board", amount = 1}]
production_time_seconds = 10
[ship.health]
hp = 45
[ship.movement]
speed_mps = 2050
main_acceleration_mpss = 1000000
maneuvering_acceleration_mpss = 1000000
angular_acceleration_radpss = 100000
max_rotation_speed_radps = 100000
[ship.sensor]
sensor_range_m = 2000
[[ship]]
id = "destroyer"
layout = ["XOOX", "OOOO", "XOOX"]
default_modules = [{type = "laser_cannon", x = 1, y = 1, rotation = "east"}]
[ship.schematic]
materials = [{item = "iron_ingot", amount = 5}, {item = "circuit_board", amount = 2}]
production_time_seconds = 20
[ship.health]
hp = 135
[ship.movement]
speed_mps = 1200
main_acceleration_mpss = 1000000
maneuvering_acceleration_mpss = 1000000
angular_acceleration_radpss = 100000
max_rotation_speed_radps = 100000
[ship.sensor]
sensor_range_m = 3000
[[ship]]
id = "salvage_ship"
layout = ["OOO", "OOO"]
[ship.schematic]
materials = [{item = "iron_ingot", amount = 4}]
production_time_seconds = 10
[ship.health]
hp = 44
[ship.movement]
speed_mps = 1100
main_acceleration_mpss = 1000000
maneuvering_acceleration_mpss = 1000000
angular_acceleration_radpss = 100000
max_rotation_speed_radps = 100000
[ship.sensor]
sensor_range_m = 2500
[[ship]]
id = "repair_ship"
layout = ["XOX", "OOO", "XOX"]
[ship.schematic]
materials = [{item = "iron_ingot", amount = 4}, {item = "circuit_board", amount = 2}]
production_time_seconds = 15
[ship.health]
hp = 65
[ship.movement]
speed_mps = 1300
main_acceleration_mpss = 1000000
maneuvering_acceleration_mpss = 1000000
angular_acceleration_radpss = 100000
max_rotation_speed_radps = 100000
[ship.sensor]
sensor_range_m = 2500

View File

@@ -14,8 +14,8 @@ surface_mask = [
level = 5
hp_formula = "300 + 40*x"
damage_formula = "5 + 4*x"
range_formula = "300 + 20*x"
fire_rate_formula = "0.5 + 0.2*x"
range_m_formula = "3000 + 200*x"
fire_rate_hz_formula = "0.5 + 0.2*x"
scrap_drop_formula = "x"
[enemy_station]
@@ -25,6 +25,6 @@ surface_mask = [
]
hp_formula = "300 + 150*x"
damage_formula = "20 + 10*x"
range_formula = "350 + 20*x"
fire_rate_formula = "1.0 + 0.2*x"
range_m_formula = "3500 + 200*x"
fire_rate_hz_formula = "1.0 + 0.2*x"
scrap_drop_formula = "10 + 5*x"

View File

@@ -0,0 +1,21 @@
# Unlock groups for the test config (REQ-LOCK-EXPLICIT). Mirrors the previous
# per-item gating: repair_ship, quick_circuit, advanced_circuit start locked and
# are awarded via defence station drops. exotic_alloy is intentionally NOT here:
# it stays implicitly gated (its output/inputs are unreachable), so it is never
# unlocked. premium_circuit uses unlocked_at_start in recipes.toml. Everything
# else (interceptor, destroyer, salvage_ship, all modules) starts unlocked.
[[unlock]]
id = "repair_ship"
station_level = 0
ships = ["repair_ship"]
[[unlock]]
id = "quick_circuit"
station_level = 0
recipes = ["quick_circuit"]
[[unlock]]
id = "advanced_circuit"
station_level = 1
recipes = ["advanced_circuit"]

View File

@@ -0,0 +1,52 @@
[world]
height_tiles = 60
refund_percentage = 75
deconstruction_time_seconds = 0.1
starting_building_blocks = 100
debris_despawn_seconds = 30
scrap_per_threat = 1.0
tile_size_m = 10
belt_speed_mps = 20
tunnel_max_distance_tiles = 10
departure_interval_seconds = 20
orbit_factor = 0.8
rally_orbit_radius_tiles = 5.0
artifact_tooltip = "Choose the artifact reward when destroying enemy stations; collect enough to win."
[regions]
asteroid_width_tiles = 40
player_buffer_width_tiles = 10
contest_zone_width_tiles = 30
enemy_buffer_width_tiles = 15
[scroll]
pan_speed_slow_tiles_per_second = 8.0
pan_speed_fast_tiles_per_second = 24.0
pan_ramp_band_width_tiles = 16
[expansion]
columns_per_expansion_tiles = 10
cost_building_blocks_formula = "400 * 2^x"
[push]
push_expand_columns_tiles = 20
boss_advance_seconds = 60
[targeting]
target_score_formula = "1 / (1 + x)" # x = distance / max weapon range; higher = better, clamped to >=0
overclaim_penalty_formula = "max(0.5, 1 - 0.1*x)" # x = competing claim count; multiplies score, clamped to [0,1]
target_hysteresis = 0.10 # keep current target unless a challenger beats it by >10%
[artifacts]
artifact_chance_formula = "0.05 * x" # 5% chance per station level
artifact_win_count = 3
[waves]
threat_rate_formula = "x"
gap_min_seconds = 15
gap_max_seconds = 45
spawn_duration_seconds = 10
boss_countdown_seconds = 300
boss_threat_duration_seconds = 60
boss_quiet_before_seconds = 60
boss_quiet_after_seconds = 60

60
cmake/version.cmake Normal file
View File

@@ -0,0 +1,60 @@
# simple check for a git repo
if(EXISTS "${CMAKE_SOURCE_DIR}/.git")
find_package(Git)
execute_process(
COMMAND ${GIT_EXECUTABLE} rev-parse --abbrev-ref HEAD
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
OUTPUT_VARIABLE GIT_BRANCH
OUTPUT_STRIP_TRAILING_WHITESPACE
)
execute_process(
COMMAND ${GIT_EXECUTABLE} log -1 --format=%h
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
OUTPUT_VARIABLE GIT_COMMIT_HASH
OUTPUT_STRIP_TRAILING_WHITESPACE
)
execute_process(
COMMAND ${GIT_EXECUTABLE} log -1 --format=%ci
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
OUTPUT_VARIABLE GIT_COMMIT_TIME
OUTPUT_STRIP_TRAILING_WHITESPACE
)
execute_process(
COMMAND ${GIT_EXECUTABLE} describe --long --match "[0-9]*" HEAD
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
OUTPUT_VARIABLE GIT_VERSION_NUMBER
OUTPUT_STRIP_TRAILING_WHITESPACE
)
string(REGEX REPLACE "^([0-9]+)\\..*" "\\1" VERSION_MAJOR "${GIT_VERSION_NUMBER}")
string(REGEX REPLACE "^[0-9]+\\.([0-9]+).*" "\\1" VERSION_MINOR "${GIT_VERSION_NUMBER}")
string(REGEX REPLACE "^[0-9]+\\.[0-9]+\\.([0-9]+).*" "\\1" VERSION_PATCH "${GIT_VERSION_NUMBER}")
string(REGEX REPLACE "^[0-9]+\\.[0-9]+\\.[0-9]+-([0-9]+).*" "\\1" VERSION_COMMIT "${GIT_VERSION_NUMBER}")
else(EXISTS "${CMAKE_SOURCE_DIR}/.git")
set(GIT_BRANCH "")
set(GIT_COMMIT_HASH "")
set(GIT_VERSION_NUMBER "")
set(VERSION_MAJOR "0")
set(VERSION_MINOR "0")
set(VERSION_PATCH "0")
set(VERSION_COMMIT "0")
set(BUILD_TYPE "")
endif(EXISTS "${CMAKE_SOURCE_DIR}/.git")
set(VERSION_STRING "${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH}.${VERSION_COMMIT}")
message(STATUS "Version: ${VERSION_STRING}")
# message(STATUS "Git current branch: ${GIT_BRANCH}")
# message(STATUS "Git version number: " ${GIT_VERSION_NUMBER} )
# message(STATUS "Git commit hash: ${GIT_COMMIT_HASH}")
# message(STATUS "Git commit time: ${GIT_COMMIT_TIME}")
# message(STATUS "Version major: ${VERSION_MAJOR}")
# message(STATUS "Version minor: ${VERSION_MINOR}")
# message(STATUS "Version patch: ${VERSION_PATCH}")
# message(STATUS "Version commit: ${VERSION_COMMIT}")

35
cmake/version.rc.in Normal file
View File

@@ -0,0 +1,35 @@
// Windows version resource. Generated by CMake via configure_file() from this
// template; @VAR@ placeholders are filled from cmake/version.cmake (version
// numbers) and the product identity variables in the top-level CMakeLists.txt.
// Shows up on the executable's Details tab (right-click -> Properties).
#include <windows.h>
VS_VERSION_INFO VERSIONINFO
FILEVERSION @VERSION_MAJOR@,@VERSION_MINOR@,@VERSION_PATCH@,@VERSION_COMMIT@
PRODUCTVERSION @VERSION_MAJOR@,@VERSION_MINOR@,@VERSION_PATCH@,@VERSION_COMMIT@
FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
FILEFLAGS 0x0L
FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP
FILESUBTYPE VFT2_UNKNOWN
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "040904b0" // US English (0x0409), Unicode (0x04b0)
BEGIN
VALUE "CompanyName", "@PRODUCT_COMPANY@"
VALUE "FileDescription", "@PRODUCT_DISPLAY_NAME@"
VALUE "FileVersion", "@VERSION_STRING@"
VALUE "InternalName", "@PRODUCT_NAME@"
VALUE "OriginalFilename", "@PRODUCT_NAME@.exe"
VALUE "ProductName", "@PRODUCT_DISPLAY_NAME@"
VALUE "ProductVersion", "@VERSION_STRING@"
VALUE "LegalCopyright", "@PRODUCT_COPYRIGHT@"
END
END
BLOCK "VarFileInfo"
BEGIN
VALUE "Translation", 0x409, 1200 // 0x409 = en-US, 1200 = Unicode code page
END
END

View File

@@ -15,7 +15,7 @@ This document captures the architectural decisions for the project. It is a comp
A strict separation between the game simulation and the Qt Widgets UI.
- The **simulation** is a pure C++ library that depends only on Qt Core and Qt Gui (QPoint, QVector2D, QRect, etc., as required by the coding guidelines), toml++, and tinyexpr. It contains no QtWidgets, no painting, and no QApplication. Note: in Qt 5, vector math types such as QVector2D live in Qt::Gui rather than Qt::Core, so the lib links both.
- The **UI** reads simulation state and renders it. It owns all widgets, painting, and input handling, and drives the simulation via a small command interface (place building, demolish, clear belt tiles, change recipe, set game speed, etc.).
- The **UI** reads simulation state and renders it. It owns all widgets, painting, and input handling, and drives the simulation via a small command interface (place building, deconstruct, clear belt tiles, change recipe, set game speed, etc.).
This split is enforced at the CMake target level (see below). Tests link only against the simulation library and run without a display server.
@@ -25,7 +25,7 @@ The simulation advances in discrete ticks. All game quantities — production ti
- Tick rate: fixed at 30 Hz; `tickDurationMs = 1000 / 30 ≈ 33.33`.
- Ticks are driven by an accumulator that is independent of the render rate. Each render frame, the driver adds `elapsedWallMs × gameSpeedMultiplier` to an accumulator and flushes one `tick()` per `tickDurationMs` of accumulated time (so multiple sim ticks may run between frames at high speeds, or a frame may run no ticks at low speeds). `gameSpeedMultiplier` ∈ {0, 0.5, 1, 2, 4} per REQ-UI-SPEED; 0× freezes the accumulator (pause). The concrete driver lives in the Rendering section.
- Config-level durations given in seconds (recipe durations, wave gap ranges, scrap despawn, etc.) are converted to ticks at config-load time.
- Config-level durations given in seconds (recipe durations, wave gap ranges, debris despawn, etc.) are converted to ticks at config-load time.
Consequences: determinism, replayability, and the time-scale feature fall out for free. The simulation advances the same number of ticks over the same amount of game-time regardless of whether the game renders at 60 FPS, 30 FPS, or a stuttery mix.
@@ -44,7 +44,7 @@ See REQ-GW-COORDS for the authoritative tile-coordinate convention. This section
- Tile coordinates are `QPoint(x, y)`. Origin `(0, 0)` is the first space tile (just right of the asteroid's right edge at game start). X grows right; Y grows down.
- Asteroid tiles have `x < 0`. Asteroid left-expansions add tiles at increasingly negative X; the origin never shifts, so existing tile coordinates remain stable across expansions.
- Continuous world positions (ship centers, scrap drops, projectiles) use `QVector2D` in tile units — one tile = 1.0 world unit. A ship center at `QVector2D(-3.5, 4.0)` sits at the center of the tile 3.5 tiles left of the asteroid's right edge and 4 tiles down from the top.
- Continuous world positions (ship centers, debris, projectiles) use `QVector2D` in tile units — one tile = 1.0 world unit. A ship center at `QVector2D(-3.5, 4.0)` sits at the center of the tile 3.5 tiles left of the asteroid's right edge and 4 tiles down from the top.
- Rendering multiplies world units by the tile size in pixels (20) at draw time.
- Ship position always refers to the ship's center — this is the point used for sensor, attack-range, and hit-detection checks.
@@ -52,30 +52,56 @@ See REQ-GW-COORDS for the authoritative tile-coordinate convention. This section
Simulation types shared across subsystems:
- `EntityId` — strictly increasing integer handle, allocated centrally by the simulation. Assigned to every targetable entity: ships, scrap drops, **and** buildings (including HQ and defence stations). Buildings additionally retain their anchor tile for spatial lookups and placement; the `EntityId` is the canonical reference used by ship-component target fields (`Weapon.currentTarget`, `RepairTool.currentTarget`, `ThreatResponse.currentTarget`, etc.), so a combat ship can target either another ship or a defence station uniformly.
- `EntityId` — strictly increasing integer handle, allocated centrally by the simulation. Assigned to every targetable entity: ships, debris, **and** buildings (including HQ and defence stations). Buildings additionally retain their anchor tile for spatial lookups and placement; the `EntityId` is the canonical reference used by ship-component target fields (`Weapon.currentTarget`, `RepairTool.currentTarget`, `AttackBehavior.currentTarget`, etc.), so a combat ship can target either another ship or a defence station uniformly.
- `Rotation` — enum `{ North, East, South, West }`. The rotation applied to a building's surface_mask when placed.
- `BuildingType` — enum covering every building type in requirements.md (Miner, Smelter, Assembler, ReprocessingPlant, Shipyard, SalvageBay, Belt, Splitter, Hq, PlayerDefenceStation, EnemyDefenceStation). `Belt` and `Splitter` share the enum for cost, construction, placement, and `visuals.toml` lookup, but their runtime data lives inside the belt subsystem rather than in `Building` instances (see Belt Subsystem).
- `ItemType` — tagged id of every transportable material (ores, ingots, intermediates, building_blocks, scrap).
- `Item``struct Item { ItemType type; }`. Items on belts have no persistent identity across ticks.
- `Port``struct Port { QPoint tile; Rotation direction; }`. Identifies a belt-adjacent cell and the direction of flow across that cell.
- `MovementIntent``struct MovementIntent { int priority; QVector2D target; }`. Priority follows the order declared under Movement Arbitration. Cleared at the start of each tick; the highest-priority write wins; `tickMovement` reads the winner.
- `FireEvent``struct FireEvent { EntityId shooter; EntityId target; Tick emittedAt; }`. Transient record emitted each time a weapon fires (REQ-SHP-FIRING, REQ-SHP-FIRING-BEAM). Buffered in a sim-owned queue and drained by the renderer; see Sim → UI Events.
- `SchematicDropEvent``struct SchematicDropEvent { ShipSchematicId schematic; int newLevel; bool wasNewUnlock; }`. Emitted when a destroyed enemy-defence-station set awards a schematic (REQ-DEF-SCHEMATIC-DROP). The UI renders a toast (REQ-UI-SCHEMATIC-TOAST); `wasNewUnlock` chooses between the "unlocked" and "level → N" wording.
- `MovementIntent``struct MovementIntent { bool active; QVector2D target; }`. Written by the winning behavior's executor (see Movement Arbitration). Cleared (`active = false`) at the start of each tick; `tickMovement` brakes when inactive, otherwise drives toward `target`.
- `BeamFiredEvent``struct BeamFiredEvent : public Event { BeamKind kind; entt::entity shooter; entt::entity target; Tick emittedAt; }`. Transient record emitted each time a weapon fires, a repair tool starts a heal cycle, or a salvage module starts a collection cycle (REQ-SHP-FIRING, REQ-SHP-FIRING-BEAM). `BeamKind` (`Weapon`/`Repair`/`Salvage`) selects the beam color. Buffered in a sim-owned vector during the tick, then drained and re-emitted via EventManager by the UI frame handler; see Sim → UI Events.
- `SchematicChoiceOption``struct SchematicChoiceOption { string schematicId; SchematicType type; string displayName; bool isNewUnlock; int targetLevel; }`. Describes one option in the schematic choice dialog (REQ-DEF-SCHEMATIC-DROP). Up to three are generated when an enemy station set is destroyed. `SchematicType` is `Ship`, `Module`, or `Recipe`.
- `SchematicChoicesAvailableEvent` — EventManager event carrying a `vector<SchematicChoiceOption>`. Sent by the UI each frame when pending choices are detected; handled by `MainWindow` which opens the schematic choice dialog.
## Sim → UI Events
## Event System
The sim owns a small set of per-frame event queues that the UI drains on each render. These carry one-shot signals that are not derivable from persistent state — currently weapon fires (REQ-SHP-FIRING-BEAM) and schematic drops (REQ-UI-SCHEMATIC-TOAST). Additional event types can be added here later (e.g., building-complete, unit-death flashes) without changing the pattern.
All inter-component communication — both sim→UI and UI→UI — uses a unified `EventManager`/`EventHandler` system. No custom Qt signals/slots are used for inter-widget communication.
Implementation: a plain `std::vector<FireEvent>` owned by `Simulation`, one vector per event type. Combat resolution (tick-order step 8) appends to it. The UI calls `simulation.drainFireEvents()` once per rendered frame, which returns the accumulated vector by move and clears the internal one. Beams are tracked by the renderer for 0.3 s of wall time (9 ticks at 30 Hz) using the events' `emittedAt` tick, then discarded. If either the shooter or target entity is gone when the renderer looks them up, the beam is dropped early.
### EventManager
We deliberately do **not** use `QObject` signals/slots or `QEvent`:
`EventManager` is a singleton (`EventManager::getInstance()`) that routes events to registered handlers.
- **Determinism.** A plain ordered vector preserves tick-order exactly; the queue is part of per-tick state, inspectable in tests.
- **Sim/UI seam.** The sim exposes pull-style access only; the UI never subscribes into the sim, keeping the simulation/presentation split clean.
- **Headless testability.** Catch2 tests read the queue directly after `tick()`; no event loop, no `QApplication`.
- **Zero overhead.** Sim types remain plain structs — no `QObject`, no moc, no signal dispatch machinery.
- `sendEventImmediately(shared_ptr<Event>)` — synchronous dispatch to all handlers of the event's type.
- `addEvent(shared_ptr<Event>)` — queues the event for later batch processing.
- `processEvents()` — drains the queue, dispatching each event to its handlers.
If the number of event types grows past a handful, we can wrap them in a small `EventQueue<T>` template, still owned by the sim. Signals/slots would only be warranted if we needed multiple independent subscribers or cross-thread dispatch, and we need neither.
The EventManager is thread-safe (mutex-guarded).
### EventHandler
`EventHandler<T>` is a CRTP-style template that a class inherits to receive events of type `T`. It provides `registerForEvent()` / `unregisterForEvent()` and requires an override of `handleEvent(shared_ptr<const T>)`.
`CombinedEventHandler<Ts...>` is a variadic template for classes that handle multiple event types. It provides `registerForEvents()` / `unregisterForEvents()` and requires one `handleEvent` override per type.
### Sim → UI Events
The simulation layer stays free of EventManager — it uses a plain `std::vector<BeamFiredEvent>` internally (owned by `Simulation`, filled by the combat, repair, and salvage systems). This preserves determinism, tick-order fidelity, and headless testability (Catch2 tests read the queue directly via `drainBeamFiredEvents()` after `tick()`).
The UI frame handler (`GameWorldView::onFrame` / `ArenaView::onFrame`) bridges the gap: each frame it calls `simulation.drainBeamFiredEvents()`, then re-emits each `BeamFiredEvent` via `EventManager::sendEventImmediately()`. Subscribers (the same view's `handleEvent(BeamFiredEvent)`) create `ActiveBeam` records tracked for 0.3 s of wall time, then discarded. If either the shooter or target entity is gone when the renderer looks them up, the beam is dropped early.
Schematic drops: when an enemy station set is destroyed, the simulation generates up to 3 `SchematicChoiceOption` entries and stores them as pending state. The UI polls `hasSchematicChoicesPending()` each frame and, when true, sends a `SchematicChoicesAvailableEvent` via EventManager. `MainWindow` handles this event by pausing the game and opening a modal `SchematicChoiceDialog`. The player's selection is fed back via `applySchematicChoice(index)`.
### UI Events
All UI interactions — building selection, builder/blueprint mode transitions, speed changes, deconstruct mode, escape menu, layout dialog requests — are communicated via EventManager events rather than Qt signals/slots. Each event is a small struct inheriting `Event` (e.g., `SelectionChangedEvent`, `BuildingTypeSelectedEvent`, `SpeedChangeRequestedEvent`). Widgets register as `CombinedEventHandler` for the events they care about and emit events via `EventManager::sendEventImmediately()`.
Bidirectional interactions use separate request/notification event types to avoid infinite recursion (e.g., `ExitBuilderModeRequestedEvent` from `BuildButtonBar``GameWorldView`, vs. `BuilderModeExitedEvent` from `GameWorldView``BuildButtonBar`).
### Reading Simulation State
The simulation is the single source of truth for every game value (building block stock, expansion cost, threat level, tick, etc.). A UI widget that needs such a value holds the `Simulation*` it was constructed with and **pulls the value on demand** via the corresponding getter (e.g., `m_sim->getBuildingBlocksStock()`), rather than caching its own copy.
State-change events (e.g., `BuildingBlocksChangedEvent`) are treated as *refresh signals*, not as carriers of truth: a widget subscribes to the event to learn *when* the value changed and then re-reads it from the simulation to learn *what* it now is. The value carried in the event payload is not authoritative and should not be stored. This keeps a single copy of each value and avoids stale-cache bugs (a widget acting on a value that has since moved on because nothing refreshed its local copy).
## Tick Order
@@ -87,40 +113,85 @@ Within a single simulation tick, subsystems run in this fixed order. The order i
4. **Building production** — advance production timers; start new cycles when inputs and output-buffer space permit (REQ-MAT-CYCLE); on completion, deposit output.
5. **Building → belt push** — buildings push items from output buffer onto the belt tile at their output port (REQ-MAT-OUTPUT-PORT).
6. **Belt tick** — advance items along belt tiles; apply splitter routing (REQ-BLD-SPLITTER).
7. **Ship behavior systems** — clear `MovementIntent` on each ship, then run `tickThreatResponse`, `tickScrapCollector`, `tickRepairBehavior`, `tickHomeReturn` in any order (arbitration is via intent priority).
8. **Combat resolution** — ships and defence stations acquire targets, fire, apply damage; queue deaths. Each fire appends a `FireEvent` to the sim's fire-event queue (REQ-SHP-FIRING-BEAM).
9. **Deaths & loot** — process queued deaths: drop scrap (REQ-RES-SCRAP-DROP); if a full enemy-defence-station set was destroyed this tick, award one schematic (REQ-DEF-SCHEMATIC-DROP) and append a `SchematicDropEvent`; remove entities.
7. **Ship behavior systems** — clear `MovementIntent` on each ship, then the `AiSystem` runs three batched phases: every behavior **evaluator** scores its behavior and sets its target data; a **selection** pass records the highest-scoring behavior per ship in `SelectedBehaviorComponent`; each behavior **executor** runs for the winner, writing `MovementIntent` and preferred module targets. The module systems then perform world mutation: `SalvagerSystem` (scrap collection/delivery) and `RepairSystem` (healing). See Movement Arbitration.
8. **Combat resolution** — ships and defence stations validate/acquire targets, fire, apply damage; queue deaths. Each fire appends a `BeamFiredEvent` to the sim's beam-fired-event queue (REQ-SHP-FIRING-BEAM). The repair and salvage module systems (tick step 7d) append their own `BeamFiredEvent`s to the same queue when they start a cycle.
9. **Deaths & loot** — process queued deaths: drop debris (REQ-RES-DEBRIS-DROP); if a full enemy-defence-station set was destroyed this tick, generate up to 3 schematic choice options (REQ-DEF-SCHEMATIC-DROP) stored as pending state for the UI to present; remove entities.
10. **`tickMovement`** — advance ship positions based on final `MovementIntent`.
11. **Scrap despawn** — decrement scrap timers; remove expired scrap (REQ-RES-SCRAP-DROP).
11. **Debris despawn** — decrement debris timers; remove expired debris (REQ-RES-DEBRIS-DROP).
## CMake Target Layout
Three product targets plus tests:
- `lib/` — simulation + config. Depends on Qt Core + Qt Gui, toml++, tinyexpr. No QtWidgets.
- `ui/` — QtWidgets + `QOpenGLWidget` code: header bar, game world view, selected building panel, build button grid. Depends on `lib` and on Qt's OpenGL widgets module.
- `ui/` — QtWidgets + `QOpenGLWidget` code: header bar, game world view, selection panel, build button bar, controls panel. Depends on `lib` and on Qt's OpenGL widgets module.
- `ui/selection/` — the selection panel's contents. `SelectionPanel` itself only arbitrates between the two selection categories, picks a card from the catalog (`SelectionContentFactory`), and hosts one at a time; each kind of selection has its own `SelectionContent` subclass assembled from shared parts (REQ-UI-SELECTION-CARD, REQ-UI-SELECTION-CONTENT).
- `app/` — thin `main()` that creates the simulation, the UI, and wires them together. Depends on `ui`.
- `tests/` — Catch2 tests. Links only against `lib`.
Directory discipline inside `lib/` keeps the internal sim/config seam clear; sim code must not reach into config parsing and vice versa.
## Player Input
Every player control is declared once, in `lib/core/ControlAction.h`, and read by three consumers that must never disagree about it:
* **`ControlsPanel`** asks which actions apply and draws a row per action (REQ-UI-CONTROLS-CONTENT).
* **`InputMapper`** resolves a key press to an action and fires the event that action stands for.
* **`GameWorldView`** resolves a mouse gesture to an action and runs the branch that carries it out.
The file declares; it never performs. It holds no simulation access, fires no events, and names nothing — display strings live in `ui/ControlActionText.h`, which renders each badge from the binding the resolver actually matches, so a chip cannot claim a key that does nothing. What an action *does* stays in the widget that always did it: the drag state machines, hit-testing, and command enqueuing were not moved.
Three invariants are easy to break here:
* **Do not add a shortcut straight to `InputMapper`'s switch or `mousePressEvent`'s branches.** Add the action and its binding to the table; the handler switches on the resolved action. A binding added directly is invisible to the panel, which is the drift the table exists to prevent. (Build hotkeys and `F3`/`F4` are deliberate exceptions, documented in the header and in REQ-UI-CONTROLS-ACCURACY.)
* **Availability and display are different questions.** An action can be live in a context the panel does not advertise it in — `Ctrl`+click with an empty selection is the standing example. `isControlActionAvailable` answers the first, the per-context row lists answer the second, and `ControlActionTest` asserts the pairing that matters: every row's bindings resolve back to that row's action.
* **Gesture state is shared, not owned by an action.** Whether a belt drag is in progress decides what the right mouse button means, so it lives on `BuildModeController` where the resolver can see it — as does the hovered-transfer flag. `m_boxSelecting` is likewise one gesture serving two actions (box select and deconstruct area).
`ControlContext` is a plain snapshot rather than references to the live controllers, which is what keeps the rules testable without a world and stops an action reaching into the simulation: if a rule needs a fact, the fact is named in the struct and the caller supplies it. When bindings become player-configurable, only the binding tables in `ControlAction.cpp` turn from hard-coded data into loaded data.
## Belt Subsystem
Belts and splitters are their own specialized subsystem. Belt items are **not** entities — they are transient data flowing through the belt representation. They do not have identities that persist across ticks.
### Public Interface
Narrow and representation-agnostic:
`BeltSystem.h` is authoritative. The surface is wider than the original design sketch — 15 public methods in five groups, not the 5-method port interface this section used to describe:
```cpp
class BeltSystem {
public:
bool tryPutItem(Port port, Item item);
std::optional<Item> tryTakeItem(Port port);
// Placement — belts/splitters/tunnels are Buildings for cost and
// construction, so BuildingSystem registers and unregisters their tiles.
void placeBelt(QPoint tile, Rotation direction);
void placeTunnelEntry(QPoint tile, Rotation direction, int maxDistance);
void placeTunnelExit(QPoint tile, Rotation direction);
void placeSplitter(QPoint tile, Rotation outputA, Rotation outputB);
void removeTile(QPoint tile);
// Splitter filter configuration (REQ-BLD-SPLITTER). A splitter's filters
// live here, not on Building, so callers that re-register a tile must
// carry them across (see BuildingSystem::reregisterBeltTile).
void setSplitterFilters(QPoint tile, const std::vector<ItemType>& filterA,
const std::vector<ItemType>& filterB);
std::optional<SplitterInfo> getSplitterInfo(QPoint tile) const;
// Port interface (buildings <-> belts)
bool tryPutItem(QPoint tile, Item item, Rotation fromDir = Rotation::West);
std::optional<Item> tryTakeItem(Port port);
std::optional<ItemType> peekItem(Port port) const;
double getProgressPerTick_tpt() const; // shared so building output items
// travel at belt speed (REQ-MAT-OUTPUT-EMERGE)
// Maintenance
void clearTiles(const std::vector<QPoint>& tiles); // REQ-UI-BELT-CLEAR
void tick();
// Rendering
void forEachVisualItem(QRect viewportTiles,
std::function<void(VisualItem)> visit) const;
// Determinism (docs/replay_design.md)
void appendChecksum(Hasher& hasher) const;
};
struct VisualItem {
@@ -129,12 +200,12 @@ struct VisualItem {
};
```
Buildings interact with belts only through port-level push and pull. Rendering reads only through `forEachVisualItem`. No other system ever asks "what is on tile X".
Item *transport* is still reached only through push and pull: `tryPutItem` / `tryTakeItem` move items, `peekItem` reveals the leading item's type but never an identity, and rendering reads only through `forEachVisualItem`. The growth is in tile **topology** — placement, removal and splitter filters — which `BuildingSystem` drives because belts are `Building`s for cost, construction and deconstruction. That coupling is real and is not going away.
### Implementation Strategy
- v1: per-tile representation. Each belt tile stores up to 2 items with a progress value in `[0, 1]` along the tile's belt direction. Sufficient for the scale this game targets.
- v2 (optional, only if v1 profiles poorly): Factorio-style belt-segment compression. Because the public interface never exposes tile-level item identity, migration is internal to the subsystem.
- v2 (optional, only if v1 profiles poorly): Factorio-style belt-segment compression. The migration argument still holds for the item representation, since no method exposes tile-level item identity — but a v2 would have to keep the placement and splitter-filter methods working per tile, which is a stronger constraint than this section originally implied.
### Rendering Note
@@ -167,20 +238,21 @@ struct Building {
- Belts and splitters are separate types owned by the belt subsystem, not general `Building` instances.
- No ECS for buildings. A miner is never also an assembler; there is no composition benefit to decomposing buildings into components.
## Scrap
## Debris
Scrap is the only non-ship, non-building entity in the simulation:
Debris the salvageable object dropped by destroyed ships and defence stations — is the
only non-ship, non-building entity in the simulation. Each piece carries a scrap amount:
```cpp
struct Scrap {
struct Debris {
EntityId id;
QVector2D position; // world units, tile-fractional; ship-center convention
int amount;
Tick despawnAt; // absolute tick at which the scrap is removed
int amount; // scrap the piece still holds
Tick despawnAt; // absolute tick at which the debris is removed
};
```
Created in tick step 9 (Deaths & loot) per REQ-RES-SCRAP-DROP, consumed by salvage ships in tick step 7 (ScrapCollector), and removed in tick step 11 when the current tick reaches `despawnAt`.
Created in tick step 9 (Deaths & loot) per REQ-RES-DEBRIS-DROP, drained one scrap per cycle by salvage ships in tick step 7 (SalvagerSystem), and removed in tick step 11 when the current tick reaches `despawnAt`.
## Ships
@@ -192,21 +264,26 @@ Ships follow a component-composition model using `std::optional<Component>` memb
struct Weapon { float damage; float range; float fireRateHz; float cooldownTicks;
std::optional<EntityId> currentTarget; };
struct SalvageCargo { int capacity; int current; };
struct RepairTool { float ratePerTick; std::optional<EntityId> currentTarget; };
struct RepairTool { float repairAmountHp; int repairIntervalTicks; int cooldownTicksRemaining;
float range; std::optional<EntityId> currentTarget; };
```
### Behavior Components
Behaviors are decomposed, not bundled into per-role monolithic AIs. This is the critical modeling choice: adding a capability (e.g., putting a `Weapon` on a repair ship) must not require rewriting AI code.
Behaviors are decomposed, not bundled into per-role monolithic AIs. This is the critical modeling choice: adding a capability (e.g., putting a `Weapon` on a repair ship) must not require rewriting AI code. Each behavior is a small component carrying its own target data plus a `float score` written by its evaluator each tick.
```cpp
struct ThreatResponse { float engagementRange; CombatStance stance;
CombatTargetPriority priority;
std::optional<EntityId> currentTarget; };
struct ScrapCollector { std::optional<QVector2D> scrapTarget; EntityId deliveryBay; };
struct RepairBehavior { RepairTargetPriority priority;
std::optional<EntityId> currentTarget; };
struct HomeReturn { float retreatHpFraction; QVector2D homePos; };
struct AdvanceBehavior { float score; }; // baseline fallback, all ships
struct RallyBehavior { QVector2D rallyPoint; float score; }; // player combat ships
struct RetreatBehavior { float retreatHpFraction; QVector2D retreatPoint; // player ships
float score; };
struct AttackBehavior { std::optional<EntityId> currentTarget; float score; };
struct RepairBehavior { std::optional<EntityId> currentTarget;
float maxRepairRange_tiles; float score; };
struct SalvageScrapBehavior { std::optional<QVector2D> debrisTarget;
float maxCollectionRange_tiles; float score; };
struct DeliverScrapBehavior { BuildingId deliveryBay; float score; };
struct SelectedBehaviorComponent { BehaviorKind winner; float bestScore; }; // selection result
```
### Ship
@@ -226,38 +303,42 @@ struct Ship {
std::optional<SalvageCargo> cargo;
std::optional<RepairTool> repairTool;
// Behaviors
std::optional<ThreatResponse> threatResponse;
std::optional<ScrapCollector> scrapCollector;
std::optional<RepairBehavior> repairBehavior;
std::optional<HomeReturn> homeReturn;
// Behaviors (attached per capability; AdvanceBehavior + SelectedBehaviorComponent
// on every ship, RetreatBehavior on player ships, etc.)
std::optional<AttackBehavior> attackBehavior;
std::optional<SalvageScrapBehavior> salvageScrapBehavior;
std::optional<DeliverScrapBehavior> deliverScrapBehavior;
std::optional<RepairBehavior> repairBehavior;
// Written by behavior systems, read by movement.
// Written by the winning behavior's executor, read by movement.
MovementIntent intent;
};
```
### Systems
Each behavior has its own tick system. A system iterates a flat `std::vector<Ship>` and skips ships that do not have the relevant components.
Each behavior is split into a stateless **evaluator** and **executor** class (one per behavior, e.g. `AttackEvaluator`/`AttackExecutor`), orchestrated by `AiSystem`. Evaluators and executors only read/write behavior components and module target fields — they never mutate the game world. World mutation lives in dedicated module systems that run every tick, independent of which behavior won:
- `tickThreatResponse` — requires `threatResponse` + `weapon`. Acquires target, fires, manages cooldown.
- `tickScrapCollector` — requires `scrapCollector` + `cargo`. Flies to scrap, picks up, returns to delivery bay.
- `tickRepairBehavior` — requires `repairBehavior` + `repairTool`. Finds damaged target, moves to range, repairs.
- `tickHomeReturn` — requires `homeReturn`. Overrides movement if hp drops below threshold.
- `tickMovement` — reads `intent`, advances `position`.
- `CombatSystem` — validates each weapon's executor-set target, falls back to nearest-target acquisition, fires, applies damage.
- `SalvagerSystem` — collects scrap into cargo and delivers full cargo at a `SalvageBay`.
- `RepairSystem` — validates each repair tool's target, falls back to nearest damaged friendly, applies healing.
- `MovementIntentSystem` (`tickMovement`) — reads `MovementIntent`, advances `position`; brakes when inactive.
### Movement Arbitration
When multiple behaviors want to drive movement, a fixed global priority resolves the conflict. Each behavior system writes a `MovementIntent` carrying its priority; a higher-priority write overwrites a lower-priority one. `tickMovement` reads the final winner.
Arbitration is **score-based**, not fixed-priority. In a single tick `AiSystem` runs three phases:
Initial priority order (subject to tuning):
1. **Evaluate** — every behavior's evaluator iterates the ships that have its component, sets its target data, and writes a `float score` (see `BehaviorScores.h`). An evaluator returns an inactive score when its behavior does not apply.
2. **Select**`selectWinningBehaviors` resets each `SelectedBehaviorComponent`, then compares every behavior's score per ship, recording the highest as `winner`. Behaviors are considered highest-band first so a strict `>` breaks ties toward the more urgent behavior.
3. **Execute** — each behavior's executor runs only for ships where it is the `winner`, writing the single `MovementIntent` and any preferred module targets.
`AdvanceBehavior` is present on every ship with the lowest score, guaranteeing a winner. The resulting band order:
```
HomeReturn > ThreatResponse > RepairBehavior > ScrapCollector
Retreat > Attack / Repair / SalvageScrap / DeliverScrap > Rally > Advance
```
`tickMovement` runs last. Intents are cleared at the start of each tick.
`MovementIntent` is cleared (inactive) at the start of each tick; `tickMovement` runs last.
### Why Not ECS
@@ -267,7 +348,11 @@ Buildings and the belt subsystem stay outside any entity model regardless of wha
## Rendering
The game world is rendered by a single `GameWorldView` widget that inherits `QOpenGLWidget` and uses `QPainter` for all drawing. This gives the same imperative paint API as a plain `QWidget` with GPU acceleration, comfortably handling the expected scale (hundreds of ships, thousands of belt items) without blocking the main thread on CPU rasterization.
The game world is drawn into a single `GameWorldView` widget that inherits `QOpenGLWidget` and uses `QPainter` for all drawing. This gives the same imperative paint API as a plain `QWidget` with GPU acceleration, comfortably handling the expected scale (hundreds of ships, thousands of belt items) without blocking the main thread on CPU rasterization.
The drawing itself lives in `WorldRenderer`, not in the widget. `paintGL` is a call sequence: build the frame's `WorldCoordinates`, hand the renderer a `WorldRenderFrame`, then draw the screen-anchored chrome. The split is the world-space / screen-space line, and it is exact: the renderer draws everything positioned in tiles, while everything positioned in pixels — the pause and deconstruct vignettes, the replay overlay, the debug stats panel — stays with the widget. A useful consequence is that the renderer draws no translatable text at all (its text is config-driven glyphs, ASCII port arrows, and numbers), so it needs no `tr()` and no tie to the meta-object system.
`WorldRenderFrame` is what makes the renderer independent of the widget. The renderer reads the simulation directly, but everything else it draws is interaction state the widget owns — the selection, the active build mode, live beams, the box-select rectangle. Those are gathered into the frame each `paintGL` and passed by reference, so the renderer keeps no copy that a later click could invalidate. The renderer knows nothing about input: the widget resolves clicks and hit-tests, and the renderer only draws the result.
### Render Loop
@@ -280,7 +365,7 @@ The game world is rendered by a single `GameWorldView` widget that inherits `QOp
### Threading
Sim and UI run on the same thread for v1. `paintEvent` reads sim state directly without locks. If profiling later justifies moving the sim to a worker thread, the pull-style `drainFireEvents()` / `drainSchematicDropEvents()` / `forEachVisualItem()` APIs already support a clean snapshot-and-render split; a single mutex at the sim boundary would suffice.
Sim and UI run on the same thread for v1. `paintEvent` reads sim state directly without locks. If profiling later justifies moving the sim to a worker thread, the pull-style `drainBeamFiredEvents()` / `getPendingSchematicChoices()` / `applySchematicChoice()` / `forEachVisualItem()` APIs already support a clean snapshot-and-render split; a single mutex at the sim boundary would suffice. The `ArenaSimulation` used by the balancing tool runs headlessly on a worker thread; fire events accumulate in its internal vector and are only drained when `ArenaView` drives `tickOnce()` on the main thread during interactive inspection.
### Layer Order (back to front)
@@ -289,15 +374,17 @@ Sim and UI run on the same thread for v1. `paintEvent` reads sim state directly
3. **Belt items** — 10×10 colored squares emitted by `BeltSystem::forEachVisualItem`.
4. **Scrap** — glyphs at world positions.
5. **Ships** — colored arrows oriented by velocity; color keyed to role (player combat / salvage / repair / enemy).
6. **Laser beams** — lines derived from live `FireEvent`s kept by the renderer for 0.3 s (REQ-SHP-FIRING-BEAM).
7. **Build overlays** — ghost in builder mode (REQ-BLD-GHOST), demolish-mode tint, tile highlight under cursor, box-drag selection rectangle.
8. **Screen-space UI** schematic toasts (REQ-UI-SCHEMATIC-TOAST) and any other screen-anchored elements, drawn after resetting the world-space transform.
6. **Laser beams** — lines derived from live `BeamFiredEvent`s kept by the renderer for 0.3 s, colored per `BeamKind` (weapon/repair/salvage) (REQ-SHP-FIRING-BEAM).
7. **Build overlays** — ghost in builder mode (REQ-BLD-GHOST), deconstruct-mode tint, tile highlight under cursor, box-drag selection rectangle.
8. **Screen-space UI** — screen-anchored elements, drawn after resetting the world-space transform.
### Coordinates and Scrolling
- `GameWorldView` holds a continuous `scrollXTiles` (float). A / D input pans this smoothly (REQ-UI-SCROLL).
- At the start of `paintEvent`, a single `painter.translate(-scrollXTiles * tilePx, 0)` maps world tile units into widget pixels (`tilePx = 20`, per REQ-GW-TILE-SIZE).
- Mouse input converts the other way: `worldX = mouseX / tilePx + scrollXTiles`; apply `floor` for a tile. Asteroid tiles (`x < 0`) need no special casing — they share the coordinate system with space tiles.
- The horizontal view position lives in `WorldCamera` (`lib/core/`) as a continuous view-center X in tiles. A / D input pans it smoothly (REQ-UI-SCROLL) at a position-dependent speed (REQ-UI-SCROLL-SPEED). The camera works purely in world units — tiles and tiles/second, never pixels — which is what keeps it independent of `WorldCoordinates`; the two meet only where `GameWorldView` feeds `getViewCenterXTiles()` into the transform.
- The camera takes no simulation dependency. Its pan limits move with asteroid expansion and with pushes, so `GameWorldView` reads them from the sim each frame and passes them in as `ScrollBounds`; the camera clamps on every `advance()`, not only when panning, so the view follows the bounds inward when they shrink. Pan *intent* is likewise passed in as a `PanDirection` rather than read from key state, so the camera is unaffected if controls later become rebindable. Both properties are what make it a plain value with unit tests (`WorldCameraTest`) — notably over the two-ramp pan-speed curve, whose overlapping-band and zero-width-band cases are otherwise easy to break unnoticed.
- The world↔widget transform itself lives in `WorldCoordinates` (`lib/core/`), not in the view. It is an immutable value, built through one of two named factories that differ only in how `tilePx` and the left edge are derived; everything downstream is shared. `scrolling(...)` is the game world: `tilePx` makes the world height fill the viewport (REQ-GW-TILE-SIZE) and the view pans horizontally. `fitToWorld(...)` is the balancing tool's arena: a fixed world shown whole, so `tilePx` is the tighter of the two axis fits and there is no scroll. Being a plain value with no Qt Widgets dependency, it is unit-tested (`WorldCoordinatesTest`) even though the widgets around it are not.
- `GameWorldView::getCoordinates()` and `ArenaView::getCoordinates()` each build one per frame in `paintGL` and per event in the mouse handlers, and pass it down: every world-space `draw<X>` takes a `const WorldCoordinates&`, while the screen-space draws (vignette borders, replay overlay, debug text) take none. The snapshot is deliberately never cached in a member — a resize or a scroll would silently invalidate it.
- Conversions are per-call arithmetic rather than a `painter.translate`, because hit-testing needs the inverse (`widgetToWorld` / `widgetToTile`, flooring for a tile) as often as drawing needs the forward direction. Asteroid tiles (`x < 0`) need no special casing — they share the coordinate system with space tiles, which is why the flooring must not be truncation.
### Culling
@@ -307,6 +394,8 @@ The renderer iterates only entities and tiles whose world X lies within the visi
Shapes are hardcoded in the renderer — a building is a rectangle per footprint tile, a ship is an oriented arrow/triangle, a belt item is a 10×10 square, scrap is a small circle, a beam is a line. These structural choices live in the `draw<X>(painter, entity)` functions of the UI and are not expected to change frequently.
The few shapes the game view and the balancing tool's arena view draw *identically* — the ship body, the health bar, the debris marker, the sensor-range circle — live in `ui/WorldPrimitives` as free functions over explicit values. The arena exists to eyeball combat, so it only works while a ship there looks like a ship in the game; keeping these in one place means a retuned ship shape cannot silently stop applying to the tool that measures it. The balancing target does not link the `ui` library, so it compiles that file into itself, the same way it already does for `VisualsLoader` and `ShipStatsPanel` (see `balancing/CMakeLists.txt`). Everything the two views draw differently — selection highlights, beams, target lines, and all of the factory — stays with each view; the shared set is deliberately not grown beyond shapes that are genuinely the same.
Colors, outline widths, glyph text, and tile tints live in a separate config file, `visuals.toml`, loaded once by the UI at startup using the same pattern and lifetime as the sim config files (see Config Loading). The file is UI-scoped: the sim does not read it and does not depend on it.
Sketch of `visuals.toml`:
@@ -337,8 +426,8 @@ width_px = 2
[overlays]
ghost_valid = "#ffffff44"
ghost_invalid = "#ff000044"
demolish_tint = "#ff000033"
selection_rect = "#00ff00"
deconstruct_tint = "#ff000033"
selected_outline = "#ffff00"
[toast]
bg = "#000000cc"

56
docs/balancing/README.md Normal file
View File

@@ -0,0 +1,56 @@
# Balancing Documentation
Everything about balancing Dota Factory, separated by role:
- **[rules.md](rules.md)** — the design rules and principles. Timeless;
changes only when the design changes.
- **[targets.md](targets.md)** — the base numbers (roots/anchors) chosen
by design. Change these first; everything else re-derives.
- **[derived.md](derived.md)** — the current tuned state of all derived
numbers, mirroring the configs. Updated whenever configs change.
- **[process.md](process.md)** — how balancing is done: the pass order,
tuning discipline, tools, and the checklist for the next round.
- **[history.md](history.md)** — chronological record of decisions,
findings, bugs, and arena rounds.
Related: game content (hull grids, footprint gating, tree design and
fiction) in [../content_design.md](../content_design.md); rules with
REQ-* ids in [../requirements.md](../requirements.md).
## Status
First full balancing round complete (2026-07-06): targets → tree →
numbers → threat-calculator parity → combat stats (arena-converged) →
pacing. Next step: full-game playtests against the run-shape targets in
`targets.md`.
## Open action items
Agreed changes that require edits to `requirements.md`, the code, or the
configs. Completed items are removed (their outcomes live in
`requirements.md`, `history.md`, and the git history).
1. **Fill unfillable schematic slots with artifacts.** With duplicates
removed, the schematic drop pool can run dry — previously unreachable.
Decision: every slot in the choice dialog that cannot be filled with a
schematic because the eligible pool is exhausted is filled with an
artifact option instead (in addition to any artifact option granted by
the regular artifact roll). A push therefore always awards a full
dialog. Update REQ-DEF-SCHEMATIC-DROP.
2. **Confirm wave scaling in playtests.** `threat_rate_formula` is the
only time-scaling axis; verify the tuned curve (see `derived.md`)
produces the intended difficulty race in real runs.
3. **Gate shortcut-recipe drops on their inputs.** Extend the assembler
recipe schematic pool eligibility in REQ-DEF-SCHEMATIC-DROP: in
addition to the existing station-level and output-item checks, all of
the recipe's input item types must be implicitly unlocked as well.
4. **Resource deposits.** Add a terrain deposit layer per the Resource
deposits rules (`rules.md`): deposit patches generated in expansion
columns (deterministic content per expansion, randomized placement
within the new columns), deposit rendering, and a miner condition (a
resource recipe is selectable only if the miner's footprint overlaps
at least one matching deposit tile). Touches REQ-BLD-MINER ("every
asteroid tile is equivalent" no longer holds),
REQ-GW-ASTEROID-EXPAND / REQ-EXP-*, `world.toml`, and `visuals.toml`.
Until this lands, quartz mines anywhere and the mid-game is
knowledge-gated only.

164
docs/balancing/derived.md Normal file
View File

@@ -0,0 +1,164 @@
# Derived Values (current tuned state)
Everything here is derived from `targets.md` under the rules in
`rules.md`, and mirrors the config files. Item threats, ship threats,
ratios, and belt checks are verified by `tools/threat_report.py` — re-run
it after any recipe or material change and update this file when values
move. Combat stats were tuned empirically against the arena suite in
`bin/balancing/data/balancing.toml` (round-by-round record in
`history.md`).
## Economy constants
- `scrap_per_threat = 0.25` — 1 scrap per 4 threat destroyed (a cruiser
kill drops ~59 scrap); threat(scrap) = 4.
- Scrap smelting: 1 scrap → 1 iron_ingot, 1 s — deliberately
value-losing; reprocessing is the value-preserving path.
- Reprocessing: 4 scrap per cycle, 4 s; full-pool weights iron_ingot 30 /
copper_ingot 30 / silicon 20 / voidsteel 20 → threat(voidsteel)
= (4·4 + 4)/0.2 = 100.
- `debris_despawn_seconds = 120` (a capital kill drops hundreds of scrap,
collected one per salvage cycle).
## Recipes and item threats
(dur in seconds; threat is per output unit)
| item | recipe | dur | out | threat |
|---|---|---|---|---|
| iron_ore / copper_ore | miner | 1 | 1 | 1 |
| quartz | miner (deposit) | 2 | 1 | 2 |
| iron_ingot | 1 iron_ore | 1 | 1 | 2 |
| copper_ingot | 1 copper_ore | 1 | 1 | 2 |
| silicon | 1 quartz | 2 | 1 | 4 |
| steel_plate | 2 iron_ingot | 3 | 1 | 7 |
| copper_wire | 1 copper_ingot | 1 | 2 | 1.5 |
| copper_coil | 2 copper_wire | 1.5 | 1 | 4.5 |
| building_block | 2 steel_plate | 2 | 4 | 4 |
| control_chip | 1 silicon + 2 copper_wire | 5 | 1 | 12 |
| capacitor_bank | 2 copper_coil + 1 silicon | 5 | 1 | 18 |
| hardened_steel | 3 steel_plate | 12 | 1 | 33 |
| ceramic_plate | 2 quartz | 4 | 1 | 8 |
| drive_unit | 2 steel_plate + 2 copper_coil + 1 control_chip | 8 | 1 | 43 |
| voidsteel_plate | 1 voidsteel + 1 hardened_steel | 8 | 1 | 141 |
| capital_core | 2 voidsteel + 1 capacitor_bank + 1 control_chip | 10 | 1 | 240 |
Shortcut recipes (drop-only; item threat stays defined by the base path
via the max rule): `direct_rolling` 3 iron_ore → 1 plate (2 s,
level 1), `direct_etching` 2 quartz → 1 chip (4 s, level 2),
`direct_hardening` 4 iron_ingot → 1 hardened (8 s, level 2).
Ratio curve realized: t1 all 1:1 (miner:smelter); t2 clean 2:3
(ingot→plate, wire→coil); t3 strange — 2:5 (silicon→chip), 3:5
(coil→capacitor), 3:4 (plate→hardened, plate→drive); t4 inverted 3:2
(hardened→voidsteel_plate). Belt check: worst input demand 1.33 items/s,
under the ~2/s single-belt cap everywhere.
## Module prefabs
(contribution = item threat + module production time)
| module | recipe | dur | mod. time | contribution |
|---|---|---|---|---|
| railgun_s | 1 copper_coil | 1 | 1 | 6.5 |
| salvager | 1 steel_plate + 2 copper_wire | 2 | 1 | 13 |
| repair_tool | 1 steel_plate + 2 copper_wire | 2 | 1 | 13 |
| armor_plates | 4 steel_plate | 3 | 1 | 32 |
| maneuvering_thrusters | 1 steel_plate + 1 copper_coil | 2 | 1 | 14.5 |
| sensor_booster | 2 copper_wire + 1 copper_coil | 2 | 1 | 10.5 |
| afterburner | 2 copper_coil + 1 steel_plate | 3 | 1 | 20 |
| weapon_stabilizer | 1 steel_plate + 1 copper_coil | 2 | 1 | 14.5 |
| weapon_primer | 1 capacitor_bank + 1 copper_coil | 4 | 2 | 28.5 |
| weapon_upgrade | 1 control_chip + 1 copper_coil | 4 | 2 | 22.5 |
| railgun_m | 1 capacitor_bank + 2 steel_plate + 1 copper_coil | 4 | 3 | 43.5 |
| drone_bay | 1 control_chip + 2 steel_plate + 1 copper_coil | 4 | 3 | 37.5 |
| railgun_l | 1 capacitor_bank + 2 hardened_steel + 1 ceramic_plate | 6 | 4 | 102 |
| drone_hangar | 1 voidsteel_plate + 2 control_chip + 1 drive_unit | 10 | 6 | 224 |
## Ships
(fitted = hull item + ship base time + default loadout; the default
loadouts are the `default_modules` used by enemy waves and are
geometry-validated against the hull grids)
| ship | hull recipe | dur | base | default loadout | fitted |
|---|---|---|---|---|---|
| drone | 1 iron_ingot | 1 | 1 | railgun_s | 10.5 |
| frigate | 2 steel_plate + 1 copper_wire | 2 | 2 | 2× railgun_s, maneuvering_thrusters | 47 |
| destroyer | 3 steel_plate + 2 copper_coil | 4 | 3 | 3× railgun_s, armor_plates, sensor_booster | 99 |
| cruiser | 2 hardened_steel + 2 control_chip | 6 | 4 | 2× railgun_m, armor_plates, maneuvering_thrusters | 233.5 |
| battlecruiser | 3 hardened_steel + 2 control_chip + 1 drive_unit | 8 | 5 | 3× railgun_m, armor_plates, 2× railgun_s | 354.5 |
| battleship | 3 voidsteel_plate + 1 drive_unit + 2 control_chip | 10 | 6 | railgun_l, 2× railgun_m, weapon_stabilizer, 2× railgun_s | 722.5 |
| dreadnought | 5 voidsteel_plate + 1 capital_core + 2 drive_unit | 12 | 8 | 3× railgun_l, 4× armor_plates, railgun_s | 1491.5 |
| carrier | 5 voidsteel_plate + 1 capital_core + 2 drive_unit | 12 | 8 | drone_hangar, 2× railgun_m, 2× armor_plates, sensor_booster | 1436.5 |
## Combat stats
(arena-converged, 2026-07; see `history.md` rounds 15)
**Weapons:** railgun_s 2 dmg × 2.0 Hz (4.0 DPS), range 50 m;
railgun_m 14 × 1.5 (21), range 70; railgun_l 52 × 0.8 (41.6), range 100.
**Hull HP** (15/threat prior + empirical trims): drone 60, frigate 300,
destroyer 550, cruiser 1500, battlecruiser 2400, battleship 6300,
dreadnought/carrier 24000.
**Mobility ladder** (speed m/s | main accel | maneuvering | angular |
max rot): drone 45|60|30|12|6, frigate 35|45|22|8|4,
destroyer 30|35|18|6|3, cruiser 24|25|12|4|2, battlecruiser 20|20|10|3|1.5,
battleship 15|14|7|2|1, dreadnought/carrier 10|8|4|1|0.5.
Sensors: 150/200/220/250/260/280/300/350 m.
**Other modules:** armor_plates +1200 HP; repair_tool 9 HP × 1 Hz,
range 80; salvager range 60, cargo 20, 0.5 collections/s; afterburner
×1.6 speed +60 accel; maneuvering_thrusters ×1.2 speed +10 maneuvering;
sensor_booster +50 m; weapon_upgrade ×1.2 damage; weapon_primer ×1.2
rate; weapon_stabilizer ×1.3 range ×0.8 rate.
**Stations:** HQ 5000 HP. Player station 3000 HP, 25 dmg × 1 Hz,
range 120, scrap 40. Enemy station: 3000+1500x HP, 25+12x dmg,
1.0+0.1x Hz, range 120, scrap 40+30x (x = push level).
## Pacing
**Unlock ladder** (level → unlocks; ← marks `unlock_requires`; starting
set at 1: drone, frigate, railgun_s, salvager, block_pressing recipe):
| level | ships | modules | recipes |
|---|---|---|---|
| 0 | destroyer | repair_tool, armor_plates | |
| 1 | | maneuvering_thrusters, sensor_booster | direct_rolling |
| 2 | cruiser | railgun_m, afterburner | direct_etching, direct_hardening |
| 3 | | weapon_stabilizer | |
| 4 | battlecruiser ← cruiser | weapon_primer, weapon_upgrade | |
| 5 | | drone_bay | |
| 6 | battleship ← battlecruiser | railgun_l ← railgun_m | |
| 8 | dreadnought ← battleship | | |
| 9 | carrier ← battleship | drone_hangar | |
Level 0's pool has exactly three entries (a full first dialog). Level 2
is the quartz gate: cruiser and railgun_m are the first schematics whose
chains reach quartz; the shortcut outputs only become implicitly
unlocked alongside them, so shortcuts cannot drop early.
**Threat rate** `2*x + 0.15*x*x` (x = boss cycle counter), against the
factory-size curve with ~half the player's output assumed military:
| cycle x | rate (threat/s) | player military (≈ curve/2) |
|---|---|---|
| 2 | 4.6 | ~12 |
| 6 | 17.4 | ~30 |
| 15 | 63.8 | ~60 |
| 20 | 100 | ~75 |
| 24 | 134 | — |
**Economy:** `starting_building_blocks = 200`; expansion cost formula
`300 + 50*x + 10*x*x` (x = expansions already purchased: ~1 affordable
per cycle mid-game at ~1/3 of block income, stretching to 23 cycles
late — quadratic so costs outrun the roughly linear block income
gradually, never with a hard wall); `artifact_win_count = 5` with
`artifact_chance_formula = 0.05*x`. Building costs: belt 2, splitter 3,
tunnels 5, miner 15, smelter 20, assembler 35, reprocessing plant 40,
salvage bay 25, shipyard 60 — averaging ≈18 blocks per placed building
(belts included), which meets the 4-minute doubling target at block
threat 4.

96
docs/balancing/history.md Normal file
View File

@@ -0,0 +1,96 @@
# Balancing History
Chronological record of the balancing work: what was decided, what was
found, what changed. Current values live in `derived.md`; this file
explains how they got there.
## 2026-07-02/03 — rules and structural decisions
- Rules document written (now `rules.md`): ratio curve, shortcut
recipes, refactorability, cost archetypes, threat model, growth curve.
- Scrap derived from threat (`scrap_per_threat`), replacing authored
per-ship scrap drops; scrap threat became the constant
`1/scrap_per_threat`, removing the old min-scrap_drop derivation and
its circularity.
- Duplicate schematic drops removed (no level-ups); ship/module levels
removed entirely — all time scaling lives in the threat rate, push
scaling stays on stations. Mk2 upgrade recipes noted as the future
per-item progression.
- Growth-curve rules added: escalating expansion costs, designed
doubling time, growth limited by economy not waiting; resource
deposits designed (deposit-gated mid resource in expansion territory).
- Production tree v2 decided: iron/copper everywhere (M-type asteroid),
quartz in geodes (mid), voidsteel battle-forged from scrap (late);
titanium dropped; lasers renamed to railguns, lasers reserved as a
future weapon type.
## 2026-07-03 — targets, tree, numbers
- Balancing targets fixed: ≤2 h run, phases 15/614/15+, factory curve
25/60/120/150, threat ladder, 25-ship swarm, block roots.
- Tree structure drafted and numbers computed (recursive threat
calculator); ratio curve realized; fitted ships within 96124% of the
strawman ladder (small end hot from fixed chain overhead — ladder
later adopted the achieved values).
- **Rule bugs found by the numbers work:** the scrap→ingot smelter
recipe would inflate basic materials via the max rule (fixed:
scrap-consuming recipes are threat fallback only); recipe output
amounts were ignored (fixed: per-unit division); items downstream of
reprocessing-only items never resolved (fixed: fixpoint resolution);
a shortcut recipe resolving earlier than the base path silently
underpriced items (fixed: commit only when all eligible recipes are
computable). All four fixed in `ThreatCostCalculator` with tests, and
implemented in `tools/threat_report.py`.
- v2 tree written into the configs; `default_modules` loadouts
geometry-validated (the numbers-pass loadouts for battlecruiser and
dreadnought were geometrically impossible — L-modifiers don't fit
beside full gun complements; corrected loadouts landed closer to the
ladder).
## 2026-07-04 — combat stats, arena rounds 15
Initial stats derived from the anchors (weapon DPS ≈0.6/threat flat,
hull 15 HP/threat, armor 20/threat, repair 2 HP/s/threat, station
range 200).
- **Round 1:** concentrated fleets won all equal-threat cross-tier
matchups flawlessly; glass beat armored; repair escort flawless; two
stations shrugged off a 3× swarm. Changes: concentration tax on m/l
gun damage (railgun_m 17→14, railgun_l 70→52), armor 640→1000,
repair 25→12, station range 200→120. (Team-1 "bias" in mirrors later
shown to be noise.)
- **Round 2 (EHP-margin logging added):** battleship +33% while
dreadnought 37% (stabilizer range + opposing armor); glass still
+11%. Changes: stabilizer range ×1.5→×1.3; per-hull trims introduced
(BC 2700→2500, BS 7500→7000, DN/CV 15500→19000).
- **Round 3 (narrow lanes — geometry fixed into the fixture):**
DN closed to 11%, BS +22%, swarm flipped to +14% over cruisers,
glass +12% third time. Changes: armor 1000→1200, BC 2500→2000,
BS 7000→6300, DN/CV 19000→22500.
- **Round 4:** glass-vs-armored resolved (+3% armored); noise floor
established (~±10%/run: BS ignored a 10% EHP cut; repair drifted
14→24% untouched). Convergence policy adopted: two-round signals only,
±20% converged. Changes: BC 2000→2200, DN/CV 22500→24000; BS +23%
accepted as doctrine texture (mechanical range edge vs. pure small
fleets).
- **Round 5 (durations logged; end-condition bug fixed upstream):**
TTK anchor validated (mirrors 23/71/95 s; DN-vs-swarm 214 s outlier
accepted); dreadnought +3%, everything else inside band. Final
changes: BC 2200→2400, repair 12→9 (persistent +24% escort margin).
**Combat pass declared converged.**
## 2026-07-05/06 — pacing pass
- Unlock ladder set (starting set drone/frigate/railgun_s/salvager;
quartz gate at level 2; capitals at 89 with `unlock_requires`
chains); threat rate `2*x + 0.15*x*x`; starting blocks 1000→200;
expansion 400 flat pending the cost formula; artifacts 3→5.
- **Bug found:** the building_block recipe was silently locked at game
start (building blocks appear in no schematic's materials, so implicit
unlocking could never reach the recipe) — fixed with an explicit
`unlock_at_station_level = -1`.
- Expansion cost formula implemented and set (`300 + 50*x + 10*x*x`):
quadratic, so costs outrun the roughly linear block income gradually
— ~1 expansion per cycle mid-game, 23 cycles apart late.
- **First full balancing round complete.** Next: full-game playtests
against the run-shape targets.

98
docs/balancing/process.md Normal file
View File

@@ -0,0 +1,98 @@
# Balancing Process
How balancing is done in this project: the pass order, the tuning
discipline, and the tools. Refer to this when starting the next
balancing round.
## The pass order
Each pass depends on the ones before it; a change in an earlier pass
invalidates the later ones (but not vice versa). Redo from the earliest
pass whose inputs changed.
1. **Targets** (`targets.md`) — choose the root numbers: run shape,
factory curve, threat-cost ladder, fleet size, block roots, combat
anchors, pacing anchors. These are design decisions, not
measurements. Everything else is derived from them.
2. **Tree structure** (`../content_design.md`) — items, chains,
what-consumes-what, per the production tree rules (one input per
phase transition, generic parts, archetypes, refactorability).
Structure only, no quantities.
3. **Numbers** (`derived.md`, recipes/materials in the configs) —
quantities and durations so every fitted ship sums to its ladder
value, the ratio curve is realized, and the belt/buffer guardrails
hold. Verified computationally by `tools/threat_report.py`.
4. **Calculator/tooling parity** — the game's `ThreatCostCalculator`
and `tools/threat_report.py` must produce identical values; the
Python tool is the design reference. Any semantic change to
REQ-THREAT-* needs both updated plus tests.
5. **Combat stats** (arena-driven) — derive stats from the combat
anchors, then iterate against the arena suite
(`bin/balancing/data/balancing.toml`) until equal-threat matchups are
near-draws. Threat costs are stat-independent, so arena ship counts
stay valid across stat changes.
6. **Pacing** — unlock ladder, `unlock_requires` edges, threat rate,
block/artifact/expansion values, per the pacing anchors.
Then: **full-game playtests**, which are the only check for the pacing
pass and feed back into targets.
## Tuning discipline (learned in arena rounds 15)
- **Change anchors, not symptoms.** When a class of results is off,
adjust the anchor that explains all of them (e.g. the concentration
tax) rather than individual stats.
- **Fewest knobs per round.** Attribution dies when many knobs move at
once. Prefer one anchor change plus its mechanical compensations.
- **Shared vs. local knobs.** Guns and module stats are shared across
many hulls — changing them moves many matchups. Per-hull HP moves
exactly one matchup; it is the designated per-ship trim knob on top of
the HP-per-threat prior.
- **Mind the ride-alongs.** A module buff lands on every default loadout
containing it (e.g. an armor buff strengthens the destroyer swarm that
opposes the dreadnought). Compute the net effect per matchup before
choosing step sizes.
- **Two-round signal policy.** Single arena runs re-roll by ~±10% EHP
margin; a margin inside ±20% counts as converged for v1. Only act on
signals that persist across two rounds.
- **Arena geometry is part of the fixture.** Lane width/height changes
the results (full engagement vs. fleets slipping past); margins are
only comparable within the same geometry.
- **Accept mechanical texture.** Not every deviation is a bug: a margin
that survives a stat change is mechanical (usually range/kiting under
the orbit AI) and may be desirable doctrine texture. Document the
acceptance in `targets.md` instead of chasing it.
- **Range is the strongest stat** under the orbit AI — free approach
fire. Price range modifiers conservatively; station dominance is
controlled via range, not HP.
## Tools
- `tools/threat_report.py` — item threats, module contributions,
hull/fitted ship threats, producer:consumer ratios, belt feasibility;
reads the real configs. The design reference for threat semantics.
- `tools/verify_recipes.py` — recipe tree closure, visuals coverage,
orphans, reprocessing-only items.
- `tools/verify_layouts.py` — module footprint gating matrix per hull.
- **Balancing tool** (`balancing` target) — parallel arena simulation of
`bin/balancing/data/balancing.toml`; logs winner, surviving counts,
team EHP %, and fight duration per arena. The suite covers: class
mirrors (expect near-mutual annihilation, symmetric winners),
equal-threat cross-tier matchups (expect near-draws — power-per-threat
made empirical), a 2:1 decisiveness check, doctrine matchups
(armored-vs-glass, repair-escort), and station assault.
## Checklist for the next balancing round
1. Pull; run `verify_recipes.py`, `verify_layouts.py`,
`threat_report.py`; compare against the tables in `derived.md`.
2. If recipes/materials changed: re-check fitted threats vs. the ladder
in `targets.md`; update arena suite ship counts if fitted values
moved.
3. Run the arena suite; read EHP margins and durations against the
expectations noted in `balancing.toml` and the anchors.
4. Apply changes per the tuning discipline (two-round signals only);
record the round and its knob changes in `history.md`.
5. Update `derived.md` where values moved; if an anchor moved, update
`targets.md` and state why.
6. Commit and push (the review workflow reads the remote).

333
docs/balancing/rules.md Normal file
View File

@@ -0,0 +1,333 @@
# Balancing & Progression Rules
Rules and principles that govern the production tree, progression pacing,
and balancing. This document contains **rules only** — the chosen base
numbers live in `targets.md`, everything derived from them in
`derived.md`, and the concrete content in the config files and
`../content_design.md`. All of those must follow the rules stated here.
## Player-experience goals
What each phase of a run should feel like:
- **Early:** learning belts and ratios with forgiving chains. The building
block economy is the main constraint; the player bootstraps a
self-sustaining factory from the starting stock.
- **Mid:** deeper chains, the first real ratio puzzles, and the first
meaningful drop decisions (which schematic, when to push).
- **Late:** combat feeds the factory — capital production requires salvage.
Progress means extending and refactoring the existing factory, not
rebuilding it. Strange ratios are deliberate optimization puzzles.
Overarching: an experienced player gains efficiency through **knowledge**
layout foresight, understanding chains, exploiting shortcut recipes — never
through hidden mechanics. An inexperienced setup should not cost much more
than an experienced one; experience pays off in how easily the factory
adapts later (see Refactorability).
## Resource phases
- A run has exactly **four base inputs**:
1. Two mined resources available from the start, minable on **every**
asteroid tile.
2. A third mined resource unlocked mid-game, minable **only on deposit
patches** found in expansion territory (see Resource deposits).
3. A fourth input unlocked late-game, obtainable **only** from
reprocessing salvaged scrap.
- The fourth input is the core loop hook: capital ship production requires
fighting (salvaging and reprocessing), not just mining.
- Every gating has a fictional reason (concrete fiction in
`../content_design.md`): the asteroid is a metal-rich body, so its bulk
rock is minable anywhere; the mid resource sits in rare pockets; the
late input is battle-forged — created only in the violence of ship
destruction, which is why any wreck (including the player's own)
yields it and no foundry can make it.
- The mid resource is **dual-gated**: schematics (knowledge, via drops)
and territory (deposits, via expansions). Tuning must guarantee the
deposit-bearing expansion is comfortably affordable by the time the
first mid-tier schematics drop, or those drops are dead picks.
- There is no direct "resource unlock" mechanism. Miner recipes unlock
**implicitly** (REQ-LOCK-IMPLICIT) when some unlocked schematic's material
chain reaches that resource. Resource pacing is therefore controlled
through the `unlock_at_station_level` values of ships, modules, and
assembler recipe schematics — and the content must guarantee that the
chains actually connect (a mid-game schematic must require an item whose
chain reaches the mid resource, or it never unlocks).
### Resource deposits
- **Rule: freedom first, geography later.** The starting resources are
minable everywhere, so the player has full layout freedom while
learning. Later mined resources are bound to deposit patches — fixed
geography as a layout puzzle, introduced once the player is competent.
- **Rule: deposits exist only in expansion territory.** Expansions buy
space *and* access to resource tiers — the second leg of the growth
curve (see Building block economy).
- **Rule: patch area is the throughput cap.** Deposits never deplete but
are finite in area; the number of deposit tiles caps how many miners
the chain supports. Buying deeper expansions raises the throughput
ceiling of high-tier chains.
- **Rule: no empty expansions.** Deposit content per expansion is
deterministic and config-defined; only the placement within the new
columns is randomized. Buying an expansion never rolls "nothing".
- **Rule: mining is binary.** A miner whose footprint overlaps at least
one deposit tile of a resource can select that resource's recipe; no
partial-coverage rate scaling.
- Deposits arrive at the periphery (expansions add columns on the left),
so each new chain starts in fresh space — supporting the
refactorability property — and high-tier chains have the longest belt
runs to the shipyards, escalating the logistics puzzle with tier.
## Production tree rules
### Structure
- **Each phase transition adds exactly one new base input chain.** A base
input is a bottom-level resource entering the factory from outside — a
mined resource or the scrap-only input. The early game starts with two
ores as the baseline; the transition to mid adds one (the deposit-bound
mid resource), the transition to late adds one (the scrap-only input).
No transition ever introduces more than one unfamiliar bottom-level
chain, so the factory grows in one direction at a time.
- **Intermediates are generic shared parts.** Keep the item count low —
modules and hulls of a tier draw from a shared pool of that tier's and
lower tiers' intermediates rather than each having bespoke inputs.
- **Thematic naming over thematic items.** Inputs should be plausible for
what the recipe produces (crystals for lasers, heat sinks for bigger
lasers). Achieve this through naming and chain membership, not by adding
item types: rename a generic part, don't add a parallel one.
### Ratios
- **Ratio "niceness" degrades with tier.** The producer:consumer ratios
needed for 100% throughput follow a curve:
- Tier 1 (ore → basic material): trivially nice (e.g. 1:1 or 1:2
miner:smelter).
- Tier 2: slightly complex but still clean (e.g. 2:3).
- Higher tiers: increasingly strange ratios, as deliberate optimization
puzzles.
- Exceptions in both directions are allowed when there is a reason — a
clean late chain as a breather, an odd early chain as a teaser — but the
curve is the default.
### Shortcut recipes
- Some strange chains get a **shortcut recipe**: an explicitly unlockable
assembler recipe schematic (`unlock_at_station_level ≥ 0`, drop-only per
REQ-LOCK-EXPLICIT) that skips a step (e.g. t1 → t3 directly) and yields
nice ratios for a chain whose base path is strange.
- **Not every strange chain gets a shortcut.** Some strangeness is
permanent; the absence of a fix is a valid design choice.
- **Shortcuts drop only for known chains.** A shortcut recipe enters the
drop pool only when both its input items and its output item are
already unlocked (in addition to the station level check). The player
is never offered a shortcut for a chain they have not built yet. The
output-item half of this check already exists in
REQ-DEF-SCHEMATIC-DROP; the input half is an open action item (see
`README.md`).
- **Shortcuts are pure rewards, never balance factors.** An item's threat
value is the *maximum* across its producing recipes (REQ-THREAT-ITEM), so
unlocking a cheaper recipe does not lower the item's threat accounting —
the player gains real factory efficiency without their ships being
valued cheaper and without enemy wave budgets shifting. Consequently:
**balance every chain around its base (expensive) path**; the shortcut's
savings define the size of the reward.
### Refactorability
- **Rule (the property):** unlocking the next tier or size of a thing must
be a *local edit* of the existing production line — adding assemblers
and belts, or replacing a machine or two in place — never a rebuild of
the line.
- **What this buys the player:** foresight pays off in space, not blocks.
An experienced player leaves a little slack in the middle of a line,
knowing the next size or tier upgrade means tearing out one assembler
and a few belts there and inserting the new step — plus maybe swapping
a recipe or two elsewhere — while the rest of the line keeps running
untouched.
- **Default technique:** the bigger version introduces one new intermediate
that is produced from a subset of the smaller version's inputs (possibly
plus one additional low-tier material), and otherwise reuses the smaller
version's inputs. Existing lines keep running and feed the new
intermediate's assemblers.
- The property is the rule; the technique is only the default. It may be
broken where it fights thematic plausibility, as long as the property
still holds.
## Cost archetypes
Every item has two cost knobs: **material quantity** and **cycle time**.
Both feed the threat value identically (threat = recursive
production-seconds, REQ-MOD-THREAT), so the split between them does not
change what an item is *worth* — it changes what kind of **factory
pressure** it creates:
- **Material-heavy, fast** (e.g. armor plates): simple items; stress belt
throughput, splitter logistics, and miner/smelter counts.
- **Time-heavy, lean** (e.g. shield modules): technically complex items;
few inputs — possibly higher-tier ones — but long cycles; stress
assembler counts and parallelization.
**Rule:** each module family commits to a clear archetype, so factories
supporting different fleet doctrines feel structurally different to build.
## Threat model (balancing backbone)
- Threat cost = total recursive production-seconds (REQ-MOD-THREAT). One
factory-second equals one threat; player output and enemy wave budgets
are denominated in the same currency.
- **Rule: combat power per threat is roughly constant** across all ships,
modules, and tiers. Higher tiers are better per *ship* and per *module
slot*, not per invested factory-second — their advantage is
concentration (fewer, bigger things; slot geometry per
`../content_design.md`) and qualitative capabilities, not a better
exchange rate. Deviations from this rule are deliberate and documented.
- **Difficulty race:** the enemy threat rate (`threat_rate_formula`) is
tuned against the factory output (threat/s) achievable by a competent
player — slightly below it early, crossing above it eventually. The game
is endless; enemy scaling must ultimately outpace any factory, and
player skill shifts *when*, not *whether*.
- **All time scaling lives in the threat rate** — waves get bigger, ships
of a given schematic never get individually stronger. There is no ship
level dimension: stat formulas are plain values, and per-ship level
scaling does not exist. Push scaling on enemy defence stations is the
separate, player-triggered difficulty axis and keeps its level formulas.
## Unlock & drop pacing
- **Starting set rule:** the schematics unlocked at game start
(`unlock_at_station_level = -1`) must be exactly enough to reach the
first push unaided — a functioning block loop, small hulls, a basic
weapon, and the salvage loop. Nothing more.
- The `unlock_at_station_level` ladder mirrors the resource phases:
mid-tier hulls/modules/recipes at low station levels, capital content at
higher levels. A schematic must not become available before the chains
its materials need can be unlocked alongside it.
- **Schematics can require other schematics.** Beyond the station-level
gate, a schematic (ship, module, or assembler recipe) may list
prerequisite schematics (`unlock_requires`, REQ-LOCK-PREREQ) that must
already be unlocked before it enters the drop pool — e.g. the medium
gun requires the small gun; a future Mk2 requires its base version.
Station level gates the earliest *when*; prerequisites gate the
*order*, keeping drop offers coherent with what the player already
owns.
- **No duplicate drops.** Ship and module schematics leave the drop pool
once owned, exactly as assembler recipe schematics already do. There are
no schematic level-ups; player power grows through unlock breadth and
factory scale only, which keeps power-per-threat exact on both sides.
The pool therefore shrinks over a run and late pushes increasingly offer
artifacts — intended: the late game is a race for the win condition.
Per-item progression may return later as Mk2 upgrade recipes (see Future
work), never as free level-ups.
- **Artifacts trade power for progress.** Artifact options compete with
schematic picks in the same choice dialog; the artifact chance must be
tuned so that taking one is a real decision (giving up an unlock), not
automatic in either direction.
## Scrap & reprocessing economy
- Scrap is the bridge from combat back into the factory, with two sinks:
**smelting** (same basic materials as ore — the safe, boring option) and
**reprocessing** (probabilistic higher intermediates, including the
late-game input — the gamble that eventually becomes mandatory).
- The reprocessing output pool renormalizes over the output groups whose items
are implicitly unlocked (REQ-LOCK-OUTPUT-POOL), so its output quality improves
automatically as the run progresses. **Rule:** weights are authored for
the *fully unlocked* pool state; early-game behavior falls out of
renormalization for free and needs no separate staging.
- **Rule: ship scrap drops are derived, never authored.** A destroyed ship
drops `threat cost × scrap_per_threat` (a `world.toml` key), with the
threat cost computed from its actual hull plus installed modules
(REQ-MOD-THREAT) — a kitted-out ship drops more scrap than a bare hull
automatically. `ships.toml` carries no scrap value. Defence stations are
the exception: they keep authored `scrap_drop_formula`s, because pushing
rewards are tuned independently of ship production costs.
- Consequence: the threat value of scrap is the constant
`1 / scrap_per_threat` (REQ-THREAT-SCRAP). The former min-`scrap_drop`
schematic derivation and its potential circularity are gone.
- **Rule:** the late-game input's income rate meaningfully gates capital
production — unlocking a capital hull must not mean spamming it; the
input trickles in slowly enough that every capital ship is a noticeable
investment. The tuning target is relative, not absolute: assume a
reference player who destroys and salvages roughly the threat the game
spawns ("fighting at parity"), and tune `scrap_per_threat`, the
reprocessing weights, and capital material costs so that this player
affords roughly N capital ships per boss cycle. An absolute income rate
would be meaningless (income depends entirely on how much the player
fights) and would not self-scale; per boss cycle, the target tracks the
threat rate as it steps up.
## Building block economy
- Building blocks are the only global currency and the early game's
central constraint. The early game is a bootstrap problem: convert the
starting stock into a self-sustaining block loop before the first waves
bite.
- **Rule:** the starting stock suffices for a minimal block loop plus the
first shipyard — with a little slack for beginner mistakes, but not
enough to skip the loop entirely.
- **Rule: the growth curve lives here.** A saturated building produces
exactly 1 threat/s, so the player's output curve *is* their
building-count curve — shaping growth over a run means shaping the
block and space economy, there is nowhere else it can live. Intended
shape: exponential bootstrap (block-limited) → ramp
(expansion-limited) → asymptotic squeeze as expansion costs outrun
income, racing the enemy threat rate throughout.
- **Rule: escalating expansion costs.** Expansion cost is a formula of
the number of expansions already purchased, rising steeply enough that
expansions eventually outrun any block income. The starting asteroid
is deliberately small — filled within the first boss cycle or two, so
the early exponential burst is a satisfying ramp, not a balance hole —
and from then on the output curve is the expansion curve. Blocks keep
a meaningful sink for the entire run, and "grow vs. army" stays a live
decision at every moment.
- **Rule: designed doubling time.** Block production is a positive
feedback loop (blocks buy assemblers, assemblers make blocks); its
time constant is a designed quantity, never an accident of quantity
choice. The block chain's depth and the per-building costs are tuned
against a stated target of the form: "a factory spending X% of its
capacity on blocks doubles in ~T minutes."
- **Rule: growth is limited by economy, never by waiting.** Construction
times stay short; the serial build queue must not be used as a growth
brake. Waiting for placed buildings to become operational — especially
at the start of a run — is frustration, not gameplay. All growth
limiting comes from block income and expansion pricing.
- Note: block income has a structural ceiling — blocks enter the stock
through the HQ's single belt port, so income is capped at belt
throughput regardless of assembler count. Per-building costs should be
high enough that this cap can bind late-game (see the condensed-block
idea under Future work).
## Numeric guardrails
Constraints that every recipe must respect, independent of tuning:
- **Belt throughput:** belt speed and per-tile capacity cap how fast a
single belt can feed an input. A recipe whose per-cycle inputs cannot be
sustained by one belt per input at 100% duty cycle is a *deliberate*
design (forcing parallel belts/splitters as part of a high-tier puzzle)
— never an accident of quantity choice.
- **Buffer burstiness:** input buffers hold 2× the per-cycle amount
(REQ-MAT-INPUT-BUFFER), so large per-cycle quantities create bursty belt
demand. Low tiers prefer small quantities with short cycles; big-batch
recipes are reserved for high tiers where burstiness is part of the
puzzle.
- **Cycle times scale with tier** monotonically — a higher-tier item never
has a shorter total chain time than a lower-tier item of the same role.
## Future work
- **Condensed building blocks** — a drop-unlockable shortcut-style
recipe that packs several blocks' worth of value into one belt item,
relieving the HQ intake ceiling (see Building block economy) as a
late-game reward. The ceiling is the puzzle, the drop is the fix —
same philosophy as shortcut recipes.
- **Mk2 upgrade recipes** — the deferred design for per-item progression,
to revisit once the config has stabilized. A duplicate-style drop
unlocks a distinct `*_mk2` item whose recipe consumes the Mk1 item plus
higher-tier parts. This preserves power-per-threat (the extra power is
paid in real production-seconds, since threat is recursive), satisfies
the refactorability rule (the Mk1 line keeps running and feeds one new
assembler), and keeps balancing one-dimensional (no level variable
anywhere). Enemy-side progression happens via `default_modules`
variants per era instead of a level formula.

104
docs/balancing/targets.md Normal file
View File

@@ -0,0 +1,104 @@
# Balancing Targets (base numbers)
The root numbers of the balancing. Everything in `derived.md` is tuned to
hit these; when rebalancing, **change these first and re-derive — never
patch derived values directly**. The rules these numbers follow live in
`rules.md`.
All time targets are in **game time**. The player can pause and
accelerate, so real session length differs; playtests measure both. The
time unit is the boss cycle (`world.toml boss_countdown_seconds`, 300 s).
Destroying a station set advances the boss countdown by
`boss_advance_seconds` (60 s), so cycles run shorter than nominal when
pushing actively — targets deliberately ignore that.
## Run shape
1. **Run length** — a winning run takes up to 2 hours of game time: win
around boss cycle 2024. Losing runs end earlier.
2. **Phase boundaries** — early = cycles 15 (iron/copper, small hulls),
mid = cycles 614 (quartz, medium hulls), late = cycles 15+
(voidsteel, capitals). Push cadence: first station set around cycle
23, roughly one per cycle from mid onward — so the destroyed set's
level is reached around cycle +2.
3. **Factory size curve** — producing buildings over time; when
saturated, output threat/s equals this count, so this curve IS the
player power curve: ~25 when the starting asteroid is full (end of
cycle 2), ~60 at the start of mid (cycle 6), ~120 at the start of
late (cycle 15), ~150 near the win. `threat_rate_formula` must remain
a fraction of this curve; buildings plus belts must physically fit
the asteroid plus affordable expansions.
4. **Threat-cost ladder** — total production-seconds per *fitted* hull
(including the typical/default module loadout): drone 10.5,
frigate 47, destroyer 99, cruiser 233.5, battlecruiser 354.5,
battleship 722.5, dreadnought 1491.5, carrier 1436.5. Every
production chain must sum to its ladder value. (The original strawman
was 10/40/80/200/350/700/1500; the small end runs ~1020% hot because
fixed chain overhead dominates small hulls — accepted, and the
achieved values adopted as the ladder. The ~×2-per-class curve shape
is the invariant.)
5. **Fleet size** — swarm-leaning: ~25 player combat ships as the
standing mid-game fleet. Standing fleet = build cadence (4) × average
ship lifetime, so this target drives time-to-kill and therefore all
combat stat magnitudes.
6. **Block economy roots** — bootstrap complete (starting asteroid full)
by the end of cycle 2; a factory spending ~30% of its capacity on
blocks doubles in ~4 minutes early game; one expansion affordable per
cycle at ~1/3 of block income mid-game, decelerating to one per 23
cycles late as escalating costs outrun income.
## Combat anchors
All combat stats derive from these; per-hull HP additionally carries
empirical trims from arena rounds (values in `derived.md`).
- **Weapon DPS per threat pays a concentration tax that grows with gun
size**: small ≈ 0.62, medium ≈ 0.48, large ≈ 0.41 DPS per threat of
weapon contribution, compensated by the range ladder 50/70/100 m.
Rationale: concentration itself (focus fire, no DPS loss to attrition,
range) is worth paying for — with a flat curve, concentrated fleets
win equal-threat fights outright (arena round 1).
- **Hull HP = 15 per threat of hull contribution** as the prior; per-hull
HP is the empirical trim knob (guns are shared across hulls, hull HP
moves exactly one matchup). The arena consistently prices capitals as
*tanks with taxed guns* — capital hulls sit well above the prior.
- **Armor HP ≈ 37 per threat** — a strong premium over hull HP because
armor is pure HP with no capability, and fights snowball: killing
removes enemy DPS, surviving merely delays — HP must be cheaper than
DPS.
- **Repair ≈ 0.7 HP/s per threat** — in-combat sustain effectively
removes enemy DPS and must be priced like DPS, not like HP.
- **TTK / fight duration**: parity fights in the 3060 s band at
mid-game scale; capital mirrors ~90 s deliberately; the extreme
tank-vs-chip-damage matchup (dreadnought vs destroyer swarm, ~3.5 min)
is an accepted outlier.
- **Mobility is monotone in size** — the smallest hulls are the fastest
and nimblest. Sensor ranges (150→350 m) always exceed weapon ranges.
- **Weapon modifiers are capital economy**: a ×1.2 damage modifier at
~22.5 threat beats adding a gun once a ship carries more than ~68
threat of weapons — modifiers pay off on gun-heavy big hulls, waste on
small ones. Range modifiers are the strongest and are priced/kept
small (×1.3): range is the dominant stat under the orbit AI (free
approach fire).
- **Stations**: a fresh player station holds one early parity wave
unaided; the enemy station at level 0 matches the player station
exactly and scales per push level. Station range is the dominance
lever, not HP (at 4× a small gun's range, two stations annihilated a
3× threat swarm through approach fire alone).
- **Accepted imbalances**: the carrier loses its equal-threat fights
until the drone-launching capability exists (the hangar is dead
threat) — fix by implementing drones, not stats. A pure smallest-ship
fleet modestly loses (~1525%) to a range-fitted capital — desirable
doctrine texture; the fair anti-capital answer is the mixed fleet.
## Pacing anchors
- **Starting set** is the rule-minimum: drone, frigate, small gun,
salvager (plus the explicitly unlocked building-block recipe).
- **Threat rate shape**: below the player's achievable military output
(≈ half the factory curve) early, crossing at the late boundary
(~cycle 15), overwhelming by ~cycle 24.
- **Winning = five real decisions**: `artifact_win_count` is set so that
across a winning run's ~1518 pushes (~7 cumulative artifact offers at
the current chance formula), the player must choose the artifact over
a schematic about five times.

View File

@@ -2,7 +2,7 @@
## Overview
A single-player asymmetric game inspired by DOTA's wave/tower structure, combined with a Factorio-style factory builder. The player builds a factory on an asteroid to supply shipyards that produce autonomous combat ships. Those ships fight off endless enemy waves advancing from the right. The goal is to survive as long as possible; elapsed time is always displayed.
A single-player asymmetric game inspired by DOTA's wave/tower structure, combined with a Factorio-style factory builder. The player builds a factory on an asteroid to supply shipyards that produce autonomous combat ships. Those ships fight off enemy waves advancing from the right, with tougher boss waves arriving periodically. Pushing into enemy territory and destroying their defence stations occasionally yields artifacts, which are used to upgrade the HQ; the goal is to upgrade the HQ enough to launch it into space, winning the game.
## Setting & Visuals
@@ -62,21 +62,26 @@ Two sources feed the same production tree:
- Waves consist of a single enemy ship type whose stats scale with difficulty.
- Waves spawn over several seconds; a gap follows before the next wave begins spawning. The previous wave may still be approaching or fighting during the gap.
- Difficulty scales multiplicatively from two sources:
- **Time scaling** — enemy strength increases gradually over elapsed time.
- **Push scaling** — destroying a set of enemy defence stations multiplies enemy strength by a configurable factor. The replacement stations are scaled by the same factor.
- A tougher **boss wave** spawns periodically on its own countdown, on top of normal waves.
- Enemy strength increases gradually over time and with each boss wave that occurs.
## Push Mechanic
- The player is not forced to push; purely defensive play is valid.
- Destroying enemy defence stations applies the push scaling multiplier to all future waves, extends the scrollable area, and places a new (stronger) set of stations at the new boundary.
- Destroyed enemy defence stations drop ship schematics.
- The player must push — destroying enemy defence stations is the only way to earn artifacts, which are required to win.
- Destroying a set of enemy defence stations advances the boss countdown (bringing the next, stronger boss wave sooner), extends the scrollable area, and places a new (stronger) set of stations at the new boundary.
- Destroyed enemy defence stations drop either a ship/module schematic or, occasionally, an artifact.
## Win Condition
- Artifacts are gathered by defeating enemy defence stations instead of taking a schematic reward.
- Artifacts are used to upgrade the HQ. Once the HQ is upgraded enough, the player can launch it into space — this is how the game is won.
## Starting Conditions & Game Over
- The player starts with the HQ and player defence stations pre-placed and a stock of building blocks; no other buildings are pre-placed.
- There is a grace period before the first wave to allow initial setup.
- If all ships and player defence stations are destroyed, enemies attack the HQ. The game is lost when the HQ is destroyed. Factory buildings are never targeted.
- The game is won when the player launches the HQ into space (see Win Condition).
## Asteroid Expansion

294
docs/content_design.md Normal file
View File

@@ -0,0 +1,294 @@
# Content Design — Ships, Modules & Production Tree
The designed game content: hull layout grids, module footprints and the
gating between them, and the production tree (items, chains, fiction).
All numbers — quantities, durations, threat values, stats, unlock levels
— live in the config files and are documented with their derivations in
`docs/balancing/` (see `balancing/README.md` for the index).
## Design principle: footprint gating
Which module fits on which hull is controlled purely by geometry — no
explicit allow-lists. Each hull grid is shaped so that it physically cannot
contain the footprint of modules from a larger size class. This keeps the
rules transparent to the player ("it doesn't fit because there is no room")
and makes them trivially moddable through the config files alone.
### Module footprint ladder
| Footprint | Modules | Smallest hull that fits it |
|-----------|---------|----------------------------|
| 1x1 | railgun_s, salvager, repair_tool | drone |
| 1x2 | maneuvering_thrusters, sensor_booster, armor_plates | frigate |
| 1x3 | afterburner | frigate (eats most of it) |
| L-shape (3 cells) | weapon_stabilizer, weapon_primer, weapon_upgrade | frigate |
| 2x2 | railgun_m, drone_bay | cruiser |
| 3x3 | railgun_l | battleship |
| 2x6 | drone_hangar | carrier (only) |
### Hull grids
`O` = buildable cell, `X` = hull structure (not buildable).
**drone (xs, 1 cell)** — exactly one 1x1 module: a small gun, a salvager, or
a repair tool. This is what makes drone roles swappable.
O
**frigate (s, 5 cells)** — plus shape. Every 1x2 placement crosses the center
cell, so at most ONE 1x2 support fits; alternatively one L-shaped weapon
modifier or one afterburner through the center line. Gun-boat with one or two
support modules, as intended.
XOX
OOO
XOX
**destroyer (s, 8 cells)** — gun deck with three turret bumps. More cells
than the frigate (more small guns), but still no 2x2 area anywhere, so medium
hardware can never be mounted.
OXOXO
OOOOO
**cruiser (m, 12 cells)** — notched corners. Fits at most two 2x2 m guns
(stacked through the middle), leaving the side cells for supports. No 3x3
area.
XOOX
OOOO
OOOO
XOOX
**battlecruiser (m, 16 cells)** — split bow with two gun cheeks, tapered
stern. Fits three 2x2 m guns — one more than the cruiser — with small support
slots left over. The bow split and stern taper prevent any 3x3 area (no l
gun) and any 2x6 area (no drone hangar).
OOXXOO
OOOOOO
XOOOOX
XXOOXX
**battleship (l, 24 cells)** — broadside hull with notched flanks on every
other row. Fits four 2x2 m guns (two per gun deck) — one more than the
battlecruiser — with bow, stern, and flank cells for supports. All 3x3
placements crowd the center columns, so at most ONE l gun fits: mounted
center it blocks every m gun mount (pure support strips remain), mounted
offset it still allows two m guns. The notched rows are never adjacent-and-
full, so no 2x6 drone hangar fits.
XOOOOX
OOOOOO
XOOOOX
OOOOOO
XOOOOX
**dreadnought (xl, 36 cells)** — the main battery deck is split into three
3x3 gun slots by structural spacer columns, so exactly three l guns fit side
by side (or m guns / supports in unused slots), plus bow/stern strips for
supports. The spacers cap every horizontal run at 5 cells, so the 2x6 drone
hangar can never fit — the carrier stays the only hangar hull.
XXXOOOOOXXX
OOOXOOOXOOO
OOOXOOOXOOO
OOOXOOOXOOO
XXOOXXXOOXX
**carrier (xl, 37 cells)** — the top flight deck (rows 01) is the only
region wide enough for the 2x6 drone hangar, and exactly one fits. The middle
deck row is broken up by elevator shafts (X cells placed so every 3-column
window hits one), which is what prevents any 3x3 l gun from ever fitting.
Lower decks hold supports and 2x2 point-defense m guns.
XOOOOOOOOX
OOOOOOOOOO
OOXOOXOOXO
XOOOOOOOOX
XXXOOOOXXX
### Verified gating matrix
Checked programmatically against the configs (all four mask rotations,
all placements) with `tools/verify_layouts.py` — re-run it after editing
layout grids or surface masks:
python dota_factory/tools/verify_layouts.py
| Footprint | drone | frigate | destroyer | cruiser | battlecruiser | battleship | dreadnought | carrier |
|-----------|:-:|:-:|:-:|:-:|:-:|:-:|:-:|:-:|
| 1x1 | x | x | x | x | x | x | x | x |
| 1x2 | | x | x | x | x | x | x | x |
| 1x3 | | x | x | x | x | x | x | x |
| L-shape | | x | x | x | x | x | x | x |
| 2x2 | | | | x | x | x | x | x |
| 3x3 | | | | | | x | x | |
| 2x6 | | | | | | | | x |
Maximum simultaneous (disjoint) placements: m guns — cruiser 2,
battlecruiser 3, battleship 4; l guns — battleship 1, dreadnought 3;
drone hangar — carrier 1.
## Production tree
Designed against the rules in `docs/balancing/rules.md` (ratio curve,
cost ladder, cost archetypes, refactorability). Quantities, durations,
and threat values live in `docs/balancing/derived.md`.
### Base inputs (4) and fiction
- **iron_ore, copper_ore** — from the start, minable on every asteroid
tile. Fiction: the asteroid is an M-type (metal) body — its bulk rock
*is* ore, which is why the shipyard operation was built here at all.
- **quartz** — mid-game, minable only on geode deposit patches in
expansion territory (see the Resource deposits rules in
`docs/balancing/rules.md`; the deposit mechanic itself is an open
action item — until it lands, quartz mines anywhere). Fiction:
ordinary silicate dust is everywhere and worthless; chips and optics
need rare, pocket-bound optical-grade crystal.
- **voidsteel** — late-game, obtained only by reprocessing scrap.
Fiction: battle-forged — formed when weapon plasma anneals hull metal
in the violence of ship destruction. Any wreck yields it, including the
player's own; no foundry can replicate it.
- **titanium was dropped** (v1 tree). Its hull-gating role moved to
quartz-era control systems ("you can smelt all the steel you want, but
you cannot steer a battlecruiser without electronics") plus the
hardened-steel quality step (a deliberately long-running, time-heavy
recipe) — explicitly not sheer steel quantity alone.
### Material palette (fingerprints per family)
- **iron/steel** — structure.
- **copper** — conduction and heat: wiring, coils, heat sinks.
- **silicon family** (all derived from quartz): silicon (logic,
sensors), ceramics (heat shielding, insulators); glass/optics are cut
from v1 — their only consumers would be lasers, which are deferred.
- **voidsteel** — capital-tier structure and exotics.
- Deliberately skipped: carbon (mostly redundant with copper/ceramics),
plastics (drags in Factorio-style chemical chains; ceramics read more
sci-fi anyway), volatiles/ice (materials are build costs only — no
consumption mechanic to justify fuel).
### Weapons
- All v1 weapons are **railguns** (`railgun_s/m/l`, renamed from the
laser placeholders; footprints and the gating matrix unchanged).
Implementation is instant damage application with no projectile and no
ammunition — the beam visual reads as a tracer round. Materials: iron
slugs, copper coils, steel rails — the starting-metal fingerprint.
- **Lasers are reserved for later** as a genuinely distinct weapon type
(e.g. once projectile/ammunition mechanics exist for other families),
arriving with quartz optics. More weapon types are planned; railguns
are simply the baseline tech that ships with v1.
- `drone_bay` and `drone_hangar` are footprint-only placeholders: the
drone-launching capability does not exist in the simulation yet, so
they define no capability section. The carrier is deliberately weak
until that capability lands (see the accepted imbalances in
`docs/balancing/targets.md`).
### Tree structure
Input lists only — quantities, durations, and per-item threat values are
in `docs/balancing/derived.md` and the configs.
**Mined (miner):** `iron_ore`, `copper_ore` (every tile), `quartz`
(geode deposits in expansion territory).
**Smelted (smelter — exactly one recipe per input item):**
| output | input | ratio class |
|---|---|---|
| iron_ingot | iron_ore | nice (1:1) |
| copper_ingot | copper_ore | nice |
| silicon | quartz | mid entry |
| iron_ingot | scrap | the safe, boring scrap sink |
**Reprocessing pool (scrap):** `iron_ingot`, `copper_ingot`, `silicon`,
`voidsteel` — the only source of voidsteel. Weights authored for the
fully unlocked pool state.
**Tier 2 — early intermediates (clean ratios, ~2:3):**
| item | inputs | role |
|---|---|---|
| steel_plate | iron_ingot | structure backbone, highest volume |
| copper_wire | copper_ingot | conductors |
| copper_coil | copper_wire | electromagnets: railguns, thrusters |
| building_block | steel_plate | depth-3 chain = the doubling-time knob |
**Tier 3 — mid intermediates (strange ratios begin, need quartz):**
| item | inputs | role |
|---|---|---|
| control_chip | silicon + copper_wire | electronics gate for m+ hulls |
| capacitor_bank | copper_coil + silicon | power for railgun m/l |
| hardened_steel | steel_plate (long cycle) | quality gate for m+ hulls; time-heavy |
| ceramic_plate | quartz | heat shielding: drives, l guns, capitals |
| drive_unit | steel_plate + copper_coil + control_chip | propulsion for m+ hulls |
**Tier 4 — late intermediates (need voidsteel):**
| item | inputs | role |
|---|---|---|
| voidsteel_plate | voidsteel + hardened_steel | capital structure |
| capital_core | voidsteel + capacitor_bank + control_chip | capital heart |
**Hull items** (`<ship>_hull`, assembler-made; the shipyard consumes the
hull item plus module materials). The m+ hull gate is **both**
hardened_steel (quality steel, the time-heavy step) *and* control_chip
(electronics):
| hull | inputs |
|---|---|
| drone_hull | iron_ingot |
| frigate_hull | steel_plate + copper_wire |
| destroyer_hull | steel_plate + copper_coil |
| cruiser_hull | hardened_steel + control_chip |
| battlecruiser_hull | hardened_steel + control_chip + drive_unit |
| battleship_hull | voidsteel_plate + drive_unit + control_chip |
| dreadnought_hull | voidsteel_plate + capital_core + drive_unit |
| carrier_hull | voidsteel_plate + capital_core + drive_unit |
**Module items** (`<module>_module`, assembler-made prefabs — kept as
items so shipyard belt inputs stay simple and module production can be
stockpiled):
| module | inputs | archetype |
|---|---|---|
| railgun_s | copper_coil | lean |
| salvager | steel_plate + copper_wire | balanced |
| repair_tool | steel_plate + copper_wire | balanced |
| armor_plates | steel_plate (many) | material-heavy, fast |
| maneuvering_thrusters | steel_plate + copper_coil | balanced |
| sensor_booster | copper_wire + copper_coil | lean (an antenna, no chip) |
| afterburner | copper_coil + steel_plate | balanced |
| weapon_stabilizer | steel_plate + copper_coil | balanced |
| weapon_primer | capacitor_bank + copper_coil | mid; time-heavy |
| weapon_upgrade | control_chip + copper_coil | mid; time-heavy |
| railgun_m | capacitor_bank + steel_plate + copper_coil | mid |
| drone_bay | control_chip + steel_plate + copper_coil | mid |
| railgun_l | capacitor_bank + hardened_steel + ceramic_plate | late |
| drone_hangar | voidsteel_plate + control_chip + drive_unit | late (carrier only) |
**Refactorability check** (the default technique holds): railgun_s → m
introduces capacitor_bank, built from a subset of the small gun's inputs
(copper_coil) plus the new base resource (silicon); the m gun otherwise
reuses the small gun's inputs. Hulls likewise: the cruiser adds
hardening (fed by the existing steel line) and chips (fed by the new
quartz territory) without touching the iron/copper core.
**Shortcut recipes** (drop-only assembler schematics; not every strange
chain gets one): `iron_ore → steel_plate` (skips the ingot step on the
highest-volume chain), `quartz → control_chip` (skips silicon),
`iron_ingot → hardened_steel` (a nicer-ratio route past the deliberately
awkward hardening step).
Consistency is checked by `tools/verify_recipes.py` — re-run it after
editing recipes, ship/module materials, or visuals:
python dota_factory/tools/verify_recipes.py
It verifies every consumed item has a producer, every item has a visuals
entry, flags orphaned items, and prints which items are
reprocessing-only (currently exactly voidsteel).

482
docs/replay_design.md Normal file
View File

@@ -0,0 +1,482 @@
# Replay — Design
This document captures the design for the replay record/playback feature. It records the
decisions made during design discussion; it is a complement to `architecture.md`. No
implementation exists yet — this is the agreed design to implement against.
## Goal
Record every play session and allow it to be played back later. Playback is **view-only**
(no interaction) with **manual game-speed selection** (including pause). Playback is launched
via a command-line argument to the executable.
## Approach: deterministic command-replay (re-simulation)
We record **player intent** (commands) plus the inputs needed to reproduce the run, and on
playback we **re-run the real simulation**, injecting the recorded commands at their recorded
ticks. We do **not** record per-tick state snapshots.
This is viable because the simulation is already built for it (see `architecture.md`:
"determinism, replayability ... fall out for free"):
- Fixed 30 Hz tick-based simulation, decoupled from render rate via `TickDriver`.
- Game speed (0/0.5/1/2/4×) and pause are tick-rate multipliers — they change *how many*
ticks run per frame, never the *outcome* of a tick. So speed, pause, camera scroll, and
selection are pure presentation and are **not recorded**.
- A single deterministic RNG stream: `Simulation::m_rng` (one `std::mt19937`) is passed by
reference into `WaveSystem` and `BuildingSystem`, the only two consumers. ECS combat/AI/
movement/scrap systems use no RNG. The `utility::getRandom*` global is not used by the sim.
- Config is immutable after load; a replay is pinned to the config it was recorded with.
A replay run is therefore a pure function of `(seed, config, ordered commands)`.
### What we do NOT do (now)
- No per-tick / keyframe state snapshots.
- No backward seek / scrubbing (would require snapshots).
- No save/load. (See "Future direction".)
- No interactive playback (no taking over a replay mid-run).
## Replay commands
A *replay command* is the resolved, serializable **intent** behind a player action — the
data, not the UI gesture. Example: placing a miner records
`PlaceBuilding{type=Miner, anchor=(3,5), rotation=East}`, not the mouse pixel that produced it.
- Commands are at **intent level, resolved to tile coordinates / domain ids** — independent
of window size, camera scroll, and DPI.
- Command payloads reference **stable, deterministic domain ids** (`BuildingId`, tile
coordinates, choice indices) — never raw `entt::entity` handles. These ids are sim-allocated
deterministically, so a recorded command resolves to the same entity on replay.
- Camera scroll, selection, game speed, and pause are **not** commands.
### Command vocabulary
One command per sim-mutating operation (the complete mutation surface):
- `PlaceBuilding`
- `Deconstruct`
- `RotateInPlace`
- `SetRecipe`
- `SetShipLayout`
- `SetSplitterFilters` (building-site and belt variants)
- `ClearBeltTiles`
- `ApplySchematicChoice`
- `Reset` / restart — see "Restart is a boundary".
### Command representation
Commands use a **base class + derived classes** (mirroring the existing `Event` hierarchy
idiom, so it is native to this codebase). They are routed through a dedicated command path,
**not** through `EventManager` (see next section).
> **Implementation refinement (Phase 1).** `PlaceBuilding` is **atomic**: it carries the
> optional recipe / ship-layout / splitter-filters to configure the new building in the same
> command. This is forced by the deferred-drain timing — commands apply at a later tick
> boundary, so the caller never sees the new `BuildingId` and therefore cannot issue a
> follow-up `SetRecipe`/`SetShipLayout` against it. The standalone `SetRecipe`,
> `SetShipLayout`, and the two `SetSplitterFilters` commands remain for the dialog-driven
> edits on *existing* buildings (which reference a known id). `Reset` carries the (move-only)
> `GameConfig` via `shared_ptr` and is moved into the sim on apply; a null config means "keep
> current config".
## Command system: reuse the *pattern*, not the EventManager singleton
We reuse the **pattern** of the existing event system (a polymorphic base + small derived
types), but the sim-mutating command path is a **dedicated, ordered queue**, not the
`EventManager` pub/sub bus. Reasons:
1. **Determinism / ordering.** Sim mutations must apply in a strict, tick-pinned, recorded
order. `architecture.md` deliberately keeps the sim free of `EventManager` for exactly this
reason (determinism, tick-order fidelity, headless testability — why `BeamFiredEvent` uses a
plain vector). Routing commands into the sim via the singleton would break that.
2. **Single consumer.** A command has exactly one recipient (the `Simulation`); pub/sub
N-handler fan-out is the wrong shape.
3. **Recording chokepoint.** One place must see every command, stamp its tick, append it to the
file, and apply it. A direct queue gives that; a multi-handler bus does not.
4. **Headless tests.** Tests link only `lib` and build a `Simulation` directly; the command
type and apply path live in `lib` and must work with no UI and no singleton.
### Structure
- **In `lib`:** a `Command` base class + derived command types, plus a `CommandManager`
(ordered queue) and a single `Simulation::apply(command)` chokepoint.
- **UI fan-in still uses `EventManager`:** widgets emit a UI event as today; a single
dispatcher/recorder catches it, builds the `lib` command, and hands it to the
`CommandManager`. This keeps widgets decoupled (consistent with current architecture).
- **Replay** skips the UI half and feeds commands straight into the same `CommandManager` /
`Simulation::apply` chokepoint.
### The completeness invariant (enforced structurally)
**Every** sim mutation must flow through the single `CommandManager → Simulation::apply`
chokepoint. Any path that mutates the sim directly would not be recorded and would silently
desync the replay.
This is enforced **structurally**: the `Simulation` player-action mutators are **private**, so
the only way production code can reach them is `apply(command)`.
> **Implementation decision (Phase 1, revised post-Phase 4).** Structural enforcement was
> initially deferred in favour of convention, because the test suite legitimately drives the
> same mutators directly and relies on their return values (notably the `BuildingId` from
> placement, which `apply()` cannot hand back to a caller). It was later restored once a key
> observation made the change cheap: **the UI's only handle to a mutable subsystem is through
> `Simulation`** — no production code in `ui`/`app`/`balancing` holds a `BuildingSystem`/
> `BeltSystem` directly, and every production `buildings()`/`belts()` call is a const query.
> So:
>
> - `Simulation::tryPlaceBuilding`, `deconstruct`, and `applySchematicChoice` are **private**.
> - The mutable subsystem accessors are private and renamed `buildingsMutable()` /
> `beltsMutable()`; only `const BuildingSystem& buildings() const` / `belts() const` are
> public (queries). UI query sites bind to the const overload unchanged.
> - `Simulation::apply` still mutates through the private members directly, so the chokepoint
> itself is unaffected.
> - Tests reach the private mutators through `SimulationTestAccess` (src/test, a `friend struct`
> of `Simulation`), so they keep calling the real mutators **and keep getting return values**
> — no id-by-position recovery needed. This header is not on the lib/ui/app include path, so
> only test translation units can use it.
>
> The `BuildingSystem` subsystem mutators (`place`, `setRecipe`, `placeImmediate`,
> `forEachBuilding`, …) stay **public**: `BuildingTest` unit-tests a bare `BuildingSystem` with
> no `Simulation`/command layer, and that surface is unreachable from production anyway (you
> cannot obtain a mutable subsystem without the private accessor). A `[command]` Catch2 suite
> still asserts `apply(...)` produces byte-identical state to the direct mutator path, guarding
> the equivalence the replay relies on. Tests are not gameplay (they never record), so direct
> mutator use there does not affect replay correctness.
Recording happens **at the apply chokepoint**, not at the UI gesture — so only commands that
actually reached the sim are recorded, and they replay through the identical apply path.
UI-side validation (placement validity, affordability) remains a pre-filter that simply does
not produce a command unless the action reaches the sim.
## Command timing: drain once per frame, before the tick batch
- During live play, input pushes commands onto the `CommandManager` queue (not applied
synchronously).
- The queue is drained at **one defined point: once per frame, before stepping the tick
batch.** The whole queue is drained in FIFO order (not one-per-tick), so bursts (e.g. laying
many belts quickly) apply immediately instead of dribbling across ticks, and it matches the
lockstep model wanted later.
- Each drained command is **tagged with the current completed-tick count**, recorded at drain
time (so record-order == apply-order canonically), and applied.
### Build-while-paused is preserved
The drain runs every frame including at 0× (the tick batch is simply empty when paused). So a
player can place buildings while paused and **see the construction sites immediately**. This is
still fully deterministic: replay applies each command at its recorded tick regardless of the
frame cadence that produced it.
On replay, there is no input; the player applies each pre-filled command at its recorded tick
through the same drain path, preserving order.
The Qt single-threaded event loop guarantees input events and the `onFrame` tick-batch never
interleave, so the completed-tick count at drain time is unambiguous. (If the sim is ever moved
to a worker thread, this needs a lock at the sim boundary.)
## Determinism: checksums and verification
We do not verify EnTT iteration order statically. EnTT view iteration is a pure function of the
sequence of spawn/destroy/add/remove operations, so on a fixed binary it contributes zero
run-to-run nondeterminism. Instead we verify **end-to-end determinism** with a state checksum,
and any divergence (EnTT order, float, container ordering, etc.) surfaces loudly.
### What is checksummed (now)
- **RNG state only**, for now. The `mt19937` state is fingerprinted into a 64-bit value.
- The hash can be extended later (entity positions/HP, belt items, building buffers, scalars)
without changing the format.
### Cadence
- **In the replay file:** every **30 ticks**, **and** after **every command** is applied. The
per-command checksum pins any divergence to the action that triggered it; the periodic one
localizes drift to a ~1 s window. On playback the recomputed checksum is compared; a mismatch
reports "desync at tick N".
- **In tests:** the Catch2 **double-run determinism test** hashes **full sim state every tick**
(not just RNG). It runs a scripted command sequence twice from the same seed and asserts
per-tick checksums match. This keeps the file lean while still catching non-RNG determinism
bugs during development.
### Known limitation of the RNG-only file checksum (accepted)
An RNG-only checksum only catches divergences that change **how much randomness is consumed**
(wave composition, recipe rolls, scrap). Float or iteration drift that does **not** alter RNG
draw counts passes the checksum undetected. This is acceptable for same-binary Windows replay
(no float drift expected on an identical binary; the checksum's real job there is catching
determinism *bugs*). When cross-platform replay becomes a goal, the **file** hash must be
expanded to include entity state.
## Cross-platform: Windows-first, portable by construction
The replay file is platform-neutral data; `std::mt19937` is bit-identical across platforms, so
RNG is not a cross-platform problem. The only real cross-platform issue is **floating-point
reproducibility** — the sim does heavy `QVector2D` float math, and a 1-ULP difference (compiler
/ CPU / SIMD / FMA contraction) can flip an in-range comparison and cascade into different ship
behavior (the classic lockstep-RTS problem).
Decision: **Windows-only first**, but make the later swap cheap and bounded by, from day one:
- a **per-period state checksum** in the file (above), and
- a **build/version + config-hash identity tag** in the header.
Then cross-platform later is a contained float-hardening pass (`/fp:strict`, no FMA contraction,
possibly fixed-point positions) guided by the checksums — **not** a redesign of the
command-replay architecture.
Note: even a new Windows *build* of the game can desync old replays for the same float reasons,
so the version tag + "warn on mismatch" is needed regardless of cross-platform ambitions.
## Seed and config
- **Seed:** a **random** seed is generated at the start of each run, **outside** the sim (e.g.
`std::random_device` in `main`/reset), so the `Simulation` stays a pure function of
`(seed, config, commands)`. The seed is written to the replay header.
- **Config:** the header stores a **config hash** (not a full config snapshot). On playback the
current config is hashed and compared; a mismatch warns/refuses. The hash is taken over the
actually-loaded config (so editing config files and restarting yields a new, consistent
replay).
## File format: line-oriented append-friendly text
Non-binary, chosen for readability and crash-safety. Size is a non-issue: the command log is
sparse (only ticks with a player action), so even a multi-hour game is tens of KB in any text
format.
- A small keyed/header section: seed, config hash, build/version, start timestamp.
- One line per command, e.g. `1234 place miner 3 5 E`.
- Periodic checksum lines interleaved, e.g. `# checksum 9000 a1b2c3...`.
Why line-oriented text:
- **Append-friendly** — the recorder stream-appends as the game runs, so a crash does not lose
the replay (a crash is exactly when you would want it). A format that must be rewritten/closed
as a whole is rejected for this reason.
- **No new dependency** — the project has no JSON lib; toml++ is parse-oriented and clunky for a
long event stream (fine for the header, awkward as an array-of-tables of thousands of
entries).
- Greppable, diffable, tiny.
- Aligns with the project's existing text-serialization idiom (`BlueprintSerializer`,
`ShipLayoutBlueprintSerializer`).
## Recording lifecycle
- **Record every run.** A new replay file is created at `Simulation` construction and at each
`reset()`.
- **Restart is a boundary.** Restart (escape menu → restart, which reloads config and resets)
closes the current file and opens a new one with a fresh seed and header. One replay file =
one contiguous run from tick 0 to game-over/quit.
- **Retention: keep everything.** Files live in the existing `data/` directory, named by
timestamp + seed. (No automatic pruning for now.)
## Playback
Launched via a command-line argument, e.g. `DotaFactory.exe --replay <file>`.
`main` for the `--replay` path:
1. Read the header → validate config hash and build/version (warn on mismatch).
2. Construct the `Simulation` from the recorded seed + config.
3. Construct the `CommandManager` in **Replay mode**, **pre-filled** with the whole command list
from the file. (Pre-fill memory is trivial; streaming-read is a later optimization if files
ever get huge — not needed now.)
4. Run the driver in replay mode: each frame, drain commands due at the reached tick (same drain
path as live), step ticks, compare checksums.
### Replay mode rules
Reframe: the schematic-choice modal is **an input source** (the device that produces an
`ApplySchematicChoice` command in live play), exactly like the mouse. Replay's single rule is
"**disable live input sources**", which the modal falls under.
- **`CommandManager` in replay mode:** `addCommand` is a no-op; the queue is pre-filled from the
file. Live input therefore produces nothing.
- **Only two reactions need explicit gating** — the sim-state *polls* in `onFrame` that emit
`SchematicChoicesAvailableEvent` and `GameOverEvent`. In replay these polls do not run, so no
modal opens, no auto-pause occurs, and there is no deadlock against the recorded command.
- **Everything else falls away for free** because it is click-driven, not sim-state-driven: the
recipe dialog (`RecipeSelectionRequestedEvent`), ship-layout dialog
(`LayoutDialogRequestedEvent`), and escape menu are all triggered by player input, which is
disabled — so they never open and need no special handling.
- **Schematic choice still resolves with no UI:** the sim regenerates identical choices
deterministically (same seed + prior commands), and the pre-filled `ApplySchematicChoice`
applies itself at its recorded tick through the normal drain path. The tick-tag invariant
places it correctly relative to when the choices became pending, in both record and replay.
- **Game-over is replaced, not just suppressed:** instead of the live restart/quit dialog,
playback detects the end condition (command stream exhausted / recorded game-over reached) and
stops, showing a passive "replay ended" state.
- **Kept in replay:** the renderer/view and **manual game-speed selection** (including pause /
0× and fast-forward via high speed). Playback only ever moves forward.
## Future direction (informs the design, not built now)
Save/load and (deterministic lockstep) multiplayer are wanted later. The command bus is the
shared foundation; two cheap shaping decisions now keep that path open:
1. **Each command carries a source/player id** (always "player 0" in single-player). Lockstep
multiplayer is just commands from multiple sources merged into one ordered stream.
2. **Commands are applied at a defined tick boundary** (already required for replay). Multiplayer
schedules them a few ticks in the future to hide latency; single-player uses the next drain.
Implications to note:
- Multiplayer makes cross-platform float determinism mandatory and promotes the checksum to
load-bearing desync-detection (rather than a test aid) — reinforcing doing the checksum now.
- **Save/load** is the one feature that needs a *different* mechanism: either "replay to current
tick" on load (reuses 100% of replay machinery; load time grows with game length, though
fast-forward usually replays hours in seconds), or a full **state-snapshot serializer**
(EnTT registry + belts + buildings + scalars). The snapshot serializer is also what
backward-seek/scrubbing would need. Building the command bus now does not block adding it
later; it is explicitly out of scope here.
## Summary of decisions
- Approach: **A — deterministic command-replay** (re-simulation), no snapshots.
- Scope: **view-only** playback + **manual speed selection**; launched via CLI argument.
- Commands: **base class + derived types**, routed through a dedicated `CommandManager` queue
and a single `Simulation::apply` chokepoint; sim mutators made non-public to **enforce** the
chokepoint. UI fan-in still uses `EventManager`.
- Timing: queue **drained once per frame before the tick batch**, whole queue FIFO, each command
tick-tagged; **build-while-paused preserved**.
- Determinism: **RNG-state checksum** in the file every **30 ticks + after each command**;
**full-state per-tick hashing** in the Catch2 double-run test. Known RNG-only blind spot
accepted for now.
- Platform: **Windows-first**; file format + version/config-hash make a later cross-platform
pass contained.
- Seed: **random**, generated outside the sim, written to the header.
- Config: **config hash** in the header, validated on playback.
- File: **line-oriented append-friendly text**, kept in `data/`, **one file per run**,
**retain everything**.
- Restart: **a boundary** — new file, new seed.
- Replay mode: `CommandManager` `addCommand` is a no-op + pre-filled; gate the two sim-state
polls (schematic choices, game-over); passive "replay ended" instead of the game-over dialog;
keep view + speed.
## Implementation plan
Ordered to de-risk: prove determinism first, then build the command path, then recording, then
playback. Each phase is independently testable and leaves the game in a working state. Phases
0 → 1 → 2 → 3 are strictly sequential; Phase 4 tests can start as soon as their subject exists.
### Phase 0 — Determinism foundation & verification (no replay yet)
The whole feature rests on a deterministic sim, so prove that before building on it.
- Add a `mt19937` state **fingerprint** (fold its serialized state into a 64-bit value).
- Add a **full-state checksum** path (positions, HP, velocities, belt items, building buffers,
scalars), used by tests; each subsystem contributes via its own `appendChecksum(Hasher&)` so
no state knowledge is duplicated.
- Add a Catch2 **double-run determinism test**: run a scripted sequence twice from the same
seed, assert per-tick **full-state** checksums match.
- **Files:** new `lib/sim` checksum helper; small additions to `Simulation`, `BeltSystem`,
`BuildingSystem`, ECS state; new test.
- **Exit criteria:** the double-run test passes. If it fails, fix the nondeterminism here before
proceeding.
### Phase 1 — Command model + chokepoint (no recording yet) — DONE
Reshape mutations to flow through one path; behaviour unchanged.
- Defined `Command` base + derived types (`PlaceBuilding`, `Deconstruct`, `RotateInPlace`,
`SetRecipe`, `SetShipLayout`, `SetSiteSplitterFilters`, `SetSplitterFilters`,
`ClearBeltTiles`, `ApplySchematicChoice`, `Reset`) in `lib`, each with a `playerId` (always 0
now). `PlaceBuilding` is atomic (carries optional config — see the refinement note above).
- Added `CommandManager` (FIFO queue, `enqueue`/`drain`) in `lib`, holding a `Simulation&`.
- Added `Simulation::apply(const Command&)` dispatching by `CommandKind` to the underlying
mutators — the single chokepoint. The `Simulation` player-action mutators are **private**
(compile-time enforced; tests reach them via the `SimulationTestAccess` friend) — see the
decision note above.
- Wired the drain: `GameWorldView::onFrame` calls `CommandManager::drain()` once per frame,
before the tick batch (runs even at 0× → build-while-paused preserved). A drained `Reset`
triggers the view reset.
- Refactored every UI mutation site: `GameWorldView` owns the `CommandManager` and enqueues
directly; `MainWindow` and `SelectionPanel` emit `CommandRequestedEvent` (carrying a
`shared_ptr<const Command>`) which `GameWorldView` subscribes to and enqueues.
- **Files:** new `lib/sim/Command.h`, `CommandManager.{h,cpp}`; `CommandRequestedEvent.h`;
`Simulation.{h,cpp}` (`apply`); `GameWorldView.{h,cpp}`, `MainWindow.cpp`,
`SelectionPanel.cpp`; new `CommandTest.cpp`.
- **Exit criteria:** game plays identically (including build-while-paused); determinism test
still passes; `[command]` equivalence tests pass; no production call site can mutate the sim
directly (compile-enforced: the `Simulation` mutators are private, tests excepted via
`SimulationTestAccess`).
### Phase 2 — Recording — DONE
- `ReplayRecorder` (lib) writes the **line-oriented append file**: header (`version`, `build`,
`seed`, `config_hash`, `timestamp`) then `---`, then one tick-tagged line per command
interleaved with `# checksum <tick> <hex>` lines. Each line is flushed, so a crash mid-run
leaves a valid partial file. `CommandSerializer` produces the per-command text (length-prefixed
variable parts; `ShipLayoutConfig`/filters serialized inline). The build tag is
`__DATE__ " " __TIME__`; the config hash is a 64-bit FNV over the `*.toml` files in the config
dir (re-hashed on playback to detect mismatch).
- **Random seed** generated in `main` (and on each restart in `MainWindow`) via
`std::random_device`; `Simulation` retains it (`getSeed()`) for the header.
- **Recorder hooked at the chokepoint:** `CommandManager` owns an optional `ReplayRecorder`;
`drain()` records each applied command (tick-tagged) + a post-apply RNG checksum, and
`recordTickCheckpoint()` (called per tick from the `onFrame` loop) writes a checksum every 30
ticks. A drained `Reset` rolls the recorder to a new file (restart = boundary).
- **Lifecycle:** `GameWorldView` attaches the recorder at construction (opens the first file with
the initial seed + a tick-0 checksum); files live in `<data>/replays`, named
`<timestamp>_<seed>.replay`; everything is retained.
- **Files:** new `lib/sim/ReplayRecorder.{h,cpp}`, `CommandSerializer.{h,cpp}`; `Simulation`
(`getSeed`); `CommandManager` (recorder + tick checkpoint); `main.cpp` (seed);
`MainWindow.cpp` / `GameWorldView.{h,cpp}` (wiring); new `ReplayRecorderTest.cpp`.
- **Exit criteria met:** recorder + serializer + drain-integration tests pass; the format is
well-formed and flushed per line. (Live GUI recording is wired but not auto-tested here.)
### Phase 3 — Playback — DONE
- `ReplayReader` (lib) parses the file into `{ header, entries }`, where each entry is a command
(with its tick) or a checksum (with its tick), kept in **file order**. `CommandSerializer`
gained the inverse `parseCommand` (round-tripping every verb).
- `--replay <file>` CLI path in `main`: reads the file, **warns** on version / config-hash
mismatch (proceeds anyway), constructs the `Simulation` from the header seed, and threads the
parsed replay through `MainWindow` to `GameWorldView`.
- `ReplayPlayer` (lib) is the playback driver. Rather than reproduce frame batching, it applies
each command at its **exact recorded tick** and verifies checksums **in file order**:
`start()` processes the tick-0 entries, then after every `sim.tick()` `advanceTo(tick)`
consumes that tick's entries (periodic checksum first, then command + its checksum — the order
the file already has). This makes playback independent of replay-time speed/pause.
- `GameWorldView` runs the player in `onFrame` when in replay mode (manual speed/pause kept,
forward-only); `CommandManager` is put in **replay mode** so live input is a no-op. The two
sim-state polls (schematic-choices, game-over) are **gated off**; dialog/escape paths are
input-driven and fall away. A **"REPLAY"** tag plus a passive **"Replay ended"** /
**"Desync at tick N"** overlay replaces the restart dialog.
- **Files:** new `lib/sim/ReplayReader.{h,cpp}`, `ReplayPlayer.{h,cpp}`; `CommandSerializer`
(`parseCommand`); `ReplayRecorder` (shared `computeReplayConfigHash`); `CommandManager`
(replay mode); `main.cpp`; `MainWindow.{h,cpp}`; `GameWorldView.{h,cpp}`; new
`ReplayPlaybackTest.cpp`.
- **Exit criteria met:** the headless `ReplayPlaybackTest` records a scripted run, reads it back,
replays it, and asserts **no desync** and a **byte-identical final state checksum** — including
the periodic-checksum-then-command ordering at a shared tick. (Live GUI playback is wired but
not auto-tested here.)
### Phase 4 — Closing tests & polish — DONE
- **Round-trip:** every command verb serializes → parses → re-serializes identically;
malformed input is rejected (`parseCommand` returns nullptr).
- **Replay-equivalence (headless):** a short scripted run and a **long ~2400-tick run through
waves/combat** each record → read → replay with **no desync** and a **byte-identical final
state checksum**.
- **Desync detection:** corrupting one recorded checksum makes the player report the exact
desync tick.
- **Reset boundary:** a `Reset` drained through `CommandManager` rolls the recorder to a new
file (named by the new seed).
- **Polish:** the end-of-replay / desync overlay dims the world behind the message for
readability; config/version mismatch is warned to the log on launch (its visible consequence,
a desync, is already surfaced by the overlay).
### Status
Record + playback is functionally complete and covered by headless tests. Still deferred (per
this design): snapshots, save/load, backward-seek, cross-platform float hardening, expanding the
file checksum beyond RNG. Known minor rough edge: in replay mode the recipe/layout dialogs and
escape→restart can still open but do nothing (their commands hit the no-op enqueue); fully
disabling that input UI is polish, not correctness.
### Notes
- Phase 1 is the largest (the mutation-site refactor); Phase 0 is the riskiest (it may surface
latent nondeterminism that must be fixed first).
- Still deferred (per this design): snapshots, save/load, backward-seek, cross-platform float
hardening, expanding the file checksum beyond RNG.

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,23 @@
set(TARGET_BASE_NAME "DotaFactory")
set(TARGET_BASE_NAME "${PRODUCT_NAME}")
set(TARGET_APP_NAME "${TARGET_BASE_NAME}")
set(TARGET_LIB_NAME "${TARGET_BASE_NAME}_lib")
set(TARGET_UI_NAME "${TARGET_BASE_NAME}_ui")
set(TARGET_TEST_NAME "${TARGET_BASE_NAME}_test")
set(TARGET_BALANCING_NAME "${TARGET_BASE_NAME}_balancing")
set(TARGET_LIB_INCLUDE_DIRS
"${CMAKE_CURRENT_SOURCE_DIR}/lib"
"${CMAKE_CURRENT_SOURCE_DIR}/external"
)
set(TARGET_UI_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/ui")
set(TARGET_UI_INCLUDE_DIRS
"${CMAKE_CURRENT_SOURCE_DIR}/ui"
# The balancing target compiles a few ui files into itself rather than linking the
# ui library, and the ship stats panel is built from the selection card's parts.
"${CMAKE_CURRENT_SOURCE_DIR}/ui/selection"
)
set(TARGET_TEST_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/test")
set(TARGET_BALANCING_INCLUDE_DIRS "${CMAKE_CURRENT_SOURCE_DIR}/balancing")
set(SRC_DIR "${CMAKE_CURRENT_SOURCE_DIR}")
@@ -76,6 +83,7 @@ unset(SRCS)
set(HDRS)
set(SRCS)
set(UI_INCLUDE_PATH)
add_subdirectory(ui)
@@ -104,6 +112,7 @@ set_target_properties(${TARGET_UI_NAME} PROPERTIES
)
target_include_directories(${TARGET_UI_NAME} PUBLIC
"${TARGET_UI_INCLUDE_DIRS}"
"${UI_INCLUDE_PATH}"
"${TARGET_LIB_INCLUDE_DIRS}"
"${LIB_INCLUDE_PATH}"
)
@@ -115,6 +124,7 @@ target_link_libraries(${TARGET_UI_NAME}
Qt5::Network
Qt5::Multimedia
Qt5::Charts
Qt5::Svg
)
target_compile_definitions(${TARGET_UI_NAME} PRIVATE TOML_FLOAT_CHARCONV=0)
@@ -177,10 +187,23 @@ set_target_properties(${TARGET_APP_NAME} PROPERTIES
VS_DEBUGGER_WORKING_DIRECTORY "${OUTPUT_ROOT_PATH}/$(Configuration)/app/"
)
target_compile_definitions(${TARGET_APP_NAME} PRIVATE
DOTA_FACTORY_CONFIG_DIR="${CMAKE_SOURCE_DIR}/bin/config"
CONFIG_DIR="${CMAKE_SOURCE_DIR}/bin/app/data/config"
)
target_link_libraries(${TARGET_APP_NAME} ${TARGET_UI_NAME})
# Embed the Windows version resource so the version shows on the executable's
# Details tab (right-click -> Properties). Values come from cmake/version.cmake
# (version numbers) and the product identity variables in the top-level
# CMakeLists.txt. MSVC compiles the .rc automatically once it is a target source.
if (WIN32)
configure_file(
"${CMAKE_SOURCE_DIR}/cmake/version.rc.in"
"${CMAKE_CURRENT_BINARY_DIR}/version.rc"
@ONLY
)
target_sources(${TARGET_APP_NAME} PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/version.rc")
endif ()
unset(APP_FILES)
unset(RELATIVE_HDRS)
unset(RELATIVE_SRCS)
@@ -188,6 +211,74 @@ unset(HDRS)
unset(SRCS)
# ============================================================
# balancing — ship balancing tool; depends on lib + QtWidgets
# ============================================================
if (WIN32)
COPY_QT_BINARIES("${OUTPUT_ROOT_PATH}/Debug/balancing/" True)
COPY_QT_BINARIES("${OUTPUT_ROOT_PATH}/Release/balancing/" False)
execute_process(
COMMAND "cmd.exe" "/k" "rmdir" "${BACKSLASHED_OUTPUT_ROOT_PATH}\\Debug\\balancing\\data" & "mklink" "/d" "/j" "${BACKSLASHED_OUTPUT_ROOT_PATH}\\Debug\\balancing\\data" "${BACKSLASHED_CMAKE_SOURCE_DIR}\\bin\\balancing\\data" & exit
COMMAND "cmd.exe" "/k" "rmdir" "${BACKSLASHED_OUTPUT_ROOT_PATH}\\Release\\balancing\\data" & "mklink" "/d" "/j" "${BACKSLASHED_OUTPUT_ROOT_PATH}\\Release\\balancing\\data" "${BACKSLASHED_CMAKE_SOURCE_DIR}\\bin\\balancing\\data" & exit
)
endif ()
set(HDRS)
set(SRCS)
add_subdirectory(balancing)
set(RELATIVE_HDRS)
foreach (_file ${HDRS})
file(RELATIVE_PATH _relPath "${SRC_DIR}" "${_file}")
list(APPEND RELATIVE_HDRS "${_relPath}")
endforeach()
set(RELATIVE_SRCS)
foreach (_file ${SRCS})
file(RELATIVE_PATH _relPath "${SRC_DIR}" "${_file}")
list(APPEND RELATIVE_SRCS "${_relPath}")
endforeach()
add_files(BALANCING_FILES ${RELATIVE_HDRS} ${RELATIVE_SRCS})
add_executable(${TARGET_BALANCING_NAME} ${BALANCING_FILES})
create_source_groups(${BALANCING_FILES})
foreach (OUTPUTCONFIG ${CMAKE_CONFIGURATION_TYPES})
string(TOUPPER ${OUTPUTCONFIG} OUTPUTCONFIG)
set_target_properties(${TARGET_BALANCING_NAME} PROPERTIES
RUNTIME_OUTPUT_DIRECTORY_${OUTPUTCONFIG} "${OUTPUT_ROOT_PATH}/${OUTPUTCONFIG}/balancing/"
)
endforeach ()
set_target_properties(${TARGET_BALANCING_NAME} PROPERTIES
AUTOMOC ON
CXX_STANDARD 17
VS_DEBUGGER_WORKING_DIRECTORY "${OUTPUT_ROOT_PATH}/$(Configuration)/balancing/"
)
target_include_directories(${TARGET_BALANCING_NAME} PRIVATE
"${TARGET_BALANCING_INCLUDE_DIRS}"
"${TARGET_LIB_INCLUDE_DIRS}"
"${TARGET_UI_INCLUDE_DIRS}"
"${LIB_INCLUDE_PATH}"
)
target_compile_definitions(${TARGET_BALANCING_NAME} PRIVATE
CONFIG_DIR="${CMAKE_SOURCE_DIR}/bin/app/data/config"
BALANCING_CONFIG="${CMAKE_SOURCE_DIR}/bin/balancing/data/balancing.toml"
TOML_FLOAT_CHARCONV=0
)
target_link_libraries(${TARGET_BALANCING_NAME} ${TARGET_LIB_NAME} Qt5::Widgets ${OPENGL_LIBRARIES})
unset(BALANCING_FILES)
unset(RELATIVE_HDRS)
unset(RELATIVE_SRCS)
unset(HDRS)
unset(SRCS)
# ============================================================
# tests — Catch2 tests; links against lib only (no QtWidgets)
# ============================================================
@@ -206,7 +297,7 @@ set_property(TARGET ${TARGET_TEST_NAME} PROPERTY INCLUDE_DIRECTORIES
"${LIB_INCLUDE_PATH}"
)
target_compile_definitions(${TARGET_TEST_NAME} PRIVATE
DOTA_FACTORY_CONFIG_DIR="${CMAKE_SOURCE_DIR}/src/test/config"
CONFIG_DIR="${CMAKE_SOURCE_DIR}/bin/test/data/config"
)
target_link_libraries(${TARGET_TEST_NAME} ${TARGET_LIB_NAME})
@@ -218,3 +309,4 @@ target_link_libraries(${TARGET_TEST_NAME} ${TARGET_LIB_NAME})
set(TARGET_LIB_NAME "${TARGET_LIB_NAME}" PARENT_SCOPE)
set(TARGET_UI_NAME "${TARGET_UI_NAME}" PARENT_SCOPE)
set(TARGET_APP_NAME "${TARGET_APP_NAME}" PARENT_SCOPE)
set(TARGET_BALANCING_NAME "${TARGET_BALANCING_NAME}" PARENT_SCOPE)

View File

@@ -1,4 +1,7 @@
#include <memory>
#include <optional>
#include <random>
#include <string>
#include <QApplication>
#include <QDir>
@@ -8,6 +11,8 @@
#include "logging.h"
#include "LogManager.h"
#include "MainWindow.h"
#include "ReplayReader.h"
#include "ReplayRecorder.h"
#include "Simulation.h"
int main(int argc, char *argv[])
@@ -31,10 +36,54 @@ int main(int argc, char *argv[])
QDir().mkdir(dataDir.dirName());
}
GameConfig config = ConfigLoader::loadFromDirectory(DOTA_FACTORY_CONFIG_DIR);
std::unique_ptr<Simulation> sim = std::make_unique<Simulation>(std::move(config));
// Optional "--replay <file>" launches view-only playback of a recorded run.
std::optional<std::string> replayPath;
for (int i = 1; i + 1 < argc; ++i)
{
if (std::string(argv[i]) == "--replay")
{
replayPath = argv[i + 1];
break;
}
}
MainWindow window(sim.get(), std::string(DOTA_FACTORY_CONFIG_DIR));
GameConfig config = ConfigLoader::loadFromDirectory(CONFIG_DIR);
unsigned int seed = 0;
std::shared_ptr<ParsedReplay> replay;
if (replayPath.has_value())
{
std::optional<ParsedReplay> parsed = readReplayFile(*replayPath);
if (!parsed.has_value())
{
LOG_ERROR("Failed to read replay file: " + *replayPath);
return 1;
}
// Warn (but proceed) on identity mismatches: a different config or build
// can desync playback (see docs/replay_design.md).
if (parsed->header.version != 1)
{
LOG_WARNING_STREAM(<< "Replay format version " << parsed->header.version
<< " differs from 1; playback may fail");
}
if (computeReplayConfigHash(CONFIG_DIR) != parsed->header.configHash)
{
LOG_WARNING("Replay config hash mismatch; playback may desync");
}
seed = parsed->header.seed;
replay = std::make_shared<ParsedReplay>(std::move(*parsed));
}
else
{
// Random seed generated outside the sim so the Simulation stays a pure
// function of (seed, config, commands); written to the replay header
// (see docs/replay_design.md "Seed and config").
seed = std::random_device{}();
}
std::unique_ptr<Simulation> sim = std::make_unique<Simulation>(std::move(config), seed);
MainWindow window(sim.get(), std::string(CONFIG_DIR), replay);
window.show();
const int ret = application.exec();

Some files were not shown because too many files have changed in this diff Show More