361 lines
23 KiB
Markdown
361 lines
23 KiB
Markdown
# Architecture
|
||
|
||
This document captures the architectural decisions for the project. It is a complement to `requirements.md`; it explains *how* the game is built, not *what* it does.
|
||
|
||
## Goals
|
||
|
||
- Keep the simulation testable with Catch2 in a headless environment.
|
||
- Keep game speed (0×–4×) and pause trivial to implement (REQ-UI-SPEED).
|
||
- Keep config-driven balancing (formulas, recipes, station and ship stats) fast to iterate on.
|
||
- Make the belt subsystem replaceable without touching the rest of the game.
|
||
- Let ship behavior grow (hybrid ships, new capabilities) without rewriting existing code.
|
||
|
||
## Simulation / Presentation Split
|
||
|
||
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.).
|
||
|
||
This split is enforced at the CMake target level (see below). Tests link only against the simulation library and run without a display server.
|
||
|
||
## Fixed-Timestep Tick-Based Simulation
|
||
|
||
The simulation advances in discrete ticks. All game quantities — production timers, belt item progress, threat accumulation, wave timers, ship cooldowns — are measured in ticks, not wall-clock seconds.
|
||
|
||
- 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.
|
||
|
||
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.
|
||
|
||
## Config Loading
|
||
|
||
Config files (`world.toml`, `buildings.toml`, `recipes.toml`, `ships.toml`, `stations.toml`) are loaded once at startup.
|
||
|
||
- `toml++` parses the files into strongly-typed config structs.
|
||
- Formula strings (e.g., threat accumulation, enemy ship level as a function of `t`, per-ship stats as functions of `level`, station stats as functions of generation) are compiled once via `tinyexpr` at load time and stored as callable objects. They are never re-parsed during simulation.
|
||
- Configs are immutable after load. Any formula that fails to parse, or any required field that is missing or malformed, aborts startup with a clear error message — never mid-game.
|
||
- The UI layer loads its own TOML file, `visuals.toml`, using the same toml++-based pattern and the same immutability / fail-fast rule (see Rendering → Visual Parameters). The simulation never reads it.
|
||
|
||
## Coordinate System
|
||
|
||
See REQ-GW-COORDS for the authoritative tile-coordinate convention. This section captures the programming-level conventions that follow from it.
|
||
|
||
- 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.
|
||
- 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.
|
||
|
||
## Core Types
|
||
|
||
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.
|
||
- `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.
|
||
- `BlueprintDropEvent` — `struct BlueprintDropEvent { ShipBlueprintId blueprint; int newLevel; bool wasNewUnlock; }`. Emitted when a destroyed enemy-defence-station set awards a blueprint (REQ-DEF-BLUEPRINT-DROP). The UI renders a toast (REQ-UI-BLUEPRINT-TOAST); `wasNewUnlock` chooses between the "unlocked" and "level → N" wording.
|
||
|
||
## Sim → UI Events
|
||
|
||
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 blueprint drops (REQ-UI-BLUEPRINT-TOAST). Additional event types can be added here later (e.g., building-complete, unit-death flashes) without changing the pattern.
|
||
|
||
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.
|
||
|
||
We deliberately do **not** use `QObject` signals/slots or `QEvent`:
|
||
|
||
- **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.
|
||
|
||
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.
|
||
|
||
## Tick Order
|
||
|
||
Within a single simulation tick, subsystems run in this fixed order. The order is load-bearing for determinism and for avoiding one-tick-delay artifacts (e.g., items landing on a belt but not advancing in the same tick).
|
||
|
||
1. **Wave scheduler** — advance wave timer; on trigger, compute wave composition per REQ-WAV-TRIGGER and schedule spawn times across REQ-WAV-SPAWN-DURATION; spawn any enemy ships whose scheduled time has arrived this tick.
|
||
2. **Threat accumulation** — add `max(0, threat_rate_formula(t))` × tick_dt to threat level (REQ-WAV-THREAT-RATE).
|
||
3. **Belt → building pull** — buildings drain eligible items from adjacent belt tiles into per-material input buffers (REQ-MAT-INPUT-PORTS).
|
||
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 blueprint (REQ-DEF-BLUEPRINT-DROP) and append a `BlueprintDropEvent`; remove entities.
|
||
10. **`tickMovement`** — advance ship positions based on final `MovementIntent`.
|
||
11. **Scrap despawn** — decrement scrap timers; remove expired scrap (REQ-RES-SCRAP-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.
|
||
- `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.
|
||
|
||
## 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:
|
||
|
||
```cpp
|
||
class BeltSystem {
|
||
public:
|
||
bool tryPutItem(Port port, Item item);
|
||
std::optional<Item> tryTakeItem(Port port);
|
||
void clearTiles(const std::vector<QPoint>& tiles); // REQ-UI-BELT-CLEAR
|
||
void tick();
|
||
void forEachVisualItem(QRect viewportTiles,
|
||
std::function<void(VisualItem)> visit) const;
|
||
};
|
||
|
||
struct VisualItem {
|
||
ItemType type;
|
||
QPointF worldPos; // in tile units, fractional
|
||
};
|
||
```
|
||
|
||
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".
|
||
|
||
### 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.
|
||
|
||
### Rendering Note
|
||
|
||
Rendering does not cache item identities across frames. Each frame calls `forEachVisualItem` and paints whatever it yields. This is correct because items can merge onto the same tile, splitters can reroute, and `clearTiles` wipes items without warning.
|
||
|
||
If a smoother animation than the tick rate is ever needed, tick interpolation can be added inside `forEachVisualItem` later. Not needed initially.
|
||
|
||
## Buildings
|
||
|
||
Buildings are plain structs with a fixed type determined at construction.
|
||
|
||
```cpp
|
||
struct Building {
|
||
EntityId id;
|
||
QPoint tile;
|
||
QSize footprint;
|
||
Rotation rotation;
|
||
BuildingType type;
|
||
float hp; // relevant for HQ and defence stations; ignored otherwise.
|
||
float maxHp;
|
||
InputBuffer inputBuffer;
|
||
OutputBuffer outputBuffer;
|
||
// Production timer, the recipe currently running, and — for reprocessing
|
||
// plants — the output item picked at cycle start (REQ-MAT-CYCLE).
|
||
std::optional<Production> production;
|
||
};
|
||
```
|
||
|
||
- The uniform "input buffer → production timer → output buffer" pattern across miner, smelter, assembler, reprocessing plant, and shipyard is driven by the recipe config, not by a class hierarchy.
|
||
- 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
|
||
|
||
Scrap is the only non-ship, non-building entity in the simulation:
|
||
|
||
```cpp
|
||
struct Scrap {
|
||
EntityId id;
|
||
QVector2D position; // world units, tile-fractional; ship-center convention
|
||
int amount;
|
||
Tick despawnAt; // absolute tick at which the scrap 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`.
|
||
|
||
## Ships
|
||
|
||
Ships follow a component-composition model using `std::optional<Component>` members. Each orthogonal capability is a component; each behavior is also a component, ticked by its own system. A ship's "role" is just which components it has — not a class or an enum.
|
||
|
||
### Capability Components
|
||
|
||
```cpp
|
||
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; };
|
||
```
|
||
|
||
### 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.
|
||
|
||
```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; };
|
||
```
|
||
|
||
### Ship
|
||
|
||
```cpp
|
||
struct Ship {
|
||
EntityId id;
|
||
QVector2D position;
|
||
QVector2D velocity;
|
||
float hp;
|
||
float maxHp;
|
||
int level;
|
||
ShipBlueprintId blueprint;
|
||
|
||
// Capabilities
|
||
std::optional<Weapon> weapon;
|
||
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;
|
||
|
||
// Written by behavior systems, 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.
|
||
|
||
- `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`.
|
||
|
||
### 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.
|
||
|
||
Initial priority order (subject to tuning):
|
||
|
||
```
|
||
HomeReturn > ThreatResponse > RepairBehavior > ScrapCollector
|
||
```
|
||
|
||
`tickMovement` runs last. Intents are cleared at the start of each tick.
|
||
|
||
### Why Not ECS
|
||
|
||
EnTT would be a reasonable fit for ships specifically, but at the scale of this game (low hundreds of ships) the iteration-speed benefit is not decisive. The `std::optional<Component>` pattern gives the same modeling expressiveness with zero dependencies and no learning curve. Migration to EnTT is mechanical (`std::optional<Weapon> weapon` becomes `registry.emplace<Weapon>(entity, ...)`) if it ever becomes warranted.
|
||
|
||
Buildings and the belt subsystem stay outside any entity model regardless of what ships do — they are the wrong shape for ECS.
|
||
|
||
## 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.
|
||
|
||
### Render Loop
|
||
|
||
- A `QTimer` in `GameWorldView` fires at 60 Hz and calls `update()`, requesting a repaint. Render rate is fixed at 60 FPS regardless of game speed.
|
||
- The sim advances independently via an accumulator-based driver:
|
||
- Each frame, compute `accumulator += elapsedWallMs * gameSpeedMultiplier`.
|
||
- While `accumulator >= tickDurationMs` (= 1000/30 ≈ 33.33 ms), advance the sim by one tick and subtract.
|
||
- 0× clamps the multiplier to 0 (pause). 0.5× / 2× / 4× scale accumulation directly.
|
||
- This decouples render rate (60 FPS) from sim rate (30 Hz, see Fixed-Timestep Tick-Based Simulation above) and keeps all game speeds correct across variable frame timing.
|
||
|
||
### 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()` / `drainBlueprintDropEvents()` / `forEachVisualItem()` APIs already support a clean snapshot-and-render split; a single mutex at the sim boundary would suffice.
|
||
|
||
### Layer Order (back to front)
|
||
|
||
1. **Tile background** — asteroid tiles and space tiles within the viewport.
|
||
2. **Buildings** — factory buildings, HQ, player and enemy defence stations.
|
||
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** — blueprint toasts (REQ-UI-BLUEPRINT-TOAST) and any other 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.
|
||
|
||
### Culling
|
||
|
||
The renderer iterates only entities and tiles whose world X lies within the visible viewport. In particular, everything at or past the rightmost enemy defence station (i.e., the current enemy buffer zone) is culled — consistent with REQ-GW-SCROLL-LIMIT.
|
||
|
||
### Visual Parameters
|
||
|
||
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.
|
||
|
||
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`:
|
||
|
||
```toml
|
||
[tiles]
|
||
asteroid = { fill = "#4a4038" }
|
||
space = { fill = "#0a0a15" }
|
||
|
||
[buildings.miner]
|
||
fill = "#6b4a2c"
|
||
outline = "#ffffff"
|
||
glyph = "M"
|
||
|
||
# ... one [buildings.<type>] section per BuildingType
|
||
# ... one [stations.<player|enemy>] section
|
||
# ... one [items.<item_type>] section per ItemType
|
||
|
||
[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" }
|
||
|
||
[beams]
|
||
color = "#ff6600"
|
||
width_px = 2
|
||
|
||
[overlays]
|
||
ghost_valid = "#ffffff44"
|
||
ghost_invalid = "#ff000044"
|
||
demolish_tint = "#ff000033"
|
||
selection_rect = "#00ff00"
|
||
|
||
[toast]
|
||
bg = "#000000cc"
|
||
fg = "#ffffff"
|
||
font_size = 14
|
||
```
|
||
|
||
Key names mirror sim identifiers (`buildings.miner` ↔ `BuildingType::Miner`, `items.<x>` ↔ `ItemType`). The UI builds a lookup indexed by the sim's enum or string id; a missing or malformed entry aborts startup with a clear error, same as sim configs. Adding a new `BuildingType` or `ItemType` to the sim requires adding a matching `visuals.toml` entry — the fail-on-missing rule catches the omission at startup rather than silently rendering invisible entities.
|
||
|
||
### Animation
|
||
|
||
v1 uses no sprite atlases, no anti-aliasing, and no tick-to-tick interpolation (ships snap to their 30 Hz positions). This is not an architectural constraint — sprite atlases, AA, and interpolation are all incremental upgrades behind the same `draw<X>` functions and the `visuals.toml` schema.
|
||
|
||
## Testing
|
||
|
||
- Catch2 tests link against `lib` only. No QApplication, no display.
|
||
- The tick-based, deterministic simulation is directly testable: construct a world, step N ticks, assert state.
|
||
- Config loading is tested with small fixture TOML files.
|
||
- Formula parsing failures must be covered (malformed input produces a clear error, does not silently default).
|