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: 11 additions & 0 deletions docs/COGNITIVE_SCENE_RUNTIME_MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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()
Expand Down Expand Up @@ -246,6 +265,7 @@ class CognitiveSceneRuntime(
cameraTransform = camera?.toCameraTransform(),
emitterStates = emitters?.instances?.map { it.toEmitterState() } ?: emptyList(),
choreographyPhase = dominantPhase(sortedAgents),
atmosphere = atmosphereState,
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand All @@ -31,6 +32,7 @@ data class SceneSnapshot(
val cameraTransform: CameraTransform?,
val emitterStates: List<EmitterState>,
val choreographyPhase: CognitivePhase,
val atmosphere: AtmosphereState? = null,
) {
init {
if (waveformHeightField != null) {
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,105 @@ 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
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<IllegalStateException> {
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)
Expand Down Expand Up @@ -80,6 +168,7 @@ class CognitiveSceneRuntimeTest {
assertNull(runtime.flow)
assertNull(runtime.emitters)
assertNull(runtime.cameraOrbit)
assertNull(runtime.currentAtmosphere)

assertNull(snapshot.waveformHeightField)
assertNull(snapshot.waveformGridWidth)
Expand All @@ -89,6 +178,7 @@ class CognitiveSceneRuntimeTest {
assertNull(snapshot.flowField)
assertTrue(snapshot.emitterStates.isEmpty())
assertNull(snapshot.cameraTransform)
assertNull(snapshot.atmosphere)
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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<IllegalArgumentException> {
Expand All @@ -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,
Expand All @@ -64,6 +81,7 @@ class SceneSnapshotTest {
cameraTransform = null,
emitterStates = emptyList(),
choreographyPhase = CognitivePhase.NONE,
atmosphere = atmosphere,
)
}
}
Loading