//! 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, x: i32, y: i32, draw_player: bool, control_paused: bool, sounds: Vec, } 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 { 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::>()})); } } 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::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); } }