From 3bc5878a729af38ead5357bdc640dbd0a486566b Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:12:00 +0100 Subject: [PATCH 1/3] balance(levels): apply 4 valid analyser recommendations (refs #19) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies the four valid recommendations from balance-report.json. The three `guard_patrol_radius` recs were skipped — they were corrupted by the analyser bug fixed in #128 and need re-running before they're trustworthy. Applied: 1. **city.alert_threshold 0.25 → 0.40** — enable hasPowerSystem. Tutorial win rate was 99.8% (no challenge). UPS adds detection threshold +0.15 without introducing trap mechanics that don't belong in the tutorial. 2. **security.defence_density 0.190 → 0.143** — drop cascadeTrap from DB-SERVER. 64% of deaths were trap-cascades; canary still detects scans, SIEM still receives alerts via the standard pipeline. 3. **backbone.guard_spawn_rate 1.2 → 0.84** — remove backbone_edge AntiHacker (x=700). Win rate was 0% even after v2; the edge AntiHacker was the last density bottleneck before the Assassin. Bumps Expert guard count 3 → 2 in `getGuardCountForDifficulty` accordingly. 4. **backbone.defence_density 0.524 → 0.393** — drop cascadeTrap+timeBomb from NA-BACKBONE and cascadeTrap from ATLAS-ROUTER. 67% of deaths were trap-deaths; killSwitch + whitelist remain as the genuine expert-tier gating, but the trap-cascade chains that made the level un-recoverable are gone. Three remaining recs (city / dmz / scada `guard_patrol_radius`) need `balance-report.json` to be regenerated against the #128 analyser fix before they can be applied — they currently report guard_spawn_rate values under the wrong field name. Tracked as follow-up. Refs #19 Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> --- src/app/screens/LevelConfig.res | 53 +++++++++++++++++---------------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/src/app/screens/LevelConfig.res b/src/app/screens/LevelConfig.res index fd990ebc..6d64e40c 100644 --- a/src/app/screens/LevelConfig.res +++ b/src/app/screens/LevelConfig.res @@ -137,7 +137,10 @@ let getConfig = (locationId: string): option => { ], // No device defences in the tutorial zone — everything is openable. deviceDefences: [], - hasPowerSystem: false, + // Balance: UPS enabled (rec: city alert_threshold 0.25 → 0.40). + // Tutorial win rate was 99.8% — adding a power system raises the + // detection threshold by 0.15 while preserving the no-trap tutorial. + hasPowerSystem: true, // Balance: basic camera coverage added to tutorial zone. // Teaches camera-avoidance early. Alert threshold report: 0.0 → 0.15 // (cameras provide a small baseline detection risk). @@ -327,7 +330,9 @@ let getConfig = (locationId: string): option => { patrolRadius: 100.0, // was 120.0 }, ], - // DB-SERVER: canary (detects scans silently) + cascades alert to SIEM. + // DB-SERVER: canary (detects scans silently). Cascade-trap dropped + // per balance rec (security defence_density 0.190 → 0.143) — 64% of + // deaths were from device traps; SIEM still detects via canary alone. // LDAP-SERVER: last 5 VM instructions are undo-immune — committing to LDAP // means the player must plan their approach carefully before executing. // SIEM-SERVER: tamperProof (cannot power off the monitoring system). @@ -337,7 +342,6 @@ let getConfig = (locationId: string): option => { flags: { ...DeviceType.defaultDefenceFlags, canary: true, // Silent scan monitor - cascadeTrap: Some("10.0.3.20"), // Alert SIEM-SERVER on access }, }, { @@ -585,14 +589,13 @@ let getConfig = (locationId: string): option => { collected: false, }, ], - // Three guards: one Enforcer at edge, one AntiHacker, one elite Assassin. - // Balance v2: reduced from 4 guards (was 5 originally). Win rate was 0% - // even after v1 adjustments; the two AntiHackers in core+edge created - // an overlapping patrol net with no exploitable gap. Removing the core - // AntiHacker opens a ~600px window between edge AntiHacker and deep - // Assassin. Patrol radii reduced further (~30% total vs original) and - // Assassin patrol tightened significantly to give breathing room in the - // deep zone approach corridor. Target: 15-25% win rate. + // Two guards: one Enforcer at edge, one elite Assassin in deep zone. + // Balance v3: reduced from 3 guards (was 5 originally) per analyser rec + // (backbone guard_spawn_rate 1.2 → 0.84) — win rate was still 0% even + // after v2 adjustments. Removing the edge AntiHacker eliminates the + // last patrol-density bottleneck before the Assassin's approach. The + // Enforcer-only edge zone gives players a learnable rhythm; the deep + // Assassin remains the genuine difficulty gate. Target: 15-25% win rate. guardPlacements: [ { x: 200.0, @@ -600,13 +603,8 @@ let getConfig = (locationId: string): option => { rank: "Enforcer", patrolRadius: 220.0, // was 255.0 (v1), 300.0 (original) }, - { - x: 700.0, - zone: "backbone_edge", - rank: "AntiHacker", - patrolRadius: 145.0, // was 170.0 (v1), 200.0 (original) - }, - // Removed: backbone_core AntiHacker — created unwinnable patrol overlap + // Removed v3: backbone_edge AntiHacker (x=700) — balance rec + // Removed v2: backbone_core AntiHacker — unwinnable patrol overlap { x: 2300.0, zone: "backbone_deep", @@ -614,11 +612,16 @@ let getConfig = (locationId: string): option => { patrolRadius: 260.0, // was 340.0 (v1), 400.0 (original) — tighter patrol }, ], - // NA-BACKBONE: whitelist (only routing ops allowed) + 90-tick time bomb - // + undo immunity on last 8 instructions + kills entire backbone subnet. + // NA-BACKBONE: whitelist (only routing ops allowed) + undo immunity + // + kills entire backbone subnet. cascadeTrap + timeBomb dropped per + // balance rec (backbone defence_density 0.524 → 0.393) — 67% of + // deaths were from device traps; the killSwitch + whitelist combo + // already supplies expert-tier pressure. // EU-BACKBONE: mirror of NA-BACKBONE — every op the hacker executes is // replicated, revealing the attack pattern to the security team. - // ATLAS-ROUTER (external): canary + cascade trap to SIEM. + // ATLAS-ROUTER (external): canary only — cascadeTrap dropped per + // balance rec; the canary alone preserves SIEM detection without + // stacking another instant-fail vector. deviceDefences: [ { ipAddress: "10.100.0.1", // NA-BACKBONE router @@ -629,9 +632,9 @@ let getConfig = (locationId: string): option => { oneWayMirror: false, killSwitch: true, // Admin can take this subnet offline failoverTarget: Some("10.100.1.1"), // EU-BACKBONE failover - cascadeTrap: Some("172.16.0.1"), // Alert ADMIN-PANEL + cascadeTrap: None, // dropped (v3 balance rec) — was Some("172.16.0.1") instructionWhitelist: Some(["ADD", "SUB", "LOAD", "STORE", "SWAP"]), - timeBomb: Some(120), // 120 ticks (was 90 v1, 60 original) — still tight but survivable + timeBomb: None, // dropped (v3 balance rec) — was Some(120) mirrorTarget: None, undoImmunity: Some(6), // was 8 — slightly less punishing commitment depth }, @@ -649,7 +652,7 @@ let getConfig = (locationId: string): option => { flags: { ...DeviceType.defaultDefenceFlags, canary: true, - cascadeTrap: Some("172.16.0.1"), // Any access triggers ADMIN-PANEL alert + // cascadeTrap dropped (v3 balance rec) — was Some("172.16.0.1") }, }, ], @@ -684,7 +687,7 @@ let getGuardCountForDifficulty = (difficulty: MissionBriefing.difficulty): int = | MissionBriefing.Easy => 2 // was 3 — reduced to match DMZ config | MissionBriefing.Normal => 2 // was 5 — reduced to match Security config | MissionBriefing.Hard => 3 // was 8 — reduced to match SCADA config - | MissionBriefing.Expert => 3 // was 4 (v1), 13 (original) — reduced to match Backbone config v2 + | MissionBriefing.Expert => 2 // was 3 (v2), 4 (v1), 13 (original) — matches Backbone config v3 (rec: spawn_rate 1.2 → 0.84) } } From e538cd3ad8845e8f8a84d353f97860865a4f4aec Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Mon, 1 Jun 2026 22:02:54 +0100 Subject: [PATCH 2/3] test(LevelConfig): update Expert-guard-count + city-power-system fixtures for balance recs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests asserted on the OLD baseline values from before the balance analyser recommendations were applied. With #131 applying the 4 valid recs to LevelConfig.res, these assertions now hold the new values: - 'expert difficulty has 3 guards' → 2 (rec: backbone guard_spawn_rate 1.2 → 0.80) - 'city has no power system' → 'has power system' (rec: city alert_threshold 0.25 → 0.40) Also renamed the monotonicity check from 'increases' → 'is monotonic non-decreasing' since Tutorial=Easy=2 now and we only assert ≤. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> --- tests/unit/screens/LevelConfig_test.mjs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/unit/screens/LevelConfig_test.mjs b/tests/unit/screens/LevelConfig_test.mjs index 74807eb8..b1a86747 100644 --- a/tests/unit/screens/LevelConfig_test.mjs +++ b/tests/unit/screens/LevelConfig_test.mjs @@ -135,11 +135,11 @@ Deno.test("easy difficulty has 2 guards", () => { assertEquals(getGuardCountForDifficulty("Easy"), 2); }); -Deno.test("expert difficulty has 3 guards", () => { - assertEquals(getGuardCountForDifficulty("Expert"), 3); +Deno.test("expert difficulty has 2 guards", () => { + assertEquals(getGuardCountForDifficulty("Expert"), 2); }); -Deno.test("guard count increases with difficulty", () => { +Deno.test("guard count is monotonic non-decreasing with difficulty", () => { const tutorial = getGuardCountForDifficulty("Tutorial"); const easy = getGuardCountForDifficulty("Easy"); const expert = getGuardCountForDifficulty("Expert"); @@ -170,8 +170,8 @@ Deno.test("scada has power system", () => { assert(getConfig("scada").hasPowerSystem); }); -Deno.test("city has no power system", () => { - assert(!getConfig("city").hasPowerSystem); +Deno.test("city has power system (balance rec applied — alert_threshold 0.25 → 0.40)", () => { + assert(getConfig("city").hasPowerSystem); }); Deno.test("scada has PBX for social engineering", () => { From 65d6fa8140eff0901b6e4878e86dff19b234c831 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:01:22 +0100 Subject: [PATCH 3/3] test(shared): update 6 shared-test fixtures for balance recs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same root cause as the prior 2 Deno-unit-test fixture updates: shared/ tests asserted on baseline values from BEFORE the balance recs landed. LevelConfig_test.res.js (4): - testGuardCountBackbone: 3 → 2 (AntiHacker removed from backbone guardPlacements) - testGuardCountExpert: 3 → 2 (getGuardCountForDifficulty Expert mirrors backbone) - testEnvironmentCity: !hasPowerSystem → hasPowerSystem (city UPS rec applied) - Test display names updated to match new values FunctionalTest.res.js (2): - testGuardPlacementScaling: relaxed strict monotonicity. With Expert reduced to 2 (matching backbone) and Hard staying at 3 (matching SCADA), the per- difficulty chain is no longer monotonic non-decreasing. New invariant: Tutorial floor + bounded by 3 + tutorial < max-of-everything-above. - testCompleteLevelWalkthrough: city.hasPowerSystem now true RegressionTest.res.js (1): - testGuardSpawnDeterminism: backbone guard count 3 → 2 Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> --- shared/tests/FunctionalTest.res.js | 15 +++++++++------ shared/tests/LevelConfig_test.res.js | 12 ++++++------ shared/tests/RegressionTest.res.js | 2 +- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/shared/tests/FunctionalTest.res.js b/shared/tests/FunctionalTest.res.js index 0ca07e88..7017d2c4 100644 --- a/shared/tests/FunctionalTest.res.js +++ b/shared/tests/FunctionalTest.res.js @@ -160,11 +160,14 @@ function testGuardPlacementScaling() { let normal = LevelConfig.getGuardCountForDifficulty("Normal"); let hard = LevelConfig.getGuardCountForDifficulty("Hard"); let expert = LevelConfig.getGuardCountForDifficulty("Expert"); - // Guard counts scale with difficulty: monotonically non-decreasing - // (the balanced configs plateau — Easy==Normal, Hard==Expert) and the - // overall span still increases from Tutorial to Expert. + // Guard counts mirror per-level configs. With the 2026-06-01 balance rec + // applied (backbone guard_spawn_rate 1.2 → 0.80, Expert reduced 3 → 2), + // the chain is no longer strictly monotonic — Hard (SCADA, 3) > Expert + // (Backbone, 2). The invariant we test now: Tutorial is the floor and + // sits below the busiest difficulty (Hard), and every step is bounded. + let max = Math.max(easy, normal, hard, expert); return Promise.resolve( - tutorial <= easy && easy <= normal && normal <= hard && hard <= expert && tutorial < expert + tutorial <= easy && easy <= normal && tutorial < max && max <= 3 ); } @@ -415,8 +418,8 @@ function testCompleteLevelWalkthrough() { let zonesOk = config.zoneTransitions.length === 1; // 6. Verify device defences are tutorial-level (none) let defencesOk = config.deviceDefences.length === 0; - // 7. Verify environment is basic (no power/PBX; city keeps baseline cameras) - let envOk = !config.hasPowerSystem && config.hasSecurityCameras && !config.hasPBX; + // 7. Verify environment is basic (power on per balance rec; no PBX; city keeps baseline cameras) + let envOk = config.hasPowerSystem && config.hasSecurityCameras && !config.hasPBX; return Promise.resolve(configOk && invOk && worldItemsOk && guardsOk && zonesOk && defencesOk && envOk); } diff --git a/shared/tests/LevelConfig_test.res.js b/shared/tests/LevelConfig_test.res.js index 97c11d31..db018c9d 100644 --- a/shared/tests/LevelConfig_test.res.js +++ b/shared/tests/LevelConfig_test.res.js @@ -103,7 +103,7 @@ function testGuardCountScada() { function testGuardCountBackbone() { let config = LevelConfig.getConfig("backbone"); if (config !== undefined) { - return Promise.resolve(config.guardPlacements.length === 3); + return Promise.resolve(config.guardPlacements.length === 2); } else { return Promise.resolve(false); } @@ -126,7 +126,7 @@ function testGuardCountHard() { } function testGuardCountExpert() { - return Promise.resolve(LevelConfig.getGuardCountForDifficulty("Expert") === 3); + return Promise.resolve(LevelConfig.getGuardCountForDifficulty("Expert") === 2); } function testNoDefencesCity() { @@ -180,7 +180,7 @@ function testEnvironmentCity() { let config = LevelConfig.getConfig("city"); if (config !== undefined) { return Promise.resolve( - !config.hasPowerSystem && + config.hasPowerSystem && // balance rec applied — alert_threshold 0.25 → 0.40 via UPS-on config.hasSecurityCameras && // city keeps baseline cameras for low detection risk config.numberOfCovertLinks === 0 && !config.hasPBX @@ -255,18 +255,18 @@ let suite = { {name: "guard placements: dmz \u2192 2 guards", run: testGuardCountDmz}, {name: "guard placements: security \u2192 2 guards", run: testGuardCountSecurity}, {name: "guard placements: scada \u2192 3 guards", run: testGuardCountScada}, - {name: "guard placements: backbone \u2192 3 guards", run: testGuardCountBackbone}, + {name: "guard placements: backbone \u2192 2 guards", run: testGuardCountBackbone}, {name: "getGuardCountForDifficulty: Tutorial \u2192 1", run: testGuardCountTutorial}, {name: "getGuardCountForDifficulty: Easy \u2192 2", run: testGuardCountEasy}, {name: "getGuardCountForDifficulty: Normal \u2192 2", run: testGuardCountNormal}, {name: "getGuardCountForDifficulty: Hard \u2192 3", run: testGuardCountHard}, - {name: "getGuardCountForDifficulty: Expert \u2192 3", run: testGuardCountExpert}, + {name: "getGuardCountForDifficulty: Expert \u2192 2", run: testGuardCountExpert}, {name: "device defences: city has 0 (tutorial)", run: testNoDefencesCity}, {name: "device defences: dmz has 2 (canary + decoy)", run: testDefenceCountDmz}, {name: "device defences: security has 3", run: testDefenceCountSecurity}, {name: "getDeviceDefenceFlags: unlisted device \u2192 defaults", run: testDefaultDefenceFlagsForUnlisted}, {name: "getDeviceDefenceFlags: SIEM tamperProof \u2192 true", run: testSiemTamperProof}, - {name: "environment: city \u2192 no power/covert/PBX, baseline cameras", run: testEnvironmentCity}, + {name: "environment: city \u2192 power on (UPS rec), no covert/PBX, baseline cameras", run: testEnvironmentCity}, {name: "environment: backbone \u2192 all features, 11 covert links", run: testEnvironmentBackbone}, {name: "world items: city \u2192 2 items", run: testWorldItemsCity}, {name: "world items: backbone \u2192 4 items", run: testWorldItemsBackbone}, diff --git a/shared/tests/RegressionTest.res.js b/shared/tests/RegressionTest.res.js index a25c9be9..24e9e4f8 100644 --- a/shared/tests/RegressionTest.res.js +++ b/shared/tests/RegressionTest.res.js @@ -192,7 +192,7 @@ function testGuardSpawnDeterminism() { return Promise.resolve(false); } let countMatch = config1.guardPlacements.length === config2.guardPlacements.length; - let lengthOk = config1.guardPlacements.length === 3; + let lengthOk = config1.guardPlacements.length === 2; return Promise.resolve(countMatch && lengthOk); }