From 7bdf3be74b617e1dae0770fe5c1898d429f6b70f Mon Sep 17 00:00:00 2001 From: Miley Chandonnet Date: Sun, 24 May 2026 17:02:40 -0500 Subject: [PATCH] Wire atmosphere into scene runtime --- docs/COGNITIVE_SCENE_RUNTIME_MIGRATION.md | 11 +++ .../phosphor/runtime/CognitiveSceneRuntime.kt | 20 +++++ .../phosphor/runtime/SceneConfiguration.kt | 4 + .../socket/phosphor/runtime/SceneSnapshot.kt | 4 + .../runtime/CognitiveSceneRuntimeTest.kt | 90 +++++++++++++++++++ .../phosphor/runtime/SceneSnapshotTest.kt | 20 ++++- 6 files changed, 148 insertions(+), 1 deletion(-) diff --git a/docs/COGNITIVE_SCENE_RUNTIME_MIGRATION.md b/docs/COGNITIVE_SCENE_RUNTIME_MIGRATION.md index 0e6c2c3..f6c9d65 100644 --- a/docs/COGNITIVE_SCENE_RUNTIME_MIGRATION.md +++ b/docs/COGNITIVE_SCENE_RUNTIME_MIGRATION.md @@ -53,6 +53,17 @@ class RuntimeAdapter( } ``` +## Atmosphere Snapshot Field + +Atmosphere is a passive runtime subsystem. It is disabled by default, so +`SceneSnapshot.atmosphere` is `null` unless `SceneConfiguration.enableAtmosphere` +is set to `true`. + +When enabled, the runtime starts from `SceneConfiguration.initialAtmosphere`, +which defaults to `AtmospherePresets.IDLE`. Calls to `runtime.setAtmosphere(state)` +replace the current value, and the next snapshot exposes that value without +interpolation. Lumos consumes the atmosphere via its renderer. + ## Notes - `CognitiveSceneRuntime` is timing-agnostic. You own timers and frame pacing. diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/runtime/CognitiveSceneRuntime.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/runtime/CognitiveSceneRuntime.kt index 6a2fe2b..fce3cef 100644 --- a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/runtime/CognitiveSceneRuntime.kt +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/runtime/CognitiveSceneRuntime.kt @@ -17,6 +17,7 @@ import link.socket.phosphor.render.Camera import link.socket.phosphor.render.CameraOrbit import link.socket.phosphor.render.CognitiveWaveform import link.socket.phosphor.signal.AgentVisualState +import link.socket.phosphor.signal.AtmosphereState import link.socket.phosphor.signal.CognitivePhase /** @@ -76,6 +77,12 @@ class CognitiveSceneRuntime( ) } + private var atmosphereState: AtmosphereState? = + if (configuration.enableAtmosphere) configuration.initialAtmosphere else null + + val currentAtmosphere: AtmosphereState? + get() = atmosphereState + private var substrateState: SubstrateState = SubstrateState.create( width = configuration.width, @@ -111,6 +118,16 @@ class CognitiveSceneRuntime( */ fun snapshot(): SceneSnapshot = latestSnapshot + /** + * Replace the current atmosphere state. + */ + fun setAtmosphere(state: AtmosphereState) { + check(configuration.enableAtmosphere) { + "Atmosphere subsystem not enabled in SceneConfiguration. Set enableAtmosphere = true to use setAtmosphere." + } + atmosphereState = state + } + /** * Advance the scene by [deltaTimeSeconds] and return an immutable snapshot. */ @@ -132,6 +149,8 @@ class CognitiveSceneRuntime( // 3) Agent state update. agents.update(deltaTimeSeconds) + // PHO-X4 will add AtmosphereChoreographer.update(dt) here + // 4) Emitter emission pass and lifecycle update. if (emitters != null) { flushQueuedEmitterEffects() @@ -246,6 +265,7 @@ class CognitiveSceneRuntime( cameraTransform = camera?.toCameraTransform(), emitterStates = emitters?.instances?.map { it.toEmitterState() } ?: emptyList(), choreographyPhase = dominantPhase(sortedAgents), + atmosphere = atmosphereState, ) } diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/runtime/SceneConfiguration.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/runtime/SceneConfiguration.kt index 7092982..8f97832 100644 --- a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/runtime/SceneConfiguration.kt +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/runtime/SceneConfiguration.kt @@ -4,8 +4,10 @@ import link.socket.phosphor.choreography.AgentLayoutOrientation import link.socket.phosphor.coordinate.CoordinateSpace import link.socket.phosphor.math.Vector2 import link.socket.phosphor.math.Vector3 +import link.socket.phosphor.palette.AtmospherePresets import link.socket.phosphor.signal.AgentActivityState import link.socket.phosphor.signal.AgentVisualState +import link.socket.phosphor.signal.AtmosphereState import link.socket.phosphor.signal.CognitivePhase /** @@ -21,6 +23,8 @@ data class SceneConfiguration( val enableFlow: Boolean = true, val enableEmitters: Boolean = true, val enableCamera: Boolean = true, + val enableAtmosphere: Boolean = false, + val initialAtmosphere: AtmosphereState = AtmospherePresets.IDLE, val coordinateSpace: CoordinateSpace = CoordinateSpace.WORLD_CENTERED, val seed: Long = 0L, val agentLayout: AgentLayoutOrientation = AgentLayoutOrientation.CUSTOM, diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/runtime/SceneSnapshot.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/runtime/SceneSnapshot.kt index ea65a86..189025d 100644 --- a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/runtime/SceneSnapshot.kt +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/runtime/SceneSnapshot.kt @@ -11,6 +11,7 @@ import link.socket.phosphor.math.Vector2 import link.socket.phosphor.math.Vector3 import link.socket.phosphor.render.Camera import link.socket.phosphor.signal.AgentVisualState +import link.socket.phosphor.signal.AtmosphereState import link.socket.phosphor.signal.CognitivePhase /** @@ -31,6 +32,7 @@ data class SceneSnapshot( val cameraTransform: CameraTransform?, val emitterStates: List, val choreographyPhase: CognitivePhase, + val atmosphere: AtmosphereState? = null, ) { init { if (waveformHeightField != null) { @@ -68,6 +70,7 @@ data class SceneSnapshot( if (cameraTransform != other.cameraTransform) return false if (emitterStates != other.emitterStates) return false if (choreographyPhase != other.choreographyPhase) return false + if (atmosphere != other.atmosphere) return false return true } @@ -87,6 +90,7 @@ data class SceneSnapshot( result = 31 * result + (cameraTransform?.hashCode() ?: 0) result = 31 * result + emitterStates.hashCode() result = 31 * result + choreographyPhase.hashCode() + result = 31 * result + (atmosphere?.hashCode() ?: 0) return result } } diff --git a/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/runtime/CognitiveSceneRuntimeTest.kt b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/runtime/CognitiveSceneRuntimeTest.kt index b52b3ad..40073c6 100644 --- a/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/runtime/CognitiveSceneRuntimeTest.kt +++ b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/runtime/CognitiveSceneRuntimeTest.kt @@ -2,6 +2,7 @@ package link.socket.phosphor.runtime import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotEquals import kotlin.test.assertNull import kotlin.test.assertTrue @@ -9,10 +10,97 @@ import link.socket.phosphor.choreography.AgentLayoutOrientation import link.socket.phosphor.emitter.EmitterEffect import link.socket.phosphor.math.Vector2 import link.socket.phosphor.math.Vector3 +import link.socket.phosphor.palette.AtmospherePresets import link.socket.phosphor.signal.AgentActivityState import link.socket.phosphor.signal.CognitivePhase class CognitiveSceneRuntimeTest { + @Test + fun `default configuration leaves atmosphere disabled`() { + val runtime = + CognitiveSceneRuntime( + SceneConfiguration( + width = 8, + height = 6, + ), + ) + + assertNull(runtime.currentAtmosphere) + assertNull(runtime.snapshot().atmosphere) + assertNull(runtime.update(0.016f).atmosphere) + } + + @Test + fun `enabled atmosphere starts with idle preset`() { + val runtime = + CognitiveSceneRuntime( + SceneConfiguration( + width = 8, + height = 6, + enableAtmosphere = true, + ), + ) + + assertEquals(AtmospherePresets.IDLE, runtime.currentAtmosphere) + assertEquals(AtmospherePresets.IDLE, runtime.snapshot().atmosphere) + assertEquals(AtmospherePresets.IDLE, runtime.update(0.016f).atmosphere) + } + + @Test + fun `enabled atmosphere uses configured initial atmosphere`() { + val runtime = + CognitiveSceneRuntime( + SceneConfiguration( + width = 8, + height = 6, + enableAtmosphere = true, + initialAtmosphere = AtmospherePresets.LISTENING, + ), + ) + + assertEquals(AtmospherePresets.LISTENING, runtime.currentAtmosphere) + assertEquals(AtmospherePresets.LISTENING, runtime.snapshot().atmosphere) + } + + @Test + fun `setAtmosphere replaces atmosphere before next snapshot`() { + val runtime = + CognitiveSceneRuntime( + SceneConfiguration( + width = 8, + height = 6, + enableAtmosphere = true, + ), + ) + + runtime.setAtmosphere(AtmospherePresets.THINKING) + val snapshot = runtime.update(0.016f) + + assertEquals(AtmospherePresets.THINKING, runtime.currentAtmosphere) + assertEquals(AtmospherePresets.THINKING, snapshot.atmosphere) + } + + @Test + fun `setAtmosphere throws when atmosphere is disabled`() { + val runtime = + CognitiveSceneRuntime( + SceneConfiguration( + width = 8, + height = 6, + ), + ) + + val failure = + assertFailsWith { + runtime.setAtmosphere(AtmospherePresets.THINKING) + } + + assertEquals( + "Atmosphere subsystem not enabled in SceneConfiguration. Set enableAtmosphere = true to use setAtmosphere.", + failure.message, + ) + } + @Test fun `update is deterministic with identical seed and dt sequence`() { val configuration = populatedConfiguration(seed = 4242L) @@ -80,6 +168,7 @@ class CognitiveSceneRuntimeTest { assertNull(runtime.flow) assertNull(runtime.emitters) assertNull(runtime.cameraOrbit) + assertNull(runtime.currentAtmosphere) assertNull(snapshot.waveformHeightField) assertNull(snapshot.waveformGridWidth) @@ -89,6 +178,7 @@ class CognitiveSceneRuntimeTest { assertNull(snapshot.flowField) assertTrue(snapshot.emitterStates.isEmpty()) assertNull(snapshot.cameraTransform) + assertNull(snapshot.atmosphere) } @Test diff --git a/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/runtime/SceneSnapshotTest.kt b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/runtime/SceneSnapshotTest.kt index 412d4bd..360b624 100644 --- a/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/runtime/SceneSnapshotTest.kt +++ b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/runtime/SceneSnapshotTest.kt @@ -6,6 +6,8 @@ import kotlin.test.assertFailsWith import kotlin.test.assertNotEquals import link.socket.phosphor.coordinate.CoordinateSpace import link.socket.phosphor.field.SubstrateState +import link.socket.phosphor.palette.AtmospherePresets +import link.socket.phosphor.signal.AtmosphereState import link.socket.phosphor.signal.CognitivePhase class SceneSnapshotTest { @@ -26,6 +28,18 @@ class SceneSnapshotTest { assertNotEquals(first, second) } + @Test + fun `atmosphere participates in equality and hash code`() { + val first = buildSnapshot(floatArrayOf(0.1f, 0.2f, 0.3f, 0.4f), AtmospherePresets.IDLE) + val second = first.copy(atmosphere = AtmospherePresets.IDLE) + val third = first.copy(atmosphere = AtmospherePresets.THINKING) + + assertEquals(first, second) + assertEquals(first.hashCode(), second.hashCode()) + assertNotEquals(first, third) + assertNotEquals(first.hashCode(), third.hashCode()) + } + @Test fun `waveform dimensions are required when height field is present`() { assertFailsWith { @@ -48,7 +62,10 @@ class SceneSnapshotTest { } } - private fun buildSnapshot(waveformHeightField: FloatArray): SceneSnapshot { + private fun buildSnapshot( + waveformHeightField: FloatArray, + atmosphere: AtmosphereState? = null, + ): SceneSnapshot { return SceneSnapshot( frameIndex = 12, elapsedTimeSeconds = 1.2f, @@ -64,6 +81,7 @@ class SceneSnapshotTest { cameraTransform = null, emitterStates = emptyList(), choreographyPhase = CognitivePhase.NONE, + atmosphere = atmosphere, ) } }