Files
dota_factory/docs/plan.md
2026-04-20 14:10:01 +02:00

18 KiB
Raw Blame History

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 done
7 Waves, threat accumulation, combat resolution, deaths & loot done
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/)

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/)

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.

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

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.

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 FireEvents for 0.3 s wall time (9 ticks @ 30 Hz), drops if either end entity is gone
  • Blueprint toasts: keeps BlueprintDropEvents 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