Pular para o conteúdo

7. Adicione seeds determinísticas e verificações de release

Fonte atual: curriculum/arrowfall/checkpoints/07-final · Flecha 0.3.0 · 7/7.

O programa neste checkpoint

Até aqui: O checkpoint 06 é um jogo completo com movimento, colisão, perseguição, HP, arco e testes.

O checkpoint 06 é jogável, mas todo empate de perseguição escolhe a mesma rota, redesenhos misturam bytes de controle com evidência e mudar uma execução exige editar código. São problemas da fronteira de release, não novas regras.

O checkpoint final adiciona seed explícita, opções de linha de comando, saída simples, testes focados e campanha golden. O objetivo é variação controlada: seeds diferentes podem escolher caminhos diferentes, enquanto a mesma seed e entrada reproduzem a execução.

Limitação atual

Todo empate é idêntico, frames interativos rolam e mudar uma execução exige editar código.

Próximo comportamento necessário: Adicionar variedade sem flakiness: seed escolhida reproduz decisões, modo simples gera evidência estável e opções ruins falham antes de iniciar.

Modelo e invariantes

Inicializar Generator uma vez em Game, analisar --seed/--no-clear separadamente e manter motor/render puros. Modos ANSI e simples compartilham o frame.

Generator::fromSeed resolve um empate existente na perseguição sem perder a reprodução da execução. Opções de linha de comando selecionam a seed e a renderização simples sem alterar as regras do motor.

Game possui sourceSeed e Generator. Game::start inicializa os dois com o mesmo argumento, então a seed exibida identifica o fluxo aleatório que controla decisões de perseguição. Renderer e CLI não sorteiam valores.

Aleatoriedade só é usada quando as distâncias horizontal e vertical são ambas diferentes de zero. Esse é um empate real entre duas prioridades legais. Generator::boolean pode trocá-las; depois disso, a lógica existente de passo com colisão continua igual.

Options separa política do processo da política do motor. --seed seleciona reprodutibilidade e --no-clear seleciona apresentação; nenhuma altera movimento, dano, linha de visão ou vitória. Opções inválidas falham antes de Game iniciar.

O parser tem estado porque --seed consome o argumento seguinte. expectsSeed registra esse contexto de um token, enquanto finish retorna Options completo. Seed ausente ou vazia usa o padrão documentado; valor não vazio malformado produz InvalidSeed.

Renderização simples é a fronteira de teste. O renderer constrói um frame a partir de Game const, e um teste golden compara mapa, status, seed e mensagens importantes. Limpeza ANSI fica confinada à apresentação interativa.

Código unsafe, jogo em rede, saves e mapas dinâmicos ficam fora do escopo. Nada disso é necessário para qualificar este pacote de terminal, e adicioná-los esconderia a fronteira determinística estabelecida aqui.

Implementação

A implementação está dividida nas mudanças abaixo. Cada trecho vem literalmente do checkpoint qualificado e é explicado na parte do fluxo de dados em que participa. Os trechos não são programas independentes; o código completo compilado está disponível depois da execução.

  • Adicionar desempate por seed e exibir a seed.
  • Adicionar opções de CLI e apresentação ANSI/simples.
  • Expandir para onze testes e qualificação golden completa.

A seed pertence ao estado

O Game final armazena a seed que explica a execução e o gerador que a avança. Os dois são inicializados uma vez em Game::start junto do restante do estado autoritativo.

Trecho: src/game.fl:99–123. O arquivo completo aparece na seção de código abaixo.

src/game.fl:99-123 (excerpt)
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 permanece imutável para status e diagnósticos. random é estado mutável privado cuja sequência avança apenas quando o motor resolve empate de perseguição.

Passar a seed para Game em vez de usar gerador global torna jogos independentes reproduzíveis e permite que testes escolham seus próprios fluxos.

Aleatoriedade resolve um empate real

A decisão aleatória é estreita de propósito. Ela ocorre apenas quando DX e DY são diferentes de zero, onde prioridade horizontal ou vertical são alternativas de perseguição.

Trecho: src/game.fl:210–224. O arquivo completo aparece na seção de código abaixo.

src/game.fl:210-224 (excerpt)
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;
}

Resultado verdadeiro troca as duas direções preferidas. As chamadas posteriores a tryGoblinStep ainda rejeitam paredes, jogador e saída; aleatoriedade escolhe ordem, mas não contorna invariantes.

Gerador com seed não promete comportamento idêntico em implementações alheias; neste pacote Flecha qualificado, seed, código e testes versionados definem a execução reproduzível.

A política de CLI é isolada

Options possui os padrões da CLI e Parser possui o estado temporário para consumir argumentos. Isso mantém sintaxe do processo fora de Game e torna falhas de opção testáveis antes do início.

Trecho: src/options.fl:1–70. O arquivo completo aparece na seção de código abaixo.

src/options.fl:1-70 (excerpt)
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 altera apenas clearFrames. --seed define expectsSeed, e o token não-opção seguinte vira u64. Nova opção onde se esperava valor é rejeitada como opção, não aceita silenciosamente como seed.

A seed padrão é constante nomeada usada para valores ausentes ou vazios. Centralizá-la faz exibição, testes e comando registrado se referirem à mesma execução.

O frame é contrato golden

O teste de render trata frame simples completo e mensagens como comportamento público. Inicia jogo com seed 42, desenha de estado const e compara strings exatas.

Trecho: src/render_test.fl:1–24. O arquivo completo aparece na seção de código abaixo.

src/render_test.fl:1-24 (excerpt)
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"
);
}

O frame esperado contém mapa, HP, flechas, turno e seed. Asserções separadas preservam as distinções entre acerto, erro e ameaça expostas pelo motor como eventos.

Isso é mais estrito que verificar substrings. Mudança de formatação atualiza contrato golden explícito, enquanto testes do motor protegem regras como parede gratuita, aviso antes de dano e resultados de disparo.

Execute e examine o resultado

O comando usa a entrada registrada do checkpoint, portanto o comportamento descrito acima pode ser reproduzido sem depender de tempo manual.

Comando de execução
longbow run tutorial/checkpoints/07-final -- --no-clear --seed 20261105 < tutorial/checkpoints/07-final/tests/input.txt
Entrada padrão registrada
Entrada padrão
s
s
l
s
s

O comando qualificado usa --no-clear --seed 20261105 e a entrada S, S, L, S, S. O frame final relata essa seed, 3 HP, 2 flechas, turno 5 e as mensagens de fuga sem bytes de controle ANSI.

Frame de terminal produzido pela versão 7: Adicione seeds determinísticas e verificações de release
Frame representativo da execução registrada do checkpoint 7.

Executar o mesmo comando duas vezes deve produzir stdout idêntica byte a byte. Alterar só a seed pode mudar escolhas de empate, mas a seed escolhida permanece visível e reproduz a execução.

Transcript acessível do frame
Transcript do frame
#########
#.......#
#..###..#
#.......#
#..###..#
#@......#
#########
HP: 3 Arrows: 2 Turn: 5 Seed: 20261105
You reach the exit.
You escaped Arrowfall!
Stdout registrada completa
Stdout esperada
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>
#########
#.......#
#..###..#
#@......#
#..###..#
#>......#
#########
HP: 3 Arrows: 2 Turn: 3 Seed: 20261105
Your arrow hits! The goblin falls.
command>
#########
#.......#
#..###..#
#.......#
#@.###..#
#>......#
#########
HP: 3 Arrows: 2 Turn: 4 Seed: 20261105
You move.
command>
#########
#.......#
#..###..#
#.......#
#..###..#
#@......#
#########
HP: 3 Arrows: 2 Turn: 5 Seed: 20261105
You reach the exit.
You escaped Arrowfall!

O que este checkpoint estabelece

O pacote final tem fronteira determinística do motor, opções explícitas, onze testes focados, saída golden exata e caminho de build otimizado. A variação é controlada por entrada, não estado global oculto.

Arrowfall termina como programa pequeno de terminal que pode ser verificado, testado, compilado, executado e distribuído. O snapshot completo abaixo é o candidato de release usado por todos os comandos da página.

Exercício

Compare duas execuções com a mesma seed: stdout idêntico. Seed 42 aparece; --no-clear não tem bytes de controle; seeds inválidas não iniciam.

Código completo e verificação

As seções abaixo preservam o código qualificado completo, o diff exato desde o predecessor e a sequência de comandos usada pela CI. São evidência de apoio para a implementação explicada acima.

Código completo testado (12 arquivos)

longbow.toml

longbow.toml
[package]
name = "arrowfall-step-07"
version = "0.1.0"
kind = "program"
flecha = ">=0.3.0, <0.4.0"
[dependencies]

longbow.lock

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

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

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

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

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

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

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

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

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

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

scripts/check-golden.sh
#!/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 exato desde a versão anterior
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
+d
diff -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.
Comandos de check, teste, build e execução
Comandos de verificação
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

Versões deste tutorial

  1. 1. Renderize a primeira sala
  2. 2. Represente o estado do jogo
  3. 3. Leia comandos de movimento
  4. 4. Adicione o mapa e a colisão
  5. 5. Adicione turnos e o goblin
  6. 6. Adicione arco e linha de visão
  7. 7. Adicione seeds determinísticas e verificações de release

Resultado e escopo

O pacote resultante tem entrada e saída determinísticas, onze testes de comportamento, verificação de saída golden e build otimizado de release. Código unsafe fica fora do escopo porque nenhuma fronteira nativa o exige.

O jogo final não inclui subsistema unsafe. Código unsafe está fora do escopo deste tutorial porque o jogo não tem uma fronteira nativa medida que o exija.

Referências

Fonte: commit Arrowfall 56bb003, verificado com Flecha oficial 0.3.0. Código, entrada, saída, diff e frame desta página são lidos desse snapshot versionado.