diff --git a/docs/tickets/maybe-later/headless-harness-regression-hardening.md b/docs/tickets/maybe-later/headless-harness-regression-hardening.md new file mode 100644 index 0000000..e3f9c8b --- /dev/null +++ b/docs/tickets/maybe-later/headless-harness-regression-hardening.md @@ -0,0 +1,23 @@ +# Maybe Later: Harden Headless Harness Regression Coverage + +## Context + +T003 adds a deliberately small headless harness for running scripted simulation input and reviewing deterministic text output. The first version keeps the harness deleteable and avoids building a parallel gameplay test framework. + +Two follow-up risks are worth revisiting once the harness has more real gameplay to observe: + +- The current repeatability check is manual. A future check could compare headless output against a committed golden transcript or another stable regression mechanism. +- Future fixture needs should not turn the harness into a gameplay mock layer. If scenarios need richer setup, prefer sim-owned inputs such as `Initial_State_Descriptor`, deterministic world setup data, or sim debug/query APIs. + +## Possible Direction + +- Add a `just test-headless` recipe that runs one or more fixtures and verifies stable output. +- Consider committed golden transcript files only after the transcript shape has settled enough to avoid churn. +- Keep fixture parsing focused on player/game input. Avoid describing authoritative positions, events, actor state, or world mutations in headless-only terms. +- If richer setup is needed, add it to `src/sim` as explicit deterministic state/config, then let the harness pass that through. + +## Non-Goals + +- Do not replace `src/sim` tests with headless golden tests. +- Do not add renderer, Raylib, or game package dependencies to the harness. +- Do not make transcript output authoritative for replay. diff --git a/fixtures/headless/basic_run.txt b/fixtures/headless/basic_run.txt new file mode 100644 index 0000000..6b006d6 --- /dev/null +++ b/fixtures/headless/basic_run.txt @@ -0,0 +1,8 @@ +# Tiny deterministic run for the headless sim harness. +seed 12345 +ticks 1 move_down +ticks 1 move_up +ticks 30 move_right +ticks 10 move_up +ticks 5 move_up_right +ticks 1 attack diff --git a/justfile b/justfile index d5c8259..694a1de 100644 --- a/justfile +++ b/justfile @@ -4,12 +4,15 @@ game_pkg := "src/game" sim_pkg := "src/sim" hot_loader_pkg := "src/lib/hot_reload" release_pkg := "src/lib/release" +headless_pkg := "src/lib/headless" hot_dir := "build/hot_reload" release_dir := "build/release" +headless_dir := "build/headless" dll_ext := if os() == "windows" { ".dll" } else if os() == "macos" { ".dylib" } else { ".so" } exe_ext := if os() == "windows" { ".exe" } else { ".bin" } hot_exe := project + "_hot_reload" + exe_ext release_exe := release_dir + "/" + project + exe_ext +headless_exe := headless_dir + "/" + project + "_headless" + exe_ext raylib_dir := if os() == "macos" { odin_root + "/vendor/raylib/macos" } else if os() == "linux" { odin_root + "/vendor/raylib/linux" } else { odin_root + "/vendor/raylib/windows" } linker_flags := if os() == "windows" { "" } else { "-Wl,-rpath " + raylib_dir } @@ -23,6 +26,7 @@ check: test-sim odin check {{ game_pkg }} -strict-style -vet -no-entry-point odin check {{ hot_loader_pkg }} -strict-style -vet odin check {{ release_pkg }} -strict-style -vet + odin check {{ headless_pkg }} -strict-style -vet # Run deterministic simulation tests without Raylib test-sim: @@ -46,6 +50,15 @@ build: build-game build-loader: odin build {{ hot_loader_pkg }} -out:{{ hot_exe }} -strict-style -vet -debug +# Build the headless simulation harness +build-headless: + @mkdir -p {{ headless_dir }} + odin build {{ headless_pkg }} -out:{{ headless_exe }} -strict-style -vet -debug + +# Run a deterministic simulation fixture without Raylib +headless fixture="fixtures/headless/basic_run.txt": build-headless + ./{{ headless_exe }} {{ fixture }} + # Build and run the hot reload template run: build-game build-loader ./{{ hot_exe }} diff --git a/src/lib/headless/main.odin b/src/lib/headless/main.odin new file mode 100644 index 0000000..5fa63dc --- /dev/null +++ b/src/lib/headless/main.odin @@ -0,0 +1,215 @@ +package main + +import simulation "../../sim" +import "core:fmt" +import "core:os" +import "core:strconv" +import "core:strings" + +DEFAULT_FIXTURE_PATH :: "fixtures/headless/basic_run.txt" + +// A script batch is only authoring shorthand. `ticks 30 move_right` keeps fixtures +// readable, but run_script still expands it into 30 recorded simulation ticks. +Script_Batch :: struct { + tick_count: u64, + command: simulation.Input_Command, +} + +// The harness owns terminal-facing script shape. The simulation owns what a seed, +// command, tick, replay, and digest mean. +Script :: struct { + seed: u64, + batches: [dynamic]Script_Batch, +} + +main :: proc() { + exit_code := run() + free_all(context.temp_allocator) + if exit_code != 0 { + os.exit(exit_code) + } +} + +run :: proc() -> int { + fixture_path := DEFAULT_FIXTURE_PATH + if len(os.args) > 2 { + fmt.printfln("usage: %s [fixture-path]", os.args[0]) + return 1 + } + if len(os.args) == 2 { + fixture_path = string(os.args[1]) + } + + script: Script + defer script_destroy(&script) + if !parse_script(fixture_path, &script) { + return 1 + } + if !run_script(&script) { + return 1 + } + return 0 +} + +script_destroy :: proc(script: ^Script) { + delete(script.batches) + script^ = {} +} + +parse_script :: proc(path: string, script: ^Script) -> bool { + data, read_err := os.read_entire_file(path, context.allocator) + if read_err != nil { + fmt.printfln("error: failed to read fixture %s: %v", path, read_err) + return false + } + defer delete(data) + + // Defaulting the seed keeps one-line experiments possible while still making + // explicit seeds the normal fixture style. + script^ = Script { + seed = simulation.DEFAULT_WORLD_SEED, + batches = make([dynamic]Script_Batch), + } + + line_number := 0 + text := string(data) + for raw_line in strings.split_lines_iterator(&text) { + line_number += 1 + line := strings.trim_space(raw_line) + if line == "" || strings.has_prefix(line, "#") { + continue + } + + fields := strings.fields(line, context.temp_allocator) + if len(fields) == 0 { + continue + } + + // The language stays deliberately tiny: fixture lines describe seed and held + // input. They do not describe positions, events, or world mutations. + switch fields[0] { + case "seed": + if len(fields) != 2 { + fmt.printfln("error: %s:%d: seed expects 1 argument", path, line_number) + return false + } + seed, ok := strconv.parse_u64_of_base(fields[1], 10) + if !ok { + fmt.printfln("error: %s:%d: invalid seed %q", path, line_number, fields[1]) + return false + } + script.seed = seed + + case "ticks": + if len(fields) != 3 { + fmt.printfln("error: %s:%d: ticks expects count and command", path, line_number) + return false + } + tick_count, count_ok := strconv.parse_u64_of_base(fields[1], 10) + if !count_ok || tick_count == 0 { + fmt.printfln("error: %s:%d: invalid tick count %q", path, line_number, fields[1]) + return false + } + + command, command_ok := command_from_verb(fields[2]) + if !command_ok { + fmt.printfln("error: %s:%d: unknown command %q", path, line_number, fields[2]) + return false + } + append(&script.batches, Script_Batch{tick_count = tick_count, command = command}) + + case: + fmt.printfln("error: %s:%d: unknown directive %q", path, line_number, fields[0]) + return false + } + } + + if len(script.batches) == 0 { + fmt.printfln("error: %s: fixture has no tick batches", path) + return false + } + return true +} + +command_from_verb :: proc(verb: string) -> (simulation.Input_Command, bool) { + // Movement verbs go through the same digital-input normalization used by the + // Raylib path. Headless maps names to inputs; sim decides the canonical command. + switch verb { + case "idle": + return {}, true + case "move_left": + return simulation.input_command_from_digital(true, false, false, false), true + case "move_right": + return simulation.input_command_from_digital(false, true, false, false), true + case "move_up": + return simulation.input_command_from_digital(false, false, true, false), true + case "move_down": + return simulation.input_command_from_digital(false, false, false, true), true + case "move_up_left": + return simulation.input_command_from_digital(true, false, true, false), true + case "move_up_right": + return simulation.input_command_from_digital(false, true, true, false), true + case "move_down_left": + return simulation.input_command_from_digital(true, false, false, true), true + case "move_down_right": + return simulation.input_command_from_digital(false, true, false, true), true + case "attack": + return simulation.input_command_from_digital(false, false, false, false, true), true + } + return {}, false +} + +run_script :: proc(script: ^Script) -> bool { + history: simulation.Run_History + simulation.run_history_init(&history, seed = script.seed) + defer simulation.run_history_destroy(&history) + + state: simulation.Sim_State + simulation.sim_init(&state, history.seed, history.initial_state) + + fmt.printfln("run seed %d", 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..