Files
dota_factory/docs/plan.md

411 lines
18 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.
# Implementation Plan — Steps 4 through 8
Cross-references: `architecture.md` (design), `requirements.md` (REQ-* citations).
## Status
| Step | Scope | State |
|------|-------|-------|
| 1 | Config loading (Formula, ConfigLoader, all config structs) | ✅ done |
| 2 | Simulation shell + TickDriver + entity id allocator + event queues | ✅ done |
| 3 | Belt subsystem (placement, port interface, per-tile v1, splitter routing, clearTiles, visual iteration) | ✅ done |
| 4 | Buildings + placement + belt↔building transport | ✅ done |
| 5 | Scrap + ships skeleton (data + spawning, no AI) | ✅ done |
| 6 | Ship behavior systems + movement arbitration | ⬜ next |
| 7 | Waves, threat accumulation, combat resolution, deaths & loot | ⬜ |
| 8 | UI layer (GameWorldView, visuals.toml, panels, build/demolish, speed controls) | ⬜ |
Tick order reference (architecture.md §Tick Order):
1. Wave scheduler — step 7
2. Threat accumulation — step 7
3. Belt→building pull — step 4
4. Building production — step 4
5. Building→belt push — step 4
6. Belt tick — step 3 ✅
7. Ship behavior systems — step 6
8. Combat resolution — step 7
9. Deaths & loot — step 7
10. `tickMovement` — step 6
11. Scrap despawn — step 5
Each new subsystem slots into `Simulation::tick()` in this exact order.
---
## Step 4 — Buildings + placement + belt↔building transport
Covers REQ-BLD-*, REQ-MAT-*. Introduces the first stateful gameplay loop: miners pull nothing, produce ore, push onto belts; smelters pull ore, produce ingots; etc.
### New types (`src/lib/sim/`)
```cpp
struct InputBuffer {
std::map<ItemType, int> counts;
std::map<ItemType, int> caps; // per-material; = 2× per-cycle requirement
};
struct OutputBuffer {
std::vector<Item> items;
int capacity; // 2× per-cycle output; 1× for ReprocessingPlant
};
struct Production {
std::string recipeId;
Tick completesAt;
std::vector<Item> chosenOutputs; // resolved at cycle start for reprocessing
};
struct Building {
EntityId id;
QPoint tile; // origin of footprint (top-left)
QSize footprint;
Rotation rotation;
BuildingType type;
float hp;
float maxHp;
std::string recipeId; // current recipe; empty = none selected
InputBuffer inputBuffer;
OutputBuffer outputBuffer;
std::optional<Production> production;
};
```
### Surface-mask parsing (new utility in `src/lib/config/`)
```cpp
struct ParsedSurfaceMask {
QSize footprint;
std::vector<QPoint> bodyCells; // relative to tile origin
std::vector<Port> outputPorts; // tile = adjacent cell OUTSIDE footprint
// direction = away from building
std::vector<QPoint> shipDockCells; // 'S' cells — for salvage bay / shipyard
};
ParsedSurfaceMask parseSurfaceMask(const std::vector<std::string>& rows,
Rotation rotation);
```
Conventions (inferred from `buildings.toml`):
- `A` = body cell
- `S` = ship dock cell (part of footprint; shipyard/salvage bay)
- `>`, `<`, `^`, `v` = direction marker on cell ADJACENT to body, NOT part of footprint
- space = empty within bounding box
- Rotation transforms the grid 90°/180°/270° around the mask origin
### Placement + BuildingSystem
Either a new `BuildingSystem` class in `src/lib/sim/` or methods on `Simulation`. Recommended: a `BuildingSystem` owned by `Simulation`, mirroring `BeltSystem`'s pattern.
```cpp
class BuildingSystem {
public:
BuildingSystem(const GameConfig& config, BeltSystem& belts,
std::function<EntityId()> allocateId,
std::mt19937& rng);
// Placement (called by UI commands in Step 8)
EntityId place(BuildingType type, QPoint tile, Rotation rotation);
void demolish(EntityId id);
void setRecipe(EntityId id, const std::string& recipeId);
// Tick hooks — called from Simulation::tick() in the correct order
void tickBeltPull(); // step 3
void tickProduction(); // step 4
void tickBeltPush(); // step 5
// Queries (for UI)
const Building* find(EntityId id) const;
std::vector<Building> all() const; // for rendering
};
```
Belts and splitters are registered with `BeltSystem` directly from `BuildingSystem::place` when type == Belt or Splitter — these don't get `Building` instances (architecture.md §Buildings).
### Production cycle (REQ-MAT-CYCLE)
In `tickProduction`:
```
for each building with recipeId set:
if building has active production:
if currentTick >= production.completesAt:
deposit chosenOutputs into outputBuffer
clear production
continue
// idle: try to start a new cycle
recipe = config.findRecipe(recipeId)
if inputs available in buffers AND outputs fit in outputBuffer:
consume inputs
if reprocessing: roll chosenOutputs via discrete_distribution on probabilities
else: chosenOutputs = recipe.outputs (expanded by amounts)
// re-check fit for reprocessing (chosen output must fit)
production = {recipeId, currentTick + secondsToTicks(recipe.durationSeconds), chosenOutputs}
```
Reprocessing uses `Simulation`'s `std::mt19937` + `std::discrete_distribution<>`. Do NOT use the legacy `WeightedRandomGenerator` (uses `auto` and float precision).
### Belt↔building interaction
`tickBeltPull` (step 3): for each building with `recipeId`, walk its footprint's edges; for each adjacent tile, construct `Port{adjTile, directionFromBeltToBuilding}` and call `belts.tryTakeItem(port)`. Accept if the item matches a required input AND the per-material buffer has space.
`tickBeltPush` (step 5): for each output port on each building with items in outputBuffer, call `belts.tryPutItem(port, item)`. On success, remove from buffer.
### Files
**New:**
- `src/lib/sim/Building.h`
- `src/lib/sim/BuildingSystem.h` / `.cpp`
- `src/lib/config/SurfaceMask.h` / `.cpp`
- `src/test/BuildingTest.cpp`
- `src/test/SurfaceMaskTest.cpp`
**Modified:**
- `src/lib/sim/Simulation.h` / `.cpp` — own `BeltSystem` + `BuildingSystem`; call their tick hooks in order
- `src/lib/sim/CMakeLists.txt`
- `src/lib/config/CMakeLists.txt`
- `src/test/CMakeLists.txt`
### Tests
- **Surface mask:** all four rotations of miner, smelter, splitter; output ports land on correct adjacent cells
- **Placement:** place miner, verify footprint occupies expected tiles; demolish removes it
- **Belt registration:** placing a Belt calls `BeltSystem::placeBelt`; demolishing calls `removeTile`
- **Miner cycle:** miner with `mine_iron_ore` recipe deposits iron_ore into outputBuffer after recipe duration ticks
- **Smelter cycle:** feed iron_ore into input buffer, 2 ore → 1 ingot in output after duration
- **Output buffer cap:** buffer fills to 2×, production stalls
- **Reprocessing cap:** buffer holds exactly 1× (REQ-MAT-OUTPUT-BUFFER-REPROCESSING)
- **Reprocessing RNG:** seed-deterministic weighted output pick; N trials match expected distribution within tolerance
- **Belt pull:** belt adjacent to smelter input edge delivers ore; smelter input buffer increments
- **Belt push:** miner outputBuffer drains onto adjacent belt each tick when space available
- **Recipe change:** `setRecipe` clears input + output buffers (REQ-MAT-INPUT-BUFFER, REQ-MAT-OUTPUT-BUFFER)
---
## Step 5 — Scrap + ships skeleton
Data structures + spawning only. No AI yet. Covers REQ-RES-SCRAP-DROP, REQ-SHP-STATS, REQ-BLD-SHIPYARD scaffolding.
### New types
```cpp
struct Scrap {
EntityId id;
QVector2D position; // tile units; ship-center convention
int amount;
Tick despawnAt;
};
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 ThreatResponse { float engagementRange; /* CombatStance, CombatTargetPriority */
std::optional<EntityId> currentTarget; };
struct ScrapCollector { std::optional<QVector2D> scrapTarget; EntityId deliveryBay; };
struct RepairBehavior { /* RepairTargetPriority */ std::optional<EntityId> currentTarget; };
struct HomeReturn { float retreatHpFraction; QVector2D homePos; };
struct Ship {
EntityId id;
QVector2D position;
QVector2D velocity;
float hp;
float maxHp;
int level;
std::string blueprintId; // matches ShipDef::id
std::optional<Weapon> weapon;
std::optional<SalvageCargo> cargo;
std::optional<RepairTool> repairTool;
std::optional<ThreatResponse> threatResponse;
std::optional<ScrapCollector> scrapCollector;
std::optional<RepairBehavior> repairBehavior;
std::optional<HomeReturn> homeReturn;
MovementIntent intent;
};
```
### `ShipSystem` / `ScrapSystem`
Small classes owned by `Simulation`:
- `ShipSystem::spawn(ShipDef, level, QVector2D position)` — builds a Ship from the config by evaluating per-role formulas at `level`; components present iff corresponding `ShipDef` sections are present
- `ShipSystem::forEach(…)` — for Step 6 behavior systems to iterate
- `ScrapSystem::spawn(QVector2D position, int amount)` — tick step 9 caller
- `ScrapSystem::tickDespawn()` — step 11
Still no AI tick hooks; `Simulation::tick()` gains step 11 only.
### Tests
- **Ship spawn:** combat ship has Weapon + ThreatResponse; salvage ship has SalvageCargo + ScrapCollector; stats evaluated from formulas at given level
- **Component absence:** salvage ship has no Weapon; combat ship has no SalvageCargo
- **Scrap spawn + despawn:** scrap created with `despawnAt = currentTick + secondsToTicks(world.scrapDespawnSeconds)`; after that many ticks `tickDespawn` removes it
- **Entity ids:** spawned ships/scrap receive strictly increasing ids from `Simulation::allocateId` (needs to be exposed to `ShipSystem`/`ScrapSystem` via constructor callback)
### Files
New: `Scrap.h`, `Ship.h`, `ShipSystem.h/.cpp`, `ScrapSystem.h/.cpp`, `ShipTest.cpp`, `ScrapTest.cpp`.
Modified: `Simulation.*`, `src/lib/sim/CMakeLists.txt`, `src/test/CMakeLists.txt`.
---
## Step 6 — Ship behavior systems + movement arbitration
All four behaviors + `tickMovement`, one at a time with focused tests. Movement intent priority (architecture.md §Movement Arbitration):
```
HomeReturn > ThreatResponse > RepairBehavior > ScrapCollector
priorities: 4 3 2 1
```
Behaviors write `MovementIntent{priority, target}` on the ship; higher priority overwrites lower. `MovementIntent` is cleared at the start of the ship behavior step.
### Sub-steps (independent commits recommended)
**6a. `tickHomeReturn`** — if `hp/maxHp < retreatHpFraction`, write intent toward `homePos` with priority 4.
**6b. `tickThreatResponse`** — acquire enemy target within `engagementRange` if none; hold existing target if still valid. If target in weapon range, fire (emit FireEvent, apply damage to target's hp, start cooldown — stays in Step 7 combat resolution if we want to centralize damage; for modularity, fire here). Else write intent toward target, priority 3.
**6c. `tickRepairBehavior`** — find damaged friendly target; move toward if out of repair range, repair if in range. Priority 2.
**6d. `tickScrapCollector`** — if cargo full, intent = `deliveryBay.tile`; else find nearest scrap, intent = scrap.position. On arrival, consume scrap (calls into `ScrapSystem`), increment cargo. Priority 1.
**6e. `tickMovement`** — for each ship with an intent, advance position toward `intent.target` by `speedPerTick` (from ShipDef speed formula). No pathfinding v1 — straight line.
### Design decision: combat resolution split
Two options for where fire/damage happens:
- (A) Inside `tickThreatResponse` — simpler, atomic
- (B) In a separate `tickCombatResolution` step 8 — matches architecture.md exactly
Recommend (B) for fidelity to architecture.md. `tickThreatResponse` only sets target + writes movement intent. Step 7 runs combat resolution across ships + stations uniformly.
### Tests
- Intent priority: ship with low hp + weapon + enemy in range routes to homePos, not enemy
- Target acquisition: closest enemy within engagementRange; unchanged while still valid
- Repair ship finds damaged ally, moves in, repairs
- Salvage ship picks up scrap, returns when cargo full, cargo empties at delivery bay
- Movement: ship travels exactly `speed × secondsToTicks(duration)` tiles over N ticks
---
## Step 7 — Waves + threat + combat + deaths & loot
Fills tick steps 1, 2, 8, 9. Covers REQ-WAV-*, REQ-SHP-FIRING-*, REQ-DEF-*, REQ-PSH-*, REQ-RES-SCRAP-DROP.
### Tick step 1 — Wave scheduler
```
- advance m_waveTimer by 1 tick
- if between waves: at wave trigger (random gap within world.waves.gap_min/max_seconds),
compute wave composition by drawing ship picks up to threat budget
(REQ-WAV-TRIGGER, REQ-WAV-THREAT-COST) using world.waves.threat_rate_formula
- schedule spawn times across spawn_duration_seconds
- spawn any enemy ships whose scheduled tick has arrived
```
Ships eligible for waves: those with `threat.costFormula(elapsedSeconds) > 0`.
### Tick step 2 — Threat accumulation
`m_threatLevel += max(0.0, world.waves.threatRateFormula.evaluate(elapsedSeconds)) × kTickDurationSeconds`.
### Tick step 8 — Combat resolution
Unified across ships + defence stations (player + enemy). Each shooter has {damage, range, fireRateHz, cooldown, currentTarget}. If target in range and cooldown ≤ 0:
- apply damage to target's hp
- emit `FireEvent{shooter.id, target.id, currentTick}` into `Simulation::m_fireEvents`
- set cooldown = `kTickRateHz / fireRateHz`
Stations fire per REQ-DEF-PLAYER-FIRE and REQ-PSH-STATION-FIRE; stats from config formulas at their level / generation.
### Tick step 9 — Deaths & loot
- For each entity with hp ≤ 0: drop scrap at position (REQ-RES-SCRAP-DROP); amount from ShipDef.loot.scrapDrop or station scrap formula
- Track enemy defence station "sets": if a full set destroyed this tick, award player one blueprint (REQ-DEF-BLUEPRINT-DROP); emit `BlueprintDropEvent`
- Remove dead entities (ships, scrap, buildings)
### Push mechanic (REQ-PSH-*)
When enemy wave progresses beyond contest zone: `world.push` expansion triggers, enemy defence station set spawns at new front, scaling_factor applied to formulas. This may belong in a dedicated `PushSystem` or fold into the wave scheduler. Decide at implementation time.
### Files
New: `WaveSystem.h/.cpp`, `CombatSystem.h/.cpp`, maybe `PushSystem.h/.cpp`, corresponding `*Test.cpp`.
Modified: `Simulation.*` to wire in tick steps 1, 2, 8, 9; `ShipSystem` to expose iteration; `BuildingSystem` to expose defence stations for combat.
### Tests
- Threat accumulates per second from the formula
- Wave spawn count matches threat budget / ship cost
- Fire event emitted + drainable + cleared
- Shooter on cooldown does not fire
- Ship at hp ≤ 0 drops scrap; scrap amount matches ShipDef
- Full enemy station set destroyed → BlueprintDropEvent with correct newLevel / wasNewUnlock
- Damage to HQ decrements HQ hp — game-over condition emitted when hp ≤ 0 (if we model it that way)
---
## Step 8 — UI layer
Big step. Break into sub-phases to keep each commit reviewable.
### 8a. Visuals config + window scaffolding
- New `visuals.toml` (REQ-UI, architecture.md §Rendering → Visual Parameters) — per-type fill/outline/glyph entries
- `src/ui/VisualsConfig.h/.cpp`, `src/ui/VisualsLoader.h/.cpp` — fail-fast on missing entries for any known sim id
- Main window widget: header bar + central game view + right-hand selected-building panel (QDockWidget or split layout)
- Wire `QApplication` + `Simulation + TickDriver` into `main.cpp` replacing the current stub
- Sim + UI share one thread; paintEvent reads sim state directly (no locks — architecture.md §Threading)
### 8b. GameWorldView (render only, no input)
- `QOpenGLWidget` subclass with `QPainter` drawing
- `QTimer` @ 60 Hz → `update()` + advances sim via `TickDriver::advance(elapsedMs, gameSpeedMultiplier)` → calls `sim.tick()` N times
- Layer order per architecture.md §Layer Order (tiles → buildings → belt items → scrap → ships → beams → overlays → screen-space)
- Scroll via `scrollXTiles` float, A/D keyboard input, clamped per REQ-GW-SCROLL-LIMIT
- Mouse→world conversion: `worldX = mouseX / 20 + scrollXTiles`
- Beam renderer: keeps `FireEvent`s for 0.3 s wall time (9 ticks @ 30 Hz), drops if either end entity is gone
- Blueprint toasts: keeps `BlueprintDropEvent`s for configured toast duration
### 8c. Input → sim commands
- Tile click: select building / select belt tiles (box drag)
- Builder mode: open from build button grid; shows ghost on cursor; click places construction site (REQ-BLD-PLACE); drag-to-place for belts (REQ-BLD-DRAG)
- Demolish mode: click building → demolish (confirm), returns refund (REQ-BLD-DEMOLISH)
- Selected-building panel: recipe picker, clear-belt button (REQ-UI-BELT-CLEAR), splitter filter config, demolish button
- Speed controls: 0 / 0.5× / 1× / 2× / 4× (REQ-UI-SPEED) — bound to spacebar pause + number keys
### 8d. Header bar + polish
- Resource counters (building blocks, blueprint collection)
- Threat meter
- Wave countdown
- FPS / speed indicator
- Minor polish: hover highlights, keyboard shortcuts, tooltip on build buttons
### Files
New: `src/ui/` populated — `MainWindow.*`, `GameWorldView.*`, `HeaderBar.*`, `BuildButtonGrid.*`, `SelectedBuildingPanel.*`, `VisualsConfig.*`, `VisualsLoader.*`, `Toast.*`, etc.
Modified: `src/ui/CMakeLists.txt` — flip from INTERFACE library to regular static library; enable AUTOMOC; add `Q_OBJECT` macros where needed. `src/app/main.cpp` — construct sim + main window.
### Tests
UI code is largely visual; prioritize:
- Visuals loader fail-fast on missing entries
- Simulation + TickDriver integration test: at 1×, 60 render frames produce ~30 sim ticks (approximately — tolerate ±1 for accumulator residue)
- Manual smoke test checklist (in-repo markdown) for builder mode, demolish, recipe change, clear belt, speed toggling
---
## Things to revisit as needed
- **Pathfinding for ships:** straight-line in v1 is fine given open space; only revisit if enemy defence stations create obstacles
- **Belt segment compression (v2):** only if v1 per-tile profiling is bad
- **Worker thread for sim:** only if paint stalls become visible; `drain*` APIs already support it
- **ECS migration for ships:** only if component iteration becomes a bottleneck
- **Belt curves rendering:** derive from consecutive belt tile directions; sim logic is unaffected