Current source: curriculum/arrowfall/checkpoints/07-final · Flecha 0.3.0 · 7/7.
The program at this checkpoint
Previously: Checkpoint 06 is a complete game with movement, collision, pursuit, HP, archery, and tests.
Checkpoint 06 is playable, but every pursuit tie chooses the same path, terminal redraws mix control bytes with evidence, and changing a run requires editing source. These are release-boundary problems rather than new game rules.
The final checkpoint adds an explicit seed, command-line options, plain output, focused tests, and a golden campaign. The goal is controlled variation: different seeds may choose different paths, while the same seed and input reproduce the same run.
Current limitation
Every pursuit tie is identical, interactive frames scroll, and changing a run requires editing source.
Required next behavior: Add variety without flaky tests: a chosen seed reproduces decisions, plain mode produces stable evidence, and bad options fail before launch.
Model and invariants
Seed Generator once inside Game, parse --seed/--no-clear separately, and keep engine/render pure. ANSI and plain modes share one frame.
Generator::fromSeed resolves an existing pursuit tie while keeping a run reproducible. Command-line options select the seed and plain rendering without changing engine rules.
Game owns both sourceSeed and Generator. Game::start initializes them from the same argument, so the displayed seed identifies the random stream that controls pursuit decisions. No renderer or CLI code draws random values.
Randomness is used only when horizontal and vertical distance are both nonzero. That is a genuine tie between two legal pursuit priorities. Generator::boolean may swap them, after which the existing collision-aware step logic remains unchanged.
Options separates process policy from engine policy. --seed selects reproducibility and --no-clear selects presentation; neither changes movement, damage, line of sight, or victory. Invalid options fail before Game starts.
The parser is stateful because --seed consumes the following argument. expectsSeed records that one-token context, while finish returns a complete Options value. An absent or empty seed uses the documented default; malformed non-empty values produce InvalidSeed.
Plain rendering is the test boundary. The renderer builds one frame from const Game state, and a golden test compares the exact map, status, seed, and important messages. ANSI screen clearing is confined to interactive presentation.
Unsafe code, network play, save files, and dynamic maps remain out of scope. None is required to qualify this terminal package, and adding them would obscure the deterministic engine boundary established here.
Implementation
The implementation is split into the changes below. Each excerpt is taken verbatim from the qualified checkpoint and is explained in the part of the data flow where it is used. The excerpts are not standalone programs; the complete compiled source is available after the run.
- Add seeded tie-breaking and display seed.
- Add CLI options and ANSI/plain presentation.
- Expand to eleven tests and full golden qualification.
The seed belongs to game state
The final Game stores the seed that explains a run and the generator that advances it. Both are initialized exactly once in Game::start alongside the rest of the authoritative state.
Excerpt: src/game.fl:99–123. The complete file appears in the source section below.
export structure Game { private Position player; private Position goblin; private bool goblinAlive; private i32 playerHealth; private u32 arrowCount; private u32 turnCount; private u64 sourceSeed; private Outcome gameOutcome; private generator::Generator random;};
Game Game::start(u64 seed) { return build Game { self.player = Position::at(1, 1); self.goblin = Position::at(7, 3); self.goblinAlive = true; self.playerHealth = 3; self.arrowCount = 3; self.turnCount = 0; self.sourceSeed = seed; self.gameOutcome = Outcome.Playing; self.random = generator::Generator::fromSeed(seed); };}sourceSeed remains unchanged for status and diagnostics. random is private mutable state whose sequence advances only when the engine resolves a pursuit tie.
Passing the seed into Game rather than using a global generator makes independent games reproducible and keeps tests free to choose their own streams.
Randomness resolves a real tie
The random decision is deliberately narrow. It runs only when both DX and DY are nonzero, where horizontal-first and vertical-first are alternative pursuit choices.
Excerpt: src/game.fl:210–224. The complete file appears in the source section below.
const i32 DX = self->player.x - self->goblin.x; const i32 DY = self->player.y - self->goblin.y; Direction horizontal = Direction.Right; Direction vertical = Direction.Down; if DX < 0 { horizontal = Direction.Left; } if DY < 0 { vertical = Direction.Up; } if DX != 0 and DY != 0 and self->random.boolean() { first := horizontal; horizontal = vertical; vertical = first; }A true result swaps the two preferred directions. The later tryGoblinStep calls still reject walls, the player cell, and the exit, so randomness chooses order but cannot bypass invariants.
Using a seeded generator does not promise identical behavior across unrelated implementations; within this qualified Flecha package, the committed seed, source, and tests define the reproducible run.
CLI policy is isolated
Options owns CLI defaults and Parser owns the temporary state needed while consuming arguments. This keeps process syntax out of Game and makes option failures testable before launch.
Excerpt: src/options.fl:1–70. The complete file appears in the source section below.
module options;
@imports :: lib::std::text::strings;
export const u64 DEFAULT_SEED = 20261105;
export error OptionError { InvalidOption, InvalidSeed}
export structure Options { bool clearFrames; u64 seed;};
export structure Parser { private Options selected; private bool expectsSeed;};
Options Options::defaults() { return build Options { self.clearFrames = true; self.seed = DEFAULT_SEED; };}
Parser Parser::start() { return build Parser { self.selected = Options::defaults(); self.expectsSeed = false; };}
error Parser::consume(&self, stringv value) { if self->expectsSeed and !starts_with_option(value) { self->expectsSeed = false; if value.len() == 0 { self->selected.seed = DEFAULT_SEED; return none; } seed, seedError := strings::stringv_to_int<u64>(value); if seedError != none { return OptionError.InvalidSeed; } self->selected.seed = seed; return none; }
self->expectsSeed = false; if value == "--no-clear" { self->selected.clearFrames = false; return none; } if value == "--seed" { self->selected.seed = DEFAULT_SEED; self->expectsSeed = true; return none; } return OptionError.InvalidOption;}
Options Parser::finish(&const self) { return self->selected;}
bool starts_with_option(stringv value) { return value.len() >= 2 and value[0] == '-' and value[1] == '-';}--no-clear changes only clearFrames. --seed sets expectsSeed, and the following non-option token is converted to u64. A new option where a value was expected is rejected as an option rather than silently accepted as a seed.
The default seed is a named constant used for absent or empty values. Centralizing it ensures the displayed default, tests, and recorded command all refer to the same run.
The frame is a golden contract
The render test treats the complete plain frame and event messages as public behavior. It starts a game with seed 42, draws from const state, and compares exact strings.
Excerpt: src/render_test.fl:1–24. The complete file appears in the source section below.
module render_test;
@imports :: root::game :: root::render :: lib::std::tests::asserts :: lib::std::tests::testing;
test renderer_and_event_messages_explain_the_run(&testing::UnitTest t) { state := game::Game::start(42); frame, frameError := render::draw(&state); const stringv EXPECTED = "#########\n#@......#\n#..###..#\n#......g#\n#..###..#\n#>......#\n#########\nHP: 3 Arrows: 3 Turn: 0 Seed: 42"; asserts::is_true( t, frameError == none and frame.view() == EXPECTED and render::player_message(game::PlayerEvent.ShotHit) == "Your arrow hits! The goblin falls." and render::player_message(game::PlayerEvent.ShotMiss) == "Your arrow misses; a wall stops it." and render::enemy_message(game::EnemyEvent.Threatened) == "The goblin closes in! Move or shoot before its next turn.", "the room, status, hit, miss, and danger messages are stable" );}The expected frame includes map cells, HP, arrows, turn, and seed. Separate message assertions protect the distinctions between hit, miss, and threat that the engine exposes through events.
This is intentionally stricter than checking a few substrings. A formatting change must update an explicit golden contract, while engine tests separately protect rules such as free walls, warning before damage, and shot outcomes.
Run and inspect the result
The command uses the checkpoint’s recorded input, so the behavior described above can be reproduced without manual timing.
longbow run tutorial/checkpoints/07-final -- --no-clear --seed 20261105 < tutorial/checkpoints/07-final/tests/input.txtRecorded standard input
sslssThe qualified command uses --no-clear --seed 20261105 and the recorded S, S, L, S, S input. The final frame reports that exact seed, 3 HP, 2 arrows, turn 5, and the escape messages without ANSI control bytes.
Running the same command twice must produce byte-identical stdout. Changing only the seed may change pursuit tie choices, but the selected seed remains visible and can reproduce the run.
Accessible frame transcript
##########.......##..###..##.......##..###..##@......##########HP: 3 Arrows: 2 Turn: 5 Seed: 20261105You reach the exit.You escaped Arrowfall!Complete recorded stdout
ARROWFALL — reach >, survive gMove Shoot W IA S D J K LH help | Q quit##########@......##..###..##......g##..###..##>......##########HP: 3 Arrows: 3 Turn: 0 Seed: 20261105command>##########.......##@.###.g##.......##..###..##>......##########HP: 3 Arrows: 3 Turn: 1 Seed: 20261105You move.The goblin moves closer.command>##########.......##..###..##@.....g##..###..##>......##########HP: 3 Arrows: 3 Turn: 2 Seed: 20261105You move.The goblin moves closer.command>##########.......##..###..##@......##..###..##>......##########HP: 3 Arrows: 2 Turn: 3 Seed: 20261105Your arrow hits! The goblin falls.command>##########.......##..###..##.......##@.###..##>......##########HP: 3 Arrows: 2 Turn: 4 Seed: 20261105You move.command>##########.......##..###..##.......##..###..##@......##########HP: 3 Arrows: 2 Turn: 5 Seed: 20261105You reach the exit.You escaped Arrowfall!What this checkpoint establishes
The final package has a deterministic engine boundary, explicit process options, eleven focused tests, exact golden output, and an optimized build path. Variation is controlled by input rather than hidden global state.
Arrowfall stops here as a small terminal program that can be checked, tested, built, run, and distributed. The complete snapshot below is the release candidate used by every command on this page.
Exercise
Diff two runs with the same seed: stdout matches byte for byte. Seed 42 is shown; --no-clear has no control bytes; invalid seeds never launch.
Complete source and verification
The sections below preserve the complete qualified source, the exact predecessor diff, and the command sequence used by CI. They are supporting evidence for the implementation explained above.
Complete tested source (12 files)
longbow.toml
[package]name = "arrowfall-step-07"version = "0.1.0"kind = "program"flecha = ">=0.3.0, <0.4.0"
[dependencies]longbow.lock
{ "schema": 1, "root": "root", "packages": [ { "id": "root", "identity": "local/arrowfall_step_07", "source": { "kind": "workspace", "path": "." }, "version": "0.1.0", "flecha": ">=0.3.0, <0.4.0", "dependencies": { "std": "toolchain-std" } }, { "id": "toolchain-std", "identity": "flecha/std", "source": { "kind": "toolchain" }, "dependencies": {} } ]}src/game.fl
module game;
@imports :: lib::std::random::generator;
const i32 WIDTH = 9;const i32 HEIGHT = 7;
export enum Direction { default Up, Left, Down, Right}
export enum Command { default MoveUp, MoveLeft, MoveDown, MoveRight, ShootUp, ShootLeft, ShootDown, ShootRight, Help, Quit}
export enum Outcome { default Playing, Won, Lost, Quit}
export enum PlayerEvent { default None, Moved, Wall, GoblinBlocks, ShotHit, ShotMiss, NoArrows, Help, Quit, Won}
export enum EnemyEvent { default None, Moved, Threatened, Attacked}
export structure Position { i32 x; i32 y;};
export structure TurnResult { PlayerEvent player; EnemyEvent enemy; bool spentTurn;};
Position Position::at(i32 x, i32 y) { return build Position { self.x = x; self.y = y; };}
bool Position::equals(&const self, &const Position other) { return self->x == other->x and self->y == other->y;}
Position Position::moved(&const self, Direction direction) { Position delta = match direction { Direction.Up => Position::at(0, -1), Direction.Left => Position::at(-1, 0), Direction.Down => Position::at(0, 1), Direction.Right => Position::at(1, 0), }; return Position::at(self->x + delta.x, self->y + delta.y);}
TurnResult TurnResult::make( PlayerEvent player, EnemyEvent enemy = EnemyEvent.None, bool spentTurn = false) { return build TurnResult { self.player = player; self.enemy = enemy; self.spentTurn = spentTurn; };}
export structure Game { private Position player; private Position goblin; private bool goblinAlive; private i32 playerHealth; private u32 arrowCount; private u32 turnCount; private u64 sourceSeed; private Outcome gameOutcome; private generator::Generator random;};
Game Game::start(u64 seed) { return build Game { self.player = Position::at(1, 1); self.goblin = Position::at(7, 3); self.goblinAlive = true; self.playerHealth = 3; self.arrowCount = 3; self.turnCount = 0; self.sourceSeed = seed; self.gameOutcome = Outcome.Playing; self.random = generator::Generator::fromSeed(seed); };}
TurnResult Game::apply(&self, Command command) { if self->gameOutcome != Outcome.Playing { return TurnResult::make(PlayerEvent.None); } if command == Command.Help { return TurnResult::make(PlayerEvent.Help); } if command == Command.Quit { self->gameOutcome = Outcome.Quit; return TurnResult::make(PlayerEvent.Quit); }
const bool DANGER_BEFORE = self->goblinAlive and adjacent(self->goblin, self->player); PlayerEvent action = PlayerEvent.None; if is_shot(command) { action = self->shoot(command_direction(command)); } else { action = self->movePlayer(command_direction(command)); } if action == PlayerEvent.Wall or action == PlayerEvent.GoblinBlocks or action == PlayerEvent.NoArrows { if !DANGER_BEFORE { return TurnResult::make(action); } self->turnCount++; return TurnResult::make(action, self->goblinTurn(true), true); }
self->turnCount++; if tile_at(self->player) == '>' { self->gameOutcome = Outcome.Won; return TurnResult::make(PlayerEvent.Won, EnemyEvent.None, true); } return TurnResult::make( action, self->goblinTurn(DANGER_BEFORE), true );}
private PlayerEvent Game::movePlayer(&self, Direction direction) { next := self->player.moved(direction); if !walkable(next) { return PlayerEvent.Wall; } if self->goblinAlive and next.equals(&self->goblin) { return PlayerEvent.GoblinBlocks; } self->player = next; return PlayerEvent.Moved;}
private PlayerEvent Game::shoot(&self, Direction direction) { if self->arrowCount == 0 { return PlayerEvent.NoArrows; } self->arrowCount--; cursor := self->player.moved(direction); while tile_at(cursor) != '#' { if self->goblinAlive and cursor.equals(&self->goblin) { self->goblinAlive = false; return PlayerEvent.ShotHit; } cursor = cursor.moved(direction); } return PlayerEvent.ShotMiss;}
private EnemyEvent Game::goblinTurn(&self, bool canAttack) { if !self->goblinAlive { return EnemyEvent.None; } if canAttack and adjacent(self->goblin, self->player) { self->playerHealth--; if self->playerHealth == 0 { self->gameOutcome = Outcome.Lost; } return EnemyEvent.Attacked; } if adjacent(self->goblin, self->player) { return EnemyEvent.Threatened; }
const i32 DX = self->player.x - self->goblin.x; const i32 DY = self->player.y - self->goblin.y; Direction horizontal = Direction.Right; Direction vertical = Direction.Down; if DX < 0 { horizontal = Direction.Left; } if DY < 0 { vertical = Direction.Up; } if DX != 0 and DY != 0 and self->random.boolean() { first := horizontal; horizontal = vertical; vertical = first; }
bool moved = false; if DX != 0 { moved = self->tryGoblinStep(horizontal); } if !moved and DY != 0 { moved = self->tryGoblinStep(vertical); } if moved and adjacent(self->goblin, self->player) { return EnemyEvent.Threatened; } if moved { return EnemyEvent.Moved; } return EnemyEvent.None;}
private bool Game::tryGoblinStep(&self, Direction direction) { next := self->goblin.moved(direction); if next.equals(&self->player) { return false; } if walkable(next) and tile_at(next) != '>' { self->goblin = next; return true; } return false;}
Position Game::playerPosition(&const self) { return self->player;}
Position Game::goblinPosition(&const self) { return self->goblin;}
bool Game::isGoblinAlive(&const self) { return self->goblinAlive;}
i32 Game::health(&const self) { return self->playerHealth;}
u32 Game::arrows(&const self) { return self->arrowCount;}
u32 Game::turn(&const self) { return self->turnCount;}
u64 Game::seed(&const self) { return self->sourceSeed;}
Outcome Game::outcome(&const self) { return self->gameOutcome;}
export char tile_at(Position position) { [63]char tiles = [ '#','#','#','#','#','#','#','#','#', '#','.','.','.','.','.','.','.','#', '#','.','.','#','#','#','.','.','#', '#','.','.','.','.','.','.','.','#', '#','.','.','#','#','#','.','.','#', '#','>','.','.','.','.','.','.','#', '#','#','#','#','#','#','#','#','#' ]; return tile_from_cells(tiles[:], position);}
char tile_from_cells([]const char tiles, Position position) { if position.x < 0 or position.x >= WIDTH or position.y < 0 or position.y >= HEIGHT { return '#'; } return tiles[(position.y * WIDTH + position.x).cast<usize>];}
bool walkable(Position position) { return tile_at(position) != '#';}
bool adjacent(Position left, Position right) { const i32 DX = left.x - right.x; const i32 DY = left.y - right.y; return (DX == 0 and (DY == 1 or DY == -1)) or (DY == 0 and (DX == 1 or DX == -1));}
bool is_shot(Command command) { return command == Command.ShootUp or command == Command.ShootLeft or command == Command.ShootDown or command == Command.ShootRight;}
Direction command_direction(Command command) { return match command { Command.MoveUp => Direction.Up, Command.MoveLeft => Direction.Left, Command.MoveDown => Direction.Down, Command.MoveRight => Direction.Right, Command.ShootUp => Direction.Up, Command.ShootLeft => Direction.Left, Command.ShootDown => Direction.Down, Command.ShootRight => Direction.Right, Command.Help => Direction.Up, Command.Quit => Direction.Up, };}src/game_test.fl
module game_test;
@imports :: root::game :: lib::std::tests::asserts :: lib::std::tests::testing;
game::TurnResult approach_goblin(&game::Game state) { state->apply(game::Command.MoveRight); state->apply(game::Command.MoveLeft); state->apply(game::Command.MoveRight); state->apply(game::Command.MoveLeft); state->apply(game::Command.MoveRight); state->apply(game::Command.MoveLeft); return state->apply(game::Command.MoveRight);}
test wall_bumps_and_help_are_free(&testing::UnitTest t) { state := game::Game::start(20261105); goblinBefore := state.goblinPosition(); wall := state.apply(game::Command.MoveUp); help := state.apply(game::Command.Help); goblinAfter := state.goblinPosition(); asserts::is_true( t, wall.player == game::PlayerEvent.Wall and !wall.spentTurn and help.player == game::PlayerEvent.Help and !help.spentTurn and state.turn() == 0 and goblinBefore.equals(&goblinAfter), "wall bumps and help neither spend a turn nor move the goblin" );}
test approach_warns_then_goblin_block_causes_damage(&testing::UnitTest t) { state := game::Game::start(20261105); warning := approach_goblin(&state); player := state.playerPosition(); goblin := state.goblinPosition(); const u32 TURN_BEFORE = state.turn(); blocked := state.apply(game::Command.MoveRight); asserts::is_true( t, warning.enemy == game::EnemyEvent.Threatened and player.x + 1 == goblin.x and player.y == goblin.y and state.health() == 2 and blocked.player == game::PlayerEvent.GoblinBlocks and blocked.enemy == game::EnemyEvent.Attacked and blocked.spentTurn and state.turn() == TURN_BEFORE + 1, "approach warns without damage, then blocking against the goblin hurts" );}
test shots_report_miss_hit_and_empty_quiver_without_underflow( &testing::UnitTest t) { missState := game::Game::start(20261105); miss := missState.apply(game::Command.ShootUp); hitState := game::Game::start(20261105); hitState.apply(game::Command.MoveDown); hitState.apply(game::Command.MoveDown); hit := hitState.apply(game::Command.ShootRight); hitState.apply(game::Command.ShootUp); hitState.apply(game::Command.ShootUp); const u32 TURN_BEFORE = hitState.turn(); empty := hitState.apply(game::Command.ShootUp); asserts::is_true( t, miss.player == game::PlayerEvent.ShotMiss and hit.player == game::PlayerEvent.ShotHit and !hitState.isGoblinAlive() and hitState.arrows() == 0 and empty.player == game::PlayerEvent.NoArrows and !empty.spentTurn and hitState.turn() == TURN_BEFORE, "shots distinguish miss, hit, and a free empty-quiver attempt" );}
test threatened_wall_and_empty_quiver_both_allow_the_attack( &testing::UnitTest t) { wallState := game::Game::start(20261105); approach_goblin(&wallState); wall := wallState.apply(game::Command.MoveUp);
emptyState := game::Game::start(20261105); emptyState.apply(game::Command.ShootUp); emptyState.apply(game::Command.ShootUp); emptyState.apply(game::Command.ShootUp); game::EnemyEvent approach = game::EnemyEvent.None; u32 steps = 0; while approach != game::EnemyEvent.Threatened and steps < 12 { game::Command movement = game::Command.MoveRight; if steps % 2 == 1 { movement = game::Command.MoveLeft; } turn := emptyState.apply(movement); approach = turn.enemy; steps++; } empty := emptyState.apply(game::Command.ShootUp); asserts::is_true( t, wall.player == game::PlayerEvent.Wall and wall.enemy == game::EnemyEvent.Attacked and wallState.health() == 2 and empty.player == game::PlayerEvent.NoArrows and empty.enemy == game::EnemyEvent.Attacked and emptyState.health() == 2, "under threat, a wall bump or empty quiver spends the turn and hurts" );}
test fleeing_or_killing_after_the_warning_avoids_damage(&testing::UnitTest t) { fleeState := game::Game::start(20261105); approach_goblin(&fleeState); flee := fleeState.apply(game::Command.MoveLeft); killState := game::Game::start(20261105); approach_goblin(&killState); kill := killState.apply(game::Command.ShootRight); asserts::is_true( t, flee.player == game::PlayerEvent.Moved and flee.enemy != game::EnemyEvent.Attacked and fleeState.health() == 3 and kill.player == game::PlayerEvent.ShotHit and kill.enemy == game::EnemyEvent.None and killState.health() == 3, "a successful escape or killing shot answers the warning safely" );}
test both_documented_routes_win(&testing::UnitTest t) { combat := game::Game::start(20261105); combat.apply(game::Command.MoveDown); combat.apply(game::Command.MoveDown); combat.apply(game::Command.ShootRight); combat.apply(game::Command.MoveDown); combatResult := combat.apply(game::Command.MoveDown); flee := game::Game::start(20261105); flee.apply(game::Command.MoveDown); flee.apply(game::Command.MoveDown); flee.apply(game::Command.MoveDown); flee.apply(game::Command.MoveDown); asserts::is_true( t, combatResult.player == game::PlayerEvent.Won and combat.outcome() == game::Outcome.Won and combat.turn() == 5 and flee.outcome() == game::Outcome.Won and flee.isGoblinAlive() and flee.health() == 3 and flee.turn() == 4, "combat and no-combat routes both reach the exit" );}src/input.fl
module input;
@imports :: root::game :: lib::std::text::{ascii, strings};
export error CommandError { InvalidCommand}
export (game::Command, error) parse(stringv text) { value := strings::trim_ascii(text); if value.len() != 1 { return (none, CommandError.InvalidCommand); } const char KEY = ascii::to_ascii_lowercase(value[0].cast<char>); if KEY == 'w' { return (game::Command.MoveUp, none); } if KEY == 'a' { return (game::Command.MoveLeft, none); } if KEY == 's' { return (game::Command.MoveDown, none); } if KEY == 'd' { return (game::Command.MoveRight, none); } if KEY == 'i' { return (game::Command.ShootUp, none); } if KEY == 'j' { return (game::Command.ShootLeft, none); } if KEY == 'k' { return (game::Command.ShootDown, none); } if KEY == 'l' { return (game::Command.ShootRight, none); } if KEY == 'h' { return (game::Command.Help, none); } if KEY == 'q' { return (game::Command.Quit, none); } return (none, CommandError.InvalidCommand);}
export (game::Command, error) read_command() { string line; inputError := stdin("command>", &line); if inputError != none { return (none, inputError); } return parse(line.view());}src/input_test.fl
module input_test;
@imports :: root::game :: root::input :: lib::std::tests::asserts :: lib::std::tests::testing;
test parser_supports_both_key_diamonds_and_free_invalid_input( &testing::UnitTest t) { move, moveError := input::parse(" W "); shot, shotError := input::parse("L"); downShot, downError := input::parse("k"); help, helpError := input::parse("H"); _, invalidError := input::parse("dance"); state := game::Game::start(1); asserts::is_true( t, moveError == none and move == game::Command.MoveUp and shotError == none and shot == game::Command.ShootRight and downError == none and downShot == game::Command.ShootDown and helpError == none and help == game::Command.Help and invalidError == input::CommandError.InvalidCommand and state.turn() == 0, "WASD and IJKL normalize once while invalid input never reaches the engine" );}src/main.fl
module main;
@imports :: root::game :: root::input :: root::options :: root::render :: lib::std::os::arguments;
i32 main() { values, argumentError := arguments::capture(); if argumentError != none { stdoutln("arrowfall: cannot read command-line options"); print_usage(); return 2; } parser := options::Parser::start(); error optionError = none; loop usize index = 1; index < values.len(); index++ { optionError = parser.consume(values[index].view()); if optionError != none { break; } } if optionError != none { if optionError == options::OptionError.InvalidSeed { stdoutln("arrowfall: seed must be a decimal u64"); } else { stdoutln("arrowfall: unknown option"); } print_usage(); return 2; } selected := parser.finish();
state := game::Game::start(selected.seed); game::PlayerEvent lastPlayer = game::PlayerEvent.None; game::EnemyEvent lastEnemy = game::EnemyEvent.None; bool invalidCommand = false; bool firstFrame = true; while state.outcome() == game::Outcome.Playing { frame, renderError := render::draw(&state); if renderError != none { stdoutln("Could not render the dungeon."); return 1; } present_frame( frame.view(), lastPlayer, lastEnemy, invalidCommand, selected.clearFrames, firstFrame ); firstFrame = false; lastPlayer = game::PlayerEvent.None; lastEnemy = game::EnemyEvent.None; invalidCommand = false;
command, commandError := input::read_command(); if !selected.clearFrames { stdoutln(); } if commandError == input::CommandError.InvalidCommand { invalidCommand = true; proceed; } if commandError != none { stdoutln("Input closed."); quitResult := state.apply(game::Command.Quit); lastPlayer = quitResult.player; proceed; } result := state.apply(command); lastPlayer = result.player; lastEnemy = result.enemy; }
finalFrame, finalError := render::draw(&state); if finalError == none { present_frame( finalFrame.view(), lastPlayer, lastEnemy, false, selected.clearFrames, false ); } if state.outcome() == game::Outcome.Won { stdoutln("You escaped Arrowfall!"); } else if state.outcome() == game::Outcome.Lost { stdoutln("The goblin caught you."); } else { stdoutln("You leave the dungeon for another day."); } return 0;}
void print_usage() { stdoutln("usage: arrowfall [--no-clear] [--seed <u64>]");}
void present_frame( stringv frame, game::PlayerEvent player, game::EnemyEvent enemy, bool invalidCommand, bool clear, bool first) { clear_terminal(clear); if clear or first { stdoutln("ARROWFALL — reach >, survive g"); stdoutln(render::player_message(game::PlayerEvent.Help)); } stdoutln(frame); playerText := render::player_message(player); enemyText := render::enemy_message(enemy); if playerText.len() > 0 { stdoutln(playerText); } if enemyText.len() > 0 { stdoutln(enemyText); } if invalidCommand { stdoutln("Unknown command. Type H for help. No turn spent."); }}
void clear_terminal(bool enabled) { if !enabled { return; } stdout(27.cast<u8>.cast<char>); stdout("[2J"); stdout(27.cast<u8>.cast<char>); stdout("[H");}src/options.fl
module options;
@imports :: lib::std::text::strings;
export const u64 DEFAULT_SEED = 20261105;
export error OptionError { InvalidOption, InvalidSeed}
export structure Options { bool clearFrames; u64 seed;};
export structure Parser { private Options selected; private bool expectsSeed;};
Options Options::defaults() { return build Options { self.clearFrames = true; self.seed = DEFAULT_SEED; };}
Parser Parser::start() { return build Parser { self.selected = Options::defaults(); self.expectsSeed = false; };}
error Parser::consume(&self, stringv value) { if self->expectsSeed and !starts_with_option(value) { self->expectsSeed = false; if value.len() == 0 { self->selected.seed = DEFAULT_SEED; return none; } seed, seedError := strings::stringv_to_int<u64>(value); if seedError != none { return OptionError.InvalidSeed; } self->selected.seed = seed; return none; }
self->expectsSeed = false; if value == "--no-clear" { self->selected.clearFrames = false; return none; } if value == "--seed" { self->selected.seed = DEFAULT_SEED; self->expectsSeed = true; return none; } return OptionError.InvalidOption;}
Options Parser::finish(&const self) { return self->selected;}
bool starts_with_option(stringv value) { return value.len() >= 2 and value[0] == '-' and value[1] == '-';}src/options_test.fl
module options_test;
@imports :: root::options :: lib::std::tests::asserts :: lib::std::tests::testing;
test seed_defaults_for_every_empty_form(&testing::UnitTest t) { absentParser := options::Parser::start(); emptyParser := options::Parser::start(); emptyError := emptyParser.consume("--seed"); emptyError = emptyParser.consume(""); missingParser := options::Parser::start(); missingError := missingParser.consume("--seed"); optionParser := options::Parser::start(); followedError := optionParser.consume("--seed"); followedError = optionParser.consume("--no-clear"); absent := absentParser.finish(); empty := emptyParser.finish(); missing := missingParser.finish(); followed := optionParser.finish(); asserts::is_true( t, emptyError == none and missingError == none and followedError == none and absent.seed == options::DEFAULT_SEED and empty.seed == options::DEFAULT_SEED and missing.seed == options::DEFAULT_SEED and followed.seed == options::DEFAULT_SEED and !followed.clearFrames, "absent, empty, missing, or option-followed seeds use the default" );}
test seed_and_plain_mode_accept_either_order(&testing::UnitTest t) { firstParser := options::Parser::start(); error firstError = firstParser.consume("--seed"); firstError = firstParser.consume("42"); firstError = firstParser.consume("--no-clear"); secondParser := options::Parser::start(); error secondError = secondParser.consume("--no-clear"); secondError = secondParser.consume("--seed"); secondError = secondParser.consume("42"); first := firstParser.finish(); second := secondParser.finish(); asserts::is_true( t, firstError == none and secondError == none and first.seed == 42 and second.seed == 42 and !first.clearFrames and !second.clearFrames, "seed and no-clear compose in either order" );}
test invalid_seed_and_unknown_option_are_rejected(&testing::UnitTest t) { invalidParser := options::Parser::start(); invalidParser.consume("--seed"); invalidError := invalidParser.consume("twelve"); overflowParser := options::Parser::start(); overflowParser.consume("--seed"); overflowError := overflowParser.consume("18446744073709551616"); unknownParser := options::Parser::start(); unknownError := unknownParser.consume("--wat"); asserts::is_true( t, invalidError == options::OptionError.InvalidSeed and overflowError == options::OptionError.InvalidSeed and unknownError == options::OptionError.InvalidOption, "non-decimal, out-of-range, and unknown options fail explicitly" );}src/render.fl
module render;
@imports :: root::game :: lib::std::text::strings;
export (string, error) draw(&const game::Game state) { canvas, allocationError := strings::StringBuilder::withCapacity(160); if allocationError != none { return (none, allocationError); } player := state->playerPosition(); goblin := state->goblinPosition(); loop i32 y = 0; y < 7; y++ { loop i32 x = 0; x < 9; x++ { position := game::Position::at(x, y); char tile = game::tile_at(position); if position.equals(&player) { tile = '@'; } else if state->isGoblinAlive() and position.equals(&goblin) { tile = 'g'; } if canvas.appendChar(tile) != none { return (none, AllocationError.OutOfMemory); } } if canvas.appendChar('\n') != none { return (none, AllocationError.OutOfMemory); } } if canvas.append("HP: ") != none or canvas.appendInteger(state->health()) != none or canvas.append(" Arrows: ") != none or canvas.appendInteger(state->arrows()) != none or canvas.append(" Turn: ") != none or canvas.appendInteger(state->turn()) != none or canvas.append(" Seed: ") != none or canvas.appendInteger(state->seed()) != none { return (none, AllocationError.OutOfMemory); } return canvas.toString();}
export stringv player_message(game::PlayerEvent event) { if event == game::PlayerEvent.Moved { return "You move."; } if event == game::PlayerEvent.Wall { return "A wall blocks your way."; } if event == game::PlayerEvent.GoblinBlocks { return "The goblin blocks that tile."; } if event == game::PlayerEvent.ShotHit { return "Your arrow hits! The goblin falls."; } if event == game::PlayerEvent.ShotMiss { return "Your arrow misses; a wall stops it."; } if event == game::PlayerEvent.NoArrows { return "Your quiver is empty."; } if event == game::PlayerEvent.Help { return "Move Shoot\n W I\nA S D J K L\nH help | Q quit"; } if event == game::PlayerEvent.Quit { return "You lower your bow."; } if event == game::PlayerEvent.Won { return "You reach the exit."; } return "";}
export stringv enemy_message(game::EnemyEvent event) { if event == game::EnemyEvent.Moved { return "The goblin moves closer."; } if event == game::EnemyEvent.Threatened { return "The goblin closes in! Move or shoot before its next turn."; } if event == game::EnemyEvent.Attacked { return "The goblin attacks: -1 HP."; } return "";}src/render_test.fl
module render_test;
@imports :: root::game :: root::render :: lib::std::tests::asserts :: lib::std::tests::testing;
test renderer_and_event_messages_explain_the_run(&testing::UnitTest t) { state := game::Game::start(42); frame, frameError := render::draw(&state); const stringv EXPECTED = "#########\n#@......#\n#..###..#\n#......g#\n#..###..#\n#>......#\n#########\nHP: 3 Arrows: 3 Turn: 0 Seed: 42"; asserts::is_true( t, frameError == none and frame.view() == EXPECTED and render::player_message(game::PlayerEvent.ShotHit) == "Your arrow hits! The goblin falls." and render::player_message(game::PlayerEvent.ShotMiss) == "Your arrow misses; a wall stops it." and render::enemy_message(game::EnemyEvent.Threatened) == "The goblin closes in! Move or shoot before its next turn.", "the room, status, hit, miss, and danger messages are stable" );}scripts/check-golden.sh
#!/usr/bin/env bashset -euo pipefail
project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"longbow_bin="${LONGBOW:-longbow}"cd "$project_root"
check_transcript() { local name="$1" local input="$2" local expected="$3" local actual actual="$(mktemp)" trap 'rm -f "$actual"' RETURN "$longbow_bin" run -- --no-clear --seed 20261105 < "$input" > "$actual" diff -u "$expected" "$actual" if od -An -tu1 "$actual" | awk ' { for (i = 1; i <= NF; i++) if ($i < 32 && $i != 10) found = 1 } END { exit found ? 0 : 1 } '; then echo "$name transcript contains a control byte" >&2 return 1 fi echo "$name transcript: OK"}
check_transcript "winning" tests/input.txt tests/output.txtcheck_transcript "defeat" tests/defeat_input.txt tests/defeat_output.txt
visible_damage="$(mktemp)"trap 'rm -f "$visible_damage"' EXIT"$longbow_bin" run -- --seed 20261105 < tests/defeat_input.txt > "$visible_damage"awk ' BEGIN { escape = sprintf("%c", 27) RS = escape "\\[2J" escape "\\[H" } index($0, "HP: 2") && index($0, "The goblin attacks: -1 HP.") { visible = 1 } END { exit visible ? 0 : 1 }' "$visible_damage"echo "clear-screen damage frame: OK"
check_seed() { local name="$1" local expected="$2" shift 2 local actual actual="$(mktemp)" trap 'rm -f "$actual"' RETURN printf 'q\n' | "$longbow_bin" run -- "$@" > "$actual" grep -F "Seed: $expected" "$actual" > /dev/null echo "$name seed: OK"}
check_seed "absent" 20261105 --no-clearcheck_seed "explicit empty" 20261105 --no-clear --seed ""check_seed "missing value" 20261105 --no-clear --seedcheck_seed "option follows" 20261105 --seed --no-clearcheck_seed "explicit value" 42 --seed 42 --no-clearcheck_seed "reverse option order" 42 --no-clear --seed 42
check_seed_error() { local name="$1" local value="$2" local actual actual="$(mktemp)" trap 'rm -f "$actual"' RETURN if "$longbow_bin" run -- --seed "$value" > "$actual"; then echo "$name unexpectedly launched" >&2 return 1 fi grep -F "arrowfall: seed must be a decimal u64" "$actual" > /dev/null grep -F "usage: arrowfall [--no-clear] [--seed <u64>]" "$actual" > /dev/null if grep -F "ARROWFALL" "$actual" > /dev/null; then echo "$name launched the game" >&2 return 1 fi echo "$name seed error: OK"}
check_seed_error "invalid" "twelve"check_seed_error "out-of-range" "18446744073709551616"Exact diff from the preceding version
diff -rN -U0 --exclude target tutorial/checkpoints/06-archery/README.md tutorial/checkpoints/07-final/README.md--- tutorial/checkpoints/06-archery/README.md+++ tutorial/checkpoints/07-final/README.md@@ -1 +1 @@-# 06 — The archer earns the name+# 07 — Reproducible, testable, terminal-ready@@ -5 +5,3 @@-Checkpoint 05 adds pressure and fair warning, but the only response is to run.+Checkpoint 06 is a complete small game, but pursuit always makes the same+choice, transcripts scroll interactively, and changing behaviour requires+editing source.@@ -9,2 +11,3 @@-An archer should trade a scarce resource for safety. The existing map already-makes direction and line of sight meaningful.+Different runs are fun; flaky lessons and tests are not. We need controlled+variation, a clean terminal presentation, and a release-quality verification+boundary without changing the pure engine/render split.@@ -14,4 +17,5 @@-I/J/K/L shoots up/left/down/right. A shot consumes one of three arrows, walks-cell by cell until a wall, and kills the goblin if the line crosses it. Hit,-miss, and empty-quiver outcomes are distinct; twin control diamonds make both-directional action families visible.+`Generator::fromSeed` breaks pursuit ties reproducibly. `--seed <u64>` lets the+player select a run, defaulting empty/absent values to `20261105`.+Interactive mode redraws with isolated ANSI; `--no-clear` preserves accessible+goldens and logs. Eleven focused tests plus winning, defeat, option, no-control,+and visible-damage checks qualify the complete project.@@ -23,0 +28 @@+- `scripts/check-golden.sh`@@ -28,0 +34,2 @@+- `src/options.fl`+- `src/options_test.fl`@@ -29,0 +37 @@+- `src/render_test.fl`@@ -31,0 +40,2 @@+- `tests/defeat_input.txt`+- `tests/defeat_output.txt`@@ -35 +45,2 @@-Exact changes: `../../diffs/06-archery.patch`.+Exact changes: `../../diffs/07-final.patch`. This snapshot contains the full+final source; it does not depend on files above this directory.@@ -40,4 +51,11 @@-longbow check tutorial/checkpoints/06-archery-longbow test tutorial/checkpoints/06-archery-longbow build tutorial/checkpoints/06-archery-longbow run tutorial/checkpoints/06-archery < tutorial/checkpoints/06-archery/tests/input.txt+longbow check tutorial/checkpoints/07-final+longbow test tutorial/checkpoints/07-final+longbow build tutorial/checkpoints/07-final+longbow run tutorial/checkpoints/07-final -- --no-clear --seed 20261105 < tutorial/checkpoints/07-final/tests/input.txt+LONGBOW=longbow tutorial/checkpoints/07-final/scripts/check-golden.sh+```++Try controlled variation:++```sh+longbow run tutorial/checkpoints/07-final -- --seed 42@@ -48,6 +66,8 @@-1. A room appears.-2. The picture becomes state.-3. The player moves.-4. Walls create collision and victory.-5. A goblin creates turns, threat, and defeat.-6. **The archer earns the name** — IJKL, arrows, line of sight, combat win.+1. A room appears — static scene.+2. The picture becomes state — structs and methods.+3. The player moves — stdin and typed commands.+4. Walls create collision and victory — map, modules, first test.+5. A goblin creates turn pressure — threat, HP, observable events.+6. The archer earns the name — IJKL, inventory, line of sight.+7. **Reproducible and terminal-ready** — seeded RNG, CLI options, ANSI/plain+ modes, persistent feedback, full rule and golden qualification.@@ -57,2 +77,3 @@-The recorded `S S L S S` run must kill the goblin on turn 3 and reach the exit-on turn 5 with 3 HP and 2 arrows. An upward shot from start must miss.+Run the same seed twice: stdout and decisions must match. Run seed 42 and+observe the displayed seed and possibly different tie choices. `--no-clear`+must contain no control bytes, and invalid non-empty seeds must not launch.diff -rN -U0 --exclude target tutorial/checkpoints/06-archery/longbow.lock tutorial/checkpoints/07-final/longbow.lock--- tutorial/checkpoints/06-archery/longbow.lock+++ tutorial/checkpoints/07-final/longbow.lock@@ -7 +7 @@- "identity": "local/arrowfall_step_06",+ "identity": "local/arrowfall_step_07",diff -rN -U0 --exclude target tutorial/checkpoints/06-archery/longbow.toml tutorial/checkpoints/07-final/longbow.toml--- tutorial/checkpoints/06-archery/longbow.toml+++ tutorial/checkpoints/07-final/longbow.toml@@ -2 +2 @@-name = "arrowfall-step-06"+name = "arrowfall-step-07"diff -rN -U0 --exclude target tutorial/checkpoints/06-archery/scripts/check-golden.sh tutorial/checkpoints/07-final/scripts/check-golden.sh--- tutorial/checkpoints/06-archery/scripts/check-golden.sh+++ tutorial/checkpoints/07-final/scripts/check-golden.sh@@ -0,0 +1,84 @@+#!/usr/bin/env bash+set -euo pipefail++project_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"+longbow_bin="${LONGBOW:-longbow}"+cd "$project_root"++check_transcript() {+ local name="$1"+ local input="$2"+ local expected="$3"+ local actual+ actual="$(mktemp)"+ trap 'rm -f "$actual"' RETURN+ "$longbow_bin" run -- --no-clear --seed 20261105 < "$input" > "$actual"+ diff -u "$expected" "$actual"+ if od -An -tu1 "$actual" | awk '+ { for (i = 1; i <= NF; i++) if ($i < 32 && $i != 10) found = 1 }+ END { exit found ? 0 : 1 }+ '; then+ echo "$name transcript contains a control byte" >&2+ return 1+ fi+ echo "$name transcript: OK"+}++check_transcript "winning" tests/input.txt tests/output.txt+check_transcript "defeat" tests/defeat_input.txt tests/defeat_output.txt++visible_damage="$(mktemp)"+trap 'rm -f "$visible_damage"' EXIT+"$longbow_bin" run -- --seed 20261105 < tests/defeat_input.txt > "$visible_damage"+awk '+ BEGIN {+ escape = sprintf("%c", 27)+ RS = escape "\\[2J" escape "\\[H"+ }+ index($0, "HP: 2") && index($0, "The goblin attacks: -1 HP.") {+ visible = 1+ }+ END { exit visible ? 0 : 1 }+' "$visible_damage"+echo "clear-screen damage frame: OK"++check_seed() {+ local name="$1"+ local expected="$2"+ shift 2+ local actual+ actual="$(mktemp)"+ trap 'rm -f "$actual"' RETURN+ printf 'q\n' | "$longbow_bin" run -- "$@" > "$actual"+ grep -F "Seed: $expected" "$actual" > /dev/null+ echo "$name seed: OK"+}++check_seed "absent" 20261105 --no-clear+check_seed "explicit empty" 20261105 --no-clear --seed ""+check_seed "missing value" 20261105 --no-clear --seed+check_seed "option follows" 20261105 --seed --no-clear+check_seed "explicit value" 42 --seed 42 --no-clear+check_seed "reverse option order" 42 --no-clear --seed 42++check_seed_error() {+ local name="$1"+ local value="$2"+ local actual+ actual="$(mktemp)"+ trap 'rm -f "$actual"' RETURN+ if "$longbow_bin" run -- --seed "$value" > "$actual"; then+ echo "$name unexpectedly launched" >&2+ return 1+ fi+ grep -F "arrowfall: seed must be a decimal u64" "$actual" > /dev/null+ grep -F "usage: arrowfall [--no-clear] [--seed <u64>]" "$actual" > /dev/null+ if grep -F "ARROWFALL" "$actual" > /dev/null; then+ echo "$name launched the game" >&2+ return 1+ fi+ echo "$name seed error: OK"+}++check_seed_error "invalid" "twelve"+check_seed_error "out-of-range" "18446744073709551616"diff -rN -U0 --exclude target tutorial/checkpoints/06-archery/src/game.fl tutorial/checkpoints/07-final/src/game.fl--- tutorial/checkpoints/06-archery/src/game.fl+++ tutorial/checkpoints/07-final/src/game.fl@@ -2,0 +3,2 @@+@imports :: lib::std::random::generator;+@@ -42,2 +44,2 @@- Won,- Quit+ Quit,+ Won@@ -64,22 +65,0 @@-export structure Game {- private Position player;- private Position goblin;- private bool goblinAlive;- private i32 playerHealth;- private u32 arrowCount;- private u32 turnCount;- private Outcome gameOutcome;-};--TurnResult TurnResult::make(- PlayerEvent player,- EnemyEvent enemy = EnemyEvent.None,- bool spentTurn = false-) {- return build TurnResult {- self.player = player;- self.enemy = enemy;- self.spentTurn = spentTurn;- };-}-@@ -107 +87,25 @@-Game Game::start() {+TurnResult TurnResult::make(+ PlayerEvent player,+ EnemyEvent enemy = EnemyEvent.None,+ bool spentTurn = false+) {+ return build TurnResult {+ self.player = player;+ self.enemy = enemy;+ self.spentTurn = spentTurn;+ };+}++export structure Game {+ private Position player;+ private Position goblin;+ private bool goblinAlive;+ private i32 playerHealth;+ private u32 arrowCount;+ private u32 turnCount;+ private u64 sourceSeed;+ private Outcome gameOutcome;+ private generator::Generator random;+};++Game Game::start(u64 seed) {@@ -114,0 +119 @@+ self.sourceSeed = seed;@@ -115,0 +121 @@+ self.random = generator::Generator::fromSeed(seed);@@ -119,0 +126,3 @@+ if self->gameOutcome != Outcome.Playing {+ return TurnResult::make(PlayerEvent.None);+ }@@ -126,0 +136 @@+@@ -142,5 +152 @@- return TurnResult::make(- action,- self->goblinTurn(true),- true- );+ return TurnResult::make(action, self->goblinTurn(true), true);@@ -147,0 +154 @@+@@ -162 +169 @@- if tile_at(next) == '#' {+ if !walkable(next) {@@ -201,0 +209 @@+@@ -211,0 +220,6 @@+ if DX != 0 and DY != 0 and self->random.boolean() {+ first := horizontal;+ horizontal = vertical;+ vertical = first;+ }+@@ -233 +247 @@- if tile_at(next) != '#' and tile_at(next) != '>' {+ if walkable(next) and tile_at(next) != '>' {@@ -263,0 +278,4 @@+u64 Game::seed(&const self) {+ return self->sourceSeed;+}+@@ -288,0 +307,16 @@+bool walkable(Position position) {+ return tile_at(position) != '#';+}++bool adjacent(Position left, Position right) {+ const i32 DX = left.x - right.x;+ const i32 DY = left.y - right.y;+ return (DX == 0 and (DY == 1 or DY == -1)) or+ (DY == 0 and (DX == 1 or DX == -1));+}++bool is_shot(Command command) {+ return command == Command.ShootUp or command == Command.ShootLeft or+ command == Command.ShootDown or command == Command.ShootRight;+}+@@ -302,12 +335,0 @@-}--bool is_shot(Command command) {- return command == Command.ShootUp or command == Command.ShootLeft or- command == Command.ShootDown or command == Command.ShootRight;-}--bool adjacent(Position left, Position right) {- const i32 DX = left.x - right.x;- const i32 DY = left.y - right.y;- return (DX == 0 and (DY == 1 or DY == -1)) or- (DY == 0 and (DX == 1 or DX == -1));diff -rN -U0 --exclude target tutorial/checkpoints/06-archery/src/game_test.fl tutorial/checkpoints/07-final/src/game_test.fl--- tutorial/checkpoints/06-archery/src/game_test.fl+++ tutorial/checkpoints/07-final/src/game_test.fl@@ -8,2 +8,13 @@-test safe_wall_is_free(&testing::UnitTest t) {- state := game::Game::start();+game::TurnResult approach_goblin(&game::Game state) {+ state->apply(game::Command.MoveRight);+ state->apply(game::Command.MoveLeft);+ state->apply(game::Command.MoveRight);+ state->apply(game::Command.MoveLeft);+ state->apply(game::Command.MoveRight);+ state->apply(game::Command.MoveLeft);+ return state->apply(game::Command.MoveRight);+}++test wall_bumps_and_help_are_free(&testing::UnitTest t) {+ state := game::Game::start(20261105);+ goblinBefore := state.goblinPosition();@@ -10,0 +22,2 @@+ help := state.apply(game::Command.Help);+ goblinAfter := state.goblinPosition();@@ -14,2 +27,3 @@- state.turn() == 0,- "a safe wall bump does not advance the goblin"+ help.player == game::PlayerEvent.Help and !help.spentTurn and+ state.turn() == 0 and goblinBefore.equals(&goblinAfter),+ "wall bumps and help neither spend a turn nor move the goblin"@@ -19,10 +33,7 @@-test approach_warns_before_a_later_attack(&testing::UnitTest t) {- state := game::Game::start();- state.apply(game::Command.MoveRight);- state.apply(game::Command.MoveLeft);- state.apply(game::Command.MoveRight);- state.apply(game::Command.MoveLeft);- state.apply(game::Command.MoveRight);- state.apply(game::Command.MoveLeft);- warning := state.apply(game::Command.MoveDown);- blocked := state.apply(game::Command.MoveDown);+test approach_warns_then_goblin_block_causes_damage(&testing::UnitTest t) {+ state := game::Game::start(20261105);+ warning := approach_goblin(&state);+ player := state.playerPosition();+ goblin := state.goblinPosition();+ const u32 TURN_BEFORE = state.turn();+ blocked := state.apply(game::Command.MoveRight);@@ -31,0 +43,3 @@+ player.x + 1 == goblin.x and player.y == goblin.y and+ state.health() == 2 and+ blocked.player == game::PlayerEvent.GoblinBlocks and@@ -33,2 +47,98 @@- state.health() == 2,- "the first adjacency warns and a later failed response takes one HP"+ blocked.spentTurn and state.turn() == TURN_BEFORE + 1,+ "approach warns without damage, then blocking against the goblin hurts"+ );+}++test shots_report_miss_hit_and_empty_quiver_without_underflow(+ &testing::UnitTest t+) {+ missState := game::Game::start(20261105);+ miss := missState.apply(game::Command.ShootUp);+ hitState := game::Game::start(20261105);+ hitState.apply(game::Command.MoveDown);+ hitState.apply(game::Command.MoveDown);+ hit := hitState.apply(game::Command.ShootRight);+ hitState.apply(game::Command.ShootUp);+ hitState.apply(game::Command.ShootUp);+ const u32 TURN_BEFORE = hitState.turn();+ empty := hitState.apply(game::Command.ShootUp);+ asserts::is_true(+ t,+ miss.player == game::PlayerEvent.ShotMiss and+ hit.player == game::PlayerEvent.ShotHit and+ !hitState.isGoblinAlive() and hitState.arrows() == 0 and+ empty.player == game::PlayerEvent.NoArrows and+ !empty.spentTurn and hitState.turn() == TURN_BEFORE,+ "shots distinguish miss, hit, and a free empty-quiver attempt"+ );+}++test threatened_wall_and_empty_quiver_both_allow_the_attack(+ &testing::UnitTest t+) {+ wallState := game::Game::start(20261105);+ approach_goblin(&wallState);+ wall := wallState.apply(game::Command.MoveUp);++ emptyState := game::Game::start(20261105);+ emptyState.apply(game::Command.ShootUp);+ emptyState.apply(game::Command.ShootUp);+ emptyState.apply(game::Command.ShootUp);+ game::EnemyEvent approach = game::EnemyEvent.None;+ u32 steps = 0;+ while approach != game::EnemyEvent.Threatened and steps < 12 {+ game::Command movement = game::Command.MoveRight;+ if steps % 2 == 1 {+ movement = game::Command.MoveLeft;+ }+ turn := emptyState.apply(movement);+ approach = turn.enemy;+ steps++;+ }+ empty := emptyState.apply(game::Command.ShootUp);+ asserts::is_true(+ t,+ wall.player == game::PlayerEvent.Wall and+ wall.enemy == game::EnemyEvent.Attacked and wallState.health() == 2 and+ empty.player == game::PlayerEvent.NoArrows and+ empty.enemy == game::EnemyEvent.Attacked and emptyState.health() == 2,+ "under threat, a wall bump or empty quiver spends the turn and hurts"+ );+}++test fleeing_or_killing_after_the_warning_avoids_damage(&testing::UnitTest t) {+ fleeState := game::Game::start(20261105);+ approach_goblin(&fleeState);+ flee := fleeState.apply(game::Command.MoveLeft);+ killState := game::Game::start(20261105);+ approach_goblin(&killState);+ kill := killState.apply(game::Command.ShootRight);+ asserts::is_true(+ t,+ flee.player == game::PlayerEvent.Moved and+ flee.enemy != game::EnemyEvent.Attacked and fleeState.health() == 3 and+ kill.player == game::PlayerEvent.ShotHit and+ kill.enemy == game::EnemyEvent.None and killState.health() == 3,+ "a successful escape or killing shot answers the warning safely"+ );+}++test both_documented_routes_win(&testing::UnitTest t) {+ combat := game::Game::start(20261105);+ combat.apply(game::Command.MoveDown);+ combat.apply(game::Command.MoveDown);+ combat.apply(game::Command.ShootRight);+ combat.apply(game::Command.MoveDown);+ combatResult := combat.apply(game::Command.MoveDown);+ flee := game::Game::start(20261105);+ flee.apply(game::Command.MoveDown);+ flee.apply(game::Command.MoveDown);+ flee.apply(game::Command.MoveDown);+ flee.apply(game::Command.MoveDown);+ asserts::is_true(+ t,+ combatResult.player == game::PlayerEvent.Won and+ combat.outcome() == game::Outcome.Won and combat.turn() == 5 and+ flee.outcome() == game::Outcome.Won and flee.isGoblinAlive() and+ flee.health() == 3 and flee.turn() == 4,+ "combat and no-combat routes both reach the exit"diff -rN -U0 --exclude target tutorial/checkpoints/06-archery/src/input.fl tutorial/checkpoints/07-final/src/input.fl--- tutorial/checkpoints/06-archery/src/input.fl+++ tutorial/checkpoints/07-final/src/input.fl@@ -52 +52 @@- return (game::Command.Quit, none);+ return (none, inputError);diff -rN -U0 --exclude target tutorial/checkpoints/06-archery/src/input_test.fl tutorial/checkpoints/07-final/src/input_test.fl--- tutorial/checkpoints/06-archery/src/input_test.fl+++ tutorial/checkpoints/07-final/src/input_test.fl@@ -9,3 +9,9 @@-test direct_key_diamonds_parse(&testing::UnitTest t) {- move, moveError := input::parse("W");- shot, shotError := input::parse("l");+test parser_supports_both_key_diamonds_and_free_invalid_input(+ &testing::UnitTest t+) {+ move, moveError := input::parse(" W ");+ shot, shotError := input::parse("L");+ downShot, downError := input::parse("k");+ help, helpError := input::parse("H");+ _, invalidError := input::parse("dance");+ state := game::Game::start(1);@@ -15,2 +21,6 @@- shotError == none and shot == game::Command.ShootRight,- "WASD moves and IJKL shoots after one lowercase normalization"+ shotError == none and shot == game::Command.ShootRight and+ downError == none and downShot == game::Command.ShootDown and+ helpError == none and help == game::Command.Help and+ invalidError == input::CommandError.InvalidCommand and+ state.turn() == 0,+ "WASD and IJKL normalize once while invalid input never reaches the engine"diff -rN -U0 --exclude target tutorial/checkpoints/06-archery/src/main.fl tutorial/checkpoints/07-final/src/main.fl--- tutorial/checkpoints/06-archery/src/main.fl+++ tutorial/checkpoints/07-final/src/main.fl@@ -3 +3,2 @@-@imports :: root::game :: root::input :: root::render;+@imports :: root::game :: root::input :: root::options :: root::render+ :: lib::std::os::arguments;@@ -5,2 +6,31 @@-void main() {- state := game::Game::start();+i32 main() {+ values, argumentError := arguments::capture();+ if argumentError != none {+ stdoutln("arrowfall: cannot read command-line options");+ print_usage();+ return 2;+ }+ parser := options::Parser::start();+ error optionError = none;+ loop usize index = 1; index < values.len(); index++ {+ optionError = parser.consume(values[index].view());+ if optionError != none {+ break;+ }+ }+ if optionError != none {+ if optionError == options::OptionError.InvalidSeed {+ stdoutln("arrowfall: seed must be a decimal u64");+ } else {+ stdoutln("arrowfall: unknown option");+ }+ print_usage();+ return 2;+ }+ selected := parser.finish();++ state := game::Game::start(selected.seed);+ game::PlayerEvent lastPlayer = game::PlayerEvent.None;+ game::EnemyEvent lastEnemy = game::EnemyEvent.None;+ bool invalidCommand = false;+ bool firstFrame = true;@@ -8,4 +38,4 @@- frame, frameError := render::draw(&state);- if frameError != none {- stdoutln("Could not render the room.");- return;+ frame, renderError := render::draw(&state);+ if renderError != none {+ stdoutln("Could not render the dungeon.");+ return 1;@@ -13 +43,13 @@- stdoutln(frame);+ present_frame(+ frame.view(),+ lastPlayer,+ lastEnemy,+ invalidCommand,+ selected.clearFrames,+ firstFrame+ );+ firstFrame = false;+ lastPlayer = game::PlayerEvent.None;+ lastEnemy = game::EnemyEvent.None;+ invalidCommand = false;+@@ -15 +57,7 @@- stdoutln();+ if !selected.clearFrames {+ stdoutln();+ }+ if commandError == input::CommandError.InvalidCommand {+ invalidCommand = true;+ proceed;+ }@@ -17 +65,3 @@- stdoutln("Unknown command. No turn spent.");+ stdoutln("Input closed.");+ quitResult := state.apply(game::Command.Quit);+ lastPlayer = quitResult.player;@@ -21,5 +71,2 @@- stdoutln(render::player_message(result.player));- enemyText := render::enemy_message(result.enemy);- if enemyText.len() > 0 {- stdoutln(enemyText);- }+ lastPlayer = result.player;+ lastEnemy = result.enemy;@@ -26,0 +74 @@+@@ -29 +77,8 @@- stdoutln(finalFrame);+ present_frame(+ finalFrame.view(),+ lastPlayer,+ lastEnemy,+ false,+ selected.clearFrames,+ false+ );@@ -34,0 +90,40 @@+ } else {+ stdoutln("You leave the dungeon for another day.");+ }+ return 0;+}++void print_usage() {+ stdoutln("usage: arrowfall [--no-clear] [--seed <u64>]");+}++void present_frame(+ stringv frame,+ game::PlayerEvent player,+ game::EnemyEvent enemy,+ bool invalidCommand,+ bool clear,+ bool first+) {+ clear_terminal(clear);+ if clear or first {+ stdoutln("ARROWFALL — reach >, survive g");+ stdoutln(render::player_message(game::PlayerEvent.Help));+ }+ stdoutln(frame);+ playerText := render::player_message(player);+ enemyText := render::enemy_message(enemy);+ if playerText.len() > 0 {+ stdoutln(playerText);+ }+ if enemyText.len() > 0 {+ stdoutln(enemyText);+ }+ if invalidCommand {+ stdoutln("Unknown command. Type H for help. No turn spent.");+ }+}++void clear_terminal(bool enabled) {+ if !enabled {+ return;@@ -35,0 +131,4 @@+ stdout(27.cast<u8>.cast<char>);+ stdout("[2J");+ stdout(27.cast<u8>.cast<char>);+ stdout("[H");diff -rN -U0 --exclude target tutorial/checkpoints/06-archery/src/options.fl tutorial/checkpoints/07-final/src/options.fl--- tutorial/checkpoints/06-archery/src/options.fl+++ tutorial/checkpoints/07-final/src/options.fl@@ -0,0 +1,70 @@+module options;++@imports :: lib::std::text::strings;++export const u64 DEFAULT_SEED = 20261105;++export error OptionError {+ InvalidOption,+ InvalidSeed+}++export structure Options {+ bool clearFrames;+ u64 seed;+};++export structure Parser {+ private Options selected;+ private bool expectsSeed;+};++Options Options::defaults() {+ return build Options {+ self.clearFrames = true;+ self.seed = DEFAULT_SEED;+ };+}++Parser Parser::start() {+ return build Parser {+ self.selected = Options::defaults();+ self.expectsSeed = false;+ };+}++error Parser::consume(&self, stringv value) {+ if self->expectsSeed and !starts_with_option(value) {+ self->expectsSeed = false;+ if value.len() == 0 {+ self->selected.seed = DEFAULT_SEED;+ return none;+ }+ seed, seedError := strings::stringv_to_int<u64>(value);+ if seedError != none {+ return OptionError.InvalidSeed;+ }+ self->selected.seed = seed;+ return none;+ }++ self->expectsSeed = false;+ if value == "--no-clear" {+ self->selected.clearFrames = false;+ return none;+ }+ if value == "--seed" {+ self->selected.seed = DEFAULT_SEED;+ self->expectsSeed = true;+ return none;+ }+ return OptionError.InvalidOption;+}++Options Parser::finish(&const self) {+ return self->selected;+}++bool starts_with_option(stringv value) {+ return value.len() >= 2 and value[0] == '-' and value[1] == '-';+}diff -rN -U0 --exclude target tutorial/checkpoints/06-archery/src/options_test.fl tutorial/checkpoints/07-final/src/options_test.fl--- tutorial/checkpoints/06-archery/src/options_test.fl+++ tutorial/checkpoints/07-final/src/options_test.fl@@ -0,0 +1,69 @@+module options_test;++@imports+ :: root::options+ :: lib::std::tests::asserts+ :: lib::std::tests::testing;++test seed_defaults_for_every_empty_form(&testing::UnitTest t) {+ absentParser := options::Parser::start();+ emptyParser := options::Parser::start();+ emptyError := emptyParser.consume("--seed");+ emptyError = emptyParser.consume("");+ missingParser := options::Parser::start();+ missingError := missingParser.consume("--seed");+ optionParser := options::Parser::start();+ followedError := optionParser.consume("--seed");+ followedError = optionParser.consume("--no-clear");+ absent := absentParser.finish();+ empty := emptyParser.finish();+ missing := missingParser.finish();+ followed := optionParser.finish();+ asserts::is_true(+ t,+ emptyError == none and missingError == none and followedError == none and+ absent.seed == options::DEFAULT_SEED and+ empty.seed == options::DEFAULT_SEED and+ missing.seed == options::DEFAULT_SEED and+ followed.seed == options::DEFAULT_SEED and+ !followed.clearFrames,+ "absent, empty, missing, or option-followed seeds use the default"+ );+}++test seed_and_plain_mode_accept_either_order(&testing::UnitTest t) {+ firstParser := options::Parser::start();+ error firstError = firstParser.consume("--seed");+ firstError = firstParser.consume("42");+ firstError = firstParser.consume("--no-clear");+ secondParser := options::Parser::start();+ error secondError = secondParser.consume("--no-clear");+ secondError = secondParser.consume("--seed");+ secondError = secondParser.consume("42");+ first := firstParser.finish();+ second := secondParser.finish();+ asserts::is_true(+ t,+ firstError == none and secondError == none and first.seed == 42 and+ second.seed == 42 and !first.clearFrames and !second.clearFrames,+ "seed and no-clear compose in either order"+ );+}++test invalid_seed_and_unknown_option_are_rejected(&testing::UnitTest t) {+ invalidParser := options::Parser::start();+ invalidParser.consume("--seed");+ invalidError := invalidParser.consume("twelve");+ overflowParser := options::Parser::start();+ overflowParser.consume("--seed");+ overflowError := overflowParser.consume("18446744073709551616");+ unknownParser := options::Parser::start();+ unknownError := unknownParser.consume("--wat");+ asserts::is_true(+ t,+ invalidError == options::OptionError.InvalidSeed and+ overflowError == options::OptionError.InvalidSeed and+ unknownError == options::OptionError.InvalidOption,+ "non-decimal, out-of-range, and unknown options fail explicitly"+ );+}diff -rN -U0 --exclude target tutorial/checkpoints/06-archery/src/render.fl tutorial/checkpoints/07-final/src/render.fl--- tutorial/checkpoints/06-archery/src/render.fl+++ tutorial/checkpoints/07-final/src/render.fl@@ -6 +6 @@- canvas, allocationError := strings::StringBuilder::withCapacity(112);+ canvas, allocationError := strings::StringBuilder::withCapacity(160);@@ -34 +34,3 @@- canvas.appendInteger(state->turn()) != none {+ canvas.appendInteger(state->turn()) != none or+ canvas.append(" Seed: ") != none or+ canvas.appendInteger(state->seed()) != none {@@ -40,0 +43,3 @@+ if event == game::PlayerEvent.Moved {+ return "You move.";+ }@@ -42 +47 @@- return "A wall blocks your way. No turn spent.";+ return "A wall blocks your way.";@@ -58,0 +64,3 @@+ if event == game::PlayerEvent.Quit {+ return "You lower your bow.";+ }@@ -62,4 +70 @@- if event == game::PlayerEvent.Quit {- return "You leave the room.";- }- return "You move.";+ return "";@@ -73 +78 @@- return "The goblin closes in! Move before its next turn.";+ return "The goblin closes in! Move or shoot before its next turn.";diff -rN -U0 --exclude target tutorial/checkpoints/06-archery/src/render_test.fl tutorial/checkpoints/07-final/src/render_test.fl--- tutorial/checkpoints/06-archery/src/render_test.fl+++ tutorial/checkpoints/07-final/src/render_test.fl@@ -0,0 +1,24 @@+module render_test;++@imports+ :: root::game+ :: root::render+ :: lib::std::tests::asserts+ :: lib::std::tests::testing;++test renderer_and_event_messages_explain_the_run(&testing::UnitTest t) {+ state := game::Game::start(42);+ frame, frameError := render::draw(&state);+ const stringv EXPECTED = "#########\n#@......#\n#..###..#\n#......g#\n#..###..#\n#>......#\n#########\nHP: 3 Arrows: 3 Turn: 0 Seed: 42";+ asserts::is_true(+ t,+ frameError == none and frame.view() == EXPECTED and+ render::player_message(game::PlayerEvent.ShotHit) ==+ "Your arrow hits! The goblin falls." and+ render::player_message(game::PlayerEvent.ShotMiss) ==+ "Your arrow misses; a wall stops it." and+ render::enemy_message(game::EnemyEvent.Threatened) ==+ "The goblin closes in! Move or shoot before its next turn.",+ "the room, status, hit, miss, and danger messages are stable"+ );+}diff -rN -U0 --exclude target tutorial/checkpoints/06-archery/tests/defeat_input.txt tutorial/checkpoints/07-final/tests/defeat_input.txt--- tutorial/checkpoints/06-archery/tests/defeat_input.txt+++ tutorial/checkpoints/07-final/tests/defeat_input.txt@@ -0,0 +1,10 @@+d+a+d+a+d+a+d+d+d+ddiff -rN -U0 --exclude target tutorial/checkpoints/06-archery/tests/defeat_output.txt tutorial/checkpoints/07-final/tests/defeat_output.txt--- tutorial/checkpoints/06-archery/tests/defeat_output.txt+++ tutorial/checkpoints/07-final/tests/defeat_output.txt@@ -0,0 +1,124 @@+ARROWFALL — reach >, survive g+Move Shoot+ W I+A S D J K L+H help | Q quit+#########+#@......#+#..###..#+#......g#+#..###..#+#>......#+#########+HP: 3 Arrows: 3 Turn: 0 Seed: 20261105+command>+#########+#.@.....#+#..###.g#+#.......#+#..###..#+#>......#+#########+HP: 3 Arrows: 3 Turn: 1 Seed: 20261105+You move.+The goblin moves closer.+command>+#########+#@.....g#+#..###..#+#.......#+#..###..#+#>......#+#########+HP: 3 Arrows: 3 Turn: 2 Seed: 20261105+You move.+The goblin moves closer.+command>+#########+#.@...g.#+#..###..#+#.......#+#..###..#+#>......#+#########+HP: 3 Arrows: 3 Turn: 3 Seed: 20261105+You move.+The goblin moves closer.+command>+#########+#@...g..#+#..###..#+#.......#+#..###..#+#>......#+#########+HP: 3 Arrows: 3 Turn: 4 Seed: 20261105+You move.+The goblin moves closer.+command>+#########+#.@.g...#+#..###..#+#.......#+#..###..#+#>......#+#########+HP: 3 Arrows: 3 Turn: 5 Seed: 20261105+You move.+The goblin moves closer.+command>+#########+#@.g....#+#..###..#+#.......#+#..###..#+#>......#+#########+HP: 3 Arrows: 3 Turn: 6 Seed: 20261105+You move.+The goblin moves closer.+command>+#########+#.@g....#+#..###..#+#.......#+#..###..#+#>......#+#########+HP: 3 Arrows: 3 Turn: 7 Seed: 20261105+You move.+The goblin closes in! Move or shoot before its next turn.+command>+#########+#.@g....#+#..###..#+#.......#+#..###..#+#>......#+#########+HP: 2 Arrows: 3 Turn: 8 Seed: 20261105+The goblin blocks that tile.+The goblin attacks: -1 HP.+command>+#########+#.@g....#+#..###..#+#.......#+#..###..#+#>......#+#########+HP: 1 Arrows: 3 Turn: 9 Seed: 20261105+The goblin blocks that tile.+The goblin attacks: -1 HP.+command>+#########+#.@g....#+#..###..#+#.......#+#..###..#+#>......#+#########+HP: 0 Arrows: 3 Turn: 10 Seed: 20261105+The goblin blocks that tile.+The goblin attacks: -1 HP.+The goblin caught you.diff -rN -U0 --exclude target tutorial/checkpoints/06-archery/tests/frame.svg tutorial/checkpoints/07-final/tests/frame.svg--- tutorial/checkpoints/06-archery/tests/frame.svg+++ tutorial/checkpoints/07-final/tests/frame.svg@@ -2,2 +2,2 @@-<svg xmlns="http://www.w3.org/2000/svg" width="360" height="200" viewBox="0 0 360 200" role="img">- <title>06-archery representative frame</title>+<svg xmlns="http://www.w3.org/2000/svg" width="417" height="238" viewBox="0 0 417 238" role="img">+ <title>07-final representative frame</title>@@ -13 +13,3 @@- <text x="24" y="170" class="terminal">HP: 3 Arrows: 2 Turn: 5</text>+ <text x="24" y="170" class="terminal">HP: 3 Arrows: 2 Turn: 5 Seed: 20261105</text>+ <text x="24" y="189" class="terminal">You reach the exit.</text>+ <text x="24" y="208" class="terminal">You escaped Arrowfall!</text>diff -rN -U0 --exclude target tutorial/checkpoints/06-archery/tests/frame.txt tutorial/checkpoints/07-final/tests/frame.txt--- tutorial/checkpoints/06-archery/tests/frame.txt+++ tutorial/checkpoints/07-final/tests/frame.txt@@ -8 +8,3 @@-HP: 3 Arrows: 2 Turn: 5+HP: 3 Arrows: 2 Turn: 5 Seed: 20261105+You reach the exit.+You escaped Arrowfall!diff -rN -U0 --exclude target tutorial/checkpoints/06-archery/tests/output.txt tutorial/checkpoints/07-final/tests/output.txt--- tutorial/checkpoints/06-archery/tests/output.txt+++ tutorial/checkpoints/07-final/tests/output.txt@@ -0,0 +1,5 @@+ARROWFALL — reach >, survive g+Move Shoot+ W I+A S D J K L+H help | Q quit@@ -8 +13 @@-HP: 3 Arrows: 3 Turn: 0+HP: 3 Arrows: 3 Turn: 0 Seed: 20261105@@ -10,2 +14,0 @@-You move.-The goblin moves closer.@@ -14,2 +17,2 @@-#@.###..#-#.....g.#+#@.###.g#+#.......#@@ -19,2 +22 @@-HP: 3 Arrows: 3 Turn: 1-command>+HP: 3 Arrows: 3 Turn: 1 Seed: 20261105@@ -22,0 +25 @@+command>@@ -26 +29 @@-#@...g..#+#@.....g#@@ -30 +33,3 @@-HP: 3 Arrows: 3 Turn: 2+HP: 3 Arrows: 3 Turn: 2 Seed: 20261105+You move.+The goblin moves closer.@@ -32 +36,0 @@-Your arrow hits! The goblin falls.@@ -40 +44,2 @@-HP: 3 Arrows: 2 Turn: 3+HP: 3 Arrows: 2 Turn: 3 Seed: 20261105+Your arrow hits! The goblin falls.@@ -42 +46,0 @@-You move.@@ -50 +54,2 @@-HP: 3 Arrows: 2 Turn: 4+HP: 3 Arrows: 2 Turn: 4 Seed: 20261105+You move.@@ -52 +56,0 @@-You reach the exit.@@ -60 +64,2 @@-HP: 3 Arrows: 2 Turn: 5+HP: 3 Arrows: 2 Turn: 5 Seed: 20261105+You reach the exit.Check, test, build, and run commands
longbow check tutorial/checkpoints/07-finallongbow test tutorial/checkpoints/07-finallongbow build tutorial/checkpoints/07-finallongbow run tutorial/checkpoints/07-final -- --no-clear --seed 20261105 < tutorial/checkpoints/07-final/tests/input.txtLONGBOW=longbow tutorial/checkpoints/07-final/scripts/check-golden.shVersions in this tutorial
- 1. Render the first room
- 2. Represent game state
- 3. Read movement commands
- 4. Add the map and collision
- 5. Add turns and the goblin
- 6. Add archery and line of sight
- 7. Add deterministic seeds and release checks
Result and scope
The resulting package has deterministic input and output, eleven behavior tests, a golden-output check, and an optimized release build. Unsafe code remains out of scope because no native boundary requires it.
The final game does not include an unsafe subsystem. Unsafe code is outside the scope of this tutorial because the game has no measured native boundary that requires it.
References
Source: Arrowfall commit 56bb003, verified with official Flecha 0.3.0. The source, input, output, diff, and frame on this page are read from that committed snapshot.