From db34f07d87dabc6dcb785d7add59a8d801243bbb Mon Sep 17 00:00:00 2001 From: Miley Chandonnet Date: Wed, 4 Mar 2026 22:21:23 -0600 Subject: [PATCH] AMPR-138 #420: adopt Phosphor 0.4.0 runtime for CLI/mobile I authored this commit as Codex. Includes CLI runtime migration, color-model unification, and snapshot-driven mobile wrapper wiring for Android/iOS. --- ampere-cli/build.gradle.kts | 2 +- .../kotlin/link/socket/ampere/DemoCommand.kt | 6 +- .../cli/animation/render/CompositeRenderer.kt | 40 ++-- .../cli/hybrid/HybridDashboardRenderer.kt | 23 +-- .../render/CognitiveSceneRuntimeAdapter.kt | 168 ++++++++++++++++ .../ampere/cli/render/WaveformPaneRenderer.kt | 167 +++++++++------- .../link/socket/ampere/DemoCommandTest.kt | 6 +- .../animation/render/CompositeRendererTest.kt | 27 +++ .../cli/render/WaveformPaneRendererTest.kt | 31 ++- ampere-compose/build.gradle.kts | 29 ++- .../socket/ampere/compose/CognitivePalette.kt | 69 ++++--- .../compose/MobileCognitionWrapperSurface.kt | 62 ++++++ .../ampere/compose/SceneSnapshotController.kt | 180 ++++++++++++++++++ .../ampere/compose/CognitivePaletteTest.kt | 35 ++++ .../compose/SceneSnapshotControllerTest.kt | 42 ++++ ampere-core/build.gradle.kts | 2 + .../kotlin/link/socket/ampere/Main.android.kt | 18 +- .../kotlin/link/socket/ampere/Main.ios.kt | 18 +- ampere-desktop/build.gradle.kts | 2 +- 19 files changed, 737 insertions(+), 190 deletions(-) create mode 100644 ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/render/CognitiveSceneRuntimeAdapter.kt create mode 100644 ampere-compose/src/commonMain/kotlin/link/socket/ampere/compose/MobileCognitionWrapperSurface.kt create mode 100644 ampere-compose/src/commonMain/kotlin/link/socket/ampere/compose/SceneSnapshotController.kt create mode 100644 ampere-compose/src/commonTest/kotlin/link/socket/ampere/compose/SceneSnapshotControllerTest.kt diff --git a/ampere-cli/build.gradle.kts b/ampere-cli/build.gradle.kts index 2fbf0295..dc14354c 100644 --- a/ampere-cli/build.gradle.kts +++ b/ampere-cli/build.gradle.kts @@ -84,7 +84,7 @@ kotlin { val jvmMain by getting { dependencies { implementation(project(":ampere-core")) - implementation("link.socket:phosphor-core:0.3.0") + implementation("link.socket:phosphor-core:0.4.0") // CLI argument parsing implementation("com.github.ajalt.clikt:clikt:4.4.0") diff --git a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/DemoCommand.kt b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/DemoCommand.kt index 12acb531..4ded2ef4 100644 --- a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/DemoCommand.kt +++ b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/DemoCommand.kt @@ -18,7 +18,6 @@ import link.socket.phosphor.timeline.PlaybackState import link.socket.phosphor.timeline.TimelineController import link.socket.phosphor.timeline.TimelineEvent import link.socket.phosphor.timeline.WaveformDemoTimeline -import link.socket.ampere.cli.render.AmperePhosphorBridge import link.socket.ampere.cli.render.WaveformPaneRenderer import link.socket.ampere.repl.TerminalFactory @@ -68,14 +67,11 @@ class WaveformDemoSubcommand : CliktCommand( val agents = AgentLayer(width, contentHeight, AgentLayoutOrientation.CIRCULAR) val flow = FlowLayer(width, contentHeight) val emitters = EmitterManager() - val emitterBridge = AmperePhosphorBridge(emitters) val substrate = SubstrateState.create(width, contentHeight, baseDensity = 0.2f) // Create waveform renderer val waveformPane = WaveformPaneRenderer( - agentLayer = agents, - emitterManager = emitters, - amperePhosphorBridge = emitterBridge + agentLayer = agents ) // Build timeline and controller diff --git a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/animation/render/CompositeRenderer.kt b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/animation/render/CompositeRenderer.kt index 139c0afb..2bf6b28c 100644 --- a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/animation/render/CompositeRenderer.kt +++ b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/animation/render/CompositeRenderer.kt @@ -1,5 +1,9 @@ package link.socket.ampere.cli.animation.render +import link.socket.phosphor.color.AnsiColorAdapter +import link.socket.phosphor.color.CognitiveColorModel +import link.socket.phosphor.color.FlowColorState +import link.socket.phosphor.color.ParticleColorKind import link.socket.phosphor.choreography.AgentLayer import link.socket.phosphor.choreography.AgentLayerRenderer import link.socket.phosphor.field.FlowLayer @@ -14,36 +18,40 @@ import link.socket.phosphor.field.SubstrateState import link.socket.phosphor.math.Vector2 import link.socket.phosphor.render.RenderCell import link.socket.phosphor.render.RenderLayer +import link.socket.phosphor.signal.AgentActivityState import kotlin.math.roundToInt /** * Color palette for Ampere TUI. */ object AmperePalette { - // Substrate colors (256-color ANSI codes) - const val SUBSTRATE_DIM = "\u001B[38;5;239m" // Dark blue-gray - const val SUBSTRATE_MID = "\u001B[38;5;73m" // Teal - const val SUBSTRATE_BRIGHT = "\u001B[38;5;117m" // Cyan + private val colorModel = CognitiveColorModel + private val ansiAdapter = AnsiColorAdapter() + + // Substrate colors sampled from Phosphor's shared flow-intensity ramp. + val SUBSTRATE_DIM = ansiAdapter.foreground(colorModel.flowIntensityRamp.sample(0.2f)) + val SUBSTRATE_MID = ansiAdapter.foreground(colorModel.flowIntensityRamp.sample(0.55f)) + val SUBSTRATE_BRIGHT = ansiAdapter.foreground(colorModel.flowIntensityRamp.sample(0.9f)) // Agent colors - const val AGENT_IDLE = "\u001B[38;5;244m" // Gray - const val AGENT_ACTIVE = "\u001B[38;5;220m" // Gold - const val AGENT_PROCESSING = "\u001B[38;5;208m" // Orange - const val AGENT_COMPLETE = "\u001B[38;5;46m" // Green + val AGENT_IDLE = ansiAdapter.foreground(colorModel.agentActivityColors.getValue(AgentActivityState.IDLE)) + val AGENT_ACTIVE = ansiAdapter.foreground(colorModel.agentActivityColors.getValue(AgentActivityState.ACTIVE)) + val AGENT_PROCESSING = ansiAdapter.foreground(colorModel.agentActivityColors.getValue(AgentActivityState.PROCESSING)) + val AGENT_COMPLETE = ansiAdapter.foreground(colorModel.agentActivityColors.getValue(AgentActivityState.COMPLETE)) // Flow colors - const val FLOW_DORMANT = "\u001B[38;5;239m" // Dark - const val FLOW_ACTIVE = "\u001B[38;5;135m" // Purple - const val FLOW_TOKEN = "\u001B[38;5;226m" // Yellow + val FLOW_DORMANT = ansiAdapter.foreground(colorModel.flowStateColors.getValue(FlowColorState.DORMANT)) + val FLOW_ACTIVE = ansiAdapter.foreground(colorModel.flowStateColors.getValue(FlowColorState.ACTIVATING)) + val FLOW_TOKEN = ansiAdapter.foreground(colorModel.particleColors.getValue(ParticleColorKind.TRAIL)) // Accent colors - const val SPARK_ACCENT = "\u001B[38;5;203m" // Coral - const val SUCCESS_GREEN = "\u001B[38;5;83m" // Green - const val LOGO_BOLT = "\u001B[38;5;226m" // Bright yellow - const val LOGO_TEXT = "\u001B[38;5;45m" // Cyan + val SPARK_ACCENT = ansiAdapter.foreground(colorModel.particleColors.getValue(ParticleColorKind.SPARK)) + val SUCCESS_GREEN = ansiAdapter.foreground(colorModel.agentActivityColors.getValue(AgentActivityState.COMPLETE)) + val LOGO_BOLT = ansiAdapter.foreground(colorModel.roleColorFor("reasoning")) + val LOGO_TEXT = ansiAdapter.foreground(colorModel.roleColorFor("coordinator")) // Reset - const val RESET = "\u001B[0m" + val RESET = AnsiColorAdapter.RESET /** * Get substrate color for density value. diff --git a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/hybrid/HybridDashboardRenderer.kt b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/hybrid/HybridDashboardRenderer.kt index ecc88eae..d0f6721f 100644 --- a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/hybrid/HybridDashboardRenderer.kt +++ b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/hybrid/HybridDashboardRenderer.kt @@ -1,11 +1,9 @@ package link.socket.ampere.cli.hybrid import com.github.ajalt.mordant.terminal.Terminal -import link.socket.phosphor.emitter.EmitterManager import link.socket.phosphor.field.ParticleSystem import link.socket.ampere.cli.animation.render.AmperePalette import link.socket.ampere.cli.animation.render.CompositeRenderer -import link.socket.ampere.cli.render.AmperePhosphorBridge import link.socket.phosphor.field.SubstrateAnimator import link.socket.phosphor.field.SubstrateGlyphs import link.socket.phosphor.field.SubstrateState @@ -66,9 +64,6 @@ class HybridDashboardRenderer( private lateinit var bridge: WatchStateAnimationBridge // 3D waveform pipeline - private lateinit var emitterManager: EmitterManager - private lateinit var amperePhosphorBridge: AmperePhosphorBridge - /** * The waveform pane renderer for the middle pane. Callers can pass this * as the `middlePane` parameter to [render] or [renderToBuffer] to display @@ -147,25 +142,15 @@ class HybridDashboardRenderer( maxParticles = config.particleMaxCount ) - // Initialize waveform pipeline - emitterManager = EmitterManager() - amperePhosphorBridge = AmperePhosphorBridge(emitterManager) - if (config.enableWaveform) { val wfPane = WaveformPaneRenderer( - agentLayer = bridge.agentLayer, - emitterManager = emitterManager, - amperePhosphorBridge = amperePhosphorBridge + agentLayer = bridge.agentLayer ) waveformPane = wfPane - // Wire cognitive events from bridge to emitter bridge - bridge.onCognitiveEvent = { event, position -> - amperePhosphorBridge.onCognitiveEvent(event, position) - } - bridge.onProviderTelemetry = { telemetry, position -> - amperePhosphorBridge.onProviderCallCompleted(telemetry, position) - } + // Route watch events into the waveform pane's runtime-backed emitter bridge. + bridge.onCognitiveEvent = wfPane::onCognitiveEvent + bridge.onProviderTelemetry = wfPane::onProviderCallCompleted } initialized = true diff --git a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/render/CognitiveSceneRuntimeAdapter.kt b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/render/CognitiveSceneRuntimeAdapter.kt new file mode 100644 index 00000000..396e0e3b --- /dev/null +++ b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/render/CognitiveSceneRuntimeAdapter.kt @@ -0,0 +1,168 @@ +package link.socket.ampere.cli.render + +import link.socket.phosphor.choreography.AgentLayer +import link.socket.phosphor.choreography.AgentLayoutOrientation +import link.socket.phosphor.coordinate.CoordinateSpace +import link.socket.phosphor.emitter.EmitterManager +import link.socket.phosphor.field.FlowLayer +import link.socket.phosphor.field.FlowState +import link.socket.phosphor.math.Vector2 +import link.socket.phosphor.render.Camera +import link.socket.phosphor.render.CognitiveWaveform +import link.socket.phosphor.runtime.CameraOrbitConfiguration +import link.socket.phosphor.runtime.CognitiveSceneRuntime +import link.socket.phosphor.runtime.SceneConfiguration +import link.socket.phosphor.runtime.SceneSnapshot +import link.socket.phosphor.runtime.WaveformConfiguration + +/** + * Bridges Ampere's existing watch-state driven layers onto Phosphor's unified + * scene runtime so rendering consumes a single runtime update contract. + */ +class CognitiveSceneRuntimeAdapter { + private var cachedWidth = 0 + private var cachedHeight = 0 + private var cachedCoordinateSpace: CoordinateSpace? = null + private var runtime: CognitiveSceneRuntime? = null + private var latestSnapshot: SceneSnapshot? = null + + val emitterManager: EmitterManager? + get() = runtime?.emitters + + val waveform: CognitiveWaveform? + get() = runtime?.waveform + + val agents: AgentLayer? + get() = runtime?.agents + + fun update( + width: Int, + height: Int, + deltaSeconds: Float, + sourceAgents: AgentLayer, + sourceFlow: FlowLayer? + ): SceneSnapshot { + val activeRuntime = ensureRuntime(width, height, sourceAgents.coordinateSpace) + syncAgents(sourceAgents, activeRuntime.agents) + activeRuntime.flow?.let { syncFlow(sourceFlow, it) } + return activeRuntime.update(deltaSeconds).also { latestSnapshot = it } + } + + fun currentCamera(): Camera? = runtime?.cameraOrbit?.currentCamera() + + fun snapshot(): SceneSnapshot? = latestSnapshot ?: runtime?.snapshot() + + private fun ensureRuntime( + width: Int, + height: Int, + coordinateSpace: CoordinateSpace + ): CognitiveSceneRuntime { + val needsRebuild = runtime == null || + width != cachedWidth || + height != cachedHeight || + coordinateSpace != cachedCoordinateSpace + + if (needsRebuild) { + cachedWidth = width + cachedHeight = height + cachedCoordinateSpace = coordinateSpace + runtime = createRuntime(width, height, coordinateSpace) + latestSnapshot = runtime?.snapshot() + } + + return requireNotNull(runtime) + } + + private fun createRuntime( + width: Int, + height: Int, + coordinateSpace: CoordinateSpace + ): CognitiveSceneRuntime { + val config = SceneConfiguration( + width = width, + height = height, + agents = emptyList(), + initialConnections = emptyList(), + enableWaveform = true, + enableParticles = false, + enableFlow = true, + enableEmitters = true, + enableCamera = true, + coordinateSpace = coordinateSpace, + seed = 0xA63E0L, + agentLayout = AgentLayoutOrientation.CUSTOM, + waveform = WaveformConfiguration( + gridWidth = width.coerceAtMost(60), + gridDepth = height.coerceAtMost(40), + worldWidth = 20f, + worldDepth = 15f + ), + cameraOrbit = CameraOrbitConfiguration( + radius = 15f, + height = 8f, + orbitSpeed = 0.08f, + wobbleAmplitude = 0.3f, + wobbleFrequency = 0.2f + ) + ) + return CognitiveSceneRuntime(config) + } + + private fun syncAgents(source: AgentLayer, target: AgentLayer) { + val sourceById = source.allAgents.associateBy { it.id } + val targetIds = target.allAgents.map { it.id }.toSet() + + for (id in targetIds - sourceById.keys) { + target.removeAgent(id) + } + + for ((id, agent) in sourceById) { + if (target.getAgent(id) == null) { + target.addAgent(agent.copy()) + continue + } + + target.setAgentPosition(id, agent.position) + target.setAgentPosition3D(id, agent.position3D) + target.updateAgentState(id, agent.state) + target.updateAgentStatus(id, agent.statusText) + target.updateAgentCognitivePhase(id, agent.cognitivePhase, agent.phaseProgress) + } + } + + private fun syncFlow(source: FlowLayer?, target: FlowLayer) { + if (source == null) { + if (target.connectionCount > 0) { + target.clear() + } + return + } + + val sourceById = source.allConnections.associateBy { it.id } + val targetConnections = target.allConnections + + for (connection in targetConnections) { + if (connection.id !in sourceById) { + target.removeConnection(connection.id) + } + } + + for (connection in source.allConnections) { + val sourcePos = connection.path.firstOrNull() ?: Vector2.ZERO + val targetPos = connection.path.lastOrNull() ?: sourcePos + val existing = target.getConnection(connection.id) + val resolvedId = existing?.id ?: target.createConnection( + sourceAgentId = connection.sourceAgentId, + targetAgentId = connection.targetAgentId, + sourcePosition = sourcePos, + targetPosition = targetPos + ) + + target.updateConnectionPath(resolvedId, sourcePos, targetPos) + + if (connection.state == FlowState.TRANSMITTING) { + target.startHandoff(resolvedId) + } + } + } +} diff --git a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/render/WaveformPaneRenderer.kt b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/render/WaveformPaneRenderer.kt index 8d955445..7862bc9f 100644 --- a/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/render/WaveformPaneRenderer.kt +++ b/ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/render/WaveformPaneRenderer.kt @@ -1,147 +1,166 @@ package link.socket.ampere.cli.render -import link.socket.phosphor.choreography.AgentLayer -import link.socket.phosphor.choreography.AgentLayoutOrientation +import link.socket.ampere.cli.layout.PaneRenderer +import link.socket.ampere.cli.watch.presentation.ProviderCallTelemetrySummary import link.socket.phosphor.bridge.CognitiveEvent import link.socket.phosphor.emitter.EmitterManager import link.socket.phosphor.field.FlowLayer +import link.socket.phosphor.field.SubstrateState import link.socket.phosphor.math.Vector3 -import link.socket.phosphor.render.CameraOrbit -import link.socket.phosphor.render.ScreenProjector -import link.socket.phosphor.render.AsciiCell import link.socket.phosphor.palette.AsciiLuminancePalette import link.socket.phosphor.palette.CognitiveColorRamp -import link.socket.phosphor.field.SubstrateState -import link.socket.phosphor.render.CognitiveWaveform +import link.socket.phosphor.render.AsciiCell import link.socket.phosphor.render.PhaseBlender +import link.socket.phosphor.render.ScreenProjector import link.socket.phosphor.render.SurfaceLighting import link.socket.phosphor.render.WaveformRasterizer -import link.socket.ampere.cli.layout.PaneRenderer +import link.socket.phosphor.choreography.AgentLayer /** * Renders the 3D cognitive waveform as a pane within the hybrid dashboard. * - * Replaces the flat spatial map with a living 3D heightmap surface rendered - * in ASCII, where agent activity creates peaks and ridges, cognitive phases - * shift the character palette and color, and emitter effects fire transient - * visual perturbations. - * - * The pane orchestrates the full pipeline each frame: - * camera orbit → waveform update → emitter update → rasterize → ANSI output + * The pane is driven by [CognitiveSceneRuntimeAdapter], so camera orbit, waveform, + * flow, and emitter updates are advanced via a single runtime update per frame. */ class WaveformPaneRenderer( private val agentLayer: AgentLayer, - private val emitterManager: EmitterManager, - private val amperePhosphorBridge: AmperePhosphorBridge + private val runtimeAdapter: CognitiveSceneRuntimeAdapter = CognitiveSceneRuntimeAdapter() ) : PaneRenderer { - - private var cameraOrbit = CameraOrbit( - radius = 15f, - height = 8f, - orbitSpeed = 0.08f, - wobbleAmplitude = 0.3f, - wobbleFrequency = 0.2f - ) - private val lighting = SurfaceLighting() private val phaseBlender = PhaseBlender(influenceRadius = 8f) // Lazily constructed per-size to avoid rebuilding when pane dimensions are stable. private var cachedWidth = 0 private var cachedHeight = 0 - private var waveform: CognitiveWaveform? = null private var rasterizer: WaveformRasterizer? = null // Animation time tracking - private var elapsedTime = 0f private var deltaSeconds = 0.033f // ~30fps default, updated externally - // Substrate and flow state provided externally via update() - private var currentSubstrate: SubstrateState? = null + // Source state from watch bridge (used to synchronize runtime flow) private var currentFlow: FlowLayer? = null + // Runtime-backed emitter bridge. Rebuilt if runtime instance changes. + private var bridgeEmitterManager: EmitterManager? = null + private var amperePhosphorBridge: AmperePhosphorBridge? = null + + // Events can arrive before runtime init (first render), so queue them. + private val queuedCognitiveEvents = mutableListOf>() + private val queuedProviderTelemetry = mutableListOf>() + /** - * Update the renderer's animation state before the next render call. + * Update the renderer's animation inputs before the next render call. * - * @param substrate Current substrate density field + * @param substrate Unused by the runtime adapter; kept for call-site compatibility. * @param flow Current flow connections (nullable) * @param dt Delta time since last frame in seconds */ fun update(substrate: SubstrateState, flow: FlowLayer?, dt: Float) { - currentSubstrate = substrate currentFlow = flow deltaSeconds = dt - elapsedTime += dt } /** * Fire a cognitive event through the emitter bridge. + * + * If the runtime is not initialized yet, the event is buffered and emitted on the next render. */ fun onCognitiveEvent(event: CognitiveEvent, agentPosition: Vector3) { - amperePhosphorBridge.onCognitiveEvent(event, agentPosition) + val bridge = amperePhosphorBridge + if (bridge == null) { + queuedCognitiveEvents += event to agentPosition + } else { + bridge.onCognitiveEvent(event, agentPosition) + } } - override fun render(width: Int, height: Int): List { - ensureComponents(width, height) + /** + * Emit provider telemetry metadata through Ampere's phase-aware bridge. + * + * If the runtime is not initialized yet, the event is buffered and emitted on the next render. + */ + fun onProviderCallCompleted(event: ProviderCallTelemetrySummary, agentPosition: Vector3) { + val bridge = amperePhosphorBridge + if (bridge == null) { + queuedProviderTelemetry += event to agentPosition + } else { + bridge.onProviderCallCompleted(event, agentPosition) + } + } - val wf = waveform ?: return emptyGrid(width, height) + override fun render(width: Int, height: Int): List { + ensureRasterizer(width, height) + runtimeAdapter.update( + width = width, + height = height, + deltaSeconds = deltaSeconds, + sourceAgents = agentLayer, + sourceFlow = currentFlow + ) + + ensureBridge() + flushQueuedEvents() + + val waveform = runtimeAdapter.waveform ?: return emptyGrid(width, height) + val camera = runtimeAdapter.currentCamera() ?: return emptyGrid(width, height) + val runtimeAgents = runtimeAdapter.agents ?: return emptyGrid(width, height) + val emitters = runtimeAdapter.emitterManager ?: return emptyGrid(width, height) val rast = rasterizer ?: return emptyGrid(width, height) - // 1. Advance camera orbit - val camera = cameraOrbit.update(deltaSeconds) - - // 2. Update waveform heightmap from current state - val substrate = currentSubstrate ?: SubstrateState.create(width, height, baseDensity = 0.1f) - wf.update(substrate, agentLayer, currentFlow, deltaSeconds) - - // 3. Update emitter effects - emitterManager.update(deltaSeconds) - - // 4. Rasterize with phase blending for per-point palette selection - val cells: Array> = if (agentLayer.agentCount > 0) { + val cells: Array> = if (runtimeAgents.agentCount > 0) { rast.rasterizeBlended( - waveform = wf, + waveform = waveform, camera = camera, blender = phaseBlender, - agents = agentLayer, + agents = runtimeAgents, fallbackPalette = AsciiLuminancePalette.STANDARD, fallbackRamp = CognitiveColorRamp.NEUTRAL, - emitterManager = emitterManager + emitterManager = emitters ) } else { rast.rasterize( - waveform = wf, + waveform = waveform, camera = camera, palette = AsciiLuminancePalette.STANDARD, colorRamp = CognitiveColorRamp.NEUTRAL, - emitterManager = emitterManager + emitterManager = emitters ) } - // 5. Convert AsciiCell grid to ANSI-styled strings return cellsToLines(cells, width, height) } - /** - * Ensure waveform and rasterizer are sized correctly for the current pane dimensions. - */ - private fun ensureComponents(width: Int, height: Int) { + private fun ensureBridge() { + val emitters = runtimeAdapter.emitterManager ?: return + if (bridgeEmitterManager !== emitters) { + bridgeEmitterManager = emitters + amperePhosphorBridge = AmperePhosphorBridge(emitters) + } + } + + private fun flushQueuedEvents() { + val bridge = amperePhosphorBridge ?: return + + if (queuedCognitiveEvents.isNotEmpty()) { + for ((event, position) in queuedCognitiveEvents) { + bridge.onCognitiveEvent(event, position) + } + queuedCognitiveEvents.clear() + } + + if (queuedProviderTelemetry.isNotEmpty()) { + for ((event, position) in queuedProviderTelemetry) { + bridge.onProviderCallCompleted(event, position) + } + queuedProviderTelemetry.clear() + } + } + + private fun ensureRasterizer(width: Int, height: Int) { if (width != cachedWidth || height != cachedHeight) { cachedWidth = width cachedHeight = height - // Heightmap resolution: use pane dimensions directly for 1:1 mapping - // but cap grid resolution to keep frame time under budget. - val gridWidth = width.coerceAtMost(60) - val gridDepth = height.coerceAtMost(40) - - waveform = CognitiveWaveform( - gridWidth = gridWidth, - gridDepth = gridDepth, - worldWidth = 20f, - worldDepth = 15f - ) - val projector = ScreenProjector( screenWidth = width, screenHeight = height @@ -161,6 +180,10 @@ class WaveformPaneRenderer( return List(height) { emptyLine } } + internal fun activeEmitterCount(): Int = runtimeAdapter.emitterManager?.activeCount ?: 0 + + internal fun runtimeFrameIndex(): Long? = runtimeAdapter.snapshot()?.frameIndex + companion object { /** * Convert a 2D AsciiCell grid to a list of ANSI-styled strings, diff --git a/ampere-cli/src/jvmTest/kotlin/link/socket/ampere/DemoCommandTest.kt b/ampere-cli/src/jvmTest/kotlin/link/socket/ampere/DemoCommandTest.kt index db1cd90e..d5539c26 100644 --- a/ampere-cli/src/jvmTest/kotlin/link/socket/ampere/DemoCommandTest.kt +++ b/ampere-cli/src/jvmTest/kotlin/link/socket/ampere/DemoCommandTest.kt @@ -8,7 +8,6 @@ import link.socket.phosphor.field.SubstrateState import link.socket.phosphor.timeline.TimelineController import link.socket.phosphor.timeline.TimelineEvent import link.socket.phosphor.timeline.WaveformDemoTimeline -import link.socket.ampere.cli.render.AmperePhosphorBridge import link.socket.ampere.cli.render.WaveformPaneRenderer import kotlin.test.Test import kotlin.test.assertEquals @@ -27,13 +26,10 @@ class DemoCommandTest { val agents = AgentLayer(width, height, AgentLayoutOrientation.CIRCULAR) val flow = FlowLayer(width, height) val emitters = EmitterManager() - val emitterBridge = AmperePhosphorBridge(emitters) val substrate = SubstrateState.create(width, height, baseDensity = 0.2f) val waveformPane = WaveformPaneRenderer( - agentLayer = agents, - emitterManager = emitters, - amperePhosphorBridge = emitterBridge + agentLayer = agents ) val timeline = WaveformDemoTimeline.build(agents, flow, emitters) diff --git a/ampere-cli/src/jvmTest/kotlin/link/socket/ampere/cli/animation/render/CompositeRendererTest.kt b/ampere-cli/src/jvmTest/kotlin/link/socket/ampere/cli/animation/render/CompositeRendererTest.kt index f595862d..6d03aa81 100644 --- a/ampere-cli/src/jvmTest/kotlin/link/socket/ampere/cli/animation/render/CompositeRendererTest.kt +++ b/ampere-cli/src/jvmTest/kotlin/link/socket/ampere/cli/animation/render/CompositeRendererTest.kt @@ -1,5 +1,9 @@ package link.socket.ampere.cli.animation.render +import link.socket.phosphor.color.AnsiColorAdapter +import link.socket.phosphor.color.CognitiveColorModel +import link.socket.phosphor.color.FlowColorState +import link.socket.phosphor.color.ParticleColorKind import link.socket.phosphor.signal.AgentActivityState import link.socket.phosphor.choreography.AgentLayer import link.socket.phosphor.signal.AgentVisualState @@ -47,6 +51,29 @@ class AmperePaletteTest { assertTrue(color.startsWith("\u001B["), "Color should start with escape sequence") } } + + @Test + fun `palette colors align with phosphor cognitive color model`() { + val model = CognitiveColorModel + val adapter = AnsiColorAdapter() + + assertEquals( + adapter.foreground(model.agentActivityColors.getValue(AgentActivityState.ACTIVE)), + AmperePalette.AGENT_ACTIVE + ) + assertEquals( + adapter.foreground(model.agentActivityColors.getValue(AgentActivityState.PROCESSING)), + AmperePalette.AGENT_PROCESSING + ) + assertEquals( + adapter.foreground(model.flowStateColors.getValue(FlowColorState.ACTIVATING)), + AmperePalette.FLOW_ACTIVE + ) + assertEquals( + adapter.foreground(model.particleColors.getValue(ParticleColorKind.SPARK)), + AmperePalette.SPARK_ACCENT + ) + } } class RenderLayerTest { diff --git a/ampere-cli/src/jvmTest/kotlin/link/socket/ampere/cli/render/WaveformPaneRendererTest.kt b/ampere-cli/src/jvmTest/kotlin/link/socket/ampere/cli/render/WaveformPaneRendererTest.kt index d88076da..3523faf3 100644 --- a/ampere-cli/src/jvmTest/kotlin/link/socket/ampere/cli/render/WaveformPaneRendererTest.kt +++ b/ampere-cli/src/jvmTest/kotlin/link/socket/ampere/cli/render/WaveformPaneRendererTest.kt @@ -5,7 +5,6 @@ import link.socket.phosphor.choreography.AgentLayer import link.socket.phosphor.choreography.AgentLayoutOrientation import link.socket.phosphor.signal.AgentVisualState import link.socket.phosphor.signal.CognitivePhase -import link.socket.phosphor.emitter.EmitterManager import link.socket.phosphor.math.Vector3 import link.socket.phosphor.render.AsciiCell import link.socket.phosphor.field.SubstrateState @@ -17,13 +16,9 @@ import kotlin.test.assertTrue class WaveformPaneRendererTest { private val agentLayer = AgentLayer(80, 24, AgentLayoutOrientation.CUSTOM) - private val emitterManager = EmitterManager() - private val amperePhosphorBridge = AmperePhosphorBridge(emitterManager) private val renderer = WaveformPaneRenderer( - agentLayer = agentLayer, - emitterManager = emitterManager, - amperePhosphorBridge = amperePhosphorBridge + agentLayer = agentLayer ) @Test @@ -148,7 +143,7 @@ class WaveformPaneRendererTest { } @Test - fun `emitter effects are active after update`() { + fun `queued cognitive events are emitted after runtime initialization`() { val width = 20 val height = 10 val substrate = SubstrateState.create(width, height, baseDensity = 0.3f) @@ -171,11 +166,29 @@ class WaveformPaneRendererTest { Vector3(0f, 0f, 0f) ) - // Effects should be active - assertTrue(emitterManager.activeCount > 0, "Emitter should have active effects after spark event") + // Runtime is not initialized before first render; event should be buffered. + assertEquals(0, renderer.activeEmitterCount()) renderer.update(substrate, flow = null, dt = 0.033f) val lines = renderer.render(width, height) assertEquals(height, lines.size) + assertTrue(renderer.activeEmitterCount() > 0, "Emitter should activate once runtime is initialized") + } + + @Test + fun `render advances runtime frame index`() { + val width = 24 + val height = 12 + val substrate = SubstrateState.create(width, height, baseDensity = 0.25f) + + renderer.update(substrate, flow = null, dt = 0.016f) + renderer.render(width, height) + val firstFrame = requireNotNull(renderer.runtimeFrameIndex()) + + renderer.update(substrate, flow = null, dt = 0.016f) + renderer.render(width, height) + val secondFrame = requireNotNull(renderer.runtimeFrameIndex()) + + assertTrue(secondFrame > firstFrame, "Runtime frame index should advance after successive renders") } } diff --git a/ampere-compose/build.gradle.kts b/ampere-compose/build.gradle.kts index 81992f41..e71a1c51 100644 --- a/ampere-compose/build.gradle.kts +++ b/ampere-compose/build.gradle.kts @@ -2,6 +2,7 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { kotlin("multiplatform") + id("com.android.library") id("org.jetbrains.compose") kotlin("plugin.compose") } @@ -14,16 +15,26 @@ version = ampereVersion kotlin { applyDefaultHierarchyTemplate() + androidTarget { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } + } + jvm { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } } + iosX64() + iosArm64() + iosSimulatorArm64() + sourceSets { val commonMain by getting { dependencies { - implementation("link.socket:phosphor-core:0.3.0") + implementation("link.socket:phosphor-core:0.4.0") implementation(compose.runtime) implementation(compose.foundation) implementation(compose.ui) @@ -36,3 +47,19 @@ kotlin { } } } + +android { + compileSdk = (findProperty("android.compileSdk") as String).toInt() + namespace = "link.socket.ampere.compose" + + defaultConfig { + minSdk = (findProperty("android.minSdk") as String).toInt() + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + kotlin { + jvmToolchain(21) + } +} diff --git a/ampere-compose/src/commonMain/kotlin/link/socket/ampere/compose/CognitivePalette.kt b/ampere-compose/src/commonMain/kotlin/link/socket/ampere/compose/CognitivePalette.kt index 790a8ce9..257e9e90 100644 --- a/ampere-compose/src/commonMain/kotlin/link/socket/ampere/compose/CognitivePalette.kt +++ b/ampere-compose/src/commonMain/kotlin/link/socket/ampere/compose/CognitivePalette.kt @@ -1,56 +1,63 @@ package link.socket.ampere.compose import androidx.compose.ui.graphics.Color +import link.socket.phosphor.color.CognitiveColorModel +import link.socket.phosphor.color.FlowColorState +import link.socket.phosphor.color.ParticleColorKind +import link.socket.phosphor.renderer.ComposeColor +import link.socket.phosphor.renderer.ComposeColorAdapter +import link.socket.phosphor.signal.AgentActivityState import link.socket.phosphor.signal.CognitivePhase /** - * Color palette for Compose rendering, equivalent to AmperePalette for terminal. - * - * Maps the same semantic color roles to Compose Color values. + * Color palette for Compose rendering sourced from Phosphor's neutral color model. */ object CognitivePalette { + private val colorModel = CognitiveColorModel + private val composeColorAdapter = ComposeColorAdapter() + + private fun toComposeColor(color: ComposeColor): Color = Color( + red = color.red / 255f, + green = color.green / 255f, + blue = color.blue / 255f, + alpha = color.alpha + ) // Substrate - val substrateDim = Color(0xFF3A3F47) // Dark blue-gray - val substrateMid = Color(0xFF5F9EA0) // Teal - val substrateBright = Color(0xFF87CEEB) // Cyan + val substrateDim = toComposeColor(composeColorAdapter.adapt(colorModel.flowIntensityRamp.sample(0.2f))) + val substrateMid = toComposeColor(composeColorAdapter.adapt(colorModel.flowIntensityRamp.sample(0.55f))) + val substrateBright = toComposeColor(composeColorAdapter.adapt(colorModel.flowIntensityRamp.sample(0.9f))) // Agents - val agentIdle = Color(0xFF808080) // Gray - val agentActive = Color(0xFFFFD700) // Gold - val agentProcessing = Color(0xFFFF8C00) // Orange - val agentComplete = Color(0xFF32CD32) // Green + val agentIdle = toComposeColor(composeColorAdapter.adapt(colorModel.agentActivityColors.getValue(AgentActivityState.IDLE))) + val agentActive = toComposeColor(composeColorAdapter.adapt(colorModel.agentActivityColors.getValue(AgentActivityState.ACTIVE))) + val agentProcessing = toComposeColor(composeColorAdapter.adapt(colorModel.agentActivityColors.getValue(AgentActivityState.PROCESSING))) + val agentComplete = toComposeColor(composeColorAdapter.adapt(colorModel.agentActivityColors.getValue(AgentActivityState.COMPLETE))) // Flow - val flowDormant = Color(0xFF3A3F47) - val flowActive = Color(0xFF9370DB) // Purple - val flowToken = Color(0xFFFFD700) // Yellow + val flowDormant = toComposeColor(composeColorAdapter.adapt(colorModel.flowStateColors.getValue(FlowColorState.DORMANT))) + val flowActive = toComposeColor(composeColorAdapter.adapt(colorModel.flowStateColors.getValue(FlowColorState.ACTIVATING))) + val flowToken = toComposeColor(composeColorAdapter.adapt(colorModel.particleColors.getValue(ParticleColorKind.TRAIL))) // Accents - val sparkAccent = Color(0xFFFF6B6B) // Coral - val logoBolt = Color(0xFFFFD700) // Bright yellow - val logoText = Color(0xFF00CED1) // Cyan - - // Cognitive phase colors (new — distinct from agent state colors) - val perceive = Color(0xFF6495ED) // Cornflower blue (sensory) - val recall = Color(0xFFDAA520) // Goldenrod (memory warmth) - val plan = Color(0xFF9370DB) // Medium purple (exploration) - val execute = Color(0xFFFF8C00) // Dark orange (discharge) - val evaluate = Color(0xFF66CDAA) // Medium aquamarine (reflection) - val loop = Color(0xFF708090) // Slate gray (reset) - - /** - * Get substrate color for a density value. - */ + val sparkAccent = toComposeColor(composeColorAdapter.adapt(colorModel.particleColors.getValue(ParticleColorKind.SPARK))) + val logoBolt = toComposeColor(composeColorAdapter.adapt(colorModel.roleColorFor("reasoning"))) + val logoText = toComposeColor(composeColorAdapter.adapt(colorModel.roleColorFor("coordinator"))) + + // Cognitive phase colors + val perceive = toComposeColor(composeColorAdapter.adapt(colorModel.phaseColorFor(CognitivePhase.PERCEIVE))) + val recall = toComposeColor(composeColorAdapter.adapt(colorModel.phaseColorFor(CognitivePhase.RECALL))) + val plan = toComposeColor(composeColorAdapter.adapt(colorModel.phaseColorFor(CognitivePhase.PLAN))) + val execute = toComposeColor(composeColorAdapter.adapt(colorModel.phaseColorFor(CognitivePhase.EXECUTE))) + val evaluate = toComposeColor(composeColorAdapter.adapt(colorModel.phaseColorFor(CognitivePhase.EVALUATE))) + val loop = toComposeColor(composeColorAdapter.adapt(colorModel.phaseColorFor(CognitivePhase.LOOP))) + fun forDensity(density: Float): Color = when { density < 0.3f -> substrateDim density < 0.6f -> substrateMid else -> substrateBright } - /** - * Get color for a cognitive phase. - */ fun forPhase(phase: CognitivePhase): Color = when (phase) { CognitivePhase.PERCEIVE -> perceive CognitivePhase.RECALL -> recall diff --git a/ampere-compose/src/commonMain/kotlin/link/socket/ampere/compose/MobileCognitionWrapperSurface.kt b/ampere-compose/src/commonMain/kotlin/link/socket/ampere/compose/MobileCognitionWrapperSurface.kt new file mode 100644 index 00000000..ce79cb2d --- /dev/null +++ b/ampere-compose/src/commonMain/kotlin/link/socket/ampere/compose/MobileCognitionWrapperSurface.kt @@ -0,0 +1,62 @@ +package link.socket.ampere.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive + +/** + * Mobile-first wrapper surface that renders cognition from scene snapshots. + */ +@Composable +fun MobileCognitionWrapperSurface( + modifier: Modifier = Modifier, + controller: SceneSnapshotController = remember { SceneSnapshotController() }, + targetFps: Int = 30 +) { + var snapshot by remember(controller) { mutableStateOf(controller.snapshot()) } + + LaunchedEffect(controller, targetFps) { + val fps = targetFps.coerceAtLeast(1) + val dt = 1f / fps + val frameDelayMs = (1000f / fps).toLong().coerceAtLeast(1L) + + while (isActive) { + snapshot = controller.update(dt) + delay(frameDelayMs) + } + } + + Box(modifier = modifier.fillMaxSize().background(Color.Black)) { + CognitiveCanvas( + substrate = snapshot.substrateState, + particles = controller.particles, + agents = controller.agentLayer, + flow = controller.flow, + camera = controller.camera, + waveform = controller.waveform, + modifier = Modifier.fillMaxSize() + ) + + BasicText( + text = "Frame ${snapshot.frameIndex} • ${snapshot.choreographyPhase.name}", + modifier = Modifier + .align(Alignment.TopStart) + .padding(12.dp), + style = androidx.compose.ui.text.TextStyle(color = Color.White) + ) + } +} diff --git a/ampere-compose/src/commonMain/kotlin/link/socket/ampere/compose/SceneSnapshotController.kt b/ampere-compose/src/commonMain/kotlin/link/socket/ampere/compose/SceneSnapshotController.kt new file mode 100644 index 00000000..a0077f99 --- /dev/null +++ b/ampere-compose/src/commonMain/kotlin/link/socket/ampere/compose/SceneSnapshotController.kt @@ -0,0 +1,180 @@ +package link.socket.ampere.compose + +import link.socket.phosphor.choreography.AgentLayer +import link.socket.phosphor.choreography.AgentLayoutOrientation +import link.socket.phosphor.emitter.EmitterEffect +import link.socket.phosphor.field.FlowLayer +import link.socket.phosphor.field.ParticleSystem +import link.socket.phosphor.math.Vector2 +import link.socket.phosphor.math.Vector3 +import link.socket.phosphor.palette.CognitiveColorRamp +import link.socket.phosphor.render.Camera +import link.socket.phosphor.render.CognitiveWaveform +import link.socket.phosphor.runtime.AgentDescriptor +import link.socket.phosphor.runtime.CameraOrbitConfiguration +import link.socket.phosphor.runtime.CognitiveSceneRuntime +import link.socket.phosphor.runtime.FlowConnectionDescriptor +import link.socket.phosphor.runtime.SceneConfiguration +import link.socket.phosphor.runtime.SceneSnapshot +import link.socket.phosphor.runtime.WaveformConfiguration +import link.socket.phosphor.signal.AgentActivityState +import link.socket.phosphor.signal.CognitivePhase + +/** + * Shared snapshot-driven runtime controller for mobile wrapper surfaces. + */ +class SceneSnapshotController( + configuration: SceneConfiguration = defaultSceneConfiguration(), + private val phaseStepSeconds: Float = 1.8f +) { + private val runtime = CognitiveSceneRuntime(configuration) + private val phaseCycle = listOf( + CognitivePhase.PERCEIVE, + CognitivePhase.RECALL, + CognitivePhase.PLAN, + CognitivePhase.EXECUTE, + CognitivePhase.EVALUATE, + CognitivePhase.LOOP + ) + private var phaseIndex = 0 + private var phaseElapsed = 0f + + val particles: ParticleSystem + get() = runtime.particles ?: error("SceneConfiguration must enable particles") + + val agentLayer: AgentLayer + get() = runtime.agents + + val flow: FlowLayer? + get() = runtime.flow + + val waveform: CognitiveWaveform? + get() = runtime.waveform + + val camera: Camera? + get() = runtime.cameraOrbit?.currentCamera() + + fun snapshot(): SceneSnapshot = runtime.snapshot() + + fun update(deltaSeconds: Float): SceneSnapshot { + phaseElapsed += deltaSeconds + if (phaseElapsed >= phaseStepSeconds) { + phaseElapsed -= phaseStepSeconds + advanceDemoPhase() + } + return runtime.update(deltaSeconds) + } + + private fun advanceDemoPhase() { + val agents = runtime.agents + if (agents.agentCount == 0) return + + phaseIndex = (phaseIndex + 1) % phaseCycle.size + val activePhase = phaseCycle[phaseIndex] + + for ((offset, agent) in agents.allAgents.withIndex()) { + val phase = phaseCycle[(phaseIndex + offset) % phaseCycle.size] + val state = when (phase) { + CognitivePhase.EXECUTE -> AgentActivityState.ACTIVE + CognitivePhase.LOOP -> AgentActivityState.IDLE + CognitivePhase.NONE -> AgentActivityState.IDLE + else -> AgentActivityState.PROCESSING + } + + agents.updateAgentState(agent.id, state) + agents.updateAgentCognitivePhase(agent.id, phase) + } + + val source = agents.allAgents.firstOrNull() ?: return + runtime.emit( + effect = when (activePhase) { + CognitivePhase.EXECUTE -> EmitterEffect.SparkBurst() + CognitivePhase.PLAN -> EmitterEffect.ColorWash( + colorRamp = CognitiveColorRamp.forPhase(CognitivePhase.PLAN) + ) + else -> EmitterEffect.HeightPulse() + }, + position = source.position3D, + metadata = emptyMap() + ) + } + + companion object { + fun defaultSceneConfiguration( + width: Int = 80, + height: Int = 34 + ): SceneConfiguration { + return SceneConfiguration( + width = width, + height = height, + agents = listOf( + AgentDescriptor( + id = "spark", + name = "Spark", + role = "reasoning", + position = Vector2.ZERO, + position3D = Vector3(-4f, 0f, -2f), + state = AgentActivityState.ACTIVE, + statusText = "Perceiving", + cognitivePhase = CognitivePhase.PERCEIVE, + phaseProgress = 0f + ), + AgentDescriptor( + id = "memory", + name = "Memory", + role = "memory", + position = Vector2.ZERO, + position3D = Vector3(4f, 0f, 2f), + state = AgentActivityState.PROCESSING, + statusText = "Recalling", + cognitivePhase = CognitivePhase.RECALL, + phaseProgress = 0f + ), + AgentDescriptor( + id = "coordinator", + name = "Coordinator", + role = "coordinator", + position = Vector2.ZERO, + position3D = Vector3(0f, 0f, 5f), + state = AgentActivityState.PROCESSING, + statusText = "Planning", + cognitivePhase = CognitivePhase.PLAN, + phaseProgress = 0f + ) + ), + initialConnections = listOf( + FlowConnectionDescriptor( + sourceAgentId = "spark", + targetAgentId = "memory", + startHandoff = true + ), + FlowConnectionDescriptor( + sourceAgentId = "memory", + targetAgentId = "coordinator", + startHandoff = true + ) + ), + enableWaveform = true, + enableParticles = true, + enableFlow = true, + enableEmitters = true, + enableCamera = true, + seed = 0xA63E0L, + agentLayout = AgentLayoutOrientation.CIRCULAR, + waveform = WaveformConfiguration( + gridWidth = 56, + gridDepth = 32, + worldWidth = 20f, + worldDepth = 15f + ), + cameraOrbit = CameraOrbitConfiguration( + radius = 15f, + height = 8f, + orbitSpeed = 0.08f, + wobbleAmplitude = 0.3f, + wobbleFrequency = 0.2f + ) + ) + } + } +} diff --git a/ampere-compose/src/commonTest/kotlin/link/socket/ampere/compose/CognitivePaletteTest.kt b/ampere-compose/src/commonTest/kotlin/link/socket/ampere/compose/CognitivePaletteTest.kt index f7a69c29..a97a0b73 100644 --- a/ampere-compose/src/commonTest/kotlin/link/socket/ampere/compose/CognitivePaletteTest.kt +++ b/ampere-compose/src/commonTest/kotlin/link/socket/ampere/compose/CognitivePaletteTest.kt @@ -1,11 +1,23 @@ package link.socket.ampere.compose import androidx.compose.ui.graphics.Color +import link.socket.phosphor.color.CognitiveColorModel +import link.socket.phosphor.color.FlowColorState +import link.socket.phosphor.color.ParticleColorKind +import link.socket.phosphor.renderer.ComposeColor +import link.socket.phosphor.renderer.ComposeColorAdapter +import link.socket.phosphor.signal.AgentActivityState import link.socket.phosphor.signal.CognitivePhase import kotlin.test.Test import kotlin.test.assertEquals class CognitivePaletteTest { + private fun assertComposeColorEquals(expected: ComposeColor, actual: Color) { + assertEquals(expected.red / 255f, actual.red, 0.0001f) + assertEquals(expected.green / 255f, actual.green, 0.0001f) + assertEquals(expected.blue / 255f, actual.blue, 0.0001f) + assertEquals(expected.alpha, actual.alpha, 0.0001f) + } @Test fun forPhase_perceive_returns_cornflower_blue() { @@ -91,4 +103,27 @@ class CognitivePaletteTest { assertEquals(1.0f, color.alpha, "Palette colors should be fully opaque") } } + + @Test + fun `palette colors align with phosphor cognitive color model`() { + val model = CognitiveColorModel + val adapter = ComposeColorAdapter() + + assertComposeColorEquals( + adapter.adapt(model.agentActivityColors.getValue(AgentActivityState.ACTIVE)), + CognitivePalette.agentActive + ) + assertComposeColorEquals( + adapter.adapt(model.flowStateColors.getValue(FlowColorState.ACTIVATING)), + CognitivePalette.flowActive + ) + assertComposeColorEquals( + adapter.adapt(model.particleColors.getValue(ParticleColorKind.SPARK)), + CognitivePalette.sparkAccent + ) + assertComposeColorEquals( + adapter.adapt(model.phaseColorFor(CognitivePhase.PLAN)), + CognitivePalette.plan + ) + } } diff --git a/ampere-compose/src/commonTest/kotlin/link/socket/ampere/compose/SceneSnapshotControllerTest.kt b/ampere-compose/src/commonTest/kotlin/link/socket/ampere/compose/SceneSnapshotControllerTest.kt new file mode 100644 index 00000000..96fc24d4 --- /dev/null +++ b/ampere-compose/src/commonTest/kotlin/link/socket/ampere/compose/SceneSnapshotControllerTest.kt @@ -0,0 +1,42 @@ +package link.socket.ampere.compose + +import link.socket.phosphor.signal.CognitivePhase +import kotlin.test.Test +import kotlin.test.assertTrue + +class SceneSnapshotControllerTest { + + @Test + fun `default controller seeds agents and scene data`() { + val controller = SceneSnapshotController() + + val snapshot = controller.snapshot() + + assertTrue(snapshot.agentStates.isNotEmpty(), "Expected seeded agents in snapshot") + assertTrue(snapshot.substrateState.width > 0, "Expected substrate field in snapshot") + assertTrue( + snapshot.waveformHeightField?.isNotEmpty() == true, + "Expected waveform data in snapshot" + ) + } + + @Test + fun `update advances frame index and rotates phases`() { + val controller = SceneSnapshotController(phaseStepSeconds = 0.05f) + val initial = controller.snapshot() + val initialById = initial.agentStates.associateBy { it.id } + + var updated = initial + repeat(3) { + updated = controller.update(0.05f) + } + + assertTrue(updated.frameIndex > initial.frameIndex, "Expected frame index to advance") + val changedPhase = updated.agentStates.any { state -> + val previous = initialById[state.id]?.cognitivePhase + previous != null && previous != state.cognitivePhase + } + assertTrue(changedPhase, "Expected at least one agent to rotate cognitive phase") + assertTrue(updated.choreographyPhase != CognitivePhase.NONE, "Expected active choreography phase") + } +} diff --git a/ampere-core/build.gradle.kts b/ampere-core/build.gradle.kts index 9ef335a6..54e6990a 100644 --- a/ampere-core/build.gradle.kts +++ b/ampere-core/build.gradle.kts @@ -165,6 +165,7 @@ kotlin { } val androidMain by getting { dependencies { + implementation(project(":ampere-compose")) implementation(compose.uiTooling) api("androidx.activity:activity-compose:1.11.0") @@ -209,6 +210,7 @@ kotlin { val iosSimulatorArm64Main by getting val iosMain by getting { dependencies { + implementation(project(":ampere-compose")) implementation("app.cash.sqldelight:native-driver:2.2.1") implementation("io.ktor:ktor-client-darwin:3.2.2") } diff --git a/ampere-core/src/androidMain/kotlin/link/socket/ampere/Main.android.kt b/ampere-core/src/androidMain/kotlin/link/socket/ampere/Main.android.kt index 62c52a07..c9ec9418 100644 --- a/ampere-core/src/androidMain/kotlin/link/socket/ampere/Main.android.kt +++ b/ampere-core/src/androidMain/kotlin/link/socket/ampere/Main.android.kt @@ -2,26 +2,14 @@ package link.socket.ampere import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview -import app.cash.sqldelight.db.SqlDriver -import link.socket.ampere.data.createAndroidDriver -import link.socket.ampere.ui.App +import link.socket.ampere.compose.MobileCognitionWrapperSurface @Composable fun MainView() { - val context = LocalContext.current - - val databaseDriver: SqlDriver = remember { - createAndroidDriver(context) - } - - App( - modifier = Modifier - .fillMaxSize(), - databaseDriver = databaseDriver, + MobileCognitionWrapperSurface( + modifier = Modifier.fillMaxSize(), ) } diff --git a/ampere-core/src/iosMain/kotlin/link/socket/ampere/Main.ios.kt b/ampere-core/src/iosMain/kotlin/link/socket/ampere/Main.ios.kt index fab2485c..abf87976 100644 --- a/ampere-core/src/iosMain/kotlin/link/socket/ampere/Main.ios.kt +++ b/ampere-core/src/iosMain/kotlin/link/socket/ampere/Main.ios.kt @@ -1,26 +1,14 @@ package link.socket.ampere import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import androidx.compose.ui.window.ComposeUIViewController -import app.cash.sqldelight.db.SqlDriver -import link.socket.ampere.data.createIosDriver -import link.socket.ampere.ui.App +import link.socket.ampere.compose.MobileCognitionWrapperSurface import platform.UIKit.UIViewController fun mainViewController(): UIViewController = ComposeUIViewController { - val databaseDriver: SqlDriver = remember { - createIosDriver() - } - - App( - modifier = Modifier - .fillMaxSize() - .padding(top = 64.dp), - databaseDriver = databaseDriver, + MobileCognitionWrapperSurface( + modifier = Modifier.fillMaxSize(), ) } diff --git a/ampere-desktop/build.gradle.kts b/ampere-desktop/build.gradle.kts index 5c260621..ac234d9f 100644 --- a/ampere-desktop/build.gradle.kts +++ b/ampere-desktop/build.gradle.kts @@ -21,7 +21,7 @@ kotlin { implementation(project(":ampere-core")) implementation(project(":ampere-compose")) implementation(project(":ampere-cli")) - implementation("link.socket:phosphor-core:0.3.0") + implementation("link.socket:phosphor-core:0.4.0") implementation(compose.desktop.currentOs) } }