diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 41a423ca..6317860e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -389,7 +389,7 @@ Three standing orders, all per unit and all honoured by the AI as well as the pl | Order | Effect | |---|---| | `SET_EMCON` | silent turns off radar and active sonar together; free switches the radar back on | -| `SET_ROE` | HOLD refuses every engagement including automatic defence; TIGHT defends only; FREE fights | +| `SET_ROE` | HOLD refuses every engagement including automatic defence; TIGHT defends itself and fires only on HOSTILE contacts when ordered; FREE fights, and a commander's ship on FREE with automatic air defence engages identified hostile aircraft in its envelope (`AirDefence.engage_hostile_aircraft`) | | `FORM_UP` / `BREAK_FORMATION` | station keeping on a leader, or steering for yourself again | `AIController._manage_emissions()` makes emissions a decision rather than a default: a ship with @@ -426,7 +426,10 @@ the target's speed, so a ship that has heard the weapon and is running is a much works out which friendly ship each detected round is going for (`threatened_unit`: the locked-on ship, else closest point of approach within 3 nm), sorts by time to impact, and lets **any** ship in range engage. Escorts defend consorts, not just - themselves. + themselves. The force allocates as one, in two passes over the raid: every round first gets + one interceptor from the best-placed ship (`_rank_defenders`: a free guidance channel first, + then the nearest), and only then are budgets topped up and close-in guns given their turn. + One time-to-impact, the round's time to the ship it is going for, decides the budget. 4. `Unit.defensive_weapons()` returns interceptors longest-reach first, so layering is a property of the data rather than hard-coded tiers. At most two interceptors are committed per round at any moment; when they miss, the next layer gets its turn. @@ -441,8 +444,15 @@ the target's speed, so a ship that has heard the weapon and is running is a much ### Engagement allowances Defence is deliberately finite, so a large enough salvo gets through a good escort: - `fire_control_channels` per platform caps how many rounds one ship can guide against at once. -- Two guided interceptors per round over its whole flight (shoot-shoot-look), and at most two in - the air at any moment. +- At most two guided interceptors in the air against a round at any moment, across the force + (`guided_budget`: one under `conserve` until the round is 45 s out, three under `saturation`). +- Each ship's layer has its own lifetime allowance against a round (`AirDefence.layer_key`, layer + and ship), so an escort's point-defence missiles never use up the defended ship's own. +- Networked fire control (`PlatformSpec.cooperative_engagement`): between two fitted ships on the + link, a radar-guided interceptor may be launched at a round below the shooter's own horizon when + the consort's emitting radar holds it inside its own (`WeaponManager.network_guide`), and the + shot fails if that support is lost. The same applies to aircraft on the plot + (`network_track_guide`). An unfitted consort's plot remains a cue only. Gameplay abstraction. - Close-in weapons are exempt from channels and the guided allowance, since they are self-contained, but get their own allowance of two bursts. One burst is modelled as a single engagement, so a close-in magazine counts bursts rather than rounds. diff --git a/CURRENT_STATE.md b/CURRENT_STATE.md index 489176ab..fa9bea5b 100644 --- a/CURRENT_STATE.md +++ b/CURRENT_STATE.md @@ -1,4 +1,30 @@ -# Current State: M36 + Command and recognition improvements +# Current State: M37 Integrated air defence, weapon fixes and flight operations + +**Weapons.** +- A refused fire order now says why. +- A standing attack gives up on a plot that stays stale for four minutes. +- Rounds queued at a ship the plot records as sunk are refunded. +- Air-to-air and anti-air "direct" rounds fly at height instead of dying over land. +- A commander's ship on Weapons Free engages identified hostile aircraft in its envelope, one round at a time. +- The AI fires two SAMs at a fighter, not four. + +**The air-defence network.** +- The force allocates as one: every inbound round gets an interceptor before any round gets a second, and a ship with a free guidance channel is asked first. +- Each ship keeps its own inner-layer allowance. +- Fitted ships on the link (`cooperative_engagement`: the 2027 US carriers, cruisers, destroyers and frigate, and Maya) can guide each other's shots below the shooter's own horizon. +- The Air Defence board names who has each round. + +**Flight operations.** +- Jets hold instead of stopping in the air. +- An aircraft launched without orders holds ahead of its ship. +- Aircraft back at a busy or burning deck hold in a marshal stack astern. +- Helicopters are turned round in helicopter-deck time aboard a carrier. +- CAP intercepts are flown at dash. +- A deck can hold 2 or 4 fighters on ready alert and scrambles them toward a raid itself. + +The [M37 note](docs/2026-10-03-integrated-air-defence-and-flight-ops.md) gives the audit findings, the design, before-and-after battle measurements and what was deliberately left alone. + +# Previous: M36 + Command and recognition improvements 22 aircraft and all 14 submarine entries now use improved original recognition models, with matching portraits, thumbnails, profiles and tactical-map plans. Aircraft have tapered wings, framed canopies, intake openings and clearer family silhouettes. Submarines have rounded bows, faired sails, corrected bow/sail planes, class-specific stern controls and open pump-jet shrouds or screws. The [combined art and command review](docs/2026-10-02-aircraft-submarines-and-integration.md) records scope and integration checks. diff --git a/HANDOFF.md b/HANDOFF.md index ea266ff0..6cac56bb 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,5 +1,14 @@ # Handoff +Latest: M37, [integrated air defence, weapon fixes and flight operations](docs/2026-10-03-integrated-air-defence-and-flight-ops.md). +- **Defence cycle.** `AirDefence.run_cycle` is now two passes over the raid, ranked by `_rank_defenders`. The per-layer lifetime allowance is keyed by `AirDefence.layer_key` (layer and ship). +- **Networked guidance.** It lives in `WeaponManager.guidance_available`, `network_guide` and `network_track_guide`, gated by `PlatformSpec.cooperative_engagement` on both ships. Interceptors no longer take the shooter-radar check at the top of `_step`; `_step_interceptor` checks guidance itself. +- **Weapons Free against aircraft.** `AirDefence.engage_hostile_aircraft` runs only for a player side with no AI controller. +- **Holds.** `Movement.enter_hold` lays a racetrack and marks it (`Unit.hold_active`/`hold_point`). Anything deciding whether a unit is idle must use `Unit.has_route()`, not `waypoints.is_empty()`; the AI search did the latter and stalled until it was changed. +- **Marshal.** `AviationManager.deck_can_recover`, `in_marshal` and `marshal_point`. +- **Ready alert.** `Unit.ready_alert`, `Order.set_ready_alert` (a new `SET_READY_ALERT` order type, appended to the enum) and `AirMissionManager._scramble_alerts`. +- **Generators.** They need `shapely`. Even with it, the generator sequence did not reproduce `data/` byte for byte in the cloud container, so data was edited by hand to match the generator output, and the generator carries the same change. + Latest art pass: 22 aircraft and all 14 submarine catalogue entries have revised original geometry, matching portraits/map plans and a shared `submarine_coating` world finish. `tools/art/build_air_subs.py` overrides the old builders; regenerate with `tools/art/rebuild_air_subs.sh` (copies donor derivatives last). Do not regenerate these entries with the legacy Blender source. `tools/recognition_playtest.gd` checks every revised entry through the real Reference viewer, including all three camera presets. See [the combined art and command review](docs/2026-10-02-aircraft-submarines-and-integration.md). Latest deeper pass: [Classic, uncertainty and guided command](docs/2026-10-02-deep-command-review.md). `CommandGuide` observes accepted player orders and held reports; progress is saved in Main's presentation dictionary, not simulation fields. Briefing F1 can resume it. New native coverage: `tools/deep_command_playtest.gd`. Classic is now [1, 2, 4, 8], manual SAMs, auto-attack off. Explicit saved values always win; legacy 4×/auto-attack remains Custom. `WorldPresentation.plotted_entry` must never read a hidden specification: class representatives come from `MapSymbols.platform_for_class`, altitude from the Track. Visual sightings now also check terrain. Known sensor-only models are tinted, and captions distinguish SIGHTED, SENSOR ESTIMATE and BEARING ONLY/NO CURRENT FIX. diff --git a/data/platforms/surface/usn_cg_ticonderoga.tres b/data/platforms/surface/usn_cg_ticonderoga.tres index ebc0d7d8..f88f210f 100644 --- a/data/platforms/surface/usn_cg_ticonderoga.tres +++ b/data/platforms/surface/usn_cg_ticonderoga.tres @@ -22,6 +22,7 @@ sensor_ids = PackedStringArray("an_spy_1b", "an_sqs_53c", "an_slq_32") mast_height_m = 32.0 aircraft_capacity = 2 fire_control_channels = 8 +cooperative_engagement = true decoy_count = 18 torpedo_decoy = "AN/SLQ-25 Nixie" towed_torpedo_decoy = true diff --git a/data/platforms/surface/usn_cvn_ford.tres b/data/platforms/surface/usn_cvn_ford.tres index ddb86981..db0bec0a 100644 --- a/data/platforms/surface/usn_cvn_ford.tres +++ b/data/platforms/surface/usn_cvn_ford.tres @@ -22,6 +22,7 @@ sensor_ids = PackedStringArray("ford_dbr", "an_slq_32") mast_height_m = 50.0 aircraft_capacity = 75 fire_control_channels = 2 +cooperative_engagement = true decoy_count = 24 torpedo_decoy = "AN/SLQ-25 Nixie" towed_torpedo_decoy = true diff --git a/data/platforms/surface/usn_cvn_nimitz.tres b/data/platforms/surface/usn_cvn_nimitz.tres index 36fc1c0a..ec591a3d 100644 --- a/data/platforms/surface/usn_cvn_nimitz.tres +++ b/data/platforms/surface/usn_cvn_nimitz.tres @@ -22,6 +22,7 @@ sensor_ids = PackedStringArray("an_sps_48", "an_slq_32") mast_height_m = 50.0 aircraft_capacity = 68 fire_control_channels = 2 +cooperative_engagement = true decoy_count = 24 torpedo_decoy = "AN/SLQ-25 Nixie" towed_torpedo_decoy = true diff --git a/data/platforms/surface/usn_ddg_arleigh_burke_iia.tres b/data/platforms/surface/usn_ddg_arleigh_burke_iia.tres index f918024a..2677acf3 100644 --- a/data/platforms/surface/usn_ddg_arleigh_burke_iia.tres +++ b/data/platforms/surface/usn_ddg_arleigh_burke_iia.tres @@ -24,6 +24,7 @@ health = 110.0 weapon_loadout = {"rgm_139_vla": 8, "mk45_mod4_gun": 250, "sm2_family": 24, "essm_family": 32, "phalanx_ciws": 60, "tomahawk_block_v": 8} aircraft_capacity = 2 fire_control_channels = 6 +cooperative_engagement = true decoy_count = 12 decoy_effectiveness = 0.35 torpedo_decoy = "AN/SLQ-25 Nixie" diff --git a/data/platforms/surface/usn_ddg_burke_iii.tres b/data/platforms/surface/usn_ddg_burke_iii.tres index 375f2c7f..01450c15 100644 --- a/data/platforms/surface/usn_ddg_burke_iii.tres +++ b/data/platforms/surface/usn_ddg_burke_iii.tres @@ -19,6 +19,7 @@ sensor_ids = PackedStringArray("an_spy_6", "an_sqs_53c", "an_slq_32") mast_height_m = 30.0 aircraft_capacity = 2 fire_control_channels = 8 +cooperative_engagement = true decoy_count = 16 torpedo_decoy = "AN/SLQ-25 Nixie" towed_torpedo_decoy = true diff --git a/data/platforms/surface/usn_ffg_constellation.tres b/data/platforms/surface/usn_ffg_constellation.tres index 554d96cd..4f0e0258 100644 --- a/data/platforms/surface/usn_ffg_constellation.tres +++ b/data/platforms/surface/usn_ffg_constellation.tres @@ -22,6 +22,7 @@ sensor_ids = PackedStringArray("an_spy_6_v3", "captas_mk2", "an_slq_32") mast_height_m = 26.0 aircraft_capacity = 2 fire_control_channels = 4 +cooperative_engagement = true decoy_count = 14 weapon_loadout = {"sm2_family": 16, "essm_family": 32, "nsm_strike_missile": 16, "ram_block2": 21, "mk110_57mm_gun": 200, "mk54_lwt": 6} source_status = "PUBLIC: designation and broad role; see DATA_SOURCES.md. GAMEPLAY_ESTIMATE: all numeric values, signatures, loadouts, channels and sensor envelopes. Not an operational capability claim." diff --git a/data/platforms/theatres/jmsdf_ddg_maya.tres b/data/platforms/theatres/jmsdf_ddg_maya.tres index 2d560781..9d8e0387 100644 --- a/data/platforms/theatres/jmsdf_ddg_maya.tres +++ b/data/platforms/theatres/jmsdf_ddg_maya.tres @@ -28,5 +28,6 @@ length_m = 170.0 displacement_t = 10250.0 aircraft_capacity = 1 vls_cells = 96 +cooperative_engagement = true default_air_wing = {"jmsdf_helo_sh60k": 1} source_status = "PUBLIC: class identity, dimensions and broad fit; see docs/THEATRES_2027.md. GAMEPLAY_ESTIMATE: ranges, signatures, hit probability, damage, timings, magazines and detachments. A 2027 recognition model, not a capability claim." diff --git a/docs/2026-10-03-integrated-air-defence-and-flight-ops.md b/docs/2026-10-03-integrated-air-defence-and-flight-ops.md new file mode 100644 index 00000000..7ff804e0 --- /dev/null +++ b/docs/2026-10-03-integrated-air-defence-and-flight-ops.md @@ -0,0 +1,110 @@ +# Integrated air defence, weapon fixes and flight operations + +This is an entertainment simulation using public platform families and estimated combat parameters. It does not reproduce Aegis software, any real combat system's behaviour, classified performance or doctrine. "Networked fire control" below is a game rule, described only in game terms. + +The owner's complaint was specific: weapons systems don't work, there is no integrated air-defence network, and flight operations are clunky and unrealistic. Three read-only audits traced the weapon path, the shared air picture and the deck cycle end to end before anything was changed. Their findings are summarised under each heading. The fixes are listed in the order a player feels them. + +## Weapons + +**Refusals now say why.** An ENGAGE order from the contact menu, the dock, Ctrl+right-click or the Weapon Control board was refused with "Order refused by 1 selected platform" and no reason. `UnitManager.engage_rejection` gives the reason the order was refused, such as weapons hold, out of range, wrong weapon for the contact or no line of fire. The radio line and the firing board's receipt now carry it. + +**A standing attack no longer parks on a stale plot.** It held fire, correctly, while the plot was stale, but it never gave up: a plot goes stale after 60 s and is not dropped for 30 minutes. After `ATTACK_STALE_S` (240 s) of closing on a plot nobody refreshes, the attack now ends and reports "Contact stale: no fresh plot to fire on". + +**Rounds still queued at a sunk ship go back in the magazine.** Once the plot records the contact as destroyed, queued rounds are cancelled with "TARGET DESTROYED" and refunded. + +**Air-to-air and anti-air "direct" rounds are not sea-skimmers.** AIM-9X, ASRAAM, IRIS-T, AAM-5, PL-10 and Mistral shared the `direct` profile with Hellfire and were treated as low flyers. A shot across a headland was refused and a round over land died. Those weapon types now fly at height. Air-to-surface `direct` weapons still meet the ground. + +**Weapons Free fights.** The player's ships never fired at hostile aircraft unless ordered, though the rules said Free "fights". `AirDefence.engage_hostile_aircraft` now has a commander's ship on Weapons Free, with automatic air defence on, engage an identified hostile aircraft in its envelope. +- It fires one round at a time, with at most two in the air at an aircraft across the side. +- It keeps the last four rounds of each missile type for missiles. +- Weapons Tight, Hold, manual missile defence (Classic) and anything not identified hostile are untouched. +- The AI's sides already did this through their own controllers. + +**The AI fires two SAMs at a fighter, not four.** Its salvo size for every missile was the four-round anti-ship volley, so it spent four long-range SAMs per aircraft. Anti-air weapons at an air contact now use the weapon's own salvo. + +## The integrated air-defence network + +Before this change the shared plot existed, but the defence cycle worked through inbound rounds one at a time and nearest ship first. In a raid, the two nearest ships both shot the first round while the second waited for a launcher to come free. A seeded Taiwan Strait run fired 46 interceptors at 16 detected rounds. + +**One allocation for the force.** `AirDefence.run_cycle` now makes two passes over the raid: +1. Every inbound round gets one interceptor from the best-placed ship. A ship with a guidance channel free is asked before a nearer, saturated one. +2. Budgets are topped up, and the close-in guns get their turn. + +One time-to-impact, to the ship the round is going for, decides the budget. Before, two checks used two different clocks, so the conserve policy could pass one and fail the other for the same shot. + +**Each ship keeps its own inner layer.** The lifetime allowance per layer was shared by the whole force. Two escort point-defence missiles at a round aimed at the carrier left the carrier unable to fire its own point-defence missiles at it. The allowance is now kept per layer and per ship (`AirDefence.layer_key`). + +**Networked fire control.** A new platform field, `cooperative_engagement`, is set on these hulls: +- Arleigh Burke IIA and III, Ticonderoga, Nimitz, Ford and Constellation (2027 catalogue). +- Maya, through its generator. +- Not on the 1990 Ticonderoga. + +Between two fitted ships on the link, a radar-guided interceptor can be launched at a round or aircraft below the shooter's own radar horizon. The consort's emitting radar must hold the target inside its own horizon, with no land in between. The shot fails if that support goes: the consort's radar switching off, either ship dropping off the link, or the target leaving the consort's horizon. An unfitted consort's plot is still only a cue. Weapons with their own active seeker never needed this and are unchanged. + +**What the player sees.** +- The Air Defence board names the ship engaging each inbound round, and how many more are on it. +- Its channel line reads "NETWORKED FIRE CONTROL n SHIPS" when two or more fitted ships are linked. +- A fitted ship's unit panel shows a NETWORKED FIRE CONTROL chip. + +## Flight operations + +**Jets no longer stop in the air.** A fixed-wing aircraft that reached its last waypoint, was ordered to stop, or finished an investigation with auto-return off sat at 0 kn. On the 0 kn floor of its fuel curve it burned less fuel than one on CAP. It now flies a 6 by 3 nm racetrack through that point at patrol speed (`Movement.enter_hold`). Helicopters still hover. + +**A launch without a mission no longer flies off the chart.** An aircraft launched from the LAUNCH tab used to fly the ship's heading at transit speed until bingo. It now holds 6 nm ahead of its ship until it is given orders. + +**Marshal.** Aircraft back at a deck that could not take them flew at the ship at transit speed and transit fuel burn, all at one point, overflying it each second. They now hold in a stack 5 nm astern at patrol speed: 1,500 m for fixed-wing, 150 m for helicopters. A deck counts as unable to take them when: +- the recovery spot is busy, or +- launches are in progress, or +- the flight deck is on fire. + +They are called down when the deck clears. The mission board shows MARSHAL. Mission launches now wait for aircraft in marshal, not only those within 3.5 nm, so recoveries come first. + +**No landing on a burning deck.** Launches were already refused at fire 0.35 or worse. Recoveries now are too, until the fire is under control. + +**A helicopter aboard a carrier is not turned round like a strike fighter.** Turnaround was the base's figure for every type, so an MH-60R took 45 minutes on a Nimitz. A helicopter now takes no longer than it would on a helicopter deck (30 minutes). + +**Air missions:** +- A CAP or ASW mission with "Back to station" off kept working after its first look, rather than holding with nothing to do until bingo. It now still intercepts the hostile it has just identified. +- A CAP intercept is flown at dash speed. The mission sets an airframe's speed when its state changes, not every second, so a dash the commander orders on station stands. +- A reconnaissance aircraft without sonar or buoys no longer flies to a submarine contact it can never identify and circles there until bingo. +- A strike whose launches were all called off ends "Strike called off", not "Strike complete". +- Bingo on a mission airframe, routine CAP rotation, no longer drops time compression to 1×. Bingo on any other aircraft still does. + +**Ready alert.** A deck with fighters can hold 2 or 4 on alert, from the right-click menu under Ready alert. When a hostile air contact comes within 60 nm, or within 120 nm and closing, the deck scrambles them itself. They go into a CAP laid toward the raid at half its range, at most 40 nm out, and the radio reports "ALERT LAUNCH". The alert is spent by the scramble. A deck already flying its own CAP keeps its alert for the next raid. The unit panel shows READY ALERT n. This is the "launch alert states" item from the M36 list of missing features, in a simplified form; there are no Alert 5/15/30 tiers. + +## Not changed, deliberately + +- **Ballistic rounds still fly at one altitude.** A test pins that every ballistic round in the catalogue has an interceptor whose window covers it. A descending trajectory is a model change, not a fix. +- **The Air Operations dialog still opens on the LAUNCH tab.** Two native interface suites in CI drive that tab by default. With the departure hold, a plain launch is no longer a trap. +- **Tanker give is still counted in the receiver's endurance-seconds**, as documented. The tanker's own fuel is debited in the same units, which is inconsistent but low impact. +- **Manual and queued ENGAGE rounds can still be fired at a stale plot.** The standing attack and group attack refuse one. Changing the ENGAGE path changes what the firing board offers and needs its own pass. + +## Validation + +The regression suite grew from 977 to 1,004 tests, with new files `tests/test_integrated_defence.gd` (15 tests) and `tests/test_flight_deck.gd` (8 tests), plus additions to the attack and air-mission suites. Two existing assertions were updated deliberately: +- An investigating jet now holds when its task ends, instead of flying straight on. +- A CAP fighter on its way back from an intercept, flown at dash, counts as working its station. + +Four operations were flown headless for 6,000 simulated seconds with the AI commanding both sides (`--autopilot --seed=2`), on `main` and on this branch. None had a script error. + +| Operation | Interceptors fired | Rounds intercepted | Leakers | Offensive rounds fired | Hits scored | +|---|---|---|---|---|---| +| Taiwan Strait, main | 46 | 12 | 6 | 43 | 2 | +| Taiwan Strait, this branch | 57 | 14 | 4 | 37 | 2 | +| Aegis Bastion, main | 0 | 0 | 0 | 40 | 5 | +| Aegis Bastion, this branch | 0 | 0 | 0 | 24 | 5 | +| Carrier Watch (1990), main | 60 | 15 | 1 | 16 | 0 | +| Carrier Watch (1990), this branch | 66 | 15 | 1 | 8 | 0 | +| Hormuz, main | 77 | 29 | 5 | 26 | 3 | +| Hormuz, this branch | 79 | 29 | 5 | 35 | 3 | + +Reading the table: +- In Taiwan Strait the raid is answered more completely: two more rounds stopped and two fewer leakers, for eleven more interceptors. +- Aegis Bastion scores the same five kills with 24 rounds instead of 40, because the AI no longer fires four long-range SAMs at each fighter. Carrier Watch's Tomcats likewise fire two Phoenix each instead of four. +- Hormuz's extra offensive rounds come from the other weapon-path changes; the outcome is unchanged. + +These runs have the AI playing the player's side, so they do not exercise Weapons Free against aircraft, which applies only to a side with no AI commander. A single seed per operation is a stability and direction check, not a balance study. + +One defect was found and fixed during validation. The first version of the jet hold made an AI search aircraft that reached a search point look busy, so it never took its next leg; in that build Carrier Watch's Tomcats fired nothing. A hold is now marked (`Unit.holding`, `Unit.has_route`), and the AI and the stalled-attack check treat a hold as no route. A test pins it. + +The scenario generators were not re-run for the data change: in this environment the sequence does not reproduce the committed scenario files byte for byte. The one generated platform file that changed (Maya) was edited by hand to match the generator output exactly, and the generator carries the same change. The browser build was not rebuilt. diff --git a/scripts/core/main.gd b/scripts/core/main.gd index 72fc75c0..befa0f3c 100644 --- a/scripts/core/main.gd +++ b/scripts/core/main.gd @@ -216,7 +216,10 @@ func _ready() -> void: radio.flash("Recovered", "info", a)) simulation.aviation_manager.aircraft_bingo.connect(func(a: Unit) -> void: if a.faction == simulation.player_faction: - SimClock.drop_to_realtime() + # A mission's airframe going home at bingo is the routine of a standing air task, and + # its relief is the mission's business: say it, but do not stop the clock for it. + if simulation.air_mission_manager.mission_for(a) == null: + SimClock.drop_to_realtime() radio.flash("Bingo fuel, returning", "warn", a) voice.say("aircraft_rtb", a)) simulation.air_mission_manager.mission_report.connect(func(m: AirMission, message: String, good: bool) -> void: @@ -2449,6 +2452,7 @@ func _apply_unit_orders(pairs: Array) -> void: var sample: Order = null var volleys := {} var rounds_committed := 0 + var first_refusal := "" for pair: Array in pairs: var u: Unit = pair[0] if u.faction != simulation.player_faction or not map.selected.has(u): @@ -2468,7 +2472,11 @@ func _apply_unit_orders(pairs: Array) -> void: rounds_committed += committed else: refused += 1 + if first_refusal == "" and pair[1].receipt != "": + first_refusal = "%s: %s" % [u.callsign, pair[1].receipt] if sample != null: + if accepted == 0 and first_refusal != "" and sample.type == Order.Type.ENGAGE: + sample.receipt = first_refusal var receipts := PackedStringArray() for volley: Dictionary in volleys.values(): receipts.append(_order_acknowledgement(Order.engage(volley["track"], volley["weapon"], volley["rounds"]))) @@ -2531,6 +2539,12 @@ func _report_orders(order: Order, accepted: int, refused: int, receipt_override if order.type == Order.Type.INTERCEPT: radio.advise("Cannot intercept: " + (order.receipt if order.receipt != "" else "no hooked ship can engage").to_lower()) return + if order.type == Order.Type.ENGAGE: + var reason := order.receipt + if reason == "" and not map.selected.is_empty(): + reason = UnitManager.engage_rejection(map.selected[0], order) + radio.advise("Cannot fire at track %s · %s" % [DataDisplay.track_number_for_track(order.track), reason if reason != "" else "no firing solution"]) + return radio.advise("Order refused by %d selected platform%s%s" % [refused, "" if refused == 1 else "s", " — pick a point in the water" if order.type == Order.Type.MOVE else ""]) if order.type == Order.Type.MOVE: map.add_effect(order.target_pos, "refused") @@ -2568,6 +2582,8 @@ static func _order_acknowledgement(order: Order) -> String: return "Returning to station, aye" Order.Type.SET_AUTO_RETURN: return "Auto-return to station %s, aye" % ("on" if order.automatic else "off") + Order.Type.SET_READY_ALERT: + return "Ready alert stood down, aye" if order.aircraft_count <= 0 else "%d fighters on ready alert, aye" % order.aircraft_count Order.Type.INVESTIGATE: return "Investigating track %s" % DataDisplay.track_number_for_track(order.track) Order.Type.ATTACK: diff --git a/scripts/core/order.gd b/scripts/core/order.gd index 5a56c7fb..b4b95dce 100644 --- a/scripts/core/order.gd +++ b/scripts/core/order.gd @@ -3,7 +3,7 @@ extends RefCounted ## Command object issued to a Unit. Pure data; UI and AI both create these and hand them to ## UnitManager.issue_order(). Never mutate a unit from UI code directly. -enum Type { MOVE, SET_COURSE, SET_SPEED, STOP, CLEAR_WAYPOINTS, ACTIVATE_RADAR, SILENCE_RADAR, ENGAGE, SET_DEPTH, ACTIVE_SONAR, PASSIVE_SONAR, SET_ALTITUDE, LAUNCH_AIRCRAFT, RETURN_TO_BASE, DEPLOY_SONOBUOY, SET_EMCON, SET_ROE, FORM_UP, BREAK_FORMATION, DEPLOY_COUNTERMEASURES, EVADE, RESUME_PLAN, SET_DEFENCE_POLICY, SET_AUTO_COUNTERMEASURES, CANCEL_FIRE, PATROL, INVESTIGATE, ATTACK, RETURN_TO_STATION, SET_AUTO_RETURN, AIR_MISSION, CANCEL_AIR_MISSION, GROUP_ATTACK, CANCEL_GROUP_ATTACK, SET_AIR_DEFENCE_MODE, INTERCEPT, DEPLOY_DIPPING_SONAR, RECOVER_DIPPING_SONAR, SET_SUB_COMMS_INTERVAL, REQUEST_SUB_CHECKIN, ASW_SEARCH, RECOVER_TOWED_ARRAY } +enum Type { MOVE, SET_COURSE, SET_SPEED, STOP, CLEAR_WAYPOINTS, ACTIVATE_RADAR, SILENCE_RADAR, ENGAGE, SET_DEPTH, ACTIVE_SONAR, PASSIVE_SONAR, SET_ALTITUDE, LAUNCH_AIRCRAFT, RETURN_TO_BASE, DEPLOY_SONOBUOY, SET_EMCON, SET_ROE, FORM_UP, BREAK_FORMATION, DEPLOY_COUNTERMEASURES, EVADE, RESUME_PLAN, SET_DEFENCE_POLICY, SET_AUTO_COUNTERMEASURES, CANCEL_FIRE, PATROL, INVESTIGATE, ATTACK, RETURN_TO_STATION, SET_AUTO_RETURN, AIR_MISSION, CANCEL_AIR_MISSION, GROUP_ATTACK, CANCEL_GROUP_ATTACK, SET_AIR_DEFENCE_MODE, INTERCEPT, DEPLOY_DIPPING_SONAR, RECOVER_DIPPING_SONAR, SET_SUB_COMMS_INTERVAL, REQUEST_SUB_CHECKIN, ASW_SEARCH, RECOVER_TOWED_ARRAY, SET_READY_ALERT } var type: Type = Type.STOP var target_pos := Vector2.ZERO @@ -183,6 +183,15 @@ static func return_to_station() -> Order: ## Whether the platform goes back to its station by itself once an identification, interception or ## attack ends. Refuelling always hands back to a valid station: it was never the commander's task. +## Hold `count` fighters of this deck on ready alert (0 stands the alert down). See +## AirMissionManager: the deck scrambles them on its own when a hostile raid comes in. +static func set_ready_alert(count: int) -> Order: + var o := Order.new() + o.type = Type.SET_READY_ALERT + o.aircraft_count = count + return o + + static func set_auto_return(enabled: bool) -> Order: var o := Order.new() o.type = Type.SET_AUTO_RETURN @@ -458,6 +467,8 @@ func describe() -> String: return "RETURN TO STATION" Type.SET_AUTO_RETURN: return "AUTO RETURN TO STATION %s" % ("ON" if automatic else "OFF") + Type.SET_READY_ALERT: + return "READY ALERT %s" % ("OFF" if aircraft_count <= 0 else "%d FIGHTERS" % aircraft_count) Type.MOVE: return "MOVE to %s/%s%s" % [Geo.format_axis(target_pos.x, "E", "W"), Geo.format_axis(target_pos.y, "N", "S"), " (append)" if append else ""] Type.PATROL: diff --git a/scripts/data/platform_spec.gd b/scripts/data/platform_spec.gd index 700a22d2..f4f08148 100644 --- a/scripts/data/platform_spec.gd +++ b/scripts/data/platform_spec.gd @@ -70,6 +70,12 @@ extends Resource @export var weapon_loadout: Dictionary = {} # weapon id -> rounds carried @export var has_datalink := true # a merchant or a boat under water is not on the network @export var fire_control_channels := 4 # simultaneous air-defence engagements (GAMEPLAY_ESTIMATE) +## Integrated fire control over the link. A ship with this, on the link, can launch a guided +## interceptor at a round or aircraft beyond its own radar's reach when another such ship on the +## link holds it inside its own, and that consort's radar carries the round in. Gameplay +## abstraction of a networked air-defence force; no real system's behaviour or performance is +## represented. Both ships need it: an ordinary consort's plot is a cue, never guidance. +@export var cooperative_engagement := false @export var decoy_count := 12 # chaff / decoy launches carried (GAMEPLAY_ESTIMATE) @export var decoy_effectiveness := 0.35 # base chance one salvo of decoys defeats a seeker diff --git a/scripts/entities/unit.gd b/scripts/entities/unit.gd index 8512d04e..c03c90b3 100644 --- a/scripts/entities/unit.gd +++ b/scripts/entities/unit.gd @@ -144,6 +144,13 @@ var auto_countermeasures := true var auto_air_defence := true var defence_policy := "balanced" # balanced | conserve | saturation var defence_priority := 0 +## A fixed-wing aircraft flying a hold that Movement laid because it had nowhere to go (see +## Movement.enter_hold), and the point the hold is through. The hold is not a route of its own. +var hold_active := false +var hold_point := Vector2.ZERO +## Fighters this deck holds on ready alert, launched by the deck itself into a CAP toward a hostile +## raid when one appears (AirMissionManager). 0 is no alert. Spent by the scramble. +var ready_alert := 0 var countermeasure_kind := "" var countermeasure_generation := 0 var countermeasure_remaining_s := 0.0 @@ -186,6 +193,17 @@ var tanking_on: Unit var tanker_offload_s := 0.0 # give remaining, in receiver endurance-seconds +## Flying a hold Movement laid for want of orders, not a route anyone gave it. +func holding() -> bool: + return hold_active and not waypoints.is_empty() and waypoints.back() == hold_point + + +## Has somewhere to go that someone chose: a hold laid for want of orders does not count, so +## whatever is deciding the next leg (the AI, a stalled task) still sees this unit as idle. +func has_route() -> bool: + return not waypoints.is_empty() and not holding() + + func is_aircraft() -> bool: return spec.domain == "air" @@ -522,6 +540,8 @@ func apply_order(order: Order) -> void: _resume_station() Order.Type.SET_AUTO_RETURN: auto_return = order.automatic + Order.Type.SET_READY_ALERT: + ready_alert = clampi(order.aircraft_count, 0, AirMissionManager.MAX_READY_ALERT) Order.Type.MOVE: if not order.append: waypoints.clear() diff --git a/scripts/entities/weapon.gd b/scripts/entities/weapon.gd index e1a5a6be..fb41fd52 100644 --- a/scripts/entities/weapon.gd +++ b/scripts/entities/weapon.gd @@ -33,7 +33,7 @@ var acoustic_decoy_tried: Dictionary = {} var hard_kill_shots := 0 var seductions := 0 # times decoys have pulled this round off a lock var guided_interceptors_committed := 0 # SAMs ever fired at this round -var defence_commitments: Dictionary = {} # defensive layer -> reserved shots +var defence_commitments: Dictionary = {} # AirDefence.layer_key (layer and ship) -> shots fired at this round var close_in_commitments: Dictionary = {} # defending unit id -> bursts var close_in_bursts_committed := 0 # close-in engagements; a round is only in that envelope briefly ## Ids of the units the commander has ordered to intercept this round (Order.intercept). A ship diff --git a/scripts/simulation/air_mission.gd b/scripts/simulation/air_mission.gd index 5e236357..51e63fde 100644 --- a/scripts/simulation/air_mission.gd +++ b/scripts/simulation/air_mission.gd @@ -22,6 +22,8 @@ const ENGAGING := "ENGAGING" const REFUELLING := "REFUELLING" const HOLDING := "HOLDING" const RETURNING := "RETURNING" +## Back at the deck and holding in the stack astern until the deck can take it. +const MARSHAL := "MARSHAL" const RECOVERING := "RECOVERING" var id := -1 @@ -110,7 +112,7 @@ func summary() -> String: if not active: return "%s · %s" % [kind_name(), ended_reason.to_lower()] var parts := PackedStringArray() - for state in [ON_STATION, DIPPING, TRANSITING, INVESTIGATING, ENGAGING, LAUNCHING, REFUELLING, HOLDING, RETURNING, RECOVERING, QUEUED]: + for state in [ON_STATION, DIPPING, TRANSITING, INVESTIGATING, ENGAGING, LAUNCHING, REFUELLING, HOLDING, RETURNING, MARSHAL, RECOVERING, QUEUED]: var n := count_in(state) if n > 0: parts.append("%d %s" % [n, state.to_lower()]) diff --git a/scripts/simulation/air_mission_manager.gd b/scripts/simulation/air_mission_manager.gd index 13811bef..d7f8aa79 100644 --- a/scripts/simulation/air_mission_manager.gd +++ b/scripts/simulation/air_mission_manager.gd @@ -38,6 +38,16 @@ const DIP_S := 180.0 const DIP_INTERVAL_S := 240.0 ## A held radar altitude above this marks an unclassified contact as probably airborne. const AIRBORNE_ALTITUDE_M := 30.0 +## Ready alert. A deck holding fighters on alert scrambles them into a CAP between itself and a +## hostile air raid that comes within this distance, or that is closing on it within the wider +## one. The CAP is laid toward the raid at this fraction of its range, no further out than the +## cap. GAMEPLAY_ESTIMATE throughout; the alert is spent by the scramble and set again by order. +const MAX_READY_ALERT := 4 +const ALERT_TRIGGER_NM := 60.0 +const ALERT_CLOSING_TRIGGER_NM := 120.0 +const ALERT_STATION_FRACTION := 0.5 +const ALERT_STATION_MAX_NM := 40.0 +const ALERT_RADIUS_NM := 15.0 var unit_manager: UnitManager var aviation_manager: AviationManager @@ -530,6 +540,85 @@ func _end(m: AirMission, reason: String) -> void: # --- The mission cycle ------------------------------------------------------------------- +## Why this deck cannot hold `count` fighters on alert, or "". Standing an alert down always works. +static func ready_alert_rejection(u: Unit, count: int) -> String: + if u == null or not u.is_engageable() or u.spec.aircraft_capacity <= 0: + return "No flight deck" + if count <= 0: + return "" + if count > MAX_READY_ALERT: + return "At most %d on alert" % MAX_READY_ALERT + if alert_fighter_type(u) == "": + return "No fighters aboard" + return "" + + +## The fighter type a deck puts on alert: the one with most airframes aboard fit for a CAP. +static func alert_fighter_type(base: Unit) -> String: + var counts := {} + for a: Unit in base.embarked: + if a.alive and type_suits(a.spec, AirMission.Kind.CAP): + counts[a.spec.id] = int(counts.get(a.spec.id, 0)) + 1 + var best := "" + for pid: String in counts: + if best == "" or int(counts[pid]) > int(counts[best]): + best = pid + return best + + +## The nearest hostile air contact that should bring an alert deck's fighters up, or null. +func _alert_threat(base: Unit) -> Track: + var best: Track = null + var best_d := INF + for t: Track in track_manager.tracks_for(base): + if t.identity != "HOSTILE" or t.status != Track.Status.ACTIVE or not _probably_air(t) or t.is_bearing_only(): + continue + var d := t.position.distance_to(base.position) + var closing := false + if t.has_kinematics and t.speed_kn > 0.0 and d > 0.01: + closing = Geo.heading_to_vector(t.course_deg).dot((base.position - t.position).normalized()) > 0.3 + if (d <= ALERT_TRIGGER_NM or (closing and d <= ALERT_CLOSING_TRIGGER_NM)) and d < best_d: + best = t + best_d = d + return best + + +## Decks on alert with a raid coming: launch the alert as a CAP toward it. A deck already flying a +## CAP of its own is already answering, and keeps its alert for the next raid. +func _scramble_alerts() -> void: + if track_manager == null: + return + for base: Unit in unit_manager.units: + if base.ready_alert <= 0 or not base.alive or not base.is_engageable(): + continue + var flying_cap := false + for m in missions: + if m.active and m.base == base and m.kind == AirMission.Kind.CAP: + flying_cap = true + break + if flying_cap: + continue + var threat := _alert_threat(base) + if threat == null: + continue + var pid := alert_fighter_type(base) + if pid == "": + continue + var available := available_for(base, pid, AirMission.Kind.CAP) + if available <= 0: + continue + var count := mini(base.ready_alert, available) + var bearing := (threat.position - base.position).normalized() + var reach := minf(base.position.distance_to(threat.position) * ALERT_STATION_FRACTION, ALERT_STATION_MAX_NM) + var order := Order.air_mission(AirMission.Kind.CAP, pid, count, base.position + bearing * reach, ALERT_RADIUS_NM) + order.origin = "crew" + var m := request(base, order) + if m == null: + continue + base.ready_alert = 0 + _report(m, "ALERT LAUNCH: %d × %s scrambled from %s toward track %s" % [count, DataDB.platform(pid).short_name, base.callsign, threat.id], true) + + func tick(dt: float, now: float) -> void: now_s = now _accum += dt @@ -538,6 +627,7 @@ func tick(dt: float, now: float) -> void: for m in missions: if m.active: _step(m) + _scramble_alerts() func _step(m: AirMission) -> void: @@ -559,7 +649,11 @@ func _step(m: AirMission) -> void: m.pending_launches = 0 _launch_owed(m) if m.aircraft.is_empty() and m.pending_launches <= 0: - _end(m, "Strike complete" if m.kind == AirMission.Kind.STRIKE else "All aircraft recovered") + if m.kind == AirMission.Kind.STRIKE: + # A strike whose launches were all struck off never flew: say so, not "complete". + _end(m, "Strike complete" if m.launched_total > 0 else "Strike called off") + else: + _end(m, "All aircraft recovered") return if m.pending_launches > 0 and m.aircraft.is_empty() and _launch_candidates(m).is_empty() and not _any_coming_ready(m): _report(m, "%s: no %s left to launch" % [m.label(), DataDB.platform(m.platform_id).short_name], false) @@ -621,15 +715,16 @@ func _any_coming_ready(m: AirMission) -> bool: return false -## An airframe back over this deck and waiting for it to stop launching. +## An airframe back at this deck, in the marshal stack or on approach, waiting for it to stop +## launching: recoveries come first, or a busy launch queue would keep it holding on its last fuel. static func _recovery_waiting(base: Unit) -> bool: if base.spec.flight_facility() == "airfield": return false # a field launches and recovers on separate runways for a in base.inbound_aircraft: - if a.alive and a.airborne() and a.returning and a.position.distance_to(base.position) <= AviationManager.RECOVERY_RANGE_NM + 1.0: + if a.alive and a.airborne() and a.returning and a.position.distance_to(base.position) <= AviationManager.MARSHAL_RANGE_NM + AviationManager.MARSHAL_SLACK_NM: return true for a in base.embarked: - if a.alive and a.airborne() and a.returning and (a.recovery_base == null or a.recovery_base == base) and a.position.distance_to(base.position) <= AviationManager.RECOVERY_RANGE_NM + 1.0: + if a.alive and a.airborne() and a.returning and (a.recovery_base == null or a.recovery_base == base) and a.position.distance_to(base.position) <= AviationManager.MARSHAL_RANGE_NM + AviationManager.MARSHAL_SLACK_NM: return true return false @@ -678,9 +773,9 @@ func _track_airframe(m: AirMission, a: Unit) -> void: if m.kind == AirMission.Kind.CAP: _enforce_cap_boundary(m, a, task) if a.returning: - if task["state"] != AirMission.RETURNING: - task["state"] = AirMission.RETURNING + if task["state"] != AirMission.RETURNING and task["state"] != AirMission.MARSHAL: _call_relief(m, a, task, "%s heading home" % a.callsign) + task["state"] = AirMission.MARSHAL if AviationManager.in_marshal(a) else AirMission.RETURNING return if a.tanking_on != null: task["state"] = AirMission.REFUELLING @@ -806,16 +901,27 @@ func _step_station(m: AirMission, a: Unit, task: Dictionary) -> void: task["next_dip_at"] = now_s + DIP_INTERVAL_S return if not a.on_station(): - # A task ended with auto-return off: the airframe holds until told to go back (S). + # A task ended with auto-return off: the airframe holds until told to go back (S). It is + # still on the mission: a CAP that has just identified a hostile still intercepts it, from + # where it is holding, instead of flying on with nothing to do until bingo. task["state"] = AirMission.HOLDING + _look(m, a, task) return var half := float(task.get("half", m.radius_nm)) + STATION_ARRIVAL_MARGIN_NM var offset := a.position - m.station task["state"] = AirMission.ON_STATION if absf(offset.x) <= half and absf(offset.y) <= half else AirMission.TRANSITING - var flight_speed := a.spec.flight_speed("patrol" if task["state"] == AirMission.ON_STATION else "transit") - if absf(a.ordered_speed_kn - flight_speed) > 0.1: - _crew(a, Order.set_speed(flight_speed)) + # The mission sets the speed when the airframe's state changes (out at transit, on station at + # patrol), not every second: a dash the commander orders on station stands until the next change. + if str(task.get("speed_for", "")) != str(task["state"]): + task["speed_for"] = task["state"] + var flight_speed := a.spec.flight_speed("patrol" if task["state"] == AirMission.ON_STATION else "transit") + if absf(a.ordered_speed_kn - flight_speed) > 0.1: + _crew(a, Order.set_speed(flight_speed)) a.station_speed_kn = a.spec.flight_speed("patrol") + _look(m, a, task) + + +func _look(m: AirMission, a: Unit, task: Dictionary) -> void: match m.kind: AirMission.Kind.CAP: _cap_look(m, a, task) @@ -882,6 +988,9 @@ func _cap_look(m: AirMission, a: Unit, task: Dictionary) -> void: return if attack: if _crew(a, Order.attack(best)): + # An intercept is flown fast: the attack takes its speed from the order after it. + _crew(a, Order.set_speed(a.spec.flight_speed("dash"))) + task["speed_for"] = AirMission.ENGAGING task["state"] = AirMission.ENGAGING task["attacked"] = true task["cap_target"] = best @@ -932,6 +1041,10 @@ func _recon_look(m: AirMission, a: Unit, task: Dictionary) -> void: for t: Track in _nearby(m, a, m.radius_nm + SEARCH_MARGIN_NM): if t.classification >= Track.Classification.CLASS_KNOWN or _committed(m.faction, t, false) > 0: continue + # A look from the air identifies what can be seen. A submarine contact is the ASW + # mission's: an early-warning aircraft sent to circle one would circle it until bingo. + if t.domain == "subsurface" and not a.has_sonar() and a.spec.sonobuoy_count <= 0: + continue if UnitManager.investigation_rejection(a, t) != "": continue var d := a.position.distance_squared_to(t.position) diff --git a/scripts/simulation/aviation_manager.gd b/scripts/simulation/aviation_manager.gd index 2696e934..dbabf9ee 100644 --- a/scripts/simulation/aviation_manager.gd +++ b/scripts/simulation/aviation_manager.gd @@ -42,6 +42,20 @@ const TANKER_RESERVE := 0.30 # a tanker keeps this much of its own fuel to get ## complex 400 miles away. An airframe with no deck of its own heads for the edge it can reach and ## is taken off the board when it crosses: it went home, and it is not coming back this scenario. const OFF_MAP_MARGIN_NM := 12.0 +## Marshal. An aircraft back at its deck while the deck cannot take it (the recovery spot is busy, +## catapults are working, or the flight deck is on fire) holds in a stack astern of the ship at its +## economical speed, rather than flying at the ship at transit speed and burning transit fuel. +## GAMEPLAY_ESTIMATE distances and heights; no real case procedure is represented. +const MARSHAL_RANGE_NM := 8.0 +const MARSHAL_ASTERN_NM := 5.0 +const MARSHAL_SLACK_NM := 2.5 +const MARSHAL_ALTITUDE_M := 1500.0 +const MARSHAL_HELO_ALTITUDE_M := 150.0 +## A deck this badly on fire suspends flight operations both ways. +const DECK_FIRE_LIMIT := 0.35 +## An aircraft launched without orders holds this far ahead of its ship until it is given some. +const DEPARTURE_HOLD_NM := 6.0 +const HELICOPTER_TURNAROUND_S := 1800.0 var unit_manager: UnitManager var sonobuoys: Array[Sonobuoy] = [] @@ -277,6 +291,15 @@ func _step_launch(a: Unit, dt: float) -> void: a.ordered_speed_kn = a.spec.flight_speed("transit") a.tanker_offload_s = a.spec.tanker_offload_s a.tanking_on = null + # Launched with no task: it holds ahead of its ship where the commander can see it, rather than + # flying the ship's heading until it reaches bingo. A mission or an order replaces this at once. + if a.waypoints.is_empty() and not a.has_station() and a.investigation_track == null and a.attack_track == null and a.home != null: + var departure := a.home.position + Geo.heading_to_vector(a.home.heading_deg) * DEPARTURE_HOLD_NM + if a.spec.can_hover: + a.waypoints.append(departure) + a.ordered_speed_kn = a.spec.flight_speed("patrol") + else: + Movement.enter_hold(a, departure) aircraft_launched.emit(a, a.home) @@ -337,20 +360,59 @@ func _step_airborne(a: Unit, dt: float) -> void: if base == null: _step_off_map_return(a) return + var gap := a.position.distance_to(base.position) + var deck_open := deck_can_recover(base) + if gap <= MARSHAL_RANGE_NM and not deck_open: + _hold_marshal(a, base) + return _steer_return(a, base) if a.dip_phase != DippingSonar.Phase.STOWED: return - if a.position.distance_to(base.position) <= RECOVERY_RANGE_NM: - if base.recovery_spots_busy() >= base.spec.recovery_capacity(): - return - if base.launch_spots_busy() > 0 and base.spec.flight_facility() != "airfield": - return + if gap <= RECOVERY_RANGE_NM and deck_open: a.flight_state = Unit.FlightState.RECOVERING a.state_timer_s = a.spec.recovery_time_s a.waypoints.clear() a.ordered_altitude_m = 0.0 +## Whether this deck can take an aircraft aboard right now: a recovery spot free, no launch on the +## catapults (a field's runways do both), and no fire bad enough to stop flight operations. +static func deck_can_recover(base: Unit) -> bool: + if base == null: + return false + if base.recovery_spots_busy() >= base.spec.recovery_capacity(): + return false + var field := base.spec.flight_facility() == "airfield" + if base.launch_spots_busy() > 0 and not field: + return false + return field or base.fire < DECK_FIRE_LIMIT + + +## Holding in the marshal stack for this deck: back at the ship and waiting for it. +static func in_marshal(a: Unit) -> bool: + return a.airborne() and a.returning and a.recovery_base != null and a.position.distance_to(a.recovery_base.position) <= MARSHAL_RANGE_NM + MARSHAL_SLACK_NM and not deck_can_recover(a.recovery_base) + + +## The marshal point: astern of the ship, which is where an aircraft is out of the way of the deck +## and set up for the approach when it is called down. +static func marshal_point(base: Unit) -> Vector2: + return base.position - Geo.heading_to_vector(base.heading_deg) * MARSHAL_ASTERN_NM + + +func _hold_marshal(a: Unit, base: Unit) -> void: + a.formation_leader = null + var point := marshal_point(base) + a.ordered_altitude_m = MARSHAL_HELO_ALTITUDE_M if a.spec.can_hover else minf(MARSHAL_ALTITUDE_M, a.spec.cruise_altitude_m) + if a.position.distance_to(point) > MARSHAL_SLACK_NM: + a.waypoints.assign([point]) + a.ordered_speed_kn = a.spec.flight_speed("patrol") + elif a.spec.can_hover: + a.waypoints.clear() + a.ordered_speed_kn = 0.0 + elif a.waypoints.is_empty() or a.waypoints.size() == 1: + Movement.enter_hold(a, point) + + func _steer_return(a: Unit, base: Unit) -> void: a.formation_leader = null a.waypoints.clear() @@ -397,7 +459,7 @@ func _step_recovery(a: Unit, dt: float) -> void: a.position = base.position a.heading_deg = base.heading_deg a.flight_state = Unit.FlightState.TURNAROUND - a.state_timer_s = base.spec.turnaround_time_s() + a.state_timer_s = turnaround_for(a, base) a.returning = false a.tanking_on = null a.clear_station() # the sortie is over; any relief is the air mission's to send @@ -413,6 +475,16 @@ func _step_recovery(a: Unit, dt: float) -> void: aircraft_recovered.emit(a, base) +## Deck time for this airframe aboard this base. The deck's figure is for its own air wing; a +## helicopter recovered aboard a carrier is not struck below and respotted like a strike fighter, +## so it takes no longer than it would on a helicopter deck. GAMEPLAY_ESTIMATE. +static func turnaround_for(a: Unit, base: Unit) -> float: + var deck := base.spec.turnaround_time_s() + if a.spec.can_hover: + return minf(deck, HELICOPTER_TURNAROUND_S) + return deck + + ## Fuel, ordnance and a spot on the deck. Until this finishes the airframe is aboard but is not a ## sortie anyone can fly, which is the constraint that makes deck size mean something. func _step_turnaround(a: Unit, dt: float) -> void: diff --git a/scripts/simulation/simulation.gd b/scripts/simulation/simulation.gd index d529ea1d..579f98a3 100644 --- a/scripts/simulation/simulation.gd +++ b/scripts/simulation/simulation.gd @@ -334,6 +334,10 @@ func _step(dt: float) -> void: _defence_accum -= DEFENCE_DT DefensiveResponse.run_cycle(unit_manager, threat_manager, weapon_manager) AirDefence.run_cycle(unit_manager, threat_manager, weapon_manager, SimClock.sim_time) + # The sides with no AI commander (the player's): their ships on Weapons Free answer hostile + # aircraft in their envelope. An AI side does this through its own controller. + if not ai_controllers.has(player_faction): + AirDefence.engage_hostile_aircraft(unit_manager, track_manager, weapon_manager, SimClock.sim_time, [player_faction]) TorpedoDefence.run_cycle(unit_manager, threat_manager, weapon_manager, SimClock.sim_time) Debug.time_add("sim/defence", Time.get_ticks_usec() - profile_at) profile_at = Time.get_ticks_usec() diff --git a/scripts/simulation/unit_manager.gd b/scripts/simulation/unit_manager.gd index 47fd17cf..165331df 100644 --- a/scripts/simulation/unit_manager.gd +++ b/scripts/simulation/unit_manager.gd @@ -23,6 +23,8 @@ const ATTACK_STANDOFF_FRACTION := 0.8 const ATTACK_ASSESS_S := 20.0 ## An attack held this long without being able to fire or close ends with the reason, rather ## than parking the platform indefinitely. GAMEPLAY. +## A standing attack closes on a stale plot for this long before giving it back. GAMEPLAY_ESTIMATE. +const ATTACK_STALE_S := 240.0 const ATTACK_STALL_S := 180.0 var units: Array[Unit] = [] @@ -93,6 +95,10 @@ func _handoff_submarine_tasks(u: Unit) -> void: ## (deck spots and fire-control channels) remain with their specialist managers. func issue_order(u: Unit, order: Order) -> bool: if not can_accept_order(u, order): + # A refused shot says why, so the console can tell the commander rather than just + # counting refusals. Each pair on a firing board carries its own Order. + if order != null and order.type == Order.Type.ENGAGE: + order.receipt = engage_rejection(u, order) return false if order.type == Order.Type.MOVE and u.needs_sea_room() and Terrain.is_land(order.target_pos): return false @@ -121,6 +127,20 @@ func _deliver_submarine_orders(u: Unit, orders: Array[Order]) -> void: u.comms_note = "%d orders delivered%s" % [accepted, " · %d no longer executable" % (orders.size() - accepted) if accepted < orders.size() else ""] +## Why this platform cannot fire this ENGAGE order now, or "". The same checks the order is +## accepted on, worded for the radio line. +static func engage_rejection(u: Unit, order: Order) -> String: + if u == null or not u.is_engageable(): + return "platform not deployed" + var spec := u.get_weapon(order.weapon_id) + if spec == null: + return "%s not aboard" % order.weapon_id + if order.track == null: + return "no contact named" + var check := Combat.check_engagement(u, spec, order.track) + return "" if bool(check.get("ok", false)) else str(check.get("reason", "no firing solution")).to_lower() + + static func can_accept_order(u: Unit, order: Order) -> bool: if u == null or order == null or not u.alive: return false @@ -152,6 +172,8 @@ static func can_accept_order(u: Unit, order: Order) -> bool: return station_rejection(u) == "" Order.Type.SET_AUTO_RETURN: return u.is_engageable() and u.spec.max_speed_kn > 0.0 + Order.Type.SET_READY_ALERT: + return AirMissionManager.ready_alert_rejection(u, order.aircraft_count) == "" Order.Type.DEPLOY_COUNTERMEASURES: return DefensiveResponse.can_deploy(u, order.countermeasure_kind) Order.Type.EVADE: @@ -183,8 +205,7 @@ static func can_accept_order(u: Unit, order: Order) -> bool: Order.Type.CANCEL_FIRE: return u.alive Order.Type.ENGAGE: - var spec := u.get_weapon(order.weapon_id) - return u.is_engageable() and spec != null and order.track != null and bool(Combat.check_engagement(u, spec, order.track).get("ok", false)) + return engage_rejection(u, order) == "" Order.Type.SET_DEPTH: return u.is_engageable() and u.is_submarine() and u.spec.max_depth_m > 0.0 Order.Type.SET_ALTITUDE: @@ -365,9 +386,13 @@ func _engage_identified(u: Unit, track: Track, started: int) -> bool: static func _hold_investigation_position(u: Unit) -> void: u.waypoints.clear() u.ordered_heading_deg = u.heading_deg - # A fixed-wing aircraft cannot hover at a contact plot. Keep it flying on its present course; - # while the task remains active, later ticks bring it back toward the held plot as it turns. - u.ordered_speed_kn = maxf(u.investigation_speed_kn, u.spec.cruise_speed_kn) if u.is_aircraft() and not u.spec.can_hover else 0.0 + # A fixed-wing aircraft cannot hover at a contact plot. It holds where it is: a racetrack + # through its present position, instead of flying on along its heading until it ran out of + # fuel; while the task remains active, later ticks bring it back toward the held plot. + if Movement.must_keep_flying(u): + Movement.enter_hold(u, u.position) + else: + u.ordered_speed_kn = 0.0 # --- The attack task --------------------------------------------------------------------- @@ -464,8 +489,14 @@ func _step_attack(u: Unit, dt: float) -> void: var check: Dictionary = solution["check"] if bool(check["ok"]): if track.status != Track.Status.ACTIVE: - # Never shoot at a stale plot: the aim point would be old. Close on it instead. + # Never shoot at a stale plot: the aim point would be old. Close on it instead, but + # not for ever: a plot nobody refreshes for a few minutes is not going to firm up + # by itself, and a crew parked on it is a crew the commander thinks is fighting. u.attack_phase = "Intercept track · contact stale" + u.attack_stall_s += dt + if u.attack_stall_s >= ATTACK_STALE_S: + _end_attack(u, track, "Contact stale: no fresh plot to fire on") + return _steer_attack(u, track, ATTACK_STANDOFF_FRACTION * Combat.effective_range_nm(u, spec), dt) return var salvo := mini(maxi(spec.salvo_default, 1), u.magazine_count(spec.id)) @@ -527,7 +558,7 @@ func _step_attack(u: Unit, dt: float) -> void: u.attack_phase = "Attack track · " + why.to_lower() _steer_attack(u, track, ATTACK_STANDOFF_FRACTION * reach, dt) # Unable to fire and going nowhere: after a while, say why and stop, rather than park. - if u.waypoints.is_empty(): + if not u.has_route(): u.attack_stall_s += dt if u.attack_stall_s >= ATTACK_STALL_S: _end_attack(u, track, "Cannot engage: " + why.to_lower()) diff --git a/scripts/simulation/weapon_manager.gd b/scripts/simulation/weapon_manager.gd index 6b29096f..14b05608 100644 --- a/scripts/simulation/weapon_manager.gd +++ b/scripts/simulation/weapon_manager.gd @@ -145,7 +145,7 @@ func engagement_check(shooter: Unit, spec: WeaponSpec, track: Track, now: float, var check := Combat.check_engagement(shooter, spec, track, reserved_round) if not check.ok: return check - if not track_support_available(shooter, spec, track): + if not track_guidance_available(shooter, spec, track): check.ok = false check.reason = "RADAR GUIDANCE UNAVAILABLE" elif not reserved_round and spec.requires_fire_control_channel() and track.domain == "air" and not channel_available(shooter, track): @@ -241,7 +241,7 @@ func launch_interceptor(shooter: Unit, spec: WeaponSpec, threat: Weapon, rounds: return 0 if not AirDefence._can_intercept(spec, threat): return 0 - if not interceptor_support_available(shooter, spec, threat): + if not guidance_available(shooter, spec, threat): return 0 now_s = now if now < _ready_time(shooter, spec): @@ -280,7 +280,7 @@ func launch_interceptor(shooter: Unit, spec: WeaponSpec, threat: Weapon, rounds: threat.close_in_commitments[shooter.id] = int(threat.close_in_commitments.get(shooter.id, 0)) + 1 else: threat.guided_interceptors_committed += available - var layer := spec.defensive_layer() + var layer := AirDefence.layer_key(spec, shooter) threat.defence_commitments[layer] = int(threat.defence_commitments.get(layer, 0)) + available if _channel_batch and spec.requires_fire_control_channel(): var held: Dictionary = _channel_cache.get(shooter, {}) @@ -306,6 +306,11 @@ func tick(dt: float, now: float) -> void: _sort_pending() for p: Dictionary in _pending.duplicate(): var check := engagement_check(p.shooter, p.spec, p.track, now, true) + if check.ok and (p.track as Track).damage_estimate >= 100.0: + # The plot records the contact as sunk (the kill was reported): the rounds still on + # the rail are kept for something that is still afloat. + check.ok = false + check.reason = "TARGET DESTROYED" if not check.ok: _pending.erase(p) revision += 1 @@ -372,11 +377,11 @@ func _step(w: Weapon, dt: float) -> void: if w.phase == Weapon.Phase.DEAD: return w.time_alive_s += dt - if not radar_support_available(w.shooter, w.spec): + if w.intercept_target == null and not radar_support_available(w.shooter, w.spec) and network_track_guide(w.shooter, w.spec, w.target_track) == null: w.phase = Weapon.Phase.DEAD w.dead_reason = "GUIDANCE LOST" return - if w.intercept_target == null and w.target_track != null and not track_support_available(w.shooter, w.spec, w.target_track): + if w.intercept_target == null and w.target_track != null and not track_guidance_available(w.shooter, w.spec, w.target_track): w.phase = Weapon.Phase.DEAD w.dead_reason = "GUIDANCE LOST" return @@ -504,7 +509,7 @@ func _step_interceptor(w: Weapon, dt: float) -> void: w.phase = Weapon.Phase.DEAD w.dead_reason = "THREAT ALREADY DEFEATED" return - if not AirDefence._can_intercept(w.spec, threat) or not interceptor_support_available(w.shooter, w.spec, threat): + if not AirDefence._can_intercept(w.spec, threat) or not guidance_available(w.shooter, w.spec, threat): w.phase = Weapon.Phase.DEAD w.dead_reason = "GUIDANCE LOST" return @@ -787,3 +792,44 @@ static func track_support_available(shooter: Unit, spec: WeaponSpec, track: Trac var altitude := maxf(track.altitude_m, 0.0) var horizon := Detection.radar_horizon_nm(Detection.mast_or_altitude_m(shooter), altitude) return shooter.position.distance_to(track.position) <= horizon and not Terrain.masks_line_of_sight(shooter.position, Detection.mast_or_altitude_m(shooter), track.position, altitude) + + +## Whether this interceptor can be guided onto this round: by the shooter's own fire control, or +## over the link by a consort's (`network_guide`). +func guidance_available(shooter: Unit, spec: WeaponSpec, threat: Weapon) -> bool: + return interceptor_support_available(shooter, spec, threat) or network_guide(shooter, spec, threat) != null + + +## The consort carrying a networked shot at a round in flight, or null. Only between ships that are +## both fitted for integrated fire control and both on the link, and only when the consort's own +## radar is up and has the round inside its own horizon with no land in between: the network moves +## the shot, it does not see through the earth. Weapons that guide themselves never need one. +func network_guide(shooter: Unit, spec: WeaponSpec, threat: Weapon) -> Unit: + if not _networked_shooter(shooter, spec): + return null + for u: Unit in unit_manager.units: + if _networked_consort(shooter, u) and interceptor_support_available(u, spec, threat): + return u + return null + + +## The same for a shot at an aircraft on the plot. +func track_guidance_available(shooter: Unit, spec: WeaponSpec, track: Track) -> bool: + return track_support_available(shooter, spec, track) or network_track_guide(shooter, spec, track) != null + + +func network_track_guide(shooter: Unit, spec: WeaponSpec, track: Track) -> Unit: + if track == null or not _networked_shooter(shooter, spec): + return null + for u: Unit in unit_manager.units: + if _networked_consort(shooter, u) and track.visible_to(u) and track_support_available(u, spec, track): + return u + return null + + +func _networked_shooter(shooter: Unit, spec: WeaponSpec) -> bool: + return unit_manager != null and shooter != null and shooter.alive and spec.requires_radar_support() and shooter.spec.cooperative_engagement and shooter.datalink_connected() + + +func _networked_consort(shooter: Unit, u: Unit) -> bool: + return u != shooter and u.alive and u.faction == shooter.faction and u.spec.cooperative_engagement and u.datalink_connected() and not u.is_aircraft() diff --git a/scripts/systems/ai_controller.gd b/scripts/systems/ai_controller.gd index abe9dea4..fb8cd712 100644 --- a/scripts/systems/ai_controller.gd +++ b/scripts/systems/ai_controller.gd @@ -464,7 +464,7 @@ func _pick_engagement(u: Unit, b: Dictionary, hostiles: Array, now: float, task var score: float = check["range_nm"] + bucket * PLAN_BUCKET_NM if score < best_range: best_range = score - var rounds := mini(_salvo_for(u, spec), spare) + var rounds := mini(_salvo_for(u, spec, t), spare) if plan != null: if plan.salvo > 0 and not spec.is_gun(): rounds = mini(plan.salvo, spare) @@ -474,10 +474,14 @@ func _pick_engagement(u: Unit, b: Dictionary, hostiles: Array, now: float, task return best -func _salvo_for(u: Unit, spec: WeaponSpec) -> int: +func _salvo_for(u: Unit, spec: WeaponSpec, t: Track = null) -> int: var rounds := _offensive_rounds(u, spec) if spec.is_gun(): return mini(spec.salvo_default, rounds) + # An aircraft is one airframe, not a ship's defences to saturate: the weapon's own doctrine + # salvo (two, typically), not the four-round anti-ship volley. + if spec.type in ["sam", "aam"] and t != null and t.domain == "air": + return clampi(spec.salvo_default, 1, rounds) if spec.is_torpedo(): return clampi(TORPEDO_SALVO, 1, rounds) return clampi(ASM_SALVO, 1, rounds) @@ -721,7 +725,7 @@ func _do_air_search(u: Unit, b: Dictionary, now: float) -> void: _manage_altitude(u, false) _move_to(u, b, next_leg, now) return - if not u.waypoints.is_empty(): + if u.has_route(): return # already on the way to the next search point var index := int(b.get("search_index", 0)) b["search_index"] = index + 1 @@ -1460,7 +1464,7 @@ func _scout_close(u: Unit, b: Dictionary, t: Track, standoff_nm: float, now: flo ## Legs through an area: its centre first, then round it. func _sweep(u: Unit, b: Dictionary, centre: Vector2, radius_nm: float, now: float) -> void: _manage_altitude(u, false) - if not u.waypoints.is_empty() and b.has("sweep_leg") and b["sweep_leg"] == b["goal"]: + if u.has_route() and b.has("sweep_leg") and b["sweep_leg"] == b["goal"]: return # still on the way to the last sweep leg var index := int(b.get("sweep_index", 0)) b["sweep_index"] = index + 1 @@ -1547,7 +1551,7 @@ func _hold_point(u: Unit, b: Dictionary, point: Vector2, now: float) -> void: func _orbit(u: Unit, b: Dictionary, centre: Vector2, radius_nm: float, now: float) -> void: _manage_altitude(u, false) - if not u.waypoints.is_empty() and u.waypoints[-1].distance_to(centre) <= radius_nm + GOAL_TOLERANCE_NM: + if u.has_route() and u.waypoints[-1].distance_to(centre) <= radius_nm + GOAL_TOLERANCE_NM: return var index := int(b.get("orbit_index", 0)) b["orbit_index"] = index + 1 @@ -1576,7 +1580,7 @@ func _move_to(u: Unit, b: Dictionary, wanted: Vector2, now: float) -> void: var previous: Vector2 = b["goal"] # Re-task only when the destination has actually moved, or when the ship has arrived and is # sitting there with nothing to do. Anything else fills the order log with noise. - if previous != Vector2.INF and previous.distance_to(goal) < GOAL_TOLERANCE_NM and not u.waypoints.is_empty(): + if previous != Vector2.INF and previous.distance_to(goal) < GOAL_TOLERANCE_NM and u.has_route(): return b["goal"] = goal b["goal_time"] = now diff --git a/scripts/systems/air_defence.gd b/scripts/systems/air_defence.gd index 8dab5051..942383e5 100644 --- a/scripts/systems/air_defence.gd +++ b/scripts/systems/air_defence.gd @@ -16,7 +16,6 @@ class_name AirDefence const CPA_THREAT_NM := 3.0 # a round passing wider than this is not treated as inbound const MAX_INTERCEPTORS_PER_THREAT := 2 # in the air against one round at any moment -const MAX_INTERCEPTORS_PER_LAYER := 2 # game doctrine budget; inner layers retain their own shots const MAX_CLOSE_IN_BURSTS_PER_THREAT := 2 # a round crosses the close-in envelope in seconds const DECOY_RANGE_NM := 2.5 @@ -122,6 +121,13 @@ static func inbound_threats(unit_manager: UnitManager, threat_manager: ThreatMan ## Ships defend the whole task force, not only themselves: any round closing on a consort is a ## valid target for a ship that can reach it. That is what makes an area-defence escort worth ## stationing near a thinner-skinned ship. +## +## The force allocates as one: the raid is worked through twice. On the first pass every inbound +## round gets one interceptor from the best-placed ship that can take it; only then, on the second, +## are budgets topped up and the close-in guns given their turn. Working one round at a time let +## the two nearest ships both shoot the first round while the second was not answered at all until +## a launcher came free. Within a pass a ship with a guidance channel to spare is asked before a +## nearer one that is saturated, so a raid spreads across the screen instead of queuing on one hull. static func run_cycle(unit_manager: UnitManager, threat_manager: ThreatManager, weapon_manager: WeaponManager, now: float) -> int: weapon_manager.begin_channel_batch() var fits := {} @@ -142,34 +148,125 @@ static func run_cycle(unit_manager: UnitManager, threat_manager: ThreatManager, reaches[u] = reach * reach for faction: String in by_faction: var inbound := inbound_threats(unit_manager, threat_manager, faction) - for entry in inbound: - var w: Weapon = entry["weapon"] - var defenders: Array = [] - for u: Unit in by_faction[faction]: - if u.position.distance_squared_to(w.position) <= float(reaches[u]): - defenders.append(u) - defenders.sort_custom(func(a: Unit, b: Unit) -> bool: - return a.position.distance_squared_to(w.position) < b.position.distance_squared_to(w.position)) - for u: Unit in defenders: - if w.phase == Weapon.Phase.DEAD or not threat_manager.visible_to(u, w): - continue - _try_decoy(u, w, weapon_manager) - if w.phase == Weapon.Phase.DEAD or u.roe == Unit.Roe.HOLD or not u.can_fire(): + if inbound.is_empty(): + continue + for pass_index in 2: + var first_pass := pass_index == 0 + for entry in inbound: + var w: Weapon = entry["weapon"] + if w.phase == Weapon.Phase.DEAD: continue - var already: int = committed.get(w.id, 0) - var limit := guided_budget(u, float(entry["time_s"])) - var guided_allowed := already < limit and engages_guided(u, w) - var before := w.guided_interceptors_committed - var fired := _engage_threat(u, w, weapon_manager, guided_allowed, now, fits[u]) - if fired > 0: - # Increment the shared assignment, rather than rescanning every weapon twice - # after every shot. Each launcher still validates its own guidance support. - committed[w.id] = int(committed.get(w.id, 0)) + w.guided_interceptors_committed - before - launched += fired + var tti := float(entry["time_s"]) + var shot_this_pass := false + for u: Unit in _rank_defenders(by_faction[faction], w, reaches, weapon_manager): + if w.phase == Weapon.Phase.DEAD or not threat_manager.visible_to(u, w): + continue + if first_pass: + _try_decoy(u, w, weapon_manager) + if w.phase == Weapon.Phase.DEAD or u.roe == Unit.Roe.HOLD or not u.can_fire(): + continue + var already: int = committed.get(w.id, 0) + var limit := guided_budget(u, tti) + if first_pass: + limit = mini(limit, 1) + var guided_allowed := not shot_this_pass and already < limit and engages_guided(u, w) + if not guided_allowed and first_pass: + continue # the guns wait for the second pass; nothing else to offer here + var before := w.guided_interceptors_committed + var fired := _engage_threat(u, w, weapon_manager, guided_allowed, now, fits[u], tti, not first_pass) + if fired > 0: + # Increment the shared assignment, rather than rescanning every weapon twice + # after every shot. Each launcher still validates its own guidance support. + var guided := w.guided_interceptors_committed - before + committed[w.id] = int(committed.get(w.id, 0)) + guided + if guided > 0: + shot_this_pass = true + launched += fired weapon_manager.end_channel_batch() return launched +## Defenders that can reach this round, best placed first: a ship already guiding against it or +## with a guidance channel free comes before a saturated one, then the nearest. Channel state is +## read from the cycle's cache, so this costs nothing extra per shot. +static func _rank_defenders(force: Array, w: Weapon, reaches: Dictionary, weapon_manager: WeaponManager) -> Array: + var out: Array = [] + var free := {} + for u: Unit in force: + if u.position.distance_squared_to(w.position) <= float(reaches[u]): + out.append(u) + free[u] = weapon_manager.channel_available(u, w) + out.sort_custom(func(a: Unit, b: Unit) -> bool: + if free[a] != free[b]: + return free[a] + return a.position.distance_squared_to(w.position) < b.position.distance_squared_to(w.position)) + return out + + +## Which ships currently have interceptors in the air against this round, nearest first. For the +## threat board: the commander sees who has the round, not only how many are up. +static func engaging_units(weapon_manager: WeaponManager, threat: Weapon) -> Array[Unit]: + var out: Array[Unit] = [] + for w in weapon_manager.in_flight: + if w.phase != Weapon.Phase.DEAD and w.intercept_target == threat and w.shooter != null and not out.has(w.shooter): + out.append(w.shooter) + return out + + +## The key a layer's lifetime allowance is kept under: per layer and per ship. Each ship's inner +## layer keeps its own shots at a round, so an escort's point-defence missiles fired at a round +## aimed at the carrier cannot use up the carrier's own last-ditch missiles. +static func layer_key(spec: WeaponSpec, shooter: Unit) -> String: + return "%s:%d" % [spec.defensive_layer(), shooter.id if shooter != null else -1] + + +## Rounds a side keeps in the air at one hostile aircraft before another ship adds more, and the +## rounds of each missile type a ship keeps back for missiles rather than spend on aircraft. +## GAMEPLAY_ESTIMATE. +const AIRCRAFT_ROUNDS_IN_FLIGHT := 2 +const SAM_RESERVE_FOR_MISSILES := 4 + + +## Weapons free means the ship fights: a commander's ship on Weapons Free with automatic air +## defence engages identified hostile aircraft that come inside its missile envelope, one round at +## a time (shoot, look, shoot), without waiting for an attack order. The AI's sides already do this +## through their own controllers, so only the sides named here are stepped. Weapons Tight, Hold, +## manual missile defence (Classic) and anything not identified hostile are left to the commander. +## Reads only the ship's own picture; every shot goes through the ordinary envelope checks. +static func engage_hostile_aircraft(unit_manager: UnitManager, track_manager: TrackManager, weapon_manager: WeaponManager, now: float, factions: Array) -> int: + if factions.is_empty() or track_manager == null: + return 0 + var fired := 0 + weapon_manager.begin_channel_batch() + for u: Unit in unit_manager.units: + if not factions.has(u.faction) or not u.is_engageable() or u.is_aircraft(): + continue + if u.roe != Unit.Roe.FREE or not u.auto_air_defence or not u.can_fire(): + continue + var sams := false + for spec: WeaponSpec in u.weapons: + if spec.type == "sam" and spec.target_types.has("air") and u.magazine_count(spec.id) > SAM_RESERVE_FOR_MISSILES: + sams = true + break + if not sams: + continue + for t: Track in track_manager.tracks_for(u): + if t.domain != "air" or t.identity != "HOSTILE" or t.status != Track.Status.ACTIVE or t.is_bearing_only() or t.damage_estimate >= 100.0: + continue + if weapon_manager.faction_commitment(u.faction, t) >= AIRCRAFT_ROUNDS_IN_FLIGHT: + continue + for spec: WeaponSpec in u.weapons_for_track(t): + if spec.type != "sam" or u.magazine_count(spec.id) <= SAM_RESERVE_FOR_MISSILES: + continue + if not bool(weapon_manager.engagement_check(u, spec, t, now).get("ok", false)): + continue + if weapon_manager.launch(u, spec, t, 1, now): + fired += 1 + break + weapon_manager.end_channel_batch() + return fired + + ## Whether this ship's area and point SAMs may engage this round now: always on automatic ## defence; on manual only once the commander has ordered it intercepted. static func engages_guided(u: Unit, w: Weapon) -> bool: @@ -238,12 +335,16 @@ static func order_intercept(u: Unit, threat: Weapon, unit_manager: UnitManager, return {"cleared": 0, "fired": 0, "reason": "No inbound weapon this unit can engage is held"} var fired := 0 var committed := _count_committed(weapon_manager) + var victims := {} + for entry: Dictionary in inbound_threats(unit_manager, threat_manager, u.faction, u): + victims[entry["weapon"]] = float(entry["time_s"]) for w: Weapon in targets: if not w.intercept_cleared.has(u.id): w.intercept_cleared.append(u.id) - var guided_allowed := int(committed.get(w.id, 0)) < guided_budget(u, w.time_to_reach_s(u.position)) + var tti := float(victims.get(w, w.time_to_reach_s(u.position))) + var guided_allowed := int(committed.get(w.id, 0)) < guided_budget(u, tti) var before := w.guided_interceptors_committed - var shot := _engage_threat(u, w, weapon_manager, guided_allowed, now) + var shot := _engage_threat(u, w, weapon_manager, guided_allowed, now, [], tti) if shot > 0: committed[w.id] = int(committed.get(w.id, 0)) + w.guided_interceptors_committed - before fired += shot @@ -280,32 +381,30 @@ static func _can_intercept(spec: WeaponSpec, threat: Weapon) -> bool: return spec.target_types.has(threat.threat_class()) and threat.flight_altitude_m() >= spec.intercept_min_altitude_m and threat.flight_altitude_m() <= spec.intercept_max_altitude_m -static func _ship_engages(weapon_manager: WeaponManager, u: Unit, threat: Weapon) -> bool: - for w in weapon_manager.in_flight: - if w.shooter == u and w.intercept_target == threat: - return true - return false - - ## Close-in weapons are the last-ditch layer: self-contained, so they neither consume a guidance ## channel nor count against the missile allowance, and whatever leaks past always gets a final ## engagement. They get their own small burst allowance, because a round crosses that envelope in ## seconds rather than minutes. -static func _engage_threat(u: Unit, threat: Weapon, weapon_manager: WeaponManager, guided_allowed: bool, now: float, fit: Array = []) -> int: +static func _engage_threat(u: Unit, threat: Weapon, weapon_manager: WeaponManager, guided_allowed: bool, now: float, fit: Array = [], tti_s := -1.0, close_in_allowed := true) -> int: var d := u.position.distance_to(threat.position) + # One time-to-impact for the whole decision: the round's time to the ship it is going for, + # as the cycle ranked it. Budgeting on the time to this defender instead let the conserve + # policy pass one check and fail the other for the same shot. + var tti := tti_s if tti_s >= 0.0 else threat.time_to_reach_s(u.position) + var budget := guided_budget(u, tti) for spec: WeaponSpec in (u.defensive_weapons() if fit.is_empty() else fit): if not _can_intercept(spec, threat): continue if d > spec.max_range_nm or d < spec.min_range_nm: continue var is_close_in := spec.type == "ciws" - var spent := int(threat.defence_commitments.get(spec.defensive_layer(), 0)) + var spent := int(threat.defence_commitments.get(layer_key(spec, u), 0)) if is_close_in: - if int(threat.close_in_commitments.get(u.id, 0)) >= MAX_CLOSE_IN_BURSTS_PER_THREAT: + if not close_in_allowed or int(threat.close_in_commitments.get(u.id, 0)) >= MAX_CLOSE_IN_BURSTS_PER_THREAT: continue - elif not guided_allowed or spent >= guided_budget(u, threat.time_to_reach_s(u.position)): + elif not guided_allowed or spent >= budget: continue - var allowance := spec.salvo_default if is_close_in else guided_budget(u, threat.time_to_reach_s(u.position)) - spent + var allowance := spec.salvo_default if is_close_in else budget - spent var rounds := mini(mini(spec.salvo_default, u.magazine_count(spec.id)), allowance) if rounds <= 0: continue diff --git a/scripts/systems/combat.gd b/scripts/systems/combat.gd index b4715b58..f7a82795 100644 --- a/scripts/systems/combat.gd +++ b/scripts/systems/combat.gd @@ -41,6 +41,11 @@ const SURFACE_BOUND_PROFILES := ["sea_skimming", "direct", "subsurface"] static func profile_is_surface_bound(spec: WeaponSpec) -> bool: + # A "direct" air-to-air or anti-air round is fired at something in the air and flies at its + # height, not along the ground: a short-range dogfight missile shot across a headland, or a + # point-defence round at a helicopter over the coast, is not a sea-skimmer. + if spec.type in ["aam", "sam", "ciws"] and spec.profile == "direct": + return false return SURFACE_BOUND_PROFILES.has(spec.profile) diff --git a/scripts/systems/movement.gd b/scripts/systems/movement.gd index 23d43bfa..7c0ed9a9 100644 --- a/scripts/systems/movement.gd +++ b/scripts/systems/movement.gd @@ -3,6 +3,31 @@ class_name Movement const ARRIVAL_MIN_NM := 0.1 const FULL_TURN_SPEED_KN := 5.0 # GAMEPLAY_ESTIMATE: below this, rudder authority scales down +## A fixed-wing aircraft with nowhere to go flies a racetrack through the point it was sent to +## rather than stopping in the air. GAMEPLAY_ESTIMATE dimensions. +const HOLD_LEG_NM := 6.0 +const HOLD_WIDTH_NM := 3.0 + + +## Whether this unit is a fixed-wing aircraft in normal flight: it cannot stop, hover or park. +static func must_keep_flying(u: Unit) -> bool: + return u.is_aircraft() and not u.spec.can_hover and u.flight_state == Unit.FlightState.AIRBORNE + + +## A holding racetrack that starts and ends at `center`, its long legs along `heading_deg`. It ends +## where it began, so arriving at the last point simply lays the same pattern again: no state. +static func hold_pattern(center: Vector2, heading_deg: float) -> Array[Vector2]: + var along := Geo.heading_to_vector(heading_deg) + var right := Geo.heading_to_vector(heading_deg + 90.0) + return [center + along * HOLD_LEG_NM, center + along * HOLD_LEG_NM + right * HOLD_WIDTH_NM, center + right * HOLD_WIDTH_NM, center] + + +## Puts a fixed-wing aircraft into a hold at `center` at its economical patrol speed. +static func enter_hold(u: Unit, center: Vector2) -> void: + u.waypoints.assign(hold_pattern(center, u.heading_deg)) + u.ordered_speed_kn = u.spec.flight_speed("patrol") + u.hold_active = true + u.hold_point = center static func step(u: Unit, dt: float) -> void: @@ -33,11 +58,18 @@ static func step(u: Unit, dt: float) -> void: u.patrol_legs_completed += 1 if u.waypoints.is_empty(): u.ordered_heading_deg = u.heading_deg - u.ordered_speed_kn = 0.0 + if must_keep_flying(u): + enter_hold(u, wp) # a jet arriving with nowhere to go holds there + else: + u.ordered_speed_kn = 0.0 if not u.waypoints.is_empty(): desired = Geo.bearing_deg(u.position, u.waypoints[0]) u.ordered_heading_deg = desired + if not evading and must_keep_flying(u) and u.waypoints.is_empty() and u.ordered_speed_kn < u.spec.flight_speed("patrol") * 0.5: + # Ordered to stop, or left with no speed by anything else: a fixed-wing aircraft holds. + enter_hold(u, u.position) + desired = Geo.bearing_deg(u.position, u.waypoints[0]) var turn_scale := clampf(u.speed_kn / FULL_TURN_SPEED_KN, 0.0, 1.0) var rate := u.spec.turn_rate_deg_s * turn_scale if u.needs_sea_room() and u.spec.length_m > 0: diff --git a/scripts/ui/cds_menus.gd b/scripts/ui/cds_menus.gd index 732da7c1..b310a594 100644 --- a/scripts/ui/cds_menus.gd +++ b/scripts/ui/cds_menus.gd @@ -164,6 +164,12 @@ static func orders_items(units: Array, target: Track, controllable: bool, movabl tasks.append(item("Return to station [S]", order_action(Order.return_to_station()), can_return.is_empty(), reason if reason != "" else "Resume the patrol, screen or air station an investigation, attack or refuelling interrupted.")) if any_deck: tasks.append(item("Flight deck...", {"kind": "palette", "id": "air_operations"}, false, "Launch aircraft, or choose where an aircraft lands.")) + var alert_decks := units.filter(func(u: Unit) -> bool: return not u.is_aircraft() and AirMissionManager.alert_fighter_type(u) != "") + if not alert_decks.is_empty(): + var alerts: Array = [] + for count in [0, 2, 4]: + alerts.append(item("Off" if count == 0 else "%d fighters" % count, order_action(Order.set_ready_alert(count)), false, "Stand the alert down." if count == 0 else "Hold fighters on deck alert; the deck scrambles them into a CAP toward a hostile raid when one appears.", _all(alert_decks, func(u: Unit) -> bool: return u.ready_alert == count))) + tasks.append(submenu("Ready alert", alerts, not controllable, why)) if ships.size() >= 2: var forms: Array = [] for pattern in ["aaw_screen", "asw_screen", "transit", "screen", "column", "abreast"]: diff --git a/scripts/ui/defence_board.gd b/scripts/ui/defence_board.gd index c4bb2bef..830fc093 100644 --- a/scripts/ui/defence_board.gd +++ b/scripts/ui/defence_board.gd @@ -82,6 +82,11 @@ func _draw() -> void: var kind := "BM" if w.threat_class() == "ballistic" else ("TORP" if w.spec.is_torpedo() else "MSL") draw_rect(Rect2(12, y - 10, 3, 12), col) var tail := "%ds · %d up" % [int(tti), assigned] + var engaging := AirDefence.engaging_units(m.weapon_manager, w) + if not engaging.is_empty(): + # Who has the round: the first shooter, and how many more ships are on it. + var who := _short_name(engaging[0].callsign).get_slice(" (", 0).left(10) + tail = "%ds · %s%s" % [int(tti), who, "" if engaging.size() == 1 else " +%d" % (engaging.size() - 1)] if w.spec.is_torpedo(): tail = "%ds · %s" % [int(tti), torpedo_answer(t["target"])] var tw := _font.get_string_size(tail, HORIZONTAL_ALIGNMENT_LEFT, -1, 10).x @@ -94,7 +99,15 @@ func _draw() -> void: y += 12.0 # Per-shooter channel load. y += 8.0 - draw_string(_font, Vector2(12, y), "CHANNELS %d / %d" % [used, channels], HORIZONTAL_ALIGNMENT_LEFT, -1, 10, UITheme.COL_DIM) + var networked := 0 + for u: Unit in ships: + if u.spec.cooperative_engagement and u.datalink_connected(): + networked += 1 + var channel_line := "CHANNELS %d / %d" % [used, channels] + if networked >= 2: + # Two or more fitted ships on the link fight as one: each can take a shot a consort guides. + channel_line += " · NETWORKED FIRE CONTROL %d SHIPS" % networked + draw_string(_font, Vector2(12, y), channel_line, HORIZONTAL_ALIGNMENT_LEFT, int(size.x - 24.0), 10, UITheme.COL_ACCENT if networked >= 2 else UITheme.COL_DIM) y += 6.0 for u: Unit in ships: if y > size.y - 14.0: diff --git a/scripts/ui/unit_panel.gd b/scripts/ui/unit_panel.gd index 65f5ae1c..fc376c76 100644 --- a/scripts/ui/unit_panel.gd +++ b/scripts/ui/unit_panel.gd @@ -219,6 +219,10 @@ func _refresh() -> void: chips.append(_chip(roe_text, [UITheme.HEX_RED, UITheme.HEX_AMBER, UITheme.HEX_GREEN][u.roe])) if not u.datalink_connected(): chips.append(_chip("OFF LINK", UITheme.HEX_AMBER)) + elif u.spec.cooperative_engagement: + chips.append(_chip("NETWORKED FIRE CONTROL", UITheme.HEX_ACCENT)) + if u.ready_alert > 0: + chips.append(_chip("READY ALERT %d" % u.ready_alert, UITheme.HEX_ACCENT)) if u.jamming(): chips.append(_chip("JAMMING", UITheme.HEX_EW)) if u.fire > 0.0: diff --git a/scripts/ui/weapon_control.gd b/scripts/ui/weapon_control.gd index 5ae6c7d7..2bec9c09 100644 --- a/scripts/ui/weapon_control.gd +++ b/scripts/ui/weapon_control.gd @@ -458,15 +458,20 @@ func _commit_plan() -> void: unit_orders_requested.emit(pairs) var accepted := 0 var rounds := 0 + var why := "" for i in pairs.size(): var pair: Array = pairs[i] if pair[1].execution_accepted: accepted += 1 var spec := (pair[0] as Unit).get_weapon(pair[1].weapon_id) rounds += maxi(simulation.weapon_manager.committed_rounds(pair[0], spec, target) - committed_before[i], 0) + elif why == "": + var reason: String = pair[1].receipt if pair[1].receipt != "" else UnitManager.engage_rejection(pair[0], pair[1]) + if reason != "": + why = " (%s: %s)" % [(pair[0] as Unit).callsign, reason] _plan.clear() refresh() - _receipt.text = "%d rounds committed across %d systems; %d orders refused. Return to the chart to advance time." % [rounds, accepted, pairs.size() - accepted] + _receipt.text = "%d rounds committed across %d systems; %d orders refused%s. Return to the chart to advance time." % [rounds, accepted, pairs.size() - accepted, why] func _commit_group() -> void: diff --git a/tests/run_tests.gd b/tests/run_tests.gd index 39396d0e..49e24647 100644 --- a/tests/run_tests.gd +++ b/tests/run_tests.gd @@ -3,7 +3,7 @@ extends SceneTree ## ~/Applications/Godot.app/Contents/MacOS/Godot --headless --path . --script tests/run_tests.gd ## ... --script tests/run_tests.gd -- --only=test_attack.gd,test_cds.gd (a few files) -const TESTS := ["res://tests/test_save_migration.gd", "res://tests/test_commander_reports.gd", "res://tests/test_command_training.gd", "res://tests/test_world_fittings.gd", "res://tests/test_weapon_fidelity.gd", "res://tests/test_submarine_comms.gd", "res://tests/test_crew_profiles.gd", "res://tests/test_dipping_sonar.gd", "res://tests/test_operation_director.gd", "res://tests/test_sensor_cycle.gd", "res://tests/test_game_options.gd", "res://tests/test_group_attack.gd", "res://tests/test_ai_plans.gd", "res://tests/test_save.gd", "res://tests/test_air_missions.gd", "res://tests/test_station.gd", "res://tests/test_attack.gd", "res://tests/test_readout.gd", "res://tests/test_voice.gd", "res://tests/test_investigate.gd", "res://tests/test_weapon_plot.gd", "res://tests/test_combat_commands.gd", "res://tests/test_weapon_tracking.gd", "res://tests/test_patrol.gd", "res://tests/test_northern_passage.gd", "res://tests/test_qc_fixes.gd", "res://tests/test_weapon_control.gd", "res://tests/test_fleet_workshop.gd", "res://tests/test_operations.gd", "res://tests/test_regressions.gd", "res://tests/test_cold_war.gd", "res://tests/test_air_operations.gd", "res://tests/test_roster_m20.gd", "res://tests/test_lifecycle.gd", "res://tests/test_relative_motion.gd", "res://tests/test_clock.gd", "res://tests/test_tracking.gd", "res://tests/test_realism.gd", "res://tests/test_cic.gd", "res://tests/test_aegis.gd", "res://tests/test_systems.gd", "res://tests/test_geo.gd", "res://tests/test_movement.gd", "res://tests/test_sensors.gd", "res://tests/test_combat.gd", "res://tests/test_defence.gd", "res://tests/test_ai.gd", "res://tests/test_mission.gd", "res://tests/test_sonar.gd", "res://tests/test_aviation.gd", "res://tests/test_ew.gd", "res://tests/test_terrain.gd", "res://tests/test_art.gd", "res://tests/test_ux.gd", "res://tests/test_ocean.gd", "res://tests/test_damage_control.gd", "res://tests/test_shore_strike.gd", "res://tests/test_world_view.gd", "res://tests/test_cds.gd", "res://tests/test_fits.gd", "res://tests/test_torpedo_defence.gd", "res://tests/test_chart.gd", "res://tests/test_theme.gd"] +const TESTS := ["res://tests/test_save_migration.gd", "res://tests/test_commander_reports.gd", "res://tests/test_command_training.gd", "res://tests/test_world_fittings.gd", "res://tests/test_weapon_fidelity.gd", "res://tests/test_submarine_comms.gd", "res://tests/test_crew_profiles.gd", "res://tests/test_dipping_sonar.gd", "res://tests/test_operation_director.gd", "res://tests/test_sensor_cycle.gd", "res://tests/test_game_options.gd", "res://tests/test_group_attack.gd", "res://tests/test_ai_plans.gd", "res://tests/test_save.gd", "res://tests/test_air_missions.gd", "res://tests/test_station.gd", "res://tests/test_attack.gd", "res://tests/test_integrated_defence.gd", "res://tests/test_readout.gd", "res://tests/test_voice.gd", "res://tests/test_investigate.gd", "res://tests/test_weapon_plot.gd", "res://tests/test_combat_commands.gd", "res://tests/test_weapon_tracking.gd", "res://tests/test_patrol.gd", "res://tests/test_northern_passage.gd", "res://tests/test_qc_fixes.gd", "res://tests/test_weapon_control.gd", "res://tests/test_fleet_workshop.gd", "res://tests/test_operations.gd", "res://tests/test_regressions.gd", "res://tests/test_cold_war.gd", "res://tests/test_air_operations.gd", "res://tests/test_roster_m20.gd", "res://tests/test_lifecycle.gd", "res://tests/test_relative_motion.gd", "res://tests/test_clock.gd", "res://tests/test_tracking.gd", "res://tests/test_realism.gd", "res://tests/test_cic.gd", "res://tests/test_aegis.gd", "res://tests/test_systems.gd", "res://tests/test_geo.gd", "res://tests/test_movement.gd", "res://tests/test_sensors.gd", "res://tests/test_combat.gd", "res://tests/test_defence.gd", "res://tests/test_ai.gd", "res://tests/test_mission.gd", "res://tests/test_sonar.gd", "res://tests/test_aviation.gd", "res://tests/test_flight_deck.gd", "res://tests/test_ew.gd", "res://tests/test_terrain.gd", "res://tests/test_art.gd", "res://tests/test_ux.gd", "res://tests/test_ocean.gd", "res://tests/test_damage_control.gd", "res://tests/test_shore_strike.gd", "res://tests/test_world_view.gd", "res://tests/test_cds.gd", "res://tests/test_fits.gd", "res://tests/test_torpedo_defence.gd", "res://tests/test_chart.gd", "res://tests/test_theme.gd"] func _initialize() -> void: diff --git a/tests/test_air_missions.gd b/tests/test_air_missions.gd index 9827e49e..2dfc51a2 100644 --- a/tests/test_air_missions.gd +++ b/tests/test_air_missions.gd @@ -103,8 +103,9 @@ func test_a_cap_launches_real_airframes_and_queues_the_rest() -> void: seen[a] = true var working := 0 for a in m.aircraft: - # The 1990 raid is real: the CAP may already be intercepting it on its own sensors. - if m.state_of(a) in [AirMission.ON_STATION, AirMission.INVESTIGATING, AirMission.ENGAGING]: + # The 1990 raid is real: the CAP may already be intercepting it on its own sensors, at + # dash, and be on its way back to the station from the chase. + if m.state_of(a) in [AirMission.ON_STATION, AirMission.INVESTIGATING, AirMission.ENGAGING] or (m.state_of(a) == AirMission.TRANSITING and bool(m.tasks[a].get("attacked", false))): working += 1 assert_true(working >= 4, "the first section is on station or working it: %s" % m.summary()) _done() @@ -833,3 +834,57 @@ func test_reopening_air_operations_after_a_simple_launch_exposes_recovery() -> v assert_eq(_sim.air_mission_manager.active_missions("BLUE").size(), 0) ui.free() _done() + + +func test_a_deck_on_ready_alert_scrambles_a_cap_toward_a_hostile_raid() -> void: + _load(CARRIER_WATCH) + var cv := _unit(IKE) + assert_true(_sim.unit_manager.issue_order(cv, Order.set_ready_alert(2))) + assert_eq(cv.ready_alert, 2) + _advance(5.0) + assert_true(_mission() == null, "nothing to scramble for yet") + var raid := _plot(cv.position + Vector2(0, 45), "T1900", 8000.0) + raid.domain = "air" + raid.identity = "HOSTILE" + raid.classification = Track.Classification.CLASS_KNOWN + _advance(2.0) + var m := _mission() + assert_true(m != null, "the alert launched") + assert_eq(m.kind, AirMission.Kind.CAP) + assert_eq(m.requested, 2, "the two on alert") + assert_eq(cv.ready_alert, 0, "the alert is spent by the scramble") + assert_true(m.station.distance_to(cv.position) > 10.0 and m.station.distance_to(raid.position) < raid.position.distance_to(cv.position), "laid between the carrier and the raid") + _done() + + +func test_ready_alert_needs_fighters_aboard() -> void: + _load(CARRIER_WATCH) + var cv := _unit(IKE) + assert_eq(AirMissionManager.ready_alert_rejection(cv, 2), "") + assert_eq(AirMissionManager.ready_alert_rejection(cv, 9), "At most %d on alert" % AirMissionManager.MAX_READY_ALERT) + for u in _sim.unit_manager.units: + if u.faction == "BLUE" and not u.is_aircraft() and u.spec.aircraft_capacity > 0 and AirMissionManager.alert_fighter_type(u) == "": + assert_true(not _sim.unit_manager.issue_order(u, Order.set_ready_alert(2)), "%s has no fighters to hold" % u.callsign) + break + _done() + + +func test_a_dash_ordered_on_station_is_not_undone_by_the_mission() -> void: + _load(CARRIER_WATCH) + var cv := _unit(IKE) + var station := cv.position + Vector2(0, 20) + assert_true(_sim.unit_manager.issue_order(cv, Order.air_mission(AirMission.Kind.CAP, "cw90_f14a", 1, station, 10.0))) + var m := _mission() + var on_station := false + for i in 40: + _advance(30.0) + if not m.aircraft.is_empty() and m.state_of(m.aircraft[0]) == AirMission.ON_STATION: + on_station = true + break + assert_true(on_station) + var jet: Unit = m.aircraft[0] + var dash := jet.spec.flight_speed("dash") + assert_true(_sim.unit_manager.issue_order(jet, Order.set_speed(dash))) + _advance(5.0) + assert_near(jet.ordered_speed_kn, dash, 0.5, "the commander's dash stands") + _done() diff --git a/tests/test_attack.gd b/tests/test_attack.gd index c82e0498..d035af34 100644 --- a/tests/test_attack.gd +++ b/tests/test_attack.gd @@ -580,3 +580,20 @@ func test_an_attack_that_can_neither_fire_nor_move_ends_with_the_reason() -> voi assert_true(ended, "but the task does not park the ship forever") assert_eq(h.ended[0][2], "Cannot engage: radar guidance unavailable") h.free_all() + + +func test_an_attack_held_on_a_stale_plot_gives_it_back_with_the_reason() -> void: + var asm := _asm(60.0) + var shooter := _unit("BLUE", Vector2.ZERO, 100.0, [asm]) + var target := _unit("RED", Vector2(0, 30), 800.0) + var t := _track(target, Vector2(0, 30)) + t.status = Track.Status.STALE + var h := _harness([shooter, target]) + h.um.issue_order(shooter, Order.attack(t)) + h.advance(UnitManager.ATTACK_STALE_S - 5.0) + assert_true(h.ended.is_empty(), "still closing on the plot") + assert_eq(shooter.attack_rounds_fired, 0) + h.advance(10.0) + assert_eq(h.ended.size(), 1, "the crew does not park on a plot nobody refreshes") + assert_eq(h.ended[0][2], "Contact stale: no fresh plot to fire on") + h.free_all() diff --git a/tests/test_flight_deck.gd b/tests/test_flight_deck.gd new file mode 100644 index 00000000..3a672791 --- /dev/null +++ b/tests/test_flight_deck.gd @@ -0,0 +1,245 @@ +extends TestCase +## Flight operations that behave like flight operations: a jet never stops in the air, an aircraft +## launched without orders holds where it can be seen, aircraft back at a busy or burning deck hold +## in a marshal stack astern instead of circling the ship at transit speed, and a helicopter is +## not turned round like a strike fighter. + + +const DT := 0.25 + + +func _ship_spec(capacity := 1) -> PlatformSpec: + var p := PlatformSpec.new() + p.short_name = "DDG Test" + p.domain = "surface" + p.max_speed_kn = 30.0 + p.cruise_speed_kn = 16.0 + p.health = 110.0 + p.signature_factor = 1.0 + p.mast_height_m = 30.0 + p.acoustic_signature = 1.0 + p.aircraft_capacity = capacity + p.turnaround_s = 600.0 # short of a real deck cycle, long enough to observe + return p + + +func _helo_spec(endurance := 3600.0) -> PlatformSpec: + var p := PlatformSpec.new() + p.short_name = "Helo Test" + p.domain = "air" + p.max_speed_kn = 145.0 + p.cruise_speed_kn = 110.0 + p.turn_rate_deg_s = 6.0 + p.accel_kn_s = 2.0 + p.health = 20.0 + p.signature_factor = 0.25 + p.cruise_altitude_m = 500.0 + p.max_altitude_m = 3500.0 + p.altitude_rate_m_s = 8.0 + p.endurance_s = endurance + p.can_hover = true + p.launch_time_s = 60.0 + p.recovery_time_s = 60.0 + p.sonobuoy_count = 4 + p.sonobuoy_sensitivity_nm = 24.0 + p.sonobuoy_life_s = 1800.0 + return p + + +func _unit(spec: PlatformSpec, faction: String, pos: Vector2, speed := 0.0) -> Unit: + var u := Unit.new() + u.spec = spec + u.faction = faction + u.callsign = "%s-%d" % [faction, randi() % 1000] + u.position = pos + u.speed_kn = speed + u.ordered_speed_kn = speed + u.health = spec.health + return u + + +class Harness: + extends RefCounted + var um: UnitManager + var av: AviationManager + var tm: TrackManager + var sm: SensorManager + + func free_all() -> void: + if sm != null: + sm.free() + if tm != null: + tm.free() + av.free() + um.free() + + +func _harness(units: Array, with_sensors := false) -> Harness: + var h := Harness.new() + h.um = UnitManager.new() + for u: Unit in units: + h.um.add_unit(u) + h.av = AviationManager.new() + h.av.unit_manager = h.um + if with_sensors: + h.tm = TrackManager.new() + h.sm = SensorManager.new() + h.sm.unit_manager = h.um + h.sm.track_manager = h.tm + h.sm.aviation_manager = h.av + h.sm.rng.seed = 4 + return h + + +func _embark(parent: Unit, aircraft: Unit) -> void: + aircraft.home = parent + aircraft.home_callsign = parent.callsign + aircraft.fuel_s = aircraft.spec.endurance_s + aircraft.sonobuoys = aircraft.spec.sonobuoy_count + parent.embarked.append(aircraft) + + +func _run(h: Harness, seconds: float, now_start := 0.0) -> float: + var now := now_start + for i in int(seconds / DT): + now += DT + h.um.tick(DT) + h.av.tick(DT, now) + return now + + +func _jet_spec() -> PlatformSpec: + var p := _helo_spec(9000.0) + p.short_name = "Jet Test" + p.can_hover = false + p.max_speed_kn = 1000.0 + p.cruise_speed_kn = 480.0 + p.turn_rate_deg_s = 7.0 + p.accel_kn_s = 20.0 + p.cruise_altitude_m = 9000.0 + p.max_altitude_m = 15000.0 + p.altitude_rate_m_s = 35.0 + p.launch_time_s = 45.0 + p.recovery_time_s = 60.0 + p.launch_requirement = "helicopter" # lets the test deck operate it + return p + + +func _airborne(spec: PlatformSpec, pos: Vector2) -> Unit: + var a := _unit(spec, "BLUE", pos, spec.flight_speed("transit")) + a.flight_state = Unit.FlightState.AIRBORNE + a.altitude_m = spec.cruise_altitude_m + a.fuel_s = spec.endurance_s + return a + + +func test_a_jet_arriving_with_nowhere_to_go_holds_instead_of_stopping() -> void: + var jet := _airborne(_jet_spec(), Vector2.ZERO) + jet.waypoints.assign([Vector2(0, 0.05)]) + Movement.step(jet, DT) + assert_true(jet.ordered_speed_kn >= jet.spec.flight_speed("patrol") - 0.01, "still flying") + assert_eq(jet.waypoints.size(), 4, "a racetrack") + assert_true(jet.waypoints.back().distance_to(Vector2(0, 0.05)) < 1e-4, "through the point it was sent to") + for i in 400: + Movement.step(jet, DT) + assert_true(jet.speed_kn > 100.0, "it never slows to a stop") + assert_true(jet.position.distance_to(Vector2(0, 0.05)) < Movement.HOLD_LEG_NM + Movement.HOLD_WIDTH_NM + 1.0, "and stays at the point") + + +func test_a_stop_order_puts_a_jet_into_a_hold_where_it_is() -> void: + var jet := _airborne(_jet_spec(), Vector2(3, 4)) + jet.apply_order(Order.stop()) + Movement.step(jet, DT) + assert_eq(jet.waypoints.size(), 4) + assert_true(jet.ordered_speed_kn > 0.0) + + +func test_a_helicopter_still_stops_at_its_point() -> void: + var helo := _airborne(_helo_spec(), Vector2.ZERO) + helo.waypoints.assign([Vector2(0, 0.05)]) + Movement.step(helo, DT) + assert_true(helo.waypoints.is_empty()) + assert_eq(helo.ordered_speed_kn, 0.0, "a helicopter can hover") + + +func test_an_aircraft_launched_without_orders_holds_ahead_of_its_ship() -> void: + var ship := _unit(_ship_spec(), "BLUE", Vector2.ZERO) + ship.heading_deg = 90.0 + var jet := _unit(_jet_spec(), "BLUE", Vector2.ZERO) + _embark(ship, jet) + var h := _harness([ship, jet]) + assert_true(h.av.launch(ship) == jet) + _run(h, jet.spec.launch_time_s + 2.0) + assert_true(jet.airborne()) + var departure := ship.position + Geo.heading_to_vector(90.0) * AviationManager.DEPARTURE_HOLD_NM + assert_true(not jet.waypoints.is_empty(), "it has somewhere to be") + assert_true(jet.waypoints.back().distance_to(departure) < 1.0, "a hold ahead of the ship") + _run(h, 1800.0, jet.spec.launch_time_s + 2.0) + assert_true(jet.position.distance_to(departure) < Movement.HOLD_LEG_NM + Movement.HOLD_WIDTH_NM + 2.0, "still there half an hour later, not flown off the chart") + h.free_all() + + +func test_an_aircraft_back_at_a_busy_deck_holds_in_the_marshal_stack_astern() -> void: + var ship := _unit(_ship_spec(2), "BLUE", Vector2.ZERO, 15.0) + var first := _airborne(_helo_spec(), Vector2(0, -1.5)) + var second := _airborne(_jet_spec(), Vector2(0, -20)) + _embark(ship, first) + _embark(ship, second) + var h := _harness([ship, first, second]) + assert_true(h.av.request_return(first)) + assert_true(h.av.request_return(second)) + _run(h, 3.0) + assert_eq(first.flight_state, Unit.FlightState.RECOVERING, "the first is on the deck's one recovery spot") + _run(h, 120.0, 3.0) + assert_true(AviationManager.in_marshal(second), "the second is waiting in marshal") + var marshal := AviationManager.marshal_point(ship) + assert_true(second.position.distance_to(marshal) < Movement.HOLD_LEG_NM + Movement.HOLD_WIDTH_NM + 2.0, "astern of the ship, not over it") + assert_true(second.ordered_speed_kn <= second.spec.flight_speed("patrol") + 0.01, "at economical speed") + assert_true(second.position.distance_to(ship.position) > AviationManager.RECOVERY_RANGE_NM, "clear of the deck") + var recovered := false + var now := 123.0 + for i in 40: + now = _run(h, 30.0, now) + if second.flight_state in [Unit.FlightState.RECOVERING, Unit.FlightState.TURNAROUND]: + recovered = true + break + assert_true(recovered, "called down once the deck is clear") + h.free_all() + + +func test_a_burning_deck_takes_nobody_aboard() -> void: + var ship := _unit(_ship_spec(), "BLUE", Vector2.ZERO) + ship.fire = 0.5 + var helo := _airborne(_helo_spec(), Vector2(0, -1.0)) + _embark(ship, helo) + var h := _harness([ship, helo]) + assert_true(h.av.request_return(helo)) + _run(h, 60.0) + assert_eq(helo.flight_state, Unit.FlightState.AIRBORNE, "flight operations suspended") + assert_true(AviationManager.in_marshal(helo)) + ship.fire = 0.0 + _run(h, 120.0, 60.0) + assert_true(helo.flight_state in [Unit.FlightState.RECOVERING, Unit.FlightState.TURNAROUND], "taken aboard once the fire is out") + h.free_all() + + +func test_a_helicopter_aboard_a_carrier_is_not_turned_round_like_a_strike_fighter() -> void: + var carrier := _unit(_ship_spec(40), "BLUE", Vector2.ZERO) + carrier.spec.turnaround_s = 2700.0 + var helo := _unit(_helo_spec(), "BLUE", Vector2.ZERO) + var jet := _unit(_jet_spec(), "BLUE", Vector2.ZERO) + assert_eq(AviationManager.turnaround_for(helo, carrier), AviationManager.HELICOPTER_TURNAROUND_S) + assert_eq(AviationManager.turnaround_for(jet, carrier), 2700.0) + + +func test_a_hold_laid_for_want_of_orders_is_not_a_route() -> void: + # The AI and the stalled-task checks ask whether a unit has a route; a jet holding because it + # had nowhere to go must still read as idle, or it would hold for ever. + var jet := _airborne(_jet_spec(), Vector2.ZERO) + jet.waypoints.assign([Vector2(0, 0.05)]) + Movement.step(jet, DT) + assert_true(jet.holding()) + assert_true(not jet.has_route(), "a hold is not a route") + jet.apply_order(Order.move(Vector2(20, 20))) + assert_true(not jet.holding(), "a new destination ends the hold") + assert_true(jet.has_route()) diff --git a/tests/test_flight_deck.gd.uid b/tests/test_flight_deck.gd.uid new file mode 100644 index 00000000..b85087d4 --- /dev/null +++ b/tests/test_flight_deck.gd.uid @@ -0,0 +1 @@ +uid://br4gjixi1shsl diff --git a/tests/test_integrated_defence.gd b/tests/test_integrated_defence.gd new file mode 100644 index 00000000..27be744e --- /dev/null +++ b/tests/test_integrated_defence.gd @@ -0,0 +1,335 @@ +extends TestCase +## The task force's air defence as one system: one allocation across the raid, each ship's own +## inner layer, networked fire control between fitted ships, Weapons Free against aircraft, and the +## weapon-path fixes that came with them (refusal reasons, kills cancelling queued rounds, stale +## attacks giving up, air-to-air rounds over land, anti-air salvo sizes). + + +func _platform(hp := 100.0) -> PlatformSpec: + var p := PlatformSpec.new() + p.short_name = "Test" + p.max_speed_kn = 30.0 + p.cruise_speed_kn = 15.0 + p.turn_rate_deg_s = 6.0 + p.accel_kn_s = 1.0 + p.health = hp + p.decoy_count = 0 + p.fire_control_channels = 4 + return p + + +func _radar() -> SensorSpec: + var s := SensorSpec.new() + s.kind = "radar" + s.range_surface_nm = 40.0 + s.range_air_nm = 200.0 + s.antenna_height_m = 20.0 + return s + + +func _asm() -> WeaponSpec: + var w := WeaponSpec.new() + w.id = "test_asm" + w.display_name = "Test ASM" + w.type = "asm" + w.max_range_nm = 70.0 + w.min_range_nm = 2.0 + w.speed_kn = 480.0 + w.turn_rate_deg_s = 15.0 + w.seeker_range_nm = 8.0 + w.damage = 40.0 + w.base_pk = 1.0 + w.salvo_default = 4 + w.launch_interval_s = 4.0 + w.altitude_m = 10.0 + w.signature_factor = 0.15 + return w + + +func _sam(id: String, max_range: float, pk: float, interval := 1.0) -> WeaponSpec: + var w := WeaponSpec.new() + w.id = id + w.display_name = id + w.type = "sam" + w.target_types = PackedStringArray(["missile", "air"]) + w.max_range_nm = max_range + w.min_range_nm = 0.05 + w.speed_kn = 2400.0 + w.turn_rate_deg_s = 120.0 + w.base_pk = pk + w.salvo_default = 2 + w.launch_interval_s = interval + return w + + +func _ship(faction: String, pos: Vector2, weapons: Array = [], rounds := 32) -> Unit: + var u := Unit.new() + u.spec = _platform() + u.faction = faction + u.callsign = "%s-%d" % [faction, int(pos.x * 10.0 + pos.y)] + u.position = pos + u.health = 100.0 + u.decoys = 0 + u.sensors.append(_radar()) + for spec: WeaponSpec in weapons: + u.weapons.append(spec) + u.magazines[spec.id] = rounds + return u + + +func _harness(units: Array) -> Array: + Terrain.clear() + var um := UnitManager.new() + for u: Unit in units: + um.add_unit(u) + var tm := ThreatManager.new() + var wm := WeaponManager.new() + wm.unit_manager = um + wm.rng.seed = 5 + um.weapon_manager = wm + return [um, tm, wm] + + +func _incoming(wm: WeaponManager, faction: String, pos: Vector2, target: Unit, wid := 900) -> Weapon: + var w := Weapon.new() + w.id = wid + w.spec = _asm() + w.faction = faction + w.position = pos + w.acquired = target + w.aim_point = target.position + w.heading_deg = Geo.bearing_deg(pos, target.position) + w.phase = Weapon.Phase.TERMINAL + wm.in_flight.append(w) + return w + + +func _cleanup(h: Array) -> void: + h[1].free() + h[2].free() + h[0].free() + + +# --- One allocation for the force ------------------------------------------------------------- + +func test_a_raid_gets_one_interceptor_per_round_before_any_round_gets_a_second() -> void: + # Each ship can put one missile up per cycle. Two rounds arrive together. Answering them one at + # a time sent both ships' missiles at the first round and none at the second. + var a := _ship("BLUE", Vector2(0, 0), [_sam("area", 50.0, 0.0, 30.0)]) + var b := _ship("BLUE", Vector2(1, 0), [_sam("area", 50.0, 0.0, 30.0)]) + var h := _harness([a, b]) + var first := _incoming(h[2], "RED", Vector2(0, 20), a, 901) + var second := _incoming(h[2], "RED", Vector2(1, 20.2), b, 902) + h[1].mark_detected("BLUE", first, 0.0) + h[1].mark_detected("BLUE", second, 0.0) + assert_eq(AirDefence.run_cycle(h[0], h[1], h[2], 0.0), 2) + assert_eq(first.guided_interceptors_committed, 1, "the first round has one interceptor") + assert_eq(second.guided_interceptors_committed, 1, "and so does the second, from the other ship") + _cleanup(h) + + +func test_a_ship_with_a_free_channel_is_asked_before_a_nearer_saturated_one() -> void: + var near := _ship("BLUE", Vector2(0, 5), [_sam("area", 50.0, 0.0)]) + near.spec.fire_control_channels = 1 + var far := _ship("BLUE", Vector2(0, 0), [_sam("area", 50.0, 0.0)]) + var h := _harness([near, far]) + var busy := _incoming(h[2], "RED", Vector2(30, 30), near, 903) + assert_eq(h[2].launch_interceptor(near, near.weapons[0], busy, 1, 0.0), 1, "the near ship's one channel is taken") + var round := _incoming(h[2], "RED", Vector2(0, 25), far, 904) + h[1].mark_detected("BLUE", round, 0.0) + AirDefence.run_cycle(h[0], h[1], h[2], 5.0) + assert_eq(AirDefence.engaging_units(h[2], round), [far] as Array[Unit], "the ship with a channel took it") + _cleanup(h) + + +func test_each_ship_keeps_its_own_inner_layer_allowance() -> void: + var point_escort := _sam("point", 12.0, 0.0) + var point_own := _sam("point", 12.0, 1.0) + var escort := _ship("BLUE", Vector2(1, 0), [point_escort]) + var victim := _ship("BLUE", Vector2(0, 0), [point_own]) + var h := _harness([escort, victim]) + var round := _incoming(h[2], "RED", Vector2(0, 8), victim) + # The escort has already spent its point-layer allowance on this round. + round.defence_commitments[AirDefence.layer_key(point_escort, escort)] = 2 + assert_eq(AirDefence._engage_threat(escort, round, h[2], true, 0.0), 0, "the escort is out of shots at it") + assert_eq(AirDefence._engage_threat(victim, round, h[2], true, 0.0), 1, "the ship it is aimed at still has its own") + _cleanup(h) + + +# --- Networked fire control ------------------------------------------------------------------- + +func _networked_case(shooter_flag: bool, consort_flag: bool) -> Array: + var sam := _sam("supported", 80.0, 0.0) + sam.guidance = "fire_control_directed" + var shooter := _ship("BLUE", Vector2(0, 0), [sam]) + shooter.spec.cooperative_engagement = shooter_flag + var consort := _ship("BLUE", Vector2(0, 32), []) + consort.spec.cooperative_engagement = consort_flag + var h := _harness([shooter, consort]) + # A sea-skimmer forty miles out: below the shooter's horizon, ten miles from the consort. + var round := _incoming(h[2], "RED", Vector2(0, 40), shooter) + h[1].mark_detected("BLUE", round, 0.0) + return [h, shooter, consort, sam, round] + + +func test_an_unfitted_consort_cues_but_cannot_guide_below_the_shooters_horizon() -> void: + var c := _networked_case(true, false) + assert_eq(AirDefence.run_cycle(c[0][0], c[0][1], c[0][2], 0.0), 0, "a cue is not guidance") + assert_true(c[0][2].network_guide(c[1], c[3], c[4]) == null) + _cleanup(c[0]) + + +func test_fitted_ships_on_the_link_guide_each_others_shots() -> void: + var c := _networked_case(true, true) + var h: Array = c[0] + var shooter: Unit = c[1] + var consort: Unit = c[2] + assert_eq(h[2].network_guide(shooter, c[3], c[4]), consort, "the consort's radar carries the shot") + assert_eq(AirDefence.run_cycle(h[0], h[1], h[2], 0.0), 1, "the shooter fires beyond its own horizon") + var interceptor: Weapon = h[2].in_flight.back() + h[2].tick(0.25, 0.25) + assert_true(interceptor.phase != Weapon.Phase.DEAD, "guidance holds while the consort has the round") + consort.radar_on = false + h[2].tick(0.25, 0.5) + assert_eq(interceptor.dead_reason, "GUIDANCE LOST", "the guide's radar went off") + _cleanup(h) + + +func test_networked_fire_control_needs_the_link_at_both_ends() -> void: + var c := _networked_case(true, true) + var h: Array = c[0] + (c[1] as Unit).spec.has_datalink = false + assert_true(h[2].network_guide(c[1], c[3], c[4]) == null, "the shooter is off the link") + (c[1] as Unit).spec.has_datalink = true + (c[2] as Unit).spec.has_datalink = false + assert_true(h[2].network_guide(c[1], c[3], c[4]) == null, "the consort is off the link") + _cleanup(h) + + +func test_a_self_guided_interceptor_needs_no_network() -> void: + var c := _networked_case(false, false) + var active := _sam("active", 80.0, 0.0) + assert_true(c[0][2].guidance_available(c[1], active, c[4]), "an active seeker guides itself") + _cleanup(c[0]) + + +func test_shipped_aegis_hulls_are_fitted_and_the_1990_cruiser_is_not() -> void: + for id: String in ["usn_ddg_arleigh_burke_iia", "usn_ddg_burke_iii", "usn_cg_ticonderoga", "jmsdf_ddg_maya"]: + assert_true(DataDB.platform(id).cooperative_engagement, "%s fights on the network" % id) + assert_true(not DataDB.platform("cw90_ticonderoga").cooperative_engagement, "not in the 1990 fleet") + + +# --- Weapons Free against aircraft ------------------------------------------------------------ + +func _air_case(identity := "HOSTILE") -> Array: + var sam := _sam("area", 40.0, 0.0) + var ship := _ship("BLUE", Vector2.ZERO, [sam], 20) + var plane := Unit.new() + plane.spec = _platform() + plane.spec.domain = "air" + plane.faction = "RED" + plane.position = Vector2(0, 20) + plane.altitude_m = 3000.0 + var h := _harness([ship, plane]) + var t := Track.new() + t.id = "T1100" + t.owner_faction = "BLUE" + t.truth = plane + t.position = plane.position + t.status = Track.Status.ACTIVE + t.domain = "air" + t.altitude_m = 3000.0 + t.identity = identity + t.classification = Track.Classification.CLASS_KNOWN + var tm := TrackManager.new() + tm._tracks["BLUE"] = [t] + return [h, ship, t, tm] + + +func test_a_ship_on_weapons_free_engages_a_hostile_aircraft_in_its_envelope() -> void: + var c := _air_case() + var h: Array = c[0] + assert_eq(AirDefence.engage_hostile_aircraft(h[0], c[3], h[2], 0.0, ["BLUE"]), 1) + assert_eq(h[2].faction_commitment("BLUE", c[2]), 1, "one round: shoot, look, shoot") + AirDefence.engage_hostile_aircraft(h[0], c[3], h[2], 1.0, ["BLUE"]) + AirDefence.engage_hostile_aircraft(h[0], c[3], h[2], 2.0, ["BLUE"]) + assert_eq(h[2].faction_commitment("BLUE", c[2]), AirDefence.AIRCRAFT_ROUNDS_IN_FLIGHT, "and no more than the side's limit") + c[3].free() + _cleanup(h) + + +func test_weapons_tight_manual_defence_and_unidentified_aircraft_are_left_to_the_commander() -> void: + var c := _air_case() + (c[1] as Unit).roe = Unit.Roe.TIGHT + assert_eq(AirDefence.engage_hostile_aircraft(c[0][0], c[3], c[0][2], 0.0, ["BLUE"]), 0, "tight") + (c[1] as Unit).roe = Unit.Roe.FREE + (c[1] as Unit).auto_air_defence = false + assert_eq(AirDefence.engage_hostile_aircraft(c[0][0], c[3], c[0][2], 0.0, ["BLUE"]), 0, "manual defence") + (c[1] as Unit).auto_air_defence = true + (c[2] as Track).identity = "UNKNOWN" + assert_eq(AirDefence.engage_hostile_aircraft(c[0][0], c[3], c[0][2], 0.0, ["BLUE"]), 0, "not identified") + assert_eq(AirDefence.engage_hostile_aircraft(c[0][0], c[3], c[0][2], 0.0, []), 0, "an AI side is not stepped here") + c[3].free() + _cleanup(c[0]) + + +func test_missiles_are_kept_back_for_missiles() -> void: + var c := _air_case() + (c[1] as Unit).magazines["area"] = AirDefence.SAM_RESERVE_FOR_MISSILES + assert_eq(AirDefence.engage_hostile_aircraft(c[0][0], c[3], c[0][2], 0.0, ["BLUE"]), 0) + c[3].free() + _cleanup(c[0]) + + +# --- The weapon path -------------------------------------------------------------------------- + +func test_a_refused_shot_says_why() -> void: + var c := _air_case() + var ship: Unit = c[1] + ship.roe = Unit.Roe.HOLD + var order := Order.engage(c[2], "area", 1) + assert_true(not c[0][0].issue_order(ship, order)) + assert_eq(order.receipt, "weapons hold") + assert_eq(UnitManager.engage_rejection(ship, Order.engage(c[2], "nothing_aboard", 1)), "nothing_aboard not aboard") + c[3].free() + _cleanup(c[0]) + + +func test_rounds_still_queued_are_returned_once_the_plot_records_the_kill() -> void: + var c := _air_case() + var ship: Unit = c[1] + var sam: WeaponSpec = ship.weapons[0] + sam.launch_interval_s = 10.0 + assert_true(c[0][2].launch(ship, sam, c[2], 3, 0.0)) + assert_eq(ship.magazine_count("area"), 17) + assert_eq(c[0][2].committed_rounds(ship, sam, c[2], true), 2, "two still on the rail") + (c[2] as Track).damage_estimate = 100.0 + c[0][2].tick(0.25, 0.25) + assert_eq(c[0][2].committed_rounds(ship, sam, c[2], true), 0) + assert_eq(ship.magazine_count("area"), 19, "both queued rounds are back in the magazine") + c[3].free() + _cleanup(c[0]) + + +func test_an_air_to_air_or_anti_air_round_is_not_a_sea_skimmer() -> void: + var aam := WeaponSpec.new() + aam.type = "aam" + aam.profile = "direct" + assert_true(not Combat.profile_is_surface_bound(aam), "a dogfight missile flies at height") + var hellfire := WeaponSpec.new() + hellfire.type = "asm" + hellfire.profile = "direct" + assert_true(Combat.profile_is_surface_bound(hellfire), "an air-to-surface direct round still meets the ground") + + +func test_the_ai_fires_a_weapons_own_salvo_at_an_aircraft_not_an_anti_ship_volley() -> void: + var ai := AIController.new() + var sam := _sam("area", 100.0, 0.5) + var ship := _ship("RED", Vector2.ZERO, [sam, _asm()], 24) + var air := Track.new() + air.domain = "air" + var surface := Track.new() + surface.domain = "surface" + assert_eq(ai._salvo_for(ship, sam, air), 2, "two SAMs at one aircraft") + assert_eq(ai._salvo_for(ship, ship.weapons[1], surface), AIController.ASM_SALVO, "a full volley at a ship") + ai.free() diff --git a/tests/test_integrated_defence.gd.uid b/tests/test_integrated_defence.gd.uid new file mode 100644 index 00000000..758745a4 --- /dev/null +++ b/tests/test_integrated_defence.gd.uid @@ -0,0 +1 @@ +uid://l3gmlfwk01dd diff --git a/tests/test_investigate.gd b/tests/test_investigate.gd index e20bf5c4..d6c0528b 100644 --- a/tests/test_investigate.gd +++ b/tests/test_investigate.gd @@ -243,8 +243,11 @@ func test_fixed_wing_keeps_flying_at_plot_and_after_classification() -> void: t.classification = Track.Classification.CLASS_KNOWN um.tick(0.25) assert_eq(a.investigation_track, null) - assert_true(a.ordered_speed_kn >= a.spec.cruise_speed_kn, "completed aircraft maintains forward flight") - assert_true(a.waypoints.is_empty(), "no continued pursuit after positive classification") + assert_true(a.ordered_speed_kn >= a.spec.flight_speed("patrol") - 0.01, "completed aircraft maintains forward flight") + # It holds where it finished, a racetrack through its own position, rather than flying on along + # its heading until bingo or continuing to pursue the classified contact. + assert_eq(a.waypoints.size(), 4, "a holding pattern") + assert_true(a.waypoints.back().distance_to(a.position) < 0.5, "the hold is laid where the aircraft is") um.free() diff --git a/tools/scenarios/theatres_2027.py b/tools/scenarios/theatres_2027.py index 3298c3e1..0ef7f132 100644 --- a/tools/scenarios/theatres_2027.py +++ b/tools/scenarios/theatres_2027.py @@ -423,6 +423,7 @@ def catalogue(): "mk45_mod4_gun": 300, "phalanx_ciws": 60, "jmsdf_type12_torpedo": 6}, length_m=170.0, displacement_t=10250.0, health=125.0, signature_factor=1.05, mast_height_m=32.0, fire_control_channels=6, decoy_count=14, aircraft_capacity=1, vls_cells=96, + cooperative_engagement=True, default_air_wing={"jmsdf_helo_sh60k": 1}, role="Aegis area air and ballistic missile defence", service_note="Aegis Baseline J7 with cooperative engagement; 96 cells shared between Standard, SM-3, SM-6, ESSM and the Type 07 rocket. Eight Type 17 in canisters.")