diff --git a/docs/tickets/done/005-physical-echo-replay.md b/docs/tickets/done/005-physical-echo-replay.md new file mode 100644 index 0000000..8f94f44 --- /dev/null +++ b/docs/tickets/done/005-physical-echo-replay.md @@ -0,0 +1,73 @@ +# T005: Replay a Prior Run as a Physical Echo + +## Goal + +Spawn a previous run as a physical echo actor that advances inside the current simulation by consuming the prior run's recorded command stream. + +## Context + +This is the first ticket where replay becomes visible as gameplay. The echo should be a real simulation actor, not a rendered ghost path and not a position playback movie. + +The first version does not need local recovery, deviation scoring, remembered gear, or rich combat. It needs to prove the core architecture: a recorded run can become an actor in a new run while sharing the same deterministic simulation rules as the live player. + +## Implemented Shape + +T005 promotes the replay substrate into a run lifecycle model owned by `src/sim`. + +- `Run_History` remains the causative record for one run: run id, sim version, seed, initial state, and one `Input_Command` per tick. +- `Run_Archive` owns completed histories. Archived runs are deep-copied, so later current-run recording cannot mutate completed command streams. +- `Run_Session` owns the active `Sim_State`, the current recording history, the archive, and run id allocation. +- `run_session_tick` records the live command for the current run, then advances the authoritative simulation with access to the archive. +- `run_session_complete_and_restart` archives the current run, resets the world from the same seed and initial state, starts a new current history, and spawns echoes for archived runs. + +Echo replay is part of actor state, not an app/rendering special case. + +- `World_State` now stores a deterministic fixed-capacity actor array instead of a singleton player field. +- `Actor_Kind` distinguishes `Player` and `Echo` actors. +- `Echo_Replay_State` stores `source_run_id`, `replay_tick`, and `Echo_Replay_Mode`. +- Echo actors do not store pointers to `Run_History`; each tick resolves commands by stable source run id through the archive. +- Both player and echo movement go through `apply_actor_command`, keeping command replay on the same gameplay path as live input. +- Once an echo consumes the final command in its source run, it enters explicit `Timeline_Ended` state instead of silently idling as if still replaying. + +Rendering and headless output observe this simulation state. + +- The game renders all active actors from `Run_Session.sim.world`. +- The live player remains independently controllable while echoes consume prior commands. +- Pressing `R` is the temporary complete-run/restart trigger. It calls the same `run_session_complete_and_restart` lifecycle that future death handling should use. +- Debug text shows current run id, command count, archive count, actor count, digest, and first echo source/cursor/mode. +- The headless harness now runs the fixture, verifies traditional command-stream replay, archives/restarts, and emits echo actor transcript facts while the echo replays. + +## Requirements + +- Store at least one completed run history that can be used by a later run. +- Start a new run from the same deterministic initial world setup. +- Spawn an echo actor associated with a prior `Run_History` or equivalent. +- Advance the echo one simulation tick at a time by reading the prior run's command for the corresponding tick. +- Make the echo use the same movement/controller path as the player wherever practical. +- Distinguish live-player commands from echo replay commands without duplicating gameplay logic. +- Keep the live player and echo in the same authoritative simulation world. +- Keep replay driven by command stream, not recorded positions. +- Emit transcript/debug facts that make echo tick, source run id, and replay cursor visible. + +## Acceptance Criteria + +- A completed command history can be replayed as an echo in a later run. +- The echo consumes exactly one prior command per simulation tick while following the recorded timeline. +- The echo exists in simulation state and can be rendered/debugged as an actor. +- Replaying the same history from the same seed produces identical echo state/digests. +- The live player can still be controlled independently while the echo replays. +- `just check` passes. + +## Verification + +- Added sim tests for archived echo spawn, source run identity, cursor advancement, explicit `Timeline_Ended`, live-player independence, and deterministic echo replay digest stability. +- `just check` passes. +- `just headless` emits run 1 replay proof, then run 2 echo transcript lines with actor kind, source run id, replay cursor, replay mode, and final echo digest. + +## Notes + +- Do not add local recovery or deviation heuristics in this ticket. +- Do not add corpse, gear, or resolution outcomes in this ticket unless a tiny placeholder is needed for flow. +- Echoes should phase through the live player for now, matching the current design note. +- T006 should build deviation/correction on top of `Echo_Replay_State`. +- T007 should attach death events and remembered gear to archived histories without changing command-stream replay authority. diff --git a/docs/tickets/todo/005-physical-echo-replay.md b/docs/tickets/todo/005-physical-echo-replay.md deleted file mode 100644 index 1f00135..0000000 --- a/docs/tickets/todo/005-physical-echo-replay.md +++ /dev/null @@ -1,46 +0,0 @@ -# T005: Replay a Prior Run as a Physical Echo - -## Goal - -Spawn a previous run as a physical echo actor that advances inside the current simulation by consuming the prior run's recorded command stream. - -## Context - -This is the first ticket where replay becomes visible as gameplay. The echo should be a real simulation actor, not a rendered ghost path and not a position playback movie. - -The first version does not need local recovery, deviation scoring, remembered gear, or rich combat. It needs to prove the core architecture: a recorded run can become an actor in a new run while sharing the same deterministic simulation rules as the live player. - -## Requirements - -- Store at least one completed run history that can be used by a later run. -- Start a new run from the same deterministic initial world setup. -- Spawn an echo actor associated with a prior `Run_History` or equivalent. -- Advance the echo one simulation tick at a time by reading the prior run's command for the corresponding tick. -- Make the echo use the same movement/controller path as the player wherever practical. -- Distinguish live-player commands from echo replay commands without duplicating gameplay logic. -- Keep the live player and echo in the same authoritative simulation world. -- Keep replay driven by command stream, not recorded positions. -- Emit transcript/debug facts that make echo tick, source run id, and replay cursor visible. - -## Suggested Implementation Direction - -- Add actor kind/state for `Player` and `Echo` only if the current world model requires it. -- Add a replay cursor per echo: source run id, current replay tick, and replay state. -- Treat missing commands past the end of a run as an explicit end-of-timeline state, not silent idle forever. -- Use a hard-coded restart/death trigger if needed; full death-event ownership belongs later. -- Keep collision/combat behavior minimal. If collisions are not ready, the echo can initially share movement in an empty arena. - -## Acceptance Criteria - -- A completed command history can be replayed as an echo in a later run. -- The echo consumes exactly one prior command per simulation tick while following the recorded timeline. -- The echo exists in simulation state and can be rendered/debugged as an actor. -- Replaying the same history from the same seed produces identical echo state/digests. -- The live player can still be controlled independently while the echo replays. -- `just check` passes. - -## Notes - -- Do not add local recovery or deviation heuristics in this ticket. -- Do not add corpse, gear, or resolution outcomes in this ticket unless a tiny placeholder is needed for flow. -- Echoes should phase through the live player for now, matching the current design note. diff --git a/src/game/app.odin b/src/game/app.odin index f863b29..ada032a 100644 --- a/src/game/app.odin +++ b/src/game/app.odin @@ -75,9 +75,11 @@ game_force_restart :: proc() -> bool { app_init :: proc(app: ^App_State) { app^ = App_State{run = true} - simulation.sim_init(&app.sim) - simulation.run_history_init(&app.history) - app.previous_player_position = app.sim.world.player.position + simulation.run_session_init(&app.session) + player := simulation.world_player(&app.session.sim.world) + if player != nil { + app.previous_player_position = player.position + } render_init(app) } @@ -86,6 +88,16 @@ app_update :: proc(app: ^App_State) { if input_requested_quit() { app.run = false } + if input_requested_complete_run() { + if !simulation.run_session_complete_and_restart(&app.session) { + app.run = false + } + player := simulation.world_player(&app.session.sim.world) + if player != nil { + app.previous_player_position = player.position + } + app.sim_accumulator = 0 + } frame_dt := f64(rl.GetFrameTime()) if frame_dt > simulation.MAX_FRAME_DT { @@ -97,12 +109,14 @@ app_update :: proc(app: ^App_State) { app.sim_accumulator += frame_dt ticks_this_frame := 0 for app.sim_accumulator >= simulation.SIM_DT && ticks_this_frame < simulation.MAX_TICKS_PER_FRAME { - if !simulation.run_history_record_tick(&app.history, &app.sim, app.last_input) { + player := simulation.world_player(&app.session.sim.world) + if player != nil { + app.previous_player_position = player.position + } + if !simulation.run_session_tick(&app.session, app.last_input) { app.run = false break } - app.previous_player_position = app.sim.world.player.position - simulation.sim_tick(&app.sim, app.last_input) app.sim_accumulator -= simulation.SIM_DT ticks_this_frame += 1 } @@ -116,6 +130,6 @@ app_update :: proc(app: ^App_State) { app_shutdown :: proc(app: ^App_State) { render_shutdown(app) - simulation.run_history_destroy(&app.history) + simulation.run_session_destroy(&app.session) app^ = {} } diff --git a/src/game/input.odin b/src/game/input.odin index f8ea17e..a62d5ad 100644 --- a/src/game/input.odin +++ b/src/game/input.odin @@ -17,6 +17,10 @@ input_requested_quit :: proc() -> bool { return rl.IsKeyPressed(DEFAULT_KEYMAP.quit) } +input_requested_complete_run :: proc() -> bool { + return rl.IsKeyPressed(DEFAULT_KEYMAP.complete_run) +} + input_requested_reload :: proc() -> bool { return rl.IsKeyPressed(DEFAULT_KEYMAP.force_reload) } diff --git a/src/game/keymap.odin b/src/game/keymap.odin index 64ce4c2..567d0c1 100644 --- a/src/game/keymap.odin +++ b/src/game/keymap.odin @@ -9,6 +9,7 @@ Keymap :: struct { move_down: rl.KeyboardKey, attack: rl.MouseButton, quit: rl.KeyboardKey, + complete_run: rl.KeyboardKey, force_reload: rl.KeyboardKey, force_restart: rl.KeyboardKey, } @@ -20,6 +21,7 @@ DEFAULT_KEYMAP :: Keymap{ move_down = .S, attack = .LEFT, quit = .ESCAPE, + complete_run = .R, force_reload = .F5, force_restart = .F6, } diff --git a/src/game/memory.odin b/src/game/memory.odin index 419312e..0efa105 100644 --- a/src/game/memory.odin +++ b/src/game/memory.odin @@ -10,8 +10,7 @@ App_State :: struct { debug_font: rl.Font, debug_font_loaded: bool, previous_player_position: simulation.Vec2_Fixed, - sim: simulation.Sim_State, - history: simulation.Run_History, + session: simulation.Run_Session, last_input: simulation.Input_Command, } diff --git a/src/game/render.odin b/src/game/render.odin index c7fdeb3..6f039a0 100644 --- a/src/game/render.odin +++ b/src/game/render.odin @@ -11,6 +11,8 @@ ROOM_COLOR :: rl.Color{24, 29, 42, 255} ROOM_EDGE_COLOR :: rl.Color{82, 94, 122, 255} PLAYER_COLOR :: rl.Color{225, 232, 255, 255} PLAYER_EDGE_COLOR :: rl.Color{88, 166, 255, 255} +ECHO_COLOR :: rl.Color{117, 218, 192, 180} +ECHO_EDGE_COLOR :: rl.Color{56, 126, 118, 230} TEXT_COLOR :: rl.Color{200, 210, 230, 255} SUBTLE_COLOR :: rl.Color{120, 132, 160, 255} PANEL_COLOR :: rl.Color{8, 10, 15, 204} @@ -43,9 +45,14 @@ render_frame :: proc(app: ^App_State) { rl.ClearBackground(BACKGROUND_COLOR) alpha := interpolation_alpha(app) + player := simulation.world_player(&app.session.sim.world) + player_position := app.previous_player_position + if player != nil { + player_position = player.position + } player_screen := sim_vec_to_render_lerped( app.previous_player_position, - app.sim.world.player.position, + player_position, alpha, ) camera := rl.Camera2D{ @@ -64,8 +71,15 @@ render_frame :: proc(app: ^App_State) { } render_world :: proc(app: ^App_State, player_screen: rl.Vector2) { - render_arena(app.sim.world.arena) - render_player(player_screen) + render_arena(app.session.sim.world.arena) + for index in 0.. string { + for index in 0.. rl.Vector2 { @@ -203,3 +263,16 @@ move_direction_name :: proc(direction: simulation.Move_Direction) -> string { return "unknown" } + +echo_mode_name :: proc(mode: simulation.Echo_Replay_Mode) -> string { + switch mode { + case .None: + return "none" + case .Following: + return "following" + case .Timeline_Ended: + return "timeline-ended" + } + + return "unknown" +} diff --git a/src/lib/headless/main.odin b/src/lib/headless/main.odin index 5fa63dc..2574b8a 100644 --- a/src/lib/headless/main.odin +++ b/src/lib/headless/main.odin @@ -160,56 +160,118 @@ command_from_verb :: proc(verb: string) -> (simulation.Input_Command, bool) { } run_script :: proc(script: ^Script) -> bool { - history: simulation.Run_History - simulation.run_history_init(&history, seed = script.seed) - defer simulation.run_history_destroy(&history) + session: simulation.Run_Session + simulation.run_session_init(&session, seed = script.seed) + defer simulation.run_session_destroy(&session) - state: simulation.Sim_State - simulation.sim_init(&state, history.seed, history.initial_state) - - fmt.printfln("run seed %d", history.seed) + fmt.printfln("run %d seed %d", session.current_run_id, session.current_history.seed) for batch in script.batches { // Expand fixture shorthand into the authoritative replay contract: exactly // one recorded command before every simulation tick. for _ in 0.. string { + switch kind { + case .None: + return "none" + case .Player: + return "player" + case .Echo: + return "echo" + } + return "unknown" +} + +echo_mode_name :: proc(mode: simulation.Echo_Replay_Mode) -> string { + switch mode { + case .None: + return "none" + case .Following: + return "following" + case .Timeline_Ended: + return "timeline-ended" + } + return "unknown" } diff --git a/src/sim/config.odin b/src/sim/config.odin index 8e647b2..b87d86b 100644 --- a/src/sim/config.odin +++ b/src/sim/config.odin @@ -23,3 +23,7 @@ FIXED_ONE :: Fixed(1 << FIXED_SHIFT) // Prototype player speed: ~8.57 whole sim units per second at 60hz. PLAYER_MOVE_CARDINAL :: Fixed((1 << FIXED_SHIFT) / 7) PLAYER_MOVE_DIAGONAL :: Fixed(6620) // round(PLAYER_MOVE_CARDINAL / sqrt(2)) + +// Fixed capacity keeps early actor iteration deterministic and cache-friendly. +// Echo caps/readability can become game rules later; this is just storage shape. +MAX_WORLD_ACTORS :: 16 diff --git a/src/sim/replay.odin b/src/sim/replay.odin index ae31143..8585f7c 100644 --- a/src/sim/replay.odin +++ b/src/sim/replay.odin @@ -10,12 +10,18 @@ Run_History :: struct { commands: [dynamic]Input_Command, } -// Replay cursors let echoes consume a past run one tick at a time while the live -// run advances. Later, local recovery can sit beside this without changing the -// fact that replay starts from commands. -Replay_Cursor :: struct { - history: ^Run_History, - tick: u64, +// Completed histories live behind an archive instead of actor-owned pointers. +// Echo actors store source run ids and cursors; each tick resolves commands here. +Run_Archive :: struct { + histories: [dynamic]Run_History, +} + +Run_Session :: struct { + sim: Sim_State, + archive: Run_Archive, + current_history: Run_History, + current_run_id: u32, + next_run_id: u32, } run_history_init :: proc( @@ -38,6 +44,14 @@ run_history_destroy :: proc(history: ^Run_History) { history^ = {} } +run_history_copy :: proc(dst: ^Run_History, src: ^Run_History) { + run_history_init(dst, src.run_id, src.seed, src.initial_state) + dst.sim_version = src.sim_version + for command in src.commands { + append(&dst.commands, command) + } +} + run_history_clear_commands :: proc(history: ^Run_History) { clear(&history.commands) } @@ -61,3 +75,111 @@ replay_history :: proc(history: ^Run_History, state: ^Sim_State) -> u64 { } return sim_digest(state) } + +run_archive_init :: proc(archive: ^Run_Archive) { + archive^ = Run_Archive{histories = make([dynamic]Run_History)} +} + +run_archive_destroy :: proc(archive: ^Run_Archive) { + for &history in archive.histories { + run_history_destroy(&history) + } + delete(archive.histories) + archive^ = {} +} + +run_archive_add_completed :: proc(archive: ^Run_Archive, history: ^Run_History) -> bool { + if run_archive_find(archive, history.run_id) != nil { + return false + } + + archived: Run_History + run_history_copy(&archived, history) + append(&archive.histories, archived) + return true +} + +run_archive_find :: proc(archive: ^Run_Archive, run_id: u32) -> ^Run_History { + if archive == nil { + return nil + } + + for &history in archive.histories { + if history.run_id == run_id { + return &history + } + } + return nil +} + +run_archive_command_count_for_run :: proc(archive: ^Run_Archive, run_id: u32) -> u64 { + history := run_archive_find(archive, run_id) + if history == nil { + return 0 + } + return run_history_command_count(history) +} + +run_archive_command_at :: proc( + archive: ^Run_Archive, + run_id: u32, + tick: u64, +) -> (Input_Command, bool) { + history := run_archive_find(archive, run_id) + if history == nil || tick >= u64(len(history.commands)) { + return {}, false + } + return history.commands[tick], true +} + +run_session_init :: proc( + session: ^Run_Session, + seed := DEFAULT_WORLD_SEED, + initial := DEFAULT_INITIAL_STATE, +) { + session^ = Run_Session{ + current_run_id = 1, + next_run_id = 2, + } + run_archive_init(&session.archive) + run_history_init(&session.current_history, session.current_run_id, seed, initial) + sim_init(&session.sim, seed, initial) +} + +run_session_destroy :: proc(session: ^Run_Session) { + run_history_destroy(&session.current_history) + run_archive_destroy(&session.archive) + session^ = {} +} + +run_session_tick :: proc(session: ^Run_Session, live_command: Input_Command) -> bool { + if !run_history_record_tick(&session.current_history, &session.sim, live_command) { + return false + } + sim_tick_with_archive(&session.sim, live_command, &session.archive) + return true +} + +run_session_complete_and_restart :: proc(session: ^Run_Session) -> bool { + seed := session.current_history.seed + initial := session.current_history.initial_state + + if !run_archive_add_completed(&session.archive, &session.current_history) { + return false + } + + run_history_destroy(&session.current_history) + session.current_run_id = session.next_run_id + session.next_run_id += 1 + run_history_init(&session.current_history, session.current_run_id, seed, initial) + sim_init(&session.sim, seed, initial) + + for history in session.archive.histories { + _, spawned := world_spawn_echo(&session.sim.world, history.run_id, history.initial_state.player_position) + if !spawned { + return false + } + } + + return true +} diff --git a/src/sim/replay_test.odin b/src/sim/replay_test.odin index cfe8ae9..d881b00 100644 --- a/src/sim/replay_test.odin +++ b/src/sim/replay_test.odin @@ -34,15 +34,18 @@ deterministic_replay_matches_original_run :: proc(t: ^testing.T) { replay_state: Sim_State replay_digest := replay_history(&history, &replay_state) + state_player := world_player(&state.world) + replay_player := world_player(&replay_state.world) // Replay must land on the same tick, command count, digest, and explicit player // state when initialized from the same seed and fed the same command stream. testing.expect_value(t, replay_state.tick, state.tick) testing.expect_value(t, run_history_command_count(&history), state.tick) testing.expect_value(t, replay_digest, original_digest) - testing.expect_value(t, i32(replay_state.world.player.position.x), i32(state.world.player.position.x)) - testing.expect_value(t, i32(replay_state.world.player.position.y), i32(state.world.player.position.y)) - testing.expect_value(t, i32(state.world.player.position.x), i32(PLAYER_MOVE_CARDINAL) * 2) - testing.expect_value(t, i32(state.world.player.position.y), i32(fixed_from_whole(0))) + testing.expect(t, state_player != nil && replay_player != nil, "player actors should exist") + testing.expect_value(t, i32(replay_player.position.x), i32(state_player.position.x)) + testing.expect_value(t, i32(replay_player.position.y), i32(state_player.position.y)) + testing.expect_value(t, i32(state_player.position.x), i32(PLAYER_MOVE_CARDINAL) * 2) + testing.expect_value(t, i32(state_player.position.y), i32(fixed_from_whole(0))) } @(test) @@ -54,10 +57,12 @@ digital_diagonal_movement_uses_precomputed_normalized_delta :: proc(t: ^testing. // path then uses a precomputed fixed-point delta instead of sqrt/division work. command := input_command_from_digital(false, true, true, false) sim_tick(&state, command) + player := world_player(&state.world) testing.expect_value(t, command.move, Move_Direction.NE) - testing.expect_value(t, i32(state.world.player.position.x), i32(PLAYER_MOVE_DIAGONAL)) - testing.expect_value(t, i32(state.world.player.position.y), i32(-PLAYER_MOVE_DIAGONAL)) + testing.expect(t, player != nil, "player actor should exist") + testing.expect_value(t, i32(player.position.x), i32(PLAYER_MOVE_DIAGONAL)) + testing.expect_value(t, i32(player.position.y), i32(-PLAYER_MOVE_DIAGONAL)) } @(test) @@ -76,3 +81,82 @@ history_rejects_missing_command_ticks :: proc(t: ^testing.T) { testing.expect(t, !recorded, "history should not accept a command after the tick was skipped") testing.expect_value(t, run_history_command_count(&history), u64(0)) } + +@(test) +physical_echo_replays_archived_commands_inside_live_world :: proc(t: ^testing.T) { + session: Run_Session + run_session_init(&session, seed = 12345) + defer run_session_destroy(&session) + + prior_commands := [?]Input_Command{{move = .E}, {move = .E}, {move = .N}} + for command in prior_commands { + testing.expect(t, run_session_tick(&session, command), "prior run should record") + } + testing.expect(t, run_session_complete_and_restart(&session), "run should archive and restart") + + testing.expect_value(t, len(session.archive.histories), 1) + testing.expect_value(t, session.current_run_id, u32(2)) + testing.expect_value(t, session.sim.tick, u64(0)) + testing.expect_value(t, session.sim.world.actor_count, u32(2)) + testing.expect_value(t, run_archive_command_count_for_run(&session.archive, 1), u64(len(prior_commands))) + + echo := first_echo(&session.sim.world) + testing.expect(t, echo != nil, "restart should spawn an echo actor") + testing.expect_value(t, echo.echo.source_run_id, u32(1)) + testing.expect_value(t, echo.echo.replay_tick, u64(0)) + testing.expect_value(t, echo.echo.mode, Echo_Replay_Mode.Following) + + testing.expect(t, run_session_tick(&session, {move = .S}), "live run should tick with echo") + player := world_player(&session.sim.world) + echo = first_echo(&session.sim.world) + testing.expect(t, player != nil && echo != nil, "player and echo should coexist") + testing.expect_value(t, i32(player.position.y), i32(PLAYER_MOVE_CARDINAL)) + testing.expect_value(t, i32(echo.position.x), i32(PLAYER_MOVE_CARDINAL)) + testing.expect_value(t, echo.echo.replay_tick, u64(1)) + testing.expect_value(t, echo.echo.mode, Echo_Replay_Mode.Following) + + testing.expect(t, run_session_tick(&session, {}), "echo should consume second command") + testing.expect(t, run_session_tick(&session, {}), "echo should consume final command") + echo = first_echo(&session.sim.world) + testing.expect(t, echo != nil, "echo should remain inspectable after timeline end") + testing.expect_value(t, i32(echo.position.x), i32(PLAYER_MOVE_CARDINAL) * 2) + testing.expect_value(t, i32(echo.position.y), i32(-PLAYER_MOVE_CARDINAL)) + testing.expect_value(t, echo.echo.replay_tick, u64(len(prior_commands))) + testing.expect_value(t, echo.echo.mode, Echo_Replay_Mode.Timeline_Ended) + testing.expect_value(t, run_archive_command_count_for_run(&session.archive, 1), u64(len(prior_commands))) + testing.expect_value(t, run_history_command_count(&session.current_history), u64(3)) +} + +@(test) +echo_replay_digest_is_stable_for_same_archive_and_live_commands :: proc(t: ^testing.T) { + first_digest := echo_replay_digest_fixture() + second_digest := echo_replay_digest_fixture() + testing.expect_value(t, second_digest, first_digest) +} + +first_echo :: proc(world: ^World_State) -> ^Actor_State { + for index in 0.. u64 { + session: Run_Session + run_session_init(&session, seed = 333) + defer run_session_destroy(&session) + + prior_commands := [?]Input_Command{{move = .E}, {move = .N}, {move = .NE}, {move = .S}} + for command in prior_commands { + run_session_tick(&session, command) + } + run_session_complete_and_restart(&session) + live_commands := [?]Input_Command{{move = .S}, {}, {move = .W}, {}} + for command in live_commands { + run_session_tick(&session, command) + } + return sim_digest(&session.sim) +} diff --git a/src/sim/sim.odin b/src/sim/sim.odin index bbb6511..9c60ade 100644 --- a/src/sim/sim.odin +++ b/src/sim/sim.odin @@ -21,25 +21,62 @@ sim_init :: proc( initial = initial, world = World_State{ seed = seed, - actor_count = 1, arena = DEFAULT_ARENA, - player = Actor_State{ - id = Actor_Id{index = 0, generation = 1}, - position = initial.player_position, - }, }, } + world_spawn_player(&state.world, initial.player_position) } // sim_tick is the only place gameplay time advances. It intentionally takes an // already-canonical command so hardware, frame time, and replay never enter sim. sim_tick :: proc(state: ^Sim_State, command: Input_Command) { - delta := move_delta(command.move) - state.world.player.position.x += delta.x - state.world.player.position.y += delta.y + sim_tick_with_archive(state, command, nil) +} + +sim_tick_with_archive :: proc( + state: ^Sim_State, + live_command: Input_Command, + archive: ^Run_Archive, +) { + player := world_player(&state.world) + if player != nil { + apply_actor_command(player, live_command) + } + + for index in 0..= command_count { + actor.echo.mode = .Timeline_Ended + continue + } + + echo_command, found := run_archive_command_at(archive, actor.echo.source_run_id, actor.echo.replay_tick) + if !found { + actor.echo.mode = .Timeline_Ended + continue + } + + apply_actor_command(actor, echo_command) + actor.echo.replay_tick += 1 + if actor.echo.replay_tick >= command_count { + actor.echo.mode = .Timeline_Ended + } + } + state.tick += 1 } +apply_actor_command :: proc(actor: ^Actor_State, command: Input_Command) { + delta := move_delta(command.move) + actor.position.x += delta.x + actor.position.y += delta.y +} + sim_digest :: proc(state: ^Sim_State) -> u64 { hash := u64(14695981039346656037) hash = digest_mix(hash, u64(SIM_VERSION)) @@ -53,10 +90,19 @@ sim_digest :: proc(state: ^Sim_State) -> u64 { hash = digest_mix_fixed(hash, state.world.arena.max.y) hash = digest_mix_fixed(hash, state.initial.player_position.x) hash = digest_mix_fixed(hash, state.initial.player_position.y) - hash = digest_mix(hash, u64(state.world.player.id.index)) - hash = digest_mix(hash, u64(state.world.player.id.generation)) - hash = digest_mix_fixed(hash, state.world.player.position.x) - hash = digest_mix_fixed(hash, state.world.player.position.y) + hash = digest_mix(hash, u64(state.world.player_id.index)) + hash = digest_mix(hash, u64(state.world.player_id.generation)) + for index in 0.. (Actor_Id, bool) { + id, actor := world_spawn_actor(world, .Player, position) + if actor == nil { + return {}, false + } + world.player_id = id + return id, true +} + +world_spawn_echo :: proc( + world: ^World_State, + source_run_id: u32, + position: Vec2_Fixed, +) -> (Actor_Id, bool) { + id, actor := world_spawn_actor(world, .Echo, position) + if actor == nil { + return {}, false + } + actor.echo = Echo_Replay_State{ + source_run_id = source_run_id, + mode = .Following, + } + return id, true +} + +world_spawn_actor :: proc( + world: ^World_State, + kind: Actor_Kind, + position: Vec2_Fixed, +) -> (Actor_Id, ^Actor_State) { + if world.actor_count >= MAX_WORLD_ACTORS { + return {}, nil + } + + index := world.actor_count + id := Actor_Id{index = index, generation = 1} + world.actors[index] = Actor_State{ + id = id, + kind = kind, + position = position, + } + world.actor_count += 1 + return id, &world.actors[index] +} + +world_actor_by_id :: proc(world: ^World_State, id: Actor_Id) -> ^Actor_State { + if id.index >= world.actor_count { + return nil + } + + actor := &world.actors[id.index] + if actor.id.generation != id.generation { + return nil + } + return actor +} + +world_player :: proc(world: ^World_State) -> ^Actor_State { + return world_actor_by_id(world, world.player_id) }