Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions shared/tests/FunctionalTest.res.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}

Expand Down Expand Up @@ -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);
}

Expand Down
12 changes: 6 additions & 6 deletions shared/tests/LevelConfig_test.res.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion shared/tests/RegressionTest.res.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
53 changes: 28 additions & 25 deletions src/app/screens/LevelConfig.res
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,10 @@ let getConfig = (locationId: string): option<levelConfig> => {
],
// 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).
Expand Down Expand Up @@ -327,7 +330,9 @@ let getConfig = (locationId: string): option<levelConfig> => {
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).
Expand All @@ -337,7 +342,6 @@ let getConfig = (locationId: string): option<levelConfig> => {
flags: {
...DeviceType.defaultDefenceFlags,
canary: true, // Silent scan monitor
cascadeTrap: Some("10.0.3.20"), // Alert SIEM-SERVER on access
},
},
{
Expand Down Expand Up @@ -585,40 +589,39 @@ let getConfig = (locationId: string): option<levelConfig> => {
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,
zone: "backbone_edge",
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",
rank: "Assassin",
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
Expand All @@ -629,9 +632,9 @@ let getConfig = (locationId: string): option<levelConfig> => {
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
},
Expand All @@ -649,7 +652,7 @@ let getConfig = (locationId: string): option<levelConfig> => {
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")
},
},
],
Expand Down Expand Up @@ -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)
}
}

Expand Down
10 changes: 5 additions & 5 deletions tests/unit/screens/LevelConfig_test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading