diff --git a/docs/machine-gun-ground-validation.md b/docs/machine-gun-ground-validation.md index df67598d..0963ba5f 100644 --- a/docs/machine-gun-ground-validation.md +++ b/docs/machine-gun-ground-validation.md @@ -39,17 +39,68 @@ reward; this run wrote `.reference/machine-gun-ground.json` outside Git. Package PNG/WAV decoding remains covered by the separate mod validator; this collision test loads the actual definitions, logical canvases and Rhai sources. +The October 11 follow-up expands that check to all 110 distinct non-boss ground +enemy definitions referenced by the 27 original maps, plus those five Iron +Coast units. All 115 can take damage from the default guns. The original ten +cases still require destruction and the exact reward. Additional cases require +damage, rather than treating disappearance as a kill: moving units can escape, +and the deliberately aggressive secret enemies can kill a starter pilot. Of +the 115 range cases, 91 were actually destroyed. Four needed a second ordinary +input strategy: a closer firing lane with movement lead. The report retains +both attempts. This proves collision eligibility, not that an unequipped pilot +can defeat every original enemy from a stationary firing lane. + +Buildings, parked cars and other map props are usually terrain definitions, +rather than enemies. A separate local test covers every one of the 889 unique +destructible terrain definitions used by the original maps, with actual health, +replacement art and rewards. All explode, settle and pay once in the native +collision window. One definition starts with negative health and therefore +explodes without a hit, as it does in the classic source. Original terrain +requires health below zero, rather than exactly zero, before its deferred +explosion; the follow-up does not change that rule. + +Two focused tests cover the reported Bravo Sector waves 6 and 7. They keep each +complete original layout and advance ordinary simulation steps to the actual +displayed positions. All 17 destructible occurrences in rows 130 through 149 +are destroyed by default-gun input. Examples in wave 7 include building `1045a` +at row 143, column 6 (25 health, 100 credits), and small vehicle/debris tiles +`1040c`, `1040a`, `1040e` and `1040d` (3–5 health). Nearby industrial/fence tiles +`1045c` through `1045f` remain intact: their original intact and replacement IDs +are identical, and the classic `TILE_IsHit` deliberately rejects them. + +The spawn-path test retains the native tick, placement, dimensions, movement +and full terrain for the four ordinary early ground emplacements. With capped +four-pixel mouse movement and the default guns, the wave 6 bonus emplacement +and turret die at ticks 565 and 648; wave 7's two turrets die at ticks 392 and +744. All runs retain 75 shield. The report records each original spawn position, +the first gun-hit coordinates, current logical bounds and prior interpolation +position. Every first hit is inside the displayed enemy's current bounds; +interpolation differs by the expected single vertical pixel. + +No further native collision defect was reproduced, so this follow-up changes +tests and documentation only. The original point collision rules, update order, +terrain delay and deliberately static scenery remain intact. These simulation +checks do not independently prove every HD image's visible footprint or the +desktop presentation; the exact reported screenshot would still be useful if +the player sees a destructible object that differs from these examples. + +The detailed JSON reports and original map contact images are local under +`.reference/ground-investigation/` in the parent project directory, outside the +Git root. The runtime catalog is tracked in `data/`. Their report variables are +`RAPTOR_GROUND_REPORT`, `RAPTOR_TERRAIN_REPORT`, `RAPTOR_EARLY_TERRAIN_REPORT` and +`RAPTOR_EARLY_SPAWN_REPORT`. + Run the portable checks with: ```sh cargo test --locked --test machine_gun_ground --test gameplay ``` -After campaign package files are stable, run the local original-data check with: +After campaign package files are stable, run all four local original-data checks with: ```sh -RAPTOR_TEST_DATA=../data cargo test --locked --test machine_gun_ground \ - real_forward_guns_damage_classic_and_iron_coast_ground_units -- --ignored +RAPTOR_TEST_DATA=../data cargo test --locked --no-default-features \ + --test machine_gun_ground -- --ignored ``` Commands above run from `RustRaptor/`. Set the usual local SDL library path diff --git a/docs/menu-validation.md b/docs/menu-validation.md index 5cb36170..81430d31 100644 --- a/docs/menu-validation.md +++ b/docs/menu-validation.md @@ -15,6 +15,9 @@ priority without moving the cursor. Returning from details or a disable confirmation restores the selected mod and its list position. Opening details again while already in diagnostics preserves its parent, so Escape always returns one level. Portable regressions exercise these navigation sequences. +The 42-capture audit was rerun after the navigation fixes on 11 October and +all four current contact sheets were visually reviewed; panels, focus, +confirmation text and shop information remain readable and correctly placed. The audit also verifies the complete startup reaches its return screen at exactly 1,891 70 Hz service ticks and restores menu music. Cancelling a mod-mismatch prompt preserves the original save; accepting it displays unavailable equipment without silently discarding it. @@ -25,4 +28,8 @@ RAPTOR_TEST_DATA="$PWD/../data" LD_LIBRARY_PATH="$HOME/.local/lib" \ cargo test --locked --lib every_menu_screen_and_original_intro_visual_audit -- --ignored ``` -Captures and the action-row manifest stay ignored in `../.reference/ui-all/`. The background was generated with the imagegen skill's built-in image generation tool; the exact prompt and provenance are tracked in `art/menu-background-v1.prompt.txt` and `art/provenance.json`. Original cinematic pixels remain outside Git. +Captures and the action-row manifest remain local in `../.reference/ui-all/`, +outside the current Git root. The background was generated with the imagegen +skill's built-in image generation tool; its prompt and provenance are tracked +in `art/menu-background-v1.prompt.txt` and `art/provenance.json`. The packaged +original cinematics are now included in tracked `data/`. diff --git a/docs/resume-checkpoint.md b/docs/resume-checkpoint.md index d45a6fe4..e03da2e5 100644 --- a/docs/resume-checkpoint.md +++ b/docs/resume-checkpoint.md @@ -6,9 +6,25 @@ helper or packaging shell process remained at recovery. The image-generation gate was paused with zero active leases, and has now been resumed. Recover each worker from its existing outputs before submitting another generation request. +## Repository relocation and GitHub + +`RustRaptor/` is now the Git root, including its `.git/` metadata. Migration +commit `8f45979` preserves history and is pushed to the public +[Quintine/RustRaptor](https://github.com/Quintine/RustRaptor) repository on +`codex/rust-rewrite`. Run all Git commands from `RustRaptor/`, with paths such +as `src/desktop.rs` and `docs/hd-backlog.md`. + +The user explicitly requested that the bundled game and data be included in +Git. `bin/`, `lib/`, `data/` and `runtime-manifest.json` are tracked; caches, +review files and duplicate zip exports are ignored. All 6,566 runtime data +hashes match in a fresh public GitHub checkout, which validates all 6,449 +original catalog entries. Its SDL game also launched from `/tmp` using only +the bundled library and an isolated profile. Pending source, mod and artwork-review work survived +the relocation. See [repository layout](repository.md). + ## Engine and package -- Last committed engine checkpoint: `fdf2ff0`, standalone RustRaptor, widescreen +- Packaged executable checkpoint: `fdf2ff0`, standalone RustRaptor, widescreen menus and always-4:3 original cinematics, including when gameplay stretching is selected. Fresh 1080p intro capture confirms black 240-pixel side borders. - `RustRaptor/bin/raptor-rust`, `lib/`, `data/` and @@ -18,28 +34,41 @@ worker from its existing outputs before submitting another generation request. - Before shutdown, 320 default tests, the complete 42-image menu/intro audit, separate cinematic aspect regression, mod/catalog/HD validation and copied 1280×800/1080p SDL/audio smoke checks passed. Later fixes require fresh checks. -- Uncommitted lightning weapon/API packages and terrain compositor improvements +- Source navigation fixes are committed in `4b1edef`; the terrain compositor + fix is committed in `ab23255`. Uncommitted Lightning weapon/API packages are preserved. Review and commit them separately; do not discard their work. The executable/package must be refreshed after source or accepted-art changes. ## Next user fixes 1. Mod/save diagnostics: parent and selection are now preserved when details - are reopened. The Escape regression passes; full screenshot audit is running. + are reopened. The Escape regression and fresh 42-capture screenshot audit + pass, including repeated details, confirmation and back navigation. 2. Installed mod rows now stay in ID order through toggles and priority changes, with dependency and confirmation behavior preserved. Navigation tests pass. 3. Default Forward Guns still appear to miss miscellaneous buildings/cars. - Expand actual original-content coverage beyond the earlier ten-unit test. + Actual original-data checks now cover all 889 destructible terrain + definitions, 17 early wave 6/7 occurrences, all 110 classic ground units + and four early native spawn paths. These pass. Some industrial pieces are + deliberately indestructible in the original tables; the exact reported + object remains unidentified, so no speculative native collision change was made. + +The resumed source suite passes 333 tests with 31 optional checks ignored. +The original menu/intro audit passes and completes at 1,891 service ticks. +These checks include pending Lightning API/mod changes, which still require +independent review and a refreshed packaged executable before release. Root owns menu fixes. `iron_coast_5` temporarily owns the ground-target diagnosis; its completed boss candidates and pending equipment review remain preserved. ## HD state and owners -The original picture inventory is 5,753 IDs; 221 are registered and 5,532 remain -unregistered. Existing raw candidates are not counted as accepted remasters. -All original/generated base pixels and proof captures remain ignored; authored -mod artwork and sound belong in Git. +The original picture inventory is 5,753 IDs. The development pack now has 340 +registered frames across 122 complete sequences; 5,413 IDs remain unregistered. +The packaged runtime still has its earlier 221-frame snapshot. Existing raw +candidates are not counted as accepted remasters. Working originals, generated +drafts and proof captures remain outside the Git root; the approved runtime +snapshot and all authored mod artwork and sound are tracked in Git. | Agent | Recover and continue | | --- | --- | diff --git a/tests/machine_gun_ground.rs b/tests/machine_gun_ground.rs index 826dabb7..a0a2e82d 100644 --- a/tests/machine_gun_ground.rs +++ b/tests/machine_gun_ground.rs @@ -267,16 +267,34 @@ fn real_forward_guns_damage_classic_and_iron_coast_ground_units() { // movement and scripts intact while isolating their actual collisions. let mission: ContentPack = serde_json::from_value(json!({"missions":[{"id":"test:ground_range","name":"Ground range","duration":10000,"auto_complete":false}]})).unwrap(); active.registry.apply(mission, "test").unwrap(); - let first = &base.missions[&ContentId::from("base:sector_1_wave_1")]; - let mut ground: Vec<_> = first + // Retain the original ten-case kill assertions. Additional native enemies + // include the 100-health secret cow; a starter pilot may die in this fixed + // firing lane, so those cases check damage without claiming combat wins. + let must_destroy: std::collections::BTreeSet<_> = base.missions + [&ContentId::from("base:sector_1_wave_1")] .spawns .iter() .map(|spawn| spawn.enemy.clone()) .filter(|id| base.enemies[id].ground && !base.enemies[id].boss) .collect(); + let mut ground: Vec<_> = base + .missions + .values() + .filter(|mission| mission.legacy_map.is_some()) + .flat_map(|mission| mission.spawns.iter()) + .map(|spawn| spawn.enemy.clone()) + .filter(|id| base.enemies[id].ground && !base.enemies[id].boss) + .collect(); ground.sort(); ground.dedup(); - assert!(!ground.is_empty()); + assert!( + ground.len() > 5, + "exercise every native ground unit referenced by the 27 maps" + ); + eprintln!( + "Auditing {} original non-boss ground definitions", + ground.len() + ); ground.extend( [ "iron_coast_turret:unit", @@ -288,60 +306,410 @@ fn real_forward_guns_damage_classic_and_iron_coast_ground_units() { .map(ContentId::from), ); let mut observations = Vec::new(); + let mut undamaged = Vec::new(); for id in ground { let pilot = Pilot { mission: "test:ground_range".into(), ..starter() }; - let mut world = World::new(&pilot.mission, &pilot, &active.registry).unwrap(); - let entity = world.spawn(&id, 144, 24, &active.registry).unwrap(); - let initial = world.enemies[0].health; - let mut lowest = initial; - for _ in 0..120 { - // Normal bounded absolute mouse movement aligns the ship with the - // observed ground target; health and projectiles are never injected. - let target = world.enemies.iter().find(|enemy| enemy.entity == entity); - let Some(enemy) = target else { + let mut attempts = Vec::new(); + let mut final_world = None; + let mut final_entity = 0; + let mut lowest = active.registry.enemies[&id].health; + let initial = lowest; + for attempt in 0..2 { + let mut world = World::new(&pilot.mission, &pilot, &active.registry).unwrap(); + let entity = world.spawn(&id, 144, 24, &active.registry).unwrap(); + lowest = initial; + for _ in 0..120 { + // Normal bounded absolute mouse movement aligns the ship with the + // observed ground target; health and projectiles are never injected. + let target = world.enemies.iter().find(|enemy| enemy.entity == entity); + let Some(enemy) = target else { + break; + }; + // The first attempt preserves the original ten-case firing lane. + // If it cannot reach an additional native target, a second ordinary + // input strategy uses a closer lane and leads horizontal movement. + // Narrow moving units can outrun shots aimed at their old position; + // hostile secret units can kill a stationary pilot before a distant + // shot reaches them. Neither case establishes a collision defect. + let y = if attempt == 0 { 160 } else { 80 }; + let lead = if attempt == 0 { + 0 + } else { + let velocity = active.registry.enemies[&id].legacy.as_ref().map_or( + 0, + |legacy| match legacy.fields[21] { + 4 => -active.registry.enemies[&id].speed, + 5 => active.registry.enemies[&id].speed, + _ => 0, + }, + ); + let flight_steps = ((y + 16 - enemy.y - enemy.height / 2).max(0) + 11) / 12; + velocity * flight_steps + }; + let x = (enemy.x + enemy.width / 2 - 16 + lead).clamp(5, 282); + world + .step( + Input { + fire: true, + absolute: Some([x, y]), + ..Input::default() + }, + &active.registry, + &mut active.scripts, + ) + .unwrap(); + if let Some(enemy) = world.enemies.iter().find(|enemy| enemy.entity == entity) { + lowest = lowest.min(enemy.health); + } + } + attempts.push(json!({ + "control": if attempt == 0 { "current-position aim at y160" } else { "movement lead at y80" }, + "lowest_observed_health": lowest, + "ticks": world.tick, + "outcome": format!("{:?}", world.outcome), + "remaining_shield": world.player.shield + })); + final_entity = entity; + final_world = Some(world); + if lowest < initial { break; - }; - let x = (enemy.x + enemy.width / 2 - 16).clamp(5, 282); - world - .step( - Input { - fire: true, - absolute: Some([x, 160]), - ..Input::default() - }, - &active.registry, - &mut active.scripts, - ) - .unwrap(); - if let Some(enemy) = world.enemies.iter().find(|enemy| enemy.entity == entity) { - lowest = lowest.min(enemy.health); } } - assert!(lowest < initial, "starter machine gun did not damage {id}"); - assert!( - !world.enemies.iter().any(|enemy| enemy.entity == entity), - "starter machine gun did not destroy {id}; lowest health {lowest}" - ); - assert_eq!( - world.player.credits, - pilot.credits + active.registry.enemies[&id].reward, - "incorrect kill reward for {id}" - ); + let world = final_world.unwrap(); + let entity = final_entity; + if lowest >= initial { + undamaged.push(format!( + "{id}: health {initial}, tick {}, outcome {:?}", + world.tick, world.outcome + )); + } + let target_present = world.enemies.iter().any(|enemy| enemy.entity == entity); + let destroyed = !target_present && lowest <= 0; + if must_destroy.contains(&id) || active.registry.enemies[&id].legacy.is_none() { + assert!( + destroyed, + "starter machine gun did not destroy {id}; lowest health {lowest}, outcome {:?}", + world.outcome + ); + } + if destroyed { + assert_eq!( + world.player.credits, + pilot.credits + active.registry.enemies[&id].reward, + "incorrect kill reward for {id}" + ); + } observations.push(json!({ "id": id, "name": active.registry.enemies[&id].name, "initial_health": initial, "lowest_observed_health": lowest, - "destroyed": true, + "destroyed": destroyed, + "escaped": !target_present && !destroyed, + "outcome": format!("{:?}", world.outcome), "ticks": world.tick, "reward": world.player.credits - pilot.credits, "remaining_shield": world.player.shield + ,"attempts": attempts })); } if let Some(path) = std::env::var_os("RAPTOR_GROUND_REPORT") { std::fs::write(path, serde_json::to_vec_pretty(&observations).unwrap()).unwrap(); } + assert!( + undamaged.is_empty(), + "Ground definitions never damaged: {undamaged:?}" + ); +} + +#[test] +#[ignore = "requires locally installed original catalog"] +fn real_forward_guns_damage_every_classic_destructible_terrain_definition() { + use std::collections::BTreeMap; + let catalog = catalog(); + let mut registry = Registry::base(&catalog).unwrap(); + // Buildings, parked cars and other scenery are native terrain, not EnemyDef. + // Use every intact/destruction pair actually referenced by the 27 maps. + let mut definitions = BTreeMap::new(); + for mission in registry + .missions + .values() + .filter(|m| m.legacy_map.is_some()) + { + for tile in &mission.tiles { + if tile.destroyed.is_some() { + definitions + .entry(tile.art.asset.unwrap()) + .or_insert_with(|| tile.clone()); + } + } + } + assert!(definitions.len() > 100); + eprintln!( + "Auditing {} original destructible terrain definitions", + definitions.len() + ); + let mission_id = ContentId::from("base:sector_1_wave_1"); + let background = registry.missions[&mission_id] + .tiles + .iter() + .find(|tile| tile.destroyed.is_none()) + .unwrap() + .clone(); + let mut observations = Vec::new(); + for (asset, tile) in definitions { + let mission = registry.missions.get_mut(&mission_id).unwrap(); + mission.spawns.clear(); + mission.auto_complete = false; + mission.duration = 10000; + mission.tiles = vec![background.clone(), tile.clone()]; + mission.layout = vec![0; 90]; + let position = 4 * 9 + 4; + mission.layout[position] = 1; + let pilot = starter(); + let mut world = World::new(&mission_id, &pilot, ®istry).unwrap(); + let mut scripts = ScriptRuntime::new(); + let mut sounds = 0; + for _ in 0..100 { + shoot(&mut world, ®istry, &mut scripts, 1); + sounds += world.sound_events.iter().filter(|s| s.asset == 139).count(); + } + assert!( + world.terrain_dead[position], + "classic tile {asset:05x} was not destroyed; health {} from {}", + world.terrain_health[position], tile.health + ); + assert!( + world.terrain_settled[position], + "classic tile {asset:05x} never settled" + ); + assert_eq!( + world.player.credits, + pilot.credits + tile.reward, + "classic tile {asset:05x} reward" + ); + assert_eq!(sounds, 1, "classic tile {asset:05x} ground-explosion sound"); + observations.push(json!({"asset":format!("{asset:05x}"),"original_health":tile.health,"reward":tile.reward,"destroyed":true,"settled":true,"ticks":world.tick})); + } + if let Some(path) = std::env::var_os("RAPTOR_TERRAIN_REPORT") { + std::fs::write(path, serde_json::to_vec_pretty(&observations).unwrap()).unwrap(); + } +} + +#[test] +#[ignore = "requires locally installed original catalog"] +fn real_wave_6_and_7_early_terrain_hits_match_visible_original_map_positions() { + let catalog = catalog(); + let mut registry = Registry::base(&catalog).unwrap(); + let mut observations = Vec::new(); + let mut destroyed_count = 0; + let mut static_count = 0; + for wave in [6, 7] { + let id = ContentId::new(format!("base:sector_1_wave_{wave}")).unwrap(); + let original = registry.missions[&id].clone(); + // Keep the complete original layout, art, tile rules and native map + // identity. Only enemy spawns are suppressed to isolate terrain hits. + let mut quiet = original.clone(); + quiet.spawns.clear(); + quiet.auto_complete = false; + registry.missions.insert(id.clone(), quiet); + for row in 130..150 { + for col in 0..9 { + let position = row * 9 + col; + let tile = &original.tiles[original.layout[position]]; + let asset = tile.art.asset.unwrap(); + // Include every destructible object in the early map section, + // plus the explicitly static industrial/fence artwork nearby. + let destructible = tile.destroyed.is_some(); + let static_landmark = matches!(asset, 0x1045c..=0x1045f); + if !destructible && !static_landmark { + continue; + } + let pilot = Pilot { + mission: id.clone(), + ..starter() + }; + let mut world = World::new(&id, &pilot, ®istry).unwrap(); + let mut scripts = ScriptRuntime::new(); + let rows = original.layout.len() / 9; + // This is exactly scene()'s original terrain row position: + // 200 - rows*32 + scroll + row*32. Advance ordinary idle + // steps until the tested row is at y=40, then fire normally. + let idle = (40 - (200 - rows as i32 * 32 + row as i32 * 32)).max(0); + for _ in 0..idle { + world + .step(Input::default(), ®istry, &mut scripts) + .unwrap(); + } + assert_eq!(200 - rows as i32 * 32 + world.scroll + row as i32 * 32, 40); + let initial_health = world.terrain_health[position]; + for _ in 0..70 { + world + .step( + Input { + fire: true, + absolute: Some([16 + col as i32 * 32, 160]), + ..Input::default() + }, + ®istry, + &mut scripts, + ) + .unwrap(); + } + if destructible { + assert!( + world.terrain_dead[position], + "wave{wave} row{row} col{col} asset{asset:05x} remains alive at its displayed position, health {}", + world.terrain_health[position] + ); + assert!(world.terrain_settled[position]); + destroyed_count += 1; + } else { + assert_eq!(world.terrain_health[position], initial_health); + assert!(!world.terrain_dead[position]); + static_count += 1; + } + observations.push(json!({"wave":wave,"row":row,"col":col,"asset":format!("{asset:05x}"),"destructible":destructible,"initial_health":initial_health,"final_health":world.terrain_health[position],"dead":world.terrain_dead[position],"settled":world.terrain_settled[position],"idle_steps":idle,"normal_fire_steps":70,"credits_gained":world.player.credits-pilot.credits})); + } + } + } + assert_eq!(destroyed_count, 17); + assert!(static_count >= 4); + if let Some(path) = std::env::var_os("RAPTOR_EARLY_TERRAIN_REPORT") { + std::fs::write(path, serde_json::to_vec_pretty(&observations).unwrap()).unwrap(); + } +} + +#[test] +#[ignore = "requires locally installed original catalog"] +fn real_wave_6_and_7_early_ground_spawns_use_original_visible_positions() { + let catalog = catalog(); + let mut registry = Registry::base(&catalog).unwrap(); + let mut observations = Vec::new(); + for wave in [6, 7] { + let id = ContentId::new(format!("base:sector_1_wave_{wave}")).unwrap(); + let original = registry.missions[&id].clone(); + let early: Vec<_> = original + .spawns + .iter() + .filter(|spawn| { + // Native difficulty codes 0..2 are disabled secret placements. + // These four early ordinary emplacements are enabled at all levels. + spawn.tick <= 600 + && spawn.difficulty == 3 + && registry.enemies[&spawn.enemy].ground + && !registry.enemies[&spawn.enemy].boss + }) + .cloned() + .collect(); + assert_eq!(early.len(), 2); + for spawn in early { + let mut isolated = original.clone(); + isolated.spawns = vec![spawn.clone()]; + isolated.auto_complete = false; + registry.missions.insert(id.clone(), isolated); + let pilot = Pilot { + mission: id.clone(), + ..starter() + }; + let mut world = World::new(&id, &pilot, ®istry).unwrap(); + let mut scripts = ScriptRuntime::new(); + let def = ®istry.enemies[&spawn.enemy]; + let art = def.art.as_ref().unwrap(); + assert_eq!(def.legacy.as_ref().unwrap().fields[21], 3); + let expected_x = spawn.x + 16 - art.logical_width as i32 / 2; + let expected_y = spawn.y - art.logical_height as i32 / 2 + 1; + let mut observed_spawn = None; + let mut first_damage = None; + let mut entity = None; + let mut lowest = def.health; + let mut last_health = None; + for _ in 0..spawn.tick + 400 { + // Capped mouse movement is achievable with ordinary controls. + // Full original terrain remains present, including objects + // that can intercept bullets before the emplacement is hit. + let desired = (expected_x + art.logical_width as i32 / 2 - 16).clamp(5, 282); + let x = world.player.x + (desired - world.player.x).clamp(-4, 4); + world + .step( + Input { + fire: true, + absolute: Some([x, 160]), + ..Input::default() + }, + ®istry, + &mut scripts, + ) + .unwrap(); + if let Some(enemy) = world + .enemies + .iter() + .find(|enemy| enemy.definition == spawn.enemy) + { + if entity.is_none() { + entity = Some(enemy.entity); + assert_eq!([enemy.x, enemy.y], [expected_x, expected_y]); + assert_eq!(enemy.age, 1); + assert_eq!(world.tick, spawn.tick + 1); + observed_spawn = Some( + json!({"world_tick":world.tick,"position":[enemy.x,enemy.y],"previous_position":[enemy.px,enemy.py],"width":enemy.width,"height":enemy.height}), + ); + } + lowest = lowest.min(enemy.health); + last_health = Some(enemy.health); + if first_damage.is_none() && enemy.health < def.health { + let hit_points: Vec<_> = world + .projectiles + .iter() + .filter(|projectile| { + projectile.weapon == ContentId::from("base:projectile_forward_guns") + && projectile.legacy.as_ref().is_some_and(|shot| shot.hit) + && projectile.x > enemy.x + && projectile.x < enemy.x + enemy.width - 1 + && projectile.y > enemy.y + && projectile.y < enemy.y + enemy.height - 1 + }) + .map(|projectile| [projectile.x, projectile.y]) + .collect(); + assert!( + !hit_points.is_empty(), + "first damage lacks a default-gun collision point" + ); + first_damage = Some( + json!({"world_tick":world.tick,"health":enemy.health,"logical_bounds":[enemy.x,enemy.y,enemy.width,enemy.height],"interpolation_start":[enemy.px,enemy.py],"hit_points":hit_points}), + ); + } + } else if entity.is_some() { + break; + } + } + assert!(entity.is_some(), "wave {wave} never spawns {}", spawn.enemy); + assert!( + lowest < def.health, + "wave {wave} default gun never damages {}", + spawn.enemy + ); + assert!( + last_health.is_some_and(|health| health <= 0), + "wave {wave} target escaped rather than being destroyed: {} / health {last_health:?}", + spawn.enemy + ); + assert!( + !world + .enemies + .iter() + .any(|enemy| Some(enemy.entity) == entity) + ); + assert_eq!(format!("{:?}", world.outcome), "Playing"); + assert!(world.player.credits >= pilot.credits + def.reward); + observations.push(json!({"wave":wave,"id":spawn.enemy,"name":def.name,"native_spawn_tick":spawn.tick,"native_spawn_position":[spawn.x,spawn.y],"observed_spawn":observed_spawn,"first_damage":first_damage,"initial_health":def.health,"lowest_observed_health":lowest,"destroyed":true,"finished_tick":world.tick,"remaining_shield":world.player.shield,"credits_gained":world.player.credits-pilot.credits})); + } + } + assert_eq!(observations.len(), 4); + if let Some(path) = std::env::var_os("RAPTOR_EARLY_SPAWN_REPORT") { + std::fs::write(path, serde_json::to_vec_pretty(&observations).unwrap()).unwrap(); + } }