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
11 changes: 8 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Verify pinned specification
run: grep -q "01f9c898423ce37dbaeace19b722fb257928e985" 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
Expand All @@ -38,11 +38,15 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Verify pinned specification
run: grep -q "01f9c898423ce37dbaeace19b722fb257928e985" 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

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.
Expand All @@ -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
Expand Down
86 changes: 42 additions & 44 deletions App/GameScene.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -712,69 +715,64 @@ final class GameScene: SKScene {
}

override func touchesBegan(_ touches: Set<UITouch>, 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<UITouch>, 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<UITouch>, 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<UITouch>, 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,
Expand Down
86 changes: 86 additions & 0 deletions AppTests/SceneInputIntegrationTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
6 changes: 4 additions & 2 deletions SPEC_BASELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ Status: PINNED
| Identity | Value |
|---|---|
| Specification repository | `scrimshawlife-ctrl/SS-specs` |
| Specification commit | `01f9c898423ce37dbaeace19b722fb257928e985` |
| Ruleset | `ss-rules-006` |
| Specification commit | `2377b99c6b6d1654e2cb976ee1b7e07b75c77e1a` |
| Ruleset | `ss-rules-007` |
| Content | `civic-seam-content-006` |
| Arena | `civic-seam-arena-004` |
| Replay schema | `runtime-kernel-001` |
Expand All @@ -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`.
4 changes: 2 additions & 2 deletions Sources/SurveillanceCore/ContractVersions.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
public enum ContractVersions {
public static let specificationCommit = "01f9c898423ce37dbaeace19b722fb257928e985"
public static let ruleset = "ss-rules-006"
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"
public static let replaySchema = "runtime-kernel-001"
Expand Down
31 changes: 29 additions & 2 deletions Sources/SurveillanceCore/Exposure/ExposureState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -141,6 +167,7 @@ public struct ExposureState: Equatable, Sendable {
if lockdownEntered { exposure = 1000 }

return Resolution(
changes: changes,
contactDelta: contact,
tamperApplied: tamper,
before: before,
Expand Down
4 changes: 2 additions & 2 deletions Sources/SurveillanceCore/Resources/SPEC_PIN.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
SS-specs commit 01f9c898423ce37dbaeace19b722fb257928e985
ruleset ss-rules-006
SS-specs commit 2377b99c6b6d1654e2cb976ee1b7e07b75c77e1a
ruleset ss-rules-007
content civic-seam-content-006
arena civic-seam-arena-004
replaySchema runtime-kernel-001
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"schemaVersion": "asset-catalog-001",
"legacyRepository": "scrimshawlife-ctrl/Surveillance-Survivor",
"legacyCommit": "3b20d88d6a6e1fe8f07f45f581359d371fa65d98",
"specificationCommit": "01f9c898423ce37dbaeace19b722fb257928e985",
"specificationCommit": "2377b99c6b6d1654e2cb976ee1b7e07b75c77e1a",
"entries": [
{
"admissionDecision": "excluded",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
2 changes: 1 addition & 1 deletion Sources/SurveillanceCore/Resources/contracts/versions.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
Loading
Loading