380 lines
16 KiB
Rust
380 lines
16 KiB
Rust
//! Opt-in ordinary-input victory check against the installed original catalog.
|
|
use raptor_replica::{
|
|
assets::{Catalog, decode_demo},
|
|
content::Registry,
|
|
modding::ScriptRuntime,
|
|
persistence::Pilot,
|
|
simulation::{Input, Outcome, World},
|
|
};
|
|
use serde_json::json;
|
|
use std::{
|
|
collections::BTreeMap,
|
|
fs,
|
|
io::{BufWriter, Write},
|
|
path::PathBuf,
|
|
};
|
|
|
|
const MISSION: &str = "base:sector_1_wave_1";
|
|
const ORIGINAL_FLYBY_SOUND: u32 = 129;
|
|
|
|
#[derive(Debug, serde::Serialize)]
|
|
struct DepartureObservation {
|
|
tick: u64,
|
|
timer: Option<u32>,
|
|
x: i32,
|
|
y: i32,
|
|
draw_player: bool,
|
|
control_paused: bool,
|
|
sounds: Vec<u32>,
|
|
}
|
|
|
|
fn target_x(world: &World, registry: &Registry) -> i32 {
|
|
let center = world.player.x + 16;
|
|
world
|
|
.enemies
|
|
.iter()
|
|
.filter(|e| e.health > 0 && e.y + e.height > 0 && e.y < world.player.y)
|
|
.min_by_key(|e| {
|
|
let def = ®istry.enemies[&e.definition];
|
|
(
|
|
!def.boss,
|
|
(e.x + e.width / 2 - center).abs() + (160 - e.y).max(0) / 3,
|
|
)
|
|
})
|
|
.map_or(160, |e| {
|
|
// Lead the current observed motion by the forward projectile's travel time.
|
|
let travel = ((world.player.y - e.y - e.height / 2) / 8).clamp(0, 24);
|
|
(e.x + e.width / 2 + (e.x - e.px).clamp(-4, 4) * travel).clamp(21, 298)
|
|
})
|
|
}
|
|
|
|
fn controller(world: &World, registry: &Registry) -> Input {
|
|
let aim = target_x(world, registry);
|
|
if world.enemies.is_empty() && !world.projectiles.iter().any(|p| p.hostile) {
|
|
return Input {
|
|
fire: true,
|
|
..Input::default()
|
|
};
|
|
}
|
|
let mut best = None;
|
|
for dy in [0, -1, 1] {
|
|
for dx in [0, -1, 1] {
|
|
let input = Input {
|
|
dx,
|
|
dy,
|
|
fire: true,
|
|
..Input::default()
|
|
};
|
|
// These are disposable predictions. The actual world changes only
|
|
// through normal keyboard movement and the held fire button.
|
|
let mut prediction = world.clone();
|
|
let mut scripts = ScriptRuntime::new();
|
|
for future in 0..12 {
|
|
let projected = if future < 3 {
|
|
input
|
|
} else {
|
|
Input {
|
|
fire: true,
|
|
..Input::default()
|
|
}
|
|
};
|
|
prediction.step(projected, registry, &mut scripts).unwrap();
|
|
}
|
|
let lost = (world.player.shield + world.player.super_shield
|
|
- prediction.player.shield
|
|
- prediction.player.super_shield)
|
|
.max(0) as i64;
|
|
let position = prediction.player.x + 16 + prediction.player.vx * 2;
|
|
let mut cost = lost * 1_000_000
|
|
+ (position - aim).abs() as i64 * 80
|
|
+ (160 - prediction.player.y).abs() as i64 * 6;
|
|
if prediction.outcome == Outcome::Failed {
|
|
cost += 1_000_000_000;
|
|
}
|
|
for p in prediction.projectiles.iter().filter(|p| p.hostile) {
|
|
let distance = (p.x - prediction.player.x - 16)
|
|
.abs()
|
|
.max((p.y - prediction.player.y - 16).abs());
|
|
cost += (40 - distance).max(0) as i64 * 30;
|
|
}
|
|
let gained = prediction
|
|
.player
|
|
.credits
|
|
.saturating_sub(world.player.credits);
|
|
cost -= gained as i64;
|
|
if best.as_ref().is_none_or(|(previous, _)| cost < *previous) {
|
|
best = Some((cost, input));
|
|
}
|
|
}
|
|
}
|
|
best.unwrap().1
|
|
}
|
|
|
|
fn demo(frames: &[[u8; 12]]) -> Vec<u8> {
|
|
assert!(frames.len() <= i16::MAX as usize);
|
|
let mut data = vec![0_u8; 12];
|
|
data[8..10].copy_from_slice(&(frames.len() as i16).to_le_bytes());
|
|
for frame in frames {
|
|
data.extend_from_slice(frame);
|
|
}
|
|
let (sector, wave, decoded) = decode_demo(&data).unwrap();
|
|
assert_eq!((sector, wave, decoded.len()), (0, 0, frames.len()));
|
|
data
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires locally installed original data; ordinary-input first mission victory"]
|
|
fn original_first_mission_is_won_with_a_fresh_pilot() {
|
|
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
|
let path = std::env::var_os("RAPTOR_TEST_DATA")
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|| root.join("../data"));
|
|
let catalog = Catalog::open(&path).unwrap();
|
|
let registry = Registry::base(&catalog).unwrap();
|
|
let reference = root.join("../.reference");
|
|
fs::create_dir_all(&reference).unwrap();
|
|
for difficulty in [1, 2] {
|
|
let pilot = Pilot {
|
|
difficulty,
|
|
..Pilot::default()
|
|
};
|
|
pilot.validate(®istry, &[]).unwrap();
|
|
let mut world = World::new(&MISSION.into(), &pilot, ®istry).unwrap();
|
|
let mut scripts = ScriptRuntime::new();
|
|
let mut bosses = BTreeMap::new();
|
|
let mut visible_bosses = BTreeMap::new();
|
|
let mut killed = BTreeMap::new();
|
|
// A removed entity with positive prior health is not credited as a
|
|
// kill. This is conservative around same-step collision deaths.
|
|
let mut positive_retirements = BTreeMap::new();
|
|
let mut frames = Vec::new();
|
|
let mut controls = Vec::new();
|
|
let mut observations = Vec::new();
|
|
let mut snapshots = Vec::new();
|
|
let mut departure = Vec::new();
|
|
for _ in 0..12_000 {
|
|
if world.outcome != Outcome::Playing {
|
|
break;
|
|
}
|
|
let input = controller(&world, ®istry);
|
|
let prior: Vec<_> = world
|
|
.enemies
|
|
.iter()
|
|
.map(|e| (e.entity, e.definition.clone(), e.health))
|
|
.collect();
|
|
let before = world.player.shield;
|
|
let before_credits = world.player.credits;
|
|
let before_timer = world.end_timer;
|
|
world.step(input, ®istry, &mut scripts).unwrap();
|
|
if world.end_timer.is_some() || before_timer.is_some() {
|
|
departure.push(DepartureObservation {
|
|
tick: world.tick,
|
|
timer: world.end_timer,
|
|
x: world.player.x,
|
|
y: world.player.y,
|
|
draw_player: world.draw_player,
|
|
control_paused: world.control_paused,
|
|
sounds: world.sounds.clone(),
|
|
});
|
|
}
|
|
for e in &world.enemies {
|
|
if registry.enemies[&e.definition].boss {
|
|
bosses
|
|
.entry(e.entity)
|
|
.or_insert_with(|| (e.definition.clone(), world.tick));
|
|
if e.health > 0
|
|
&& e.y + e.height > 0
|
|
&& e.y < 200
|
|
&& e.x + e.width > 0
|
|
&& e.x < 320
|
|
{
|
|
visible_bosses.entry(e.entity).or_insert(world.tick);
|
|
}
|
|
}
|
|
}
|
|
for (entity, definition, health) in prior {
|
|
if !world.enemies.iter().any(|e| e.entity == entity) {
|
|
if health <= 0 {
|
|
let reward = registry.enemies[&definition].reward;
|
|
if bosses.contains_key(&entity) {
|
|
assert!(
|
|
world.player.credits >= before_credits + reward,
|
|
"original boss death did not award its reward"
|
|
);
|
|
}
|
|
killed.insert(entity, json!({"definition":definition,"tick":world.tick,"last_health":health,"reward":reward}));
|
|
} else {
|
|
positive_retirements.insert(entity, (definition, health));
|
|
}
|
|
}
|
|
}
|
|
let mut frame = [0_u8; 12];
|
|
frame[0] = u8::from(input.fire);
|
|
frame[1] = u8::from(input.cycle);
|
|
frame[2] = u8::from(input.bomb);
|
|
frame[4..6].copy_from_slice(&(world.player.x as i16).to_le_bytes());
|
|
frame[6..8].copy_from_slice(&(world.player.y as i16).to_le_bytes());
|
|
frame[8..10].copy_from_slice(&(world.player.bank as i16).to_le_bytes());
|
|
frames.push(frame);
|
|
controls.push(json!({"tick":world.tick,"dx":input.dx,"dy":input.dy,"fire":input.fire}));
|
|
snapshots.push(world.reference_state(®istry));
|
|
if world.tick % 100 == 0
|
|
|| before != world.player.shield
|
|
|| world.outcome != Outcome::Playing
|
|
{
|
|
observations.push(json!({"tick":world.tick,"x":world.player.x,"y":world.player.y,"shield":world.player.shield,"credits":world.player.credits,"enemies":world.enemies.iter().map(|e| json!({"entity":e.entity,"id":e.definition,"health":e.health,"x":e.x,"y":e.y})).collect::<Vec<_>>()}));
|
|
}
|
|
}
|
|
let cue = departure
|
|
.iter()
|
|
.find(|observation| observation.timer == Some(40))
|
|
.expect("successful original mission skipped its departure cue");
|
|
let report = json!({"mission":MISSION,"initial_pilot":pilot,"difficulty":difficulty,"ticks":world.tick,"demo_recorded_steps":cue.tick,"outcome":world.outcome,"shield":world.player.shield,"credits":world.player.credits,"spawns":world.spawn_cursor,"total_spawns":registry.missions[&world.mission].spawns.len(),"bosses":bosses,"bosses_first_visible":visible_bosses,"killed":killed,"positive_health_retirements":positive_retirements,"departure":departure,"observations":observations});
|
|
let name = format!("first-mission-fresh-difficulty-{difficulty}");
|
|
// The original DEMO_Think records before the stats-stage forced exit
|
|
// movement. End-of-step coordinates are equivalent until timer40.
|
|
// Keep this legacy recording to that boundary; the keyboard log below
|
|
// independently reproduces the complete ordinary departure.
|
|
let recorded = demo(&frames[..cue.tick as usize]);
|
|
fs::write(reference.join(format!("{name}.dem")), &recorded).unwrap();
|
|
fs::write(
|
|
reference.join(format!("{name}-controls.json")),
|
|
serde_json::to_vec(&controls).unwrap(),
|
|
)
|
|
.unwrap();
|
|
fs::write(
|
|
reference.join(format!("{name}-report.json")),
|
|
serde_json::to_vec_pretty(&report).unwrap(),
|
|
)
|
|
.unwrap();
|
|
let mut trace =
|
|
BufWriter::new(fs::File::create(reference.join(format!("{name}.jsonl"))).unwrap());
|
|
for snapshot in &snapshots {
|
|
writeln!(&mut trace, "{snapshot}").unwrap();
|
|
}
|
|
trace.flush().unwrap();
|
|
eprintln!(
|
|
"First original mission difficulty {difficulty}: {:?}, {} steps, shield {}, credits {}, bosses {:?}, confirmed kills {}, positive-health retirements {}",
|
|
world.outcome,
|
|
world.tick,
|
|
world.player.shield,
|
|
world.player.credits,
|
|
bosses,
|
|
killed.len(),
|
|
positive_retirements.len()
|
|
);
|
|
assert_eq!(world.outcome, Outcome::Complete, "{report}");
|
|
let mission = ®istry.missions[&world.mission];
|
|
assert_eq!(world.spawn_cursor, mission.spawns.len());
|
|
let expected_bosses = mission
|
|
.spawns
|
|
.iter()
|
|
.filter(|spawn| {
|
|
registry.enemies[&spawn.enemy].boss
|
|
&& match spawn.difficulty {
|
|
3 => true,
|
|
4 => difficulty >= 2,
|
|
5 => difficulty >= 3,
|
|
_ => false,
|
|
}
|
|
})
|
|
.count();
|
|
assert!(
|
|
expected_bosses > 0,
|
|
"catalog did not contain an original boss"
|
|
);
|
|
assert_eq!(
|
|
bosses.len(),
|
|
expected_bosses,
|
|
"some original bosses never appeared"
|
|
);
|
|
assert_eq!(
|
|
visible_bosses.len(),
|
|
bosses.len(),
|
|
"some bosses never entered the playfield alive"
|
|
);
|
|
assert!(
|
|
bosses.keys().all(|entity| killed.contains_key(entity)),
|
|
"an original boss escaped alive: {report}"
|
|
);
|
|
assert!(
|
|
world.player.credits > pilot.credits,
|
|
"victory must award original rewards"
|
|
);
|
|
assert!(cue.sounds.contains(&ORIGINAL_FLYBY_SOUND));
|
|
assert!(cue.control_paused);
|
|
let remaining_24 = departure
|
|
.iter()
|
|
.find(|observation| observation.timer == Some(24))
|
|
.expect("successful original mission skipped its departure animation");
|
|
assert!(
|
|
remaining_24.draw_player,
|
|
"a surviving pilot was hidden like a dead pilot"
|
|
);
|
|
assert!(
|
|
world.player.y < 0,
|
|
"surviving pilot never flew off the top of the screen"
|
|
);
|
|
// Legacy playback must preserve every snapshot through the boss kill
|
|
// and flyby cue, before automatic departure changes the record phase.
|
|
let (_, _, replay) = decode_demo(&recorded).unwrap();
|
|
let mut replay_world = World::new(&MISSION.into(), &pilot, ®istry).unwrap();
|
|
let mut replay_scripts = ScriptRuntime::new();
|
|
for (frame, expected) in replay.into_iter().zip(&snapshots) {
|
|
replay_world
|
|
.step(
|
|
Input {
|
|
fire: frame.buttons[0],
|
|
cycle: frame.buttons[1],
|
|
bomb: frame.buttons[2],
|
|
absolute: Some([frame.x as i32, frame.y as i32]),
|
|
demo_bank: Some(frame.bank as i32),
|
|
..Input::default()
|
|
},
|
|
®istry,
|
|
&mut replay_scripts,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
replay_world.reference_state(®istry),
|
|
*expected,
|
|
"recorded first-mission input diverged at step {}",
|
|
replay_world.tick
|
|
);
|
|
}
|
|
assert_eq!(replay_world.tick, cue.tick);
|
|
assert_eq!(replay_world.end_timer, Some(40));
|
|
assert!(replay_world.enemies.is_empty());
|
|
// The keyboard log is also independently decoded and replayed. Unlike
|
|
// the original demo format, it includes ordinary departure movement.
|
|
let keyboard: Vec<serde_json::Value> = serde_json::from_slice(
|
|
&fs::read(reference.join(format!("{name}-controls.json"))).unwrap(),
|
|
)
|
|
.unwrap();
|
|
let mut keyboard_world = World::new(&MISSION.into(), &pilot, ®istry).unwrap();
|
|
let mut keyboard_scripts = ScriptRuntime::new();
|
|
assert_eq!(keyboard.len(), snapshots.len());
|
|
for (control, expected) in keyboard.into_iter().zip(snapshots) {
|
|
keyboard_world
|
|
.step(
|
|
Input {
|
|
dx: control["dx"].as_i64().unwrap() as i32,
|
|
dy: control["dy"].as_i64().unwrap() as i32,
|
|
fire: control["fire"].as_bool().unwrap(),
|
|
..Input::default()
|
|
},
|
|
®istry,
|
|
&mut keyboard_scripts,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
keyboard_world.reference_state(®istry),
|
|
expected,
|
|
"keyboard recording diverged at step {}",
|
|
keyboard_world.tick
|
|
);
|
|
}
|
|
assert_eq!(keyboard_world.outcome, Outcome::Complete);
|
|
}
|
|
}
|