flybrain/services/flysim/crates/flybrain-gb/tests/recovery_policy.rs
acamilo 660c3cf00d
Some checks failed
ci / node 22 (test + typecheck) (push) Has been cancelled
ci / rust stable (cargo test --workspace --release) (push) Has been cancelled
ci / infra/tests/lint.sh (push) Has been cancelled
ci / playwright apps/stage (allowed to fail) (push) Has been cancelled
flybrain v0.4.0: public tree (history retained privately)
2026-09-21 15:09:46 +00:00

173 lines
7.3 KiB
Rust

//! Per-game recovery policies: the game-over trigger and the larger budgets.
//!
//! `docs/design/platformer.md` §5 ("Recovery and ratchet rules") asks for a
//! restore trigger a platformer needs and Pokémon does not, plus budgets a
//! platformer would otherwise spend in its first hour. Both arrive as a
//! [`RecoveryPolicy`] the adapter provides, so the ratchet itself stays generic.
//!
//! The first test is the important one: Pokémon's behaviour must be bit-for-bit
//! what it was before the policy existed.
use flybrain_gb::adapter::GameAdapter;
use flybrain_gb::platformer::{PlatformerAdapter, RECOVERY_POLICY};
use flybrain_gb::ratchet::{
MAX_ATTEMPTS, MAX_RECOVERIES, RECOVERY_COOLDOWN_MS, RecoveryPolicy, STALL_MS, UNSAFE_RESET_MS,
};
use flybrain_gb::{FRAMEBUFFER_LEN, Ratchet, RatchetState, Snapshot};
fn snapshot() -> Snapshot {
Snapshot { game: vec![1], frame: vec![0; FRAMEBUFFER_LEN] }
}
#[test]
fn the_pokemon_adapter_keeps_the_original_policy_exactly() {
let adapter = flybrain_gb::adapter_for("pokemon-red").unwrap();
let policy = adapter.recovery_policy();
assert_eq!(policy, RecoveryPolicy::default());
assert_eq!(policy.stall_ms, STALL_MS);
assert_eq!(policy.cooldown_ms, RECOVERY_COOLDOWN_MS);
assert_eq!(policy.unsafe_reset_ms, UNSAFE_RESET_MS);
assert_eq!(policy.max_attempts, MAX_ATTEMPTS);
assert_eq!(policy.max_recoveries, MAX_RECOVERIES);
assert_eq!(policy.game_over_cooldown_ms, None, "Pokémon has no game-over restore");
assert!(!adapter.game_over());
}
#[test]
fn a_default_policy_ignores_the_game_over_flag_entirely() {
let mut ratchet = Ratchet::new();
ratchet.observe(true, 1, 1, 0, snapshot);
// Same clock as the stall test below, where the platformer policy recovers
// at once: with the trigger disabled nothing happens until the stall window.
assert!(!ratchet.observe_with_game_over(false, 1, 1, 70_000, true, snapshot));
assert!(!ratchet.observe_with_game_over(true, 1, 1, 70_000, true, snapshot));
assert_eq!(ratchet.state.recoveries, 0);
// And the 120-second window still fires on its own.
assert!(ratchet.observe(true, 1, 1, 200_000, snapshot));
}
#[test]
fn the_platformer_policy_restores_on_a_game_over_without_waiting_out_a_stall() {
let mut ratchet = Ratchet::with_policy(RECOVERY_POLICY);
ratchet.observe(true, 4, 1, 0, snapshot);
assert!(
ratchet.observe_with_game_over(false, 4, 1, 1_000, true, snapshot),
"a game over discards the run; there is nothing to wait for"
);
assert_eq!(ratchet.state.recoveries, 1);
assert_eq!(ratchet.state.attempts, 1);
// Its own 60-second cooldown, not the 180-second stall cooldown.
assert!(!ratchet.observe_with_game_over(false, 4, 1, 60_000, true, snapshot));
assert!(ratchet.observe_with_game_over(false, 4, 1, 61_000, true, snapshot));
assert_eq!(ratchet.state.recoveries, 2);
// Three attempts per rank, then the run is left alone.
assert!(ratchet.observe_with_game_over(false, 4, 1, 130_000, true, snapshot));
assert!(!ratchet.observe_with_game_over(false, 4, 1, 200_000, true, snapshot));
assert_eq!(ratchet.state.attempts, 3);
// A new best rank refreshes the attempt budget, as it does for a stall.
ratchet.observe(true, 5, 1, 260_000, snapshot);
assert_eq!(ratchet.state.attempts, 0);
assert!(ratchet.observe_with_game_over(false, 5, 1, 330_000, true, snapshot));
}
#[test]
fn the_platformer_stall_window_is_three_hundred_seconds() {
let mut ratchet = Ratchet::with_policy(RECOVERY_POLICY);
ratchet.observe(true, 4, 1, 0, snapshot);
assert!(!ratchet.observe(true, 4, 1, 299_000, snapshot), "Pokémon would have recovered");
assert!(ratchet.observe(true, 4, 1, 300_001, snapshot));
// A new band is new coverage, which restarts the window.
assert!(!ratchet.observe(true, 4, 2, 600_001, snapshot));
assert!(!ratchet.observe(true, 4, 2, 890_000, snapshot));
assert!(ratchet.observe(true, 4, 2, 901_000, snapshot));
}
#[test]
fn an_exhausted_lifetime_budget_leaves_the_run_unrecovered() {
let policy = RecoveryPolicy { max_recoveries: 2, ..RECOVERY_POLICY };
let mut ratchet = Ratchet::with_policy(policy);
ratchet.observe(true, 4, 1, 0, snapshot);
let mut now = 0u64;
let mut recovered = 0;
for _ in 0..6 {
now += 100_000;
// A fresh best rank each time, so the per-rank budget is never the limit.
ratchet.observe(true, 4 + recovered as u64 + 1, 1, now, snapshot);
now += 100_000;
if ratchet.observe_with_game_over(false, 4, 1, now, true, snapshot) {
recovered += 1;
}
}
assert_eq!(recovered, 2, "the lifetime budget is the ceiling");
assert_eq!(ratchet.state.recoveries, 2);
}
#[test]
fn a_checkpoint_is_validated_against_this_ratchets_own_budgets() {
let platformer = RatchetState {
best: 4,
recoveries: 40,
attempts: 1,
..RatchetState::default()
};
// 40 recoveries is inside the platformer's budget of 48 and outside Pokémon's 36.
assert!(
Ratchet::with_policy(RECOVERY_POLICY)
.import(Some(platformer), Some(snapshot()), platformer_ladder())
.is_ok()
);
assert!(
Ratchet::new().import(Some(platformer), Some(snapshot()), pokemon_ladder()).is_err()
);
assert_eq!(RECOVERY_POLICY.max_recoveries, 48);
assert_eq!(MAX_RECOVERIES, 36, "and Pokémon's is the long ladder's 36");
}
/// The two bounds are independent: the budgets come from the policy, the rank bound
/// from the adapter's ladder. A roomy policy does not widen the ladder, and a long
/// ladder does not widen the budgets.
#[test]
fn the_rank_bound_is_the_adapters_ladder_whatever_the_policy_allows() {
// Rank 20 exists on Pokémon's 38-rung ladder and not on the platformer's 16,
// so the platformer's roomier policy still rejects it.
let deep = RatchetState { best: 20, ..RatchetState::default() };
assert!(
Ratchet::with_policy(RECOVERY_POLICY)
.import(Some(deep), Some(snapshot()), platformer_ladder())
.is_err(),
"rank 20 is off the end of a 16-rung ladder"
);
assert!(
Ratchet::new().import(Some(deep), Some(snapshot()), pokemon_ladder()).is_ok(),
"and on the ladder it came from it is fine under the default policy"
);
// The converse: a checkpoint inside the platformer's ladder but past Pokémon's
// lifetime budget is refused for the budget, not the rank.
let spent = RatchetState { best: 4, recoveries: 40, ..RatchetState::default() };
assert!(Ratchet::new().import(Some(spent), Some(snapshot()), pokemon_ladder()).is_err());
}
fn platformer_ladder() -> usize {
let len = PlatformerAdapter::new().rank_ladder().len();
assert_eq!(len, 16, "docs/design/platformer.md §3: ranks 0 to 15");
len
}
fn pokemon_ladder() -> usize {
let len = flybrain_gb::adapter_for("pokemon-red").unwrap().rank_ladder().len();
assert_eq!(len, 38, "docs/design/ladder.md: 38 rungs");
len
}
#[test]
fn the_platformer_adapter_supplies_that_policy() {
let adapter = PlatformerAdapter::new();
assert_eq!(adapter.recovery_policy(), RECOVERY_POLICY);
assert_eq!(RECOVERY_POLICY.game_over_cooldown_ms, Some(60_000));
assert_eq!(RECOVERY_POLICY.stall_ms, 300_000);
assert_eq!(RECOVERY_POLICY.max_attempts, 3);
}