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); } 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) } } 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", () => {