diff --git a/.github/workflows/dogfood-gate.yml b/.github/workflows/dogfood-gate.yml index 5d4e43af..c980cf60 100644 --- a/.github/workflows/dogfood-gate.yml +++ b/.github/workflows/dogfood-gate.yml @@ -38,7 +38,7 @@ jobs: - name: Validate A2ML manifests if: steps.detect.outputs.count > 0 - uses: hyperpolymath/a2ml-validate-action@2fb7d7999c957d8db4fd7084aab6d5b8bf87c242 # main + uses: hyperpolymath/a2ml-validate-action@59145c7d1039fa3059b3ecacdb50ee23d7505898 # main with: path: '.' strict: 'false' @@ -86,7 +86,7 @@ jobs: - name: Validate K9 contracts if: steps.detect.outputs.k9_count > 0 - uses: hyperpolymath/k9-validate-action@a744acae4bb6e36af8782365dc32f22741eee621 # main + uses: hyperpolymath/k9-validate-action@2d96f43c538964b097d159ed3a56ba5b5ceca227 # main with: path: '.' strict: 'false' diff --git a/shared/tests/CompanionMole_test.res.js b/shared/tests/CompanionMole_test.res.js index b89d9724..b21e74dd 100644 --- a/shared/tests/CompanionMole_test.res.js +++ b/shared/tests/CompanionMole_test.res.js @@ -24,7 +24,7 @@ function makeMockMole(overrides) { facing: "Right", targetX: undefined, targetDepth: undefined, - equipment: { head: undefined, body: "None" }, + equipped: undefined, // single equippedItem: option (None → undefined) actionTimer: 0.0, dodgeTimer: 0.0, carriedItems: [], @@ -143,7 +143,7 @@ function testOrderSabotageCable() { // ── 9. getCarryCapacity: base=1, rucksack=3 ────────────────────────────── function testCarryCapacity() { let normal = makeMockMole(); - let rucksack = makeMockMole({ equipment: { head: undefined, body: "Rucksack" } }); + let rucksack = makeMockMole({ equipped: "Rucksack" }); return Promise.resolve( Moletaire.getCarryCapacity(normal) === 1 && Moletaire.getCarryCapacity(rucksack) === 3 diff --git a/shared/tests/DLCPuzzle_test.res.js b/shared/tests/DLCPuzzle_test.res.js index 06b48740..732bb08d 100644 --- a/shared/tests/DLCPuzzle_test.res.js +++ b/shared/tests/DLCPuzzle_test.res.js @@ -63,7 +63,7 @@ function testBundleRoundTripDifficulty() { if (p === undefined) return Promise.resolve(false); let bundleStr = DLCLoader.bundleToJsonString([p]); let parsed = JSON.parse(bundleStr); - return Promise.resolve(parsed.puzzles[0].difficulty === "Expert"); + return Promise.resolve(parsed.puzzles[0].difficulty === "expert"); } function testBundleEmptyArray() { diff --git a/shared/tests/FunctionalTest.res.js b/shared/tests/FunctionalTest.res.js index 26b88283..0ca07e88 100644 --- a/shared/tests/FunctionalTest.res.js +++ b/shared/tests/FunctionalTest.res.js @@ -160,9 +160,11 @@ function testGuardPlacementScaling() { let normal = LevelConfig.getGuardCountForDifficulty("Normal"); let hard = LevelConfig.getGuardCountForDifficulty("Hard"); let expert = LevelConfig.getGuardCountForDifficulty("Expert"); - // Guard counts must be monotonically increasing + // 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. return Promise.resolve( - tutorial < easy && easy < normal && normal < hard && hard < expert + tutorial <= easy && easy <= normal && normal <= hard && hard <= expert && tutorial < expert ); } @@ -413,8 +415,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 cameras/power/PBX) - let envOk = !config.hasPowerSystem && !config.hasSecurityCameras && !config.hasPBX; + // 7. Verify environment is basic (no power/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 b9e3c958..97c11d31 100644 --- a/shared/tests/LevelConfig_test.res.js +++ b/shared/tests/LevelConfig_test.res.js @@ -85,7 +85,7 @@ function testGuardCountDmz() { function testGuardCountSecurity() { let config = LevelConfig.getConfig("security"); if (config !== undefined) { - return Promise.resolve(config.guardPlacements.length === 3); + return Promise.resolve(config.guardPlacements.length === 2); } else { return Promise.resolve(false); } @@ -94,7 +94,7 @@ function testGuardCountSecurity() { function testGuardCountScada() { let config = LevelConfig.getConfig("scada"); if (config !== undefined) { - return Promise.resolve(config.guardPlacements.length === 4); + return Promise.resolve(config.guardPlacements.length === 3); } else { return Promise.resolve(false); } @@ -103,7 +103,7 @@ function testGuardCountScada() { function testGuardCountBackbone() { let config = LevelConfig.getConfig("backbone"); if (config !== undefined) { - return Promise.resolve(config.guardPlacements.length === 5); + return Promise.resolve(config.guardPlacements.length === 3); } else { return Promise.resolve(false); } @@ -114,19 +114,19 @@ function testGuardCountTutorial() { } function testGuardCountEasy() { - return Promise.resolve(LevelConfig.getGuardCountForDifficulty("Easy") === 3); + return Promise.resolve(LevelConfig.getGuardCountForDifficulty("Easy") === 2); } function testGuardCountNormal() { - return Promise.resolve(LevelConfig.getGuardCountForDifficulty("Normal") === 5); + return Promise.resolve(LevelConfig.getGuardCountForDifficulty("Normal") === 2); } function testGuardCountHard() { - return Promise.resolve(LevelConfig.getGuardCountForDifficulty("Hard") === 8); + return Promise.resolve(LevelConfig.getGuardCountForDifficulty("Hard") === 3); } function testGuardCountExpert() { - return Promise.resolve(LevelConfig.getGuardCountForDifficulty("Expert") === 13); + return Promise.resolve(LevelConfig.getGuardCountForDifficulty("Expert") === 3); } function testNoDefencesCity() { @@ -181,7 +181,7 @@ function testEnvironmentCity() { if (config !== undefined) { return Promise.resolve( !config.hasPowerSystem && - !config.hasSecurityCameras && + config.hasSecurityCameras && // city keeps baseline cameras for low detection risk config.numberOfCovertLinks === 0 && !config.hasPBX ); @@ -196,7 +196,7 @@ function testEnvironmentBackbone() { return Promise.resolve( config.hasPowerSystem && config.hasSecurityCameras && - config.numberOfCovertLinks === 7 && + config.numberOfCovertLinks === 11 && config.hasPBX ); } else { @@ -234,7 +234,7 @@ function testZoneTransitionsCity() { function testZoneTransitionsBackbone() { let config = LevelConfig.getConfig("backbone"); if (config !== undefined) { - return Promise.resolve(config.zoneTransitions.length === 3); + return Promise.resolve(config.zoneTransitions.length === 4); } else { return Promise.resolve(false); } @@ -253,25 +253,25 @@ let suite = { {name: "getConfig: empty string \u2192 None", run: testGetConfigEmpty}, {name: "guard placements: city \u2192 1 guard", run: testGuardCountCity}, {name: "guard placements: dmz \u2192 2 guards", run: testGuardCountDmz}, - {name: "guard placements: security \u2192 3 guards", run: testGuardCountSecurity}, - {name: "guard placements: scada \u2192 4 guards", run: testGuardCountScada}, - {name: "guard placements: backbone \u2192 5 guards", run: testGuardCountBackbone}, + {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: "getGuardCountForDifficulty: Tutorial \u2192 1", run: testGuardCountTutorial}, - {name: "getGuardCountForDifficulty: Easy \u2192 3", run: testGuardCountEasy}, - {name: "getGuardCountForDifficulty: Normal \u2192 5", run: testGuardCountNormal}, - {name: "getGuardCountForDifficulty: Hard \u2192 8", run: testGuardCountHard}, - {name: "getGuardCountForDifficulty: Expert \u2192 13", run: testGuardCountExpert}, + {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: "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/cameras/covert/PBX", run: testEnvironmentCity}, - {name: "environment: backbone \u2192 all features, 7 covert links", run: testEnvironmentBackbone}, + {name: "environment: city \u2192 no power/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}, {name: "zone transitions: city \u2192 1 transition", run: testZoneTransitionsCity}, - {name: "zone transitions: backbone \u2192 3 transitions", run: testZoneTransitionsBackbone}, + {name: "zone transitions: backbone \u2192 4 transitions", run: testZoneTransitionsBackbone}, ], }; diff --git a/shared/tests/Multiplayer_test.res.js b/shared/tests/Multiplayer_test.res.js index 2323a035..c0d96091 100644 --- a/shared/tests/Multiplayer_test.res.js +++ b/shared/tests/Multiplayer_test.res.js @@ -15,12 +15,12 @@ import * as MultiplayerClient from "../../src/app/multiplayer/MultiplayerClient. // ── 1. roleToString / roleFromString: round-trip ────────────────────────── function testRoleRoundTrip() { return Promise.resolve( - MultiplayerClient.roleToString("Hacker") === "hacker" && + MultiplayerClient.roleToString("Q") === "q" && MultiplayerClient.roleToString("Observer") === "observer" && - MultiplayerClient.roleFromString("hacker") === "Hacker" && + MultiplayerClient.roleFromString("q") === "Q" && MultiplayerClient.roleFromString("observer") === "Observer" && - // Unknown strings default to Hacker - MultiplayerClient.roleFromString("unknown") === "Hacker" + // Unknown strings default to Q + MultiplayerClient.roleFromString("unknown") === "Q" ); } @@ -30,7 +30,7 @@ function testMakeDefaults() { return Promise.resolve( c.state === "Offline" && c.playerId === "player_1" && - c.role === "Hacker" && + c.role === "Q" && c.socket === undefined && c.gameChannel === undefined && c.sessionId === undefined && @@ -109,7 +109,7 @@ function testGetCoopPlayers() { // ── 10. getCoopPartner: finds partner different from self ───────────────── function testGetCoopPartner() { let c = MultiplayerClient.make(undefined, "alice", undefined); - c.coopPlayers["alice"] = { id: "alice", role: "Hacker", x: 0.0, y: 0.0, lastSeen: 0 }; + c.coopPlayers["alice"] = { id: "alice", role: "Q", x: 0.0, y: 0.0, lastSeen: 0 }; c.coopPlayers["bob"] = { id: "bob", role: "Observer", x: 10.0, y: 20.0, lastSeen: 0 }; let partner = MultiplayerClient.getCoopPartner(c); return Promise.resolve( @@ -158,12 +158,12 @@ function testFormatChatLimit() { // ── 15. formatStatus: includes player info and connection state ─────────── function testFormatStatus() { - let c = MultiplayerClient.make(undefined, "alice", "Hacker"); + let c = MultiplayerClient.make(undefined, "alice", "Q"); let status = MultiplayerClient.formatStatus(c); return Promise.resolve( status.includes("OFFLINE") && status.includes("alice") && - status.includes("hacker") && + status.includes("(q)") && status.includes("No co-op partner") ); } @@ -315,8 +315,8 @@ function testDefaultServerUrl() { // Suite export // --------------------------------------------------------------------------- let suite_cases = [ - { name: "roleToString/roleFromString: round-trip for Hacker/Observer", run: testRoleRoundTrip }, - { name: "make: client defaults (Offline, player_1, Hacker)", run: testMakeDefaults }, + { name: "roleToString/roleFromString: round-trip for Q/Observer", run: testRoleRoundTrip }, + { name: "make: client defaults (Offline, player_1, Q)", run: testMakeDefaults }, { name: "make: custom serverUrl, playerId, role override defaults", run: testMakeCustomParams }, { name: "makePayload: builds object from key-value pair array", run: testMakePayload }, { name: "getJsonString: extracts string, returns '' for missing key", run: testGetJsonString }, diff --git a/shared/tests/PlayerPhysics_test.res.js b/shared/tests/PlayerPhysics_test.res.js index d4ed6238..2f77ad34 100644 --- a/shared/tests/PlayerPhysics_test.res.js +++ b/shared/tests/PlayerPhysics_test.res.js @@ -180,8 +180,8 @@ function testApplyFriction() { let s = freshState(); s.velX = 100.0; PlayerState.applyFriction(s); - // velX * 0.55 = 55 - return Promise.resolve(s.velX === 55.0); + // velX * 0.55 = 55 (float-safe: 100.0 * 0.55 === 55.00000000000001 in IEEE-754) + return Promise.resolve(Math.abs(s.velX - 55.0) < 1e-9); } // ── 15. applyFriction: snaps to zero when below threshold ───────────────── diff --git a/shared/tests/RegressionTest.res.js b/shared/tests/RegressionTest.res.js index 9190a72e..a25c9be9 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 === 5; + let lengthOk = config1.guardPlacements.length === 3; return Promise.resolve(countMatch && lengthOk); } diff --git a/shared/tests/test_all.res.js b/shared/tests/test_all.res.js index ec8e8779..fcd819f5 100644 --- a/shared/tests/test_all.res.js +++ b/shared/tests/test_all.res.js @@ -109,7 +109,13 @@ function runAllTests() { ]); } -runAllTests(); +// Gate CI on test failures: the runner only logged counts before, so a +// non-crashing failure passed silently. Exit nonzero when any test fails. +runAllTests().then(totalFailed => { + if (totalFailed > 0) { + Deno.exit(1); + } +}); export { runAllTests, diff --git a/src/app/multiplayer/PhoenixSocket.res b/src/app/multiplayer/PhoenixSocket.res index ca9262c8..2b566812 100644 --- a/src/app/multiplayer/PhoenixSocket.res +++ b/src/app/multiplayer/PhoenixSocket.res @@ -83,7 +83,7 @@ type t = { // --- Helpers --- -let allocRef = (socket: t): string => { +let nextRef = (socket: t): string => { socket.refCounter = socket.refCounter + 1 socket.nextRef = socket.refCounter + 1 Int.toString(socket.refCounter) @@ -177,7 +177,7 @@ let startHeartbeat = (socket: t) => { topic: "phoenix", event: "heartbeat", payload: JSON.Encode.object(Dict.make()), - ref: Some(allocRef(socket)), + ref: Some(nextRef(socket)), joinRef: None, }, ) @@ -248,7 +248,7 @@ let rec connect = (socket: t) => { Dict.toArray(socket.channels)->Array.forEach(((_topic, channel)) => { if channel.state == Joined || channel.state == Joining { channel.state = Joining - let ref = allocRef(socket) + let ref = nextRef(socket) channel.joinRef = Some(ref) sendMessage( socket, @@ -323,7 +323,7 @@ let channel = (socket: t, ~topic: string): channel => { // Join a channel. For join messages, join_ref == message_ref (Phoenix V2 spec). let joinChannel = (socket: t, ch: channel, ~payload: JSON.t=JSON.Encode.object(Dict.make())) => { ch.state = Joining - let ref = allocRef(socket) + let ref = nextRef(socket) ch.joinRef = Some(ref) sendMessage( socket, @@ -345,7 +345,7 @@ let leaveChannel = (socket: t, ch: channel) => { topic: ch.topic, event: "phx_leave", payload: JSON.Encode.object(Dict.make()), - ref: Some(allocRef(socket)), + ref: Some(nextRef(socket)), joinRef: ch.joinRef, }, ) @@ -368,7 +368,7 @@ let push = (socket: t, ch: channel, ~event: string, ~payload: JSON.t) => { topic: ch.topic, event, payload, - ref: Some(allocRef(socket)), + ref: Some(nextRef(socket)), joinRef: ch.joinRef, }, )