diff --git a/airdef-mod/src/lib.rs b/airdef-mod/src/lib.rs index 93bddf1..6b71b38 100644 --- a/airdef-mod/src/lib.rs +++ b/airdef-mod/src/lib.rs @@ -2742,6 +2742,75 @@ mod tests { assert_eq!((after.sim.sams[0].air_48n6_stock, after.sim.sams[2].ground_stock, after.sim.sams[2].air_48n6_stock), (1, 0, 0)); } + /// #128 end to end: A declares a return and drives off, B waits for a round; the truck must + /// serve B anyway (a save/load mid-flight included), and once A is back the actual return + /// completes. Rounds are conserved throughout. + #[test] + fn a_moving_return_claim_drives_and_supplies_and_survives_a_state_round_trip() { + struct Flat; + impl sim::world::WorldSource for Flat { + fn solid(&self, _: i32, y: i32, _: i32) -> Option { Some(y < 64) } + } + let mut game = test_mod(); + for (kind, x) in [(SamKind::Depot, 0.0), (SamKind::S400, 120.0), (SamKind::S400, 240.0), (SamKind::Tzm, 0.0)] { + let id = game.sim.id(); + game.sim.sams.push(Sam::new(id, kind, DVec3::new(x, 64.0, 0.0), 0.0)); + } + game.sim.sams[0].ground_stock = 2; + game.sim.sams[1].ground_rounds = 1; + game.sim.sams[1].missiles = 3; + game.sim.unload_ground_round(1).unwrap(); + assert!(game.sim.sams[1].ground_return); + game.sim.sams[2].missiles = 3; + // B is out of the depot's reach: the load becomes a pending delivery request. + game.sim.load_ground_round(2).unwrap(); + assert!(game.sim.sams[2].ground_request); + // A drives away: its return claim is impossible for a while. + assert!(game.sim.order_move(1, DVec3::new(420.0, 64.0, 0.0))); + let mut out = Out::default(); + let mut restored_mid_flight = false; + let mut supplied = false; + for _ in 0..12000 { + let now = game.sim.tick; + let cols = game.ground.take_wanted().into_iter().map(|(x, z)| (x, z, 64)).collect(); + game.ground.answer(cols, now); + let sections = game.ground.world.take_wanted(now); + game.ground.world.serve(&Flat, sections, now); + game.sim.step(&mut game.ground, None, &mut out); + if !restored_mid_flight && game.sim.sams[3].ground_stock == 1 { + // The truck took a round for B: save, reload, continue from the restored state. + let saved = game.state_text(false, false); + let mut restored = test_mod(); + restored.apply_state(&saved).unwrap(); + game = restored; + restored_mid_flight = true; + } + if game.sim.sams[2].ground_rounds == 1 { supplied = true; break; } + out = Out::default(); + } + assert!(restored_mid_flight, "truck never picked up a round for B"); + assert!(supplied, "B starved while A's return was impossible"); + assert!(game.sim.sams[1].ground_return, "claim must survive the supply run"); + assert_eq!(game.sim.sams[0].ground_stock + game.sim.sams[3].ground_stock + game.sim.sams[1].ground_rounds + game.sim.sams[2].ground_rounds, 3); + // A returns to its place; the waiting claim is then collected and driven to the depot. + assert!(game.sim.order_move(1, DVec3::new(120.0, 64.0, 0.0))); + let mut returned = false; + for _ in 0..12000 { + let now = game.sim.tick; + let cols = game.ground.take_wanted().into_iter().map(|(x, z)| (x, z, 64)).collect(); + game.ground.answer(cols, now); + let sections = game.ground.world.take_wanted(now); + game.ground.world.serve(&Flat, sections, now); + game.sim.step(&mut game.ground, None, &mut out); + if game.sim.sams[0].ground_stock == 2 && game.sim.sams[1].ground_rounds == 0 { returned = true; break; } + out = Out::default(); + } + assert!(returned); + assert!(!game.sim.sams[1].ground_return); + assert!(!game.sim.sams[3].return_cargo); + assert_eq!(game.sim.sams[0].ground_stock + game.sim.sams[3].ground_stock + game.sim.sams[1].ground_rounds + game.sim.sams[2].ground_rounds, 3); + } + /// #83: while an export records no other replay or film replaces it; after it, they may. #[test] fn typed_ammunition_changes_mark_the_state_dirty() { diff --git a/airdef-mod/src/sim/logistics.rs b/airdef-mod/src/sim/logistics.rs index 2df706a..fb3e945 100644 --- a/airdef-mod/src/sim/logistics.rs +++ b/airdef-mod/src/sim/logistics.rs @@ -119,41 +119,59 @@ impl Sim { let at = truck.pos; let truck_id = truck.id; // A return is picked up physically; its cargo remains marked until a depot accepts it. + // The return keeps its priority only while it can actually proceed (#128): a carrier + // holding a reserved round needs a depot with room in reach, an empty one a system + // ready to hand a round over (whole, deployed, not claimed by another truck, a + // reachable route). A return that is merely claimed but impossible used to clear the + // job and stop the truck, starving independent supply requests; the claim now stays + // untouched and only real RM48U demand pulls the truck away from its watch. + let return_cargo = truck.return_cargo && truck.ground_stock > 0; let can_pick = truck.ground_stock == 0 && truck.air_48n6_stock < TRUCK_LOAD; - let return_work = truck.return_cargo || (can_pick && self.sams.iter().any(|s| s.kind == SamKind::S400 && s.alive() && s.ground_return)); - if return_work { - let taken: Vec = self.sams.iter().filter(|s| s.kind == SamKind::Tzm && s.alive() && s.id != truck_id).filter_map(|s| s.job).collect(); - let target = if truck.return_cargo { - self.sams.iter().filter(|s| s.kind == SamKind::Depot && s.alive() && s.ground_stock < STOCK_LIMIT && !self.routes.out_of_reach(truck_id, s.id, now)).min_by(|a,b| a.pos.distance(at).total_cmp(&b.pos.distance(at))) - } else { - self.sams.iter().filter(|s| s.kind == SamKind::S400 && s.alive() && s.ground_return && s.ground_rounds > 0 && !s.moving() && s.preparing.is_none() && s.reloading == 0 && s.part(Part::Launcher) > 0.0 && !taken.contains(&s.id) && !self.routes.out_of_reach(truck_id, s.id, now)).min_by(|a,b| a.pos.distance(at).total_cmp(&b.pos.distance(at))) - }.map(|s| (s.id, s.pos)); - self.sams[t].job = target.map(|x| x.0); - if let Some((id, to)) = target { - if at.distance(to) > HANDOVER { - let side = horizontal(at - to).normalize_or(DVec3::X) * (HANDOVER * 0.6); - self.order_move_near(t, to + side, 3.5); - } else if let Some(j) = self.sams.iter().position(|s| s.id == id) { - if self.sams[t].return_cargo { - self.sams[j].ground_stock += 1; - self.sams[t].ground_stock -= 1; - self.sams[t].return_cargo = false; - } else { - self.sams[j].ground_rounds -= 1; - self.sams[j].ground_return = false; - self.sams[t].ground_stock += 1; - self.sams[t].return_cargo = true; - if self.sams[t].air_48n6_stock > 0 && self.sams[j].missiles + self.sams[j].ground_rounds < self.sams[j].cells() { - self.sams[t].air_48n6_stock -= 1; - self.sams[j].missiles += 1; - } + let claim_pending = return_cargo || (can_pick && self.sams.iter().any(|s| s.kind == SamKind::S400 && s.alive() && s.ground_return)); + let taken: Vec = self.sams.iter().filter(|s| s.kind == SamKind::Tzm && s.alive() && s.id != truck_id).filter_map(|s| s.job).collect(); + let target = if return_cargo { + self.sams.iter().filter(|s| s.kind == SamKind::Depot && s.alive() && s.ground_stock < STOCK_LIMIT && !self.routes.out_of_reach(truck_id, s.id, now)).min_by(|a,b| a.pos.distance(at).total_cmp(&b.pos.distance(at))) + } else if can_pick { + self.sams.iter().filter(|s| s.kind == SamKind::S400 && s.alive() && s.ground_return && s.ground_rounds > 0 && !s.moving() && s.preparing.is_none() && s.reloading == 0 && s.part(Part::Launcher) > 0.0 && !taken.contains(&s.id) && !self.routes.out_of_reach(truck_id, s.id, now)).min_by(|a,b| a.pos.distance(at).total_cmp(&b.pos.distance(at))) + } else { + None + }.map(|s| (s.id, s.pos)); + if let Some((id, to)) = target { + self.sams[t].job = Some(id); + if at.distance(to) > HANDOVER { + let side = horizontal(at - to).normalize_or(DVec3::X) * (HANDOVER * 0.6); + self.order_move_near(t, to + side, 3.5); + } else if let Some(j) = self.sams.iter().position(|s| s.id == id) { + if self.sams[t].return_cargo { + self.sams[j].ground_stock += 1; + self.sams[t].ground_stock -= 1; + self.sams[t].return_cargo = false; + } else { + self.sams[j].ground_rounds -= 1; + self.sams[j].ground_return = false; + self.sams[t].ground_stock += 1; + self.sams[t].return_cargo = true; + if self.sams[t].air_48n6_stock > 0 && self.sams[j].missiles + self.sams[j].ground_rounds < self.sams[j].cells() { + self.sams[t].air_48n6_stock -= 1; + self.sams[j].missiles += 1; } - self.sams[t].job = None; - out.sound("minecraft:block.piston.extend", at, 1.5, 0.8); } + self.sams[t].job = None; + out.sound("minecraft:block.piston.extend", at, 1.5, 0.8); } continue; } + if claim_pending { + // The impossible return still outranks a mere stock run: without RM48U demand + // the truck holds its watch (and drops a stale job) exactly as before. + let supply_demand = (self.sams[t].ground_stock > 0 && self.sams.iter().any(|s| s.alive() && s.ground_request)) + || (self.sams.iter().any(|s| s.kind == SamKind::S400 && s.alive() && s.ground_request) + && self.sams.iter().any(|s| s.kind == SamKind::Depot && s.alive() && s.ground_stock > 0)); + if !supply_demand { + self.sams[t].job = None; + continue; + } + } // Typed strike cargo takes precedence over generic reloads. Jobs are reconstructed // after loading a save; the request and physical cargo survive independently. let typed_work = truck.ground_stock > 0 || truck.air_48n6_stock > 0 || @@ -176,6 +194,8 @@ impl Sim { let take = self.sams[t].ground_stock.min(STOCK_LIMIT - self.sams[j].ground_stock); self.sams[j].ground_stock += take; self.sams[t].ground_stock -= take; + // The last reserved round left the truck: nothing marks as return cargo any more. + if self.sams[t].ground_stock == 0 { self.sams[t].return_cargo = false; } } if self.sams[t].ground_stock == 0 && self.sams[j].ground_stock > 0 && self.sams[t].air_48n6_stock == 0 && self.sams.iter().any(|s| s.kind == SamKind::S400 && s.alive() && s.ground_request) { self.sams[j].ground_stock -= 1; @@ -188,6 +208,9 @@ impl Sim { if self.sams[j].ground_rounds >= self.sams[j].cells() && self.sams[j].kind.spec().gun.is_none() { self.sams[j].empty = false; } self.sams[j].ground_request = false; self.sams[t].ground_stock -= 1; + // A round reserved for return redirected to a waiting request is no + // longer return cargo once the last one leaves the truck (#128). + if self.sams[t].ground_stock == 0 { self.sams[t].return_cargo = false; } self.sams[t].air_48n6_stock += displaced; out.sound("minecraft:block.piston.extend", at, 1.5, 0.8); } @@ -408,4 +431,170 @@ mod tests { assert!(!sim.sams[1].ground_request); } + /// Runs `step_supply`, jumping the truck beside its current job each tick so handovers + /// resolve without a full drive (movement itself is covered by the lib round-trip tests). + /// Fails after 200 ticks if `done` never holds. + fn serve_until(sim: &mut Sim, truck: usize, mut done: impl FnMut(&Sim) -> bool) { + let mut ok = false; + for _ in 0..200 { + if done(sim) { ok = true; break; } + if let Some(id) = sim.sams[truck].job() + && let Some(j) = sim.sams.iter().position(|s| s.id == id) + { + sim.sams[truck].pos = sim.sams[j].pos; + } + sim.sams[truck].state = SamState::Search; + sim.tick += 1; + sim.step_supply(&mut Out::default()); + } + assert!(ok, "condition not reached within 200 step_supply calls"); + } + + /// #128: a return that is claimed but cannot proceed (here: the returning system is on the + /// move) must not stop the free truck with job=None; an independent typed request is served. + /// The claim stays, the rounds are conserved, and once the system stops the return completes. + #[test] + fn a_moving_return_claim_does_not_starve_an_independent_supply_request() { + let mut sim = Sim::new(5); + for (kind, x) in [(SamKind::Depot, 0.0), (SamKind::S400, 40.0), (SamKind::S400, 160.0), (SamKind::Tzm, 0.0)] { + let id = sim.id(); sim.sams.push(Sam::new(id, kind, DVec3::new(x, 70.0, 0.0), 0.0)); + } + let (depot, a, b, truck) = (0usize, 1usize, 2usize, 3usize); + let b_id = sim.sams[b].id; + sim.sams[depot].ground_stock = 2; + sim.sams[a].ground_rounds = 1; + sim.sams[a].missiles = 3; + sim.sams[a].ground_return = true; + sim.sams[b].ground_request = true; + assert!(sim.order_move(a, DVec3::new(400.0, 70.0, 0.0))); + // The free truck loads one RM48U at the depot and starts driving to B within 100 ticks; + // before the fix it entered the return branch, found the moving A impossible and idled. + let mut started = false; + for _ in 0..100 { + sim.tick += 1; + sim.step_supply(&mut Out::default()); + if sim.sams[truck].job() == Some(b_id) { started = true; break; } + } + assert!(started, "truck never started the delivery to B"); + // The claim and the rounds are untouched by the fallback. + assert!(sim.sams[a].ground_return); + assert_eq!(sim.sams[a].ground_rounds, 1); + assert_eq!((sim.sams[depot].ground_stock, sim.sams[truck].ground_stock), (1, 1)); + assert_eq!(sim.sams[depot].ground_stock + sim.sams[truck].ground_stock + sim.sams[a].ground_rounds + sim.sams[b].ground_rounds, 3); + // Hand the round over to B; the full drive is exercised in the lib round-trip test. + serve_until(&mut sim, truck, |s| s.sams[b].ground_rounds == 1); + assert!(!sim.sams[b].ground_request); + assert_eq!((sim.sams[b].missiles, sim.sams[truck].air_48n6_stock), (3, 1)); + assert_eq!(sim.sams[depot].ground_stock + sim.sams[truck].ground_stock + sim.sams[a].ground_rounds + sim.sams[b].ground_rounds, 3); + // No request is left, so the truck holds its watch (a stock run aside): the pending + // return still outranks it and the claim survives. + for _ in 0..20 { sim.tick += 1; sim.step_supply(&mut Out::default()); } + assert!(sim.sams[a].ground_return); + assert_eq!(sim.sams[a].ground_rounds, 1); + // A stops: the actual return is picked up and driven back to the depot. + sim.sams[a].state = SamState::Search; + sim.sams[a].dest = None; + serve_until(&mut sim, truck, |s| s.sams[truck].return_cargo); + assert!(!sim.sams[a].ground_return); + assert_eq!((sim.sams[a].ground_rounds, sim.sams[a].missiles), (0, 4)); + assert_eq!((sim.sams[truck].ground_stock, sim.sams[truck].air_48n6_stock), (1, 0)); + serve_until(&mut sim, truck, |s| s.sams[depot].ground_stock == 2); + assert!(!sim.sams[truck].return_cargo); + assert_eq!((sim.sams[depot].ground_stock, sim.sams[truck].ground_stock, sim.sams[b].ground_rounds), (2, 0, 1)); + assert_eq!(sim.sams[depot].ground_stock + sim.sams[truck].ground_stock + sim.sams[a].ground_rounds + sim.sams[b].ground_rounds, 3); + } + + /// #128 variant: the pickup is already claimed by another truck, so for this one the return + /// is impossible; it must turn to the waiting request instead of idling with job=None. A + /// dropped job (as after loading a save) is rebuilt, damaged trucks wait, and the repaired + /// claiming truck keeps the priority of the now executable return over the request. + #[test] + fn a_return_claimed_by_another_truck_leaves_the_supply_to_the_second_one() { + let mut sim = Sim::new(7); + for (kind, x) in [(SamKind::Depot, 0.0), (SamKind::S400, 160.0), (SamKind::S400, 200.0), (SamKind::Tzm, 0.0), (SamKind::Tzm, 150.0)] { + let id = sim.id(); sim.sams.push(Sam::new(id, kind, DVec3::new(x, 70.0, 0.0), 0.0)); + } + let (depot, a, b, t2, t1) = (0usize, 1usize, 2usize, 3usize, 4usize); + let (a_id, b_id) = (sim.sams[a].id, sim.sams[b].id); + sim.sams[depot].ground_stock = 2; + sim.sams[a].ground_rounds = 1; + sim.sams[a].missiles = 3; + sim.sams[a].ground_return = true; + sim.sams[b].ground_request = true; + // The first truck claimed A's pickup earlier; both trucks start damaged: nothing + // moves, no round is lost, the claim waits. + sim.sams[t1].job = Some(a_id); + sim.sams[t1].comps.0[Part::Tracks as usize] = 0.0; + sim.sams[t2].comps.0[Part::Tracks as usize] = 0.0; + for _ in 0..30 { sim.tick += 1; sim.step_supply(&mut Out::default()); } + assert_eq!(sim.sams[t2].job(), None); + assert_eq!((sim.sams[depot].ground_stock, sim.sams[b].ground_rounds), (2, 0)); + assert!(sim.sams[a].ground_return); + assert_eq!(sim.sams[depot].ground_stock + sim.sams[t1].ground_stock + sim.sams[t2].ground_stock + sim.sams[a].ground_rounds + sim.sams[b].ground_rounds, 3); + // Repaired, the second truck must not idle on the claimed return: depot first, then B. + sim.sams[t2].comps.0[Part::Tracks as usize] = 1.0; + let mut started = false; + for _ in 0..100 { + sim.tick += 1; + sim.step_supply(&mut Out::default()); + if sim.sams[t2].job() == Some(b_id) { started = true; break; } + } + assert!(started, "second truck never started the delivery to B"); + assert_eq!((sim.sams[depot].ground_stock, sim.sams[t2].ground_stock), (1, 1)); + assert!(sim.sams[a].ground_return, "claimed return must survive the fallback"); + // A save/load drops the job; the request and the cargo survive and the job is rebuilt. + sim.sams[t2].job = None; + serve_until(&mut sim, t2, |s| s.sams[t2].job() == Some(b_id)); + // The claiming truck is repaired: the executable return has priority over the request, + // and the still-hauling second truck is not diverted from B. + sim.sams[t1].comps.0[Part::Tracks as usize] = 1.0; + serve_until(&mut sim, t1, |s| s.sams[t1].return_cargo); + assert!(!sim.sams[a].ground_return); + assert_eq!(sim.sams[a].ground_rounds, 0); + assert_eq!(sim.sams[t2].job(), Some(b_id), "hauling truck must keep its supply job"); + // B gets its round; the displaced air round rides along. + serve_until(&mut sim, t2, |s| s.sams[b].ground_rounds == 1); + assert_eq!((sim.sams[b].missiles, sim.sams[t2].air_48n6_stock), (3, 1)); + assert_eq!(sim.sams[depot].ground_stock + sim.sams[t1].ground_stock + sim.sams[t2].ground_stock + sim.sams[a].ground_rounds + sim.sams[b].ground_rounds, 3); + // And the picked-up return reaches the depot: everything is back except B's new round. + serve_until(&mut sim, t1, |s| s.sams[depot].ground_stock == 2); + assert!(!sim.sams[t1].return_cargo); + assert_eq!((sim.sams[depot].ground_stock, sim.sams[t1].ground_stock, sim.sams[b].ground_rounds), (2, 0, 1)); + } + + /// #128 variant: the route to the returning system is marked unreachable, so the return is + /// impossible for now; the truck serves the request, and the return completes once the + /// route is passable again and the system is still waiting with its claim. + #[test] + fn an_unreachable_route_falls_back_to_supply_until_it_expires() { + let mut sim = Sim::new(9); + for (kind, x) in [(SamKind::Depot, 0.0), (SamKind::S400, 40.0), (SamKind::S400, 160.0), (SamKind::Tzm, 0.0)] { + let id = sim.id(); sim.sams.push(Sam::new(id, kind, DVec3::new(x, 70.0, 0.0), 0.0)); + } + let (depot, a, b, truck) = (0usize, 1usize, 2usize, 3usize); + let (a_id, b_id) = (sim.sams[a].id, sim.sams[b].id); + sim.sams[depot].ground_stock = 2; + sim.sams[a].ground_rounds = 1; + sim.sams[a].missiles = 3; + sim.sams[a].ground_return = true; + sim.sams[b].ground_request = true; + sim.routes.unreachable.insert((sim.sams[truck].id, a_id), u64::MAX); + let mut started = false; + for _ in 0..100 { + sim.tick += 1; + sim.step_supply(&mut Out::default()); + if sim.sams[truck].job() == Some(b_id) { started = true; break; } + } + assert!(started, "truck never started the delivery to B"); + assert!(sim.sams[a].ground_return, "claim must survive the blocked route"); + serve_until(&mut sim, truck, |s| s.sams[b].ground_rounds == 1); + // The route is passable again: the waiting claim is picked up and returned. + sim.routes.unreachable.clear(); + serve_until(&mut sim, truck, |s| s.sams[truck].return_cargo); + assert!(!sim.sams[a].ground_return); + serve_until(&mut sim, truck, |s| s.sams[depot].ground_stock == 2); + assert!(!sim.sams[truck].return_cargo); + assert_eq!(sim.sams[depot].ground_stock + sim.sams[truck].ground_stock + sim.sams[a].ground_rounds + sim.sams[b].ground_rounds, 3); + } + } diff --git a/docs/GROUND-STRIKES.md b/docs/GROUND-STRIKES.md index 34d618a..0ae5dd5 100644 --- a/docs/GROUND-STRIKES.md +++ b/docs/GROUND-STRIKES.md @@ -66,3 +66,9 @@ `ground-unload` у склада выгружает одну RM48U сразу; вдали резервирует её и вызывает ТЗМ для вывоза. `ground-cancel` отменяет ещё ожидающую доставку/вывоз. Уже забранная ракета находится в ТЗМ и продолжает путь на склад; команда не телепортирует её обратно в ПУ. + +Возврат имеет приоритет, только пока он выполним: ПУ исправна и развёрнута, маршрут доступен, +ракету ещё не забирает другая ТЗМ, а у склада есть место. Заявка при этом сохраняется, но +невыполнимый вывоз не блокирует ТЗМ: свободная машина исполняет независимые заявки на доставку +RM48U, а как только вывоз снова становится выполнимым, он встаёт в приоритет. Проверено +регрессиями #128, включая консервацию боеприпасов и save-restore посреди маршрута. diff --git a/docs/IMPLEMENTATION-STATUS.md b/docs/IMPLEMENTATION-STATUS.md index cdd7a7f..0956dbb 100644 --- a/docs/IMPLEMENTATION-STATUS.md +++ b/docs/IMPLEMENTATION-STATUS.md @@ -29,6 +29,7 @@ | #115 | Три пилота с кадрами до/после, спокойные материалы; authored-проход по боевым и вспомогательным машинам | Крупные планы и точность прототипов ещё промежуточные; остальные летательные аппараты/ракеты, анимации, дальние виды и FPS | | #116 | Streaming не освобождает busy-guard работающего acoustic worker; проверено engine-тестами | Внешняя игровая приёмка на целевых машинах | | #118 | Физический С-400/RM48U, четыре общие ячейки, конечные запасы; доставка и обратный вывоз ТЗМ, резерв/отмена, возврат снятой ПВО; обе фазы вывозного маршрута сохраняются; carrier/крышки/выброс/зажигание/разгон в записи; фантомное ПВО удалено | Визуальная приёмка пуска и маршрутов на сложном рельефе; другие носители не добавлены без подтверждённого профиля | +| #128 | Невыполнимый возврат RM48U (ПУ движется/повреждена, маршрут недоступен, заявку уже везёт другая ТЗМ, у склада нет места) больше не блокирует ТЗМ: заявка сохраняется, свободная ТЗМ исполняет независимые заявки на доставку; выполнимый возврат снова в приоритете; консервация боеприпасов и save-restore посреди маршрута покрыты регрессиями | Игровая приёмка маршрутов на сложном рельефе остаётся за #118 | Для #118 RM48U — явно условный Minecraft-профиль. Существование сочетания опирается на сообщение ведомства, а игровые параметры не выдаются за реальные. `ground-stock` — команда подготовки sandbox-сценария,