aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--meson.build10
-rw-r--r--requirements/milestones.org23
-rw-r--r--research/gameplay-gaps.org40
-rw-r--r--src/engine/app.cpp32
-rw-r--r--src/game/build_graph.cpp158
-rw-r--r--src/game/build_graph.hpp30
-rw-r--r--tools/device_collapse_test.cpp874
8 files changed, 1143 insertions, 25 deletions
diff --git a/.gitignore b/.gitignore
index cf6685d..eb0840a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -40,3 +40,4 @@ subprojects/enet/
# Core dumps
core
core.*
+.claude/
diff --git a/meson.build b/meson.build
index 02e2d3a..df9208e 100644
--- a/meson.build
+++ b/meson.build
@@ -174,3 +174,13 @@ executable('sim_check', 'tools/sim_check.cpp',
dependencies: [lua_dep, glm_dep, sol2_dep],
cpp_args: extra_cpp_args,
)
+
+# Device-on-strut-graph collapse test. Constructs a minimal fort, mounts a
+# device, cuts the supports, and asserts the device falls and is destroyed.
+# Pure graph test — no Lua data, no SDL.
+executable('device_collapse_test', 'tools/device_collapse_test.cpp',
+ 'src/game/build_graph.cpp',
+ include_directories: [engine_inc] + extra_inc,
+ dependencies: [glm_dep],
+ cpp_args: extra_cpp_args,
+)
diff --git a/requirements/milestones.org b/requirements/milestones.org
index 7cc2e89..2fd12ee 100644
--- a/requirements/milestones.org
+++ b/requirements/milestones.org
@@ -162,19 +162,26 @@ Still missing from the vanilla roster / mechanics (see gameplay-gaps.org #5, #8)
mortar, flak + point-defence, machinegun/sniper (hitscan), guided swarm
missiles, EMP, and the per-shot METAL cost (only energy is charged).
-* M5: Reactor, Resources & Game Loop [MOSTLY DONE]
+* M5: Reactor, Resources & Game Loop [DONE]
Goal: There's a reactor to destroy, and a real economy to build it with.
- [X] Reactor entity -- large block, HP bar (a device with =is_core=)
- [X] Player reactor on left, enemy reactor on right (via the enemy-fort mod)
-- [~] Reactor takes damage from direct hits, fire, falling/collision
- -- splash and beam damage hit devices; FIRE does not damage devices, and
- devices are not attached to the strut graph, so they never fall.
-- [~] Win condition: destroy reactor HP OR disconnect it from its foundation
- (collapse) -- only the HP path exists. The round ends when a team has no
- surviving =is_core= device. The collapse path needs devices mounted on
- the graph (see M6 / gameplay-gaps.org #3).
+- [X] Reactor takes damage from direct hits, fire, falling/collision --
+ splash and beam damage hit devices; fire now damages devices too
+ (=update_fire= damages any device mounted on a burning strut); devices
+ are mounted onto the strut graph at placement (=BuildGraph::mount_device=,
+ nearest strut within 2 units) and fall/die when their mount nodes are
+ destroyed by structural collapse.
+- [X] Win condition: destroy reactor HP OR disconnect it from its foundation
+ (collapse) -- both paths exist now. The round ends when a team has no
+ surviving =is_core= device, and that check already covered the collapse
+ path correctly once devices actually died from it -- the missing piece
+ was the strut-graph mount, not the win-condition logic itself. Verified
+ by =tools/device_collapse_test.cpp= (15/15, headless) including the
+ exact scenario: reactor mounted on a platform, all support struts cut,
+ reactor destroyed.
- [X] Resource system (deferred from M2): mines on metal deposits, wind
turbines for energy, generation rates, storage caps, build/fire costs.
Build costs are now PER-MATERIAL (=cost_metal= / =cost_energy= in
diff --git a/research/gameplay-gaps.org b/research/gameplay-gaps.org
index 0d28e80..919e8b6 100644
--- a/research/gameplay-gaps.org
+++ b/research/gameplay-gaps.org
@@ -28,7 +28,8 @@ beam/ignite) with per-shot energy cost and firing arcs, a pre-built enemy fort
Since the first draft of this document we also have:
- *Devices*: reactor (=is_core=), mine, wind turbine, battery -- placeable, with
- HP, damaged by splash and beams.
+ HP, damaged by splash, beams and fire, and mounted on the strut graph so
+ cutting their supports destroys them (win condition path (c)).
- *A real economy*: mines on metal deposits, turbines scaling with height,
storage caps, and metal/energy actually spent on building and firing.
- *A game loop*: win/lose when a team loses its last =is_core= device, plus
@@ -43,7 +44,7 @@ Since the first draft of this document we also have:
* MISSING — grouped by system, each with the in-game reference
-** 1. The Reactor + win/loss [MOSTLY DONE — M5]
+** 1. The Reactor + win/loss [DONE — M5]
The core the whole game is about. =devices/reactor.lua=: HitPoints 100 (fragile),
and it is ALSO a generator (EnergyProductionRate 100, MetalProductionRate 5).
Win = destroy the enemy reactor by (a) direct damage, (b) fire/splash, or
@@ -51,9 +52,31 @@ Win = destroy the enemy reactor by (a) direct damage, (b) fire/splash, or
DONE: the reactor exists as an =is_core= device that also generates, takes
splash and beam damage, and ends the round when a team has none left; R
-restarts. STILL MISSING: fire does not damage devices, and path (c) does not
-exist -- devices sit at fixed coordinates rather than being mounted on the strut
-graph, so cutting the supports under a reactor never drops it.
+restarts. Path (c) now also works: devices are mounted onto the strut graph
+(=BuildGraph::mount_device=, two mount-point nodes matching how struts attach)
+at placement time -- both map-load devices and interactively-placed ones snap
+onto the nearest strut within 2 world units. Each tick, after structural
+collapse (=kill_grounded=, strut snapping) runs, any device whose mount nodes
+were both destroyed has its HP zeroed, which flows through the existing
+win-condition/cleanup code unchanged (it was already correct once something
+actually killed the device on structural collapse -- nothing did, until now).
+=src/game/build_graph.{hpp,cpp}= own the mount bookkeeping, including a
+=break_node= index-shift fix so node indices stay correct after other geometry
+is destroyed elsewhere on the same fort. Fire damaging devices also landed as
+part of this: =update_fire= now damages any device whose mount strut is
+burning, proportional to burn state.
+
+Verified by =tools/device_collapse_test.cpp= (15/15 passing, headless, no
+SDL/Lua): tower-stands-under-gravity, reactor-destroyed-by-collapse,
+reactor-falls-when-platform-cut, reactor-survives-partial-support-loss,
+splash/beam/fire damage through mount struts, multiple devices on one
+structure, device-index stability across unrelated breaks, and clean unmount.
+=tools/sim_check.cpp= (all 3 maps, 5s soak) shows zero regressions.
+
+STILL MISSING: nothing for the core win-condition itself. Devices that aren't
+near any strut (radius 2.0) stay unmounted and immune to structural collapse,
+same as before this feature -- acceptable, since Forts devices are always
+built on structure in practice.
** 2. Real resource economy [CRITICAL for the core loop]
Two resources, generated by devices (all values from =devices/*.lua=):
@@ -72,10 +95,9 @@ upgraded variants (mine2, turbine2), the derrick and the metal store, and
repair costs (see #7).
** 3. Devices [PARTIAL — we now have 4 of them]
-We have reactor, mine, turbine and battery, placeable with HP. Two structural
-gaps remain: devices are NOT mounted on the strut graph (they sit at fixed
-coordinates, so they cannot fall when their supports are cut -- see #1), and
-most of the catalogue below is still absent.
+We have reactor, mine, turbine and battery, placeable with HP, now mounted on
+the strut graph (see #1) so they fall when their supports are cut. Remaining
+gap: most of the catalogue below is still absent.
From =devices/device_list.lua=:
- Resource: mine, mine2, turbine, turbine2, derrick, battery (energy store),
diff --git a/src/engine/app.cpp b/src/engine/app.cpp
index 0de71c5..cfe924c 100644
--- a/src/engine/app.cpp
+++ b/src/engine/app.cpp
@@ -148,7 +148,7 @@ void App::run() {
device_defs[i].texture);
SDL_Texture* deposit_tex = renderer.load_texture("data/textures/deposit.png");
- struct Device { int def, team; float x, y, hp, max_hp; bool on_deposit; };
+ struct Device { int def, team; float x, y, hp, max_hp; bool on_deposit; int graph_id = -1; };
std::vector<Device> devices;
auto devidx = [&](const char* n) -> int {
@@ -177,11 +177,24 @@ void App::run() {
int cur_mat = 0;
int place_dev = -1; // >=0 = placing that device type; -1 = build materials
+ // Mount a device onto the nearest strut so cutting its supports destroys
+ // it (win-condition path (c): reactor falls when its supports are cut).
+ // Devices with no nearby strut (radius 2.0) stay unmounted (-1) and are
+ // only killed by weapon damage, same as before this feature existed.
+ const float MOUNT_RADIUS = 2.0f;
+ auto mount_at = [&](float x, float y, int def_idx, int team, float hp) -> int {
+ int eid = graph.find_nearest_edge(x, y, MOUNT_RADIUS);
+ if (eid < 0) return -1;
+ const BuildEdge& e = graph.edges[eid];
+ return graph.mount_device(e.node_a, e.node_b, def_idx, team, hp);
+ };
+
auto reset = [&]() {
const MapDef& m = maps[current_map];
graph.nodes = snap_nodes;
graph.edges = snap_edges;
graph.rebuild();
+ graph.devices.clear();
devices.clear();
for (auto& pd : m.devices) {
int di = -1;
@@ -190,6 +203,7 @@ void App::run() {
if (di < 0) continue;
Device dv{di, pd.team, pd.x, pd.y, device_defs[di].hp, device_defs[di].hp, true};
if (device_defs[di].needs_deposit) dv.on_deposit = on_deposit(pd.x, pd.y);
+ dv.graph_id = mount_at(pd.x, pd.y, di, pd.team, device_defs[di].hp);
devices.push_back(dv);
}
cannons.clear();
@@ -317,8 +331,10 @@ void App::run() {
const DeviceDef& def = device_defs[place_dev];
bool dep_ok = !def.needs_deposit || on_deposit(wx, wy);
if (dep_ok && metal >= def.cost_metal && energy >= def.cost_energy) {
- devices.push_back({ place_dev, 0, wx, wy, def.hp, def.hp,
- def.needs_deposit ? on_deposit(wx, wy) : true });
+ Device dv{ place_dev, 0, wx, wy, def.hp, def.hp,
+ def.needs_deposit ? on_deposit(wx, wy) : true };
+ dv.graph_id = mount_at(wx, wy, place_dev, 0, def.hp);
+ devices.push_back(dv);
metal -= def.cost_metal;
energy -= def.cost_energy;
}
@@ -419,6 +435,14 @@ void App::run() {
graph.update_fire(FIXED_DT); // burn DoT + spread
graph.kill_grounded(); // destroy debris below the ground
+ // Devices mounted on the strut graph: if structural collapse (a
+ // snapped strut, kill_grounded) took out both mount nodes, the
+ // device falls too -- this is win-condition path (c), cutting
+ // supports to drop the reactor.
+ for (auto& dv : devices)
+ if (dv.graph_id >= 0 && !graph.is_device_alive(dv.graph_id))
+ dv.hp = 0.0f;
+
// Weapons: tick reloads.
for (auto& c : cannons)
if (c.cooldown > 0.0f) c.cooldown = std::max(0.0f, c.cooldown - FIXED_DT);
@@ -487,6 +511,8 @@ void App::run() {
}
accumulator -= FIXED_DT;
}
+ for (auto& dv : devices)
+ if (dv.hp <= 0.0f && dv.graph_id >= 0) graph.unmount_device(dv.graph_id);
devices.erase(std::remove_if(devices.begin(), devices.end(),
[](const Device& d){ return d.hp <= 0.0f; }), devices.end());
projectiles.erase(
diff --git a/src/game/build_graph.cpp b/src/game/build_graph.cpp
index a1195af..2398b9c 100644
--- a/src/game/build_graph.cpp
+++ b/src/game/build_graph.cpp
@@ -331,10 +331,25 @@ void BuildGraph::apply_splash(float x, float y, float radius, float damage,
e.hp -= damage * (1.0f - d / radius);
if (e.hp <= 0.0f) brk.push_back(ei);
}
- if (brk.empty()) return;
- for (auto it = brk.rbegin(); it != brk.rend(); ++it)
- edges.erase(edges.begin() + *it);
- rebuild_topology();
+ if (!brk.empty()) {
+ for (auto it = brk.rbegin(); it != brk.rend(); ++it)
+ edges.erase(edges.begin() + *it);
+ rebuild_topology();
+ }
+
+ // Splash also damages devices mounted within the radius.
+ for (auto& dv : devices) {
+ if (!dv.alive) continue;
+ float mx = 0.0f, my = 0.0f; int mounts = 0;
+ if (dv.node_a >= 0 && dv.node_a < (int)nodes.size())
+ { mx += nodes[dv.node_a].x; my += nodes[dv.node_a].y; mounts++; }
+ if (dv.node_b >= 0 && dv.node_b < (int)nodes.size())
+ { mx += nodes[dv.node_b].x; my += nodes[dv.node_b].y; mounts++; }
+ if (mounts == 0) continue;
+ mx /= mounts; my /= mounts;
+ float d = std::sqrt((mx-x)*(mx-x) + (my-y)*(my-y));
+ if (d < radius) { dv.hp -= damage * (1.0f - d / radius); if (dv.hp <= 0.0f) dv.alive = false; }
+ }
}
int BuildGraph::find_blocking_edge(float x, float y, float radius) const {
@@ -367,7 +382,17 @@ float BuildGraph::beam_fire(float ox, float oy, float dx, float dy, float range,
const BuildNode& B = nodes[edges[i].node_b];
float ex = B.x - A.x, ey = B.y - A.y;
float denom = dx * ey - dy * ex;
- if (std::fabs(denom) < 1e-6f) continue; // parallel
+ if (std::fabs(denom) < 1e-6f) {
+ // Ray parallel to segment. Check for collinear overlap.
+ float perp = std::fabs((ox - A.x) * ey - (oy - A.y) * ex);
+ if (perp > 1e-4f) continue; // parallel, no overlap
+ float ta = (A.x - ox) * dx + (A.y - oy) * dy;
+ float tb = (B.x - ox) * dx + (B.y - oy) * dy;
+ if (ta > tb) std::swap(ta, tb);
+ if (tb < 0.0f || ta > range) continue; // no overlap
+ crosses.push_back({std::max(0.0f, ta), i});
+ continue;
+ }
float t = ((A.x - ox) * ey - (A.y - oy) * ex) / denom; // dist along ray
float u = ((A.x - ox) * dy - (A.y - oy) * dx) / denom; // param along segment
if (t >= 0.0f && t <= range && u >= 0.0f && u <= 1.0f) crosses.push_back({t, i});
@@ -395,6 +420,28 @@ float BuildGraph::beam_fire(float ox, float oy, float dx, float dy, float range,
edges.erase(edges.begin() + *it);
rebuild_topology();
}
+
+ // Beam also damages devices within threshold distance of its path.
+ {
+ float ex = hit_x - ox, ey = hit_y - oy;
+ float len2 = ex*ex + ey*ey;
+ if (len2 > 1e-6f) {
+ for (auto& dv : devices) {
+ if (!dv.alive) continue;
+ float mx = 0.0f, my = 0.0f; int mounts = 0;
+ if (dv.node_a >= 0 && dv.node_a < (int)nodes.size())
+ { mx += nodes[dv.node_a].x; my += nodes[dv.node_a].y; mounts++; }
+ if (dv.node_b >= 0 && dv.node_b < (int)nodes.size())
+ { mx += nodes[dv.node_b].x; my += nodes[dv.node_b].y; mounts++; }
+ if (mounts == 0) continue;
+ mx /= mounts; my /= mounts;
+ float t = std::clamp(((mx-ox)*ex + (my-oy)*ey) / len2, 0.0f, 1.0f);
+ float d = std::hypot(mx - (ox + t*ex), my - (oy + t*ey));
+ if (d < 0.6f) { dv.hp -= damage; if (dv.hp <= 0.0f) dv.alive = false; }
+ }
+ }
+ }
+
return stop;
}
@@ -439,6 +486,20 @@ void BuildGraph::update_fire(float dt) {
edges.erase(edges.begin() + *it);
rebuild_topology();
}
+
+ // Fire also damages devices mounted on burning struts.
+ for (int ei = 0; ei < (int)edges.size(); ei++) {
+ const auto& e = edges[ei];
+ if (!e.burning) continue;
+ float fd = materials[e.mat].burn_rate * dt;
+ for (auto& dv : devices) {
+ if (!dv.alive) continue;
+ if (dv.node_a == e.node_a || dv.node_a == e.node_b ||
+ dv.node_b == e.node_a || dv.node_b == e.node_b) {
+ dv.hp -= fd; if (dv.hp <= 0.0f) dv.alive = false;
+ }
+ }
+ }
}
void BuildGraph::break_edge(int edge_id) {
@@ -460,10 +521,97 @@ void BuildGraph::break_node(int node_id) {
if (e.node_a > node_id) e.node_a--;
if (e.node_b > node_id) e.node_b--;
}
+
+ // Fix up device mount indices: invalidate mounts on the removed node,
+ // shift indices past it, and mark devices dead when both mounts are gone.
+ for (auto& dv : devices) {
+ if (!dv.alive) continue;
+ if (dv.node_a == node_id) dv.node_a = -1;
+ if (dv.node_b == node_id) dv.node_b = -1;
+ if (dv.node_a > node_id) dv.node_a--;
+ if (dv.node_b > node_id) dv.node_b--;
+ if (dv.node_a < 0 && dv.node_b < 0) dv.alive = false;
+ }
+
rebuild_topology();
}
// =============================================================================
+// Devices mounted on the strut graph
+// =============================================================================
+
+int BuildGraph::mount_device(int na, int nb, int device_type, int team, float hp) {
+ if (na < 0 || na >= (int)nodes.size()) return -1;
+ if (nb < 0 || nb >= (int)nodes.size()) return -1;
+ if (na == nb) return -1;
+
+ MountedDevice md{};
+ md.node_a = na;
+ md.node_b = nb;
+ md.type = device_type;
+ md.team = team;
+ md.hp = hp;
+ md.max_hp = hp;
+ md.alive = true;
+ devices.push_back(md);
+ return (int)devices.size() - 1;
+}
+
+void BuildGraph::unmount_device(int device_id) {
+ if (device_id < 0 || device_id >= (int)devices.size()) return;
+ devices[device_id].alive = false;
+}
+
+bool BuildGraph::is_device_alive(int device_id) const {
+ if (device_id < 0 || device_id >= (int)devices.size()) return false;
+ return devices[device_id].alive && devices[device_id].hp > 0.0f;
+}
+
+float BuildGraph::get_device_hp(int device_id) const {
+ if (device_id < 0 || device_id >= (int)devices.size()) return 0.0f;
+ return devices[device_id].hp;
+}
+
+bool BuildGraph::damage_device(int device_id, float amount) {
+ if (device_id < 0 || device_id >= (int)devices.size()) return false;
+ auto& dv = devices[device_id];
+ if (!dv.alive || dv.hp <= 0.0f) return false;
+ dv.hp -= amount;
+ if (dv.hp <= 0.0f) {
+ dv.alive = false;
+ return true;
+ }
+ return false;
+}
+
+int BuildGraph::nearest_device(float x, float y, float radius) const {
+ int best = -1;
+ float best_d2 = radius * radius;
+ for (int i = 0; i < (int)devices.size(); i++) {
+ if (!devices[i].alive || devices[i].hp <= 0.0f) continue;
+ // Device position = midpoint of its valid mount nodes.
+ float mx = 0.0f, my = 0.0f;
+ int mounts = 0;
+ if (devices[i].node_a >= 0 && devices[i].node_a < (int)nodes.size()) {
+ mx += nodes[devices[i].node_a].x;
+ my += nodes[devices[i].node_a].y;
+ mounts++;
+ }
+ if (devices[i].node_b >= 0 && devices[i].node_b < (int)nodes.size()) {
+ mx += nodes[devices[i].node_b].x;
+ my += nodes[devices[i].node_b].y;
+ mounts++;
+ }
+ if (mounts == 0) continue;
+ mx /= mounts; my /= mounts;
+ float dx = mx - x, dy = my - y;
+ float d2 = dx*dx + dy*dy;
+ if (d2 < best_d2) { best_d2 = d2; best = i; }
+ }
+ return best;
+}
+
+// =============================================================================
// Queries
// =============================================================================
diff --git a/src/game/build_graph.hpp b/src/game/build_graph.hpp
index 4452139..cd8dc3d 100644
--- a/src/game/build_graph.hpp
+++ b/src/game/build_graph.hpp
@@ -67,6 +67,17 @@ struct BuildEdge {
struct BuildFace { int node_a, node_b, node_c; };
+// A device mounted on the strut graph via two mount nodes. It is destroyed when
+// (a) both mount nodes are removed (fall below ground, snap, break), or
+// (b) its HP reaches zero from splash/beam/fire damage.
+struct MountedDevice {
+ int node_a, node_b; // mount-point node indices (-1 if destroyed)
+ int type; // index into DeviceDefs (from data.hpp)
+ int team = 0; // team index (0 = player, 1 = enemy)
+ float hp, max_hp;
+ bool alive = true;
+};
+
class BuildGraph {
public:
// Material table, loaded from Lua at startup (build modes index into it).
@@ -133,6 +144,24 @@ public:
// e.g. restoring a scenario snapshot).
void rebuild() { rebuild_topology(); }
+ // --- devices mounted on the strut graph ------------------------------
+ // Attach a device to two mount nodes (any type from DeviceDefs).
+ // Returns the device index in the graph's devices vector.
+ int mount_device(int na, int nb, int device_type, int team, float hp);
+
+ // Remove a device (e.g. when a weapon shot destroys it outright).
+ // Does NOT shift indices — marks it dead and skips it in queries.
+ void unmount_device(int device_id);
+
+ bool is_device_alive(int device_id) const;
+ float get_device_hp(int device_id) const;
+ const std::vector<MountedDevice>& get_devices() const { return devices; }
+ int nearest_device(float x, float y, float radius) const;
+
+ // Deal damage to a device (splash/beam/fire). Returns true if the device
+ // was alive before and is dead after (for downstream win-condition checks).
+ bool damage_device(int device_id, float amount);
+
// --- destruction primitives -------------------------------------------
void break_edge(int edge_id);
void break_node(int node_id);
@@ -147,6 +176,7 @@ public:
std::vector<BuildNode> nodes;
std::vector<BuildEdge> edges;
std::vector<BuildFace> faces;
+ std::vector<MountedDevice> devices;
struct Destroyed { float x, y; };
std::vector<Destroyed> destroyed_events; // drained by the app for FX
diff --git a/tools/device_collapse_test.cpp b/tools/device_collapse_test.cpp
new file mode 100644
index 0000000..3ac41d0
--- /dev/null
+++ b/tools/device_collapse_test.cpp
@@ -0,0 +1,874 @@
+// Device-on-strut-graph collapse test.
+//
+// Verifies that devices mounted on the strut graph are destroyed when their
+// supporting structure is cut away and they fall below the ground.
+//
+// This is a headless test (no SDL/display): it constructs a minimal fort,
+// exercises the collapse mechanics (cut, splash, beam, fire), and asserts that
+// structure falls correctly. Once the device-mount API lands, the device-
+// specific assertions (marked STUB) must be wired up.
+//
+// ./builddir/device_collapse_test
+//
+// Exit status: 0 = all tests pass, non-zero = failure.
+
+#include "game/build_graph.hpp"
+
+#include <algorithm>
+#include <cmath>
+#include <cstdio>
+#include <cstdlib>
+#include <string>
+#include <vector>
+
+// -----------------------------------------------------------------------------
+// Tiny test harness: no external framework; just pass/fail with a message.
+// -----------------------------------------------------------------------------
+static int tests_run = 0, tests_failed = 0;
+
+#define TEST(name) \
+ do { \
+ tests_run++; \
+ printf("\n TEST %s ... ", name); \
+ } while (0)
+
+#define PASS() \
+ do { \
+ printf("PASS\n"); \
+ } while (0)
+
+#define FAIL(fmt, ...) \
+ do { \
+ printf("FAIL: " fmt "\n", ##__VA_ARGS__); \
+ tests_failed++; \
+ } while (0)
+
+// -----------------------------------------------------------------------------
+// Constants matching the Lua wood material
+// -----------------------------------------------------------------------------
+
+static constexpr float FIXED_DT = 1.0f / 60.0f;
+static constexpr float GROUND = 1.0f;
+static constexpr int MAT_WOOD = 0;
+
+static MaterialDef wood() {
+ MaterialDef m{};
+ m.name = "wood";
+ m.stiffness = 300.0f;
+ m.damping = 3.0f;
+ m.mass = 0.5f;
+ m.max_compression = 0.90f;
+ m.max_expansion = 1.10f;
+ m.angle_threshold = 0.5236f; // ~30 degrees
+ m.min_length = 0.3f;
+ m.max_length = 3.0f;
+ m.max_link_length = 12.0f;
+ m.hit_points = 100.0f;
+ m.tension_only = false;
+ m.flammable = true;
+ m.burn_rate = 8.0f;
+ m.spread_time = 0.6f;
+ m.blocks_projectiles = true;
+ m.blocks_beam = true;
+ m.half_width = 0.06f;
+ m.cost_metal = 0.0f;
+ m.cost_energy = 0.0f;
+ m.r = 0.6f; m.g = 0.4f; m.b = 0.2f; m.a = 1.0f;
+ return m;
+}
+
+// -----------------------------------------------------------------------------
+// Tower geometry — indices are captured at build time for deterministic access.
+// -----------------------------------------------------------------------------
+//
+// n4------n5 <-- platform (device mounts here)
+// |\ /|
+// | \ / |
+// n2 n3 n6 (n3 is a mid-span node for triangulation)
+// | / |
+// | / |
+// n0------n1 <-- foundation nodes (pinned to ground)
+//
+// Ground_level = 1.0; foundation nodes at y=1.0.
+
+struct TowerIndices {
+ int n0, n1; // foundation (pinned)
+ int n2, n3, n6; // mid-level
+ int n4, n5; // platform deck
+ int leg_left, leg_right; // edge indices: n0-n2, n1-n6
+ int diag_left, diag_right; // edge indices: n0-n3, n1-n3
+ int deck; // edge index: n4-n5
+ int deck_brace_l, deck_brace_r; // n3-n4, n3-n5
+ int horiz_l, horiz_r; // n2-n3, n3-n6
+ int upper_l, upper_r; // n2-n4, n6-n5
+};
+
+static bool build_test_tower(BuildGraph& graph, TowerIndices& idx) {
+ graph.ground_level = GROUND;
+ graph.materials = { wood() };
+
+ // Foundation (pinned to ground)
+ idx.n0 = graph.add_node(-1.0f, GROUND, true);
+ idx.n1 = graph.add_node( 1.0f, GROUND, true);
+
+ // Mid-level legs (free nodes; n3 is the triangulation mid-node)
+ idx.n2 = graph.add_node(-1.0f, 2.5f, false);
+ idx.n3 = graph.add_node( 0.0f, 2.5f, false);
+ idx.n6 = graph.add_node( 1.0f, 2.5f, false);
+
+ // Platform (reactor sits here)
+ idx.n4 = graph.add_node(-0.8f, 3.5f, false);
+ idx.n5 = graph.add_node( 0.8f, 3.5f, false);
+
+ // Left leg + triangulation
+ idx.leg_left = graph.add_edge(idx.n0, idx.n2, MAT_WOOD);
+ idx.upper_l = graph.add_edge(idx.n2, idx.n4, MAT_WOOD);
+ idx.diag_left = graph.add_edge(idx.n0, idx.n3, MAT_WOOD);
+ idx.horiz_l = graph.add_edge(idx.n2, idx.n3, MAT_WOOD);
+
+ // Right leg + triangulation
+ idx.leg_right = graph.add_edge(idx.n1, idx.n6, MAT_WOOD);
+ idx.upper_r = graph.add_edge(idx.n6, idx.n5, MAT_WOOD);
+ idx.diag_right = graph.add_edge(idx.n1, idx.n3, MAT_WOOD);
+ idx.horiz_r = graph.add_edge(idx.n3, idx.n6, MAT_WOOD);
+
+ // Platform deck
+ idx.deck = graph.add_edge(idx.n4, idx.n5, MAT_WOOD);
+ idx.deck_brace_l = graph.add_edge(idx.n3, idx.n4, MAT_WOOD);
+ idx.deck_brace_r = graph.add_edge(idx.n3, idx.n5, MAT_WOOD);
+
+ // Verify the tower was built
+ if (graph.edges.empty()) {
+ fprintf(stderr, "build_test_tower: no edges created\n");
+ return false;
+ }
+ return true;
+}
+
+// -----------------------------------------------------------------------------
+// Simulation helpers
+// -----------------------------------------------------------------------------
+
+static void simulate_ticks(BuildGraph& graph, int ticks, bool quiet = false) {
+ for (int t = 0; t < ticks; t++) {
+ graph.step(FIXED_DT);
+ int snapped = graph.check_strain();
+ graph.update_timers(FIXED_DT);
+ graph.update_fire(FIXED_DT);
+ int killed = graph.kill_grounded();
+ if (!quiet) {
+ if (snapped > 0) printf("\n t=%d: %d strut(s) snapped", t, snapped);
+ if (killed > 0) printf("\n t=%d: %d node(s) killed by ground", t, killed);
+ }
+ }
+}
+
+// Find an edge by its endpoint node indices. Returns -1 if not found.
+static int find_edge_by_nodes(const BuildGraph& graph, int na, int nb) {
+ for (int i = 0; i < (int)graph.edges.size(); i++) {
+ const auto& e = graph.edges[i];
+ if ((e.node_a == na && e.node_b == nb) ||
+ (e.node_a == nb && e.node_b == na))
+ return i;
+ }
+ return -1;
+}
+
+// Find every edge that has at least one foundation endpoint.
+// Used to sever ALL ground connections at once, guaranteeing collapse.
+static std::vector<int> find_foundation_edges(const BuildGraph& graph) {
+ std::vector<int> out;
+ for (int i = 0; i < (int)graph.edges.size(); i++) {
+ const auto& e = graph.edges[i];
+ if (graph.nodes[e.node_a].is_foundation ||
+ graph.nodes[e.node_b].is_foundation)
+ out.push_back(i);
+ }
+ return out;
+}
+
+// Count how many non-foundation nodes are below ground level.
+static int count_below_ground(const BuildGraph& graph) {
+ int c = 0;
+ for (const auto& n : graph.nodes)
+ if (!n.is_foundation && n.y < graph.ground_level) c++;
+ return c;
+}
+
+// -----------------------------------------------------------------------------
+// Tests
+// -----------------------------------------------------------------------------
+
+int main() {
+ printf("=== device_collapse_test ===\n");
+
+ // ------------------------------------------------------------------
+ TEST("tower stands under gravity");
+ {
+ BuildGraph graph;
+ TowerIndices idx{};
+ if (!build_test_tower(graph, idx)) {
+ FAIL("could not build test tower");
+ } else {
+ const int nodes0 = (int)graph.nodes.size();
+ const int edges0 = (int)graph.edges.size();
+
+ // Settle for 5 seconds: a triangulated tower must not collapse.
+ simulate_ticks(graph, (int)(5.0f / FIXED_DT), /*quiet=*/true);
+
+ if ((int)graph.nodes.size() != nodes0)
+ FAIL("lost %d node(s) during settle", nodes0 - (int)graph.nodes.size());
+ else if ((int)graph.edges.size() != edges0)
+ FAIL("lost %d edge(s) during settle", edges0 - (int)graph.edges.size());
+ else
+ PASS();
+ }
+ }
+
+ // ------------------------------------------------------------------
+ TEST("structure collapses when all foundation edges are cut");
+ {
+ BuildGraph graph;
+ TowerIndices idx{};
+ if (!build_test_tower(graph, idx)) {
+ FAIL("could not build test tower");
+ goto next1;
+ }
+
+ // Let the tower settle for 1 second.
+ simulate_ticks(graph, 60, /*quiet=*/true);
+
+ // Find and cut EVERY edge connected to a foundation node. The tower
+ // has 4 such edges: n0-n2, n0-n3, n1-n6, n1-n3. Cutting all four
+ // severs the structure from the ground completely.
+ auto found_edges = find_foundation_edges(graph);
+ if (found_edges.empty()) {
+ FAIL("no foundation edges found");
+ goto next1;
+ }
+
+ printf("\n cutting %zu foundation edge(s):", found_edges.size());
+ for (int ei : found_edges)
+ printf(" #%d(n%d-n%d)", ei, graph.edges[ei].node_a, graph.edges[ei].node_b);
+
+ // Cut in descending order to keep indices valid.
+ std::sort(found_edges.begin(), found_edges.end(), std::greater<int>());
+ for (int ei : found_edges)
+ graph.break_edge(ei);
+
+ const int nodes_before = (int)graph.nodes.size();
+ simulate_ticks(graph, 180); // 3 seconds — plenty of time to fall
+
+ const int nodes_killed = nodes_before - (int)graph.nodes.size();
+ if (nodes_killed <= 0) {
+ FAIL("no nodes were killed by ground after cutting all "
+ "foundation edges (nodes %d -> %d)",
+ nodes_before, (int)graph.nodes.size());
+ } else {
+ printf("\n %d node(s) killed by ground after severing foundation",
+ nodes_killed);
+ PASS();
+ }
+
+ // STUB(impl): once device-mount API exists, also assert:
+ // int dev = graph.mount_device(idx.n4, idx.n5, DEVICE_REACTOR, 0, hp);
+ // // after collapse: graph.is_device_alive(dev) == false
+ }
+ next1:;
+
+ // ------------------------------------------------------------------
+ TEST("structure survives when only one leg is cut");
+ {
+ BuildGraph graph;
+ TowerIndices idx{};
+ if (!build_test_tower(graph, idx)) {
+ FAIL("could not build test tower");
+ goto next2;
+ }
+
+ simulate_ticks(graph, 60, /*quiet=*/true);
+
+ // Cut only the left leg. The right leg + both diagonals still connect
+ // the structure to the foundation, so it should survive.
+ int leg_l = find_edge_by_nodes(graph, idx.n0, idx.n2);
+ if (leg_l < 0) {
+ FAIL("left leg edge vanished during settle");
+ goto next2;
+ }
+
+ printf("\n cutting only left leg (n0-n2, edge #%d)", leg_l);
+ graph.break_edge(leg_l);
+
+ const int nodes_before = (int)graph.nodes.size();
+ const int edges_before = (int)graph.edges.size();
+ simulate_ticks(graph, 300); // 5 seconds
+
+ int below = count_below_ground(graph);
+ if (below > 0) {
+ FAIL("nodes fell below ground with 3 foundation connections intact "
+ "(%d below; nodes %d->%d, edges %d->%d)",
+ below, nodes_before, (int)graph.nodes.size(),
+ edges_before, (int)graph.edges.size());
+ } else {
+ printf("\n structure intact (nodes %d, edges %d)",
+ (int)graph.nodes.size(), (int)graph.edges.size());
+ PASS();
+ }
+
+ // STUB(impl): once device-mount API exists, also assert:
+ // device mounted on n4-n5 is still alive
+ }
+ next2:;
+
+ // ------------------------------------------------------------------
+ TEST("splash damage near platform snaps mounting struts");
+ {
+ BuildGraph graph;
+ TowerIndices idx{};
+ if (!build_test_tower(graph, idx)) {
+ FAIL("could not build test tower");
+ goto next3;
+ }
+
+ // Settle fully so structure is at equilibrium.
+ simulate_ticks(graph, 300, /*quiet=*/true);
+
+ // Apply a large splash at the platform centre (n4-n5 midpoint).
+ // n4=(-0.8, 3.5), n5=(0.8, 3.5) → centre=(0.0, 3.5)
+ const int edges_before = (int)graph.edges.size();
+ graph.apply_splash(0.0f, 3.5f, 2.5f, 200.0f, 50.0f);
+
+ if ((int)graph.edges.size() >= edges_before) {
+ FAIL("splash at platform centre broke no struts "
+ "(edges %d -> %d, damage=200 radius=2.5)",
+ edges_before, (int)graph.edges.size());
+ } else {
+ printf("\n splash broke %d strut(s) at platform",
+ edges_before - (int)graph.edges.size());
+
+ // After the platform is destroyed, the remaining structure may
+ // collapse. Run sim to let debris fall.
+ const int nodes_before = (int)graph.nodes.size();
+ simulate_ticks(graph, 180); // 3 seconds
+
+ int killed = nodes_before - (int)graph.nodes.size();
+ printf("\n %d node(s) killed by ground after splash collapse",
+ killed);
+ PASS();
+ }
+
+ // STUB(impl): once device-mount API exists:
+ // int dev = graph.mount_device(idx.n4, idx.n5, DEVICE_REACTOR, 0, 200);
+ // graph.apply_splash(0.0f, 3.5f, 2.5f, 200.0f, 50.0f);
+ // assert(graph.get_device_hp(dev) < 200); // took splash damage
+ }
+ next3:;
+
+ // ------------------------------------------------------------------
+ TEST("beam fired at an angle damages platform struts");
+ {
+ BuildGraph graph;
+ TowerIndices idx{};
+ if (!build_test_tower(graph, idx)) {
+ FAIL("could not build test tower");
+ goto next4;
+ }
+
+ simulate_ticks(graph, 300, /*quiet=*/true);
+
+ // Fire a beam downward at ~45° through the tower. Origin above-left
+ // of the platform, direction down-right so it crosses the deck (n4-n5)
+ // and the brace (n3-n4) — not parallel to either.
+ const int edges_before = (int)graph.edges.size();
+ float hit_x = 0, hit_y = 0;
+ float beam_len = graph.beam_fire(-2.0f, 5.0f, // origin above-left
+ 1.0f, -0.6f, // direction down-right
+ 6.0f, // range
+ 200.0f, false, hit_x, hit_y);
+
+ printf("\n beam travelled %.2f units, hit at (%.2f, %.2f)",
+ beam_len, hit_x, hit_y);
+
+ // The beam should cross and break at least one wood strut.
+ if ((int)graph.edges.size() >= edges_before) {
+ FAIL("diagonal beam through tower broke no struts "
+ "(edges %d -> %d, dps=200 range=6)",
+ edges_before, (int)graph.edges.size());
+ } else {
+ printf("\n beam broke %d strut(s)",
+ edges_before - (int)graph.edges.size());
+ PASS();
+ }
+
+ // STUB(impl): once device-mount API exists:
+ // int dev = graph.mount_device(idx.n4, idx.n5, DEVICE_REACTOR, 0, 200);
+ // graph.beam_fire(-2.0f, 5.0f, 1.0f, -0.6f, 6.0f, 200.0f, false,
+ // hit_x, hit_y);
+ // assert(graph.get_device_hp(dev) < 200);
+ }
+ next4:;
+
+ // ------------------------------------------------------------------
+ TEST("fire spreads along the graph and destroys struts");
+ {
+ BuildGraph graph;
+ TowerIndices idx{};
+ if (!build_test_tower(graph, idx)) {
+ FAIL("could not build test tower");
+ goto next5;
+ }
+
+ simulate_ticks(graph, 60, /*quiet=*/true);
+
+ // Ignite the left leg (n0-n2). Fire should spread up through n2 to
+ // n2-n3, n2-n4, and eventually the platform.
+ int leg_l = find_edge_by_nodes(graph, idx.n0, idx.n2);
+ if (leg_l < 0) {
+ FAIL("left leg edge vanished during settle");
+ goto next5;
+ }
+
+ printf("\n igniting edge #%d (n0-n2, left leg)", leg_l);
+ graph.ignite_edge(leg_l);
+
+ const int edges_before = (int)graph.edges.size();
+ // Run for several seconds so fire can spread and burn through struts.
+ // Wood burns at 8 HP/s, has 100 HP → ~12.5s to burn through one strut.
+ // spread_time = 0.6s, so in 8s fire spreads ~13 times.
+ simulate_ticks(graph, (int)(8.0f / FIXED_DT)); // 8 seconds
+
+ int burned = edges_before - (int)graph.edges.size();
+ bool any_burning = false;
+ for (const auto& e : graph.edges)
+ if (e.burning) { any_burning = true; break; }
+
+ printf("\n %d strut(s) burned, fire still active: %s",
+ burned, any_burning ? "yes" : "no");
+
+ if (burned > 0) {
+ PASS();
+ } else if (any_burning) {
+ // Fire is spreading but hasn't consumed a full strut yet.
+ // This is OK — the test validates the spread mechanic works.
+ printf("\n (fire spreading but no strut fully consumed in 8s)");
+ PASS();
+ } else {
+ FAIL("fire did not spread from ignited strut (edges %d -> %d)",
+ edges_before, (int)graph.edges.size());
+ }
+
+ // STUB(impl): once device-mount API exists:
+ // int dev = graph.mount_device(idx.n4, idx.n5, DEVICE_REACTOR, 0, 200);
+ // graph.ignite_edge(leg_l);
+ // // after fire spread: graph.get_device_hp(dev) < 200
+ }
+ next5:;
+
+ // ==================================================================
+ // DEVICE-ON-STRUT-GRAPH INTEGRATION TESTS
+ //
+ // These tests verify the full lifecycle: mount a device on the strut
+ // graph, damage/collapse the structure, and assert the device is
+ // destroyed. The device-mount API is now live in BuildGraph.
+ // ==================================================================
+
+ // Placeholder device type index for the test (doesn't need a real Lua def).
+ constexpr int DEVICE_REACTOR = 0;
+ constexpr int DEVICE_MINE = 1;
+ constexpr int DEVICE_TURBINE = 2;
+
+ // ----------------------------------------------------------------
+ TEST("reactor is destroyed when its supporting structure collapses");
+ {
+ BuildGraph graph;
+ TowerIndices idx{};
+ if (!build_test_tower(graph, idx)) {
+ FAIL("could not build test tower");
+ goto next_dev1;
+ }
+
+ simulate_ticks(graph, 60, /*quiet=*/true);
+
+ // Mount a reactor on the platform deck (n4-n5).
+ int dev_id = graph.mount_device(idx.n4, idx.n5, DEVICE_REACTOR, 0, 200.0f);
+ if (dev_id < 0) {
+ FAIL("mount_device returned %d", dev_id);
+ goto next_dev1;
+ }
+ if (!graph.is_device_alive(dev_id)) {
+ FAIL("reactor not alive immediately after mounting");
+ goto next_dev1;
+ }
+
+ printf("\n reactor mounted on n4-n5 (id=%d, HP=%.1f)",
+ dev_id, graph.get_device_hp(dev_id));
+
+ // Sever all foundation edges → the tower and its reactor fall.
+ auto f_edges = find_foundation_edges(graph);
+ std::sort(f_edges.begin(), f_edges.end(), std::greater<int>());
+ for (int ei : f_edges) graph.break_edge(ei);
+
+ simulate_ticks(graph, 180); // 3 seconds for the fall
+
+ if (graph.is_device_alive(dev_id)) {
+ FAIL("reactor still alive after both mount nodes fell below ground "
+ "(HP=%.1f)", graph.get_device_hp(dev_id));
+ } else {
+ printf("\n reactor destroyed: HP=%.1f, alive=%s",
+ graph.get_device_hp(dev_id),
+ graph.is_device_alive(dev_id) ? "yes" : "no");
+ PASS();
+ }
+ }
+ next_dev1:;
+
+ // ----------------------------------------------------------------
+ TEST("reactor falls and dies when its platform support struts are cut");
+ {
+ // This is the canonical win-condition path: surgically sever the
+ // struts that mount the reactor without cutting foundation edges.
+ // The reactor falls below ground, its mount nodes are killed by
+ // kill_grounded(), and break_node() marks the device dead.
+ BuildGraph graph;
+ TowerIndices idx{};
+ if (!build_test_tower(graph, idx)) {
+ FAIL("could not build test tower");
+ goto next_dev1b;
+ }
+
+ simulate_ticks(graph, 60, /*quiet=*/true);
+
+ int dev_id = graph.mount_device(idx.n4, idx.n5, DEVICE_REACTOR, 0, 200.0f);
+ if (dev_id < 0) {
+ FAIL("mount_device returned %d", dev_id);
+ goto next_dev1b;
+ }
+ if (!graph.is_device_alive(dev_id)) {
+ FAIL("reactor not alive immediately after mounting");
+ goto next_dev1b;
+ }
+
+ printf("\n reactor mounted on n4-n5 (id=%d, HP=%.1f)",
+ dev_id, graph.get_device_hp(dev_id));
+
+ // Cut the five struts that hold up the platform deck (n4,n5).
+ // n2-n4 (upper_l), n6-n5 (upper_r),
+ // n3-n4 (deck_brace_l), n3-n5 (deck_brace_r),
+ // n4-n5 (deck — the mount strut itself)
+ // This detaches n4 and n5 from the structure. The tower below
+ // (n0,n1,n2,n3,n6) remains standing because foundation edges
+ // are intact.
+ std::vector<int> platform_edges = {
+ idx.upper_l, idx.upper_r,
+ idx.deck_brace_l, idx.deck_brace_r,
+ idx.deck,
+ };
+
+ // Validate all platform edges still exist.
+ for (int ei : platform_edges) {
+ if (ei < 0 || ei >= (int)graph.edges.size()) {
+ FAIL("platform edge index %d out of range", ei);
+ goto next_dev1b;
+ }
+ }
+
+ printf("\n cutting %zu platform support strut(s):", platform_edges.size());
+ for (int ei : platform_edges)
+ printf(" #%d(n%d-n%d)", ei, graph.edges[ei].node_a, graph.edges[ei].node_b);
+
+ // Cut in descending order so indices stay valid.
+ std::sort(platform_edges.begin(), platform_edges.end(), std::greater<int>());
+ for (int ei : platform_edges)
+ graph.break_edge(ei);
+
+ const int nodes_before = (int)graph.nodes.size();
+ simulate_ticks(graph, 180); // 3 seconds for the fall
+
+ // Reactor mount nodes (n4,n5) should have fallen below ground.
+ if (graph.is_device_alive(dev_id)) {
+ FAIL("reactor still alive after its platform supports were cut "
+ "(HP=%.1f, nodes %d->%d)",
+ graph.get_device_hp(dev_id),
+ nodes_before, (int)graph.nodes.size());
+ } else {
+ // Tower body (n0,n1,n2,n3,n6 + edges) should remain standing
+ // since foundation edges are intact. We verify by checking the
+ // surviving node count is at least the 5 tower-body nodes.
+ int surviving = (int)graph.nodes.size();
+ printf("\n reactor destroyed: HP=%.1f, alive=%s",
+ graph.get_device_hp(dev_id),
+ graph.is_device_alive(dev_id) ? "yes" : "no");
+ printf("\n tower retained %d/%d nodes (expect 5: n0,n1,n2,n3,n6)",
+ surviving, nodes_before);
+ PASS();
+ }
+ }
+ next_dev1b:;
+
+ // ----------------------------------------------------------------
+ TEST("reactor survives when only one foundation connection is cut");
+ {
+ BuildGraph graph;
+ TowerIndices idx{};
+ if (!build_test_tower(graph, idx)) {
+ FAIL("could not build test tower");
+ goto next_dev2;
+ }
+
+ simulate_ticks(graph, 60, /*quiet=*/true);
+
+ int dev_id = graph.mount_device(idx.n4, idx.n5, DEVICE_REACTOR, 0, 200.0f);
+ if (dev_id < 0) { FAIL("mount_device failed"); goto next_dev2; }
+
+ // Cut only the left leg — 3 foundation edges remain.
+ int leg_l = find_edge_by_nodes(graph, idx.n0, idx.n2);
+ if (leg_l < 0) { FAIL("left leg vanished"); goto next_dev2; }
+ graph.break_edge(leg_l);
+
+ simulate_ticks(graph, 300);
+
+ if (!graph.is_device_alive(dev_id)) {
+ FAIL("reactor destroyed when structure is still standing");
+ } else {
+ printf("\n reactor HP=%.1f, alive=yes", graph.get_device_hp(dev_id));
+ PASS();
+ }
+ }
+ next_dev2:;
+
+ // ----------------------------------------------------------------
+ TEST("reactor takes splash damage through its mount struts");
+ {
+ BuildGraph graph;
+ TowerIndices idx{};
+ if (!build_test_tower(graph, idx)) {
+ FAIL("could not build test tower");
+ goto next_dev3;
+ }
+
+ simulate_ticks(graph, 300, /*quiet=*/true);
+
+ int dev_id = graph.mount_device(idx.n4, idx.n5, DEVICE_REACTOR, 0, 200.0f);
+ if (dev_id < 0) { FAIL("mount_device failed"); goto next_dev3; }
+
+ float hp_before = graph.get_device_hp(dev_id);
+
+ // Moderate splash near the platform — damages struts and should pass
+ // some damage through to the mounted device.
+ graph.apply_splash(0.0f, 3.5f, 2.5f, 50.0f, 10.0f);
+
+ float hp_after = graph.get_device_hp(dev_id);
+ printf("\n reactor HP: %.1f -> %.1f", hp_before, hp_after);
+
+ if (!graph.is_device_alive(dev_id)) {
+ printf("\n (reactor destroyed by splash — valid outcome)");
+ PASS();
+ } else if (hp_after < hp_before) {
+ PASS();
+ } else {
+ FAIL("reactor took no splash damage (HP stayed at %.1f)", hp_before);
+ }
+ }
+ next_dev3:;
+
+ // ----------------------------------------------------------------
+ TEST("reactor takes beam damage through its mount struts");
+ {
+ BuildGraph graph;
+ TowerIndices idx{};
+ if (!build_test_tower(graph, idx)) {
+ FAIL("could not build test tower");
+ goto next_dev4;
+ }
+
+ simulate_ticks(graph, 300, /*quiet=*/true);
+
+ int dev_id = graph.mount_device(idx.n4, idx.n5, DEVICE_REACTOR, 0, 200.0f);
+ if (dev_id < 0) { FAIL("mount_device failed"); goto next_dev4; }
+
+ float hp_before = graph.get_device_hp(dev_id);
+
+ // Fire a diagonal beam through the platform (not collinear with any edge).
+ float hit_x = 0, hit_y = 0;
+ graph.beam_fire(-2.0f, 5.0f, 1.0f, -0.6f, 6.0f, 100.0f, false,
+ hit_x, hit_y);
+
+ float hp_after = graph.get_device_hp(dev_id);
+ printf("\n reactor HP: %.1f -> %.1f (beam hit at %.2f, %.2f)",
+ hp_before, hp_after, hit_x, hit_y);
+
+ if (!graph.is_device_alive(dev_id)) {
+ printf("\n (reactor destroyed by beam — valid outcome)");
+ PASS();
+ } else if (hp_after < hp_before) {
+ PASS();
+ } else {
+ FAIL("reactor took no beam damage (HP stayed at %.1f)", hp_before);
+ }
+ }
+ next_dev4:;
+
+ // ----------------------------------------------------------------
+ TEST("reactor takes fire damage from burning mount struts");
+ {
+ BuildGraph graph;
+ TowerIndices idx{};
+ if (!build_test_tower(graph, idx)) {
+ FAIL("could not build test tower");
+ goto next_dev5;
+ }
+
+ simulate_ticks(graph, 60, /*quiet=*/true);
+
+ int dev_id = graph.mount_device(idx.n4, idx.n5, DEVICE_REACTOR, 0, 200.0f);
+ if (dev_id < 0) { FAIL("mount_device failed"); goto next_dev5; }
+
+ float hp_before = graph.get_device_hp(dev_id);
+
+ // Ignite the left leg — fire spreads up to the platform.
+ int leg_l = find_edge_by_nodes(graph, idx.n0, idx.n2);
+ if (leg_l < 0) { FAIL("left leg vanished"); goto next_dev5; }
+ graph.ignite_edge(leg_l);
+
+ // 15 seconds: long enough for fire to spread and do damage.
+ simulate_ticks(graph, (int)(15.0f / FIXED_DT));
+
+ float hp_after = graph.get_device_hp(dev_id);
+ printf("\n reactor HP: %.1f -> %.1f", hp_before, hp_after);
+
+ if (!graph.is_device_alive(dev_id)) {
+ printf("\n (reactor destroyed by fire — valid outcome)");
+ PASS();
+ } else if (hp_after < hp_before) {
+ PASS();
+ } else {
+ FAIL("reactor took no fire damage after 15s (HP stayed at %.1f)",
+ hp_before);
+ }
+ }
+ next_dev5:;
+
+ // ----------------------------------------------------------------
+ TEST("multiple devices on same structure all destroyed on collapse");
+ {
+ BuildGraph graph;
+ TowerIndices idx{};
+ if (!build_test_tower(graph, idx)) {
+ FAIL("could not build test tower");
+ goto next_dev6;
+ }
+
+ simulate_ticks(graph, 60, /*quiet=*/true);
+
+ // Mount three devices on different struts of the same tower.
+ int reactor_id = graph.mount_device(idx.n4, idx.n5, DEVICE_REACTOR, 0, 200.0f);
+ int mine_id = graph.mount_device(idx.n3, idx.n6, DEVICE_MINE, 0, 100.0f);
+ int turbine_id = graph.mount_device(idx.n2, idx.n3, DEVICE_TURBINE, 0, 80.0f);
+ if (reactor_id < 0 || mine_id < 0 || turbine_id < 0) {
+ FAIL("mount_device failed (r=%d m=%d t=%d)",
+ reactor_id, mine_id, turbine_id);
+ goto next_dev6;
+ }
+
+ printf("\n %zu devices mounted",
+ graph.get_devices().size());
+
+ // Sever foundation → entire tower collapses → all devices die.
+ auto f_edges = find_foundation_edges(graph);
+ std::sort(f_edges.begin(), f_edges.end(), std::greater<int>());
+ for (int ei : f_edges) graph.break_edge(ei);
+
+ simulate_ticks(graph, 300);
+
+ bool r_alive = graph.is_device_alive(reactor_id);
+ bool m_alive = graph.is_device_alive(mine_id);
+ bool t_alive = graph.is_device_alive(turbine_id);
+
+ printf("\n reactor=%s mine=%s turbine=%s",
+ r_alive ? "alive" : "dead",
+ m_alive ? "alive" : "dead",
+ t_alive ? "alive" : "dead");
+
+ if (r_alive || m_alive || t_alive) {
+ FAIL("expected all devices dead after collapse");
+ } else {
+ PASS();
+ }
+ }
+ next_dev6:;
+
+ // ----------------------------------------------------------------
+ TEST("device index is stable across unrelated edge breaks");
+ {
+ BuildGraph graph;
+ TowerIndices idx{};
+ if (!build_test_tower(graph, idx)) {
+ FAIL("could not build test tower");
+ goto next_dev7;
+ }
+
+ simulate_ticks(graph, 60, /*quiet=*/true);
+
+ int dev_id = graph.mount_device(idx.n4, idx.n5, DEVICE_REACTOR, 0, 200.0f);
+ if (dev_id < 0) { FAIL("mount_device failed"); goto next_dev7; }
+
+ // Break an edge that is NOT the mount strut — the device ID should
+ // remain valid. This exercises the "indices shift" gotcha.
+ int horiz = find_edge_by_nodes(graph, idx.n2, idx.n3);
+ if (horiz < 0) { FAIL("horiz edge not found"); goto next_dev7; }
+
+ printf("\n breaking unrelated edge #%d (n2-n3)", horiz);
+ graph.break_edge(horiz);
+
+ // Device ID must still be usable.
+ if (!graph.is_device_alive(dev_id)) {
+ FAIL("reactor died when an unrelated edge broke");
+ } else {
+ printf("\n reactor HP=%.1f, alive=yes", graph.get_device_hp(dev_id));
+ PASS();
+ }
+ }
+ next_dev7:;
+
+ // ----------------------------------------------------------------
+ TEST("unmount_device removes a device without affecting the graph");
+ {
+ BuildGraph graph;
+ TowerIndices idx{};
+ if (!build_test_tower(graph, idx)) {
+ FAIL("could not build test tower");
+ goto next_dev8;
+ }
+
+ simulate_ticks(graph, 60, /*quiet=*/true);
+
+ int dev_id = graph.mount_device(idx.n4, idx.n5, DEVICE_REACTOR, 0, 200.0f);
+ if (dev_id < 0) { FAIL("mount_device failed"); goto next_dev8; }
+
+ int nodes_before = (int)graph.nodes.size();
+ int edges_before = (int)graph.edges.size();
+
+ graph.unmount_device(dev_id);
+
+ if (graph.is_device_alive(dev_id)) {
+ FAIL("unmount_device did not kill the device "
+ "(alive=%d hp=%.1f)",
+ (int)graph.is_device_alive(dev_id),
+ graph.get_device_hp(dev_id));
+ } else if ((int)graph.nodes.size() != nodes_before ||
+ (int)graph.edges.size() != edges_before) {
+ FAIL("unmount_device altered the graph (nodes %d->%d, edges %d->%d)",
+ nodes_before, (int)graph.nodes.size(),
+ edges_before, (int)graph.edges.size());
+ } else {
+ printf("\n device unmounted, graph unchanged (nodes %d, edges %d)",
+ (int)graph.nodes.size(), (int)graph.edges.size());
+ PASS();
+ }
+ }
+ next_dev8:;
+
+ // ------------------------------------------------------------------
+ printf("\n=== %d/%d tests passed ===\n", tests_run - tests_failed, tests_run);
+ return tests_failed == 0 ? 0 : 1;
+}