From 776f6d32aa7ee94de804d3ae15f2ed6ecd783781 Mon Sep 17 00:00:00 2001 From: Daniel Meyer Date: Tue, 13 Oct 2026 17:28:07 -0700 Subject: [PATCH 1/2] Fix Release input and finalize all Exposure deltas (D-102, T301/T410) --- .github/workflows/ci.yml | 11 +- App/GameScene.swift | 86 +++++++------ AppTests/SceneInputIntegrationTests.swift | 86 +++++++++++++ README.md | 2 +- SPEC_BASELINE.md | 6 +- .../SurveillanceCore/ContractVersions.swift | 4 +- .../Exposure/ExposureState.swift | 31 ++++- .../SurveillanceCore/Resources/SPEC_PIN.txt | 4 +- .../contracts/asset-catalog-001.json | 2 +- .../contracts/runtime-kernel-001.json | 2 +- .../Resources/contracts/versions.json | 2 +- .../fixtures/complete-run-vectors-001.json | 8 +- .../fixtures/kernel-vectors-001.json | 2 +- .../Resources/fixtures/replay-matrix-001.json | 4 +- .../Resources/fixtures/replay-smoke-001.json | 2 +- .../SurveillanceCore/World/Simulation.swift | 52 ++------ .../CompleteRunVectorTests.swift | 6 +- .../ExposureFinalizationTests.swift | 117 ++++++++++++++++++ Tests/SurveillanceCoreTests/ReplayTests.swift | 11 +- docs/review/D102.md | 14 +++ intent/2026-10-13-release-input.md | 36 ++++++ 21 files changed, 374 insertions(+), 114 deletions(-) create mode 100644 AppTests/SceneInputIntegrationTests.swift create mode 100644 Tests/SurveillanceCoreTests/ExposureFinalizationTests.swift create mode 100644 docs/review/D102.md create mode 100644 intent/2026-10-13-release-input.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3d2b04..b97882b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Verify pinned specification - run: grep -q "01f9c898423ce37dbaeace19b722fb257928e985" SPEC_BASELINE.md + run: grep -q "92355fbaa7575425f8ab4e2d2eec7b7071fe2ec1" SPEC_BASELINE.md - name: Test deterministic core run: swift test --skip CameraPlacementTests --skip CameraFairnessTests --parallel --num-workers 1 - name: Test camera placement @@ -38,11 +38,15 @@ jobs: steps: - uses: actions/checkout@v4 - name: Verify pinned specification - run: grep -q "01f9c898423ce37dbaeace19b722fb257928e985" SPEC_BASELINE.md + run: grep -q "92355fbaa7575425f8ab4e2d2eec7b7071fe2ec1" SPEC_BASELINE.md - name: Byte-compare bundled contracts and fixtures against the pinned SS-specs run: bash scripts/spec_parity.sh app: + strategy: + fail-fast: false + matrix: + configuration: [Debug, Release] # The iOS app target had never been compiled by CI: Bundle.module was # unavailable to a framework target and App/ had access-level errors. # Build it on every change so the README bootstrap path stays honest. @@ -68,7 +72,8 @@ jobs: # read 0 and the job could never fail. Keep xcodebuild's own status. set +e xcodebuild -project SSRuntime.xcodeproj -scheme SSRuntime -sdk iphonesimulator \ - -destination "platform=iOS Simulator,id=$sim" CODE_SIGNING_ALLOWED=NO test \ + -destination "platform=iOS Simulator,id=$sim" -configuration "${{ matrix.configuration }}" \ + CODE_SIGNING_ALLOWED=NO ENABLE_TESTABILITY=YES test \ > /tmp/xcodebuild.log 2>&1 status=$? set -e diff --git a/App/GameScene.swift b/App/GameScene.swift index 8c378b8..7bec856 100644 --- a/App/GameScene.swift +++ b/App/GameScene.swift @@ -193,7 +193,7 @@ final class GameSession { } final class GameScene: SKScene { - private let session = GameSession() + let session = GameSession() private let instrumentation = RunInstrumentation() private let deviceRunTracker = DeviceRunTracker() private var terminalEvidenceStored = false @@ -209,6 +209,7 @@ final class GameScene: SKScene { private let soundEngine = AudioEngine() private var controller = TouchController() private var projector: HUDProjector? + var controlLayout: ControlLayout? { hud.controlLayout } /// player-controller-001 PC-008: pause creates no simulation ticks. private var runPaused = false /// Raised when the player presses Pause; SwiftUI owns the surface itself. @@ -467,6 +468,8 @@ final class GameScene: SKScene { } else { applyController() } +#else + applyController() #endif session.step() // D-097: the takedown's hit-stop and ring, after the step that made it. @@ -712,69 +715,64 @@ final class GameScene: SKScene { } override func touchesBegan(_ touches: Set, with event: UIEvent?) { - guard let projector else { return } - // D-098: any touch skips the intro, and does nothing else. - if skipIntroIfRunning() { return } - let snap = session.snapshot - for touch in touches { guard let point = points(touch) else { continue } + if beginTouch(token: token(touch), atPoints: point) { return } + } + } - // Only a finished run restarts, and only from the restart control. - // - // This was `snap.outcome != .playing`, which is also true while the - // upgrade selection is open — so the first tap at the upgrade gate - // restarted the entire run and the selection branch below could - // never be reached. `isTerminal` excludes that state. - // - // Requiring the control also ends restart-on-any-touch, which had - // no surface telling the player the run was over. - if snap.outcome.isTerminal { - if hud.terminalRestartHit(atPoints: point, projector: projector) { - restartRun() - } else if hud.terminalShareHit(atPoints: point, projector: projector) { - presentShare() - } - return - } - // The protected selection takes every touch while it is open. - if snap.upgradePending { - if let choice = hud.upgradeCardIndex(atPoints: point, projector: projector) { - session.pendingUpgradeChoice = choice - } - continue + /// The production point-space boundary also exercised by PC-009 tests. + /// True consumes the remainder of a touch batch (intro or terminal action). + @discardableResult + func beginTouch(token: TouchController.TouchToken, atPoints point: CGPoint) -> Bool { + guard let projector else { return false } + if skipIntroIfRunning() { return true } + let snap = session.snapshot + if snap.outcome.isTerminal { + if hud.terminalRestartHit(atPoints: point, projector: projector) { + restartRun() + } else if hud.terminalShareHit(atPoints: point, projector: projector) { + presentShare() } - switch controller.began(token: token(touch), atPoints: point, layout: hud.controlLayout ?? .empty) { - case .pause: - onPauseRequested?() - case .stick, .dodge, .none: - break + return true + } + if snap.upgradePending { + if let choice = hud.upgradeCardIndex(atPoints: point, projector: projector) { + session.pendingUpgradeChoice = choice } + return false } + if controller.began(token: token, atPoints: point, layout: hud.controlLayout ?? .empty) == .pause { + onPauseRequested?() + } + return false } override func touchesMoved(_ touches: Set, with event: UIEvent?) { - guard let layout = hud.controlLayout else { return } for touch in touches { guard let point = points(touch) else { continue } - controller.moved(token: token(touch), toPoints: point, layout: layout) + moveTouch(token: token(touch), toPoints: point) } } + func moveTouch(token: TouchController.TouchToken, toPoints point: CGPoint) { + guard let layout = hud.controlLayout else { return } + controller.moved(token: token, toPoints: point, layout: layout) + } + override func touchesEnded(_ touches: Set, with event: UIEvent?) { - for touch in touches { - controller.ended(token: token(touch)) - } + for touch in touches { endTouch(token: token(touch)) } } override func touchesCancelled(_ touches: Set, with event: UIEvent?) { - for touch in touches { - controller.ended(token: token(touch)) - } + for touch in touches { endTouch(token: token(touch)) } + } + + func endTouch(token: TouchController.TouchToken) { + controller.ended(token: token) } - /// § 11 Share: the system share sheet with the run card's plain text and - /// nothing else — no seed, no receipt, no identifier. + /// § 11 Share: the system share sheet contains only the run card copy. private func presentShare() { guard let text = runCard?.shareText, let view, diff --git a/AppTests/SceneInputIntegrationTests.swift b/AppTests/SceneInputIntegrationTests.swift new file mode 100644 index 0000000..9096b6e --- /dev/null +++ b/AppTests/SceneInputIntegrationTests.swift @@ -0,0 +1,86 @@ +import SpriteKit +import Testing +@testable import SSRuntime + +/// PC-009 / T301. These run in both CI configurations and enter at the same +/// point-space boundary as UITouch, then execute the real GameScene.update. +@Suite(.serialized) +@MainActor +struct SceneInputIntegrationTests { + final class Finger {} + private func token(_ finger: Finger) -> TouchController.TouchToken { + .init(id: ObjectIdentifier(finger)) + } + + private func makeScene() -> (GameScene, SKView) { + let view = SKView(frame: CGRect(x: 0, y: 0, width: 844, height: 390)) + view.isPaused = true // Only the explicitly driven frames advance. + let scene = GameScene(size: view.bounds.size) + view.presentScene(scene) + scene.session.audioSettings = .init(effectsEnabled: false, hapticsEnabled: false, musicEnabled: false) + return (scene, view) + } + + @Test func touchMovementAndDodgeReachTheSimulation() throws { + let (scene, view) = makeScene() + defer { view.presentScene(nil) } + let layout = try #require(scene.controlLayout) + let stick = Finger(), dodge = Finger() + let start = scene.session.simulation.state.player.position + scene.beginTouch(token: token(stick), atPoints: layout.stickCentre) + scene.moveTouch(token: token(stick), toPoints: CGPoint(x: layout.stickCentre.x + layout.stickRadius, y: layout.stickCentre.y)) + scene.update(1) + #expect(scene.session.commandLog.last?.moveX == Int16(TouchController.maximum)) + #expect(scene.session.simulation.state.player.position != start) + scene.beginTouch(token: token(dodge), atPoints: CGPoint(x: layout.dodgeRect.midX, y: layout.dodgeRect.midY)) + scene.update(2) + #expect(scene.session.commandLog.last?.dodgePressed == true) + scene.update(3) + #expect(scene.session.commandLog.last?.dodgePressed == false) + scene.endTouch(token: token(stick)) + scene.endTouch(token: token(dodge)) + scene.update(4) + #expect(scene.session.commandLog.last?.moveX == 0) + } + + @Test func pauseResumeClearsHeldMovementAndBufferedDodge() throws { + let (scene, view) = makeScene() + defer { view.presentScene(nil) } + let layout = try #require(scene.controlLayout) + let stick = Finger(), dodge = Finger(), replacement = Finger() + let edge = CGPoint(x: layout.stickCentre.x + layout.stickRadius, y: layout.stickCentre.y) + scene.beginTouch(token: token(stick), atPoints: edge) + scene.update(1) + #expect(scene.session.commandLog.last?.moveX != 0) + scene.beginTouch(token: token(dodge), atPoints: CGPoint(x: layout.dodgeRect.midX, y: layout.dodgeRect.midY)) + let tick = scene.currentTick + scene.setPaused(true) + scene.update(2) + #expect(scene.currentTick == tick) + scene.setPaused(false) + scene.update(3) + #expect(scene.currentTick == tick + 1) + #expect(scene.session.commandLog.last?.moveX == 0) + #expect(scene.session.commandLog.last?.dodgePressed == false) + scene.beginTouch(token: token(replacement), atPoints: edge) + scene.update(4) + #expect(scene.session.commandLog.last?.moveX == Int16(TouchController.maximum)) + } + + @Test func restartRestoresInitialStateAndClearsInput() throws { + let (scene, view) = makeScene() + defer { view.presentScene(nil) } + let layout = try #require(scene.controlLayout) + let stick = Finger(), dodge = Finger() + let initial = scene.session.simulation.state.digest() + scene.beginTouch(token: token(stick), atPoints: CGPoint(x: layout.stickCentre.x + layout.stickRadius, y: layout.stickCentre.y)) + scene.update(1) + scene.beginTouch(token: token(dodge), atPoints: CGPoint(x: layout.dodgeRect.midX, y: layout.dodgeRect.midY)) + scene.restartRun() + #expect(scene.currentTick == 0) + #expect(scene.session.simulation.state.digest() == initial) + scene.update(2) + #expect(scene.session.commandLog.last?.moveX == 0) + #expect(scene.session.commandLog.last?.dodgePressed == false) + } +} diff --git a/README.md b/README.md index 6e6ab4d..589dd87 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ iPhone runtime for the one-level **Surveillance Survivor** reboot. - Repository: [scrimshawlife-ctrl/SS-specs](https://github.com/scrimshawlife-ctrl/SS-specs) - Baseline commit: see [SPEC_BASELINE.md](SPEC_BASELINE.md). -- Ruleset: `ss-rules-006` +- Ruleset: `ss-rules-007` - Content: `civic-seam-content-006` - Arena: `civic-seam-arena-004` - Replay schema: `runtime-kernel-001` diff --git a/SPEC_BASELINE.md b/SPEC_BASELINE.md index a2d493b..62caea7 100644 --- a/SPEC_BASELINE.md +++ b/SPEC_BASELINE.md @@ -5,8 +5,8 @@ Status: PINNED | Identity | Value | |---|---| | Specification repository | `scrimshawlife-ctrl/SS-specs` | -| Specification commit | `01f9c898423ce37dbaeace19b722fb257928e985` | -| Ruleset | `ss-rules-006` | +| Specification commit | `92355fbaa7575425f8ab4e2d2eec7b7071fe2ec1` | +| Ruleset | `ss-rules-007` | | Content | `civic-seam-content-006` | | Arena | `civic-seam-arena-004` | | Replay schema | `runtime-kernel-001` | @@ -20,3 +20,5 @@ Changing any pinned identity requires: 5. this file and `ContractVersions.swift` updated together. Legacy evidence is frozen at `scrimshawlife-ctrl/Surveillance-Survivor@3b20d88d6a6e1fe8f07f45f581359d371fa65d98`. Copying legacy files is prohibited. Bounded reimplementation must follow `legacy-admission.md`. + +D-102 adoption: [SS-specs PR #61](https://github.com/Zero-State-LLC/SS-specs/pull/61). Merge the specification before this runtime change. Ruleset-006 replays fail closed; fixture review is in `docs/review/D102.md`. diff --git a/Sources/SurveillanceCore/ContractVersions.swift b/Sources/SurveillanceCore/ContractVersions.swift index 45161d4..3acf6b9 100644 --- a/Sources/SurveillanceCore/ContractVersions.swift +++ b/Sources/SurveillanceCore/ContractVersions.swift @@ -1,6 +1,6 @@ public enum ContractVersions { - public static let specificationCommit = "01f9c898423ce37dbaeace19b722fb257928e985" - public static let ruleset = "ss-rules-006" + public static let specificationCommit = "92355fbaa7575425f8ab4e2d2eec7b7071fe2ec1" + public static let ruleset = "ss-rules-007" public static let content = "civic-seam-content-006" public static let arena = "civic-seam-arena-004" public static let replaySchema = "runtime-kernel-001" diff --git a/Sources/SurveillanceCore/Exposure/ExposureState.swift b/Sources/SurveillanceCore/Exposure/ExposureState.swift index d993254..7948be2 100644 --- a/Sources/SurveillanceCore/Exposure/ExposureState.swift +++ b/Sources/SurveillanceCore/Exposure/ExposureState.swift @@ -64,7 +64,14 @@ public struct ExposureState: Equatable, Sendable { return delta } + public struct Change: Equatable, Sendable { + public var before: Int + public var after: Int + public var reason: ExposureReason + } + public struct Resolution: Equatable, Sendable { + public var changes: [Change] public var contactDelta: Int public var tamperApplied: Int public var before: Int @@ -75,13 +82,15 @@ public struct ExposureState: Equatable, Sendable { public var reason: ExposureReason } - /// Phase 13–14: surviving contacts, then ordered Tamper, then state/Lockdown. + /// D-102 phases 13–14: all eligible deltas, then one final state/Lockdown. public mutating func resolveTick( survivingContactCount: Int, tamperAmounts: [Int], signalJammer: Bool, forceLockdown: Bool = false, - destroyedCameras: Int = 0 + destroyedCameras: Int = 0, + fogPulseAmounts: [Int] = [], + observationPulseAmounts: [Int] = [] ) -> Resolution { let before = exposure let stateBefore = detectionState @@ -126,6 +135,23 @@ public struct ExposureState: Equatable, Sendable { } exposure = min(1000, max(0, exposure)) + var changes: [Change] = [] + if exposure != before { + changes.append(Change(before: before, after: exposure, reason: reason)) + } + // Observation amounts already carry the boss phase and Signal Jammer + // modifiers. Fog's existing modifier applies here exactly once. + let namedPulses = fogPulseAmounts.map { + (signalJammer ? Int(IntMath.divHalfAway(Int64($0) * 75, 100)) : $0, ExposureReason.fogPulse) + } + observationPulseAmounts.map { ($0, ExposureReason.observationPulse) } + for (amount, pulseReason) in namedPulses where exposure < 1000 { + let pulseBefore = exposure + exposure = min(1000, max(0, exposure + amount)) + if exposure != pulseBefore { + reason = pulseReason + changes.append(Change(before: pulseBefore, after: exposure, reason: reason)) + } + } if exposure > peak { peak = exposure } var lockdownThisTick = false @@ -141,6 +167,7 @@ public struct ExposureState: Equatable, Sendable { if lockdownEntered { exposure = 1000 } return Resolution( + changes: changes, contactDelta: contact, tamperApplied: tamper, before: before, diff --git a/Sources/SurveillanceCore/Resources/SPEC_PIN.txt b/Sources/SurveillanceCore/Resources/SPEC_PIN.txt index 7a2767a..1d59ba1 100644 --- a/Sources/SurveillanceCore/Resources/SPEC_PIN.txt +++ b/Sources/SurveillanceCore/Resources/SPEC_PIN.txt @@ -1,5 +1,5 @@ -SS-specs commit 01f9c898423ce37dbaeace19b722fb257928e985 -ruleset ss-rules-006 +SS-specs commit 92355fbaa7575425f8ab4e2d2eec7b7071fe2ec1 +ruleset ss-rules-007 content civic-seam-content-006 arena civic-seam-arena-004 replaySchema runtime-kernel-001 diff --git a/Sources/SurveillanceCore/Resources/contracts/asset-catalog-001.json b/Sources/SurveillanceCore/Resources/contracts/asset-catalog-001.json index ffd4c0a..255d40c 100644 --- a/Sources/SurveillanceCore/Resources/contracts/asset-catalog-001.json +++ b/Sources/SurveillanceCore/Resources/contracts/asset-catalog-001.json @@ -2,7 +2,7 @@ "schemaVersion": "asset-catalog-001", "legacyRepository": "scrimshawlife-ctrl/Surveillance-Survivor", "legacyCommit": "3b20d88d6a6e1fe8f07f45f581359d371fa65d98", - "specificationCommit": "01f9c898423ce37dbaeace19b722fb257928e985", + "specificationCommit": "92355fbaa7575425f8ab4e2d2eec7b7071fe2ec1", "entries": [ { "admissionDecision": "excluded", diff --git a/Sources/SurveillanceCore/Resources/contracts/runtime-kernel-001.json b/Sources/SurveillanceCore/Resources/contracts/runtime-kernel-001.json index da183dc..f5d999e 100644 --- a/Sources/SurveillanceCore/Resources/contracts/runtime-kernel-001.json +++ b/Sources/SurveillanceCore/Resources/contracts/runtime-kernel-001.json @@ -7,7 +7,7 @@ "required": ["schemaVersion", "rulesetVersion", "contentVersion", "arenaVersion", "seed", "commands"], "properties": { "schemaVersion": {"const": "runtime-kernel-001"}, - "rulesetVersion": {"const": "ss-rules-006"}, + "rulesetVersion": {"const": "ss-rules-007"}, "contentVersion": {"const": "civic-seam-content-006"}, "arenaVersion": {"const": "civic-seam-arena-004"}, "seed": {"type": "integer", "minimum": 0}, diff --git a/Sources/SurveillanceCore/Resources/contracts/versions.json b/Sources/SurveillanceCore/Resources/contracts/versions.json index e777c0f..c38d09a 100644 --- a/Sources/SurveillanceCore/Resources/contracts/versions.json +++ b/Sources/SurveillanceCore/Resources/contracts/versions.json @@ -1,6 +1,6 @@ { "schemaVersion": "runtime-kernel-001", - "rulesetVersion": "ss-rules-006", + "rulesetVersion": "ss-rules-007", "contentVersion": "civic-seam-content-006", "arenaVersion": "civic-seam-arena-004", "componentContracts": { diff --git a/Sources/SurveillanceCore/Resources/fixtures/complete-run-vectors-001.json b/Sources/SurveillanceCore/Resources/fixtures/complete-run-vectors-001.json index bc14910..560b3f3 100644 --- a/Sources/SurveillanceCore/Resources/fixtures/complete-run-vectors-001.json +++ b/Sources/SurveillanceCore/Resources/fixtures/complete-run-vectors-001.json @@ -1,6 +1,6 @@ { "fixtureVersion": "complete-run-vectors-001", - "rulesetVersion": "ss-rules-006", + "rulesetVersion": "ss-rules-007", "vectors": [ { "id": "CR-001", @@ -11,7 +11,7 @@ "camerasDestroyed": 0, "networkBlackout": false, "elapsedTicks": 302, - "terminalDigest": "ec62706bd0b7832119c6c1294c7b11edf738c805906234fb3454500ff5e8b24c" + "terminalDigest": "cdf91bab889f07b651dd1b3cd07368ec5712b0d5940d42502bca69178a1cdf73" } }, { @@ -23,7 +23,7 @@ "camerasDestroyed": 0, "networkBlackout": false, "elapsedTicks": 302, - "terminalDigest": "a93f4b0ad1c4d817cdec5d6a4d7ffd08b94267d8f3c60e9103197e10eac38f51" + "terminalDigest": "9c0bc683b26d32f1d381dade2d7193996e8aa313775c334ef720061a917f332e" } }, { @@ -35,7 +35,7 @@ "camerasDestroyed": 0, "networkBlackout": false, "elapsedTicks": 302, - "terminalDigest": "c77d01df29536e06c2045b36b1ade3cf610f03e8963390574b7e0db5e1d7de29" + "terminalDigest": "3b28326edd7e16d9cd13774d4d2990133151b81c3b4a2450fea5884243be0ea8" } } ] diff --git a/Sources/SurveillanceCore/Resources/fixtures/kernel-vectors-001.json b/Sources/SurveillanceCore/Resources/fixtures/kernel-vectors-001.json index 0146497..c6a342c 100644 --- a/Sources/SurveillanceCore/Resources/fixtures/kernel-vectors-001.json +++ b/Sources/SurveillanceCore/Resources/fixtures/kernel-vectors-001.json @@ -1,6 +1,6 @@ { "fixtureVersion":"kernel-vectors-001", - "rulesetVersion":"ss-rules-006", + "rulesetVersion":"ss-rules-007", "vectors":[ {"id":"EX-001","initial":{"exposure":0,"state":"hidden","noContactTicks":0},"ticks":100,"contactsPerTick":1,"expected":{"exposure":200,"state":"observed"}}, {"id":"EX-005","initial":{"exposure":300,"state":"observed","noContactTicks":0},"ticks":61,"contactsPerTick":0,"expected":{"exposure":298,"state":"observed","noContactTicks":61}}, diff --git a/Sources/SurveillanceCore/Resources/fixtures/replay-matrix-001.json b/Sources/SurveillanceCore/Resources/fixtures/replay-matrix-001.json index bf5c858..cac7a40 100644 --- a/Sources/SurveillanceCore/Resources/fixtures/replay-matrix-001.json +++ b/Sources/SurveillanceCore/Resources/fixtures/replay-matrix-001.json @@ -1,11 +1,11 @@ { "fixtureVersion": "replay-matrix-001", - "rulesetVersion": "ss-rules-006", + "rulesetVersion": "ss-rules-007", "entries": [ { "id": "RM-001", "fixture": "replay-smoke-001", - "expectedFinalDigest": "e52e8f1bc48cb041dce57d53ac141eecf5b0d150866b98dba096ec27a7cb4827", + "expectedFinalDigest": "bc9d5d632c4cc73c297b87b51a9e4e390511908287bc17a1ae0d8720d4a8e694", "tripleRunRequired": true } ] diff --git a/Sources/SurveillanceCore/Resources/fixtures/replay-smoke-001.json b/Sources/SurveillanceCore/Resources/fixtures/replay-smoke-001.json index b674e7f..b6ddf20 100644 --- a/Sources/SurveillanceCore/Resources/fixtures/replay-smoke-001.json +++ b/Sources/SurveillanceCore/Resources/fixtures/replay-smoke-001.json @@ -1,6 +1,6 @@ { "schemaVersion":"runtime-kernel-001", - "rulesetVersion":"ss-rules-006", + "rulesetVersion":"ss-rules-007", "contentVersion":"civic-seam-content-006", "arenaVersion":"civic-seam-arena-004", "seed":1, diff --git a/Sources/SurveillanceCore/World/Simulation.swift b/Sources/SurveillanceCore/World/Simulation.swift index 5cf6200..ef8273d 100644 --- a/Sources/SurveillanceCore/World/Simulation.swift +++ b/Sources/SurveillanceCore/World/Simulation.swift @@ -260,7 +260,11 @@ public struct Simulation: Equatable, Sendable { signalJammer: state.upgrade.signalJammer, forceLockdown: forceLockdown, // D-084: every Camera destroyed this run, this tick's included. - destroyedCameras: state.destructions.count + destroyedCameras: state.destructions.count, + // D-102: named pulses participate before final state/latch emission. + // Preserve their existing suppression on the Player's lethal tick. + fogPulseAmounts: state.outcome == .failure ? [] : fogPulses, + observationPulseAmounts: state.outcome == .failure ? [] : observationPulses ) emitExposure(resolution, tick: tick) resolveQuietApproach() @@ -269,8 +273,6 @@ public struct Simulation: Equatable, Sendable { return finishTick() } - applyNamedPulses(fogPulses, reason: .fogPulse, tick: tick) - applyNamedPulses(observationPulses, reason: .observationPulse, tick: tick) openEliteGateIfDue(tick: tick) resolveObjectives(tick: tick) @@ -766,42 +768,6 @@ public struct Simulation: Equatable, Sendable { state.eliteGateOpenTick = nil } - private mutating func applyNamedPulses(_ pulses: [Int], reason: ExposureReason, tick: UInt64) { - for pulse in pulses { - let amount = reason == .fogPulse && state.upgrade.signalJammer - ? Int(IntMath.divHalfAway(Int64(pulse) * 75, 100)) - : pulse - let before = state.exposure.exposure - if !state.exposure.lockdownEntered { - state.exposure.exposure = min(1000, state.exposure.exposure + amount) - if state.exposure.exposure > state.exposure.peak { - state.exposure.peak = state.exposure.exposure - } - state.exposure.detectionState = DetectionState.projected(state.exposure.exposure) - if state.exposure.exposure >= 1000 && !state.exposure.lockdownEntered { - state.exposure.lockdownEntered = true - state.exposure.detectionState = .lockdown - events.emit( - tick: tick, - phase: 13, - type: .lockdownEntered, - payload: ["reason": .string(reason.rawValue)] - ) - } - events.emit( - tick: tick, - phase: 13, - type: .exposureChanged, - payload: [ - "before": .integer(Int64(before)), - "after": .integer(Int64(state.exposure.exposure)), - "reason": .string(reason.rawValue) - ] - ) - } - } - } - /// Every Player Integrity loss, from any source, lands here: contact, /// hostile bolts, mines, the Daemon query, and the Captain's attacks. /// @@ -1338,15 +1304,15 @@ public struct Simulation: Equatable, Sendable { } private mutating func emitExposure(_ resolution: ExposureState.Resolution, tick: UInt64) { - if resolution.after != resolution.before { + for change in resolution.changes { events.emit( tick: tick, phase: 13, type: .exposureChanged, payload: [ - "before": .integer(Int64(resolution.before)), - "after": .integer(Int64(resolution.after)), - "reason": .string(resolution.reason.rawValue) + "before": .integer(Int64(change.before)), + "after": .integer(Int64(change.after)), + "reason": .string(change.reason.rawValue) ] ) } diff --git a/Tests/SurveillanceCoreTests/CompleteRunVectorTests.swift b/Tests/SurveillanceCoreTests/CompleteRunVectorTests.swift index 71b0bca..971c998 100644 --- a/Tests/SurveillanceCoreTests/CompleteRunVectorTests.swift +++ b/Tests/SurveillanceCoreTests/CompleteRunVectorTests.swift @@ -11,15 +11,15 @@ struct CompleteRunVectorTests { private static let expectedVectors: [UpgradeID: ExpectedVector] = [ .signalJammer: ExpectedVector( elapsedTicks: 302, - terminalDigest: "ec62706bd0b7832119c6c1294c7b11edf738c805906234fb3454500ff5e8b24c" + terminalDigest: "cdf91bab889f07b651dd1b3cd07368ec5712b0d5940d42502bca69178a1cdf73" ), .ricochetPulse: ExpectedVector( elapsedTicks: 302, - terminalDigest: "a93f4b0ad1c4d817cdec5d6a4d7ffd08b94267d8f3c60e9103197e10eac38f51" + terminalDigest: "9c0bc683b26d32f1d381dade2d7193996e8aa313775c334ef720061a917f332e" ), .ghostStep: ExpectedVector( elapsedTicks: 302, - terminalDigest: "c77d01df29536e06c2045b36b1ade3cf610f03e8963390574b7e0db5e1d7de29" + terminalDigest: "3b28326edd7e16d9cd13774d4d2990133151b81c3b4a2450fea5884243be0ea8" ) ] diff --git a/Tests/SurveillanceCoreTests/ExposureFinalizationTests.swift b/Tests/SurveillanceCoreTests/ExposureFinalizationTests.swift new file mode 100644 index 0000000..0a65109 --- /dev/null +++ b/Tests/SurveillanceCoreTests/ExposureFinalizationTests.swift @@ -0,0 +1,117 @@ +import Testing +@testable import SurveillanceCore + +/// D-102 / T410: real phase ordering, not a direct call to the Exposure helper. +struct ExposureFinalizationTests { + private func fogCrossing() throws -> Simulation { + var sim = try Simulation.make(seed: 1) + sim.testing_keepCameras([], integrity: 3) + for _ in 0..<61 { sim.step(command: nil) } + sim.testing_setExposure(432) + sim.testing_insertEnemy(EnemyBody( + id: EntityID(999_999), archetype: .fogAnalyticsCloud, + position: sim.state.player.position, velocity: .zero, + integrity: 10_000, radius: 16, speedUnitsPerSecond: 0, contactDps: 0, + state: .telegraph, stateTicks: 1, spawnTick: 0, + nextSpecialTick: 0, encounterId: "audit-fog" + )) + return sim + } + + @Test func fogCrossingRevokesQuietBeforeRecoveryCanEraseIt() throws { + var sim = try fogCrossing() + sim.step(command: nil) + #expect(sim.state.exposure.exposure == 450) + #expect(sim.state.exposure.detectionState == .tracked) + #expect(!sim.state.exposure.quietApproach) + sim.step(command: nil) + #expect(sim.state.exposure.exposure == 448) + #expect(sim.state.exposure.detectionState == .observed) + #expect(!sim.state.exposure.quietApproach) + } + + @Test func fogCrossingPublishesOneFinalDetectionEvent() throws { + var sim = try fogCrossing() + let result = sim.step(command: nil) + let changes = result.events.filter { $0.type == .detectionStateChanged } + #expect(changes.count == 1) + #expect(changes.first?.payload["before"] == .string("observed")) + #expect(changes.first?.payload["after"] == .string("tracked")) + } + @Test func fogTransitionReachesAudioAndVFX() throws { + var sim = try fogCrossing() + let result = sim.step(command: nil) + var audio = AudioProjector() + let projected = audio.project(tick: result.tick, events: result.events, world: .from(sim.state)) + #expect(projected.cues.contains { $0.audioId == "exposure_state_up" }) + #expect(projected.captions.contains("Detection tracked")) + var vfx = VFXProjector() + let frame = vfx.project(tick: result.tick, events: result.events, catalog: try .bundled()) + #expect(frame.presentations.filter { $0.recipeId == "exposureThreshold" }.count == 1) + } + + @Test func signalJammerModifiesTheFogPulseExactlyOnce() throws { + var sim = try fogCrossing() + sim.testing_selectUpgrade(.signalJammer) + let result = sim.step(command: nil) + #expect(sim.state.exposure.exposure == 445) + #expect(sim.state.exposure.quietApproach) + #expect(!result.events.contains { $0.type == .detectionStateChanged }) + } + + @Test func lethalTickSuppressesNamedPulsesButStillResolvesRecovery() throws { + var sim = try fogCrossing() + sim.testing_setPlayerIntegrity(1) + sim.testing_injectHostileBolt(damage: 100) + let result = sim.step(command: nil) + #expect(result.outcome == .failure) + #expect(sim.state.exposure.exposure == 430) + #expect(!result.events.contains { $0.payload["reason"] == .string("fogPulse") }) + let terminalTick = sim.state.tick + let next = sim.step(command: nil) + #expect(next.events.isEmpty) + #expect(sim.state.tick == terminalTick) + } + + @Test func forcedMobCLockdownWithDueFogKeepsQuiet() throws { + var sim = try fogCrossing() + sim.testing_completeEncounter("M-A") + sim.testing_completeEncounter("M-B") + let trigger = try #require(sim.state.arena.encounterTriggers.first { $0.encounterId == "M-C" }) + sim.testing_setPlayerPosition(trigger.aabb.center) + // Add a due pulse at the actual M-C trigger, not at the old position. + sim.testing_insertEnemy(EnemyBody( + id: EntityID(999_998), archetype: .fogAnalyticsCloud, + position: sim.state.player.position, velocity: .zero, + integrity: 10_000, radius: 16, speedUnitsPerSecond: 0, contactDps: 0, + state: .telegraph, stateTicks: 1, spawnTick: 0, + nextSpecialTick: 0, encounterId: "audit-fog" + )) + let result = sim.step(command: nil) + #expect(sim.state.encounters["M-C"]?.activated == true) + #expect(sim.state.exposure.exposure == 1000) + #expect(sim.state.exposure.quietApproach) + let lockdown = result.events.filter { $0.type == .lockdownEntered } + #expect(lockdown.count == 1) + #expect(lockdown.first?.phase == 14) + #expect(lockdown.first?.payload["reason"] == .string("forcedLockdown")) + } + + @Test func allDeltasProduceOneFinalStateAndOneLockdown() { + var state = ExposureState(exposure: 440, detectionState: .observed) + let result = state.resolveTick(survivingContactCount: 1, tamperAmounts: [150], + signalJammer: false, fogPulseAmounts: [20, 20], + observationPulseAmounts: [400, 30]) + #expect(result.stateBefore == .observed) + #expect(result.stateAfter == .lockdown) + #expect(result.after == 1000) + #expect(result.lockdownEnteredThisTick) + #expect(result.reason == .observationPulse) + #expect(result.changes.map(\.after) == [592, 612, 632, 1000]) + let next = state.resolveTick(survivingContactCount: 0, tamperAmounts: [], + signalJammer: false, observationPulseAmounts: [400]) + #expect(!next.lockdownEnteredThisTick) + #expect(next.changes.isEmpty) + } + +} diff --git a/Tests/SurveillanceCoreTests/ReplayTests.swift b/Tests/SurveillanceCoreTests/ReplayTests.swift index 59dc76d..b99dc81 100644 --- a/Tests/SurveillanceCoreTests/ReplayTests.swift +++ b/Tests/SurveillanceCoreTests/ReplayTests.swift @@ -17,7 +17,7 @@ struct ReplayTests { @Test func duplicateCommandTickIsRejected() throws { let json = """ - {"schemaVersion":"runtime-kernel-001","rulesetVersion":"ss-rules-006","contentVersion":"civic-seam-content-006","arenaVersion":"civic-seam-arena-004","seed":1,"commands":[{"tick":1,"moveX":0,"moveY":0,"dodgePressed":false},{"tick":1,"moveX":0,"moveY":0,"dodgePressed":false}]} + {"schemaVersion":"runtime-kernel-001","rulesetVersion":"ss-rules-007","contentVersion":"civic-seam-content-006","arenaVersion":"civic-seam-arena-004","seed":1,"commands":[{"tick":1,"moveX":0,"moveY":0,"dodgePressed":false},{"tick":1,"moveX":0,"moveY":0,"dodgePressed":false}]} """.data(using: .utf8)! let result = ReplayEnvelope.load(json: json) guard case .failure(.duplicateCommandTick(1)) = result else { @@ -49,4 +49,13 @@ struct ReplayTests { #expect(sim.state.digest() == initial) #expect(sim.state.cameras.allSatisfy { $0.integrity == 3 }) } + @Test func previousRulesetFailsClosedBeforeExecution() throws { + let json = String(decoding: SpecBundle.fixture("replay-smoke-001"), as: UTF8.self) + .replacingOccurrences(of: ContractVersions.ruleset, with: "ss-rules-006") + guard case .failure(.incompatibleIdentity) = ReplayEnvelope.load(json: Data(json.utf8)) else { + Issue.record("D-102 must reject a ruleset-006 replay before execution") + return + } + } + } diff --git a/docs/review/D102.md b/docs/review/D102.md new file mode 100644 index 0000000..4a8c781 --- /dev/null +++ b/docs/review/D102.md @@ -0,0 +1,14 @@ +# D-102 fixture review — 2026-10-13 + +The corrected runtime was evaluated first under the previous ruleset identity. All three complete-run golden vectors still end successfully at tick 302, and the smoke replay at tick 5 has its original digest. Under `ss-rules-007`, canonical state differs in exactly `rulesetVersion` for these four fixtures: every other canonical value is unchanged. The new hashes below were computed from the complete canonical UTF-8 bytes with SHA-256, not copied from failing expectations. + +| Vector | ruleset-006 | ruleset-007 | +|---|---|---| +| ghostStep | `c77d01df29536e06c2045b36b1ade3cf610f03e8963390574b7e0db5e1d7de29` | `3b28326edd7e16d9cd13774d4d2990133151b81c3b4a2450fea5884243be0ea8` | +| ricochetPulse | `a93f4b0ad1c4d817cdec5d6a4d7ffd08b94267d8f3c60e9103197e10eac38f51` | `9c0bc683b26d32f1d381dade2d7193996e8aa313775c334ef720061a917f332e` | +| signalJammer | `ec62706bd0b7832119c6c1294c7b11edf738c805906234fb3454500ff5e8b24c` | `cdf91bab889f07b651dd1b3cd07368ec5712b0d5940d42502bca69178a1cdf73` | +| smoke | `e52e8f1bc48cb041dce57d53ac141eecf5b0d150866b98dba096ec27a7cb4827` | `bc9d5d632c4cc73c297b87b51a9e4e390511908287bc17a1ae0d8720d4a8e694` | + +This review does not claim the correction is inert for every replay. The reproduced Fog threshold case deliberately changes quietApproach and adds the missing Detection State event. The ruleset bump separates those outcomes; ruleset-006 input fails closed before execution. No content magnitudes, arena geometry, schema shape, or kernel-vector expectations were changed. + +The integration suite `ExposureFinalizationTests` verifies that a real simulation step publishes the fog transition, revokes quiet before recovery, reaches audio/VFX, applies Signal Jammer once, preserves lethal-tick suppression, and exempts M-C activation. Its first two cases failed against the original runtime with five assertion failures, then passed after the correction. Debug/Release scene tests and their CI evidence belong to the paired runtime PR; physical-device and human-playtest gates remain open. diff --git a/intent/2026-10-13-release-input.md b/intent/2026-10-13-release-input.md new file mode 100644 index 0000000..595b08e --- /dev/null +++ b/intent/2026-10-13-release-input.md @@ -0,0 +1,36 @@ +# Intent + +**Author:** Codex, at the repository owner's direction +**Date:** 2026-10-13 +**Status:** accepted +**Next stage:** implement the SS-specs D-102 / PC-009 correction + +Owner authorization: “execute plan” on 2026-10-13, referring to the reviewed three-fix plan. + +## Problem + +The sole controller-to-session input transfer is inside DEBUG. Release builds never consume movement or Dodge. + +## Proposed outcome + +Touch movement, one-shot Dodge, pause/resume, and restart pass through the production scene in Debug and Release. Existing CI verifies both configurations. + +## Affected users/systems + +Players, GameScene input integration, app tests, and existing CI. + +## Constraints/non-goals + +Preserve input quantization, debug autopilot, and one-level scope. Gameplay Exposure changes are owned by SS-specs intent/2026-10-13-audit-corrections.md and D-102, not this runtime-only intent. + +## Open questions + +None for the authorized correction. Physical-device acceptance remains separate. + +## Verified claims + +OBSERVED: applyController's only caller is conditionally compiled under DEBUG; touch callbacks mutate the controller but not the session. Existing controller tests do not traverse that boundary. + +## Assumed claims + +Debug and Release simulator tests must establish the correction; source inspection alone does not establish interactive acceptance. From 03a6488ba6bde4cfb61fa80db13def29fe6471d2 Mon Sep 17 00:00:00 2001 From: Daniel Meyer Date: Tue, 13 Oct 2026 17:33:20 -0700 Subject: [PATCH 2/2] Cover combined Exposure deltas and pin final D-102 specification --- .github/workflows/ci.yml | 4 +-- SPEC_BASELINE.md | 2 +- .../SurveillanceCore/ContractVersions.swift | 2 +- .../SurveillanceCore/Resources/SPEC_PIN.txt | 2 +- .../contracts/asset-catalog-001.json | 2 +- .../ExposureFinalizationTests.swift | 25 +++++++++++++++++++ 6 files changed, 31 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b97882b..88e4c56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Verify pinned specification - run: grep -q "92355fbaa7575425f8ab4e2d2eec7b7071fe2ec1" SPEC_BASELINE.md + run: grep -q "2377b99c6b6d1654e2cb976ee1b7e07b75c77e1a" SPEC_BASELINE.md - name: Test deterministic core run: swift test --skip CameraPlacementTests --skip CameraFairnessTests --parallel --num-workers 1 - name: Test camera placement @@ -38,7 +38,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Verify pinned specification - run: grep -q "92355fbaa7575425f8ab4e2d2eec7b7071fe2ec1" SPEC_BASELINE.md + run: grep -q "2377b99c6b6d1654e2cb976ee1b7e07b75c77e1a" SPEC_BASELINE.md - name: Byte-compare bundled contracts and fixtures against the pinned SS-specs run: bash scripts/spec_parity.sh diff --git a/SPEC_BASELINE.md b/SPEC_BASELINE.md index 62caea7..3e1962c 100644 --- a/SPEC_BASELINE.md +++ b/SPEC_BASELINE.md @@ -5,7 +5,7 @@ Status: PINNED | Identity | Value | |---|---| | Specification repository | `scrimshawlife-ctrl/SS-specs` | -| Specification commit | `92355fbaa7575425f8ab4e2d2eec7b7071fe2ec1` | +| Specification commit | `2377b99c6b6d1654e2cb976ee1b7e07b75c77e1a` | | Ruleset | `ss-rules-007` | | Content | `civic-seam-content-006` | | Arena | `civic-seam-arena-004` | diff --git a/Sources/SurveillanceCore/ContractVersions.swift b/Sources/SurveillanceCore/ContractVersions.swift index 3acf6b9..1420a1e 100644 --- a/Sources/SurveillanceCore/ContractVersions.swift +++ b/Sources/SurveillanceCore/ContractVersions.swift @@ -1,5 +1,5 @@ public enum ContractVersions { - public static let specificationCommit = "92355fbaa7575425f8ab4e2d2eec7b7071fe2ec1" + public static let specificationCommit = "2377b99c6b6d1654e2cb976ee1b7e07b75c77e1a" public static let ruleset = "ss-rules-007" public static let content = "civic-seam-content-006" public static let arena = "civic-seam-arena-004" diff --git a/Sources/SurveillanceCore/Resources/SPEC_PIN.txt b/Sources/SurveillanceCore/Resources/SPEC_PIN.txt index 1d59ba1..fae5e8a 100644 --- a/Sources/SurveillanceCore/Resources/SPEC_PIN.txt +++ b/Sources/SurveillanceCore/Resources/SPEC_PIN.txt @@ -1,4 +1,4 @@ -SS-specs commit 92355fbaa7575425f8ab4e2d2eec7b7071fe2ec1 +SS-specs commit 2377b99c6b6d1654e2cb976ee1b7e07b75c77e1a ruleset ss-rules-007 content civic-seam-content-006 arena civic-seam-arena-004 diff --git a/Sources/SurveillanceCore/Resources/contracts/asset-catalog-001.json b/Sources/SurveillanceCore/Resources/contracts/asset-catalog-001.json index 255d40c..a41df3a 100644 --- a/Sources/SurveillanceCore/Resources/contracts/asset-catalog-001.json +++ b/Sources/SurveillanceCore/Resources/contracts/asset-catalog-001.json @@ -2,7 +2,7 @@ "schemaVersion": "asset-catalog-001", "legacyRepository": "scrimshawlife-ctrl/Surveillance-Survivor", "legacyCommit": "3b20d88d6a6e1fe8f07f45f581359d371fa65d98", - "specificationCommit": "92355fbaa7575425f8ab4e2d2eec7b7071fe2ec1", + "specificationCommit": "2377b99c6b6d1654e2cb976ee1b7e07b75c77e1a", "entries": [ { "admissionDecision": "excluded", diff --git a/Tests/SurveillanceCoreTests/ExposureFinalizationTests.swift b/Tests/SurveillanceCoreTests/ExposureFinalizationTests.swift index 0a65109..2e61029 100644 --- a/Tests/SurveillanceCoreTests/ExposureFinalizationTests.swift +++ b/Tests/SurveillanceCoreTests/ExposureFinalizationTests.swift @@ -114,4 +114,29 @@ struct ExposureFinalizationTests { #expect(next.changes.isEmpty) } + @Test func multipleFogPulsesEmitOnlyTheFinalTransition() throws { + var sim = try fogCrossing() + sim.testing_insertEnemy(EnemyBody( + id: EntityID(999_998), archetype: .fogAnalyticsCloud, + position: sim.state.player.position, velocity: .zero, + integrity: 10_000, radius: 16, speedUnitsPerSecond: 0, contactDps: 0, + state: .telegraph, stateTicks: 1, spawnTick: 0, + nextSpecialTick: 0, encounterId: "audit-fog" + )) + let result = sim.step(command: nil) + #expect(sim.state.exposure.exposure == 470) + #expect(result.events.filter { $0.type == .detectionStateChanged }.count == 1) + #expect(result.events.filter { $0.payload["reason"] == .string("fogPulse") }.count == 2) + } + + @Test func recoveryAndPulseDoNotPublishAnIntermediateState() throws { + var sim = try fogCrossing() + sim.testing_setExposure(450) + let result = sim.step(command: nil) + #expect(sim.state.exposure.exposure == 468) + #expect(sim.state.exposure.detectionState == .tracked) + #expect(!sim.state.exposure.quietApproach) + #expect(!result.events.contains { $0.type == .detectionStateChanged }) + } + }