diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..49c507f --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,203 @@ +# 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 (QPoint, QVector3D, QRect, etc., as required by the coding guidelines), toml++, and tinyexpr. It contains no QtWidgets, no painting, and no QApplication. +- 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 (30 or 60 Hz, TBD; choose once and document here). +- The outer loop advances `N` ticks per wall-clock frame, where `N` is derived from the selected game speed: + - 0× → 0 ticks/frame (pause) + - 0.5× → one tick every two frames + - 1× → one tick/frame + - 2× → two ticks/frame + - 4× → four ticks/frame +- 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 falls out for free. + +## 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. + +## CMake Target Layout + +Three product targets plus tests: + +- `lib/` — simulation + config. Depends on Qt Core, toml++, tinyexpr. No QtWidgets. +- `ui/` — QtWidgets code: header bar, game world view, selected building panel, build button grid. Depends on `lib`. +- `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 tryTakeItem(Port port); + void clearTiles(const std::vector& tiles); // REQ-UI-BELT-CLEAR + void tick(); + void forEachVisualItem(QRect viewportTiles, + std::function 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 { + QPoint tile; + QSize footprint; + Rotation rotation; + BuildingType type; + InputBuffer inputBuffer; + OutputBuffer outputBuffer; + // Production timer and current recipe, where applicable. + std::optional 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. + +## Ships + +Ships follow a component-composition model using `std::optional` 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 currentTarget; }; +struct SalvageCargo { int capacity; int current; }; +struct RepairTool { float ratePerTick; std::optional 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 currentTarget; }; +struct ScrapCollector { std::optional scrapTarget; EntityId deliveryBay; }; +struct RepairBehavior { RepairTargetPriority priority; + std::optional currentTarget; }; +struct HomeReturn { float retreatHpFraction; QVector3D homePos; }; +``` + +### Ship + +```cpp +struct Ship { + EntityId id; + QVector3D position; + QVector3D velocity; + float hp; + float maxHp; + int level; + ShipBlueprintId blueprint; + + // Capabilities + std::optional weapon; + std::optional cargo; + std::optional repairTool; + + // Behaviors + std::optional threatResponse; + std::optional scrapCollector; + std::optional repairBehavior; + std::optional 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` 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` pattern gives the same modeling expressiveness with zero dependencies and no learning curve. Migration to EnTT is mechanical (`std::optional weapon` becomes `registry.emplace(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. + +## 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).