Files
dota_factory/docs/architecture.md

204 lines
9.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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 {
QPoint tile;
QSize footprint;
Rotation rotation;
BuildingType type;
InputBuffer inputBuffer;
OutputBuffer outputBuffer;
// Production timer and current recipe, where applicable.
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.
## 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<QVector3D> scrapTarget; EntityId deliveryBay; };
struct RepairBehavior { RepairTargetPriority priority;
std::optional<EntityId> 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> 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.
## 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).