diff --git a/docs/playerbots.md b/docs/playerbots.md index a56943c..b96c1f8 100644 --- a/docs/playerbots.md +++ b/docs/playerbots.md @@ -39,11 +39,17 @@ Focused scenarios often use fixed destinations, controlled monsters, teleports, Navigation is hierarchical: [`PlayerBotTopology`](../server/src/playerbottopology.cpp) indexes the loaded map into connected local regions and transition edges. The [controller](../server/src/playerbotcontroller.cpp) selects a global route, refines its next segment into tile-level actions, and considers eligible NPC travel connections. Whole-map routing describes its scope, not a guarantee that every destination is reachable. +Raid definitions and activation are innate bot knowledge. The [raid overlay](../server/src/playerbotraidhazards.h) adds configured species and spawn footprints to the cached static danger model without tracking hidden live creatures. Footprints describe potential danger, not monster leashes. Warnings survive event completion and raid reload; a future wave cannot be cleared merely because its tiles are currently empty. Script-only raid spawns have no inferred footprint, but visible threats still apply. + +[Travel safety](../server/src/playerbottravelsafety.cpp) uses bounded private sightings and exact visible-tile clearance across activities. Fresh threats override clearance; losing sight does not establish death, and expired clearance restores the warning rather than declaring safety. Memory survives death recovery within the server run, but not server restart. Planning freezes evidence into a private spatial overlay without rebuilding the launch-time heatmap or mixing observations into shared static caches. Innate raid changes start a new planning epoch. Ordinary sightings do not discard whole search frontiers or return coverage: completed and retained executable legs are checked against fresh exposure before admission or reuse, and movement still checks current threats. Paid itineraries replan later legs after arrival; their retained first-leg checks do not establish fresh clearance of every future leg. + +Unsafe observed encounters interrupt hunt, depot, service, trainer, progression, and loot work. The bot cancels conflicting movement/combat, heals normally, and attempts a visible ordinary step toward lower exposure or a reachable adjacent protection zone. Detailed routes exclude excessive transient exposure; the final movement gate also checks reused routes, transitions, and sidesteps. If no safe route exists, travel is deferred, not counted as repeated pathfinding failure. A remote warning permits only a bounded approach over visible zero-exposure ground to gain fresh evidence. Withdrawal is local, not a pursuit simulation: trapped bots or faster pursuers can still kill them. There is no automatic raid-hunting goal, global all-clear signal, or guarantee of survival. + Patrol approach selection and detailed navigation share the peak-danger ceiling. An exact or any-of goal whose usable endpoints all exceed that ceiling reports `risk_rejected` before route expansion; genuine search exhaustion remains unknown. Spawn danger adds direct attacks, but merges modeled damage conditions by the engine’s type/ID/sub-ID key. Built-in XML and Lua delayed damage conditions use their loaded tick rules with conservative sustained uptime; scripted spells and field damage are not inferred. These are forecasts, not measured encounter damage. Hunt selection uses a [resumable route planner](../server/src/playerbothunttravel.cpp). Walking and NPC approach connections retain a coarse itinerary through connected pieces of the map's sectors. Ordinary segments use destination-guided tile search; stairs and teleports remain explicit crossings, checked against the live world. An unsafe or failed itinerary first tries an alternative coarse route, then searches progressively wider corridors of connected map pieces before falling back to the whole live graph. An itinerary whose coarse danger estimate and walked prefix both exceed the risk budget skips those fallbacks and stays unknown. All attempts share the original connection allowance; a restricted failure cannot prove unreachability. When the source and every goal are graph nodes, a graph "no path" is final for the loaded map; off-graph endpoints retain live validation. The graph includes every possible rope and ladder landing for connectivity, but itineraries use only the landing the climb script chooses. It also includes traversable doors, known shovel passages, and tiles blocked only by movable items, but not parcel-only climbs or machete/pick passages. Live tile changes still invalidate affected detailed searches. Scripted or admin map edits that add routes require `/reload scripts` after temporary blockers clear; the rebuild uses the live map. Corridor and whole-map searches use a shortcut-aware lower bound that includes directed teleports and floor redirects. Its relaxed geometry ignores obstacles and collapses floors, so guidance remains optimistic rather than excluding potentially better routes. Live shortcut changes restart affected search priorities; ordinary map changes do not discard the guidance. All valid adjacent NPC approach tiles form one destination set rather than separate searches; teleport tiles are never approach goals. Coarse travel estimates cannot establish an accepted route or reject one for danger: detailed connections supply that evidence. Each bot owns a risk profile (currently the same defaults). Hunt and service route decisions use detailed risk evidence; a required hunt return can use the least-risk executable depot approach when no safe one is available. The selector retains alternative fare and risk labels and uses safe route-cost bounds rather than a fixed shortlist. Paid offers must leave the minimum recovery reserve; free offers remain eligible. -Depot liquidation keeps seller candidates and unfinished routes across turns. Before detailed planning, it rules out candidates with no affordable travel chain or possible profit, using lower bounds on fare and travel time. It checks the remaining candidates in order of potential profit, then revalidates routes before withdrawing stock. A failed source route rules out that depot's other sellers for the scan. A failed seller route tries other conversation tiles before rejecting the seller. Service approaches track a wandering NPC, wait briefly if it leaves speech range, and replan around blocked steps. Withdrawals respect backpack space and carrying capacity; excess stock waits for another trip. Paid travel retains the recovery fare reserve. Newly available travel offers may wait until the next scan. Both legs use the shared sliced route engine, including walking-only seller routes. Walking-only requests exclude NPC offers and active blocked tiles. Interrupted or exhausted legs revalidate through that engine; execution checks live risk, funds, quoted fare and steps. If two paid legs do not fit the budget together, a safe walking source replaces the paid one. Candidate snapshots still run synchronously. +Depot liquidation keeps seller candidates and unfinished routes across turns. Before detailed planning, it rules out candidates with no affordable travel chain or possible profit, using lower bounds on fare and travel time. It checks the remaining candidates in order of potential profit, then revalidates routes before withdrawing stock. A failed source route rules out that depot's other sellers for the scan. A failed seller route tries other conversation tiles before rejecting the seller. Service approaches track a wandering NPC, wait briefly if it leaves speech range, and replan around blocked steps. Temporary threat rejection and active movement exclusions preserve the pending provider rather than blacklist it. Incomplete searches receive bounded retries; exhaustion reports `service_route_planning_incomplete`, not a missing shop offer. Withdrawals respect backpack space and carrying capacity; excess stock waits for another trip. Paid travel retains the recovery fare reserve. Newly available travel offers may wait until the next scan. Both legs use the shared sliced route engine, including walking-only seller routes. Walking-only requests exclude NPC offers and active blocked tiles. Interrupted or exhausted legs revalidate through that engine; execution checks live risk, funds, quoted fare and steps. If two paid legs do not fit the budget together, a safe walking source replaces the paid one. Candidate snapshots still run synchronously. Coarse connectivity, successful ordinary walking segments and combat-profile-specific danger samples use bounded, shared caches. Live movement checks and watched map/line-of-sight dependencies protect reuse across callers; capability, risk-model and topology inputs remain part of cache identity. Unrestricted fallbacks reuse a source search tree across goals within the same actor's planning pass. Retargeting changes frontier priorities while retaining final settled costs. A settled goal supplies a route once the frontier lower bound rules out a cheaper target; otherwise the existing search continues. Unsettled goals remain unknown at the limit, never proof of unreachability or an unreachable-region cooldown. A graph-connected path that exhausts the detailed-search allowance remains unknown. Relevant world and provider changes invalidate dependent work. Harmless corpse decay that changes only movable/pickupable flags preserves route evidence; mobility changes on blockers, height items, and passages still invalidate it. The [shared planning budget](../server/src/playerbotplanningbudget.h) charges actual elapsed work across controllers and queues continuations fairly. It does not create parallel game threads or preempt an expensive operation. Topology primitives and candidate output remain synchronous, so neither the budget nor focused fixtures establish a hard latency bound or measured ten-bot capacity. Movement and NPC execution still replan against the live world. diff --git a/docs/testing.md b/docs/testing.md index 76dcca7..b5c76b8 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -57,6 +57,8 @@ Ranged positioning (#235) has `ranged_position_control` (the Paladin fights like Each changed positioning decision or retreat request emits `ranged_position` with `action` (`hold`, `retreat`, `close`, `fight_in_place`), `reason`, `distance`, `range`, raw speeds, current cardinal step durations (`self_step_ms`, `target_step_ms`), and the requested retreat `tile`. `ranged_position_step` records observed `completed` or `failed` movement; a request alone is not displacement evidence. +Raid travel safety (#267) has `raid_travel` and `raid_recovery`, selected with `-Focused -RaidTravel` or exact `-Scenario` names. Both use real raid XML to spawn one Lua-defined **Raid Safety Wolf** on an isolated runway. The unique species avoids later XML loading overwriting its fixed 40-HP life drain every two seconds and speed 160. Position samples must prove withdrawal, a five-second live-threat guard, and arrival after controlled removal; safety telemetry alone cannot pass. The guard rejects unsafe forward retries, not distance lost to a closing pursuer while the bot waits for an ordinary step. Recovery injects one combat death, then requires normal recovery and protection against the same surviving threat. These controlled scenarios do not establish natural raid survival or sustained progression. Non-Docker checks are `lua server/tests/playerbot_raid_fixture_contracts.lua` and `pwsh -File server/tests/playerbot_raid_fixture_assertions.ps1`. + `-Scenario deep_hunt_return` seeds a level-15 Knight at `(33101,31745,9)` and checks arrival at the planner-selected depot locker approach. It does not prove unattended hunt progression. `-SkipBuild` requires a known-current `angelion-server:latest` image and does not prove it matches the worktree. `-KeepStack` preserves the final stack for debugging. `-TimeoutSeconds` accepts 30–3600 seconds. Most focused scenarios use controlled state or destinations; map-derived planning modes improve integration evidence but still do not prove long-running progression. @@ -110,6 +112,12 @@ sh server/tests/playerbotroutecorridor_contracts.sh sh server/tests/playerbotguidedtree_contracts.sh sh server/tests/playerbotroutecache_contracts.sh sh server/tests/playerbotdangersamplekey_contracts.sh +sh server/tests/playerbotraidhazards_contracts.sh +sh server/tests/playerbottravelsafety_contracts.sh +sh server/tests/playerbottravelchurn_contracts.sh +sh server/tests/playerbotservicerouteretry_contracts.sh +# Uses server development headers; set CPPFLAGS as described below. +sh server/tests/playerbottransientnavigation_contracts.sh sh server/tests/playerbotplanningbudget_contracts.sh sh server/tests/playerbotdepottelemetry_contracts.sh sh server/tests/playerbot_loot_contracts.sh diff --git a/scripts/playerbot-gameplay/assertions-raid.ps1 b/scripts/playerbot-gameplay/assertions-raid.ps1 new file mode 100644 index 0000000..ab3098a --- /dev/null +++ b/scripts/playerbot-gameplay/assertions-raid.ps1 @@ -0,0 +1,92 @@ +function Assert-RaidTravelEvents { + param([string]$Logs, [ValidateSet('raid_travel', 'raid_recovery')][string]$Mode) + + if ($Logs -match 'RAID_FIXTURE_FAILED' -or + $Logs -notmatch ('PLAYERBOT_GAMEPLAY_TEST ' + $Mode.ToUpperInvariant() + '_PASS')) { + throw 'Raid travel lacks a successful independent position verifier.' + } + $events = @(ConvertFrom-PlayerbotLogs -Logs $Logs) + $phases = @{} + $phaseOrder = @('start', 'activated', 'spawned', 'withdrew') + if ($Mode -eq 'raid_recovery') { $phaseOrder += @('kill_injected', 'death', 'recovered') } + $phaseOrder += @('guarded', 'removed', 'arrived') + $nextPhase = 0 + $withdrawal = $false + $recoverySafety = $false + $resume = $false + $dead = 0 + $recovered = $false + for ($i = 0; $i -lt $events.Count; ++$i) { + $e = $events[$i] + if ($e.bot -and $e.bot -ne 'Bot One') { throw 'Raid fixture must seed only Bot One.' } + if ($e.event -eq 'raid_fixture') { + if ($e.scenario -ne $Mode -or $e.source -ne 'lua_position_verifier' -or + $nextPhase -ge $phaseOrder.Count -or $e.phase -ne $phaseOrder[$nextPhase]) { + throw 'Raid position evidence is missing, duplicated, or out of order.' + } + if ($e.z -ne 7 -or $e.x -lt 60128 -or $e.x -gt 60216 -or $e.y -ne 60200 -or + ($e.phase -ne 'death' -and $e.health -le 0)) { + throw 'Raid position evidence left the live isolated runway.' + } + $phases[$e.phase] = $e + ++$nextPhase + } + if ($e.event -eq 'lifecycle' -and $e.status -eq 'dead') { + if ($Mode -ne 'raid_recovery' -or -not $phases.ContainsKey('kill_injected') -or + $phases.ContainsKey('recovered') -or ++$dead -ne 1) { + throw 'Raid caused an unplanned production death.' + } + } + if ($e.event -eq 'terminal' -and -not ($Mode -eq 'raid_recovery' -and + $e.reason -eq 'controlled_player_dead' -and $phases.ContainsKey('kill_injected') -and + -not $phases.ContainsKey('recovered'))) { + throw "Raid controller stopped unexpectedly: $($e.reason)" + } + if ($e.event -eq 'lifecycle' -and $e.status -eq 'online' -and $e.recovered -eq $true) { + if ($Mode -ne 'raid_recovery' -or -not $phases.ContainsKey('recovered') -or + $phases.ContainsKey('removed') -or $recovered) { + throw 'Raid recovery was missing, repeated, or too late.' + } + $recovered = $true + } + if ($e.event -eq 'travel_safety') { + if ($e.action -notin @('withdraw', 'defer', 'resume', 'step_rejected') -or + -not $e.reason -or $null -eq $e.danger -or [double]$e.danger -lt 0) { + throw 'Raid safety telemetry lacks its action/danger/reason contract.' + } + if ($phases.ContainsKey('activated') -and -not $phases.ContainsKey('removed')) { + if ($e.action -eq 'withdraw' -and [double]$e.danger -gt 0.08) { $withdrawal = $true } + if ($recovered -and $e.action -in @('withdraw', 'defer', 'step_rejected')) { $recoverySafety = $true } + } + if ($phases.ContainsKey('removed') -and -not $phases.ContainsKey('arrived') -and + $e.action -eq 'resume') { $resume = $true } + } + } + if ($nextPhase -ne $phaseOrder.Count -or -not $withdrawal -or -not $resume) { + throw 'Raid travel needs a real spawn, observed withdrawal, guarded clearance, resume, and arrival.' + } + if ($phases.start.x -ne 60160 -or $phases.start.y -ne 60200 -or $phases.start.health_max -ne 185 -or + $phases.start.goal_x -ne 60208 -or $phases.start.goal_y -ne 60200 -or $phases.start.goal_z -ne 7 -or + $phases.activated.x -lt $phases.start.x + 8 -or $phases.activated.spawn_x -ne 60169 -or + $phases.activated.spawn_y -ne 60200 -or $phases.activated.spawn_z -ne 7 -or + $phases.spawned.count -ne 1 -or $phases.spawned.target_id -le 0 -or + $phases.withdrew.from_x -lt $phases.activated.x -or + $phases.withdrew.from_x - $phases.withdrew.x -lt 2 -or $phases.guarded.guard_ms -lt 5000 -or + $phases.arrived.x -ne $phases.start.goal_x -or $phases.arrived.y -ne $phases.start.goal_y -or + $phases.arrived.elapsed_ms -le $phases.removed.elapsed_ms) { + throw 'Raid fixture did not prove ordinary outbound, retreat, live-threat guard, and post-removal displacement.' + } + foreach ($phase in @('withdrew', 'guarded', 'removed')) { + if ($phases[$phase].target_id -ne $phases.spawned.target_id) { throw 'Raid fixture changed its threat identity.' } + } + if ($Mode -eq 'raid_recovery') { + if ($dead -ne 1 -or -not $recovered -or -not $recoverySafety -or + $phases.death.intentional -ne $true -or $phases.recovered.target_id -ne $phases.spawned.target_id -or + $phases.recovered.x -ne $phases.start.x -or $phases.recovered.y -ne $phases.start.y -or + $phases.arrived.deaths -ne 1) { + throw 'Raid recovery needs one intentional normal death and renewed safety against the same live threat.' + } + } elseif ($dead -ne 0 -or $phases.arrived.deaths -ne 0) { + throw 'Raid travel did not remain death-free.' + } +} diff --git a/scripts/playerbot-gameplay/scenarios-raid.ps1 b/scripts/playerbot-gameplay/scenarios-raid.ps1 new file mode 100644 index 0000000..4880b17 --- /dev/null +++ b/scripts/playerbot-gameplay/scenarios-raid.ps1 @@ -0,0 +1,25 @@ +if ($RaidTravel) { + foreach ($raidMode in @('raid_travel', 'raid_recovery')) { + Invoke-Scenario -Name $raidMode -DefaultTimeoutSeconds 180 -Body { + $previousRaidCatalog = $env:PLAYERBOT_RAID_CATALOG + $previousSpeedBonus = $env:PLAYERBOT_SPEED_BONUS + try { + Invoke-Compose down --volumes --remove-orphans + $env:PLAYERBOT_RAID_CATALOG = './tests/playerbot-gameplay/raids/raids.xml' + $env:PLAYERBOT_SPEED_BONUS = '0' + $env:PLAYERBOT_GAMEPLAY_MODE = $raidMode + $env:PLAYERBOT_HUNT_DURATION_SECONDS = '900' + $env:PLAYERBOT_RELOG_DELAY_SECONDS = '1' + $env:PLAYERBOT_MAX_CONSECUTIVE_DEATHS = '2' + Invoke-Compose up --detach + $logs = Wait-ForLog -Pattern ('PLAYERBOT_GAMEPLAY_TEST ' + $raidMode.ToUpperInvariant() + + '_PASS|PLAYERBOT_GAMEPLAY_TEST RAID_FIXTURE_FAILED|"event":"terminal"(?![^\r\n]*"reason":"controlled_player_dead")') + Assert-RaidTravelEvents -Logs $logs -Mode $raidMode + } + finally { + $env:PLAYERBOT_RAID_CATALOG = $previousRaidCatalog + $env:PLAYERBOT_SPEED_BONUS = $previousSpeedBonus + } + } + } +} diff --git a/scripts/test-playerbot-gameplay.ps1 b/scripts/test-playerbot-gameplay.ps1 index b017e51..394fdd8 100644 --- a/scripts/test-playerbot-gameplay.ps1 +++ b/scripts/test-playerbot-gameplay.ps1 @@ -32,6 +32,7 @@ param( [switch]$FullNavigation, [switch]$TargetApproach, [switch]$RangedPosition, + [switch]$RaidTravel, [switch]$CorpseLoot, [switch]$DeathTelemetry, [switch]$Healing, @@ -90,6 +91,7 @@ $scenarioCatalog = @( "equipment_purchase_rejected", "equipment_purchase_spear", "equipment_tool_replenishment", "equipment_tool_nested_inventory", "adaptive_challenge", "mainland_equipment_reward", "oracle_departure", "oracle_level_eight_interrupt", "oracle_level_eight_recovery", "navigation", "navigation_recovery", "svargrond_local_route_recovery", "navigation_fare_rejection", "navigation_risk_rejection", "door_passages", "carlin_service_route", "mutable_portal_route", "mutable_portal_open_no_shovel", "patrol_recovery", "target_approach", "target_approach_unreachable", "target_attacker_priority", "ranged_position_control", "ranged_position", "ranged_position_corner", + "raid_travel", "raid_recovery", "paladin_spell_training", "paladin_healing_economy", "paladin_mana_healing", "paladin_healing_fallback", "paladin_supply_restock", "spell_training", "spell_training_shortlist", "spell_training_low_supplies", "spell_training_low_supplies_unaffordable", "spell_use", "spell_calibration", "magic_training_haste", "magic_training_great_light", "magic_training_light", "magic_training_refresh", "magic_training_reserve", "magic_training_exact_full", @@ -105,8 +107,8 @@ foreach ($scenarioName in $scenarioCatalog) { throw "Duplicate gameplay scenario name: $scenarioName" } } -if ($scenarioCatalog.Count -ne 122) { - throw "The gameplay scenario catalog must contain 122 scenarios; found $($scenarioCatalog.Count)." +if ($scenarioCatalog.Count -ne 124) { + throw "The gameplay scenario catalog must contain 124 scenarios; found $($scenarioCatalog.Count)." } $requestedScenarioNames = @($Scenario | ForEach-Object { $_ -split ',' } | Where-Object { $_ }) $exactScenarioSelection = $requestedScenarioNames.Count -gt 0 @@ -161,6 +163,7 @@ $minimumServerOnlineEvents = 0 . $PSScriptRoot/playerbot-gameplay/assertions-readiness.ps1 . $PSScriptRoot/playerbot-gameplay/assertions-recovery.ps1 . $PSScriptRoot/playerbot-gameplay/assertions-navigation.ps1 +. $PSScriptRoot/playerbot-gameplay/assertions-raid.ps1 . $PSScriptRoot/playerbot-gameplay/assertions-spells.ps1 . $PSScriptRoot/playerbot-gameplay/assertions-magic-training.ps1 @@ -168,7 +171,7 @@ if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { throw "Docker is required to run the playerbot gameplay suite." } -$focusedScenarioRequested = $FullNavigation -or $TargetApproach -or $RangedPosition -or $CorpseLoot -or $DeathTelemetry -or $Healing -or $ValueLoot -or +$focusedScenarioRequested = $FullNavigation -or $TargetApproach -or $RangedPosition -or $RaidTravel -or $CorpseLoot -or $DeathTelemetry -or $Healing -or $ValueLoot -or $PickupProgression -or $GoalArbitration -or $OracleDeparture -or $StaminaProjection -or $HuntRegionPlanning -or $AdaptiveChallenge -or $CombatReadiness -or $EquipmentOffers -or $EquipmentPurchases -or $MainlandRewards -or $Depot -or $SlottedLoot -or $SellLoot -or $MainlandLoop -or $SpellTraining -or $SpellUse -or $SpellCalibration -or $MagicTraining -or $MagicTrainingCase @@ -176,7 +179,7 @@ if ($Focused -and -not $focusedScenarioRequested) { throw "-Focused requires at least one focused scenario switch." } if (-not $Focused) { - $FullNavigation = $TargetApproach = $RangedPosition = $CorpseLoot = $DeathTelemetry = $Healing = $ValueLoot = $true + $FullNavigation = $TargetApproach = $RangedPosition = $RaidTravel = $CorpseLoot = $DeathTelemetry = $Healing = $ValueLoot = $true $PickupProgression = $GoalArbitration = $OracleDeparture = $StaminaProjection = $HuntRegionPlanning = $true $AdaptiveChallenge = $CombatReadiness = $EquipmentOffers = $EquipmentPurchases = $MainlandRewards = $true $Depot = $SlottedLoot = $SellLoot = $MainlandLoop = $SpellTraining = $SpellUse = $SpellCalibration = $MagicTraining = $true @@ -223,6 +226,8 @@ try { . $PSScriptRoot/playerbot-gameplay/scenarios-spells.ps1 . $PSScriptRoot/playerbot-gameplay/scenarios-combat-loot.ps1 + + . $PSScriptRoot/playerbot-gameplay/scenarios-raid.ps1 if (-not $Focused) { $resultNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($result in $scenarioResults) { diff --git a/server/compose.playerbot-gameplay.yaml b/server/compose.playerbot-gameplay.yaml index 1f758eb..f2fc179 100644 --- a/server/compose.playerbot-gameplay.yaml +++ b/server/compose.playerbot-gameplay.yaml @@ -18,3 +18,7 @@ services: volumes: - ./tests/playerbot-gameplay:/app/data/scripts/playerbot-gameplay:ro - ./tests/playerbot-monsters/corpse.lua:/app/data/monster/lua/playerbot_corpse_monsters.lua:ro + - ./tests/playerbot-gameplay/raids:/app/data/raids/playerbot-fixture:ro + # Ordinary scenarios retain the normal catalog. Only raid scenarios select + # the isolated catalog; no host config or production raid file is rewritten. + - ${PLAYERBOT_RAID_CATALOG:-./data/raids/raids.xml}:/app/data/raids/raids.xml:ro diff --git a/server/src/CMakeLists.txt b/server/src/CMakeLists.txt index f5041d6..17027e4 100644 --- a/server/src/CMakeLists.txt +++ b/server/src/CMakeLists.txt @@ -48,6 +48,7 @@ set(tfs_SRC ${CMAKE_CURRENT_LIST_DIR}/playerbotcombat.cpp ${CMAKE_CURRENT_LIST_DIR}/playerbotcombatruntime.cpp ${CMAKE_CURRENT_LIST_DIR}/playerbotcontroller.cpp + ${CMAKE_CURRENT_LIST_DIR}/playerbottravelsafety.cpp ${CMAKE_CURRENT_LIST_DIR}/playerbotdepotworkflow.cpp ${CMAKE_CURRENT_LIST_DIR}/playerbotfixturedriver.cpp ${CMAKE_CURRENT_LIST_DIR}/playerbotdepotsession.cpp diff --git a/server/src/playerbot.cpp b/server/src/playerbot.cpp index 59c314c..b8f4412 100644 --- a/server/src/playerbot.cpp +++ b/server/src/playerbot.cpp @@ -265,7 +265,7 @@ bool PlayerBotManager::load(const RecordPtr& record, bool recovered) if (speedBonus) g_game.changeSpeed(player, speedBonus); if (!record->state.managed() || record->lifecycleGeneration != placementGeneration || player->isRemoved()) return false; record->activeControllerId = std::to_string(record->guid) + "-" + std::to_string(++nextControllerGeneration); - record->controller = std::make_shared(*player, record->activeControllerId, record->huntRegionCooldowns); + record->controller = std::make_shared(*player, record->activeControllerId, record->huntRegionCooldowns, record->travelSafety); record->lastSpawnedAt = std::chrono::steady_clock::now(); const uint64_t generation = record->lifecycleGeneration; const auto startingController = record->controller; diff --git a/server/src/playerbot.h b/server/src/playerbot.h index 97fb711..377f996 100644 --- a/server/src/playerbot.h +++ b/server/src/playerbot.h @@ -13,6 +13,7 @@ #include "position.h" #include "playerbotlifecycle.h" +#include "playerbottravelsafety.h" #include #include @@ -91,6 +92,7 @@ class PlayerBotManager bool awaitingDeathRemoval = false; bool removingForRecovery = false; std::map huntRegionCooldowns; + PlayerBotTravelSafetyMemory travelSafety; std::chrono::steady_clock::time_point lastSpawnedAt; }; using RecordPtr = std::shared_ptr; diff --git a/server/src/playerbotcombat.cpp b/server/src/playerbotcombat.cpp index fedfdd9..664d5dc 100644 --- a/server/src/playerbotcombat.cpp +++ b/server/src/playerbotcombat.cpp @@ -2554,8 +2554,7 @@ const char* PlayerBotController::continueHuntPatrolTrip(Player& player, const Po << ",\"blocked_positions\":" << positionListJson(blockedPositions) << ",\"continuations\":" << trip.continuations; emit("navigation_progress", currentPosition, fields.str()); - navigationRuntime.observePlan({PlayerBotNavigationGoal::exact(destination), std::move(leg), player.canDoAction(), - startsNavigation, std::chrono::steady_clock::now()}); + observeNavigationPlan(player, PlayerBotNavigationGoal::exact(destination), std::move(leg), startsNavigation); huntPatrolValidatedDestination = destination; return nullptr; } @@ -3144,6 +3143,7 @@ void PlayerBotController::processTraversal(Player* player, const Position& curre const uint64_t coverageRevision = PlayerBotHuntRegionPlanner::getCacheRevision(); bool destinationCovered = false; bool extendsCoverage = false; + std::shared_ptr extendedExposure; if (!validateReturn) { destinationCovered = huntReturnCoverage.covers( huntReturnCoverageVariantId, coverageRevision, @@ -3152,6 +3152,21 @@ void PlayerBotController::processTraversal(Player* player, const Position& curre huntReturnCoverageVariantId, coverageRevision, huntReturnCoverageContext(*player, currentPosition)) && playerBotNavigationIsReversibleLocalWalk(currentPosition, patrol.destination, preflight); + if (extendsCoverage && !destinationCovered) { + const auto inherited = huntReturnCoverage.travelExposure(); + if (inherited && inherited->samples.size() + preflight.steps.size() <= playerBotNavigationMaximumExpandedNodes) { + PlayerBotNavigationRoutePlan reverse; + reverse.metrics.result = PlayerBotNavigationResult::Reached; + reverse.steps = playerBotTravelReverseWalk(currentPosition, preflight.steps); + const auto policy = navigationCostPolicy(*player); + extendedExposure = std::make_shared(*inherited); + extendedExposure->prepend(travelRouteExposure(*player, patrol.destination, reverse, policy)); + const auto context = huntReturnCoverageContext(*player, currentPosition); + const auto combined = extendedExposure->refreshed(*context.travelField); + extendsCoverage = playerBotNavigationRiskAccepts(extendedExposure->risk, + combined.dangerCost, combined.maximumHealthLossPerSecond); + } else extendsCoverage = false; // Missing/overlong concrete proof needs a fresh return route. + } if (!destinationCovered && !extendsCoverage) { huntPatrolValidationDestination = patrol.destination; huntPatrolValidationOrigin = currentPosition; @@ -3197,8 +3212,9 @@ void PlayerBotController::processTraversal(Player* player, const Position& curre huntReturnCoverage.validate(huntReturnCoverageVariantId, coverageRevision, huntReturnCoverageContext(*player, patrol.destination)); } else if (extendsCoverage && !destinationCovered) { - huntReturnCoverage.validate(huntReturnCoverageVariantId, coverageRevision, - huntReturnCoverageContext(*player, patrol.destination)); + auto context = huntReturnCoverageContext(*player, patrol.destination); + context.travelExposure = std::move(extendedExposure); + huntReturnCoverage.validate(huntReturnCoverageVariantId, coverageRevision, context); } if (recoveryEquipment && recoveryEquipment->departurePending && validateReturn) { // The ordinary route ceiling alone is not the recovery health floor. @@ -3256,7 +3272,7 @@ void PlayerBotController::processTraversal(Player* player, const Position& curre resetNavigation(); huntPatrolTrip = HuntPatrolTrip{patrol.destination, huntReturnDestination, huntReturnCoverageVariantId, outboundPlan.metrics.fare, std::move(search.outboundOffers)}; - observeNavigationPlan(patrol.destination, std::move(outboundPlan.steps)); + observeNavigationPlan(*player, PlayerBotNavigationGoal::exact(patrol.destination), std::move(outboundPlan)); huntPatrolValidatedDestination = patrol.destination; schedule(SCHEDULER_MINTICKS); return; diff --git a/server/src/playerbotcontroller.cpp b/server/src/playerbotcontroller.cpp index abbdde9..09bc014 100644 --- a/server/src/playerbotcontroller.cpp +++ b/server/src/playerbotcontroller.cpp @@ -20,6 +20,7 @@ #include "playerbotroutecache.h" #include "playerbottransportsearch.h" +#include #include // Playerbot lifecycle, scheduling, navigation execution, and telemetry. @@ -95,6 +96,14 @@ PlayerBotNavigationCostPolicy PlayerBotController::navigationCostPolicy(const Pl local->samples.emplace(position, LocalSample{danger, std::move(watch)}); return danger; }; + // Private, immutable dynamic evidence never enters the shared static-sample LRU. + const auto dynamic = travelDangerField(combat); + policy.transientHealthLossPerSecond = [dynamic](const Position& position) { return dynamic->dangerAt(position); }; + const auto baseline = policy.expectedHealthLossPerSecond; + policy.staticHealthLossPerSecond = baseline; + policy.expectedHealthLossPerSecond = [baseline, dynamic](const Position& position) { + return std::max(baseline(position), dynamic->dangerAt(position)); + }; return policy; } @@ -184,8 +193,9 @@ bool PlayerBotController::processNpcApproach(Player* player, const Position& cur } PlayerBotController::PlayerBotController(const Player& player, std::string controllerId, - std::map& sharedHuntRegionCooldowns) : - playerId(player.getID()), playerGuid(player.getGUID()), playerName(player.getName()), fixtureDriver(playerBotTestPolicyFromEnvironment()), + std::map& sharedHuntRegionCooldowns, + PlayerBotTravelSafetyMemory& safetyMemory) : + playerId(player.getID()), playerGuid(player.getGUID()), playerName(player.getName()), travelSafety(safetyMemory), fixtureDriver(playerBotTestPolicyFromEnvironment()), telemetry(player.getName(), player.getGUID(), std::move(controllerId)), inventoryPolicy(economyCatalog.sellValues(), [this](const Player& candidatePlayer, const Item& item) { return recoveryEquipmentProtected(item.getID()) || equipmentPolicy.evaluateUpgrade(PlayerBotEquipmentAdapter::player(candidatePlayer), @@ -432,6 +442,8 @@ playerbot::PlayerBotTelemetrySummary PlayerBotController::telemetrySummary() con if (summary.planning.empty() && (turnRouter.cyclePhase() == CyclePhase::ReturnToDepot || turnRouter.cyclePhase() == CyclePhase::DepositLoot) && depotWorkflow.snapshot().stage == PlayerBotDepotStage::Discover) summary.planning = "depot"; + if (travelWithdrawing) summary.recovery = "travel_withdrawal"; + else if (travelDeferred) summary.waitingReason = "travel_threat"; if (supplyRecovery.active()) summary.recovery = "supply"; else if (huntCoordinator.retreatingFromDanger()) summary.recovery = "danger_retreat"; return summary; @@ -520,6 +532,7 @@ bool PlayerBotController::findPath(Player* player, const Position& target, std:: void PlayerBotController::resetNavigation() { navigationRuntime.reset(); + travelNavigationExposure.reset(); navigationPlanningRisk.reset(); rangedMovement.clear(); rangedMovement.clearBlockedPositions(); @@ -530,13 +543,40 @@ void PlayerBotController::resetNavigation() huntPatrolValidatedDestination.reset(); } -void PlayerBotController::observeNavigationPlan(const Position& destination, std::deque steps) +PlayerBotNavigationRuntimeOutcome PlayerBotController::observeNavigationPlan(Player& player, + const PlayerBotNavigationGoal& goal, PlayerBotNavigationRoutePlan plan, bool startsNavigation, + const PlayerBotNavigationCostPolicy* originalPolicy) { - PlayerBotNavigationRoutePlan plan; - plan.metrics.result = PlayerBotNavigationResult::Reached; - plan.metrics.steps = steps.size(); - plan.steps = std::move(steps); - navigationRuntime.observePlan({PlayerBotNavigationGoal::exact(destination), std::move(plan), true, true, std::chrono::steady_clock::now()}); + auto livePolicy = navigationCostPolicy(player); + if (originalPolicy) livePolicy.risk = originalPolicy->risk; + const auto& policy = originalPolicy ? *originalPolicy : livePolicy; + auto exposure = travelRouteExposure(player, player.getPosition(), plan, policy); + const auto combat = equipmentPolicy.combatProfile(PlayerBotEquipmentAdapter::player(player), PlayerBotEquipmentAdapter::loadout(player)); + const auto refreshed = exposure.refreshed(*travelDangerField(combat)); + const auto rejected = playerBotNavigationTransientRejection(plan, livePolicy, + [&player](const PlayerBotNavigationStep& step) { return step.action == PlayerBotNavigationAction::Move ? + player.getStepDuration(step.direction) : 1000u; }); + // Replacement is one dispatcher operation: never leave a prior route's + // exposure or observation epoch attached to newly installed steps. + travelNavigationExposure.reset(); + travelNavigationIdentity = travelSafetyIdentity(); + travelNavigationEpoch = travelPlanningIdentity(); + travelNavigationExposurePosition = player.getPosition(); + navigationPlanningRisk = policy.risk; + if (rejected || refreshed.transientRiskRejected) { + navigationRuntime.deferPlan(); + deferUnsafeTravel(player.getPosition(), rejected ? *rejected : refreshed.maximumHealthLossPerSecond, "installed_route_threat"); + PlayerBotNavigationRuntimeOutcome outcome; + outcome.routeUnsafe = true; + return outcome; + } + if (plan.metrics.result == PlayerBotNavigationResult::Reached) { + plan.metrics.dangerCost = std::max(plan.metrics.dangerCost, refreshed.dangerCost); + plan.metrics.maximumHealthLossPerSecond = std::max(plan.metrics.maximumHealthLossPerSecond, refreshed.maximumHealthLossPerSecond); + travelNavigationExposure = std::move(exposure); + } + return navigationRuntime.observePlan({goal, std::move(plan), player.canDoAction(), startsNavigation, + std::chrono::steady_clock::now()}); } void PlayerBotController::onDeath(const Player& player, const Creature* killer, const Creature* mostDamageKiller) @@ -624,6 +664,10 @@ std::optional PlayerBotController::resolveTopologyPorta bool PlayerBotController::executeNavigationStep(Player* player, const PlayerBotNavigationStep& step) { + if (!travelEscapeDispatch && !travelStepSafe(*player, step)) { + deferUnsafeTravel(player->getPosition(), navigationCostPolicy(*player).dangerAt(step.expectedPosition), "step_threat"); + return false; + } telemetry.recordActionAttempt(); if (step.action == PlayerBotNavigationAction::Move) { if (!fixtureDriver.navigationStepCommand().dispatch) { @@ -804,8 +848,11 @@ PlayerBotNavigationRoutePlan PlayerBotController::planCompleteNavigationRoute( maximumExpandedNodes > plan.metrics.expandedNodes ? maximumExpandedNodes - plan.metrics.expandedNodes : 0, &plan.metrics.closestPosition, &costPolicy, &segmentCost); plan.metrics.expandedNodes += segmentNodes; + plan.metrics.transientRiskRejected = segmentCost.transientRiskRejected; if (result != PlayerBotNavigationResult::Reached) { plan.metrics.result = result; + plan.metrics.maximumHealthLossPerSecond = std::max( + plan.metrics.maximumHealthLossPerSecond, segmentCost.maximumHealthLossPerSecond); break; } plan.metrics.movementCost = static_cast(std::min( @@ -885,6 +932,7 @@ PlayerBotNavigationRoutePlan PlayerBotController::planNavigationRoute(Player& pl &routePlan.metrics.closestPosition, &costPolicy, &costSummary, sameFloorOnly); routePlan.metrics.movementCost = costSummary.movementCost; + routePlan.metrics.transientRiskRejected = costSummary.transientRiskRejected; routePlan.metrics.dangerEvidence = PlayerBotNavigationDangerEvidence::Detailed; routePlan.metrics.localDangerCost = costSummary.dangerCost; routePlan.metrics.localMaximumHealthLossPerSecond = costSummary.maximumHealthLossPerSecond; @@ -933,6 +981,7 @@ PlayerBotNavigationRoutePlan PlayerBotController::planNavigationRoute(Player& pl routePlan.metrics.expandedNodes, maximumExpandedNodes, &routePlan.metrics.closestPosition, &costPolicy, &costSummary); routePlan.metrics.movementCost = costSummary.movementCost; + routePlan.metrics.transientRiskRejected = costSummary.transientRiskRejected; routePlan.metrics.localDangerCost = costSummary.dangerCost; routePlan.metrics.localMaximumHealthLossPerSecond = costSummary.maximumHealthLossPerSecond; // The local search itself is complete when the topology waypoint is the @@ -1404,10 +1453,19 @@ bool PlayerBotController::huntTravelFareAffordable( PlayerBotHuntReturnCoverageContext PlayerBotController::huntReturnCoverageContext( Player& player, const Position& coveredPosition) const { - return {PlayerBotTopology::instance().generation(), g_game.getNpcGeneration(), player.getLevel(), + PlayerBotHuntReturnCoverageContext context{PlayerBotTopology::instance().generation(), g_game.getNpcGeneration(), player.getLevel(), coveredPosition, huntReturnDestination, huntExitFareReserve, g_game.findItemOfType(&player, playerbot::ropeItemId, true) != nullptr, - g_game.findItemOfType(&player, shovelToolItemId, true) != nullptr, player.isPremium()}; + g_game.findItemOfType(&player, shovelToolItemId, true) != nullptr, player.isPremium(), travelPlanningIdentity()}; + const auto proof = selectedHuntRoute && selectedHuntRoute->evidence.depot ? + selectedHuntRoute->evidence.depot : lastCompletedTravelEvidence; + if (proof && proof->source == coveredPosition && proof->destination == huntReturnDestination) + context.travelExposure = proof->travelExposure; + else context.travelExposure = huntReturnCoverage.travelExposureAt(coveredPosition); + const auto combat = equipmentPolicy.combatProfile(PlayerBotEquipmentAdapter::player(player), PlayerBotEquipmentAdapter::loadout(player)); + context.travelField = travelDangerField(combat); + context.observationIdentity = travelSafetyIdentity(); + return context; } PlayerBotNavigationRoutePlan PlayerBotController::planHuntTravelRoute( @@ -1605,6 +1663,26 @@ bool PlayerBotController::processNavigation(Player* player, const Position& curr { const Position destination = goal.representative(); const auto now = std::chrono::steady_clock::now(); + const std::string safetyIdentity = travelSafetyIdentity(); + const std::string safetyEpoch = travelPlanningIdentity(); + if (travelNavigationExposure && travelNavigationExposurePosition != currentPosition) { + travelNavigationExposure->passed(currentPosition); + travelNavigationExposurePosition = currentPosition; + } + if (travelNavigationEpoch != safetyEpoch || travelNavigationIdentity != safetyIdentity) { + const auto combat = equipmentPolicy.combatProfile(PlayerBotEquipmentAdapter::player(*player), PlayerBotEquipmentAdapter::loadout(*player)); + if (travelNavigationEpoch != safetyEpoch || (travelNavigationExposure && + travelNavigationExposure->refreshed(*travelDangerField(combat)).transientRiskRejected)) { + navigationRuntime.deferPlan(); + travelNavigationExposure.reset(); + } + travelNavigationIdentity = safetyIdentity; + travelNavigationEpoch = safetyEpoch; + } + if (travelDeferred && now < travelRetryAt) { + schedule(blockedRouteRetryInterval); + return false; + } if (allowRoutePlanning) { const auto& requestedRisk = risk ? *risk : riskProfile; if ((navigationPlanningRisk && *navigationPlanningRisk != requestedRisk) || @@ -1647,11 +1725,24 @@ bool PlayerBotController::processNavigation(Player* player, const Position& curr routePlan = planNavigationRoute(*player, outcome.routeRequest->goal, blockedPositions, std::min(fixturePlan.maximumExpandedNodes, routeNodeBudget), sameFloorOnly, risk); } + PlayerBotNavigationCostPolicy transientPolicy = navigationCostPolicy(*player); + if (risk) transientPolicy.risk = *risk; + const auto transientRejection = playerBotNavigationTransientRejection(routePlan, transientPolicy, + [player](const PlayerBotNavigationStep& step) -> uint32_t { + return step.action == PlayerBotNavigationAction::Move ? player->getStepDuration(step.direction) : 1000; + }); + if (transientRejection) { + deferUnsafeTravel(currentPosition, *transientRejection, "route_threat"); + navigationRuntime.deferPlan(); + approachTravelFrontier(*player, goal); + schedule(blockedRouteRetryInterval); + return false; + } const PlayerBotPendingMovementResult movementResult = outcome.movementResult; const std::optional failedMovementTarget = outcome.failedMovementTarget; std::optional failedMovementStep = std::move(outcome.failedMovementStep); const bool positionalProgress = outcome.positionalProgress; - outcome = navigationRuntime.observePlan({goal, std::move(routePlan), player->canDoAction(), false, now}); + outcome = observeNavigationPlan(*player, goal, std::move(routePlan), false, &transientPolicy); outcome.movementResult = movementResult; outcome.failedMovementTarget = failedMovementTarget; outcome.failedMovementStep = std::move(failedMovementStep); @@ -1681,6 +1772,7 @@ bool PlayerBotController::processNavigation(Player* player, const Position& curr schedule(blockedRouteRetryInterval); return false; } + if (outcome.plan.attempted && !outcome.routeUnavailable) resumeSafeTravel(currentPosition); if (risk && outcome.plan.attempted && !outcome.routeUnavailable) { if (playerBotNavigationRiskVerdict(*risk, outcome.plan) == PlayerBotNavigationRiskVerdict::Rejected || (outcome.plan.dangerEvidence == PlayerBotNavigationDangerEvidence::Coarse && @@ -1865,11 +1957,20 @@ bool PlayerBotController::processNavigation(Player* player, const Position& curr if (step.topologyPortal || step.action == PlayerBotNavigationAction::UseShovel) { resolvedStep = resolveTopologyPortal(*player, step, navigationRuntime.activeBlockedPositions(now)); } - if (risk && resolvedStep) { - const PlayerBotNavigationCostPolicy costPolicy = navigationCostPolicy(*player); + if (resolvedStep) { + PlayerBotNavigationCostPolicy costPolicy = navigationCostPolicy(*player); + if (risk) costPolicy.risk = *risk; const uint32_t exposureMs = resolvedStep->action == PlayerBotNavigationAction::Move ? player->getStepDuration(resolvedStep->direction) : 1000; - if (playerBotNavigationRiskVerdict(*risk, PlayerBotNavigationDangerEvidence::Detailed, + const auto transient = playerBotNavigationTransientRouteRisk(costPolicy, std::array{*resolvedStep}, + [exposureMs](const PlayerBotNavigationStep&) { return exposureMs; }); + if (transient.transientRiskRejected) { + deferUnsafeTravel(currentPosition, transient.maximumHealthLossPerSecond, "step_threat"); + navigationRuntime.deferPlan(); + schedule(blockedRouteRetryInterval); + return false; + } + if (risk && playerBotNavigationRiskVerdict(*risk, PlayerBotNavigationDangerEvidence::Detailed, costPolicy.dangerCost(resolvedStep->expectedPosition, exposureMs), costPolicy.dangerAt(resolvedStep->expectedPosition)) == PlayerBotNavigationRiskVerdict::Rejected) { huntCoordinator.clearTransitMovementFallback(); @@ -1942,6 +2043,12 @@ bool PlayerBotController::processNavigation(Player* player, const Position& curr } if (!blocker || blocker == player) huntCoordinator.observeViableTransitMovement(); } + if (resolvedStep && !travelStepSafe(*player, *resolvedStep)) { + deferUnsafeTravel(currentPosition, navigationCostPolicy(*player).dangerAt(resolvedStep->expectedPosition), "step_threat"); + navigationRuntime.deferPlan(); + schedule(blockedRouteRetryInterval); + return false; + } if (!resolvedStep || !executeNavigationStep(player, *resolvedStep)) { if ((resolvedStep && resolvedStep->action == PlayerBotNavigationAction::Move) || step.action == PlayerBotNavigationAction::Move) { @@ -2031,6 +2138,8 @@ void PlayerBotController::navigate() if (!selectTopLevelGoal(*player, currentPosition, "startup")) { return; } + } else if (fixtureDriver.travelSafetyGoal()) { + progressionRuntime.enterHunt(); } else if (fixtureDriver.startInHunt()) { progressionRuntime.enterHunt(); startHunt(player, currentPosition, "focused_fixture"); @@ -2040,6 +2149,15 @@ void PlayerBotController::navigate() schedule(navigationInterval); return; } + observeTravelSafety(*player); + PlayerBotTurnObservation safetyTurn; + safetyTurn.travelSafetyRequired = travelSafetyRequired(*player); + if (turnRouter.route(safetyTurn) == PlayerBotTurnCommand::TravelSafety && processTravelSafety(player, currentPosition)) return; + if (const auto goal = fixtureDriver.travelSafetyGoal()) { + if (processNavigation(player, currentPosition, *goal, nullptr, playerBotNavigationMaximumExpandedNodes, true)) + schedule(navigationInterval); + return; + } telemetry.maybeEmitSummary(currentPosition, telemetrySummary()); recordActiveHuntCombat(*player); verifySpellCast(*player, currentPosition); diff --git a/server/src/playerbotcontroller.h b/server/src/playerbotcontroller.h index ca5a2e9..21a8d66 100644 --- a/server/src/playerbotcontroller.h +++ b/server/src/playerbotcontroller.h @@ -42,6 +42,7 @@ #include "playerbotfixturedriver.h" #include "playerbottelemetry.h" #include "playerbotserviceworkflow.h" +#include "playerbotservicerouteretry.h" #include "playerbotturnrouter.h" #include "playerbottransitcombat.h" #include "playerbotroutechanges.h" @@ -179,7 +180,8 @@ class PlayerBotController : public std::enable_shared_from_this& sharedHuntRegionCooldowns); + std::map& sharedHuntRegionCooldowns, + PlayerBotTravelSafetyMemory& travelSafety); void start(const Position& position, bool recovered, uint32_t recoveryCount); @@ -530,7 +532,9 @@ class PlayerBotController : public std::enable_shared_from_this steps); + PlayerBotNavigationRuntimeOutcome observeNavigationPlan(Player& player, const PlayerBotNavigationGoal& goal, + PlayerBotNavigationRoutePlan plan, bool startsNavigation = true, + const PlayerBotNavigationCostPolicy* originalPolicy = nullptr); bool findDepositableItem(const Player& player, Container* container, Container*& source, Item*& depositItem, uint8_t& count) const; @@ -587,6 +591,19 @@ class PlayerBotController : public std::enable_shared_from_this travelDangerField(const PlayerBotCombatProfile& combat) const; + std::string travelSafetyIdentity() const; +std::string travelPlanningIdentity() const; +PlayerBotTravelRouteExposure travelRouteExposure(const Player& player, const Position& source, + const PlayerBotNavigationRoutePlan& route, const PlayerBotNavigationCostPolicy& policy) const; + bool travelSafetyRequired(const Player& player) const; + bool processTravelSafety(Player* player, const Position& position); + void deferUnsafeTravel(const Position& position, double danger, const char* reason); + void resumeSafeTravel(const Position& position); + bool travelStepSafe(const Player& player, const PlayerBotNavigationStep& step) const; + void approachTravelFrontier(Player& player, const PlayerBotNavigationGoal& goal); + void beginLoot(Player* player, const Position& currentPosition, const PlayerBotCombatDecision& defeatedTarget); void finishLoot(Player* player, const Position& currentPosition); @@ -600,6 +617,20 @@ class PlayerBotController : public std::enable_shared_from_this travelNavigationExposure; +Position travelNavigationExposurePosition; +std::shared_ptr lastCompletedTravelEvidence; + mutable std::string travelFieldIdentity; + mutable std::map, std::shared_ptr> travelFields; + bool travelDeferred = false, travelWithdrawing = false, travelEscapeDispatch = false; + std::optional travelEscapeExpected; + Position travelEscapeFrom; + std::chrono::steady_clock::time_point travelEscapeStarted{}; uint32_t scheduledTurnEvent = 0; uint64_t scheduledTurnGeneration = 0; std::chrono::steady_clock::time_point scheduledTurnDeadline; @@ -777,6 +808,7 @@ class PlayerBotController : public std::enable_shared_from_this work; }; std::optional serviceRouteSearch; + PlayerBotServiceRouteRetry serviceRouteRetry; uint64_t serviceRouteSerial = 0; // Trainer discovery and execution never borrow service/hunt frontiers. struct TrainerRouteSearch : ServiceRouteSearch { diff --git a/server/src/playerbotdeparture.cpp b/server/src/playerbotdeparture.cpp index 791b065..86ecd0d 100644 --- a/server/src/playerbotdeparture.cpp +++ b/server/src/playerbotdeparture.cpp @@ -147,7 +147,12 @@ void PlayerBotController::beginOracleDeparture(Player& player, const Position& p { progressionRuntime.beginDeparture(std::move(plan)); const auto& departure = progressionRuntime.departure().plan(); - observeNavigationPlan(departure.approachPosition, std::move(steps)); + PlayerBotNavigationRoutePlan route; + route.metrics.result = PlayerBotNavigationResult::Reached; + route.metrics.steps = departure.travelSteps; + route.metrics.expandedNodes = departure.expandedNodes; + route.steps = std::move(steps); + observeNavigationPlan(player, PlayerBotNavigationGoal::exact(departure.approachPosition), std::move(route)); emit("strategy_selection", position, "\"goal\":\"oracle_departure\",\"npc_id\":" + std::to_string(departure.npcId) + ",\"town\":\"thais\",\"town_id\":" + std::to_string(oracleTownId) + diff --git a/server/src/playerbotfixturedriver.cpp b/server/src/playerbotfixturedriver.cpp index 48ce362..761e834 100644 --- a/server/src/playerbotfixturedriver.cpp +++ b/server/src/playerbotfixturedriver.cpp @@ -119,6 +119,14 @@ playerbot::PlayerBotFixtureLocalRouteRecovery playerbot::PlayerBotFixtureDriver: return {policy.localRouteRecoveryFixture, svargrondRecoveryDestination, svargrondRecoverySuppressedPosition}; } +std::optional playerbot::PlayerBotFixtureDriver::travelSafetyGoal() const +{ + // Keep the same destination across recovery; only ordinary safety/navigation + // may decide when to approach it. The matching runway is fixture Lua data. + if (policy.raidTravelFixture) return Position(60208, 60200, 7); + return std::nullopt; +} + Position playerbot::PlayerBotFixtureDriver::navigationPreflightGoal() const { return fixtureHuntPatrol.front(); diff --git a/server/src/playerbotfixturedriver.h b/server/src/playerbotfixturedriver.h index 12cc580..7ae76b0 100644 --- a/server/src/playerbotfixturedriver.h +++ b/server/src/playerbotfixturedriver.h @@ -6,6 +6,7 @@ #include "position.h" #include +#include #include #include @@ -69,6 +70,7 @@ namespace playerbot { PlayerBotFixtureLocalRouteRecovery localRouteRecovery() const; NavigationPreflightFixture navigationPreflightFixture() const { return policy.navigationPreflightFixture; } Position navigationPreflightGoal() const; + std::optional travelSafetyGoal() const; uint32_t huntPlanningDuration(uint32_t configuredDurationSeconds) const { return playerBotFixtureHuntPlanningDuration(policy.mainlandLoopFixture, configuredDurationSeconds); diff --git a/server/src/playerbothuntregions.h b/server/src/playerbothuntregions.h index a9b2a17..8a8f2d5 100644 --- a/server/src/playerbothuntregions.h +++ b/server/src/playerbothuntregions.h @@ -17,6 +17,7 @@ #include "playerbotsupplypolicy.h" #include "playerbotsupplyrecovery.h" #include "playerbotpreparation.h" +#include "playerbottravelsafety.h" #include "position.h" #include @@ -795,13 +796,19 @@ struct PlayerBotHuntReturnCoverageContext { bool canUseRope = false; bool canUseShovel = false; bool premium = false; + // Coarse proofs follow raid epochs, not unrelated observation revisions. + std::string travelSafetyIdentity{}; + std::shared_ptr travelExposure{}; + std::shared_ptr travelField{}; + std::string observationIdentity{}; bool operator==(const PlayerBotHuntReturnCoverageContext& other) const { return topologyGeneration == other.topologyGeneration && npcGeneration == other.npcGeneration && level == other.level && coveredPosition == other.coveredPosition && depotDestination == other.depotDestination && fare == other.fare && - canUseRope == other.canUseRope && canUseShovel == other.canUseShovel && premium == other.premium; + canUseRope == other.canUseRope && canUseShovel == other.canUseShovel && premium == other.premium && + travelSafetyIdentity == other.travelSafetyIdentity; } }; @@ -820,8 +827,11 @@ class PlayerBotHuntReturnCoverage bool covers(uint64_t variantId, uint64_t atlasRevision, const PlayerBotHuntReturnCoverageContext& context) const { - return evidence && evidence->routeValid && evidence->variantId == variantId && - evidence->atlasRevision == atlasRevision && evidence->context == context; + if (!evidence || !evidence->routeValid || evidence->variantId != variantId || + evidence->atlasRevision != atlasRevision || !(evidence->context == context)) return false; + const auto& retained = evidence->context; + return retained.observationIdentity == context.observationIdentity || !retained.travelExposure || + !context.travelField || !retained.travelExposure->refreshed(*context.travelField).transientRiskRejected; } void invalidate() @@ -829,6 +839,12 @@ class PlayerBotHuntReturnCoverage if (evidence) evidence->routeValid = false; } bool valid() const { return evidence && evidence->routeValid; } + std::shared_ptr travelExposure() const { + return evidence ? evidence->context.travelExposure : nullptr; + } + std::shared_ptr travelExposureAt(Position position) const { + return evidence && evidence->context.coveredPosition == position ? evidence->context.travelExposure : nullptr; + } private: struct Evidence { diff --git a/server/src/playerbothunttravel.cpp b/server/src/playerbothunttravel.cpp index 2d767a8..05f619a 100644 --- a/server/src/playerbothunttravel.cpp +++ b/server/src/playerbothunttravel.cpp @@ -72,6 +72,7 @@ struct PlayerBotHuntTravelWork { PlayerBotRouteChanges::Watch watch; PlayerBotNavigationCostPolicy policy; PlayerBotNavigationRiskProfile riskProfile; + std::string travelSafetyIdentity; PlayerBotNavigationRoutePlan walking; std::unique_ptr local; std::string profile; @@ -178,6 +179,9 @@ bool PlayerBotController::huntTravelEvidenceValid(Player& player, const Position PlayerBotEquipmentAdapter::loadout(player)); const bool rope = g_game.findItemOfType(&player, ropeItemId, true) != nullptr; const bool shovel = g_game.findItemOfType(&player, shovelToolItemId, true) != nullptr; + if (evidence.travelSafetyIdentity != travelPlanningIdentity()) return false; + if (evidence.observationIdentity != travelSafetyIdentity() && evidence.travelExposure && + evidence.travelExposure->refreshed(*travelDangerField(combat)).transientRiskRejected) return false; if (!evidence.validContext(source, destination, actorFacts(player, combat, rope, shovel), PlayerBotTopology::instance().generation(), PlayerBotHuntRegionPlanner::getCacheRevision(), riskProfile)) return false; if (evidence.paid && !evidence.validOffers(offerFacts(player, source, playerBotHuntTransportFunds( @@ -193,6 +197,9 @@ const char* PlayerBotController::trainerTravelExecutionChange(Player& player, Pl PlayerBotEquipmentAdapter::loadout(player)); const bool rope = g_game.findItemOfType(&player, ropeItemId, true) != nullptr; const bool shovel = g_game.findItemOfType(&player, shovelToolItemId, true) != nullptr; + if (evidence.travelSafetyIdentity != travelPlanningIdentity()) return "travel_threat_changed"; + if (evidence.observationIdentity != travelSafetyIdentity() && evidence.travelExposure && + evidence.travelExposure->refreshed(*travelDangerField(combat)).transientRiskRejected) return "travel_threat_changed"; return evidence.executionContextChange(actorFacts(player, combat, rope, shovel), static_cast(player).getStepSpeed(), PlayerBotTopology::instance().generation(), PlayerBotHuntRegionPlanner::getCacheRevision(), riskProfile); } @@ -219,14 +226,16 @@ std::optional PlayerBotController::advanceHuntTrav const auto offers = offerFacts(player, source, money, request.walkingOnly, unavailableTravelOffers); const uint64_t generation = PlayerBotTopology::instance().generation(); const uint64_t riskRevision = PlayerBotHuntRegionPlanner::getCacheRevision(); + const std::string safetyIdentity = travelPlanningIdentity(); const bool sameRequest = huntTravelWork && huntTravelWork->sameRequest(request, source, transportReserve, sellEconomy, riskProfile); const auto journalReason = sameRequest ? huntTravelWork->watch.check() : PlayerBotRouteChanges::Reason::None; const char* invalidationReason = !sameRequest ? "request" : + huntTravelWork->travelSafetyIdentity != safetyIdentity ? "travel_threat" : huntTravelWork->actor != actor ? "actor" : huntTravelWork->topologyGeneration != generation ? "topology" : huntTravelWork->riskRevision != riskRevision ? "risk" : PlayerBotRouteChanges::reasonName(journalReason); - const bool reuse = sameRequest && huntTravelWork->actor == actor && + const bool reuse = sameRequest && huntTravelWork->travelSafetyIdentity == safetyIdentity && huntTravelWork->actor == actor && huntTravelWork->topologyGeneration == generation && huntTravelWork->riskRevision == riskRevision && journalReason == PlayerBotRouteChanges::Reason::None; if (!reuse) { @@ -263,7 +272,10 @@ std::optional PlayerBotController::advanceHuntTrav std::ostringstream key; key << std::setprecision(17); std::apply([&](const auto&... fields) { ((key << fields << ';'), ...); }, profile); - w.profile = key.str(); + w.travelSafetyIdentity = safetyIdentity; + // The frontier keeps this immutable observation snapshot for its entire + // request. Shared caches still require the EXACT private field identity. + w.profile = key.str() + ";safety:" + safetyIdentity + ":" + travelSafetyIdentity(); w.restartBudget = restartBudget; w.riskRevision = riskRevision; w.policy = navigationCostPolicy(player, request.combat ? &*request.combat : nullptr); @@ -276,18 +288,43 @@ std::optional PlayerBotController::advanceHuntTrav } const auto work = huntTravelWork; // Keep the scoped tile observer alive through return. auto& w = *work; - auto retainEvidence = [&](const PlayerBotNavigationRoutePlan& route) { - if (!completedEvidence || !safe(route, riskProfile)) return; + auto refreshRoute = [&](PlayerBotNavigationRoutePlan& route) -> std::shared_ptr { + if (!safe(route, riskProfile)) return nullptr; + auto exposure = std::make_shared(travelRouteExposure(player, source, route, w.policy)); + const auto refreshed = exposure->refreshed(*travelDangerField(combat)); + auto livePolicy = navigationCostPolicy(player, &combat); + livePolicy.risk = riskProfile; + const auto transient = playerBotNavigationTransientRouteRisk(livePolicy, route.steps, + [&](const PlayerBotNavigationStep& step) { return step.action == PlayerBotNavigationAction::Move ? + player.getStepDuration(step.direction) : 1000u; }); + if (refreshed.transientRiskRejected || transient.transientRiskRejected) { + route.metrics.result = PlayerBotNavigationResult::RiskRejected; + route.metrics.transientRiskRejected = true; + route.metrics.dangerCost = std::max(refreshed.dangerCost, transient.dangerCost); + route.metrics.maximumHealthLossPerSecond = std::max(refreshed.maximumHealthLossPerSecond, transient.maximumHealthLossPerSecond); + return nullptr; + } + route.metrics.dangerCost = refreshed.dangerCost; + route.metrics.maximumHealthLossPerSecond = refreshed.maximumHealthLossPerSecond; + return exposure; + }; + auto retainEvidence = [&](PlayerBotNavigationRoutePlan& route) { + auto exposure = refreshRoute(route); + if (!exposure) return; auto evidence = std::make_shared(); evidence->source = w.source; evidence->destination = w.destination; evidence->actor = w.actor; evidence->topologyGeneration = w.topologyGeneration; evidence->riskRevision = w.riskRevision; evidence->transportReserve = w.transportReserve; evidence->risk = w.riskProfile; evidence->watch = w.watch; + evidence->travelSafetyIdentity = w.travelSafetyIdentity; + evidence->observationIdentity = travelSafetyIdentity(); + evidence->travelExposure = std::move(exposure); evidence->retainExecutionLeg(route, static_cast(player).getStepSpeed()); evidence->paid = route.metrics.firstNpcTravelOffer.has_value(); evidence->walkingOnly = w.walkingOnly; if (evidence->paid) { evidence->offers = w.offers; evidence->providers = w.providerValidation; } - *completedEvidence = std::move(evidence); + lastCompletedTravelEvidence = evidence; + if (completedEvidence) *completedEvidence = std::move(evidence); }; if (!w.transport) { // NPC movement/offers cannot invalidate the independent walking frontier. @@ -349,6 +386,9 @@ std::optional PlayerBotController::advanceHuntTrav if (const auto peak = rejectedEndpoints(source, w.destinations, "walking")) { w.walking.metrics.result = PlayerBotNavigationResult::RiskRejected; w.walking.metrics.maximumHealthLossPerSecond = *peak; + w.walking.metrics.transientRiskRejected = policy.transientHealthLossPerSecond && + playerBotNavigationRejectedEndpointPeak(source, PlayerBotNavigationGoal::anyOf(w.destinations), + riskProfile.maximumHealthLossPerSecond, policy.transientHealthLossPerSecond).has_value(); if (walkingAlternative) *walkingAlternative = w.walking; PlayerBotNavigationRoutePlan rejected = std::move(w.walking); huntTravelWork.reset(); @@ -410,6 +450,7 @@ std::optional PlayerBotController::advanceHuntTrav w.walking.metrics.result = *result; w.walking.metrics.expandedNodes = w.local->expanded; w.walking.metrics.movementCost = w.local->summary.movementCost; + w.walking.metrics.transientRiskRejected = w.local->summary.transientRiskRejected; w.walking.metrics.dangerCost = w.local->summary.dangerCost; w.walking.metrics.maximumHealthLossPerSecond = w.local->summary.maximumHealthLossPerSecond; w.walking.metrics.estimatedTravelSeconds = w.local->seconds; @@ -420,9 +461,9 @@ std::optional PlayerBotController::advanceHuntTrav w.walkingDone = true; timing.walkingReached += *result == PlayerBotNavigationResult::Reached; if (w.preferSafeWalking && safe(w.walking, riskProfile) && !w.walking.steps.empty()) { - if (walkingAlternative) *walkingAlternative = w.walking; PlayerBotNavigationRoutePlan route = std::move(w.walking); retainEvidence(route); + if (walkingAlternative) *walkingAlternative = route; huntTravelWork.reset(); return route; } @@ -503,7 +544,10 @@ std::optional PlayerBotController::advanceHuntTrav ++timing.npcReturned; ++timing.npcReached; timing.npcReturnedExpandedNodes += w.npcNodes; } - if (walkingAlternative) *walkingAlternative = w.walking; + if (walkingAlternative) { + *walkingAlternative = w.walking; + refreshRoute(*walkingAlternative); + } PlayerBotNavigationRoutePlan result = std::move(w.walking); if (const auto& paid = w.transport->bestPaid; paid && (!safe(result, riskProfile) || paid->cost < cost(result, w.sellEconomy))) { result = {}; diff --git a/server/src/playerbothunttravelevidence.h b/server/src/playerbothunttravelevidence.h index 8272df9..833e1b7 100644 --- a/server/src/playerbothunttravelevidence.h +++ b/server/src/playerbothunttravelevidence.h @@ -5,6 +5,7 @@ #include "playerbothuntrouteselection.h" #include "playerbotroutechanges.h" #include "playerbotnavigationruntime.h" +#include "playerbottravelsafety.h" #include #include @@ -19,6 +20,9 @@ struct PlayerBotHuntTravelEvidence { using Offer = std::tuple>; Position source, destination; Actor actor; + std::string travelSafetyIdentity; // Raid/planning epoch, not the observation cache key. + std::string observationIdentity; + std::shared_ptr travelExposure; uint64_t topologyGeneration = 0, riskRevision = 0, transportReserve = 0; PlayerBotNavigationRiskProfile risk; PlayerBotRouteChanges::Watch watch; diff --git a/server/src/playerbothuntwalk.h b/server/src/playerbothuntwalk.h index aa4b751..6e4ef73 100644 --- a/server/src/playerbothuntwalk.h +++ b/server/src/playerbothuntwalk.h @@ -330,7 +330,12 @@ class PlayerBotHuntWalkSearch { PlayerBotNavigationCostSummary cost; tree->search.extract(goals, path, cost); append(player, policy, path); - } else if (*state == PlayerBotNavigationResult::NodeLimit) ++timing.nodeLimits; + } else { + // Preserve definitive transient rejection from the unrestricted + // kernel. Restricted failures and node limits remain inconclusive. + summary = tree->search.summary; + if (*state == PlayerBotNavigationResult::NodeLimit) ++timing.nodeLimits; + } result = *state; // Cached settled labels remain final under any consistent target // potential. Charge the maximum potential grid along with the tree. diff --git a/server/src/playerbotnavigation.cpp b/server/src/playerbotnavigation.cpp index 1e8ce45..2c3d4d2 100644 --- a/server/src/playerbotnavigation.cpp +++ b/server/src/playerbotnavigation.cpp @@ -555,6 +555,7 @@ std::optional PlayerBotNavigator::advance( const auto& start = search.start; const auto& goal = search.goal; if (costPolicy) search.constrain(costPolicy->risk); + if (costPolicy && search.rejectUnsafeTransientEndpoints(*costPolicy)) return search.result; if (costPolicy && search.rejectUnsafeEndpoints([&](const Position& target) { return costPolicy->dangerAt(target); })) { return search.result; } @@ -569,8 +570,11 @@ std::optional PlayerBotNavigator::advance( uint32_t movementCost, uint32_t exposureMs, const PlayerBotNavigationStep& step) { if (!search.sourceTree && !search.unbounded && !isInsideSearchBounds(to, start, goal)) return; if (corridor && !corridor->contains(PlayerBotTopology::instance().walkNode(to))) return; - arcs.push_back({step, movementCost, costPolicy ? costPolicy->dangerCost(to, exposureMs) : 0, - costPolicy ? costPolicy->dangerAt(to) : 0}); + if (costPolicy && costPolicy->transientHealthLossPerSecond && + search.rejectTransientArc(costPolicy->transientHealthLossPerSecond(to), + costPolicy->risk.maximumHealthLossPerSecond)) return; + const double peak = costPolicy ? costPolicy->dangerAt(to) : 0; + arcs.push_back({step, movementCost, costPolicy ? costPolicy->dangerCostForSample(peak, exposureMs) : 0, peak}); }; for (Direction direction : directions) { Position next; diff --git a/server/src/playerbotnavigation.h b/server/src/playerbotnavigation.h index 857bfc7..5c53472 100644 --- a/server/src/playerbotnavigation.h +++ b/server/src/playerbotnavigation.h @@ -317,6 +317,12 @@ struct PlayerBotNavigationCostPolicy { PlayerBotNavigationRiskProfile risk; std::function expectedHealthLossPerSecond; uint32_t topologyExposureMs = 16000; + // Private observation/raid evidence imposes a peak ceiling even on callers + // that retain legacy weighted static-risk acceptance (for example depots). + std::function transientHealthLossPerSecond{}; + // Preserve cause when individually acceptable static and transient exposure + // combine into an unsafe route. This is the cached static model, not occupancy. + std::function staticHealthLossPerSecond{}; bool enabled() const { return static_cast(expectedHealthLossPerSecond) && risk.healthLossCost > 0; } double dangerAt(const Position& position) const @@ -341,8 +347,44 @@ struct PlayerBotNavigationCostSummary { uint32_t movementCost = 0; uint32_t dangerCost = 0; double maximumHealthLossPerSecond = 0; + // Evidence from rejected arcs/endpoints, not merely the presence of a raid. + bool transientRiskRejected = false; }; +// Inspect only the route that would actually execute, including appended +// portals. A harmless/off-route observation must not hide static risk failures. +template +PlayerBotNavigationCostSummary playerBotNavigationTransientRouteRisk( + const PlayerBotNavigationCostPolicy& policy, const Steps& steps, Exposure exposure) +{ + PlayerBotNavigationCostSummary summary, baseline, combined; + if (!policy.transientHealthLossPerSecond) return summary; + auto add = [&](PlayerBotNavigationCostSummary& value, double peak, uint32_t milliseconds) { + value.maximumHealthLossPerSecond = std::max(value.maximumHealthLossPerSecond, peak); + value.dangerCost = static_cast(std::min(UINT32_MAX, + uint64_t(value.dangerCost) + policy.dangerCostForSample(peak, milliseconds))); + }; + for (const auto& step : steps) { + const uint32_t milliseconds = exposure(step); + const double peak = policy.transientHealthLossPerSecond(step.expectedPosition); + add(summary, peak, milliseconds); + if (policy.staticHealthLossPerSecond) { + const double fixed = policy.staticHealthLossPerSecond(step.expectedPosition); + add(baseline, fixed, milliseconds); + add(combined, std::max(fixed, peak), milliseconds); + } + } + const auto accepted = [&](const PlayerBotNavigationCostSummary& value) { + return playerBotNavigationRiskAccepts(policy.risk, value.dangerCost, value.maximumHealthLossPerSecond); + }; + summary.transientRiskRejected = !accepted(summary); + if (policy.staticHealthLossPerSecond && accepted(baseline) && !accepted(combined)) { + combined.transientRiskRejected = true; + return combined; + } + return summary; +} + class PlayerBotNavigator { public: diff --git a/server/src/playerbotnavigationruntime.h b/server/src/playerbotnavigationruntime.h index 551d4a0..1ac1376 100644 --- a/server/src/playerbotnavigationruntime.h +++ b/server/src/playerbotnavigationruntime.h @@ -58,6 +58,7 @@ struct PlayerBotNavigationPlanMetrics { bool dangerAware = false; std::chrono::microseconds elapsed = std::chrono::microseconds::zero(); bool attempted = false; + bool transientRiskRejected = false; }; inline PlayerBotNavigationRiskVerdict playerBotNavigationRiskVerdict( @@ -89,6 +90,25 @@ struct PlayerBotNavigationRoutePlan { std::deque steps; }; +// A temporary threat is not an unavailable destination. Require explicit +// rejected-search evidence or unsafe transient exposure on the actual plan. +// Node limits, static risk and unrelated warnings retain their normal bounds. +template +std::optional playerBotNavigationTransientRejection(const PlayerBotNavigationRoutePlan& plan, + const PlayerBotNavigationCostPolicy& policy, Exposure exposure) +{ + if (plan.metrics.result == PlayerBotNavigationResult::RiskRejected && plan.metrics.transientRiskRejected) { + return plan.metrics.maximumHealthLossPerSecond; + } + // A completed walking path may be relabeled RiskRejected by itinerary + // aggregation. Its concrete steps still establish mixed-exposure cause. + if (plan.metrics.result != PlayerBotNavigationResult::Reached && + plan.metrics.result != PlayerBotNavigationResult::RiskRejected) return std::nullopt; + const auto transient = playerBotNavigationTransientRouteRisk(policy, plan.steps, exposure); + if (transient.transientRiskRejected) return transient.maximumHealthLossPerSecond; + return std::nullopt; +} + // Only a complete, unexposed same-floor walk can extend known return coverage. // Added danger needs a new return-risk estimate. Portals, NPC travel, item use, // doors, and partial approaches may be one-way even within one hunt region. @@ -289,6 +309,8 @@ class PlayerBotNavigationRuntime fixedTargetFailures.reset(); fixedTargetGoal.reset(); } + // Discard an unsafe route without charging or forgiving fixed-goal failures. + void deferPlan() { session.clear(); } void resetPatrolRecovery() { session.clearBlockedPositions(); session.resetStepFailures(); } void clearBlockedPositions() { session.clearBlockedPositions(); } diff --git a/server/src/playerbotpathsearch.h b/server/src/playerbotpathsearch.h index 1c3eaf1..ebd2f14 100644 --- a/server/src/playerbotpathsearch.h +++ b/server/src/playerbotpathsearch.h @@ -13,6 +13,24 @@ // no Player, Tile, or cost-policy closure survives a yield. class PlayerBotPathSearch { public: + bool rejectTransientArc(double peak, double maximum) { + if (playerBotNavigationPeakAccepts(maximum, peak)) return false; + transientRiskRejected = true; + transientRejectedPeak = std::max(transientRejectedPeak, peak); + return true; + } + + bool rejectUnsafeTransientEndpoints(const PlayerBotNavigationCostPolicy& policy) { + if (result || !policy.transientHealthLossPerSecond) return false; + const auto peak = playerBotNavigationRejectedEndpointPeak(start, goal, + std::min(maximumPeakDanger, policy.risk.maximumHealthLossPerSecond), policy.transientHealthLossPerSecond); + if (!peak) return false; + summary.transientRiskRejected = true; + summary.maximumHealthLossPerSecond = *peak; + result = PlayerBotNavigationResult::RiskRejected; + return true; + } + struct Arc { PlayerBotNavigationStep step; uint32_t movement = 0; @@ -122,8 +140,13 @@ class PlayerBotPathSearch { // must be able to continue through this node without re-expanding it. if (sourceTree && targetSettled()) return result = PlayerBotNavigationResult::Reached; } - if (open.empty()) result = PlayerBotNavigationResult::Unreachable; - else if (expanded >= allowance) result = PlayerBotNavigationResult::NodeLimit; + if (open.empty()) { + result = transientRiskRejected ? PlayerBotNavigationResult::RiskRejected : PlayerBotNavigationResult::Unreachable; + if (transientRiskRejected) { + summary.transientRiskRejected = true; + summary.maximumHealthLossPerSecond = transientRejectedPeak; + } + } else if (expanded >= allowance) result = PlayerBotNavigationResult::NodeLimit; return result; } @@ -207,6 +230,8 @@ class PlayerBotPathSearch { bool zeroDangerOnly = false; private: + bool transientRiskRejected = false; + double transientRejectedPeak = 0; struct Node { uint32_t estimate; uint32_t cost; diff --git a/server/src/playerbotprogression.cpp b/server/src/playerbotprogression.cpp index c64132d..23cfba3 100644 --- a/server/src/playerbotprogression.cpp +++ b/server/src/playerbotprogression.cpp @@ -1048,7 +1048,13 @@ void PlayerBotController::beginPickupReward(Player& player, const Position& posi progressionRuntime.beginReward(std::move(reward), std::move(displaced)); const auto& selected = progressionRuntime.reward().plan(); if (!selected.resumeEquipment) { - observeNavigationPlan(selected.approachPosition, std::move(rewardSteps)); + PlayerBotNavigationRoutePlan route; + route.metrics.result = PlayerBotNavigationResult::Reached; + route.metrics.steps = selected.travelSteps; + route.metrics.dangerCost = selected.travelDangerCost; + route.metrics.maximumHealthLossPerSecond = selected.maximumTravelDanger; + route.steps = std::move(rewardSteps); + observeNavigationPlan(player, PlayerBotNavigationGoal::exact(selected.approachPosition), std::move(route)); } std::ostringstream fields; fields << "\"goal\":\"pickup_reward\",\"acquisition_source\":\"map_reward\",\"candidate_id\":" << selected.uniqueId diff --git a/server/src/playerbotraidhazards.h b/server/src/playerbotraidhazards.h new file mode 100644 index 0000000..873bc6d --- /dev/null +++ b/server/src/playerbotraidhazards.h @@ -0,0 +1,166 @@ +#ifndef FS_PLAYERBOTRAIDHAZARDS_H +#define FS_PLAYERBOTRAIDHAZARDS_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "position.h" + +struct PlayerBotRaidMonster { + std::string name; + uint32_t count; +}; + +struct PlayerBotRaidHazard { + uint64_t id; + std::string raid; + Position from, to; + std::vector monsters; + // A configured wave is still expected here; local emptiness cannot clear it. + bool pending = false; +}; + +struct PlayerBotRaidFootprint { + Position from, to; + std::vector monsters; +}; + +// Dispatcher-owned shared knowledge, not a live creature/occupancy index. Counts +// are configured upper bounds, including future waves; rectangles describe spawn +// origins, never monster leashes or proof that tiles outside them are safe. +// Script-only spawns have no static footprint and are not represented here. +class PlayerBotRaidHazards { +public: + static constexpr size_t MaxFootprints = 256; + + static PlayerBotRaidHazards& instance() { + static PlayerBotRaidHazards hazards; + return hazards; + } + + uint64_t revision() const { return generation; } + std::shared_ptr> snapshot() const { return records; } + + // Publish one activation atomically. Equal raid/rectangle pairs in this call + // combine wave counts (saturating); matching older records are replaced with + // new IDs so a bot's observed clearance cannot clear a later activation/wave. + // Start and wave signals publish the full configured activation forecast: + // earlier monsters may survive later waves. Never publish only a weaker later + // wave here. setPending() tracks the remaining sequence independently, without + // weakening species/counts or adding them again across publications. + // Unmatched records retain their IDs. At capacity the oldest record is evicted. + void activate(const std::string& raid, const std::vector& footprints, bool pending = false) { + std::shared_ptr> updated; + uint64_t candidateId = nextId; + for (const auto& footprint : footprints) { + if (!hasMonsters(footprint)) continue; + + const Position from = lowerCorner(footprint); + const Position to = upperCorner(footprint); + if (!updated) updated = std::make_shared>(*records); + auto found = std::find_if(updated->begin(), updated->end(), [&](const auto& hazard) { + return hazard.raid == raid && hazard.from == from && hazard.to == to; + }); + if (found != updated->end() && found->id < nextId) { + updated->erase(found); + found = updated->end(); + } + if (found == updated->end()) { + if (updated->size() == MaxFootprints) updated->erase(updated->begin()); + updated->push_back({candidateId++, raid, from, to, {}, pending}); + found = std::prev(updated->end()); + } + for (const auto& monster : footprint.monsters) { + if (monster.name.empty() || monster.count == 0) continue; + auto species = std::find_if(found->monsters.begin(), found->monsters.end(), [&](const auto& entry) { + return entry.name == monster.name; + }); + if (species == found->monsters.end()) { + found->monsters.push_back(monster); + } else { + const uint32_t room = std::numeric_limits::max() - species->count; + species->count += std::min(room, monster.count); + } + } + } + if (!updated) return; + records = std::move(updated); + nextId = candidateId; + ++generation; + } + + // Pending means a future configured wave, not surviving occupancy. Changing + // that fact retains the full forecast and IDs; only a new spawn signal needs + // to invalidate prior observed clearance. No-op updates do not copy or revise. + void setPending(const std::string& raid, const std::vector& remaining) { + std::shared_ptr> updated; + for (size_t i = 0; i < records->size(); ++i) { + const auto& hazard = (*records)[i]; + if (hazard.raid != raid) continue; + const bool pending = std::any_of(remaining.begin(), remaining.end(), [&](const auto& footprint) { + return hazard.from == lowerCorner(footprint) && hazard.to == upperCorner(footprint) && hasMonsters(footprint); + }); + if (hazard.pending == pending) continue; + if (!updated) updated = std::make_shared>(*records); + (*updated)[i].pending = pending; + } + if (!updated) return; + records = std::move(updated); + ++generation; + } + + // Completion, failure, cancellation and reload all stop promising future waves. + // Existing monsters may survive any of them, so do not erase their warnings. + void retirePending(const std::string& raid) { setPending(raid, {}); } + + // Explicit knowledge reset for tests, not evidence of monster removal. Raid + // completion and definition clear/reload must not call this: spawned monsters + // can outlive both their event sequence and the objects that configured them. + // IDs are never reused, even across clears. Empty clears are no-ops. + void clear() { + if (records->empty()) return; + records = emptySnapshot(); + ++generation; + } + +private: + static bool hasMonsters(const PlayerBotRaidFootprint& footprint) { + return std::any_of(footprint.monsters.begin(), footprint.monsters.end(), [](const auto& monster) { + return !monster.name.empty() && monster.count != 0; + }); + } + + static Position lowerCorner(const PlayerBotRaidFootprint& footprint) { + return {std::min(footprint.from.x, footprint.to.x), std::min(footprint.from.y, footprint.to.y), + std::min(footprint.from.z, footprint.to.z)}; + } + + static Position upperCorner(const PlayerBotRaidFootprint& footprint) { + return {std::max(footprint.from.x, footprint.to.x), std::max(footprint.from.y, footprint.to.y), + std::max(footprint.from.z, footprint.to.z)}; + } + + static std::shared_ptr> emptySnapshot() { + static const auto empty = std::make_shared>(); + return empty; + } + + PlayerBotRaidHazards() = default; + PlayerBotRaidHazards(const PlayerBotRaidHazards&) = delete; + PlayerBotRaidHazards& operator=(const PlayerBotRaidHazards&) = delete; + + std::shared_ptr> records = emptySnapshot(); + uint64_t generation = 0; + uint64_t nextId = 1; +}; + +#endif diff --git a/server/src/playerbotservice.cpp b/server/src/playerbotservice.cpp index 04d70a7..cc65efc 100644 --- a/server/src/playerbotservice.cpp +++ b/server/src/playerbotservice.cpp @@ -1444,7 +1444,7 @@ void PlayerBotController::processService(Player* player, const Position& current } PlayerBotServiceCommand command = serviceWorkflow.advance(observation, economyCatalog, dispositionPolicy); const std::vector discoveries = command.discoveries; - std::deque approachSteps; + PlayerBotNavigationRoutePlan approachPlan; uint32_t routeValidations = 0; PlayerBotNavigationResult lastRouteResult = PlayerBotNavigationResult::Unreachable; uint64_t lastRouteExpandedNodes = 0; @@ -1461,6 +1461,8 @@ void PlayerBotController::processService(Player* player, const Position& current revalidatingSelectedApproach = true; } } + if (command.type != PlayerBotServiceCommandType::ValidateProviderRoute && + command.type != PlayerBotServiceCommandType::NavigateProvider) serviceRouteRetry.reset(); // Keep a sliced search when an exhausted approach needs validation again. if (command.type != PlayerBotServiceCommandType::ValidateProviderRoute && serviceRouteSearch) { serviceRouteSearch.reset(); @@ -1585,7 +1587,7 @@ void PlayerBotController::processService(Player* player, const Position& current // A creature blocking a validated step (usually a wandering NPC or pet) // is not a route failure. Walk around it, or wait for it to move. bool routeBlocked = false; - const auto firstTravel = std::find_if(routePlan.steps.begin(), routePlan.steps.end(), [](const auto& step) { + auto firstTravel = std::find_if(routePlan.steps.begin(), routePlan.steps.end(), [](const auto& step) { return step.action == PlayerBotNavigationAction::NpcTravel; }); if (command.destination != currentPosition && routePlan.metrics.result == PlayerBotNavigationResult::Reached && @@ -1615,6 +1617,56 @@ void PlayerBotController::processService(Player* player, const Position& current routeBlocked = true; } } + firstTravel = std::find_if(routePlan.steps.begin(), routePlan.steps.end(), [](const auto& step) { + return step.action == PlayerBotNavigationAction::NpcTravel; + }); + auto freshPolicy = navigationCostPolicy(*player); + if (preparationPurchase) freshPolicy.risk.maximumRouteHealthLoss = freshPolicy.risk.maximumHealthLossPerSecond = 0; + const auto transientRejection = playerBotNavigationTransientRejection(routePlan, freshPolicy, + [player](const PlayerBotNavigationStep& step) -> uint32_t { + return step.action == PlayerBotNavigationAction::Move ? player->getStepDuration(step.direction) : 1000; + }); + using Retry = PlayerBotServiceRouteRetry; + const auto retry = serviceRouteRetry.observe(routeProviderId, currentPosition, routeDestination, + routePlan.metrics.result, transientRejection.has_value(), !blockedPositions.empty()); + if (retry != Retry::Decision::Proceed || routePlan.metrics.result != PlayerBotNavigationResult::Reached) { + const char* reason = retry == Retry::Decision::TransientThreat ? "transient_route_threat" : + retry == Retry::Decision::TemporaryExclusion ? "temporary_route_exclusion" : + retry == Retry::Decision::RetryIncomplete ? "node_limit_retry" : + retry == Retry::Decision::IncompleteExhausted ? "planning_incomplete" : "route_rejected"; + std::ostringstream fields; + fields << "\"reason\":" << jsonString(reason) << ",\"npc_id\":" << routeProviderId + << ",\"destination\":{\"x\":" << routeDestination.x << ",\"y\":" << routeDestination.y + << ",\"z\":" << unsigned(routeDestination.z) << "},\"route_result\":" + << jsonString(playerBotNavigationResultName(routePlan.metrics.result)) + << ",\"expanded_nodes\":" << routePlan.metrics.expandedNodes + << ",\"transient_risk_rejected\":" << (transientRejection ? "true" : "false") + << ",\"incomplete_retries\":" << serviceRouteRetry.retryCount() + << ",\"blocked_position_count\":" << blockedPositions.size() << ",\"blocked_positions\":["; + size_t logged = 0; + for (const auto& blocked : blockedPositions) { + if (logged == 8) break; + if (logged++) fields << ','; + fields << "{\"x\":" << blocked.x << ",\"y\":" << blocked.y << ",\"z\":" << unsigned(blocked.z) << '}'; + } + fields << ']'; + emit("service_route_validation", currentPosition, fields.str()); + } + if (retry == Retry::Decision::IncompleteExhausted) { + stop("service_route_planning_incomplete", currentPosition); + return; + } + if (retry != Retry::Decision::Proceed) { + // Do not feed a temporary or incomplete route failure to the workflow: + // that rejects approaches and eventually blacklists the provider. + navigationRuntime.deferPlan(); + if (transientRejection) { + deferUnsafeTravel(currentPosition, *transientRejection, "service_route_threat"); + approachTravelFrontier(*player, PlayerBotNavigationGoal::exact(routeDestination)); + } + schedule(Retry::RetryDelayMs); + return; + } if (!routeBlocked) serviceWorkflow.approach().unblocked(); const auto* quote = sellingLocalLoot ? &*serviceWorkflow.liquidation() : nullptr; const bool quoteAllowed = (!preparationPurchase || routePlan.metrics.fare == 0) && @@ -1644,8 +1696,11 @@ void PlayerBotController::processService(Player* player, const Position& current lastRouteRequiresNpcTravel = validatedNpcTravel || std::any_of(routePlan.steps.begin(), routePlan.steps.end(), [](const PlayerBotNavigationStep& step) { return step.action == PlayerBotNavigationAction::NpcTravel; }); routeObservation.approachRoute.requiresNpcTravel = lastRouteRequiresNpcTravel; - if (reached) approachSteps = std::move(routePlan.steps); - else if (routePlan.metrics.result != PlayerBotNavigationResult::Reached) { + if (reached) { + resumeSafeTravel(currentPosition); + approachPlan.metrics = routePlan.metrics; + approachPlan.steps = std::move(routePlan.steps); + } else if (routePlan.metrics.result != PlayerBotNavigationResult::Reached) { huntCoordinator.observeTransitMovementFailure(currentPosition); } const auto* liquidation = sellingLocalLoot ? &*serviceWorkflow.liquidation() : nullptr; @@ -1825,7 +1880,7 @@ void PlayerBotController::processService(Player* player, const Position& current } Npc* provider = command.providerId == 0 ? nullptr : g_game.getNpcByID(command.providerId); if (command.type == PlayerBotServiceCommandType::NavigateProvider && provider) { - if (!approachSteps.empty()) observeNavigationPlan(command.destination, std::move(approachSteps)); + if (!approachPlan.steps.empty()) observeNavigationPlan(*player, PlayerBotNavigationGoal::exact(command.destination), std::move(approachPlan)); if (Position::areInRange<3, 3, 0>(currentPosition, provider->getPosition())) { resetNavigation(); schedule(SCHEDULER_MINTICKS); @@ -2104,6 +2159,10 @@ bool PlayerBotController::discoverDepot(Player& player, const Position& currentP { const auto now = std::chrono::steady_clock::now(); using DepotTelemetry = playerbot::PlayerBotDepotTelemetry; + if (travelDeferred && now < travelRetryAt) { + schedule(blockedRouteRetryInterval); + return false; + } if (!depotDiscoveryTelemetry.active() && depotWorkflow.snapshot().stage == PlayerBotDepotStage::Discover) { depotDiscoveryTelemetry.begin(now, currentPosition, sellLootPlan ? DepotTelemetry::Intent::Liquidation : huntTravelBudgetPhase == HuntTravelBudgetPhase::ReturnToDepot ? DepotTelemetry::Intent::ForcedReturn : @@ -2234,7 +2293,7 @@ bool PlayerBotController::discoverDepot(Player& player, const Position& currentP } if (command.type != PlayerBotDepotCommandType::ValidateRoute) depotSourceRouteSearch.reset(); - std::deque steps; + PlayerBotNavigationRoutePlan selectedRoute; uint32_t routeValidations = 0; uint32_t lastDangerCost = 0; double lastMaximumHealthLossPerSecond = 0; @@ -2343,6 +2402,23 @@ bool PlayerBotController::discoverDepot(Player& player, const Position& currentP huntTravelBudgetPhase == HuntTravelBudgetPhase::ReturnToDepot ? PlayerBotRouteIntent::ForcedReturn : PlayerBotRouteIntent::Optional); } + const auto transientRejection = valid && candidate.approachPosition != currentPosition ? + playerBotNavigationTransientRejection(routePlan, navigationCostPolicy(player), + [&player](const PlayerBotNavigationStep& step) -> uint32_t { + return step.action == PlayerBotNavigationAction::Move ? player.getStepDuration(step.direction) : 1000; + }) : std::nullopt; + if (transientRejection) { + // Leave this candidate pending. Unsafe/Unreachable observations would + // suppress its approach and spend discovery/fixed-goal failure bounds. + deferUnsafeTravel(currentPosition, *transientRejection, "depot_route_threat"); + navigationRuntime.deferPlan(); + depotDiscoveryTelemetry.pending(std::chrono::duration_cast( + std::chrono::steady_clock::now() - startedAt)); + emitDepotDiscovery(DepotTelemetry::Result::Progress, currentPosition, "transient_route_threat"); + approachTravelFrontier(player, PlayerBotNavigationGoal::exact(candidate.approachPosition)); + schedule(blockedRouteRetryInterval); + return false; + } const auto verdict = playerBotNavigationRiskVerdict(riskProfile, routePlan.metrics); const bool routeSafe = candidate.approachPosition == currentPosition || verdict == PlayerBotNavigationRiskVerdict::Accepted; @@ -2398,7 +2474,9 @@ bool PlayerBotController::discoverDepot(Player& player, const Position& currentP depotDiscoveryTelemetry.validated(std::move(routeEvidence), observation.routeResult, routePlan.metrics.expandedNodes, std::chrono::duration_cast(std::chrono::steady_clock::now() - startedAt)); if (reached) { - steps = std::move(routePlan.steps); + resumeSafeTravel(currentPosition); + selectedRoute.metrics = routePlan.metrics; + selectedRoute.steps = std::move(routePlan.steps); } else if (!preferredExit && routePlan.metrics.attempted && routePlan.metrics.result != PlayerBotNavigationResult::Reached) { huntCoordinator.observeTransitMovementFailure(currentPosition); @@ -2430,8 +2508,8 @@ bool PlayerBotController::discoverDepot(Player& player, const Position& currentP return false; } if (command.type == PlayerBotDepotCommandType::Navigate && command.snapshot.hasSelectedDepot) { - if (!steps.empty()) { - observeNavigationPlan(command.snapshot.selected.approachPosition, std::move(steps)); + if (!selectedRoute.steps.empty()) { + observeNavigationPlan(player, PlayerBotNavigationGoal::exact(command.snapshot.selected.approachPosition), std::move(selectedRoute)); } if (routeValidations == 0) return true; // Already selected; do not report another discovery every movement turn. emitDepotDiscovery(DepotTelemetry::Result::Selected, currentPosition); diff --git a/server/src/playerbotservicerouteretry.h b/server/src/playerbotservicerouteretry.h new file mode 100644 index 0000000..f2d8067 --- /dev/null +++ b/server/src/playerbotservicerouteretry.h @@ -0,0 +1,43 @@ +#ifndef FS_PLAYERBOTSERVICEROUTERETRY_H +#define FS_PLAYERBOTSERVICEROUTERETRY_H + +#include "playerbotnavigation.h" + +#include +#include + +// A new frontier is not a new service request. Keep incomplete-search retries +// outside the sliced search so rebuilding it cannot renew the allowance. +class PlayerBotServiceRouteRetry { +public: + enum class Decision { Proceed, TransientThreat, TemporaryExclusion, RetryIncomplete, IncompleteExhausted }; + static constexpr uint32_t MaximumRetries = 3; + static constexpr uint32_t RetryDelayMs = 1000; + + Decision observe(uint32_t provider, Position origin, Position destination, + PlayerBotNavigationResult result, bool transientRejected, bool temporarilyBlocked) + { + const Context next{provider, origin, destination}; + if (!context || *context != next) { + context = next; + retries = 0; + } + if (result != PlayerBotNavigationResult::NodeLimit) retries = 0; + if (transientRejected) return Decision::TransientThreat; + if (result == PlayerBotNavigationResult::Unreachable && temporarilyBlocked) return Decision::TemporaryExclusion; + if (result != PlayerBotNavigationResult::NodeLimit) return Decision::Proceed; + if (retries == MaximumRetries) return Decision::IncompleteExhausted; + ++retries; + return Decision::RetryIncomplete; + } + + void reset() { context.reset(); retries = 0; } + uint32_t retryCount() const { return retries; } + +private: + using Context = std::tuple; + std::optional context; + uint32_t retries = 0; +}; + +#endif diff --git a/server/src/playerbotspells.cpp b/server/src/playerbotspells.cpp index 911e089..85af153 100644 --- a/server/src/playerbotspells.cpp +++ b/server/src/playerbotspells.cpp @@ -914,8 +914,8 @@ void PlayerBotController::beginSpellTraining(Player& player, const Position& pos progressionRuntime.beginSpellTraining(std::move(plan)); const auto& training = progressionRuntime.spellTraining().plan(); resetNavigation(); - if (trainerJourney) navigationRuntime.observePlan({PlayerBotNavigationGoal::anyOf(trainerJourney->destinations), - trainerJourney->plan, player.canDoAction(), true, std::chrono::steady_clock::now()}); + if (trainerJourney) observeNavigationPlan(player, PlayerBotNavigationGoal::anyOf(trainerJourney->destinations), + trainerJourney->plan); emit("strategy_selection", position, "\"goal\":\"learn_spell\",\"npc_id\":" + std::to_string(training.npcId) + ",\"spell\":" + jsonString(training.spellName) + ",\"keyword\":" + jsonString(training.keyword) + ",\"price\":" + @@ -1057,8 +1057,7 @@ void PlayerBotController::processSpellTraining(Player* player, const Position& c return; } trainerJourney->plan = *planned; - navigationRuntime.observePlan({PlayerBotNavigationGoal::anyOf(destinations), std::move(*planned), - player->canDoAction(), true, std::chrono::steady_clock::now()}); + observeNavigationPlan(*player, PlayerBotNavigationGoal::anyOf(destinations), std::move(*planned)); trainerJourneyNeedsRoute = false; } PlayerBotNavigationRuntimeOutcome navigation; diff --git a/server/src/playerbottestpolicy.cpp b/server/src/playerbottestpolicy.cpp index cfa632b..a811cd7 100644 --- a/server/src/playerbottestpolicy.cpp +++ b/server/src/playerbottestpolicy.cpp @@ -61,6 +61,8 @@ const playerbot::PlayerBotTestPolicy& playerbot::playerBotTestPolicyFromEnvironm std::strcmp(gameplayMode, "sell_loot_remote_depot") == 0 || (std::strncmp(gameplayMode, "magic_training", 14) == 0 && std::strcmp(gameplayMode, "magic_training_hunt") != 0))); + const bool raidTravelFixture = gameplayMode && + (std::strcmp(gameplayMode, "raid_travel") == 0 || std::strcmp(gameplayMode, "raid_recovery") == 0); const bool huntAreaArrivalFixture = gameplayMode && std::strcmp(gameplayMode, "hunt_area_arrival") == 0; const bool remoteHuntFixture = gameplayMode && std::strcmp(gameplayMode, "remote_hunt") == 0; const bool depotRiskFallbackFixture = gameplayMode && std::strcmp(gameplayMode, "depot_risk_fallback") == 0; @@ -68,7 +70,7 @@ const playerbot::PlayerBotTestPolicy& playerbot::playerBotTestPolicyFromEnvironm std::strcmp(gameplayMode, "navigation_fare_rejection") == 0 ? NavigationPreflightFixture::Fare : std::strcmp(gameplayMode, "navigation_risk_rejection") == 0 ? NavigationPreflightFixture::Risk : NavigationPreflightFixture::None; - const bool startInHunt = recoveryLoadoutFixture || (gameplayMode && + const bool startInHunt = raidTravelFixture || recoveryLoadoutFixture || (gameplayMode && (std::strcmp(gameplayMode, "navigation") == 0 || std::strcmp(gameplayMode, "navigation_recovery") == 0 || std::strcmp(gameplayMode, "carlin_service_route") == 0 || std::strcmp(gameplayMode, "mutable_portal_route") == 0 || @@ -172,7 +174,7 @@ const playerbot::PlayerBotTestPolicy& playerbot::playerBotTestPolicyFromEnvironm gameplayMode && std::strcmp(gameplayMode, "equipment_buy_provider_unreachable") == 0, adaptiveChallengeFixture, adaptiveChallengeFixture, - gameplayMode && (recoveryLoadoutFixture || std::strcmp(gameplayMode, "mainland_reward") == 0 || + gameplayMode && (raidTravelFixture || recoveryLoadoutFixture || std::strcmp(gameplayMode, "mainland_reward") == 0 || std::strcmp(gameplayMode, "spell_training") == 0 || std::strcmp(gameplayMode, "spell_training_shortlist") == 0 || std::strcmp(gameplayMode, "spell_training_low_supplies") == 0 || @@ -195,6 +197,7 @@ const playerbot::PlayerBotTestPolicy& playerbot::playerBotTestPolicyFromEnvironm navigationPreflightFixture, gameplayMode && std::strcmp(gameplayMode, "hunt_planning") == 0, gameplayMode && std::strcmp(gameplayMode, "ranged_position_control") == 0, + raidTravelFixture, }; }(); return policy; diff --git a/server/src/playerbottestpolicy.h b/server/src/playerbottestpolicy.h index bf26cd9..29820fe 100644 --- a/server/src/playerbottestpolicy.h +++ b/server/src/playerbottestpolicy.h @@ -74,6 +74,7 @@ namespace playerbot { NavigationPreflightFixture navigationPreflightFixture; bool detailAllHuntCandidates; // Planning assertions inspect rejected candidates. bool rangedPositionDisabled; // Control run: ranged weapons fight like melee (#235). + bool raidTravelFixture; }; inline uint32_t playerBotFixtureHuntPlanningDuration(bool mainlandLoopFixture, diff --git a/server/src/playerbottravelsafety.cpp b/server/src/playerbottravelsafety.cpp new file mode 100644 index 0000000..8ab6760 --- /dev/null +++ b/server/src/playerbottravelsafety.cpp @@ -0,0 +1,333 @@ +#include "otpch.h" + +#include "playerbotcontroller.h" +#include "playerbotdangersamplekey.h" +#include "playerbothuntregionadapter.h" +#include "playerbothunttriptiming.h" +#include "playerbotraidhazards.h" +#include "monsters.h" + +extern Monsters g_monsters; + +namespace { + constexpr auto observationInterval = std::chrono::milliseconds(250); + constexpr auto retryInterval = std::chrono::seconds(1); + constexpr auto logInterval = std::chrono::seconds(5); + + uint32_t distance(Position a, Position b) + { + return a.z == b.z ? std::max(Position::getDistanceX(a, b), Position::getDistanceY(a, b)) : UINT32_MAX; + } + + bool visible(const Player& player, Position position) + { + return position.z == player.getPosition().z && player.canSee(position) && + g_game.isSightClear(player.getPosition(), position, true); + } +} + +void PlayerBotController::observeTravelSafety(Player& player) +{ + const auto now = std::chrono::steady_clock::now(); + const auto raidRevision = PlayerBotRaidHazards::instance().revision(); + const Position origin = player.getPosition(); + if (origin == travelObservedPosition && raidRevision == travelObservedRaidRevision && + now - travelObservedAt < observationInterval) return; + travelObservedAt = now; + travelObservedPosition = origin; + travelObservedRaidRevision = raidRevision; + travelSafety.expire(now); + + SpectatorVec spectators; + g_game.map.getSpectators(spectators, origin); + std::vector hostiles; + for (Creature* creature : spectators) { + const Monster* monster = creature->getMonster(); + if (!monster || creature->isRemoved() || !player.canSeeCreature(creature) || + !visible(player, creature->getPosition()) || creature->isDead() || creature->getMaster() || !monster->isHostile()) continue; + hostiles.push_back(creature); + } + // Keep nearest threats if a viewport is unusually crowded. Work/storage do + // not grow with the world's total creature population. + std::sort(hostiles.begin(), hostiles.end(), [&](const auto* a, const auto* b) { + return std::make_pair(distance(origin, a->getPosition()), a->getID()) < + std::make_pair(distance(origin, b->getPosition()), b->getID()); + }); + for (const auto& old : travelSafety.threats(now)) { + if (visible(player, old.position) && std::none_of(hostiles.begin(), hostiles.end(), [&](const auto* creature) { + return creature->getPosition() == old.position; + })) travelSafety.observeEmpty(old.position, now); + } + for (size_t i = 0; i < std::min(hostiles.size(), PlayerBotTravelSafetyMemory::MaxThreats); ++i) { + const auto* creature = hostiles[i]; + travelSafety.observeThreat(creature->getID(), creature->getPosition(), creature->getName(), now); + } + + const auto hazards = PlayerBotRaidHazards::instance().snapshot(); + const bool warningNearby = std::any_of(hazards->begin(), hazards->end(), [&](const auto& hazard) { + return origin.z >= hazard.from.z && origin.z <= hazard.to.z && + int(origin.x) + Map::maxClientViewportX + 1 >= hazard.from.x && + int(origin.x) - Map::maxClientViewportX - 1 <= hazard.to.x && + int(origin.y) + Map::maxClientViewportY + 1 >= hazard.from.y && + int(origin.y) - Map::maxClientViewportY - 1 <= hazard.to.y; + }); + if (!warningNearby) return; + // Only an actual visible tile can contradict an innate warning. There is + // no inspection of hidden occupancy, raid placement success, or removals. + for (int dx = -Map::maxClientViewportX; dx <= Map::maxClientViewportX; ++dx) { + for (int dy = -Map::maxClientViewportY; dy <= Map::maxClientViewportY; ++dy) { + const int x = int(origin.x) + dx, y = int(origin.y) + dy; + if (x < 0 || x > UINT16_MAX || y < 0 || y > UINT16_MAX) continue; + const Position position(x, y, origin.z); + if (!visible(player, position) || !g_game.map.getTile(position)) continue; + bool covered = false, pending = false; + for (const auto& hazard : *hazards) { + if (position.x < hazard.from.x || position.x > hazard.to.x || position.y < hazard.from.y || + position.y > hazard.to.y || position.z < hazard.from.z || position.z > hazard.to.z) continue; + covered = true; + pending = pending || hazard.pending; + } + // Clearance outside a warning changes no danger estimate. Do not + // invalidate route frontiers merely because another tile enters view. + if (!covered || pending) continue; + if (std::any_of(hostiles.begin(), hostiles.end(), [&](const auto* creature) { + return creature->getPosition() == position; + })) continue; + travelSafety.observeClear(position, raidRevision, now); + } + } +} + +std::string PlayerBotController::travelSafetyIdentity() const +{ + const auto now = std::chrono::steady_clock::now(); + travelSafety.expire(now); + const uint64_t raid = PlayerBotRaidHazards::instance().revision(); + if (raid == 0 && travelSafety.threats(now).empty()) return {}; + return std::to_string(playerGuid) + ":" + std::to_string(raid) + ":" + std::to_string(travelSafety.revision()); +} + +std::string PlayerBotController::travelPlanningIdentity() const +{ + return playerBotTravelPlanningIdentity(playerGuid, PlayerBotRaidHazards::instance().revision()); +} + +PlayerBotTravelRouteExposure PlayerBotController::travelRouteExposure(const Player& player, const Position& source, + const PlayerBotNavigationRoutePlan& route, const PlayerBotNavigationCostPolicy& policy) const +{ + Position current = source; + PlayerBotTravelRouteExposure exposure; + exposure.retain(route.steps, policy, [&](const PlayerBotNavigationStep& step) -> uint32_t { + const Tile* tile = g_game.map.getTile(step.expectedPosition); + const Item* ground = tile ? tile->getGround() : nullptr; + const uint32_t movement = playerBotHuntStepMilliseconds(ground ? Item::items[ground->getID()].speed : 150, + static_cast(player).getStepSpeed(), (step.direction & DIRECTION_DIAGONAL_MASK) != 0, + current.z != step.expectedPosition.z); + current = step.expectedPosition; + if (step.action == PlayerBotNavigationAction::NpcTravel) return 1000; + if (step.action == PlayerBotNavigationAction::Move) return movement; + const bool tool = step.action == PlayerBotNavigationAction::UseRope || step.action == PlayerBotNavigationAction::UseShovel; + return playerBotHuntActionMilliseconds(movement, std::max(0, g_config.getNumber(tool ? + ConfigManager::EX_ACTIONS_DELAY_INTERVAL : ConfigManager::ACTIONS_DELAY_INTERVAL))); + }, route.metrics.dangerCost, route.metrics.maximumHealthLossPerSecond); + return exposure; +} + +std::shared_ptr PlayerBotController::travelDangerField(const PlayerBotCombatProfile& combat) const +{ + const auto identity = travelSafetyIdentity(); + if (identity != travelFieldIdentity) { + travelFields.clear(); + travelFieldIdentity = identity; + } + const auto key = PlayerBotDangerSampleKey(0, {}, combat).combat; + if (const auto found = travelFields.find(key); found != travelFields.end()) return found->second; + const auto now = std::chrono::steady_clock::now(); + const auto threats = travelSafety.threats(now); + const auto hazards = PlayerBotRaidHazards::instance().snapshot(); + std::map species; + auto danger = [&](const std::string& name) { + if (const auto found = species.find(name); found != species.end()) return found->second; + const MonsterType* type = g_monsters.getMonsterType(name, false); + // An unknown scripted species is not evidence of zero damage. Known + // harmless species, however, must not manufacture a raid danger. + const double value = !type ? 0.10 : !type->info.isHostile ? 0 : + PlayerBotHuntRegionAdapter::fightEstimate(combat, name, 1).incomingDamagePerSecond / + std::max(combat.maximumHealth, 1); + species.emplace(name, value); + return value; + }; + std::vector rectangles; + for (const auto& hazard : *hazards) { + std::array strongest{}; + for (const auto& monster : hazard.monsters) { + for (uint32_t i = 0; i < std::min(monster.count, strongest.size()); ++i) { + double contribution = danger(monster.name); + for (auto& value : strongest) if (contribution > value) std::swap(contribution, value); + } + } + double total = 0; + for (const double value : strongest) total += value; + rectangles.push_back({hazard.from, hazard.to, total}); + } + std::vector local; + for (const auto& threat : threats) local.push_back({threat.position, danger(threat.species)}); + auto field = std::make_shared(std::move(rectangles), std::move(local), + travelSafety.clearPositions(PlayerBotRaidHazards::instance().revision(), now)); + if (travelFields.size() >= 4) travelFields.clear(); + travelFields.emplace(key, field); + return field; +} + +bool PlayerBotController::travelSafetyRequired(const Player& player) const +{ + if (travelWithdrawing || (travelDeferred && std::chrono::steady_clock::now() < travelRetryAt)) return true; + const auto combat = equipmentPolicy.combatProfile(PlayerBotEquipmentAdapter::player(player), PlayerBotEquipmentAdapter::loadout(player)); + return !player.getTile()->hasFlag(TILESTATE_PROTECTIONZONE) && + travelDangerField(combat)->dangerAt(player.getPosition()) > riskProfile.maximumHealthLossPerSecond; +} + +bool PlayerBotController::travelStepSafe(const Player& player, const PlayerBotNavigationStep& step) const +{ + const Tile* tile = g_game.map.getTile(step.expectedPosition); + if (tile && tile->hasFlag(TILESTATE_PROTECTIONZONE)) return true; + const auto combat = equipmentPolicy.combatProfile(PlayerBotEquipmentAdapter::player(player), PlayerBotEquipmentAdapter::loadout(player)); + return travelDangerField(combat)->dangerAt(step.expectedPosition) <= riskProfile.maximumHealthLossPerSecond; +} + +void PlayerBotController::deferUnsafeTravel(const Position& position, double danger, const char* reason) +{ + const auto now = std::chrono::steady_clock::now(); + if (!travelDeferred || now - travelSafetyLoggedAt >= logInterval) { + emit("travel_safety", position, "\"action\":\"defer\",\"reason\":" + playerbot::jsonString(reason) + + ",\"danger\":" + std::to_string(danger) + ",\"knowledge\":\"raid_and_observation\"" + + ",\"raid_revision\":" + std::to_string(PlayerBotRaidHazards::instance().revision()) + + ",\"observation_revision\":" + std::to_string(travelSafety.revision())); + travelSafetyLoggedAt = now; + } + travelDeferred = true; + travelRetryAt = now + retryInterval; +} + +void PlayerBotController::resumeSafeTravel(const Position& position) +{ + if (!travelDeferred && !travelWithdrawing) return; + emit("travel_safety", position, "\"action\":\"resume\",\"reason\":\"route_revalidated\",\"danger\":0"); + travelDeferred = travelWithdrawing = false; +} + +void PlayerBotController::approachTravelFrontier(Player& player, const PlayerBotNavigationGoal& goal) +{ + // A remote warning can cut the only route even after a raid is cleared. + // Approach only across currently visible, zero-exposure ordinary ground to + // gain local evidence. Never interpret a retry timer as proof of clearance. + if (player.getWalkDelay() > 0 || !player.canDoAction()) return; + const Position origin = player.getPosition(); + const auto cost = navigationCostPolicy(player); + if (cost.dangerAt(origin) != 0) return; + const PlayerBotNavigator navigator; + static constexpr Direction directions[] = {DIRECTION_NORTH, DIRECTION_EAST, DIRECTION_SOUTH, DIRECTION_WEST, + DIRECTION_NORTHEAST, DIRECTION_SOUTHEAST, DIRECTION_SOUTHWEST, DIRECTION_NORTHWEST}; + for (const auto direction : directions) { + const Position target = getNextPosition(direction, origin); + const Tile* tile = g_game.map.getTile(target); + PlayerBotNavigationStep step; + if (goal.distance(target) >= goal.distance(origin) || !visible(player, target) || !tile || tile->getTopCreature() || + tile->hasFlag(TILESTATE_FLOORCHANGE | TILESTATE_TELEPORT) || cost.dangerAt(target) != 0 || + !navigator.resolveMove(player, origin, direction, {}, step) || step.expectedPosition != target) continue; + executeNavigationStep(&player, step); + return; + } +} + +bool PlayerBotController::processTravelSafety(Player* player, const Position& position) +{ + const auto now = std::chrono::steady_clock::now(); + // Safety turns bypass the ordinary spell-verification tail. Retire casts + // here too, or the first heal would block every subsequent healing spell. + verifySpellCast(*player, position); + if (travelEscapeExpected) { + if (position == *travelEscapeExpected || now - travelEscapeStarted >= playerbot::navigationStepTimeout) { + emit("travel_safety_step", position, "\"result\":" + playerbot::jsonString(position == *travelEscapeExpected ? "completed" : "failed") + + ",\"from_x\":" + std::to_string(travelEscapeFrom.x) + ",\"from_y\":" + std::to_string(travelEscapeFrom.y)); + travelEscapeExpected.reset(); + } + } + const auto combat = equipmentPolicy.combatProfile(PlayerBotEquipmentAdapter::player(*player), PlayerBotEquipmentAdapter::loadout(*player)); + const auto field = travelDangerField(combat); + const double danger = player->getTile()->hasFlag(TILESTATE_PROTECTIONZONE) ? 0 : field->dangerAt(position); + const bool unsafe = danger > riskProfile.maximumHealthLossPerSecond; + if (!unsafe && (!travelWithdrawing || danger <= riskProfile.maximumHealthLossPerSecond * 0.35)) { + travelWithdrawing = false; + if (now >= travelRetryAt) return false; + schedule(playerbot::blockedRouteRetryInterval); + return true; + } + if (!travelWithdrawing) { + // Explicit safety interruption, rather than an incidental navigation + // reset. The current goal remains; its route must be revalidated later. + cancelHuntPlanning("travel_threat", position); + interruptTrainerRouteWork(); + if (huntCoordinator.hasDefensiveCombat()) finishDefensiveCombat(player, position, "skipped", "travel_threat"); + if (huntCoordinator.traversalTarget()) finishTraversalCombat(player, position, "travel_threat"); + resetNavigation(); + travelWithdrawing = true; + } + g_game.playerSetAttackedCreature(playerId, 0); + g_game.playerFollowCreature(playerId, 0); + g_game.playerStopAutoWalk(playerId); + deferUnsafeTravel(position, danger, "observed_threat"); + // Defensive actions retain their normal costs and verification, but their + // Wait result must not starve movement while the monster keeps attacking. + handleHealing(player, position); + if (!turnRouter.running() || telemetry.terminalLogged()) return true; + if (player->getWalkDelay() > 0 || travelEscapeExpected) { + schedule(100); + return true; + } + const PlayerBotNavigator navigator; + static constexpr Direction directions[] = {DIRECTION_NORTH, DIRECTION_EAST, DIRECTION_SOUTH, DIRECTION_WEST, + DIRECTION_NORTHEAST, DIRECTION_SOUTHEAST, DIRECTION_SOUTHWEST, DIRECTION_NORTHWEST}; + std::vector candidates; + std::map steps; + const auto threats = travelSafety.threats(now); + auto separation = [&](const Position& candidate) { + uint32_t nearest = UINT32_MAX; + for (const auto& threat : threats) nearest = std::min(nearest, distance(candidate, threat.position)); + return nearest; + }; + const uint32_t currentSeparation = separation(position); + for (const auto direction : directions) { + const Position target = getNextPosition(direction, position); + const Tile* tile = g_game.map.getTile(target); + PlayerBotNavigationStep step; + if (!tile || tile->getTopCreature() || !visible(*player, target) || + !navigator.resolveMove(*player, position, direction, {}, step) || step.expectedPosition != target || + tile->hasFlag(TILESTATE_FLOORCHANGE | TILESTATE_TELEPORT)) continue; + const bool refuge = tile->hasFlag(TILESTATE_PROTECTIONZONE) && !player->isPzLocked(); + // Do not escape a raid by stepping into a worse static spawn hazard. + const double nextDanger = refuge ? 0 : std::max(field->dangerAt(target), PlayerBotHuntRegionAdapter::travelDanger(combat, target)); + // The full-damage band includes adjacent tiles. Permit one step out + // of that plateau only if separation from every nearby threat improves. + const double score = nextDanger == danger && separation(target) > currentSeparation ? nextDanger - 1e-6 : nextDanger; + candidates.push_back({target, score, refuge}); + steps.emplace(target, step); + } + if (const auto target = playerBotChooseTravelWithdrawal(danger, candidates)) { + travelEscapeDispatch = true; + const bool dispatched = executeNavigationStep(player, steps.at(*target)); + travelEscapeDispatch = false; + if (dispatched) { + travelEscapeExpected = *target; + travelEscapeFrom = position; + travelEscapeStarted = now; + emit("travel_safety", position, "\"action\":\"withdraw\",\"reason\":\"lower_exposure\",\"danger\":" + + std::to_string(danger) + ",\"next_danger\":" + std::to_string(field->dangerAt(*target)) + + ",\"x\":" + std::to_string(target->x) + ",\"y\":" + std::to_string(target->y)); + } + } + // A trapped or faster-than-us pursuer may still kill the player. Never + // manufacture a teleport, hidden route, or unbounded escape search. + schedule(100); + return true; +} diff --git a/server/src/playerbottravelsafety.h b/server/src/playerbottravelsafety.h new file mode 100644 index 0000000..ff7e738 --- /dev/null +++ b/server/src/playerbottravelsafety.h @@ -0,0 +1,394 @@ +#ifndef FS_PLAYERBOTTRAVELSAFETY_H +#define FS_PLAYERBOTTRAVELSAFETY_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "position.h" +#include "playerbotnavigation.h" + +// Per-bot evidence, owned outside a controller so recovery need not forget it. +// Only caller-supplied observations change knowledge; there is no world access. +class PlayerBotTravelSafetyMemory { +public: + using Clock = std::chrono::steady_clock; + static constexpr size_t MaxThreats = 64; + static constexpr size_t MaxClearTiles = 512; + static constexpr std::chrono::seconds ThreatLifetime{120}; + static constexpr std::chrono::seconds ClearLifetime{5}; + + struct Threat { + uint32_t id; + Position position; + std::string species; + Clock::time_point seen; + }; + + // Planning-visible changes only; refreshing an unchanged record's lifetime + // does not invalidate caches. Expiry still does. + uint64_t revision() const { return generation; } + + // Reject delayed/out-of-order evidence, including after an explicit expiry. + // Reading snapshots never refreshes timestamps. Expiry changes revision once + // per call, even when it removes several records of both kinds. + void expire(Clock::time_point now) { + if (now < latest) return; + latest = now; + const auto threatsBefore = remembered.size(), clearsBefore = cleared.size(); + remembered.erase(std::remove_if(remembered.begin(), remembered.end(), [&](const auto& threat) { + return !fresh(threat.seen, now, ThreatLifetime); + }), remembered.end()); + cleared.erase(std::remove_if(cleared.begin(), cleared.end(), [&](const auto& tile) { + return !fresh(tile.seen, now, ClearLifetime); + }), cleared.end()); + if (remembered.size() != threatsBefore || cleared.size() != clearsBefore) ++generation; + } + + void observeThreat(uint32_t id, Position position, std::string species, Clock::time_point now) { + if (now < latest) return; + expire(now); + bool changed = false; + auto found = std::find_if(remembered.begin(), remembered.end(), [&](const auto& threat) { + return threat.id == id; + }); + if (found == remembered.end()) { + evictOldest(remembered, MaxThreats); + remembered.push_back({id, position, std::move(species), now}); + changed = true; + } else { + changed = found->position != position || found->species != species; + if (changed) *found = {id, position, std::move(species), now}; + else found->seen = now; + } + const auto before = cleared.size(); + cleared.erase(std::remove_if(cleared.begin(), cleared.end(), [&](const auto& tile) { + return tile.position == position; + }), cleared.end()); + if (changed || cleared.size() != before) ++generation; + } + + // Absence at one observed tile is not death, nor clearance of a region. + void observeEmpty(Position position, Clock::time_point now) { + if (now < latest) return; + expire(now); + const auto before = remembered.size(); + remembered.erase(std::remove_if(remembered.begin(), remembered.end(), [&](const auto& threat) { + return threat.position == position; + }), remembered.end()); + if (remembered.size() != before) ++generation; + } + + std::vector threats(Clock::time_point now) { + expire(now); + std::vector result; + for (const auto& threat : remembered) { + if (fresh(threat.seen, now, ThreatLifetime)) result.push_back(threat); + } + return result; + } + + // Use the current raid registry revision, not a controller-local counter. + // An activation invalidates old clearance without removing any raid warning. + void observeClear(Position position, uint64_t raidRevision, Clock::time_point now) { + if (now < latest) return; + expire(now); + if (std::any_of(remembered.begin(), remembered.end(), [&](const auto& threat) { + return threat.position == position; + })) return; // Caller must actually observe absence before declaring clear. + auto found = std::find_if(cleared.begin(), cleared.end(), [&](const auto& tile) { + return tile.position == position; + }); + if (found == cleared.end()) { + evictOldest(cleared, MaxClearTiles); + cleared.push_back({position, raidRevision, now}); + } else { + if (found->raidRevision == raidRevision) { + found->seen = now; + return; + } + *found = {position, raidRevision, now}; + } + ++generation; + } + + // Const clearance reads filter expired evidence without changing revision. + // Call expire(now) before using revision() as a planning-cache key. + bool clearAt(Position position, uint64_t raidRevision, Clock::time_point now) const { + return std::any_of(cleared.begin(), cleared.end(), [&](const auto& tile) { + return tile.position == position && tile.raidRevision == raidRevision && fresh(tile.seen, now, ClearLifetime); + }); + } + + std::vector clearPositions(uint64_t raidRevision, Clock::time_point now) const { + std::vector result; + for (const auto& tile : cleared) { + if (tile.raidRevision == raidRevision && fresh(tile.seen, now, ClearLifetime)) result.push_back(tile.position); + } + return result; + } + +private: + struct ClearTile { + Position position; + uint64_t raidRevision; + Clock::time_point seen; + }; + + static bool fresh(Clock::time_point seen, Clock::time_point now, std::chrono::seconds lifetime) { + return now >= seen && now - seen < lifetime; + } + + template + static void evictOldest(std::vector& entries, size_t maximum) { + if (entries.size() == maximum) { + entries.erase(std::min_element(entries.begin(), entries.end(), [](const auto& a, const auto& b) { + return a.seen < b.seen; + })); + } + } + + std::vector remembered; + std::vector cleared; + Clock::time_point latest = Clock::time_point::min(); + uint64_t generation = 0; +}; + +// Immutable planning snapshot, in normalized health loss per second (not capped +// at 1). Combine with ordinary static-spawn danger using max, not addition: the +// same monsters may already be represented there. No flee/decision policy here. +class PlayerBotTravelDangerField { +public: + static constexpr size_t MaxRectangles = 256; + static constexpr size_t MaxRectangleBuckets = 64; + static constexpr int BucketSize = 16; + static constexpr int LocalRadius = 6; + static constexpr size_t MaxLocalContributors = 3; + + struct Rectangle { Position from, to; double danger; }; + struct LocalThreat { Position position; double danger; }; + + PlayerBotTravelDangerField(std::vector rectangles, std::vector localThreats, + std::vector clearPositions) : local(std::move(localThreats)) { + for (const auto& position : clearPositions) cleared.insert(key(position.x, position.y, position.z)); + for (auto rectangle : rectangles) { + if (!(rectangle.danger > 0)) continue; + const auto from = rectangle.from, to = rectangle.to; + rectangle.from = {std::min(from.x, to.x), std::min(from.y, to.y), std::min(from.z, to.z)}; + rectangle.to = {std::max(from.x, to.x), std::max(from.y, to.y), std::max(from.z, to.z)}; + if (areas.size() < MaxRectangles) { + areas.push_back(rectangle); + } else { + // Defensive overflow: conservatively merge, never silently drop a + // warning. Normal raid snapshots have at most 256 footprints. + auto& tail = areas.back(); + tail.from = {std::min(tail.from.x, rectangle.from.x), std::min(tail.from.y, rectangle.from.y), + std::min(tail.from.z, rectangle.from.z)}; + tail.to = {std::max(tail.to.x, rectangle.to.x), std::max(tail.to.y, rectangle.to.y), + std::max(tail.to.z, rectangle.to.z)}; + tail.danger = std::max(tail.danger, rectangle.danger); + } + } + for (size_t i = 0; i < areas.size(); ++i) { + const auto& area = areas[i]; + const int x1 = area.from.x / BucketSize, x2 = area.to.x / BucketSize; + const int y1 = area.from.y / BucketSize, y2 = area.to.y / BucketSize; + const size_t count = size_t(x2 - x1 + 1) * (y2 - y1 + 1) * (int(area.to.z) - area.from.z + 1); + if (count > MaxRectangleBuckets) { + largeAreas.push_back(i); + continue; + } + for (int z = area.from.z; z <= area.to.z; ++z) { + for (int y = y1; y <= y2; ++y) { + for (int x = x1; x <= x2; ++x) areaBuckets[key(x, y, z)].push_back(i); + } + } + } + // A threat is inserted once per touched bucket. A sample reads one bucket, + // so even at bucket boundaries each threat contributes at most once. + for (size_t i = 0; i < local.size(); ++i) { + const auto& threat = local[i]; + if (!(threat.danger > 0)) continue; + const int x1 = std::max(0, int(threat.position.x) - LocalRadius) / BucketSize; + const int x2 = std::min(65535, int(threat.position.x) + LocalRadius) / BucketSize; + const int y1 = std::max(0, int(threat.position.y) - LocalRadius) / BucketSize; + const int y2 = std::min(65535, int(threat.position.y) + LocalRadius) / BucketSize; + for (int y = y1; y <= y2; ++y) { + for (int x = x1; x <= x2; ++x) localBuckets[key(x, y, threat.position.z)].push_back(i); + } + } + } + + double dangerAt(Position position) const { + const auto bucket = key(position.x / BucketSize, position.y / BucketSize, position.z); + double footprint = 0; + if (cleared.count(key(position.x, position.y, position.z)) == 0) { + const auto sample = [&](size_t i) { + const auto& area = areas[i]; + if (position.x >= area.from.x && position.x <= area.to.x && + position.y >= area.from.y && position.y <= area.to.y && + position.z >= area.from.z && position.z <= area.to.z) footprint = std::max(footprint, area.danger); + }; + const auto found = areaBuckets.find(bucket); + if (found != areaBuckets.end()) for (const auto i : found->second) sample(i); + for (const auto i : largeAreas) sample(i); + } + std::array strongest{}; + const auto found = localBuckets.find(bucket); + if (found != localBuckets.end()) { + for (const auto i : found->second) { + const auto& threat = local[i]; + const int distance = std::max(std::abs(int(position.x) - threat.position.x), + std::abs(int(position.y) - threat.position.y)); + if (distance >= LocalRadius) continue; + // Same-floor Chebyshev distance matches eight-direction tile travel. + double contribution = threat.danger * (distance <= 1 ? 1.0 : double(LocalRadius - distance) / (LocalRadius - 1)); + for (auto& value : strongest) { + if (contribution > value) std::swap(contribution, value); + } + } + } + for (const auto value : strongest) footprint += value; + return footprint; + } + +private: + static uint64_t key(int x, int y, int z) { + return (uint64_t(z) << 32) | (uint64_t(y) << 16) | uint64_t(x); + } + + std::vector areas; + std::vector local; + std::unordered_set cleared; + std::unordered_map> areaBuckets, localBuckets; + std::vector largeAreas; +}; + +// An observation revision identifies an immutable field, not a planning epoch. +// Only innate raid changes restart a request; selected legs are checked against +// fresh observations before admission/reuse instead of rescanning its frontier. +inline std::string playerBotTravelPlanningIdentity(uint32_t guid, uint64_t raidRevision) { + return std::to_string(guid) + ":" + std::to_string(raidRevision); +} + +// Concrete exposure of the retained executable leg. Keep the accepted total +// (including non-executable paid/coarse tails), and charge any increased local +// exposure against that same cumulative budget. Old exposure is conservative: +// expiry/clearance cannot manufacture more budget for an already accepted trip. +struct PlayerBotTravelRouteExposure { + struct Sample { Position position; uint32_t milliseconds; double danger; }; + std::vector samples; + PlayerBotNavigationRiskProfile risk; + uint32_t dangerCost = 0; + double peak = 0; + + template + void retain(const Steps& steps, const PlayerBotNavigationCostPolicy& policy, Exposure exposure, + uint32_t totalDanger, double maximumPeak) { + risk = policy.risk; + dangerCost = totalDanger; + peak = maximumPeak; + samples.clear(); + uint64_t localDanger = 0; + for (const auto& step : steps) { + const Sample sample{step.expectedPosition, exposure(step), policy.dangerAt(step.expectedPosition)}; + samples.push_back(sample); + localDanger = std::min(UINT32_MAX, localDanger + policy.dangerCostForSample(sample.danger, sample.milliseconds)); + peak = std::max(peak, sample.danger); + } + // Legacy callers may retain only steps. Never install them with zero + // exposure, and never discard the larger validated whole-trip budget. + dangerCost = std::max(dangerCost, static_cast(localDanger)); + } + + PlayerBotNavigationCostSummary refreshed(const PlayerBotTravelDangerField& field) const { + PlayerBotNavigationCostPolicy policy; + policy.risk = risk; + policy.expectedHealthLossPerSecond = [](Position) { return 0.; }; + PlayerBotNavigationCostSummary result; + result.dangerCost = dangerCost; + result.maximumHealthLossPerSecond = peak; + bool increased = false; + for (const auto& sample : samples) { + const double danger = std::max(sample.danger, field.dangerAt(sample.position)); + increased = increased || danger > sample.danger; + const uint32_t added = policy.dangerCostForSample(danger, sample.milliseconds) - + policy.dangerCostForSample(sample.danger, sample.milliseconds); + result.dangerCost = static_cast(std::min(UINT32_MAX, uint64_t(result.dangerCost) + added)); + result.maximumHealthLossPerSecond = std::max(result.maximumHealthLossPerSecond, danger); + } + // Static weighted routes may already exceed tolerance. Do not relabel + // their existing failure as a transient one without increased exposure. + result.transientRiskRejected = increased && !playerBotNavigationRiskAccepts( + risk, result.dangerCost, result.maximumHealthLossPerSecond); + return result; + } + + void prepend(const PlayerBotTravelRouteExposure& connector) { + samples.insert(samples.begin(), connector.samples.begin(), connector.samples.end()); + dangerCost = static_cast(std::min(UINT32_MAX, uint64_t(dangerCost) + connector.dangerCost)); + peak = std::max(peak, connector.peak); + } + + // Ordinary navigation need not react to threats behind the actor. Its + // execution gate still checks every re-resolved landing before dispatch. + void passed(Position position) { + const auto found = std::find_if(samples.begin(), samples.end(), [&](const auto& sample) { + return sample.position == position; + }); + if (found != samples.end()) samples.erase(samples.begin(), std::next(found)); + } +}; + +// Only for an already verified reversible ordinary walk. Reverse the edges, +// not just the sample order: the reverse landing includes the original source. +inline std::deque playerBotTravelReverseWalk( + Position source, const std::deque& steps) { + std::deque reversed; + for (auto step : steps) { + const auto next = step.expectedPosition; + step.target = step.expectedPosition = source; + step.direction = static_cast(step.direction ^ ((step.direction & DIRECTION_DIAGONAL_MASK) ? 3 : 2)); + reversed.push_front(std::move(step)); + source = next; + } + return reversed; +} + +struct PlayerBotTravelWithdrawalCandidate { + Position position; + double danger; + bool refuge = false; +}; + +inline constexpr size_t PlayerBotTravelMaxWithdrawalNeighbours = 8; +inline constexpr double PlayerBotTravelWithdrawalEpsilon = 1e-9; + +// The caller supplies walkable neighbours and owns plateau escape/scoring. +// Only the first eight entries are considered. Require a real danger reduction, +// then prefer refuge, then lowest danger; exact ties retain caller order. +inline std::optional playerBotChooseTravelWithdrawal( + double currentDanger, const std::vector& neighbours) { + const PlayerBotTravelWithdrawalCandidate* best = nullptr; + for (size_t i = 0; i < std::min(neighbours.size(), PlayerBotTravelMaxWithdrawalNeighbours); ++i) { + const auto& candidate = neighbours[i]; + if (!(candidate.danger >= 0) || !(candidate.danger < currentDanger - PlayerBotTravelWithdrawalEpsilon)) continue; + if (!best || (candidate.refuge && !best->refuge) || + (candidate.refuge == best->refuge && candidate.danger < best->danger)) best = &candidate; + } + if (!best) return std::nullopt; + return best->position; +} + +#endif diff --git a/server/src/playerbotturnrouter.h b/server/src/playerbotturnrouter.h index 4e6c129..eac3afc 100644 --- a/server/src/playerbotturnrouter.h +++ b/server/src/playerbotturnrouter.h @@ -25,6 +25,7 @@ enum class PlayerBotControllerLifecycle : uint8_t { enum class PlayerBotTurnCommand : uint8_t { None, + TravelSafety, Progression, TrainerDiscovery, MagicTraining, @@ -50,6 +51,7 @@ struct PlayerBotTurnObservation { bool huntCycleFinished = false; bool preparationActive = false; bool trainerDiscoveryActive = false; + bool travelSafetyRequired = false; }; class PlayerBotTurnRouter @@ -74,6 +76,7 @@ class PlayerBotTurnRouter constexpr PlayerBotTurnCommand route(const PlayerBotTurnObservation& observation) const { if (!running()) return PlayerBotTurnCommand::None; + if (observation.travelSafetyRequired) return PlayerBotTurnCommand::TravelSafety; if (observation.trainerDiscoveryActive) return PlayerBotTurnCommand::TrainerDiscovery; if (observation.progressionActive) return PlayerBotTurnCommand::Progression; if (observation.magicTrainingActive) return PlayerBotTurnCommand::MagicTraining; diff --git a/server/src/raids.cpp b/server/src/raids.cpp index 8c5ffa8..4b74820 100644 --- a/server/src/raids.cpp +++ b/server/src/raids.cpp @@ -155,12 +155,17 @@ void Raids::clear() g_scheduler.stopEvent(checkRaidsEvent); checkRaidsEvent = 0; + // A running non-repeat raid has already been removed from raidList. + if (running) running->stopEvents(); for (Raid* raid : raidList) { raid->stopEvents(); delete raid; } raidList.clear(); + // Clearing/reloading definitions does not remove spawned monsters. Retain + // hazard knowledge independently of the raid objects and their event state. + loaded = false; started = false; running = nullptr; @@ -240,6 +245,12 @@ void Raid::startRaid() { RaidEvent* raidEvent = getNextRaidEvent(); if (raidEvent) { + std::vector footprints; + for (const RaidEvent* event : raidEvents) { + event->appendPlayerBotHazards(footprints); + } + // Local emptiness before any configured wave spawns is not clearance. + PlayerBotRaidHazards::instance().activate(name, footprints, true); state = RAIDSTATE_EXECUTING; nextEventEvent = g_scheduler.addEvent(createSchedulerTask(raidEvent->getDelay(), std::bind(&Raid::executeRaidEvent, this, raidEvent))); } @@ -247,6 +258,21 @@ void Raid::startRaid() void Raid::executeRaidEvent(RaidEvent* raidEvent) { + std::vector footprints; + raidEvent->appendPlayerBotHazards(footprints); + if (!footprints.empty()) { + std::vector allFootprints, remainingFootprints; + for (size_t i = 0; i < raidEvents.size(); ++i) { + raidEvents[i]->appendPlayerBotHazards(allFootprints); + if (i > nextEvent) raidEvents[i]->appendPlayerBotHazards(remainingFootprints); + } + // Earlier waves can survive a weaker later wave. Republish the full + // activation forecast with fresh IDs, then update only pending state. + // Both changes precede spawning on the dispatcher; no controller sees + // the intermediate snapshot. Non-spawn events change nothing. + PlayerBotRaidHazards::instance().activate(name, allFootprints); + PlayerBotRaidHazards::instance().setPending(name, remainingFootprints); + } if (raidEvent->executeEvent()) { nextEvent++; RaidEvent* newRaidEvent = getNextRaidEvent(); @@ -264,6 +290,9 @@ void Raid::executeRaidEvent(RaidEvent* raidEvent) void Raid::resetRaid() { + // Event completion/failure does not remove already spawned monsters. + // Retain warnings, but failed/cancelled future waves no longer block clearance. + PlayerBotRaidHazards::instance().retirePending(name); nextEvent = 0; state = RAIDSTATE_IDLE; g_game.raids.setRunning(nullptr); @@ -272,6 +301,7 @@ void Raid::resetRaid() void Raid::stopEvents() { + PlayerBotRaidHazards::instance().retirePending(name); if (nextEventEvent != 0) { g_scheduler.stopEvent(nextEventEvent); nextEventEvent = 0; @@ -381,6 +411,11 @@ bool SingleSpawnEvent::configureRaidEvent(const pugi::xml_node& eventNode) return true; } +void SingleSpawnEvent::appendPlayerBotHazards(std::vector& footprints) const +{ + footprints.push_back({position, position, {{monsterName, 1}}}); +} + bool SingleSpawnEvent::executeEvent() { Monster* monster = Monster::createMonster(monsterName); @@ -519,6 +554,17 @@ bool AreaSpawnEvent::configureRaidEvent(const pugi::xml_node& eventNode) return true; } +void AreaSpawnEvent::appendPlayerBotHazards(std::vector& footprints) const +{ + PlayerBotRaidFootprint footprint{fromPos, toPos, {}}; + for (const MonsterSpawn& spawn : spawnList) { + // uniform_random accepts reversed bounds. Report the configured upper + // bound, not a random roll or the number of successful placements. + footprint.monsters.push_back({spawn.name, std::max(spawn.minAmount, spawn.maxAmount)}); + } + footprints.push_back(std::move(footprint)); +} + bool AreaSpawnEvent::executeEvent() { for (const MonsterSpawn& spawn : spawnList) { diff --git a/server/src/raids.h b/server/src/raids.h index fd116bf..858daa8 100644 --- a/server/src/raids.h +++ b/server/src/raids.h @@ -22,6 +22,7 @@ #include "const.h" #include "position.h" +#include "playerbotraidhazards.h" #include "baseevents.h" enum RaidState_t { @@ -163,6 +164,8 @@ class RaidEvent virtual bool configureRaidEvent(const pugi::xml_node& eventNode); virtual bool executeEvent() = 0; + // Only statically configured spawns contribute; scripts/announcements do not. + virtual void appendPlayerBotHazards(std::vector&) const {} uint32_t getDelay() const { return delay; } @@ -191,6 +194,7 @@ class SingleSpawnEvent final : public RaidEvent bool configureRaidEvent(const pugi::xml_node& eventNode) override; bool executeEvent() override; + void appendPlayerBotHazards(std::vector& footprints) const override; private: std::string monsterName; @@ -203,6 +207,7 @@ class AreaSpawnEvent final : public RaidEvent bool configureRaidEvent(const pugi::xml_node& eventNode) override; bool executeEvent() override; + void appendPlayerBotHazards(std::vector& footprints) const override; private: std::list spawnList; diff --git a/server/tests/playerbot-gameplay/includes/login.inc b/server/tests/playerbot-gameplay/includes/login.inc index 6f78b9c..898de47 100644 --- a/server/tests/playerbot-gameplay/includes/login.inc +++ b/server/tests/playerbot-gameplay/includes/login.inc @@ -41,6 +41,10 @@ function F.login.onLogin(player) F.setupRecoveryLoadout(player) return true end + if mode == "raid_travel" or mode == "raid_recovery" then + F.setupRaidTravel(player) + return true + end assert(mode == "transit_return" or mode == "mainland" or mode == "carlin_local_service" or mode == "cycle" or mode == "depot" or mode == "depot_risk_fallback" or mode == "slotted_loot_seller" or mode == "slotted_loot_no_seller" or mode == "sell_loot" or mode == "sell_loot_remote_depot" or mode == "navigation" or mode == "navigation_recovery" or mode == "carlin_service_route" or mode == "mutable_portal_route" or mode == "patrol_recovery" or mode == "target_approach" or mode == "target_approach_unreachable" or mode == "target_attacker_priority" or mode == "ranged_position" or mode == "ranged_position_corner" or mode == "ranged_position_control" or mode == "hunt_area_arrival" or mode == "remote_hunt" or mode == "corpse" or mode == "corpse_detour" or mode == "corpse_inaccessible" or mode == "death" or mode == "healing" or mode == "mana_potion" or mode == "spell_use" or mode == "healing_resupply" or mode == "value" or mode == "cargo_currency" or mode == "cargo_partial" or mode == "cargo_nested" or mode == "cargo_protected" or mode == "progression" or mode == "progression_bundle" or diff --git a/server/tests/playerbot-gameplay/includes/raid-travel.inc b/server/tests/playerbot-gameplay/includes/raid-travel.inc new file mode 100644 index 0000000..570236b --- /dev/null +++ b/server/tests/playerbot-gameplay/includes/raid-travel.inc @@ -0,0 +1,187 @@ +local F = PlayerbotGameplayFixture +local mode = os.getenv("PLAYERBOT_GAMEPLAY_MODE") +if mode ~= "raid_travel" and mode ~= "raid_recovery" then return end + +-- Use a unique fixture species: Lua scripts load before XML monsters, so a +-- native name would let later XML loading overwrite this normalization. One +-- guaranteed 40-HP life-drain hit / 2 seconds = 0.108 maxHP/s at level 8 +-- (185 HP), above the 0.08 route ceiling even with the seeded armor/shield. +-- Slow pursuit bounds exposure; combat, movement and raid spawning stay native. +Game.createMonsterType("Raid Safety Wolf"):register({ + description = "a raid safety wolf", experience = 0, outfit = {lookType = 3}, + health = 1000, maxHealth = 1000, speed = 160, race = "blood", corpse = 6009, + flags = {attackable = true, hostile = true, pushable = false, + canPushItems = false, canPushCreatures = false, targetDistance = 1, staticAttackChance = 100}, + attacks = {{name = "combat", type = COMBAT_LIFEDRAIN, interval = 2000, chance = 100, + minDamage = -40, maxDamage = -40, target = true, range = 1}}, + defenses = {armor = 0, defense = 0}, loot = {}, +}) + +local start = Position(60160, 60200, 7) +local spawn = Position(60169, 60200, 7) -- Must match the real raid XML. +local goal = Position(60208, 60200, 7) -- Must match travelSafetyGoal(). +local observation +local guardDuration = 5000 + +local function distance(a, b) + if a.z ~= b.z then return math.huge end + return math.max(math.abs(a.x - b.x), math.abs(a.y - b.y)) +end + +local function evidence(player, phase, extra) + local p = player:getPosition() + print(string.format('{"component":"playerbot","event":"raid_fixture","bot":"Bot One",' .. + '"source":"lua_position_verifier","scenario":"%s","phase":"%s",' .. + '"x":%d,"y":%d,"z":%d,"health":%d,"health_max":%d,"elapsed_ms":%d%s}', + mode, phase, p.x, p.y, p.z, player:getHealth(), player:getMaxHealth(), + os.mtime() - observation.startedAt, extra or "")) +end + +local function check(condition, reason) + if condition then return end + print('PLAYERBOT_GAMEPLAY_TEST RAID_FIXTURE_FAILED ' .. mode .. ' ' .. reason) + error(reason) +end + +F.raidDeath = CreatureEvent("PlayerbotRaidFixtureDeath") +function F.raidDeath.onDeath(player) + if observation and player:getName() == F.botName then + observation.deaths = observation.deaths + 1 + evidence(player, "death", ',"intentional":' .. tostring(observation.injectingDeath == true)) + check(observation.injectingDeath and observation.deaths == 1, + "raid caused an unplanned death") + end + return true +end +F.raidDeath:register() + +function F.setupRaidTravel(player) + check(player:getName() == F.botName, "raid fixture touched another player") + if observation then + -- Re-enter the SAME runway from outside the threat's viewport. Do not + -- reload raids, reset controller/manager memory, move the wolf, or grant + -- levels/health/equipment on recovery. The normal recovery path owns that. + check(mode == "raid_recovery" and observation.injectingDeath and observation.deaths == 1 and + not observation.recovered, "unexpected raid fixture relog") + check(Monster(observation.monsterId), "raid threat disappeared during recovery") + check(player:teleportTo(start), "recovered bot could not enter its runway") + check(player:registerEvent("PlayerbotRaidFixtureDeath"), "recovery death observer registration failed") + observation.playerId = player:getId() + observation.recovered = true + observation.guardStartedAt = os.mtime() + observation.guardPreviousPosition = player:getPosition() + evidence(player, "recovered", ',"target_id":' .. observation.monsterId) + check(player:setStorageValue(F.fixtureReadyStorage, 1), "recovery readiness failed") + return + end + + check(player:getLevel() == 8 and player:getVocation():getId() == 4 and player:getMaxHealth() == 185, + "raid fixture requires the fresh level-eight Knight") + -- A straight open-ground runway leaves only the ordinary backward escape. + -- No alternate lane may bypass the threat before clearance is observed. + for x = 60128, 60216 do + local p = Position(x, start.y, start.z) + check(not Tile(p), "raid runway overlaps mapped terrain") + local tile = Game.createTile(p) + check(tile and Game.createItem(4526, 1, p) and tile:isWalkable(), "raid runway ground failed") + end + check(player:teleportTo(start) and player:setHealth(player:getMaxHealth()), "raid runway entry failed") + player:removeCondition(CONDITION_MANASHIELD) + check(player:registerEvent("PlayerbotRaidFixtureDeath"), "raid death observer registration failed") + observation = {playerId = player:getId(), startedAt = os.mtime(), deaths = 0} + evidence(player, "start", ',"goal_x":' .. goal.x .. ',"goal_y":' .. goal.y .. ',"goal_z":' .. goal.z) + check(player:setStorageValue(F.fixtureReadyStorage, 1), "raid fixture readiness failed") + addEvent(F.measureRaidTravel, 100) +end + +function F.measureRaidTravel() + local o = observation + if not o or o.done then return end + local now = os.mtime() + check(now - o.startedAt < 120000, "raid fixture deadline exceeded") + local player = Player(o.playerId) + if o.injectingDeath and not o.recovered then + -- The dispatcher may still own the dead Player during recovery. Never + -- treat it as an alive sample, even if Player(id) still resolves. + addEvent(F.measureRaidTravel, 100) + return + end + check(player and not player:isRemoved() and player:getHealth() > 0, "raid player disappeared") + local p = player:getPosition() + check(p.z == start.z and p.y == start.y and p.x >= 60128 and p.x <= 60216, + "raid player left the isolated runway") + if not o.activated and p.x >= start.x + 8 then + -- Trigger only after observed ordinary travel, not at login. The engine + -- loads this named raid from fixture XML; Lua never creates the wolf. + o.activated = true + o.activationX, o.activationAt = p.x, now + check(Game.startRaid("Playerbot Travel Safety") == RETURNVALUE_NOERROR, "real raid activation failed") + evidence(player, "activated", ',"spawn_x":' .. spawn.x .. ',"spawn_y":' .. spawn.y .. ',"spawn_z":' .. spawn.z) + end + if o.activated and not o.withdrew then o.peakX = math.max(o.peakX or p.x, p.x) end + if o.activated and not o.monsterId then + local wolves = {} + for _, creature in ipairs(Game.getSpectators(spawn, false, false, 48, 48, 1, 1)) do + if creature:isMonster() and creature:getName() == "Raid Safety Wolf" then wolves[#wolves + 1] = creature end + end + check(#wolves <= 1, "raid fixture spawned more than one wolf") + if #wolves == 1 then + o.monsterId = wolves[1]:getId() + evidence(player, "spawned", ',"target_id":' .. o.monsterId .. ',"count":1') + else + check(now - o.activationAt < 5000, "real raid did not spawn its wolf") + end + end + if o.monsterId and not o.removed then + local wolf = Monster(o.monsterId) + check(wolf and wolf:getHealth() > 0, "raid threat died/disappeared before controlled removal") + if not o.withdrew and o.peakX - p.x >= 2 then + o.withdrew = true + evidence(player, "withdrew", ',"from_x":' .. o.peakX .. ',"target_id":' .. o.monsterId) + if mode == "raid_recovery" then + -- Inject exactly one combat death AFTER observed withdrawal. + -- The wolf is left alive; this does not claim a naturally caused + -- death is safe or acceptable. + o.injectingDeath = true + evidence(player, "kill_injected") + check(doTargetCombatHealth(0, player:getId(), COMBAT_LIFEDRAIN, + -player:getHealth(), -player:getHealth(), CONST_ME_NONE), "intentional combat death failed") + check(player:getHealth() == 0, "intentional combat damage did not kill the bot") + addEvent(F.measureRaidTravel, 100) + return + end + o.guardStartedAt = now + o.guardPreviousPosition = p + end + if o.guardStartedAt then + check(p.x < spawn.x - 1, "bot retried lethal contact while the raid remained alive") + -- Compare both player samples against the SAME current wolf tile. + -- Closing pursuit while a normal walk step is pending is not a + -- forward retry. Safe approach after recovery remains eligible; + -- moving toward the wolf within its unsafe two-tile band does not. + local wolfPosition = wolf:getPosition() + local separation = distance(p, wolfPosition) + check(separation > 2 or separation >= distance(o.guardPreviousPosition, wolfPosition), + "bot moved toward the live threat while unsafe") + o.guardPreviousPosition = p + if now - o.guardStartedAt >= guardDuration then + evidence(player, "guarded", ',"guard_ms":' .. (now - o.guardStartedAt) .. + ',"target_id":' .. o.monsterId) + check(wolf:remove() and not Monster(o.monsterId), "controlled wolf removal failed") + o.removed = true + evidence(player, "removed", ',"target_id":' .. o.monsterId) + end + end + end + if o.removed and distance(p, goal) == 0 then + check(o.withdrew and o.deaths == (mode == "raid_recovery" and 1 or 0), "raid completion lost safety evidence") + o.done = true + evidence(player, "arrived", ',"deaths":' .. o.deaths) + print("PLAYERBOT_GAMEPLAY_TEST " .. mode:upper() .. "_PASS") + -- This fixture then idles without ordinary C++ telemetry. Lua's stdout + -- is block-buffered in Docker; publish the final marker immediately. + io.stdout:flush() + return + end + addEvent(F.measureRaidTravel, 100) +end diff --git a/server/tests/playerbot-gameplay/playerbot_gameplay.lua b/server/tests/playerbot-gameplay/playerbot_gameplay.lua index 6edcffa..be9d204 100644 --- a/server/tests/playerbot-gameplay/playerbot_gameplay.lua +++ b/server/tests/playerbot-gameplay/playerbot_gameplay.lua @@ -7,5 +7,6 @@ dofile("data/scripts/playerbot-gameplay/includes/helpers.inc") dofile("data/scripts/playerbot-gameplay/includes/combat.inc") dofile("data/scripts/playerbot-gameplay/includes/verifiers.inc") dofile("data/scripts/playerbot-gameplay/includes/ranged-position.inc") +dofile("data/scripts/playerbot-gameplay/includes/raid-travel.inc") dofile("data/scripts/playerbot-gameplay/includes/recovery-loadout.inc") dofile("data/scripts/playerbot-gameplay/includes/login.inc") diff --git a/server/tests/playerbot-gameplay/raids/raids.xml b/server/tests/playerbot-gameplay/raids/raids.xml new file mode 100644 index 0000000..9733468 --- /dev/null +++ b/server/tests/playerbot-gameplay/raids/raids.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/server/tests/playerbot-gameplay/raids/travel.xml b/server/tests/playerbot-gameplay/raids/travel.xml new file mode 100644 index 0000000..8a1041e --- /dev/null +++ b/server/tests/playerbot-gameplay/raids/travel.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/server/tests/playerbot_raid_fixture_assertions.ps1 b/server/tests/playerbot_raid_fixture_assertions.ps1 new file mode 100644 index 0000000..decceb0 --- /dev/null +++ b/server/tests/playerbot_raid_fixture_assertions.ps1 @@ -0,0 +1,148 @@ +#Requires -Version 7.0 +# Run from repository root: pwsh -NoProfile -File server/tests/playerbot_raid_fixture_assertions.ps1 +$ErrorActionPreference = 'Stop' +$root = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) +. "$root/scripts/playerbot-gameplay/log-parsing.ps1" +. "$root/scripts/playerbot-gameplay/assertions-raid.ps1" + +function New-RaidEvents([string]$Mode) { + @( + @{event='raid_fixture';phase='start';x=60160;y=60200;z=7;health=185;health_max=185;goal_x=60208;goal_y=60200;goal_z=7;elapsed_ms=0} + @{event='lifecycle';status='online';recovered=$false} + @{event='raid_fixture';phase='activated';x=60168;y=60200;z=7;health=185;spawn_x=60169;spawn_y=60200;spawn_z=7;elapsed_ms=3000} + @{event='travel_safety';action='withdraw';danger=0.108;reason='unsafe_travel'} + @{event='raid_fixture';phase='spawned';x=60168;y=60200;z=7;health=185;target_id=99;count=1;elapsed_ms=4000} + @{event='raid_fixture';phase='withdrew';x=60166;y=60200;z=7;health=185;target_id=99;from_x=60168;elapsed_ms=5000} + if ($Mode -eq 'raid_recovery') { + @{event='raid_fixture';phase='kill_injected';x=60166;y=60200;z=7;health=185;elapsed_ms=5000} + @{event='raid_fixture';phase='death';x=60166;y=60200;z=7;health=0;intentional=$true;elapsed_ms=5000} + @{event='lifecycle';status='dead'} + @{event='terminal';reason='controlled_player_dead'} + @{event='lifecycle';status='recovery_scheduled'} + @{event='raid_fixture';phase='recovered';x=60160;y=60200;z=7;health=175;target_id=99;elapsed_ms=6000} + @{event='lifecycle';status='online';recovered=$true} + @{event='travel_safety';action='defer';danger=0.108;reason='route_unsafe'} + } + @{event='raid_fixture';phase='guarded';x=60162;y=60200;z=7;health=175;target_id=99;guard_ms=5000;elapsed_ms=11000} + @{event='raid_fixture';phase='removed';x=60162;y=60200;z=7;health=175;target_id=99;elapsed_ms=11000} + @{event='travel_safety';action='resume';danger=0;reason='observed_clearance'} + @{event='raid_fixture';phase='arrived';x=60208;y=60200;z=7;health=175;deaths=[int]($Mode -eq 'raid_recovery');elapsed_ms=30000} + ) +} +function Test-RaidEvents([array]$Events, [string]$Mode, [bool]$Reject=$false, [bool]$Pass=$true) { + $logs = ($Events | ForEach-Object { + $_.component='playerbot'; if (-not $_.bot) { $_.bot='Bot One' } + if ($_.event -eq 'raid_fixture') { $_.scenario=$Mode; $_.source='lua_position_verifier' } + $_ | ConvertTo-Json -Compress -Depth 8 + }) -join "`n" + if ($Pass) { $logs += "`nPLAYERBOT_GAMEPLAY_TEST $($Mode.ToUpperInvariant())_PASS" } + try { Assert-RaidTravelEvents -Logs $logs -Mode $Mode } + catch { if ($Reject) { return }; throw } + if ($Reject) { throw 'Incomplete/invalid raid evidence passed.' } +} +foreach ($mode in @('raid_travel','raid_recovery')) { + $events = New-RaidEvents $mode + Test-RaidEvents $events $mode + Test-RaidEvents $events $mode $true $false + # All independent observations and all production safety decisions are needed. + $required = @($events | Where-Object { $_.event -in @('raid_fixture','travel_safety') -or + ($_.event -eq 'lifecycle' -and ($_.status -eq 'dead' -or $_.recovered -eq $true)) }) + foreach ($event in $required) { + Test-RaidEvents @($events | Where-Object { $_ -ne $event }) $mode $true + } + foreach ($field in @('reason','danger')) { + $broken = New-RaidEvents $mode + ($broken | Where-Object { $_.action -eq 'withdraw' }).Remove($field) + Test-RaidEvents $broken $mode $true + } + foreach ($danger in @(0, 0.08)) { + $broken = New-RaidEvents $mode + ($broken | Where-Object { $_.action -eq 'withdraw' }).danger = $danger + Test-RaidEvents $broken $mode $true + } + $broken = New-RaidEvents $mode + ($broken | Where-Object { $_.phase -eq 'withdrew' }).x = 60168 + # Even a production "completed" step cannot replace observed displacement. + Test-RaidEvents @($broken + @{event='travel_safety_step';result='completed'}) $mode $true + $broken = New-RaidEvents $mode + ($broken | Where-Object { $_.phase -eq 'guarded' }).guard_ms = 4999 + Test-RaidEvents $broken $mode $true + $broken = New-RaidEvents $mode + ($broken | Where-Object { $_.phase -eq 'removed' }).target_id = 100 + Test-RaidEvents $broken $mode $true + $broken = New-RaidEvents $mode + ($broken | Where-Object { $_.phase -eq 'arrived' }).elapsed_ms = 10999 + Test-RaidEvents $broken $mode $true + $broken = New-RaidEvents $mode + ($broken | Where-Object { $_.phase -eq 'activated' }).x = 60160 + Test-RaidEvents $broken $mode $true + $broken = New-RaidEvents $mode + ($broken | Where-Object { $_.phase -eq 'spawned' }).count = 2 + Test-RaidEvents $broken $mode $true + Test-RaidEvents @($events + @{event='lifecycle';status='dead'}) $mode $true + Test-RaidEvents @($events + @{event='terminal';reason='navigation_failed'}) $mode $true + Test-RaidEvents @($events + @{event='lifecycle';status='online';bot='Bot Two'}) $mode $true + if ($mode -eq 'raid_recovery') { + $broken = New-RaidEvents $mode + ($broken | Where-Object { $_.phase -eq 'death' }).intentional = $false + Test-RaidEvents $broken $mode $true + $broken = New-RaidEvents $mode + ($broken | Where-Object { $_.phase -eq 'recovered' }).target_id = 100 + Test-RaidEvents $broken $mode $true + } +} + +# Exercise catalog wiring without Docker. The raid-specific mount and speed +# override must not leak on success OR failure; generic runtime owns other envs. +$script:RaidTravel = $true +$script:projectRoot = $root +$script:ran = @() +$script:failWait = $false +$script:waitPattern = $null +$originalCatalog = $env:PLAYERBOT_RAID_CATALOG +$originalSpeed = $env:PLAYERBOT_SPEED_BONUS +function Invoke-Scenario { param($Name, $DefaultTimeoutSeconds, [scriptblock]$Body) + if ($DefaultTimeoutSeconds -ne 180) { throw 'Raid deadline wiring changed.' } + $script:ran += $Name + & $Body +} +function Invoke-Compose { + if ($args[0] -eq 'up' -and ($env:PLAYERBOT_RAID_CATALOG -ne './tests/playerbot-gameplay/raids/raids.xml' -or + $env:PLAYERBOT_SPEED_BONUS -ne '0' -or $env:PLAYERBOT_HUNT_DURATION_SECONDS -ne '900')) { + throw 'Raid scenario did not isolate its catalog/speed/duration.' + } +} +function Wait-ForLog { param($Pattern) + $script:waitPattern = $Pattern + if ($script:failWait) { throw 'deliberate waiter failure' } + return 'stub logs' +} +function Assert-RaidTravelEvents { param($Logs, $Mode) + if ($Mode -ne $env:PLAYERBOT_GAMEPLAY_MODE -or $Logs -ne 'stub logs') { throw 'Wrong raid assertion wiring.' } +} +$restore = @{} +foreach ($name in @('PLAYERBOT_GAMEPLAY_MODE','PLAYERBOT_HUNT_DURATION_SECONDS','PLAYERBOT_RELOG_DELAY_SECONDS','PLAYERBOT_MAX_CONSECUTIVE_DEATHS')) { + $restore[$name] = [Environment]::GetEnvironmentVariable($name) +} +try { + $env:PLAYERBOT_RAID_CATALOG='incoming-catalog'; $env:PLAYERBOT_SPEED_BONUS='123' + . "$root/scripts/playerbot-gameplay/scenarios-raid.ps1" + if (($ran -join ',') -ne 'raid_travel,raid_recovery' -or $env:PLAYERBOT_RAID_CATALOG -ne 'incoming-catalog' -or + $env:PLAYERBOT_SPEED_BONUS -ne '123') { throw 'Raid scenario selection/environment leaked.' } + if ('{"event":"terminal","reason":"controlled_player_dead"}' -match $waitPattern -or + '{"event":"terminal","reason":"navigation_failed"}' -notmatch $waitPattern) { + throw 'Raid waiter mishandles intentional controller replacement.' + } + $script:failWait=$true + $failed=$false + try { . "$root/scripts/playerbot-gameplay/scenarios-raid.ps1" } + catch { if ($_.Exception.Message -ne 'deliberate waiter failure') { throw }; $failed=$true } + if (-not $failed -or $env:PLAYERBOT_RAID_CATALOG -ne 'incoming-catalog' -or $env:PLAYERBOT_SPEED_BONUS -ne '123') { + throw 'Failed raid scenario leaked its environment.' + } +} +finally { + $env:PLAYERBOT_RAID_CATALOG=$originalCatalog; $env:PLAYERBOT_SPEED_BONUS=$originalSpeed + foreach ($name in $restore.Keys) { [Environment]::SetEnvironmentVariable($name, $restore[$name]) } +} +Write-Host 'Playerbot raid fixture assertions and scenario wiring passed.' diff --git a/server/tests/playerbot_raid_fixture_contracts.lua b/server/tests/playerbot_raid_fixture_contracts.lua new file mode 100644 index 0000000..d41d4ae --- /dev/null +++ b/server/tests/playerbot_raid_fixture_contracts.lua @@ -0,0 +1,232 @@ +-- Run from repository root: lua server/tests/playerbot_raid_fixture_contracts.lua +local path = "server/tests/playerbot-gameplay/includes/raid-travel.inc" +local nativeGetenv, nativeMtime, nativePrint = os.getenv, os.mtime, print +local mode, now, queued, output, player, wolf, definition, raidCalls, spawnAt, tiles +os.getenv = function(key) if key == "PLAYERBOT_GAMEPLAY_MODE" then return mode end return nativeGetenv(key) end +os.mtime = function() return now end +Position = function(x, y, z) return {x = x, y = y, z = z} end +COMBAT_LIFEDRAIN, CONDITION_MANASHIELD, CONST_ME_NONE, RETURNVALUE_NOERROR = 1, 2, 3, 0 + +-- Inert outside the exact modes, without any world/monster/event APIs present. +for _, inactive in ipairs({"cycle", "ranged_position", "raid_travel_typo", ""}) do + mode, PlayerbotGameplayFixture = inactive, {} + dofile(path) + assert(next(PlayerbotGameplayFixture) == nil, "inactive raid fixture touched the world") +end + +local events, players = {}, {} +CreatureEvent = function(name) + local event = {} + function event:register() events[name] = self end + return event +end +local function key(p) return p.x .. "," .. p.y .. "," .. p.z end +Tile = function(p) return tiles[key(p)] end +Game = { + createMonsterType = function(name) + assert(name == "Raid Safety Wolf", "fixture must not override a native XML species") + return {register = function(_, value) definition = value end} + end, + createTile = function(p) + local tile = {isWalkable = function(self) return self.ground == 4526 end} + tiles[key(p)] = tile + return tile + end, + createItem = function(id, count, p) + assert(id == 4526 and count == 1) + Tile(p).ground = id + return {} + end, + startRaid = function(name) + assert(name == "Playerbot Travel Safety") + raidCalls = raidCalls + 1 + spawnAt = now + 1000 + return RETURNVALUE_NOERROR + end, + getSpectators = function(p, multifloor, onlyPlayers, west, east, north, south) + assert(p.x == 60169 and p.y == 60200 and p.z == 7 and not multifloor and not onlyPlayers) + assert(west == 48 and east == 48 and north == 1 and south == 1) + return wolf and {wolf} or {} + end, +} +-- Deliberately no Game.createMonster/selectTarget/movement API: only the raid +-- engine boundary may introduce the wolf; only the test moves player samples. +Player = function(id) return players[id] end +Monster = function(id) if wolf and id == wolf.id then return wolf end end +addEvent = function(callback, delay) + assert(delay == 100 and queued == nil, "unbounded/duplicate observation scheduling") + queued = callback +end +print = function(line) output[#output + 1] = line end + +local function makePlayer(id) + local p = {id = id, health = 100, position = Position(10, 10, 7), writes = {}, registrations = {}} + function p:getId() return self.id end + function p:getName() return "Bot One" end + function p:getLevel() return 8 end + function p:getVocation() return {getId = function() return 4 end} end + function p:getMaxHealth() return 185 end + function p:getHealth() return self.health end + function p:setHealth(value) self.health = value; return true end + function p:getPosition() return Position(self.position.x, self.position.y, self.position.z) end + function p:teleportTo(value) self.position = value; self.teleports = (self.teleports or 0) + 1; return true end + function p:isRemoved() return false end + function p:removeCondition(kind) assert(kind == CONDITION_MANASHIELD); return true end + function p:registerEvent(name) assert(events[name]); self.registrations[name] = true; return true end + function p:setStorageValue(storage, value) assert(storage == 50099 and value == 1); self.writes[storage] = value; return true end + players[id] = p + return p +end +local function makeWolf() + wolf = {id = 99, position = Position(60169, 60200, 7)} + function wolf:getId() return self.id end + function wolf:getName() return "Raid Safety Wolf" end + function wolf:isMonster() return true end + function wolf:getPosition() return self.position end + function wolf:getHealth() return 1000 end + function wolf:remove() wolf = nil; return true end +end +local function start(fixtureMode) + mode, now, queued, output, player, wolf, definition, raidCalls, spawnAt, tiles = fixtureMode, 0, nil, {}, nil, nil, nil, 0, nil, {} + events, players = {}, {} + PlayerbotGameplayFixture = {botName = "Bot One", fixtureReadyStorage = 50099} + dofile(path) + player = makePlayer(1) + PlayerbotGameplayFixture.selectHealthPotion = function(p) assert(p:getName() == "Bot One") end + dofile("server/tests/playerbot-gameplay/includes/login.inc") + assert(PlayerbotGameplayFixture.login.onLogin({getName = function() return "Rook Tester" end}), + "raid login touched a human player") + assert(PlayerbotGameplayFixture.login.onLogin(player)) + assert(player.position.x == 60160 and player.position.y == 60200 and player.health == 185 and raidCalls == 0) + assert(player.writes[50099] == 1 and player.teleports == 1) + assert(Tile(Position(60128, 60200, 7)):isWalkable() and Tile(Position(60216, 60200, 7)):isWalkable()) + assert(not Tile(Position(60169, 60199, 7)), "runway permits bypassing the threat") + assert(not Tile(Position(60127, 60200, 7))) + local attack = definition.attacks[1] + assert(definition.maxHealth == 1000 and definition.speed == 160 and definition.flags.hostile) + assert(attack.type == COMBAT_LIFEDRAIN and attack.range == 1 and attack.chance == 100 and attack.interval == 2000) + assert(attack.minDamage == -40 and attack.maxDamage == -40 and 40 / (attack.interval / 1000) / 185 > 0.08) + return PlayerbotGameplayFixture +end +local function tick(time, x) + now = time + if x then player.position = Position(x, 60200, 7) end + if spawnAt and now >= spawnAt and not wolf then makeWolf(); spawnAt = nil end + local callback = assert(queued) + queued = nil + callback() +end +local function has(fragment) + for _, line in ipairs(output) do if line:find(fragment, 1, true) then return true end end + return false +end +local function rejected(callback, fragment) + local ok, err = pcall(callback) + assert(not ok and tostring(err):find(fragment, 1, true), "expected rejection: " .. fragment) + assert(has("RAID_FIXTURE_FAILED"), "fixture failure was not observable by the scenario waiter") +end + +local F = start("raid_travel") +tick(100, 60167) +assert(raidCalls == 0, "raid triggered before ordinary outbound movement") +tick(200, 60168) +assert(raidCalls == 1 and not wolf) +tick(1200, 60168) +assert(wolf and has('"phase":"spawned"') and not has('"phase":"withdrew"')) +-- A request/event alone cannot complete it. Only sampled westward displacement. +tick(1300, 60167) +assert(not has('"phase":"withdrew"')) +tick(1400, 60166) +assert(has('"phase":"withdrew"') and wolf) +-- The pursuer closes during an ordinary step delay. This is not a bot retry. +wolf.position.x = 60167 +player.health = 145 +tick(1500) +tick(1600, 60165) -- Moving away from close pursuit remains legal. +tick(6300) +assert(wolf and not has('"phase":"removed"')) +tick(6400) +assert(not wolf and has('"phase":"guarded"') and has('"phase":"removed"')) +tick(6500, 60208) +assert(has("RAID_TRAVEL_PASS") and has('"deaths":0') and not queued and player.teleports == 1) +F.measureRaidTravel() +assert(not queued, "finished observer scheduled again") + +F = start("raid_recovery") +doTargetCombatHealth = function(attacker, id, kind, minimum, maximum, effect) + assert(attacker == 0 and id == player.id and kind == COMBAT_LIFEDRAIN and minimum == -185 and maximum == -185 and effect == CONST_ME_NONE) + player.health = 0 + assert(events.PlayerbotRaidFixtureDeath.onDeath(player)) + players[player.id] = nil + return true +end +tick(100, 60168) +tick(1100, 60168) +tick(1200, 60166) +assert(has('"phase":"kill_injected"') and has('"intentional":true') and wolf) +tick(1300) +player = makePlayer(2) -- normal engine recovery creates another Player +player.health = 175 +assert(F.login.onLogin(player)) +assert(player.health == 175 and player.teleports == 1 and raidCalls == 1 and wolf.id == 99, + "recovery reset the raid or granted health") +assert(has('"phase":"recovered"')) +tick(1400, 60164) -- Recovery may approach while still outside the unsafe band. +wolf.position.x = 60165 +player.health = 135 +tick(1500) -- Stationary player, closing wolf: do not fail before a walk can finish. +tick(1600, 60163) +tick(6300) +assert(not wolf and has('"phase":"removed"')) +tick(6400, 60208) +assert(has("RAID_RECOVERY_PASS") and has('"deaths":1') and not queued) + +for _, fixtureMode in ipairs({"raid_travel", "raid_recovery"}) do + F = start(fixtureMode) + tick(100, 60168) + tick(1100, 60168) + tick(1200, 60166) + if fixtureMode == "raid_recovery" then + tick(1300) + player = makePlayer(2) + player.health = 175 + assert(F.login.onLogin(player)) + tick(1400, 60164) + end + wolf.position.x = player.position.x + 2 + tick(1500) -- Pursuit alone cannot fail the guard, even at distance two. + rejected(function() tick(1600, player.position.x + 1) end, "moved toward the live threat while unsafe") +end + +F = start("raid_travel") +rejected(function() events.PlayerbotRaidFixtureDeath.onDeath(player) end, "unplanned death") +F = start("raid_travel") +rejected(function() tick(120000) end, "deadline exceeded") +F = start("raid_travel") +tick(100, 60168) +tick(1100, 60168) +tick(1200, 60166) +rejected(function() tick(1300, 60173) end, "retried lethal contact") +F = start("raid_travel") +tick(100, 60168) +tick(1100, 60168) +wolf = nil +rejected(function() tick(1200) end, "threat died/disappeared") +F = start("raid_travel") +rejected(function() tick(100, 10) end, "left the isolated runway") + +-- The real XML and C++ fixture goal must stay aligned with the Lua runway. +local function source(file) + local handle = assert(io.open(file)); local content = handle:read("*a"); handle:close(); return content +end +local raid = source("server/tests/playerbot-gameplay/raids/travel.xml") +assert(raid:find('', 1, true)) +assert(not raid:find("areaspawn", 1, true), "raid fixture introduced spawn RNG") +assert(not source("server/data/monster/monsters.xml"):find('name="Raid Safety Wolf"', 1, true), + "native XML loading could overwrite the unique fixture species") +local catalog = source("server/tests/playerbot-gameplay/raids/raids.xml") +assert(catalog:find('name="Playerbot Travel Safety" file="playerbot-fixture/travel.xml"', 1, true)) +assert(catalog:find('margin="1440"', 1, true), "fixture raid could activate randomly") +assert(source("server/src/playerbotfixturedriver.cpp"):find("if (policy.raidTravelFixture) return Position(60208, 60200, 7);", 1, true)) +os.getenv, os.mtime, print = nativeGetenv, nativeMtime, nativePrint +print("playerbot raid fixture contracts passed") diff --git a/server/tests/playerbotraidhazards_contracts.cpp b/server/tests/playerbotraidhazards_contracts.cpp new file mode 100644 index 0000000..221b871 --- /dev/null +++ b/server/tests/playerbotraidhazards_contracts.cpp @@ -0,0 +1,300 @@ +#include "playerbotraidhazards.h" + +#include +#include +#include +#include +#include +#include + +namespace { +using Records = std::vector; + +const PlayerBotRaidHazard& at(const Records& records, std::string_view raid, const Position& from) { + const auto found = std::find_if(records.begin(), records.end(), [&](const auto& hazard) { + return hazard.raid == raid && hazard.from == from; + }); + assert(found != records.end()); + return *found; +} + +uint32_t count(const PlayerBotRaidHazard& hazard, const std::string& name) { + const auto found = std::find_if(hazard.monsters.begin(), hazard.monsters.end(), [&](const auto& monster) { + return monster.name == name; + }); + assert(found != hazard.monsters.end()); + return found->count; +} + +std::string body(const std::string& source, const std::string& signature) { + const auto signatureStart = source.find(signature); + assert(signatureStart != std::string::npos); + const auto start = source.find('{', signatureStart); + assert(start != std::string::npos); + size_t depth = 1; + for (size_t i = start + 1; i < source.size(); ++i) { + if (source[i] == '{') ++depth; + if (source[i] == '}' && --depth == 0) return source.substr(start + 1, i - start - 1); + } + assert(false && "unterminated function body"); + return {}; +} + +// Scheduling/reset contracts need the engine; check their production call sites +// without linking a second game world into this pure registry executable. +void sourceContracts(const char* filename) { + std::ifstream file(filename); + assert(file.good()); + const std::string source{std::istreambuf_iterator(file), std::istreambuf_iterator()}; + const auto start = body(source, "void Raid::startRaid()"); + const auto collect = start.find("event->appendPlayerBotHazards(footprints)"); + const auto publish = start.find("PlayerBotRaidHazards::instance().activate(name, footprints, true)"); + const auto schedule = start.find("g_scheduler.addEvent"); + assert(start.find("for (const RaidEvent* event : raidEvents)") < collect); + assert(collect < publish && publish < schedule && schedule != std::string::npos); + + const auto reset = body(source, "void Raid::resetRaid()"); + assert(reset.find("PlayerBotRaidHazards::instance().retirePending(name)") != std::string::npos); + assert(reset.find("clear(") == std::string::npos); + const auto stop = body(source, "void Raid::stopEvents()"); + assert(stop.find("PlayerBotRaidHazards::instance().retirePending(name)") != std::string::npos); + const auto clear = body(source, "void Raids::clear()"); + const auto reload = body(source, "bool Raids::reload()"); + assert(clear.find("if (running) running->stopEvents()") != std::string::npos); + assert(clear.find("raid->stopEvents()") < clear.find("delete raid")); + assert(reload.find("PlayerBotRaidHazards") == std::string::npos); + assert(reload.find("clear()") != std::string::npos); + assert(reload.find("loadFromXml()") != std::string::npos); + // No lifecycle path may erase memory of surviving monsters. + assert(source.find("PlayerBotRaidHazards::instance().clear()") == std::string::npos); + + const auto wave = body(source, "void Raid::executeRaidEvent("); + const auto waveCollect = wave.find("raidEvent->appendPlayerBotHazards(footprints)"); + const auto wavePublish = wave.find("PlayerBotRaidHazards::instance().activate(name, allFootprints)"); + const auto pendingPublish = wave.find("PlayerBotRaidHazards::instance().setPending(name, remainingFootprints)"); + const auto execute = wave.find("raidEvent->executeEvent()"); + assert(waveCollect < wavePublish && wavePublish < pendingPublish && pendingPublish < execute); + assert(execute != std::string::npos); + const auto spawnOnly = body(wave, "if (!footprints.empty())"); + assert(spawnOnly.find("activate(name, allFootprints)") != std::string::npos); + assert(spawnOnly.find("setPending(name, remainingFootprints)") != std::string::npos); + assert(spawnOnly.find("for (size_t i = 0; i < raidEvents.size(); ++i)") != std::string::npos); + assert(spawnOnly.find("raidEvents[i]->appendPlayerBotHazards(allFootprints)") != std::string::npos); + assert(spawnOnly.find("if (i > nextEvent) raidEvents[i]->appendPlayerBotHazards(remainingFootprints)") != std::string::npos); + + const auto single = body(source, "void SingleSpawnEvent::appendPlayerBotHazards("); + assert(single.find("{position, position, {{monsterName, 1}}}") != std::string::npos); + const auto area = body(source, "void AreaSpawnEvent::appendPlayerBotHazards("); + assert(area.find("footprint{fromPos, toPos, {}}") != std::string::npos); + assert(area.find("for (const MonsterSpawn& spawn : spawnList)") != std::string::npos); + assert(area.find("{spawn.name, std::max(spawn.minAmount, spawn.maxAmount)}") != std::string::npos); + assert(single.find("g_game") == std::string::npos && area.find("g_game") == std::string::npos); + assert(single.find("createMonster") == std::string::npos && area.find("createMonster") == std::string::npos); +} + +void waveContracts() { + auto& registry = PlayerBotRaidHazards::instance(); + registry.clear(); + const Position point(200, 300, 7), from(210, 310, 7), to(220, 320, 7); + const PlayerBotRaidFootprint single{point, point, {{"Giant Spider", 1}}}; + const PlayerBotRaidFootprint firstWave{from, to, {{"Dragon", 3}}}; + const PlayerBotRaidFootprint secondWave{from, to, {{"Rat", 4}, {"Wolf", 2}}}; + const std::vector forecast{firstWave, secondWave, single}; + const auto assertForecast = [&](const Records& records) { + assert(records.size() == 2); + const auto& area = at(records, "waves", from); + assert(area.monsters.size() == 3); + assert(count(area, "Dragon") == 3 && count(area, "Rat") == 4 && count(area, "Wolf") == 2); + assert(count(at(records, "waves", point), "Giant Spider") == 1); + }; + const auto publishWave = [&](const std::vector& remaining) { + // Same full forecast plus independent pending update as executeRaidEvent. + registry.activate("waves", forecast); + registry.setPending("waves", remaining); + assertForecast(*registry.snapshot()); + }; + registry.activate("waves", forecast, true); + const auto initial = registry.snapshot(); + assertForecast(*initial); + assert(at(*initial, "waves", from).pending && at(*initial, "waves", point).pending); + const auto initialRevision = registry.revision(); + + publishWave({secondWave, single}); + const auto first = registry.snapshot(); + assert(registry.revision() == initialRevision + 2); + assert(at(*first, "waves", from).id > at(*initial, "waves", from).id); + assert(at(*first, "waves", from).pending && at(*first, "waves", point).pending); + + // An announcement/script has no static footprint: source contracts ensure + // the wave publication path is skipped, and an empty activation is a no-op. + const auto beforeAnnouncementRevision = registry.revision(); + registry.activate("waves", {}); + assert(registry.snapshot() == first && registry.revision() == beforeAnnouncementRevision); + + publishWave({single}); // rats spawn later; surviving dragons remain in metadata + const auto second = registry.snapshot(); + assert(registry.revision() == initialRevision + 4); + assert(at(*second, "waves", from).id > at(*first, "waves", from).id); + assert(!at(*second, "waves", from).pending && at(*second, "waves", point).pending); + + publishWave({}); + const auto final = registry.snapshot(); + assert(registry.revision() == initialRevision + 5); + assert(!at(*final, "waves", from).pending && !at(*final, "waves", point).pending); + assert(at(*final, "waves", from).id > at(*second, "waves", from).id); + const auto completedRevision = registry.revision(); + registry.retirePending("waves"); // normal completion needs no further mutation + assert(registry.revision() == completedRevision && registry.snapshot() == final); + assertForecast(*initial); + assert(at(*initial, "waves", from).pending && at(*first, "waves", from).pending); + + // A repeat gets fresh IDs and full counts, not totals accumulated per signal. + registry.activate("waves", forecast, true); + assertForecast(*registry.snapshot()); + assert(registry.revision() == initialRevision + 6); + assert(at(*registry.snapshot(), "waves", from).pending && at(*registry.snapshot(), "waves", point).pending); + assert(at(*registry.snapshot(), "waves", from).id > final->back().id); + registry.clear(); +} + +void cancellationContracts() { + auto& registry = PlayerBotRaidHazards::instance(); + registry.clear(); + const Position from(300, 400, 7), to(310, 410, 7); + const PlayerBotRaidFootprint dragon{from, to, {{"Dragon", 1}}}; + const PlayerBotRaidFootprint rat{to, from, {{"Rat", 5}}}; // same normalized rectangle + registry.activate("cancelled", {dragon, rat}, true); + registry.activate("unrelated", {dragon}, true); + const auto before = registry.snapshot(); + const auto revision = registry.revision(); + const auto cancelledId = at(*before, "cancelled", from).id; + const auto otherId = at(*before, "unrelated", from).id; + + // Cancellation before the first wave (also failure/reset and reload/stop). + registry.retirePending("cancelled"); + const auto cancelled = registry.snapshot(); + assert(cancelled->size() == 2 && registry.revision() == revision + 1); + assert(!at(*cancelled, "cancelled", from).pending); + assert(at(*cancelled, "cancelled", from).id == cancelledId); + assert(count(at(*cancelled, "cancelled", from), "Dragon") == 1); + assert(count(at(*cancelled, "cancelled", from), "Rat") == 5); + assert(at(*cancelled, "unrelated", from).pending && at(*cancelled, "unrelated", from).id == otherId); + assert(at(*before, "cancelled", from).pending); // old snapshot is unchanged + registry.retirePending("cancelled"); + registry.retirePending("absent"); + assert(registry.snapshot() == cancelled && registry.revision() == revision + 1); + + // Restart, then cancel between mixed-species waves; counts survive both. + registry.activate("cancelled", {dragon, rat}); + registry.setPending("cancelled", {rat}); + const auto between = registry.snapshot(); + assert(at(*between, "cancelled", from).pending); + assert(at(*between, "cancelled", from).id > cancelledId); + const auto betweenRevision = registry.revision(); + registry.retirePending("cancelled"); + assert(registry.revision() == betweenRevision + 1); + assert(!at(*registry.snapshot(), "cancelled", from).pending); + assert(count(at(*registry.snapshot(), "cancelled", from), "Dragon") == 1); + assert(count(at(*registry.snapshot(), "cancelled", from), "Rat") == 5); + // Zero-count future spawns do not promise another wave at this footprint. + registry.setPending("cancelled", {{from, to, {{"Rat", 0}}}}); + assert(registry.revision() == betweenRevision + 1); + registry.clear(); +} +} + +int main(int argc, char** argv) { + assert(argc == 2); + static_assert(std::is_same_v>); + auto& registry = PlayerBotRaidHazards::instance(); + assert(®istry == &PlayerBotRaidHazards::instance()); + registry.clear(); + const auto empty = registry.snapshot(); + const auto initialRevision = registry.revision(); + registry.clear(); + registry.activate("script only", {}); + registry.activate("empty", {{{1, 2, 7}, {1, 2, 7}, {{"rat", 0}, {"", 1}}}}); + assert(registry.revision() == initialRevision && registry.snapshot() == empty); + + const Position point(100, 200, 7), from(90, 190, 7), to(110, 210, 8); + registry.activate("wolves", { + {point, point, {{"War Wolf", 1}}}, + {to, from, {{"Wolf", 55}, {"War Wolf", 70}}}, // normalized, multiple floors + {from, to, {{"Wolf", 5}, {"War Wolf", 2}, {"Wolf", 3}}}, + }); + const auto first = registry.snapshot(); + assert(empty->empty()); + assert(registry.revision() == initialRevision + 1 && first->size() == 2); + assert(!at(*first, "wolves", point).pending && !at(*first, "wolves", from).pending); + const auto pointId = at(*first, "wolves", point).id; + const auto areaId = at(*first, "wolves", from).id; + assert(pointId != 0 && areaId > pointId); + assert(at(*first, "wolves", from).to == to); + assert(at(*first, "wolves", from).monsters.size() == 2); + assert(count(at(*first, "wolves", from), "Wolf") == 63); + assert(count(at(*first, "wolves", from), "War Wolf") == 72); + for (int i = 0; i < 1000; ++i) assert(registry.snapshot() == first); + + registry.activate("wolves", {{point, point, {{"Rat", 4}}}}); + const auto second = registry.snapshot(); + assert(registry.revision() == initialRevision + 2 && second->size() == 2); + assert(at(*second, "wolves", point).id > areaId); + assert(at(*second, "wolves", point).monsters.size() == 1); + assert(count(at(*second, "wolves", point), "Rat") == 4); + assert(at(*second, "wolves", from).id == areaId); // unrelated footprint unchanged + assert(at(*first, "wolves", point).id == pointId && count(at(*first, "wolves", point), "War Wolf") == 1); + + registry.activate("other raid", {{point, point, {{"Dragon", 2}}}}); + assert(registry.snapshot()->size() == 3); // same rectangle, different raid + registry.activate("wolves", {{point, to, {{"Wolf", 1}}}}); + assert(registry.snapshot()->size() == 4); // overlapping is not matching + const auto maximum = std::numeric_limits::max(); + registry.activate("saturating", {{point, point, {{"Rat", maximum}, {"Rat", 1}}}}); + assert(count(at(*registry.snapshot(), "saturating", point), "Rat") == maximum); + + const auto beforeClear = registry.snapshot(); + const auto lastId = beforeClear->back().id; + const auto beforeClearRevision = registry.revision(); + registry.clear(); + assert(registry.snapshot()->empty() && registry.revision() == beforeClearRevision + 1); + assert(beforeClear->size() == 5 && first->size() == 2 && second->size() == 2); + registry.activate("after clear", {{point, point, {{"Rat", 1}}}}); + assert(registry.snapshot()->front().id > lastId); + registry.clear(); + + // Oldest-footprint eviction, including more than one capacity in one call. + std::vector many; + for (uint16_t i = 0; i < 300; ++i) many.push_back({{i, 10, 7}, {i, 10, 7}, {{"Rat", 1}}}); + registry.activate("bounded", many, true); + const auto bounded = registry.snapshot(); + assert(std::all_of(bounded->begin(), bounded->end(), [](const auto& hazard) { return hazard.pending; })); + assert(bounded->size() == PlayerBotRaidHazards::MaxFootprints); + assert(bounded->front().from.x == 44 && bounded->back().from.x == 299); + const auto evictId = bounded->front().id; + const auto refreshId = at(*bounded, "bounded", {100, 10, 7}).id; + const auto stableId = at(*bounded, "bounded", {101, 10, 7}).id; + const auto boundedRevision = registry.revision(); + registry.activate("bounded", {{{100, 10, 7}, {100, 10, 7}, {{"Rat", 2}}}}); + assert(registry.snapshot()->size() == PlayerBotRaidHazards::MaxFootprints); + assert(registry.snapshot()->front().id == evictId); // refresh does not evict a neighbor + assert(registry.snapshot()->back().id > refreshId && !registry.snapshot()->back().pending); + assert(at(*registry.snapshot(), "bounded", {101, 10, 7}).id == stableId); + assert(at(*registry.snapshot(), "bounded", {101, 10, 7}).pending); + assert(at(*bounded, "bounded", {100, 10, 7}).pending); + assert(registry.revision() == boundedRevision + 1); + for (int i = 0; i < 300; ++i) { + registry.activate("bounded", {{{100, 10, 7}, {100, 10, 7}, {{"Rat", 2}}}}); + assert(registry.snapshot()->size() == PlayerBotRaidHazards::MaxFootprints); + } + registry.activate("new", {{point, point, {{"Dragon", 1}}}}); + assert(registry.snapshot()->size() == PlayerBotRaidHazards::MaxFootprints); + assert(registry.snapshot()->front().from.x == 45); + assert(bounded->front().id == evictId && count(at(*bounded, "bounded", {100, 10, 7}), "Rat") == 1); + registry.clear(); + + sourceContracts(argv[1]); + waveContracts(); + cancellationContracts(); + std::cout << "playerbot raid hazards contracts passed\n"; +} diff --git a/server/tests/playerbotraidhazards_contracts.sh b/server/tests/playerbotraidhazards_contracts.sh new file mode 100644 index 0000000..3af3c26 --- /dev/null +++ b/server/tests/playerbotraidhazards_contracts.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# Pure registry checks plus source contracts for engine activation/reset hooks. +set -eu +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +binary=$(mktemp) +trap 'rm -f "$binary"' EXIT HUP INT TERM +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -I"$root/src" \ + "$root/tests/playerbotraidhazards_contracts.cpp" -o "$binary" +"$binary" "$root/src/raids.cpp" diff --git a/server/tests/playerbotservicerouteretry_contracts.cpp b/server/tests/playerbotservicerouteretry_contracts.cpp new file mode 100644 index 0000000..4407a15 --- /dev/null +++ b/server/tests/playerbotservicerouteretry_contracts.cpp @@ -0,0 +1,173 @@ +#include "otpch.h" +#include "playerbotservicerouteretry.h" +#include "playerbotserviceworkflow.h" +#include "playerbotnavigationruntime.h" +#include "playerbotnavigationsession.h" + +#include + +namespace { +using Retry = PlayerBotServiceRouteRetry; +using Decision = Retry::Decision; +using Result = PlayerBotNavigationResult; +using Command = PlayerBotServiceCommandType; +using namespace std::chrono_literals; +const Position origin(32352, 32227, 7), destination(32399, 32219, 7), exitTile(32351, 32228, 7); +constexpr uint32_t provider = 7; + +struct Service { + PlayerBotServiceWorkflow workflow; + PlayerBotServiceObservation observation; + PlayerBotEconomyCatalog catalog; + PlayerBotDispositionPolicy disposition; + + Service() { + workflow.reset(PlayerBotServiceIntent::ResupplyWithLocalSale); + workflow.setLiquidationPlan({provider, {{7634, 10, 5, 0}}}); + observation.currentPosition = origin; + observation.shops.push_back({provider, Position(32399, 32222, 7), {{7634, 0, 5, 0}}}); + observation.providers[provider] = {true, false, false, true, {{destination, 1, false}}}; + observation.maximumAttempts = 3; + } + + void pending() { + const auto command = workflow.advance(observation, catalog, disposition); + assert(command.type == Command::ValidateProviderRoute); + assert(command.providerId == provider && command.destination == destination); + assert(workflow.unavailableProviders().empty()); + } + + void reached() { + observation.approachRoute = {provider, destination, PlayerBotServiceRouteResult::Reached, 10}; + assert(workflow.advance(observation, catalog, disposition).outcome == PlayerBotServiceOutcome::Success); + const auto command = workflow.advance(observation, catalog, disposition); + assert(command.type == Command::NavigateProvider && command.destination == destination); + assert(workflow.unavailableProviders().empty()); + } + + void reject() { + observation.approachRoute = {provider, destination, PlayerBotServiceRouteResult::Unreachable}; + const auto command = workflow.advance(observation, catalog, disposition); + assert(command.type == Command::Wait && command.outcome == PlayerBotServiceOutcome::Retry); + assert(workflow.unavailableProviders().count(provider) == 1); + } +}; + +uint32_t exposure(const PlayerBotNavigationStep&) { return 1000; } + +void transientDeferrals() +{ + Service service; + Retry retry; + PlayerBotNavigationCostPolicy policy; + policy.transientHealthLossPerSecond = [](Position) { return 0.4; }; + PlayerBotNavigationRoutePlan rejected; + rejected.metrics.result = Result::RiskRejected; + rejected.metrics.transientRiskRejected = true; + rejected.metrics.maximumHealthLossPerSecond = 0.4; + for (int turn = 0; turn < 60; ++turn) { + service.pending(); + const auto threat = playerBotNavigationTransientRejection(rejected, policy, exposure); + assert(threat == 0.4); + assert(retry.observe(provider, origin, destination, rejected.metrics.result, threat.has_value(), false) == + Decision::TransientThreat); + assert(retry.retryCount() == 0); + // Production deferral leaves the route observation absent, not Unreachable. + } + PlayerBotNavigationRoutePlan safe; + safe.metrics.result = Result::Reached; + assert(!playerBotNavigationTransientRejection(safe, policy, exposure)); + assert(retry.observe(provider, origin, destination, Result::Reached, false, false) == Decision::Proceed); + service.reached(); + // Revalidation of an already-selected approach must preserve it too. + for (int turn = 0; turn < 30; ++turn) { + assert(retry.observe(provider, origin, destination, Result::RiskRejected, true, false) == Decision::TransientThreat); + const auto command = service.workflow.advance(service.observation, service.catalog, service.disposition); + assert(command.type == Command::NavigateProvider && command.destination == destination); + assert(service.workflow.unavailableProviders().empty()); + } + + // A fresh preparation policy must retain zero-risk acceptance even for + // below-normal-ceiling exposure on an otherwise accepted route. + PlayerBotNavigationStep step; + step.target = step.expectedPosition = destination; + safe.steps.push_back(step); + policy.transientHealthLossPerSecond = [](Position) { return 0.001; }; + assert(!playerBotNavigationTransientRejection(safe, policy, exposure)); + policy.risk.maximumHealthLossPerSecond = policy.risk.maximumRouteHealthLoss = 0; + assert(playerBotNavigationTransientRejection(safe, policy, exposure) == 0.001); + + // An unrelated warning cannot turn a static failure into a transient wait. + rejected.metrics.transientRiskRejected = false; + assert(!playerBotNavigationTransientRejection(rejected, policy, exposure)); + assert(retry.observe(provider, origin, destination, Result::RiskRejected, false, false) == Decision::Proceed); +} + +void blockedExitExpires() +{ + Service service; + Retry retry; + PlayerBotNavigationSession navigation; + const auto epoch = std::chrono::steady_clock::time_point{} + 100s; + navigation.suppress(exitTile, epoch + 30s); + for (int second = 0; second < 30; ++second) { + service.pending(); + const auto blocked = navigation.activeBlockedPositions(epoch + std::chrono::seconds(second)); + assert(blocked.count(exitTile) == 1); + assert(retry.observe(provider, origin, destination, Result::Unreachable, false, !blocked.empty()) == + Decision::TemporaryExclusion); + } + assert(navigation.activeBlockedPositions(epoch + 30s).empty()); + assert(retry.observe(provider, origin, destination, Result::Reached, false, false) == Decision::Proceed); + service.reached(); + + // Expiry does not convert a genuinely disconnected route into success. + Service disconnected; + disconnected.pending(); + assert(retry.observe(provider, origin, destination, Result::Unreachable, false, false) == Decision::Proceed); + disconnected.reject(); +} + +void incompleteIsBounded() +{ + Service service; + Retry retry; + service.pending(); + for (uint32_t attempt = 1; attempt <= Retry::MaximumRetries; ++attempt) { + assert(retry.observe(provider, origin, destination, Result::NodeLimit, false, false) == Decision::RetryIncomplete); + assert(retry.retryCount() == attempt); + service.pending(); // A freshly rebuilt frontier does not renew retries. + } + assert(retry.observe(provider, origin, destination, Result::NodeLimit, false, false) == Decision::IncompleteExhausted); + assert(retry.retryCount() == 3 && service.workflow.unavailableProviders().empty()); + service.pending(); // Exhaustion stops the controller, not the provider workflow. + assert(retry.observe(provider, origin, destination, Result::NodeLimit, false, false) == Decision::IncompleteExhausted); + + // All three identity fields renew the allowance; no frontier ID is used. + for (const auto& context : { + std::make_tuple(provider + 1, origin, destination), + std::make_tuple(provider + 1, exitTile, destination), + std::make_tuple(provider + 1, exitTile, origin)}) { + const auto [npc, from, to] = context; + assert(retry.observe(npc, from, to, Result::NodeLimit, false, false) == Decision::RetryIncomplete); + assert(retry.retryCount() == 1); + } + for (const auto completed : {Result::Reached, Result::Unreachable, Result::RiskRejected}) { + assert(retry.observe(provider, origin, destination, completed, false, false) == Decision::Proceed); + assert(retry.retryCount() == 0); + assert(retry.observe(provider, origin, destination, Result::NodeLimit, false, false) == Decision::RetryIncomplete); + assert(retry.retryCount() == 1); + } + retry.reset(); + assert(retry.retryCount() == 0); + assert(Retry::RetryDelayMs >= 500 && Retry::RetryDelayMs <= 1000); +} +} + +int main() +{ + transientDeferrals(); + blockedExitExpires(); + incompleteIsBounded(); + std::cout << "service route retry contracts passed\n"; +} diff --git a/server/tests/playerbotservicerouteretry_contracts.sh b/server/tests/playerbotservicerouteretry_contracts.sh new file mode 100644 index 0000000..256ba9c --- /dev/null +++ b/server/tests/playerbotservicerouteretry_contracts.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# Service retry decisions with the real provider workflow and exclusion TTLs. +set -eu +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +build=$(mktemp -d) +trap 'rm -rf "$build"' EXIT HUP INT TERM +"${CXX:-c++}" ${CPPFLAGS:-} ${CXXFLAGS:-} -std=c++17 -Wall -Wextra -Werror \ + -Wno-error=reorder -Wno-missing-field-initializers -ffunction-sections -fdata-sections -I"$root/src" \ + "$root/tests/playerbotservicerouteretry_contracts.cpp" "$root/src/playerbotserviceworkflow.cpp" \ + "$root/src/playerbotnpcsession.cpp" "$root/src/playerbotservicesession.cpp" "$root/src/playerboteconomy.cpp" \ + "$root/src/playerbotnavigationsession.cpp" \ + ${LDFLAGS:-} -Wl,--gc-sections -o "$build/service-route-retry" +"$build/service-route-retry" diff --git a/server/tests/playerbottransientnavigation_contracts.cpp b/server/tests/playerbottransientnavigation_contracts.cpp new file mode 100644 index 0000000..a26672b --- /dev/null +++ b/server/tests/playerbottransientnavigation_contracts.cpp @@ -0,0 +1,297 @@ +#include "playerbotnavigationruntime.h" +#include "playerbotdepotworkflow.h" +#include "playerbotpathsearch.h" +#include "playerbottravelsafety.h" + +#include +#include +#include + +namespace { +using namespace std::chrono_literals; +using Result = PlayerBotNavigationResult; +const Position source(100, 100, 7), prefix(101, 100, 7), crossing(102, 100, 7), depot(103, 100, 7); +const auto epoch = std::chrono::steady_clock::time_point{} + 100s; + +PlayerBotNavigationStep move(Position target) +{ + PlayerBotNavigationStep step; + step.action = PlayerBotNavigationAction::Move; + step.direction = DIRECTION_EAST; + step.target = step.expectedPosition = target; + return step; +} + +uint32_t exposure(const PlayerBotNavigationStep&) { return 1000; } + +PlayerBotNavigationCostPolicy policy(double threat, double baseline = 0) +{ + PlayerBotNavigationCostPolicy value; + value.transientHealthLossPerSecond = [threat](Position p) { return p == crossing ? threat : 0; }; + value.expectedHealthLossPerSecond = [threat, baseline](Position p) { + return std::max(baseline, p == crossing ? threat : 0); + }; + return value; +} + +// The production search kernel and rejection selectors over a tiny world graph. +// Only tile inspection is synthetic; the risk policy and frontier are real. +PlayerBotNavigationRoutePlan search(const PlayerBotNavigationCostPolicy& cost, bool detour = false, + uint64_t allowance = 20) +{ + const auto goal = PlayerBotNavigationGoal::exact(depot); + PlayerBotPathSearch frontier(source, goal, allowance); + frontier.constrain(cost.risk); + if (!frontier.rejectUnsafeTransientEndpoints(cost)) { + frontier.rejectUnsafeEndpoints([&](Position p) { return cost.dangerAt(p); }); + } + auto expand = [&](Position from) { + std::vector arcs; + auto add = [&](Position to) { + if (frontier.rejectTransientArc(cost.transientHealthLossPerSecond(to), + cost.risk.maximumHealthLossPerSecond)) return; + arcs.push_back({move(to), 10, cost.dangerCost(to, 1000), cost.dangerAt(to)}); + }; + if (from == source) add(prefix); + if (from == prefix) { + add(crossing); + if (detour) add(depot); + } + if (from == crossing) add(depot); + return arcs; + }; + while (!frontier.advance(1, expand, [&](Position p) { return p == depot; }, + [&](Position p) { return playerBotNavigationDistance(p, depot); })) {} + PlayerBotNavigationRoutePlan result; + result.metrics.attempted = true; + result.metrics.result = *frontier.result; + result.metrics.dangerCost = frontier.summary.dangerCost; + result.metrics.maximumHealthLossPerSecond = frontier.summary.maximumHealthLossPerSecond; + result.metrics.transientRiskRejected = frontier.summary.transientRiskRejected; + result.steps = std::move(frontier.steps); + return result; +} + +void explicitSearchEvidence() +{ + const auto hazard = policy(0.4); + const auto rejected = search(hazard); + assert(rejected.metrics.result == Result::RiskRejected); + assert(rejected.metrics.transientRiskRejected && rejected.steps.empty()); + assert(rejected.metrics.maximumHealthLossPerSecond == 0.4); + assert(playerBotNavigationTransientRejection(rejected, hazard, exposure) == 0.4); + + // Seeing rejected arcs does not taint a safe route that was actually found. + const auto detour = search(hazard, true); + assert(detour.metrics.result == Result::Reached && !detour.metrics.transientRiskRejected); + assert(!playerBotNavigationTransientRejection(detour, hazard, exposure)); + + // A partial search is still incomplete, not a proven transient rejection. + const auto limited = search(hazard, true, 2); + assert(limited.metrics.result == Result::NodeLimit && !limited.metrics.transientRiskRejected); + assert(!playerBotNavigationTransientRejection(limited, hazard, exposure)); + + // Static zero-risk rejection must not defer just because another tile has + // a raid warning or this bot remembers a harmless rat. + auto staticRisk = policy(0.001, 0.4); + staticRisk.risk.maximumRouteHealthLoss = 0; + staticRisk.risk.maximumHealthLossPerSecond = 0; + const auto staticRejected = search(staticRisk); + assert(staticRejected.metrics.result == Result::RiskRejected); + assert(!staticRejected.metrics.transientRiskRejected); + assert(!playerBotNavigationTransientRejection(staticRejected, staticRisk, exposure)); + + const auto weightedStatic = search(policy(0.001, 0.4)); + assert(weightedStatic.metrics.result == Result::Reached); + assert(playerBotNavigationRiskVerdict(policy(0).risk, weightedStatic.metrics) == + PlayerBotNavigationRiskVerdict::Rejected); + assert(!playerBotNavigationTransientRejection(weightedStatic, policy(0.001, 0.4), exposure)); + + PlayerBotPathSearch endpoint(source, PlayerBotNavigationGoal::exact(crossing), 20); + assert(endpoint.rejectUnsafeTransientEndpoints(hazard)); + assert(endpoint.result == Result::RiskRejected && endpoint.summary.transientRiskRejected); + PlayerBotPathSearch alternatives(source, PlayerBotNavigationGoal::anyOf({crossing, depot}), 20); + assert(!alternatives.rejectUnsafeTransientEndpoints(hazard)); + PlayerBotPathSearch alreadyThere(crossing, PlayerBotNavigationGoal::exact(crossing), 20); + assert(!alreadyThere.rejectUnsafeTransientEndpoints(hazard)); +} + +void acceptedPrefixAndResolvedPortal() +{ + PlayerBotNavigationRoutePlan partial; + partial.metrics.result = Result::Reached; + partial.metrics.dangerEvidence = PlayerBotNavigationDangerEvidence::Coarse; + partial.steps.push_back(move(prefix)); + auto portal = move(crossing); + portal.action = PlayerBotNavigationAction::UseRope; + portal.topologyPortal = true; + partial.steps.push_back(portal); + // The detailed local prefix is safe, but the appended crossing is not. + assert(playerBotNavigationLocalRiskVerdict(policy(0).risk, partial.metrics) == + PlayerBotNavigationRiskVerdict::Accepted); + assert(playerBotNavigationTransientRejection(partial, policy(0.4), exposure) == 0.4); + assert(!playerBotNavigationTransientRejection(partial, policy(0.001), exposure)); + + // Re-resolving an initially safe portal can reveal a different unsafe + // landing. The dispatch selector runs before static-risk failure accounting. + portal.expectedPosition = depot; + assert(!playerBotNavigationTransientRouteRisk(policy(0.4), std::array{portal}, exposure).transientRiskRejected); + portal.expectedPosition = crossing; + assert(playerBotNavigationTransientRouteRisk(policy(0.4), std::array{portal}, exposure).transientRiskRejected); + + // Below-peak transient exposure can still exhaust the route-loss budget. + const auto cumulative = playerBotNavigationTransientRouteRisk(policy(0.06), std::array{portal}, + [](const PlayerBotNavigationStep&) { return 10000; }); + assert(cumulative.transientRiskRejected && cumulative.maximumHealthLossPerSecond == 0.06); + assert(cumulative.dangerCost == 600); +} + +void installedRetainedExposure() +{ + PlayerBotNavigationRuntime runtime; + const auto goal = PlayerBotNavigationGoal::exact(depot); + PlayerBotTravelRouteExposure retained; + const auto baseline = policy(0); + // Replace an unrelated prior proof, retaining the validated whole-trip + // budget even when only its current executable leg is installed. + retained.retain(std::array{move({500, 500, 7})}, baseline, exposure, 0, 0); + auto route = search(baseline); + route.metrics.dangerCost = 400; + retained.retain(route.steps, baseline, exposure, route.metrics.dangerCost, route.metrics.maximumHealthLossPerSecond); + const auto installed = runtime.observePlan({goal, route, true, true, epoch}); + assert(installed.plan.dangerCost == 400 && runtime.routeSize() == route.steps.size()); + assert(retained.samples.size() == route.steps.size() && retained.samples.front().position == prefix); + PlayerBotTravelDangerField unrelated({}, {{{500, 500, 7}, 4}}, {}); + assert(!retained.refreshed(unrelated).transientRiskRejected); + assert(runtime.routeSize() == route.steps.size()); + PlayerBotTravelDangerField changed({{prefix, depot, 0.06}}, {}, {}); + const auto fresh = retained.refreshed(changed); + assert(fresh.maximumHealthLossPerSecond == 0.06 && fresh.dangerCost == 580); + assert(fresh.transientRiskRejected); + runtime.deferPlan(); + assert(runtime.routeSize() == 0 && runtime.fixedTargetRouteFailureCount() == 0); +} + +PlayerBotDepotCommand scan(PlayerBotDepotWorkflow& workflow) +{ + PlayerBotDepotObservation observed; + observed.currentPosition = source; + observed.now = epoch; + assert(workflow.advance(observed, 2, 3, 30s).type == PlayerBotDepotCommandType::Scan); + observed.scan.observed = true; + observed.scan.indexedCandidates = observed.scan.inScopeCandidates = observed.scan.standableCandidates = 1; + observed.scan.candidates.push_back({1, 2589, Position(104, 100, 7), depot, 3}); + return workflow.advance(observed, 2, 3, 30s); +} + +void depotAndFailureBounds() +{ + PlayerBotDepotWorkflow workflow; + const auto initial = scan(workflow); + assert(initial.type == PlayerBotDepotCommandType::ValidateRoute); + PlayerBotNavigationRuntime runtime; + const auto goal = PlayerBotNavigationGoal::exact(depot); + runtime.process({source, goal, false, true, {epoch, 1s, 30s, 30s}}); + PlayerBotNavigationRoutePlan staticFailure; + staticFailure.metrics.result = Result::RiskRejected; + for (int i = 0; i < 3; ++i) runtime.observePlan({goal, staticFailure, true, false, epoch}); + assert(runtime.fixedTargetRouteFailureCount() == 3); + const auto installed = search(policy(0)); + runtime.observePlan({goal, installed, true, false, epoch}); + assert(runtime.routeSize() != 0 && runtime.fixedTargetRouteFailureCount() == 3); + + const auto hazard = policy(0.4); + const auto rejected = search(hazard); + for (int turn = 0; turn < 60; ++turn) { + assert(playerBotNavigationTransientRejection(rejected, hazard, exposure)); + // The production callers leave the route observation absent on defer. + // Neither workflow unavailability nor fixed-target exhaustion advances. + runtime.deferPlan(); + assert(runtime.routeSize() == 0); + const auto retry = runtime.process({source, goal, false, true, + {epoch + std::chrono::seconds(turn), 1s, 30s, 30s}}); + assert(retry.routeRequest && retry.fixedTargetRouteFailures == 3 && !retry.fixedTargetRouteExhausted); + PlayerBotDepotObservation observed; + observed.currentPosition = source; + observed.now = epoch + std::chrono::seconds(turn); + const auto pending = workflow.advance(observed, 2, 3, 30s); + assert(pending.type == PlayerBotDepotCommandType::ValidateRoute); + assert(pending.snapshot.routeCandidate.approachPosition == depot); + assert(pending.snapshot.attempts == initial.snapshot.attempts); + assert(pending.snapshot.candidateOffset == initial.snapshot.candidateOffset); + assert(pending.snapshot.suppressedApproaches == 0 && pending.snapshot.unsafeRouteCandidates == 0); + assert(runtime.fixedTargetRouteFailureCount() == 3); + } + const auto safe = search(policy(0)); + assert(safe.metrics.result == Result::Reached); + assert(!playerBotNavigationTransientRejection(safe, policy(0), exposure)); + PlayerBotDepotObservation observed; + observed.currentPosition = source; + observed.now = epoch + 60s; + observed.routeResult = PlayerBotDepotRouteResult::Reached; + assert(workflow.advance(observed, 2, 3, 30s).type == PlayerBotDepotCommandType::Navigate); + + // Static failures still exhaust the original fixed-target bound. + for (int i = 3; i < 20; ++i) { + assert(!playerBotNavigationTransientRejection(staticFailure, hazard, exposure)); + const auto failed = runtime.observePlan({goal, staticFailure, true, false, epoch}); + assert(failed.fixedTargetRouteExhausted == (i == 19)); + } + + // Post-plan/step rejection still has the same terminal bound when its + // cause is static. Transient step gates must run before this call. + PlayerBotNavigationRuntime stepRuntime; + stepRuntime.process({source, goal, false, true, {epoch, 1s, 30s, 30s}}); + for (int i = 0; i < 20; ++i) { + stepRuntime.observePlan({goal, installed, true, false, epoch}); + const auto failed = stepRuntime.rejectAcceptedPlan(); + assert(failed.fixedTargetRouteExhausted == (i == 19)); + } + + // Preserve the existing forced-return fallback for static danger. A raid + // elsewhere must not turn this fallback into a wait. + PlayerBotDepotWorkflow forced; + assert(scan(forced).type == PlayerBotDepotCommandType::ValidateRoute); + observed.routeResult = PlayerBotDepotRouteResult::Unsafe; + observed.dangerCost = 800; + observed.maximumHealthLossPerSecond = 0.4; + const auto fallback = forced.advance(observed, 2, 3, 30s); + assert(fallback.type == PlayerBotDepotCommandType::ValidateRoute && fallback.snapshot.validatingRiskFallback); + assert(playerBotDepotRouteSafetyAccepted(false, false, PlayerBotRouteIntent::ForcedReturn)); + assert(!playerBotDepotRouteSafetyAccepted(false, true, PlayerBotRouteIntent::ForcedReturn)); + assert(!playerBotDepotRouteSafetyAccepted(false, false, PlayerBotRouteIntent::Optional)); + observed.routeResult = PlayerBotDepotRouteResult::Reached; + const auto selected = forced.advance(observed, 2, 3, 30s); + assert(selected.type == PlayerBotDepotCommandType::Navigate && selected.telemetry.riskFallback); +} +} + +int main() +{ + // Individually acceptable, disjoint exposure still shares one route budget. + auto mixed = policy(0.02); + mixed.staticHealthLossPerSecond = [](Position p) { return p == prefix ? 0.04 : 0.; }; + mixed.expectedHealthLossPerSecond = [](Position p) { return p == prefix ? 0.04 : p == crossing ? 0.02 : 0.; }; + PlayerBotNavigationRoutePlan frozen; + frozen.metrics.result = Result::Reached; + frozen.metrics.dangerCost = 600; + frozen.steps = {move(prefix), move(crossing)}; + const auto longStep = [](const PlayerBotNavigationStep&) { return 10000u; }; + const auto combined = playerBotNavigationTransientRouteRisk(mixed, frozen.steps, longStep); + assert(combined.transientRiskRejected && combined.dangerCost == 600); + assert(combined.maximumHealthLossPerSecond == 0.04); + assert(playerBotNavigationTransientRejection(frozen, mixed, longStep) == 0.04); + frozen.metrics.result = Result::RiskRejected; + assert(playerBotNavigationTransientRejection(frozen, mixed, longStep) == 0.04); + mixed.transientHealthLossPerSecond = [](Position) { return 0.; }; + assert(!playerBotNavigationTransientRejection(frozen, mixed, longStep)); + // A route already unsafe from static exposure keeps the normal failure path. + mixed.staticHealthLossPerSecond = [](Position p) { return p == prefix ? 0.06 : 0.; }; + mixed.transientHealthLossPerSecond = [](Position p) { return p == crossing ? 0.02 : 0.; }; + assert(!playerBotNavigationTransientRejection(frozen, mixed, longStep)); + explicitSearchEvidence(); + acceptedPrefixAndResolvedPortal(); + installedRetainedExposure(); + depotAndFailureBounds(); + std::cout << "playerbot transient navigation contracts passed\n"; +} diff --git a/server/tests/playerbottransientnavigation_contracts.sh b/server/tests/playerbottransientnavigation_contracts.sh new file mode 100644 index 0000000..68b7187 --- /dev/null +++ b/server/tests/playerbottransientnavigation_contracts.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# Real search kernel, depot workflow and navigation failure bounds; no live world. +set -eu +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +build=$(mktemp -d) +trap 'rm -rf "$build"' EXIT HUP INT TERM +"${CXX:-c++}" ${CPPFLAGS:-} ${CXXFLAGS:-} -std=c++17 -Wall -Wextra -Werror \ + -Wno-error=reorder -Wno-missing-field-initializers -ffunction-sections -fdata-sections -I"$root/src" \ + "$root/tests/playerbottransientnavigation_contracts.cpp" "$root/src/playerbotdepotworkflow.cpp" \ + "$root/src/playerbotdepotsession.cpp" \ + "$root/src/playerbotnavigationruntime.cpp" "$root/src/playerbotnavigationsession.cpp" "$root/src/playerbotnavigation.cpp" \ + ${LDFLAGS:-} -Wl,--gc-sections -o "$build/transient-navigation" +"$build/transient-navigation" diff --git a/server/tests/playerbottravelchurn_contracts.cpp b/server/tests/playerbottravelchurn_contracts.cpp new file mode 100644 index 0000000..88195b5 --- /dev/null +++ b/server/tests/playerbottravelchurn_contracts.cpp @@ -0,0 +1,173 @@ +#include "playerbottravelsafety.h" +#include "playerbotpathsearch.h" +#include "playerbothuntregions.h" + +#include +#include + +namespace { +using Field = PlayerBotTravelDangerField; +using Memory = PlayerBotTravelSafetyMemory; +const Position source(100, 100, 7), destination(140, 100, 7); + +PlayerBotNavigationStep move(Position position) { + PlayerBotNavigationStep step; + step.action = PlayerBotNavigationAction::Move; + step.direction = DIRECTION_EAST; + step.target = step.expectedPosition = position; + return step; +} + +PlayerBotNavigationCostPolicy policy(std::shared_ptr field) { + PlayerBotNavigationCostPolicy result; + result.expectedHealthLossPerSecond = [field](Position p) { return field->dangerAt(p); }; + result.transientHealthLossPerSecond = result.expectedHealthLossPerSecond; + return result; +} + +std::shared_ptr field(Position position, double danger) { + return std::make_shared(std::vector{}, + std::vector{{position, danger}}, std::vector{}); +} + +void slicedRequestAndReturnReuse() { + Memory memory; + const auto start = Memory::Clock::time_point{} + std::chrono::seconds(100); + const std::string planning = playerBotTravelPlanningIdentity(17, 9); + assert(planning != playerBotTravelPlanningIdentity(18, 9)); // Private cache partition. + const auto frozen = policy(field({100, 200, 7}, 0.4)); + PlayerBotNavigationGoal goal; + goal.type = PlayerBotNavigationGoalType::Exact; + goal.position = destination; + PlayerBotPathSearch search(source, goal, 100); + search.constrain(frozen.risk); + auto expand = [&](Position from) { + const Position next(from.x + 1, from.y, from.z); + return std::vector{{move(next), 10, + frozen.dangerCost(next, 1000), frozen.dangerAt(next)}}; + }; + uint64_t revision = 0; + int slices = 0; + while (!search.result) { + // Hostile sightings move on every slice, including harmless movement + // directly on the corridor. None changes the request's immutable field. + memory.observeThreat(1, {uint16_t(100 + slices), 200, 7}, "dragon", start + std::chrono::seconds(slices)); + memory.observeThreat(2, {uint16_t(100 + slices), 100, 7}, "rat", start + std::chrono::seconds(slices)); + assert(memory.revision() > revision); + revision = memory.revision(); + assert(playerBotTravelPlanningIdentity(17, 9) == planning); + search.advance(1, expand, [](Position p) { return p == destination; }, + [](Position p) { return playerBotNavigationDistance(p, destination); }); + ++slices; + } + assert(slices == 41 && search.result == PlayerBotNavigationResult::Reached); + assert(search.steps.size() == 40 && search.summary.dangerCost == 0); + + auto exposure = std::make_shared(); + exposure->retain(search.steps, frozen, [](const auto&) { return 1000u; }, 0, 0); + PlayerBotHuntReturnCoverageContext original; + original.coveredPosition = source; + original.depotDestination = destination; + original.travelSafetyIdentity = planning; + original.travelExposure = exposure; + original.travelField = field({100, 200, 7}, 0.4); + original.observationIdentity = "17:9:0"; + PlayerBotHuntReturnCoverage coverage; + coverage.validate(2, 3, original); + for (int observation = 0; observation < 60; ++observation) { + auto live = original; + live.observationIdentity = "17:9:" + std::to_string(observation + 1); + live.travelField = field({uint16_t(100 + observation % 40), 100, 7}, 0.001); + assert(!exposure->refreshed(*live.travelField).transientRiskRejected); + assert(coverage.covers(2, 3, live)); + live.travelField = field({uint16_t(100 + observation), 200, 7}, 4.0); + assert(coverage.covers(2, 3, live)); + } + // Fresh on-route evidence prevents both admission and retained-proof reuse, + // even though the completed search itself used a safe snapshot. + auto live = original; + live.observationIdentity = "17:9:999"; + live.travelField = field({120, 100, 7}, 0.4); + assert(exposure->refreshed(*live.travelField).transientRiskRejected); + assert(!coverage.covers(2, 3, live)); + const auto step = std::array{move({120, 100, 7})}; + assert(playerBotNavigationTransientRouteRisk(policy(live.travelField), step, + [](const auto&) { return 1000u; }).transientRiskRejected); + live.travelField = original.travelField; + live.travelSafetyIdentity = playerBotTravelPlanningIdentity(17, 10); + assert(!coverage.covers(2, 3, live)); // A new raid wave invalidates coarse proofs. +} + +void extendedReturnConnector() { + const auto clear = policy(field({100, 200, 7}, 0)); + PlayerBotTravelRouteExposure original; + original.retain(std::array{move({101, 100, 7})}, clear, [](const auto&) { return 1000u; }, 400, 0.04); + std::deque outgoing; + for (int x = 99; x >= 90; --x) { + auto step = move({uint16_t(x), 100, 7}); + step.direction = DIRECTION_WEST; + outgoing.push_back(step); + } + const auto reverse = playerBotTravelReverseWalk(source, outgoing); + assert(reverse.size() == 10 && reverse.front().expectedPosition == Position(91, 100, 7)); + assert(reverse.back().expectedPosition == source); + assert(reverse.front().direction == DIRECTION_EAST); + PlayerBotTravelRouteExposure connector; + connector.retain(reverse, clear, [](const auto&) { return 1000u; }, 0, 0); + auto combined = std::make_shared(original); + combined->prepend(connector); + assert(combined->dangerCost == 400 && combined->samples.size() == 11); + PlayerBotHuntReturnCoverageContext context; + context.coveredPosition = {90, 100, 7}; + context.travelExposure = combined; + context.travelSafetyIdentity = playerBotTravelPlanningIdentity(17, 9); + context.observationIdentity = "old"; + context.travelField = field({100, 200, 7}, 0); + PlayerBotHuntReturnCoverage coverage; + coverage.validate(2, 3, context); + assert(!coverage.travelExposureAt(source)); // No implicit extension to another source. + context.observationIdentity = "new"; + context.travelField = std::make_shared(std::vector{{{95, 100, 7}, {95, 100, 7}, 0.4}}, + std::vector{}, std::vector{}); + assert(!original.refreshed(*context.travelField).transientRiskRejected); + assert(!coverage.covers(2, 3, context)); // Danger occurs ONLY on the added connector. + context.travelField = std::make_shared(std::vector{{{91, 100, 7}, {100, 100, 7}, 0.02}}, + std::vector{}, std::vector{}); + assert(!coverage.covers(2, 3, context)); // 400 inherited + 200 connector exceeds the total budget. + context.travelField = field({95, 100, 7}, 0.001); + assert(coverage.covers(2, 3, context)); +} + +void cumulativeAndConsumedExposure() { + PlayerBotNavigationCostPolicy baseline; + baseline.expectedHealthLossPerSecond = [](Position) { return 0.04; }; + std::vector steps; + for (int i = 0; i < 10; ++i) steps.push_back(move({uint16_t(101 + i), 100, 7})); + PlayerBotTravelRouteExposure exposure; + exposure.retain(steps, baseline, [](const auto&) { return 1000u; }, 400, 0.04); + Field newExposure({{{101, 100, 7}, {110, 100, 7}, 0.06}}, {}, {}); + const auto rejected = exposure.refreshed(newExposure); + assert(rejected.maximumHealthLossPerSecond == 0.06); + assert(rejected.dangerCost == 600 && rejected.transientRiskRejected); + Field empty({}, {}, {}); + assert(exposure.refreshed(empty).dangerCost == 400); // No manufactured budget. + exposure.passed({110, 100, 7}); + assert(exposure.samples.empty()); + assert(!exposure.refreshed(newExposure).transientRiskRejected); // Behind us. + + // Below-peak transient exposure can combine with disjoint static exposure + // to exceed the total budget, even when each component alone would fit. + baseline.expectedHealthLossPerSecond = [](Position p) { return p.x <= 105 ? 0.08 : 0.; }; + exposure.retain(steps, baseline, [](const auto&) { return 1000u; }, 400, 0.08); + Field mixed({{{106, 100, 7}, {110, 100, 7}, 0.04}}, {}, {}); + assert(exposure.refreshed(mixed).transientRiskRejected); + assert(exposure.refreshed(mixed).dangerCost == 600); +} +} + +int main() { + slicedRequestAndReturnReuse(); + extendedReturnConnector(); + cumulativeAndConsumedExposure(); + std::cout << "playerbot travel churn contracts passed\n"; +} diff --git a/server/tests/playerbottravelchurn_contracts.sh b/server/tests/playerbottravelchurn_contracts.sh new file mode 100644 index 0000000..d652bf4 --- /dev/null +++ b/server/tests/playerbottravelchurn_contracts.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# Real sliced frontier, retained exposure and return coverage; no engine/runtime. +set -eu +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +binary=$(mktemp) +trap 'rm -f "$binary"' EXIT HUP INT TERM +"${CXX:-c++}" -std=c++17 -O2 -Wall -Wextra -Werror -I"$root/src" \ + "$root/tests/playerbottravelchurn_contracts.cpp" -o "$binary" +"$binary" diff --git a/server/tests/playerbottravelsafety_contracts.cpp b/server/tests/playerbottravelsafety_contracts.cpp new file mode 100644 index 0000000..9be0a0b --- /dev/null +++ b/server/tests/playerbottravelsafety_contracts.cpp @@ -0,0 +1,393 @@ +#include "playerbottravelsafety.h" +#include "playerbotturnrouter.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace { +using Memory = PlayerBotTravelSafetyMemory; +using Field = PlayerBotTravelDangerField; +using namespace std::chrono_literals; +const auto epoch = Memory::Clock::time_point{} + 1000s; +const Position tile{100, 200, 7}; + +void near(double actual, double expected) { + assert(std::abs(actual - expected) < 1e-10); +} + +void evidence() { + Memory memory, other; + static_assert(Memory::MaxThreats == 64 && Memory::MaxClearTiles == 512); + static_assert(Memory::ThreatLifetime == 120s && Memory::ClearLifetime == 5s); + static_assert(std::is_same_v); + assert(memory.revision() == 0); + memory.expire(epoch); + memory.observeEmpty(tile, epoch); + assert(memory.revision() == 0); + + memory.observeThreat(1, tile, "Dragon", epoch); + const auto firstRevision = memory.revision(); + assert(firstRevision > 0); + memory.observeThreat(1, tile, "Dragon", epoch); + assert(memory.revision() == firstRevision); + const auto snapshot = memory.threats(epoch); + assert(snapshot.size() == 1 && snapshot[0].id == 1 && snapshot[0].position == tile); + assert(snapshot[0].species == "Dragon" && snapshot[0].seen == epoch); + for (int i = 0; i < 100; ++i) assert(memory.threats(epoch + 1s).size() == 1); + assert(memory.revision() == firstRevision); // Reads are not new sightings. + assert(other.threats(epoch + 1s).empty()); // No singleton/shared per-bot state. + + memory.observeThreat(1, {101, 200, 7}, "Dragon Lord", epoch + 2s); + assert(memory.threats(epoch + 2s).size() == 1); // ID moves, not duplicates. + assert(memory.threats(epoch + 2s)[0].species == "Dragon Lord"); + assert(snapshot[0].position == tile && snapshot[0].species == "Dragon"); + const auto movedRevision = memory.revision(); + memory.observeThreat(1, tile, "old sighting", epoch + 1s); + memory.observeEmpty({101, 200, 7}, epoch + 1s); + memory.observeClear({101, 200, 7}, 1, epoch + 1s); + assert(memory.revision() == movedRevision); // Delayed evidence cannot undo new evidence. + + memory.observeThreat(2, tile, "Rat", epoch + 2s); + memory.observeThreat(3, {101, 200, 8}, "Rat", epoch + 2s); + memory.observeEmpty({101, 200, 7}, epoch + 3s); + auto threats = memory.threats(epoch + 3s); + assert(threats.size() == 2 && threats[0].id == 2 && threats[1].id == 3); + assert(!memory.clearAt({101, 200, 7}, 1, epoch + 3s)); // Empty is not automatically raid clearance. + memory.observeEmpty({100, 201, 7}, epoch + 3s); + assert(memory.threats(epoch + 3s).size() == 2); + memory.observeThreat(1, {102, 200, 7}, "Dragon", epoch + 4s); // Absence was not death. + assert(memory.threats(epoch + 4s).size() == 3); + + assert(memory.threats(epoch + 122s - 1ns).size() == 3); + const auto beforeExpiry = memory.revision(); + assert(memory.threats(epoch + 122s).size() == 1); // Exact TTL boundary. + assert(memory.revision() == beforeExpiry + 1); + assert(memory.threats(epoch + 124s).empty()); + const auto expiredRevision = memory.revision(); + memory.expire(epoch + 200s); + memory.observeThreat(1, tile, "delayed", epoch + 5s); + assert(memory.revision() == expiredRevision && memory.threats(epoch + 200s).empty()); +} + +void clearance() { + Memory memory; + memory.observeClear(tile, 10, epoch); + const auto revision = memory.revision(); + memory.observeClear(tile, 10, epoch); + assert(memory.revision() == revision); + const Memory& view = memory; + assert(view.clearAt(tile, 10, epoch)); + assert(!view.clearAt(tile, 10, epoch - 1ns)); // Future evidence is not proof. + assert(!view.clearAt({101, 200, 7}, 10, epoch)); + assert(!view.clearAt({100, 200, 8}, 10, epoch)); + assert(!view.clearAt(tile, 11, epoch)); // Raid activation invalidates old clearance. + assert(view.clearPositions(11, epoch).empty()); + assert(view.clearPositions(10, epoch) == std::vector{tile}); + assert(view.clearAt(tile, 10, epoch + 5s - 1ns)); + assert(!view.clearAt(tile, 10, epoch + 5s)); + assert(view.clearPositions(10, epoch + 5s).empty()); + assert(memory.revision() == revision); // Const lookup filters; expire owns removal. + memory.expire(epoch + 5s); + assert(memory.revision() == revision + 1); + memory.expire(epoch + 5s); + assert(memory.revision() == revision + 1); + + memory.observeClear(tile, 11, epoch + 6s); + memory.observeClear({101, 200, 7}, 11, epoch + 6s); + memory.observeThreat(9, tile, "Dragon", epoch + 7s); + assert(!memory.clearAt(tile, 11, epoch + 7s)); // Reacquisition revokes this tile only. + assert(memory.clearAt({101, 200, 7}, 11, epoch + 7s)); + memory.observeClear(tile, 11, epoch + 8s); + assert(!memory.clearAt(tile, 11, epoch + 8s)); // Contradictory clearance is rejected. + memory.observeEmpty(tile, epoch + 9s); + memory.observeClear(tile, 11, epoch + 9s); + assert(memory.clearAt(tile, 11, epoch + 9s)); + memory.observeClear(tile, 12, epoch + 10s); + assert(!memory.clearAt(tile, 11, epoch + 10s)); + assert(memory.clearAt(tile, 12, epoch + 10s)); + + Memory simultaneousExpiry; + simultaneousExpiry.observeThreat(1, tile, "Rat", epoch); + simultaneousExpiry.observeClear({101, 200, 7}, 1, epoch); + const auto before = simultaneousExpiry.revision(); + simultaneousExpiry.expire(epoch + 120s); + assert(simultaneousExpiry.revision() == before + 1); +} + +void refreshRevisions() { + Memory threats; + threats.observeThreat(1, tile, "Dragon", epoch); + const auto initialRevision = threats.revision(); + const auto original = threats.threats(epoch); + for (int seconds = 1; seconds <= 119; ++seconds) { + const auto now = epoch + std::chrono::seconds(seconds); + threats.observeThreat(1, tile, "Dragon", now); + assert(threats.revision() == initialRevision); + assert(threats.threats(now)[0].seen == now); + } + assert(original[0].seen == epoch); // A timestamp refresh cannot mutate snapshots. + assert(threats.threats(epoch + 120s).size() == 1); // Refresh extended the TTL. + assert(threats.revision() == initialRevision); + threats.observeThreat(1, tile, "Dragon Lord", epoch + 121s); + assert(threats.revision() == initialRevision + 1); // Species alone is semantic. + threats.observeThreat(1, {101, 200, 7}, "Dragon Lord", epoch + 122s); + assert(threats.revision() == initialRevision + 2); // Position alone is semantic. + threats.observeThreat(1, {101, 200, 7}, "Dragon Lord", epoch + 123s); + assert(threats.revision() == initialRevision + 2); + threats.expire(epoch + 243s - 1ns); + assert(threats.revision() == initialRevision + 2); + threats.expire(epoch + 243s); + assert(threats.revision() == initialRevision + 3); + threats.observeThreat(1, {101, 200, 7}, "Dragon Lord", epoch + 243s); + assert(threats.revision() == initialRevision + 4); // Reappearance after expiry is new evidence. + + Memory clears; + clears.observeClear(tile, 7, epoch); + const auto clearRevision = clears.revision(); + for (int seconds = 1; seconds <= 4; ++seconds) { + clears.observeClear(tile, 7, epoch + std::chrono::seconds(seconds)); + assert(clears.revision() == clearRevision); + } + assert(clears.clearAt(tile, 7, epoch + 5s)); // Still fresh past the original TTL. + clears.expire(epoch + 9s - 1ns); + assert(clears.revision() == clearRevision); + clears.expire(epoch + 9s); + assert(clears.revision() == clearRevision + 1); + assert(!clears.clearAt(tile, 7, epoch + 9s)); + clears.observeClear(tile, 7, epoch + 9s); + assert(clears.revision() == clearRevision + 2); + clears.observeClear(tile, 8, epoch + 10s); + assert(clears.revision() == clearRevision + 3); // Raid revision alone is semantic. + clears.observeClear(tile, 8, epoch + 11s); + assert(clears.revision() == clearRevision + 3); + assert(clears.clearAt(tile, 8, epoch + 15s)); +} + +void capacity() { + Memory memory, isolated; + for (uint32_t i = 0; i < Memory::MaxThreats; ++i) { + memory.observeThreat(i, {uint16_t(i), 10, 7}, "Rat", epoch); + } + const auto beforeThreatRefresh = memory.revision(); + memory.observeThreat(0, {0, 10, 7}, "Rat", epoch + 1s); // Refresh oldest before overflowing. + assert(memory.revision() == beforeThreatRefresh); + memory.observeThreat(64, {64, 10, 7}, "Rat", epoch + 2s); + assert(memory.revision() == beforeThreatRefresh + 1); + auto snapshot = memory.threats(epoch + 2s); + assert(snapshot.size() == Memory::MaxThreats); + assert(std::none_of(snapshot.begin(), snapshot.end(), [](const auto& threat) { return threat.id == 1; })); + assert(std::any_of(snapshot.begin(), snapshot.end(), [](const auto& threat) { return threat.id == 0; })); + for (uint32_t i = 65; i < 1000; ++i) memory.observeThreat(i, {uint16_t(i), 10, 7}, "Rat", epoch + 2s); + assert(memory.threats(epoch + 2s).size() == Memory::MaxThreats); + assert(isolated.threats(epoch + 2s).empty()); + + for (uint16_t i = 0; i < Memory::MaxClearTiles; ++i) memory.observeClear({i, 20, 7}, 3, epoch + 2s); + const auto beforeClearRefresh = memory.revision(); + memory.observeClear({0, 20, 7}, 3, epoch + 3s); + assert(memory.revision() == beforeClearRefresh); + memory.observeClear({512, 20, 7}, 3, epoch + 4s); + assert(memory.revision() == beforeClearRefresh + 1); + assert(memory.clearPositions(3, epoch + 4s).size() == Memory::MaxClearTiles); + assert(memory.clearAt({0, 20, 7}, 3, epoch + 4s)); + assert(!memory.clearAt({1, 20, 7}, 3, epoch + 4s)); + for (uint16_t i = 513; i < 2000; ++i) memory.observeClear({i, 20, 7}, 3, epoch + 4s); + assert(memory.clearPositions(3, epoch + 4s).size() == Memory::MaxClearTiles); + assert(isolated.clearPositions(3, epoch + 4s).empty()); +} + +void fieldSamples() { + near(Field({}, {}, {}).dangerAt(tile), 0); + const std::vector rectangles{ + {{110, 210, 8}, {90, 190, 7}, 0.2}, // Reversed inclusive bounds. + {{100, 200, 7}, {110, 210, 7}, 0.4}, + }; + const Field footprints(rectangles, {}, {}); + near(footprints.dangerAt(tile), 0.4); // Overlap is maximum, not sum. + near(footprints.dangerAt({90, 190, 7}), 0.2); + near(footprints.dangerAt({110, 210, 8}), 0.2); + near(footprints.dangerAt({89, 190, 7}), 0); + near(footprints.dangerAt({110, 211, 8}), 0); + near(footprints.dangerAt({100, 200, 6}), 0); + near(footprints.dangerAt({100, 200, 9}), 0); + + const Field local({}, {{tile, 0.5}}, {}); + near(local.dangerAt(tile), 0.5); + near(local.dangerAt({101, 201, 7}), 0.5); // Chebyshev distance <=1: full strength. + near(local.dangerAt({102, 200, 7}), 0.4); + near(local.dangerAt({103, 203, 7}), 0.3); + near(local.dangerAt({105, 200, 7}), 0.1); + near(local.dangerAt({106, 200, 7}), 0); + near(local.dangerAt({107, 200, 7}), 0); + near(local.dangerAt({100, 200, 8}), 0); + near(local.dangerAt({100, 200, 6}), 0); + + // Rank contributions after falloff, not raw danger; count distinct co-located + // threats separately, but each input entry only once across bucket boundaries. + const Field ranked(rectangles, {{tile, 0.1}, {tile, 0.2}, {tile, 0.3}, {{105, 200, 7}, 1.0}}, {}); + near(ranked.dangerAt(tile), 0.4 + 0.3 + 0.2 + 0.2); + const Field boundary({}, {{{16, 16, 7}, 0.5}}, {}); + near(boundary.dangerAt({15, 15, 7}), 0.5); + near(boundary.dangerAt({17, 17, 7}), 0.5); + const Field edges({}, {{{0, 0, 0}, 1}, {{65535, 65535, 255}, 2}}, {}); + near(edges.dangerAt({0, 0, 0}), 1); + near(edges.dangerAt({1, 1, 0}), 1); + near(edges.dangerAt({65535, 65535, 255}), 2); + near(edges.dangerAt({65534, 65534, 255}), 2); + near(edges.dangerAt({0, 0, 255}), 0); // No wraparound at map edges. + + const Field cleared(rectangles, {{tile, 0.3}}, {tile}); + near(cleared.dangerAt(tile), 0.3); // Clearance never suppresses the local component. + near(cleared.dangerAt({101, 200, 7}), 0.7); // Does not clear the whole footprint. + near(cleared.dangerAt({100, 200, 8}), 0.2); + near(std::max(cleared.dangerAt(tile), 0.8), 0.8); // Caller combines ordinary static danger via max. + + Memory memory; + memory.observeClear(tile, 7, epoch); + const Field before(rectangles, {}, memory.clearPositions(7, epoch)); + near(before.dangerAt(tile), 0); + near(Field(rectangles, {}, memory.clearPositions(8, epoch)).dangerAt(tile), 0.4); + near(Field(rectangles, {}, memory.clearPositions(7, epoch + 5s)).dangerAt(tile), 0.4); + memory.observeThreat(1, tile, "Dragon", epoch + 1s); + const Field acquired(rectangles, {{tile, 0.3}}, memory.clearPositions(7, epoch + 1s)); + near(acquired.dangerAt(tile), 0.7); + near(before.dangerAt(tile), 0); // Snapshot has no hidden live memory/world access. + assert(memory.threats(epoch + 121s).empty()); + near(Field(rectangles, {}, memory.clearPositions(7, epoch + 121s)).dangerAt(tile), 0.4); // Raid persists. + + const double nan = std::numeric_limits::quiet_NaN(); + near(Field({{tile, tile, -1}, {tile, tile, nan}}, {{tile, -1}, {tile, nan}}, {}).dangerAt(tile), 0); + const Field infinite({}, {{tile, std::numeric_limits::infinity()}}, {}); + assert(std::isinf(infinite.dangerAt(tile))); + near(infinite.dangerAt({106, 200, 7}), 0); // No infinity * zero NaN at radius boundary. +} + +void withdrawal() { + using Candidate = PlayerBotTravelWithdrawalCandidate; + const Position north{100, 199, 7}, east{101, 200, 7}, south{100, 201, 7}, west{99, 200, 7}; + static_assert(PlayerBotTravelMaxWithdrawalNeighbours == 8); + const double epsilon = PlayerBotTravelWithdrawalEpsilon; + assert(!playerBotChooseTravelWithdrawal(1, {})); + assert(!playerBotChooseTravelWithdrawal(1, {{north, 1}, {east, 2}, {south, 1, true}})); + assert(!playerBotChooseTravelWithdrawal(0, {{north, 0, true}})); + assert(!playerBotChooseTravelWithdrawal(1, {{north, 1 - epsilon / 2}})); + assert(!playerBotChooseTravelWithdrawal(2 * epsilon, {{north, epsilon}})); + assert(playerBotChooseTravelWithdrawal(3 * epsilon, {{north, epsilon}}) == north); + assert(playerBotChooseTravelWithdrawal(1, {{north, 0.8}, {east, 0.2}, {south, 0.5}}) == east); + assert(playerBotChooseTravelWithdrawal(1, {{north, 0.1}, {east, 0.8, true}, {south, 0.5, true}}) == south); + assert(playerBotChooseTravelWithdrawal(1, {{north, 0.8, true}, {east, 0.1}}) == north); + assert(playerBotChooseTravelWithdrawal(1, {{north, 0.2}, {east, 1, true}}) == north); // Refuge cannot excuse danger. + assert(playerBotChooseTravelWithdrawal(1, {{north, 0.2}, {east, 0.2}}) == north); // Stable caller tie-break. + assert(playerBotChooseTravelWithdrawal(1, {{east, 0.2}, {north, 0.2}}) == east); + const double nan = std::numeric_limits::quiet_NaN(); + const double infinity = std::numeric_limits::infinity(); + assert(!playerBotChooseTravelWithdrawal(nan, {{north, 0}})); + assert(!playerBotChooseTravelWithdrawal(1, {{north, nan}, {east, -1}, {south, infinity}})); + assert(playerBotChooseTravelWithdrawal(infinity, {{north, infinity}, {east, 1}}) == east); + + std::vector bounded(8, {north, 1}); + bounded.push_back({east, 0, true}); + assert(!playerBotChooseTravelWithdrawal(1, bounded)); // A ninth entry is not examined. + bounded[7] = {west, 0.3}; + assert(playerBotChooseTravelWithdrawal(1, bounded) == west); + + const Field local({}, {{tile, 0.5}}, {}); + std::vector plateau; + for (int y = 199; y <= 201; ++y) { + for (int x = 99; x <= 101; ++x) { + const Position p{uint16_t(x), uint16_t(y), 7}; + if (p != tile) plateau.push_back({p, local.dangerAt(p)}); + } + } + assert(!playerBotChooseTravelWithdrawal(local.dangerAt(tile), plateau)); // Equal danger stays caller-owned. + assert(playerBotChooseTravelWithdrawal(local.dangerAt(east), + {{{102, 200, 7}, local.dangerAt({102, 200, 7})}}) == Position(102, 200, 7)); +} + +// Independent full-scan oracle checks indexed sampling, including huge rectangles. +// Production sampling must not rasterize the world or scan all local threats. +double reference(const std::vector& rectangles, const std::vector& threats, + const std::vector& clears, Position p) { + double result = 0; + if (std::find(clears.begin(), clears.end(), p) == clears.end()) { + for (const auto& r : rectangles) { + if (p.x >= std::min(r.from.x, r.to.x) && p.x <= std::max(r.from.x, r.to.x) && + p.y >= std::min(r.from.y, r.to.y) && p.y <= std::max(r.from.y, r.to.y) && + p.z >= std::min(r.from.z, r.to.z) && p.z <= std::max(r.from.z, r.to.z)) result = std::max(result, r.danger); + } + } + std::vector contributions; + for (const auto& threat : threats) { + if (p.z != threat.position.z) continue; + const auto d = std::max(std::abs(int(p.x) - threat.position.x), std::abs(int(p.y) - threat.position.y)); + if (d < 6) contributions.push_back(threat.danger * (d <= 1 ? 1 : (6 - d) / 5.0)); + } + std::sort(contributions.begin(), contributions.end(), std::greater()); + for (size_t i = 0; i < std::min(size_t(3), contributions.size()); ++i) result += contributions[i]; + return result; +} + +void indexedOracle() { + std::mt19937 random(267); + const auto coordinate = [&]() { return uint16_t(random() % 128); }; + const auto position = [&]() { return Position{coordinate(), coordinate(), uint8_t(random() % 3 + 6)}; }; + std::vector rectangles{{{0, 0, 0}, {65535, 65535, 255}, 0.03}}; + std::vector threats; + std::vector clears; + for (int i = 0; i < 200; ++i) { + const auto p = position(); + rectangles.push_back({p, {uint16_t(p.x + 4), uint16_t(p.y + 4), p.z}, (random() % 100) / 100.0}); + threats.push_back({position(), (random() % 100) / 100.0}); + clears.push_back(position()); + } + const Field field(rectangles, threats, clears); + for (int z = 6; z <= 8; ++z) { + for (int y = 0; y < 132; ++y) { + for (int x = 0; x < 132; ++x) { + const Position p{uint16_t(x), uint16_t(y), uint8_t(z)}; + near(field.dangerAt(p), reference(rectangles, threats, clears, p)); + } + } + } + near(field.dangerAt({65535, 65535, 255}), 0.03); + // Capacity overflow may overestimate by merging the tail but must not forget + // any warning, including on another floor or far outside the small buckets. + for (int i = 0; i < 1000; ++i) { + const Position p{uint16_t(i), uint16_t(i), uint8_t(i % 16)}; + rectangles.push_back({p, p, 0.9}); + } + const Field overflow(rectangles, {}, {}); + for (const auto& rectangle : rectangles) assert(overflow.dangerAt(rectangle.from) >= rectangle.danger); +} +} + +int main() { + for (const auto phase : {PlayerBotCyclePhase::Idle, PlayerBotCyclePhase::Service, PlayerBotCyclePhase::ReturnToDepot, + PlayerBotCyclePhase::DepositLoot, PlayerBotCyclePhase::Hunt}) { + for (const auto stage : {PlayerBotScenarioStage::Traverse, PlayerBotScenarioStage::LootCorpse, PlayerBotScenarioStage::TraversalCombat}) { + PlayerBotTurnRouter router; + router.setCyclePhase(phase); + router.setScenarioStage(stage); + PlayerBotTurnObservation turn; + turn.travelSafetyRequired = turn.progressionActive = turn.trainerDiscoveryActive = turn.preparationActive = true; + turn.magicTrainingActive = turn.huntRegionSelectionRequired = turn.huntPlanningActive = true; + assert(router.route(turn) == PlayerBotTurnCommand::TravelSafety); + router.pause(); + assert(router.route(turn) == PlayerBotTurnCommand::None); + router.stop(); + assert(router.route(turn) == PlayerBotTurnCommand::None); + } + } + evidence(); + clearance(); + refreshRevisions(); + capacity(); + fieldSamples(); + withdrawal(); + indexedOracle(); + std::cout << "playerbot travel safety contracts passed\n"; +} diff --git a/server/tests/playerbottravelsafety_contracts.sh b/server/tests/playerbottravelsafety_contracts.sh new file mode 100755 index 0000000..4418fc9 --- /dev/null +++ b/server/tests/playerbottravelsafety_contracts.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# Pure evidence-memory and indexed danger-field contracts; no engine/runtime. +set -eu +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +binary=$(mktemp) +trap 'rm -f "$binary"' EXIT HUP INT TERM +"${CXX:-c++}" -std=c++17 -O2 -Wall -Wextra -Werror -I"$root/src" \ + "$root/tests/playerbottravelsafety_contracts.cpp" -o "$binary" +"$binary"