Skip to content

City: track crashed vehicles in a set, delay and gate AI rescue dispatch (#1694) - #1695

Open
andremmfaria wants to merge 2 commits into
OpenApoc:masterfrom
andremmfaria:fix/1694-crashed-vehicle-list
Open

andremmfaria wants to merge 2 commits into
OpenApoc:masterfrom
andremmfaria:fix/1694-crashed-vehicle-list

Conversation

@andremmfaria

Copy link
Copy Markdown
Contributor

Fixes #1694.

Thanks to @JonnyH for measuring this and pointing it out in the review comment on db04a32: the rescue dispatch added in #1684 turned every GameState::update into an orgs x vehicles x rescuers scan, about 24 ms per update on an idle city on a 5800X3D, whether or not anything had crashed.

Root cause

Organisation::updateMissions (called once per organisation per update) collected every idle rescue capable craft, rebuilt the set of already claimed victims from every vehicle's mission list, and then walked state.vehicles up to twice per idle rescuer calling VehicleMission::canRecoverVehicle on each. On a populated city that is hundreds of idle rescuers times hundreds of vehicles times 28 organisations, with a string id compare and a relation lookup per candidate, and it ran even when no vehicle in the city was crashed. perf on the pre fix harness binary puts the time in Organisation::updateMissions, _Rb_tree_increment, memcmp and Organisation::getRelationTo, which is that allied rescue loop.

Fix

The strategy follows JonnyH's suggestion on Discord: instead of scanning the fleet for wrecks, keep a list of crashed vehicles fed by the state transition, and put the non vanilla mechanics behind NewFeature options that default to off.

  • GameState::crashedVehicles is a std::set<UString> of vehicle ids maintained at every site that flips Vehicle::crashed: Vehicle::crash, Vehicle::setCrashed, enterBuilding, enterDimensionGate, dropCarriedVehicle, plus the death note prune in cleanUpDeathNote. It is not serialized: initState() rebuilds it from the loaded vehicles, so saves are untouched. Every reader self heals a stale entry (id no longer in state.vehicles, or the flag already false) by erasing it.
  • GameState::update walks that set once per update, shared by all organisations. If no eligible non alien wreck exists, nothing else runs. If one does, the claimed victim set is built once and passed to the new Organisation::dispatchRescueCraft, which iterates crashedVehicles instead of state.vehicles for both the owned and the allied target loops. The rescue dispatch code itself is the City: dispatch every idle rescue craft per organisation update (#1008) #1684 logic, just moved out of updateMissions and pointed at the set.
  • Each wreck draws a rescue availability tick when it becomes crashed, now plus a random 60 to 600 game seconds from state.rng. The update gate and both dispatch loops ignore a wreck until that tick, so emergency services do not launch within seconds of a crash and the dispatch pass stays idle for the first minutes after one. Player initiated recovery through the UI is not delayed. The tick is not saved, so a wreck loaded from a save is eligible at once.
  • Options, all default off: new OpenApoc.NewFeature.RescueCrashedVehicles (organisations dispatch rescue craft) and OpenApoc.NewFeature.CrashingDamagedVehicles (damaged vehicles crash instead of flying on). The existing CrashingGroundVehicles, CrashingDimensionGate and CrashingOutOfFuel are flipped from default on to default off. UFO crashes are unchanged. Both new options are listed in the More Options screen.

An earlier variant of this branch kept the fleet scan but gated it behind a "is anything crashed" check. It measured the same on an idle city but fell straight back to the full 25 to 30 ms per update for as long as a single wreck existed in the city, so it was dropped in favour of the crashed vehicle list.

Verification

ctest 15/15 on this branch. tests/test_crashed_vehicle_recovery.cpp forces the rescue option on and zeroes the per wreck delay on its victims so it keeps comparing turbo against normal cadence recovery throughput rather than measuring the delay.

Harness (posted as a comment below, not committed): same populated idle city, 798 vehicles, 28 organisations, 326 idle rescue capable craft, 200 x update(1), ms per update.

build idle, option off idle, option on eligible wreck present wreck inside its delay
master d06cc10 27.1 27.8 27.9 29.8
this branch 1.82 1.87 2.84 2.04

Master has neither the option nor the delay, so its last column is simply another full scan. The gate check on this branch dispatches nothing with the option off, nothing before the wreck's delay elapses and exactly one recover mission after it. The set is checked against every crashed flag write site (crash, deliver, die) in the same harness.

FPS recording in the same comment: a new campaign at Speed 1 after the debug hotkey crashes the whole airborne fleet, master versus this branch.

…mechanics

Organisation::updateMissions ran an orgs x vehicles x rescuers rescue
scan on every GameState::update even when nothing had crashed, costing
tens of milliseconds on an idle city. GameState now keeps a set of
crashed vehicle ids, maintained where the crashed flag changes and
rebuilt from the flags in initState so saves are unaffected. The
dispatch moves out of updateMissions into
Organisation::dispatchRescueCraft, runs only when the set holds a
recoverable non-alien wreck, iterates the set instead of the fleet, and
shares one claimed-victim set across organisations. Stale entries are
dropped on lookup.

The dispatch is gated behind a new OpenApoc.NewFeature.RescueCrashedVehicles
option (default off). A second new option, CrashingDamagedVehicles, gates
whether non-UFO vehicles crash land when damaged below their crash
threshold. CrashingGroundVehicles, CrashingDimensionGate and
CrashingOutOfFuel now default to off as well, matching vanilla
behaviour by default.

Fixes OpenApoc#1694
…ds per wreck

Each vehicle draws a rescue availability tick when it becomes crashed. The
update gate and both dispatch target loops ignore a wreck until that tick, so
emergency services do not launch within seconds of a crash and the dispatch
scan stays idle for the first minutes after one. Player initiated recovery is
unchanged. The tick is not saved, so a wreck loaded from a save is eligible at
once. The recovery test zeroes the tick so it keeps measuring throughput.

Refs OpenApoc#1694
@andremmfaria

Copy link
Copy Markdown
Contributor Author

Temporary harness used for the before/after check (not committed). Built as a standalone target next to test_crashed_vehicle_recovery, same difficulty0 state loading; the timing legs keep the natural city population (fillOrgStartingProperty()), the gate and sync legs strip it the way the committed test does.

// Timing: 200 x update(1) on the populated idle city, four legs.
// on_wreck parks one crashed non-alien vehicle inside a building (stays crashed and alive for the
// whole run, one rescuer claims it, every other rescuer keeps looking), on_wreck_waiting is the
// same wreck still inside its 60..600 s dispatch delay.
struct TimingLeg { UString label; bool rescueOption; bool withWreck; bool waitingWreck; };
std::vector<TimingLeg> timingLegs = {
    {"off", false, false, false}, {"on", true, false, false},
    {"on_wreck", true, true, false}, {"on_wreck_waiting", true, true, true}};
for (auto &leg : timingLegs)
{
	config().set("OpenApoc.NewFeature.RescueCrashedVehicles", leg.rescueOption);
	auto state = loadTimingState(commonPath, gamestatePath); // loadGame x2, startGame, initState,
	                                                         // fillPlayerStartingProperty, fillOrgStartingProperty
	if (leg.withWreck)
	{
		auto wreck = state->current_city->placeVehicle(*state, craftType, org, homeBuilding);
		wreck->setCrashed(*state);
		wreck->rescueAvailableTick =
		    leg.waitingWreck ? state->gameTime.getTicks() + 600 * TICKS_PER_SECOND : 0;
	}
	auto start = std::chrono::steady_clock::now();
	for (int i = 0; i < iterations; i++)
		state->update(1);
	auto end = std::chrono::steady_clock::now();
	std::cout << "rescue_option=" << leg.label << "\n"
	          << "mean_update_ms=" << std::chrono::duration<double, std::milli>(end - start).count() / iterations << "\n"
	          << "vehicle_count=" << state->vehicles.size() << "\n"
	          << "idle_rescue_craft_count=" << countIdleRescueCraft(state) << "\n";
	// wreck legs also print wreck_crashed / wreck_carried / wreck_dead / recover_missions / wreck_eligible_in_s
}

// Gate: one idle rescuer + one crashed victim of a non-player, non-alien org, co-located on an
// open sky tile so dispatch does not depend on flight time. countRecoverVehicleMissions() counts
// RecoverVehicle missions across state->vehicles.
auto off = loadGateState(commonPath, gamestatePath); // loadTimingState + vehicles.clear() + agents.clear()
spawnRescueScenario(off);                            // placeVehicle rescuer, placeVehicle victim, victim->setCrashed
gGateVictim->rescueAvailableTick = 0;                // this leg is about the option, not the delay
config().set("OpenApoc.NewFeature.RescueCrashedVehicles", false);
for (int t = 0; t < 50; t++) off->update(1);
std::cout << "dispatched_off=" << countRecoverVehicleMissions(off) << "\n";

auto on = loadGateState(commonPath, gamestatePath);
spawnRescueScenario(on);
config().set("OpenApoc.NewFeature.RescueCrashedVehicles", true);
int64_t delaySeconds = (gGateVictim->rescueAvailableTick - on->gameTime.getTicks()) / TICKS_PER_SECOND;
std::cout << "delay_s=" << delaySeconds << "\n";                  // FAIL unless 60 <= delay_s <= 600
for (int t = 0; t < 50; t++) on->update(1);
std::cout << "dispatched_before_delay=" << countRecoverVehicleMissions(on) << "\n"; // expect 0
while (on->gameTime.getTicks() < gGateVictim->rescueAvailableTick)
	on->update(TICKS_PER_SECOND);
for (int t = 0; t < 5; t++) on->update(1);
std::cout << "dispatched_after_delay=" << countRecoverVehicleMissions(on) << "\n";  // expect 1

// Sync (this branch only): crashedVehicles must follow every Vehicle::crashed write site.
auto sync = loadGateState(commonPath, gamestatePath);
auto v = sync->current_city->placeVehicle(*sync, craftType, org, spawnPos, 0.0f);
v->homeBuilding = homeBuilding;
v->setCrashed(*sync, true);
std::cout << "sync_after_crash=" << sync->crashedVehicles.size() << "\n";   // expect 1
v->enterBuilding(*sync, homeBuilding);
std::cout << "sync_after_deliver=" << sync->crashedVehicles.size() << "\n"; // expect 0
v->setCrashed(*sync, true);
v->die(*sync, true);
sync->update(1);
std::cout << "sync_after_die=" << sync->crashedVehicles.size() << "\n";     // expect 0

Output with master d06cc102's gamestate.cpp, organisation.cpp and organisation.h in place of this branch's (same harness, sync leg not compiled since crashedVehicles does not exist there):

rescue_option=off
mean_update_ms=27.0911
vehicle_count=798
organisation_count=28
idle_rescue_craft_count=326
rescue_option=on
mean_update_ms=27.7613
vehicle_count=798
organisation_count=28
idle_rescue_craft_count=326
rescue_option=on_wreck
mean_update_ms=27.9167
vehicle_count=799
organisation_count=28
idle_rescue_craft_count=327
wreck_crashed=1
wreck_carried=0
wreck_dead=0
recover_missions=1
wreck_eligible_in_s=-43201
rescue_option=on_wreck_waiting
mean_update_ms=29.8195
vehicle_count=799
organisation_count=28
idle_rescue_craft_count=327
wreck_crashed=1
wreck_carried=0
wreck_dead=0
recover_missions=1
wreck_eligible_in_s=598
dispatched_off=1
delay_s=487
dispatched_before_delay=1
dispatched_after_delay=0
FAIL: expected no dispatch before the delay elapses, got 1
FAIL: expected dispatch with RescueCrashedVehicles=true after the delay elapses, got none
FAIL: expected no dispatch with RescueCrashedVehicles=false, got 1

The pre fix code has no option and no delay, so it dispatches at once in every gate leg (dispatched_after_delay=0 there only because that rescue had already been completed by the time the delay window closed), and every timing leg pays the full scan, wreck or no wreck.

Output on this branch dfa93bea:

rescue_option=off
mean_update_ms=1.82311
vehicle_count=798
organisation_count=28
idle_rescue_craft_count=326
rescue_option=on
mean_update_ms=1.86667
vehicle_count=798
organisation_count=28
idle_rescue_craft_count=326
rescue_option=on_wreck
mean_update_ms=2.83631
vehicle_count=800
organisation_count=28
idle_rescue_craft_count=327
wreck_crashed=1
wreck_carried=0
wreck_dead=0
recover_missions=1
wreck_eligible_in_s=-43201
rescue_option=on_wreck_waiting
mean_update_ms=2.0361
vehicle_count=800
organisation_count=28
idle_rescue_craft_count=327
wreck_crashed=1
wreck_carried=0
wreck_dead=0
recover_missions=0
wreck_eligible_in_s=598
dispatched_off=0
delay_s=277
dispatched_before_delay=0
dispatched_after_delay=1
sync_after_crash=1
sync_after_deliver=0
sync_after_die=0
PASS

4 core box, RelWithDebInfo, -j2 builds; the absolute numbers are noisy by a few tenths of a ms between runs, the ratio is not.

FPS, crashed fleet scenario. New Medium campaign under Xvfb 640x480 with llvmpipe, Speed 3 for 15 s so traffic is airborne, back to Speed 1, then the debug hotkey x crashes every airborne vehicle, 4 s settle, 10 s recorded. All Notifications.City pauses off so the destroyed vehicle events do not stop the clock. Left panel master, right panel this branch's crashed vehicle list (labelled with the pre squash commit 9d275b6, same tree as 99a1763 here), middle panel the dropped gate only variant mentioned in the description, kept because it shows the cliff (it starts fast and sinks as wrecks accumulate). Recorded before the dispatch delay commit, which only changes when dispatch starts, not what it costs.

crashed fleet FPS, master vs gate only vs this branch

End frames, master then this branch:

master end frame
this branch end frame

Each campaign starts at a random base, so the rendered scene and the number of airborne vehicles at the crash differ per run; the harness table is the controlled comparison, the recording is illustration.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

City: rescue dispatch scan in Organisation::updateMissions runs every update for every organisation, ~24 ms per update on an idle city

1 participant