diff options
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | meson.build | 10 | ||||
| -rw-r--r-- | requirements/milestones.org | 23 | ||||
| -rw-r--r-- | research/gameplay-gaps.org | 40 | ||||
| -rw-r--r-- | src/engine/app.cpp | 32 | ||||
| -rw-r--r-- | src/game/build_graph.cpp | 158 | ||||
| -rw-r--r-- | src/game/build_graph.hpp | 30 | ||||
| -rw-r--r-- | tools/device_collapse_test.cpp | 874 |
8 files changed, 1143 insertions, 25 deletions
@@ -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; +} |
