From 282d9612f6d3476fcb07046fe8b01c7b7149f915 Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Fri, 11 Sep 2026 21:56:15 +0200 Subject: [PATCH 1/4] server: adaptive remote hunts with supply recovery and transit escape - Hunt calibration: shared bounded corrections for untested regions, no candidate ceiling, compact topology reachability with incremental transport validation, and fixture-only planning horizons. - Supply recovery replaces the insufficient-potion-funds terminal: degraded challenge frontier, gold/safety-weighted region selection, affordable partial restock, and survival selling at the nearest reachable safe merchant when no profitable trip exists. - Transit escape: progress-based fixed-goal failure accounting, one no-chase defense attempt per episode, persistent corridor blockers confirmed route-critical on repeat detours, exhaustion honored on nominally successful plans, and a bounded 30-second breakout. Depot replans respect suppressed tiles and defer the depot-unavailable stop while a blocker is active. - Gameplay fixture fixes: guaranteed-loot monster no longer races defensive AI; mainland loop uses a fixture-only planning horizon. --- docs/playerbots.md | 127 +++-- docs/testing.md | 129 ++++- .../assertions-progression.ps1 | 87 ++- .../scenarios-progression.ps1 | 11 + scripts/test-playerbot-gameplay.ps1 | 6 +- scripts/test-playerbot-hunt-fixture.lua | 19 +- scripts/test-playerbot-supply-assertions.ps1 | 59 +- server/compose.yaml | 2 +- server/config.lua | 2 +- server/src/game.cpp | 3 +- server/src/game.h | 2 + server/src/playerbotcombat.cpp | 335 ++++++++--- server/src/playerbotcontroller.cpp | 295 +++++++++- server/src/playerbotcontroller.h | 33 +- server/src/playerbotdepotworkflow.h | 5 + server/src/playerboteconomy.cpp | 23 - server/src/playerboteconomy.h | 29 +- server/src/playerbotfixturedriver.cpp | 19 +- server/src/playerbotfixturedriver.h | 5 + server/src/playerbothuntcoordinator.cpp | 5 + server/src/playerbothuntcoordinator.h | 20 + server/src/playerbothuntplanningsession.cpp | 69 ++- server/src/playerbothuntplanningsession.h | 25 +- server/src/playerbothuntpolicy.cpp | 116 ++-- server/src/playerbothuntpolicy.h | 16 +- server/src/playerbothuntregionadapter.cpp | 53 +- server/src/playerbothuntregions.h | 200 +++++-- server/src/playerbothuntruntime.cpp | 75 ++- server/src/playerbothuntruntime.h | 32 +- server/src/playerbotnavigation.cpp | 11 - server/src/playerbotnavigation.h | 43 +- server/src/playerbotnavigationruntime.cpp | 19 +- server/src/playerbotnavigationruntime.h | 68 ++- server/src/playerbotnavigationsession.cpp | 25 +- server/src/playerbotnavigationsession.h | 1 + server/src/playerbotservice.cpp | 125 ++++- server/src/playerbotserviceworkflow.cpp | 4 +- server/src/playerbotserviceworkflow.h | 2 + server/src/playerbotsupplyrecovery.h | 60 ++ server/src/playerbottestpolicy.cpp | 7 +- server/src/playerbottestpolicy.h | 11 + server/src/playerbottopology.cpp | 92 ++- server/src/playerbottopology.h | 62 +++ server/src/playerbottransitcombat.h | 54 +- .../playerbot-gameplay/includes/combat.inc | 5 + .../playerbot-gameplay/includes/login.inc | 12 +- server/tests/playerbot_contracts.cpp | 527 +++++++++++++++--- server/tests/playerbotdepotworkflow_test.cpp | 21 +- 48 files changed, 2513 insertions(+), 438 deletions(-) create mode 100644 server/src/playerbotsupplyrecovery.h diff --git a/docs/playerbots.md b/docs/playerbots.md index d6e39e8a..bb8cf114 100644 --- a/docs/playerbots.md +++ b/docs/playerbots.md @@ -102,31 +102,80 @@ relying on topology-based planning again. Hunt regions come from all loaded hostile, attackable spawns. At startup, the shared atlas groups same-floor spawn pockets, joins reciprocal locally connected floor transitions into sites, and exposes pocket, neighborhood, and whole-site -variants. Each controller scores all topology-reachable variants in batches of -256 using health, equipment, skill, cooldowns, observed performance, clear time, -patrol time, and projected XP. It then validates at most eight score-ranked -candidates through normal navigation. Forward and return routes run on separate -scheduler turns with one `100000`-node budget per turn, so one hunt decision does -not synchronously pathfind the whole shortlist. +variants. Each controller scores variants in batches of 256 using health, +equipment, skill, cooldowns, observed performance, clear time, patrol time, and +projected XP. The cheap pass rejects lethal, unsustainable, cooldown-excluded, +and disconnected candidates across the full atlas. Before scoring, a separate +transport-feasibility phase checks eight loaded offers per scheduler turn. It +uses shared, compact walk-component reachability snapshots keyed by topology, +origin, tools, and level instead of retaining a full node-distance array for every +arrival. The weak cache retains at most 512 keys; active planning sessions own the +snapshots they use. Each planning session captures an immutable NPC capability +catalog from current provider positions and offers. NPC movement does not cancel an +active plan; the next plan captures the new state, while full route validation remains +authoritative. +A disconnected region remains viable only when currently eligible registered NPC +offers provide a plausible affordable multihop chain. Local, remote, tested, and +untested hunts then share one policy ranking; there are no reserved slots or +exploration quotas. +Observed potion use does not calibrate the static supply forecast. + +Route preflight compares complete static navigation with registered NPC travel. +It honors loaded dialogue, fare, level and premium requirements, opaque-condition +rejection, arrival verification, and normal danger limits. Outbound, depot-exit, +and relevant potion-supplier routes run on separate scheduler turns with one +`100000`-node budget per route candidate. A failed candidate advances to the next +ranked survivor. After a safe candidate is known, validation skips each candidate +whose no-travel XP bound and income tier cannot beat it, while retaining any later +candidate that can. It stops when no competitive candidate remains. The finite +atlas and one-route-leg-per-turn rule bound work without an arbitrary shortlist. + +Before committing to a hunt, the controller validates a safe outbound route and +a safe route from the hunting area to a usable depot. It keeps one ranked approach +for every distinct loaded depot ID, rather than requiring a return to the selection +origin or a permanent service base. When the reconciled forecast expects potion +use, or the route reserve reaches the bot's available potions, it likewise keeps +one ranked approach for every loaded potion provider. Each route is still +validated on a separate scheduler turn. Combined +outbound, exit, and supply fares must leave the recovery spending +reserve intact. Replanning can choose different NPC offers, but before adopting +a changed route and again before each paid NPC step, the controller protects the +remaining exit and supply fares plus the money still needed for the potion target. +Free routes remain available, and the protected recovery amount falls after a +successful restock. Post-hunt depot discovery runs +again from the bot's current position and can use NPC travel; protected hunt +exits reject the depot workflow's legacy unsafe emergency fallback. No hardcoded +city/depot mapping is used. Before moving toward an active-region patrol point, the controller validates the complete outbound route on one scheduler turn and the complete route from that -point back to the selected hunt-return endpoint on the next. Both routes must -have danger cost at most `500` and peak expected health loss at most `0.08` per +point back to the selected depot endpoint on the next. Both routes must have +danger cost at most `500` and peak expected health loss at most `0.08` per second. An unsafe waypoint is skipped before movement. The controller installs the validated outbound route directly and restarts both checks after displacement -or a replan request. The validated return route sets the potion reserve for that +or a replan request. The validated exit route sets the potion reserve for that waypoint. `hunt_supply_reserve` uses source `selected_return_route` for the initial reserve and `patrol_return_route` when a waypoint requires an increase. -Navigation remains behind the destination/reachability interface so a later -navigator can replace tile planning without changing hunt selection. The bot switches from transit to active hunting when any patrol point in the selected region enters normal client view range on the current floor. It does not wait to stand on the first patrol point. Outside that area it still ignores non-attacking transit monsters; inside it proactively targets the selected -region's monster types. During transit, one to three adjacent attackers retain -normal detour and route-blocker handling; four force defensive combat. A visible +region's monster types. During transit, adjacent attackers retain normal detour +and route-blocker handling. A transit episode permits one five-second, +no-chase defense attempt against a route-critical blocker; replans and +intermediate route legs do not renew that budget, while a goal or phase change +starts a new episode. A repeated detour onto an already suppressed tile +proves a persistent corridor blocker and confirms it as route-critical +immediately. Adjacent-approach goal flapping (for example, depot discovery +alternating between standable tiles of the same depot) does not reset fixed +goal failure accounting or erase confirmed blockers. If 20 fixed-goal route +failures confirm that adjacent hostile blockers have sealed the exits, the bot +enters a final 30-second no-chase breakout, and exhaustion is also honored when +nominally successful plans would keep dispatching a blocked corridor step. +Healing keeps turn priority, navigation replans each turn, +and real positional progress or an open route ends the breakout immediately. +Expiry falls back to the normal route-unavailable terminal. A visible non-adjacent ranged attacker is engaged through bounded target-approach routing instead of being allowed to attack indefinitely at range. @@ -154,22 +203,30 @@ does not credit potion or spell recovery before that lethal point. Each controller starts with a `0.20` pressure frontier. Every nonlethal region at or below the frontier plus `0.05` headroom competes by projected XP; there is no -minimum pressure band. Thirty seconds of active combat, at least one recorded kill, -near-full health, and no verified recovery escalates the frontier by `0.025`. -Verified potion or healing-spell pressure, observed danger, or death backs it -off by `0.05`; the next two qualifying hunts hold before escalation resumes. -The target stays within `0.10` through `0.40`. It is an adaptation bound, not a -lethal threshold. Travel, idle full health, and target engagement without a -kill do not count as easy evidence. Active-combat uptime remains diagnostic; it -is not an escalation threshold because sparse, fast-kill hunts naturally have -low uptime. -Frontier and performance state are per-controller and reset on relog or restart. +minimum pressure band. Escalation requires at least 60 active combat seconds, +three kills, p10 health of at least 70%, and no critical pressure. A productive +hunt with zero to two potions and a healthy mana floor advances by `0.10`; a +moderate mana floor advances by `0.05`. A single Exura or potion never causes +automatic backoff. Exura pressure is based on sampled mana rather than cast count. +Three or more potions back off only when their normalized rate reaches one per +active combat minute. Danger, death, health below 35%, or healing-time mana below +20% backs off by `0.10`; the next two qualifying hunts hold before escalation +resumes. The frontier stays within `0.10` through `0.60`. Predicted lethal damage, +route danger, and reserves remain independent hard gates. Travel, idle full +health, and insufficient combat do not escalate. Frontier and performance state +are per-controller and reset on relog or restart. Taking one maximum health pool of active-combat damage within the first two -minutes abandons the region for ten minutes. Completed hunts update a -per-controller XP correction after at least 30 seconds and one kill; the sample -is clamped to `0.25` through `2.0` and uses a 65/35 rolling blend. When all -globally scored candidates are unavailable, the controller emits +minutes abandons the region for ten minutes. A completed hunt updates its own +per-controller, per-variant XP correction only after at least 120 seconds (or the +full configured outing when shorter), 60 active-combat seconds, three kills, +positive XP, valid projection inputs, and no observed danger or death. The sample +is clamped to `0.25` through `2.0` and uses a 65/35 rolling blend. An untested +variant inherits the arithmetic mean of one reliable correction per tested +variant. Repeated outings refine that variant's value but do not add extra weight +to the shared mean; default, unreliable, malformed, out-of-range, and prior-atlas-revision +entries are excluded. A tested variant always keeps its own correction. When all globally +scored candidates are unavailable, the controller emits `hunt_scope_exhausted` and retries after 30 seconds. A successful selection resets the counter; three consecutive exhausted scans stop the controller instead of rescanning an unchanged world snapshot forever. @@ -271,8 +328,10 @@ walk to a provider and from an arrival point is validated through bounded normal navigation. A route must fit the bot's carried-plus-bank funds and is unavailable while the bot is PZ-locked. Offers with dynamic destinations, custom conditions, or custom actions are opaque and excluded rather than reimplemented in C++. -Execution greets the selected NPC, follows the registered dialogue, relies on -the normal script for payment and movement, and verifies the exact destination. +Execution completes a coarse provider route with bounded same-floor navigation to +the NPC's live three-tile interaction range. It then greets the selected NPC, +follows the registered dialogue, relies on the normal script for payment and +movement, and verifies the exact destination. A failed NPC/destination pair is suppressed for five minutes. The runtime executes one travel leg and replans from the observed arrival position. @@ -400,8 +459,12 @@ and targets 10. Only the selected potion is reserved toward that target; other health potions may be deposited. A complete restock takes priority when total carried and bank gold can pay for it; an optional partial restock preserves the carried-gold reserve. If total gold cannot raise stock above the return -threshold, service stops with `insufficient_potion_funds` without buying an -unusable partial reserve. The cycle deposits carried money and withdraws up to +threshold, service enters supply recovery instead of stopping: it buys whatever +the gold affords, sells eligible loot at the nearest reachable safe merchant +even without a profitable trip, and hunts under a lowered challenge frontier +that prefers observed coin income and safety. Normal profit-seeking selling and +the full challenge frontier resume once gold covers the restock again. The +cycle deposits carried money and withdraws up to 100 gp without exceeding the bot's total available gold. It does not buy food merely because none is carried. The low-wealth regression isolates this banking contract with 56 bank gp, zero carried gp, ten selected health potions, and no @@ -563,7 +626,7 @@ events are emitted by that controller. | Spell casting | `action_result` with `action="cast_spell"` records the need, semantic `policy_candidate`, selected method, mana reserve, normal-path request, engine result, observed outcome, fallback, captured engine bounds, monotonic observation age, target class, distinct synchronous spell-victim count, measured Haste condition ticks, and controller-local calibration counts, range, conservative value, ranking estimate, confidence, and evidence reason. `spell_calibration` separates `classifier_helper` and `profile_math` fixture evidence; `spell_calibration_eviction` records bounded-profile replacement. `legal_candidates` contains only normal-path casts confirmed by resource evidence. | | Magic training | `goal_candidate`, `goal_selection`, and `goal_result` expose overflow arbitration. `action_result` with `action="magic_training"` emits a requested `engine_path` record and a success or failure `engine_verification` record with spell, reserve, current/maximum/predicted/lost mana, aggregated gain, aligned interval/remaining time, loaded cost, mana delta, and magic progression. `magic_training_fixture` with `source="authoritative_forecast"` is controlled fixture evidence, not a live cast. | | Actions | `action_result`, `target_changed`, `service_discovered`, `service_provider_rejected`, `npc_reply`, `npc_capability_audit`, and `stuck` record externally relevant attempts, discovery health, provider fallback, and outcomes. | -| Hunting | `hunt_region_candidate`, `hunt_region_scan`, `hunt_region_selection`, `hunt_region_outcome`, `hunt_challenge_frontier`, `hunt_scope_exhausted`, `hunt_region_patrol`, `hunt_supply_reserve`, and `hunt_area_entered` expose planner inputs, recovery assumptions, bounded shortlist and patrol validation, active-combat and kill evidence, reserve changes, area activation, frontier updates, and bounded exhaustion. `action_result` with `action="hunt_waypoint"` records patrol completion. | +| Hunting | `hunt_region_candidate`, `hunt_region_scan`, `hunt_region_selection`, `hunt_region_outcome`, `hunt_challenge_frontier`, `hunt_scope_exhausted`, `hunt_region_patrol`, `hunt_supply_reserve`, and `hunt_area_entered` expose planner inputs, recovery assumptions, incremental route and patrol validation, active-combat and kill evidence, reserve changes, area activation, frontier updates, and bounded exhaustion. Candidate records include calibration source/sample count and transport plausibility. Outcomes report performance evidence qualification; selections report bounded route-rejection counts. `action_result` with `action="hunt_waypoint"` records patrol completion. | | Navigation | `navigation_progress` records bounded recovery such as oscillation suppression; `npc_travel` records verified travel success or failure. Lifecycle records include loaded shop-provider, spell-trainer, travel-offer, and opaque travel-offer counts. | | Liquidation | `sell_loot_candidate`, `sell_loot_plan`, `sell_loot_defer`, and `sell_loot_withdraw` expose source depot and seller selection, manifest batches, revenue, fare, time and danger costs, utility, bounded scan progress, defer reasons, and verified inventory/depot deltas. | | Health | `summary` reports cumulative timing, action, failure, stuck, and suppression counters every 60 seconds. | diff --git a/docs/testing.md b/docs/testing.md index 394fcb90..4bd2692d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -87,12 +87,23 @@ reserve and receives at most one mana pool of credit, not repeated refill cycles No current-health spending, future food, looted supplies, other spells, equipment regeneration, expected profit, or observed supply calibration is assumed. -The existing 256-candidate scoring turns and eight-route shortlist remain bounded. -Route validation now compares the safe shortlist instead of accepting its first -safe entry: at most one outbound or return path per turn. Actual travel time and -outbound/return potion reserves update the budget before final selection. This -can take more turns and does not promise the whole-map optimum. Potion changes -or mana loss invalidate the planning snapshot. +Before scoring, transport feasibility processes eight loaded offers per turn +against an immutable player/resource snapshot. Shared compact component +reachability snapshots replace per-arrival full-graph distance arrays. Each planning +session captures an immutable offer catalog from current provider positions and +offers; provider movement does not repeatedly cancel active planning. Repeated bounded +passes retain the cheapest affordable label and establish true multihop +connectivity. Scoring then remains bounded at 256 candidates per turn. The cheap +full-atlas pass keeps only nonlethal, sustainable candidates with static +connectivity or a plausible eligible registered transport chain. Survivors retain normal policy order; no +local, remote, tested, untested, or income quota changes that order. Route +validation checks at most one outbound, depot-exit, or supply path per turn and +continues beyond failed candidates. Once a safe candidate exists, it skips each +candidate whose no-travel XP bound and income tier cannot beat that choice, but +continues to any later competitive candidate. Actual travel time and outbound/return +potion reserves update the budget before final +selection. Potion changes, mana loss, cancellation, or snapshot invalidation +stop the incremental session safely. `hunt_region_candidate` reports `supply_estimate_source=static_duration_budget`, `supply_budget_fits`, `supply_expected_damage`, `supply_regeneration_healing`, @@ -137,8 +148,78 @@ and budget-fit flags, and the selection rule. Their source is the real trainer and payment path: two potions plus 270 gold learn Exura and retain both potions/100 gold; 269 gold rejects it. Existing spell persistence, shortlist, adaptive-challenge, and hunt-planning assertions remain in place. -Map extraction, actual route integration, and real combat consumption still need -live validation; the synthetic contrast alone does not establish sustainable XP. +Map extraction and real combat consumption still need live validation; the +synthetic contrast alone does not establish sustainable XP. + +## Adaptive challenge and remote hunt routes + +Challenge evidence now requires at least 60 active combat seconds and three +kills. Zero to two potions, or routine Exura casts while the sampled mana floor +stays healthy, can support a bounded `+0.10` strong-evidence step. A single +recovery never causes backoff by itself. Three potions cause backoff only at one +or more uses per active combat minute; slower use holds the frontier. Danger, +death, critical health, critical mana during healing, and burst potion pressure +back off by `0.10`. The frontier is capped at `0.60`; predicted-lethal combat, +route danger, and reserves remain separate hard gates. `hunt_challenge_frontier` +reports p10 health and mana percentages plus potions per active combat minute. + +The atlas no longer discards a hunt only because static walking/tool topology +cannot reach it. Before expensive routing, a disconnected candidate must match a +plausible chain of registered travel arrivals whose loaded offers satisfy current +level, premium, opaque-condition, and aggregate-fare requirements. All cheap +survivors compete together using calibrated XP and estimated travel; there is no +remote probe, unobserved slot, execution randomization, or local/remote quota. +Each scheduler turn validates at most one outbound, depot-exit, or supply route. +Validation compares static navigation with registered NPC travel, including +actual offer conditions, fare, travel time, and danger. After route reserves are reconciled, modeled potion use or an available +potion count at the route threshold also requires a safe route from the depot to +a loaded potion seller. The combined outbound, exit, and +supply fares must leave the recovery spending reserve intact. A changed route is +accepted only if its complete current fare still leaves the later-phase fares +and current potion-target cost protected; each paid NPC step repeats that check. +Free routes are never rejected for a cash shortfall, and the protected potion +cost decreases after restocking. Exit-route validation reserves only later supply +fare, not the exit fare a second time. Protected post-hunt depot exits cannot use +the legacy unsafe-route fallback. + +A selected hunt must have a safe route to a usable depot near the hunt area; it +need not return to the selection origin. Depot preflight keeps one approach for +every distinct loaded depot ID, and supply preflight keeps one approach for every +loaded matching provider. Neither list has an arbitrary eight/four truncation. +Both validate one route per turn. Normal post-hunt depot +discovery also permits NPC travel and replans from the bot's current position. +For example, a Carlin departure may select a Darashia hunt and then use a +Darashia depot. No city or home mapping is encoded. + +Candidate telemetry exposes `topology_reachable`, `transport_plausible`, +`calibration_source`, `calibration_sample_count`, `outbound_npc_travel`, +`outbound_fare`, `exit_npc_travel`, `exit_fare`, `supply_npc_travel`, +`supply_fare`, the chosen `exit_depot_destination`, and bounded rejection reasons +such as `transport_requirements_unavailable`, `safe_depot_exit_unavailable`, and +`travel_fare_breaks_recovery_reserve`. Selection records aggregate +`route_rejection_counts`. Hunt outcomes report `performance_observed` and +`performance_evidence_reason`. + +`sh server/tests/playerbot_contracts.sh` covers routine Exura and potion use, +challenge and performance evidence qualification, shared correction averaging, +variant-local correction priority, exclusion of default/invalid entries, +non-weighting of repeated outings, more than eight failed route candidates before +a winner, planning cancellation between bounded scoring turns, absence of route +quotas, travel-offer requirements, combined fare reserves, and switching from a +coarse provider route to the live local interaction range. `server/tests/playerbotdepotworkflow_test.cpp` +checks that post-hunt depot ranking uses the current hunt area instead of the +departure city. The focused `remote_hunt` gameplay fixture raises the seeded +Knight to level 15 in Carlin, restricts only the fixture's route candidate queue to atlas candidates outside +static topology, and requires a registered NPC travel success, remote hunt entry, +a safe local depot preflight, and the same depot during the real post-hunt workflow: + +```powershell +pwsh -File scripts/test-playerbot-gameplay.ps1 -Scenario remote_hunt +``` + +This proves the integrated runtime path against the loaded map and NPC offers. It +does not calibrate static potion forecasts from observed use or guarantee that a +remote hunt beats a local hunt in ordinary selection. ## Sustained hunt eligibility and gross coin income @@ -239,24 +320,15 @@ all-over-budget fallback and emergency/route reserves. Missing recovery offers retain the existing unaffordable-reserve behavior. Funds changes invalidate an in-progress planning snapshot. There is no personality framework. -Before routing, the planner also checks whether the maximum outbound plus return -potion reserve allowed by the existing route-risk ceiling could make supplies -low and recovery unaffordable. Only then does it diversify the eight-candidate -route shortlist. Supply-fit tiers still precede over-budget tiers, and lower -potion demand still precedes higher demand when neither fits. Within an -oversubscribed tier, half the remaining slots (rounded up) retain current-policy -order; the rest take distinct coin-ranked alternatives, with stable ties. For a -single large tier this is four policy choices plus four income alternatives. -Income never displaces a better supply tier or bypasses suitability/reachability. - -The shortlist is computed once per completed planning session. Raw scored order, -region IDs, initial selection, and fixture observations are unchanged. Both -outbound and return routes still require the existing safety checks; reconciled -reserves and affordability govern final preference. Affordable or plentiful -worst-case recovery retains the ordinary shortlist. `hunt_region_scan` reports -`route_shortlist_policy` (`current_policy` or `supply_tier_policy_and_coin`) and -`route_shortlist_size`. This bounded diversification does not promise the global -best route. +Before routing, supply-fit tiers still precede over-budget tiers, and lower +potion demand still precedes higher demand when neither fits. Under cash pressure, +coin income remains the tie-breaker inside that normal policy; it does not receive +reserved route slots. Both outbound and return routes retain the existing safety, +reserve, and affordability checks. The planner incrementally replaces rejected +candidates from the complete cheap-viable queue. It stops after exhausting that +finite queue or when no remaining optimistic bound can beat the validated choice. +`hunt_region_scan` reports `route_candidate_policy=all_cheap_viable_ranked` and +`route_candidate_count`. `hunt_region_candidate` adds `sustained_eligible`, `reachable_spawns`, `replenishing_spawns`, `minimum_away_interval_ratio`, @@ -516,6 +588,11 @@ Each selected scenario owns six environment settings: `PLAYERBOT_GAMEPLAY_MODE` and `PLAYERBOT_DEPOT_MOVE_CASE` (`normal`). `Invoke-Scenario` applies these defaults before the body; individual cases may then override them. It restores incoming values after success or failure. +The `mainland_loop` fixture keeps its configured 10-second execution deadline for +fast cycle, restart, and persistence coverage, but scores hunt candidates against +a 900-second planning horizon. This prevents the fixture-only deadline from making +every real travel route consume the whole forecast; normal playerbots use the same +configured duration for planning and execution. Skipped scenarios do not touch the environment. Suite CLI options (including `-TimeoutSeconds`) and unrelated environment settings remain unchanged; this is test-harness ownership, not a change to production configuration. diff --git a/scripts/playerbot-gameplay/assertions-progression.ps1 b/scripts/playerbot-gameplay/assertions-progression.ps1 index 7418f1d6..1d4c0788 100644 --- a/scripts/playerbot-gameplay/assertions-progression.ps1 +++ b/scripts/playerbot-gameplay/assertions-progression.ps1 @@ -321,10 +321,35 @@ function Assert-HuntRegionPlanningEvents { $events = @(ConvertFrom-PlayerbotLogs -Logs $Logs) $scored = @($events | Where-Object { - $_.event -eq "hunt_region_scan" -and $_.phase -in @("scoring_started", "scored") + $_.event -eq "hunt_region_scan" -and $_.phase -eq "scored" }) - $build = @($scored | Where-Object { $_.cache -eq "build" }) - $hit = @($scored | Where-Object { $_.cache -eq "hit" }) + $planningStarted = @($events | Where-Object { + $_.event -eq 'hunt_region_scan' -and $_.phase -eq 'planning_started' + }) + $startedBuild = @($planningStarted | Where-Object { $_.cache -eq 'build' }) + $startedHit = @($planningStarted | Where-Object { $_.cache -eq 'hit' }) + $completedScoring = @($scored | Where-Object { + $_.candidate_count -gt 0 -and $_.scored_candidate_count -eq $_.candidate_count -and + $_.suitable_candidate_count -ge 1 -and $_.route_candidate_count -ge 1 + }) + $completedBuildScoring = @($completedScoring | Where-Object { $_.cache -eq 'build' }) + $buildLifecycleValid = $completedBuildScoring.Count -ge 1 -and @($completedBuildScoring | Where-Object { + $completion = $_ + @($startedBuild | Where-Object { + $_.candidate_count -eq $completion.candidate_count -and + $_.snapshot_time_us -eq $completion.snapshot_time_us -and + $_.clustering_time_us -eq $completion.clustering_time_us + }).Count -ge 1 + }).Count -eq $completedBuildScoring.Count + $cacheReuseValid = $startedHit.Count -ge 2 -and @($startedHit | Where-Object { + $hitStart = $_ + $hitStart.candidate_count -le 0 -or $hitStart.scored_candidate_count -ne 0 -or + $hitStart.route_candidate_count -ne 0 -or $hitStart.snapshot_time_us -ne 0 -or + $hitStart.clustering_time_us -ne 0 -or + @($completedScoring | Where-Object { $_.candidate_count -eq $hitStart.candidate_count }).Count -lt 1 + }).Count -eq 0 + $transportYields = @($events | Where-Object { $_.event -eq 'hunt_region_scan' -and $_.phase -eq 'transport_yield' }) + $scoringYields = @($events | Where-Object { $_.event -eq 'hunt_region_scan' -and $_.phase -eq 'scoring_yield' }) $cancelled = @($events | Where-Object { $_.event -eq "hunt_region_scan" -and $_.phase -eq "cancelled" }) $staleRevision = @($events | Where-Object { $_.event -eq "hunt_region_scan" -and $_.phase -eq "stale_revision" }) $topologyScans = @($events | Where-Object { @@ -339,8 +364,14 @@ function Assert-HuntRegionPlanningEvents { $completed = @($events | Where-Object { $_.event -eq "hunt_region_scan" -and $_.phase -eq "selected" -and $_.decision_latency_us -gt 0 }) + $selectedScan = if ($completed.Count -gt 0) { $completed[$completed.Count - 1] } else { $null } $selectedCandidate = if ($selection) { @($candidates | Where-Object { $_.region_id -eq $selection.region_id }) } else { @() } $reachableCandidates = @($candidates | Where-Object { $_.suitable -and $_.reachable -and $_.route_validated }) + $validatedVariantCount = @($reachableCandidates | Select-Object -ExpandProperty atlas_variant_id -Unique).Count + $finiteRouteQueue = $selectedScan -and $selectedScan.route_candidate_policy -eq 'all_cheap_viable_ranked' -and + $selectedScan.route_candidate_count -gt 0 -and $selectedScan.route_candidate_count -le $selectedScan.candidate_count -and + $validatedVariantCount -le $selectedScan.route_candidate_count -and $selectedScan.transport_offer_count -ge 0 -and + $selectedScan.transport_arrival_count -ge 1 $budgetCandidates = @($reachableCandidates | Where-Object { $_.supply_budget_fits }) $minimumPotions = if ($reachableCandidates.Count -gt 0) { ($reachableCandidates | Measure-Object -Property supply_expected_potions -Minimum).Minimum } else { $null } $preferredCandidates = if ($budgetCandidates.Count -gt 0) { $budgetCandidates } else { @@ -363,10 +394,10 @@ function Assert-HuntRegionPlanningEvents { $expectedThreshold = [Math]::Max(1, [Math]::Ceiling($expectedHealthLoss / $reserve.minimum_potion_healing)) $reserveFormulaValid = $reserve.return_threshold -eq $expectedThreshold } - if ($build.Count -lt 2 -or $hit.Count -lt 1 -or $cancelled.Count -ne 1 -or $staleRevision.Count -ne 1 -or - $topologyScans.Count -lt 1 -or $routeValidations.Count -lt 1 -or -not $selection -or $selectedCandidate.Count -ne 1 -or + if (-not $buildLifecycleValid -or -not $cacheReuseValid -or $transportYields.Count -lt 1 -or $scoringYields.Count -lt 1 -or + $cancelled.Count -ne 1 -or $staleRevision.Count -ne 1 -or $topologyScans.Count -lt 1 -or $routeValidations.Count -lt 1 -or -not $selection -or $selectedCandidate.Count -ne 1 -or $bestScore -eq $null -or [Math]::Abs($selectedCandidate[0].score - $bestScore) -gt 0.01 -or - $reachableCandidates.Count -gt 8 -or $selection.selection_rule -ne $selectionRule -or + -not $finiteRouteQueue -or $selection.selection_rule -ne $selectionRule -or -not $selectedCandidate[0].route_validated -or ($budgetCandidates.Count -gt 0 -and -not $selectedCandidate[0].supply_budget_fits) -or ($budgetCandidates.Count -eq 0 -and $selectedCandidate[0].supply_expected_potions -ne $minimumPotions) -or @@ -374,7 +405,7 @@ function Assert-HuntRegionPlanningEvents { -not $selectedCandidate[0].reachable -or $selectedCandidate[0].route_danger_cost -lt 0 -or $outsideLocalFixture.Count -lt 1 -or $completedTopology.Count -lt 1 -or -not $reserveFormulaValid) { - throw "Hunt planning telemetry was incomplete. build=$($build.Count), hit=$($hit.Count), cancelled=$($cancelled.Count), stale=$($staleRevision.Count), topology_scans=$($topologyScans.Count), route_validations=$($routeValidations.Count), selection=$($selection.Count), outside=$($outsideLocalFixture.Count), completed=$($completedTopology.Count)." + throw "Hunt planning telemetry was incomplete. started_build=$($startedBuild.Count), started_hit=$($startedHit.Count), completed_scoring=$($completedScoring.Count), build_lifecycle=$buildLifecycleValid, cache_reuse=$cacheReuseValid, transport_yields=$($transportYields.Count), scoring_yields=$($scoringYields.Count), cancelled=$($cancelled.Count), stale=$($staleRevision.Count), topology_scans=$($topologyScans.Count), route_candidates=$($selectedScan.route_candidate_count), validated_variants=$validatedVariantCount, route_validations=$($routeValidations.Count), selection=$($selection.Count), outside=$($outsideLocalFixture.Count), completed=$($completedTopology.Count)." } } @@ -409,6 +440,38 @@ function Assert-HuntAreaArrivalEvents { } } +function Assert-RemoteHuntEvents { + param([string]$Logs) + + $events = @(ConvertFrom-PlayerbotLogs -Logs $Logs) + $selection = @($events | Where-Object { $_.event -eq 'hunt_region_selection' -and $_.result -eq 'selected' }) | Select-Object -Last 1 + if (-not $selection) { throw 'Remote hunt did not select a region.' } + $candidate = @($events | Where-Object { + $_.event -eq 'hunt_region_candidate' -and $_.region_id -eq $selection.region_id -and $_.route_validated + }) | Select-Object -Last 1 + $entry = @($events | Where-Object { $_.event -eq 'hunt_area_entered' -and $_.region_id -eq $selection.region_id }) | Select-Object -First 1 + $depot = @($events | Where-Object { $_.event -eq 'action_result' -and $_.action -eq 'depot_discover' -and $_.result -eq 'success' }) | Select-Object -Last 1 + $travel = @($events | Where-Object { $_.event -eq 'npc_travel' -and $_.result -eq 'success' }) + $boatTravel = @($travel | Where-Object { $_.npc_name -like 'Captain *' }) + $return = @($events | Where-Object { $_.event -eq 'action_result' -and $_.action -eq 'return' -and $_.result -eq 'started' }) + $terminal = @($events | Where-Object { $_.event -eq 'terminal' }) + $remoteDistance = if ($candidate) { + [Math]::Max([Math]::Abs($candidate.center.x - 32360), [Math]::Abs($candidate.center.y - 31782)) + } else { 0 } + $depotMatchesPreflight = $candidate -and $depot -and + $depot.approach.z -eq $candidate.exit_depot_destination.z -and + [Math]::Max([Math]::Abs($depot.approach.x - $candidate.exit_depot_destination.x), + [Math]::Abs($depot.approach.y - $candidate.exit_depot_destination.y)) -le 2 + $darashiaRegion = $candidate -and $candidate.center.x -ge 33100 -and $candidate.center.x -le 33350 -and + $candidate.center.y -ge 32300 -and $candidate.center.y -le 32650 + if (-not $candidate -or -not $darashiaRegion -or $candidate.topology_reachable -or -not $candidate.outbound_npc_travel -or + $candidate.outbound_fare -le 0 -or $candidate.exit_npc_travel -or $remoteDistance -le 100 -or + -not $entry -or $travel.Count -lt 1 -or $boatTravel.Count -lt 1 -or $return.Count -lt 1 -or + -not $depotMatchesPreflight -or $terminal.Count -ne 0) { + throw "Remote hunt integration failed. candidate=$([bool]$candidate), darashia=$darashiaRegion, distance=$remoteDistance, travel=$($travel.Count), boat_travel=$($boatTravel.Count), entry=$([bool]$entry), return=$($return.Count), depot_match=$depotMatchesPreflight, terminal=$($terminal.Count)." + } +} + function Assert-SupplyBudgetEvents { param([string]$Logs) $fixtures = @(ConvertFrom-PlayerbotLogs -Logs $Logs | Where-Object { @@ -434,7 +497,7 @@ function Assert-AdaptiveChallengeEvents { $events = @(ConvertFrom-PlayerbotLogs -Logs $Logs) $frontier = @($events | Where-Object { $_.event -eq "hunt_challenge_frontier" }) $idle = @($frontier | Where-Object { $_.result -eq "insufficient_active_combat" -and $_.active_combat_seconds -eq 0 -and $_.kills -eq 0 }) - $noKill = @($frontier | Where-Object { $_.result -eq "insufficient_active_combat" -and $_.active_combat_seconds -ge 30 -and $_.kills -eq 0 }) + $noKill = @($frontier | Where-Object { $_.result -eq "insufficient_active_combat" -and $_.active_combat_seconds -ge 60 -and $_.kills -eq 0 }) $escalated = @($frontier | Where-Object { $_.result -eq "escalated" }) $invalidEscalation = @($escalated | Where-Object { $_.active_combat_seconds -lt $_.minimum_active_combat_seconds -or $_.kills -lt $_.minimum_kills @@ -463,10 +526,10 @@ function Assert-AdaptiveChallengeEvents { if ($idle.Count -ne 1 -or $noKill.Count -ne 1 -or $invalidEscalation.Count -ne 0 -or $escalated.Count -ne 3 -or $backoff.Count -ne 2 -or $deathBackoff.Count -ne 1 -or $hold.Count -ne 2 -or [Math]::Abs($escalated[0].frontier_before - 0.20) -gt 0.001 -or - [Math]::Abs($escalated[1].frontier_after - 0.25) -gt 0.001 -or - [Math]::Abs($escalated[2].frontier_after - 0.225) -gt 0.001 -or - [Math]::Abs($backoff[0].frontier_after - 0.20) -gt 0.001 -or - [Math]::Abs($deathBackoff[0].frontier_after - 0.175) -gt 0.001 -or + [Math]::Abs($escalated[1].frontier_after - 0.40) -gt 0.001 -or + [Math]::Abs($escalated[2].frontier_after - 0.40) -gt 0.001 -or + [Math]::Abs($backoff[0].frontier_after - 0.30) -gt 0.001 -or + [Math]::Abs($deathBackoff[0].frontier_after - 0.30) -gt 0.001 -or $hold[0].hold_qualifying_hunts -ne 1 -or $hold[1].hold_qualifying_hunts -ne 0 -or $fixture.Count -ne 1 -or -not $fixture[0].recovery_spell_legal -or $fixture[0].recovery_spell_casts -lt 1 -or $fixture[0].equipment_pressure_after -gt $fixture[0].equipment_pressure_before -or diff --git a/scripts/playerbot-gameplay/scenarios-progression.ps1 b/scripts/playerbot-gameplay/scenarios-progression.ps1 index 57715608..d5e5db0c 100644 --- a/scripts/playerbot-gameplay/scenarios-progression.ps1 +++ b/scripts/playerbot-gameplay/scenarios-progression.ps1 @@ -140,6 +140,17 @@ $arrivalLogs = Wait-ForLog -Pattern '"event":"target_changed".*"reason":"visible_monster"' Assert-HuntAreaArrivalEvents -Logs $arrivalLogs } + Invoke-Scenario -Name "remote_hunt" -DefaultTimeoutSeconds 600 -Body { + Invoke-Compose down --volumes --remove-orphans + $env:PLAYERBOT_GAMEPLAY_MODE = "remote_hunt" + $env:PLAYERBOT_HUNT_DURATION_SECONDS = "900" + Invoke-Compose up --detach + Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST HUNT_MAINLAND_LOADOUT_PASS remote_hunt' | Out-Null + Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST REMOTE_HUNT_START' | Out-Null + Wait-ForLog -Pattern '"event":"hunt_area_entered"' | Out-Null + $remoteLogs = Wait-ForLog -Pattern '"event":"action_result".*"action":"depot_discover".*"result":"success"' + Assert-RemoteHuntEvents -Logs $remoteLogs + } } if ($CombatReadiness) { diff --git a/scripts/test-playerbot-gameplay.ps1 b/scripts/test-playerbot-gameplay.ps1 index d17945a9..f7a3b11e 100644 --- a/scripts/test-playerbot-gameplay.ps1 +++ b/scripts/test-playerbot-gameplay.ps1 @@ -76,7 +76,7 @@ $scenarioCatalog = @( "stamina_bonus_projection", "stamina_boundary_projection", "stamina_normal_projection", "hunt_region_planning", "combat_readiness_ready", "combat_readiness_upgrade", "combat_readiness_missing_weapon", "combat_readiness_supplies", "combat_readiness_no_food", "combat_readiness_low_wealth", "combat_readiness_food_capacity", - "combat_readiness_retention", "hunt_area_arrival", "equipment_offer_shadow_upgrade", "equipment_offer_shadow_unaffordable", + "combat_readiness_retention", "hunt_area_arrival", "remote_hunt", "equipment_offer_shadow_upgrade", "equipment_offer_shadow_unaffordable", "equipment_offer_shadow_no_upgrade", "equipment_purchase", "equipment_purchase_resume", "equipment_purchase_provider_moved", "equipment_purchase_provider_unreachable", "equipment_purchase_space", "equipment_purchase_rejected", "adaptive_challenge", "mainland_equipment_reward", "oracle_departure", "oracle_level_eight_interrupt", "oracle_level_eight_recovery", @@ -94,8 +94,8 @@ foreach ($scenarioName in $scenarioCatalog) { throw "Duplicate gameplay scenario name: $scenarioName" } } -if ($scenarioCatalog.Count -ne 90) { - throw "The gameplay scenario catalog must contain 90 scenarios; found $($scenarioCatalog.Count)." +if ($scenarioCatalog.Count -ne 91) { + throw "The gameplay scenario catalog must contain 91 scenarios; found $($scenarioCatalog.Count)." } $requestedScenarioNames = @($Scenario | ForEach-Object { $_ -split ',' } | Where-Object { $_ }) $exactScenarioSelection = $requestedScenarioNames.Count -gt 0 diff --git a/scripts/test-playerbot-hunt-fixture.lua b/scripts/test-playerbot-hunt-fixture.lua index b66eb5f1..43126b53 100644 --- a/scripts/test-playerbot-hunt-fixture.lua +++ b/scripts/test-playerbot-hunt-fixture.lua @@ -29,7 +29,8 @@ SKILL_FIST, SKILL_CLUB, SKILL_SWORD, SKILL_AXE, SKILL_DISTANCE, SKILL_SHIELD, SK ITEM_GOLD_COIN = 2148 CreatureEvent = function() return {register = function() end} end Town = function(id) assert(id == 4); return {} end -Game = {getExperienceForLevel = function(level) assert(level == 8); return seeded.experience end} +local experienceByLevel = {[8] = seeded.experience, [15] = 37800} +Game = {getExperienceForLevel = function(level) return assert(experienceByLevel[level]) end} PlayerbotGameplayFixture = {} local root = 'server/tests/playerbot-gameplay/includes/' dofile(root .. 'constants.inc') @@ -58,11 +59,16 @@ local function login(selectedMode, fault, starter) equip(CONST_SLOT_AMMO, 2050) -- firstitems.lua runs before the fixture if fault == 'tool' then inventory[2120] = 0 end local vocation, health, position = 4, 1, nil + local level, experience = seeded.level, seeded.experience local player = { getName = function() return F.botName end, getId = function() return 3 end, - getLevel = function() return seeded.level end, - getExperience = function() return seeded.experience end, + getLevel = function() return level end, + getExperience = function() return experience end, + addExperience = function(_, amount) + experience = experience + amount + if experience >= experienceByLevel[15] then level = 15 end + end, getVocation = function() return {getId = function() return vocation end} end, setVocation = function(_, value) assert(value == 4, 'hunt fixture restored Rookgaard'); vocation = value; return true end, setTown = function() return true end, @@ -105,12 +111,15 @@ local function login(selectedMode, fault, starter) assert(skills[skill] == wanted and tries[skill] == 0) end assert(health == seeded.health and position.x == seeded.posx and position.y == seeded.posy and position.z == seeded.posz) + assert(level == (mode == 'remote_hunt' and 15 or 8)) assert(inventory[7618] == 10 and inventory[8704] == 0) assert(suppressed == (mode == 'hunt_planning' and 1 or 0)) assert(output[1]:find('HUNT_MAINLAND_LOADOUT_PASS ' .. mode, 1, true)) - assert(output[2] == 'PLAYERBOT_GAMEPLAY_TEST ' .. (mode == 'hunt_planning' and 'HUNT_PLANNING_START' or 'HUNT_AREA_ARRIVAL_START')) + local start = mode == 'hunt_planning' and 'HUNT_PLANNING_START' or + mode == 'hunt_area_arrival' and 'HUNT_AREA_ARRIVAL_START' or 'REMOTE_HUNT_START' + assert(output[2] == 'PLAYERBOT_GAMEPLAY_TEST ' .. start) end -for _, selectedMode in ipairs({'hunt_planning', 'hunt_area_arrival'}) do +for _, selectedMode in ipairs({'hunt_planning', 'hunt_area_arrival', 'remote_hunt'}) do login(selectedMode, nil, false) login(selectedMode, nil, true) assert(login(selectedMode, 'unequipped'):find('did not equip seeded item', 1, true)) diff --git a/scripts/test-playerbot-supply-assertions.ps1 b/scripts/test-playerbot-supply-assertions.ps1 index 952c395a..3377b3af 100644 --- a/scripts/test-playerbot-supply-assertions.ps1 +++ b/scripts/test-playerbot-supply-assertions.ps1 @@ -63,4 +63,61 @@ $rejected = @(@{ event = 'spell_candidate'; spell = 'Light Healing'; price = 170 $checkRejected = { param($logs) Assert-LowSupplySpellTrainingEvents -Logs $logs -Unaffordable } Test-Evidence $rejected $checkRejected Test-Evidence ($rejected + $spell[3]) $checkRejected $true -Write-Host 'Supply and Exura telemetry assertion contracts passed.' + +# Hunt planning may validate more than eight candidates. The finite queue is +# bounded by the selected scan's cheap-viable candidate count, not a quota. +$huntPlanning = @( + @{ event = 'hunt_region_scan'; phase = 'planning_started'; cache = 'hit'; selection_strategy = 'atlas_topology_selection' + snapshot_time_us = 0; clustering_time_us = 0; candidate_count = 20; scored_candidate_count = 0 + route_candidate_count = 0; transport_offer_count = 12; transport_arrival_count = 1 } + @{ event = 'hunt_region_scan'; phase = 'cancelled' } + @{ event = 'hunt_region_scan'; phase = 'planning_started'; cache = 'hit'; selection_strategy = 'atlas_topology_selection' + snapshot_time_us = 0; clustering_time_us = 0; candidate_count = 20; scored_candidate_count = 0 + route_candidate_count = 0; transport_offer_count = 12; transport_arrival_count = 1 } + @{ event = 'hunt_region_scan'; phase = 'stale_revision' } + @{ event = 'hunt_region_scan'; phase = 'planning_started'; cache = 'build'; selection_strategy = 'atlas_topology_selection' + snapshot_time_us = 2; clustering_time_us = 3; candidate_count = 20; scored_candidate_count = 0 + route_candidate_count = 0; transport_offer_count = 12; transport_arrival_count = 1 } + @{ event = 'hunt_region_scan'; phase = 'transport_yield'; cache = 'build'; selection_strategy = 'atlas_topology_selection' } + @{ event = 'hunt_region_scan'; phase = 'scoring_yield'; cache = 'build'; selection_strategy = 'atlas_topology_selection' } + @{ event = 'hunt_region_scan'; phase = 'scored'; cache = 'build'; selection_strategy = 'atlas_topology_selection' + snapshot_time_us = 2; clustering_time_us = 3; candidate_count = 20; scored_candidate_count = 20 + suitable_candidate_count = 10; route_candidate_count = 10 } +) +for ($index = 1; $index -le 10; $index++) { + $huntPlanning += @{ + event = 'hunt_region_candidate'; region_id = $index; atlas_variant_id = $index + suitable = $true; reachable = $true; route_validated = $true; supply_budget_fits = $true + supply_expected_potions = 1; score = 101 - $index; topology_reachable = $true + topology_travel_steps = 10; route_danger_cost = 0; center = @{ x = 32400 + $index; y = 31800; z = 7 } + } +} +$huntPlanning += @( + @{ event = 'hunt_region_selection'; result = 'selected'; region_id = 1; selection_rule = 'supply_budget_then_xp' } + @{ event = 'hunt_supply_reserve'; source = 'selected_return_route'; route_danger_cost = 100 + maximum_health = 200; health_loss_cost = 1000; minimum_potion_healing = 125 + return_threshold = 1; restock_target = 10 } + @{ event = 'hunt_region_scan'; phase = 'selected'; cache = 'build'; selection_strategy = 'atlas_topology_selection' + topology_time_us = 1; decision_latency_us = 1; candidate_count = 20; route_candidate_count = 10 + route_candidate_policy = 'all_cheap_viable_ranked'; transport_offer_count = 12; transport_arrival_count = 3 } +) +$checkHuntPlanning = { param($logs) Assert-HuntRegionPlanningEvents -Logs $logs } +Test-Evidence $huntPlanning $checkHuntPlanning +$originalSelectedScan = $huntPlanning[$huntPlanning.Count - 1] +$huntPlanning[$huntPlanning.Count - 1] = $originalSelectedScan.Clone() +$huntPlanning[$huntPlanning.Count - 1].route_candidate_count = 8 +Test-Evidence $huntPlanning $checkHuntPlanning $true +$huntPlanning[$huntPlanning.Count - 1] = $originalSelectedScan + +$completedScore = @($huntPlanning | Where-Object { $_.event -eq 'hunt_region_scan' -and $_.phase -eq 'scored' })[0] +$completedScore.scored_candidate_count = 19 +Test-Evidence $huntPlanning $checkHuntPlanning $true +$completedScore.scored_candidate_count = 20 +$cacheHitStart = @($huntPlanning | Where-Object { + $_.event -eq 'hunt_region_scan' -and $_.phase -eq 'planning_started' -and $_.cache -eq 'hit' +})[0] +$cacheHitStart.snapshot_time_us = 1 +Test-Evidence $huntPlanning $checkHuntPlanning $true +$cacheHitStart.snapshot_time_us = 0 + +Write-Host 'Supply, Exura, and hunt-planning telemetry assertion contracts passed.' diff --git a/server/compose.yaml b/server/compose.yaml index 2eaed2df..95534f0f 100644 --- a/server/compose.yaml +++ b/server/compose.yaml @@ -51,7 +51,7 @@ services: build: . environment: PLAYERBOT_ENABLED: ${PLAYERBOT_ENABLED:-true} - PLAYERBOT_HUNT_DURATION_SECONDS: ${PLAYERBOT_HUNT_DURATION_SECONDS:-1500} + PLAYERBOT_HUNT_DURATION_SECONDS: ${PLAYERBOT_HUNT_DURATION_SECONDS:-2400} PLAYERBOT_RELOG_DELAY_SECONDS: ${PLAYERBOT_RELOG_DELAY_SECONDS:-5} PLAYERBOT_MAX_CONSECUTIVE_DEATHS: ${PLAYERBOT_MAX_CONSECUTIVE_DEATHS:-3} PLAYERBOT_SPEED_BONUS: ${PLAYERBOT_SPEED_BONUS:-300} diff --git a/server/config.lua b/server/config.lua index 7b13c757..22a27bdd 100644 --- a/server/config.lua +++ b/server/config.lua @@ -5,7 +5,7 @@ blockLogin = false blockLoginText = "Server is closed for bug fixing." showPackets = false playerbotEnabled = os.getenv("PLAYERBOT_ENABLED") ~= "false" -playerbotHuntDurationSeconds = tonumber(os.getenv("PLAYERBOT_HUNT_DURATION_SECONDS")) or 1500 +playerbotHuntDurationSeconds = tonumber(os.getenv("PLAYERBOT_HUNT_DURATION_SECONDS")) or 2400 playerbotRelogDelaySeconds = tonumber(os.getenv("PLAYERBOT_RELOG_DELAY_SECONDS")) or 5 playerbotMaxConsecutiveDeaths = tonumber(os.getenv("PLAYERBOT_MAX_CONSECUTIVE_DEATHS")) or 3 playerbotSpeedBonus = tonumber(os.getenv("PLAYERBOT_SPEED_BONUS")) or 300 diff --git a/server/src/game.cpp b/server/src/game.cpp index 7b944c17..f4eb935d 100644 --- a/server/src/game.cpp +++ b/server/src/game.cpp @@ -4916,11 +4916,12 @@ void Game::removePlayer(Player* player) void Game::addNpc(Npc* npc) { npcs[npc->getID()] = npc; + ++npcGeneration; } void Game::removeNpc(Npc* npc) { - npcs.erase(npc->getID()); + if (npcs.erase(npc->getID()) != 0) ++npcGeneration; } void Game::addMonster(Monster* monster) diff --git a/server/src/game.h b/server/src/game.h index 65b39f74..f439b48b 100644 --- a/server/src/game.h +++ b/server/src/game.h @@ -469,6 +469,7 @@ class Game const std::unordered_map& getPlayers() const { return players; } const std::map& getNpcs() const { return npcs; } + uint64_t getNpcGeneration() const { return npcGeneration; } const std::map& getMonsters() const { return monsters; } void addPlayer(Player* player); @@ -549,6 +550,7 @@ class Game WildcardTreeNode wildcardTree { false }; std::map npcs; + uint64_t npcGeneration = 0; std::map monsters; //list of items that are in trading state, mapped to the player diff --git a/server/src/playerbotcombat.cpp b/server/src/playerbotcombat.cpp index 826250e2..4bb67b4e 100644 --- a/server/src/playerbotcombat.cpp +++ b/server/src/playerbotcombat.cpp @@ -12,6 +12,7 @@ #include "playerbotcontroller.h" #include "playerbothuntregionadapter.h" +#include "playerbotnpccapabilities.h" #include "playerbottopology.h" #include "spells.h" @@ -22,7 +23,6 @@ extern Spells* g_spells; namespace { constexpr size_t maximumHuntCandidateTelemetry = 64; - constexpr size_t maximumHuntRouteCandidates = playerBotMaximumHuntRouteCandidates; constexpr uint64_t maximumTargetApproachExpandedNodes = 10000; double projectedHuntStaminaMultiplier(const Player& player, double availableHuntSeconds) @@ -52,8 +52,10 @@ namespace { observation.staminaMinutes = player.getStaminaMinutes(); observation.experience = player.getExperience(); observation.topologyGeneration = PlayerBotTopology::instance().generation(); + observation.npcGeneration = g_game.getNpcGeneration(); observation.canUseRope = g_game.findItemOfType(&player, ropeItemId, true) != nullptr; observation.canUseShovel = g_game.findItemOfType(&player, 2554, true) != nullptr; + observation.premium = player.isPremium(); observation.potions = static_cast(player).getItemTypeCount(recoveryPotionItemId(player.getVocationId())); observation.mana = player.getMana(); observation.funds = player.getMoney() + player.getBankBalance(); @@ -72,6 +74,23 @@ namespace { weapon ? weapon->getAttack() : 7, weapon ? player.getWeaponSkill(weapon) : player.getSkillLevel(SKILL_FIST), player.getAttackFactor()}; } + + std::shared_ptr> huntTransportCatalog() + { + // Capture providers once per planning session. NPC movement must not + // invalidate an active plan, while the next plan sees current positions + // and offers and owns an immutable view of them. + auto catalog = std::make_shared>(); + for (const auto& entry : g_game.getNpcs()) { + Npc* npc = entry.second; + if (!npc || !playerBotNpcHasCapability(*npc, PlayerBotNpcCapability::Travel)) continue; + for (const NpcTravelOffer& offer : npc->getTravelOffers()) { + catalog->push_back({npc->getPosition(), offer.destination, offer.price, offer.level, + offer.premium, offer.hasOpaqueCondition, offer.hasOpaqueAction}); + } + } + return catalog; + } } PlayerBotExpectedCorpse PlayerBotController::expectedCorpseFor(const Creature& target) const @@ -380,7 +399,7 @@ bool PlayerBotController::attackDefensiveThreat(Player* player, const Position& creature->getAttackedCreature() == player && player->canSee(creature->getPosition()) && Position::areInRange<1, 1, 0>(currentPosition, creature->getPosition()); }); - if (huntCoordinator.inTransit() || adjacentAttackers < 4) { + if (!huntCoordinator.transitBreakoutActive() && (huntCoordinator.inTransit() || adjacentAttackers < 4)) { for (Creature* creature : spectators) { if (!creature->getMonster() || creature->isRemoved() || creature->isDead() || !player->canSee(creature->getPosition()) || @@ -401,7 +420,10 @@ bool PlayerBotController::attackDefensiveThreat(Player* player, const Position& const bool blockerOnly = huntCoordinator.inTransit(); std::vector candidates; for (Creature* creature : spectators) { - const bool routeCritical = isRouteCritical(creature); + const bool adjacentAttacker = creature->getAttackedCreature() == player && + Position::areInRange<1, 1, 0>(currentPosition, creature->getPosition()); + const bool routeCritical = isRouteCritical(creature) || + (huntCoordinator.transitBreakoutActive() && adjacentAttacker); if (!creature->getMonster() || creature->isRemoved() || creature->isDead() || (!routeCritical && (blockerOnly || creature->getAttackedCreature() != player)) || !player->canSee(creature->getPosition()) || @@ -604,7 +626,8 @@ void PlayerBotController::recordActiveHuntCombat(const Player& player) } } } - huntCoordinator.sampleHuntCombat({active, now, player.getHealth(), player.getMaxHealth(), attackers}); + huntCoordinator.sampleHuntCombat({active, now, player.getHealth(), player.getMaxHealth(), + player.getMana(), player.getMaxMana(), attackers}); } void PlayerBotController::recordHuntRecovery(bool potion) @@ -636,7 +659,11 @@ void PlayerBotController::emitChallengeFrontier(const PlayerBotHuntChallengeUpda << ",\"minimum_active_combat_seconds\":" << update.minimumActiveCombatSeconds << ",\"minimum_kills\":" << update.minimumKills << ",\"minimum_health\":" << (update.combat.minimumHealth == std::numeric_limits::max() ? 0 : update.combat.minimumHealth) + << ",\"p10_health_percent\":" << static_cast(update.combat.p10HealthPercent) + << ",\"minimum_mana\":" << (update.combat.minimumMana == std::numeric_limits::max() ? 0 : update.combat.minimumMana) + << ",\"p10_mana_percent\":" << static_cast(update.combat.p10ManaPercent) << ",\"verified_recoveries\":" << update.verifiedRecoveries + << ",\"potion_recoveries_per_active_minute\":" << update.potionRecoveriesPerActiveMinute << ",\"retreat\":" << (std::strcmp(reason, "hunt_region_observed_danger") == 0 ? "true" : "false") << ",\"danger\":" << (update.combat.dangerObserved ? "true" : "false") << ",\"death\":" << (update.combat.deathObserved ? "true" : "false"); @@ -650,6 +677,7 @@ void PlayerBotController::emitHuntRegionCandidate(const PlayerBotHuntRegion& reg << "\"region_id\":" << region.id << ",\"atlas_site_id\":" << region.atlasSiteId << ",\"atlas_variant_id\":" << region.atlasVariantId + << ",\"atlas_revision\":" << region.atlasRevision << ",\"atlas_pockets\":" << region.atlasPocketCount << ",\"atlas_spawns\":" << region.atlasSpawnCount << ",\"atlas_floors\":" << region.atlasFloorCount @@ -676,6 +704,8 @@ void PlayerBotController::emitHuntRegionCandidate(const PlayerBotHuntRegion& reg << ",\"available_hunt_seconds\":" << region.availableHuntSeconds << ",\"observed_experience_per_minute\":" << region.observedExperiencePerMinute << ",\"observed_correction\":" << region.observedCorrection + << ",\"calibration_source\":" << jsonString(region.calibrationSource) + << ",\"calibration_sample_count\":" << region.calibrationSampleCount << ",\"stamina_minutes\":" << region.staminaMinutes << ",\"stamina_experience_multiplier\":" << region.staminaExperienceMultiplier << ",\"projected_experience\":" << region.projectedExperience @@ -715,9 +745,19 @@ void PlayerBotController::emitHuntRegionCandidate(const PlayerBotHuntRegion& reg << ",\"score\":" << region.score << ",\"travel_steps\":" << region.travelSteps << ",\"topology_reachable\":" << (region.topologyReachable ? "true" : "false") + << ",\"transport_plausible\":" << (region.transportPlausible ? "true" : "false") << ",\"topology_travel_steps\":" << region.topologyTravelSteps << ",\"route_danger_cost\":" << region.routeDangerCost << ",\"maximum_route_danger\":" << region.maximumRouteDanger + << ",\"outbound_npc_travel\":" << (region.outboundNpcTravel ? "true" : "false") + << ",\"outbound_fare\":" << region.outboundFare + << ",\"exit_npc_travel\":" << (region.exitNpcTravel ? "true" : "false") + << ",\"exit_fare\":" << region.exitFare + << ",\"supply_npc_travel\":" << (region.supplyNpcTravel ? "true" : "false") + << ",\"supply_fare\":" << region.supplyFare + << ",\"exit_depot_destination\":{\"x\":" << region.exitDepotDestination.x + << ",\"y\":" << region.exitDepotDestination.y << ",\"z\":" + << static_cast(region.exitDepotDestination.z) << '}' << ",\"suitable\":" << (region.suitable ? "true" : "false") << ",\"reachable\":" << (region.reachable ? "true" : "false") << ",\"rejection_reason\":" << (region.rejectionReason.empty() ? "null" : jsonString(region.rejectionReason)) @@ -747,11 +787,12 @@ void PlayerBotController::emitHuntRegionPlanning(const PlayerBotHuntPlanningSess fields << "\"phase\":" << jsonString(phase) << ",\"cache\":" << jsonString(planning.cacheHit() ? "hit" : "build") << ",\"snapshot_time_us\":" << planning.snapshotTimeUs() << ",\"clustering_time_us\":" << planning.clusteringTimeUs() << ",\"topology_time_us\":" << planning.topologyTimeUs() + << ",\"transport_offer_count\":" << planning.transportOfferCount() + << ",\"transport_arrival_count\":" << planning.transportArrivalCount() << ",\"scoring_time_us\":" << planning.scoringTimeUs() << ",\"candidate_count\":" << planning.totalCandidates() << ",\"scored_candidate_count\":" << planning.scoredCandidates() << ",\"suitable_candidate_count\":" << planning.suitableCandidates() - << ",\"route_shortlist_policy\":" << jsonString(planning.profile().diversifyIncomeRoutes ? - "supply_tier_policy_and_coin" : "current_policy") - << ",\"route_shortlist_size\":" << planning.routeCandidates().size() + << ",\"route_candidate_policy\":\"all_cheap_viable_ranked\"" + << ",\"route_candidate_count\":" << planning.routeCandidates().size() << ",\"yields\":" << planning.yields() << ",\"selection_strategy\":" << jsonString(planning.topologySelection() ? "atlas_topology_selection" : "atlas_geometric_selection") << ",\"challenge_frontier\":" << planning.profile().challengeFrontier << ",\"decision_latency_us\":" << latencyUs; @@ -780,6 +821,8 @@ void PlayerBotController::finishHuntRegion(const Player& player, const Position& << ",\"actual_experience_per_minute\":" << completion->performance.actualExperiencePerMinute << ",\"predicted_experience\":" << completion->region.projectedExperience << ",\"updated_observed_correction\":" << completion->performance.updatedCorrection + << ",\"performance_observed\":" << (completion->performance.observed ? "true" : "false") + << ",\"performance_evidence_reason\":" << jsonString(completion->performance.evidenceReason) << ",\"kills\":" << combat.kills << ",\"damage_taken\":" << combat.damageTaken << ",\"active_combat_seconds\":" << combat.activeSeconds << ",\"active_combat_uptime\":" << (completion->durationSeconds == 0 ? 0 : combat.activeSeconds / completion->durationSeconds) @@ -798,9 +841,12 @@ void PlayerBotController::finishHuntRegion(const Player& player, const Position& bool PlayerBotController::selectHuntRegion(Player& player, const Position& position, const char* reason, std::chrono::steady_clock::duration* retryAfter) { + updateSupplyRecovery(player, position); const auto now = std::chrono::steady_clock::now(); const PlayerBotHuntPlanningObservation fixtureObservation = fixtureDriver.huntPlanningObservation(); - const uint32_t duration = static_cast(std::max(1, g_config.getNumber(ConfigManager::PLAYERBOT_HUNT_DURATION_SECONDS))); + const uint32_t configuredDuration = static_cast( + std::max(1, g_config.getNumber(ConfigManager::PLAYERBOT_HUNT_DURATION_SECONDS))); + const uint32_t duration = fixtureDriver.huntPlanningDuration(configuredDuration); auto planningInput = [&]() { PlayerBotHuntRuntimePlanningInput input; input.player = huntPlayerObservation(player); @@ -812,9 +858,12 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit PlayerBotHuntRegionPlanner planner; const auto topologyStarted = std::chrono::steady_clock::now(); std::shared_ptr topologyDistances; + std::shared_ptr topologyReachability; if (PlayerBotTopology::instance().walkComponent(player.getPosition())) { topologyDistances = std::make_shared(PlayerBotTopology::instance().distancesFrom( player.getPosition(), input.player.canUseRope, input.player.canUseShovel, player.getLevel())); + topologyReachability = PlayerBotTopology::instance().reachabilityFrom( + player.getPosition(), input.player.canUseRope, input.player.canUseShovel, player.getLevel()); } const uint64_t topologyTimeUs = std::chrono::duration_cast( std::chrono::steady_clock::now() - topologyStarted).count(); @@ -822,18 +871,9 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit auto profile = huntPlanningFacts(player, huntCombatProfile(player)); profile.cashPressure = playerBotHuntCashPressure(profile.potionCount, huntPotionReturnThreshold, input.player.funds, recoverySpendingReserve(player, huntPotionRestockTarget)); - // Diversify only if a route within the existing risk ceiling could - // leave supplies low and recovery unaffordable. Do not route to test - // this bound, or change the initial scoring/fixture selection policy. - const PlayerBotNavigationRiskProfile risk; - const uint32_t maximumLegReserve = recoveryPotionRouteReserve(player.getVocationId(), player.getMaxHealth(), - static_cast(risk.maximumRouteHealthLoss * risk.healthLossCost), - static_cast(risk.healthLossCost)); - const uint32_t maximumCandidateReserve = static_cast(std::min(UINT32_MAX, - static_cast(maximumLegReserve) * 2)); - profile.diversifyIncomeRoutes = playerBotHuntCashPressure(profile.potionCount, maximumCandidateReserve, - input.player.funds, recoverySpendingReserve(player, recoveryPotionRestockTargetForReserve(maximumCandidateReserve))); - input.start = {{std::move(scan), std::move(profile), std::move(topologyDistances), topologyTimeUs}}; + + input.start = {{std::move(scan), std::move(profile), std::move(topologyDistances), + std::move(topologyReachability), huntTransportCatalog(), topologyTimeUs}}; } return input; }; @@ -845,6 +885,37 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit outcome = huntCoordinator.advancePlanning(planningInput(), now, refreshedObservation); outcome.staleRevision = true; } + if (!outcome.transportWork.empty()) { + std::vector observations; + observations.reserve(outcome.transportWork.size()); + for (const PlayerBotHuntRuntimeTransportWork& work : outcome.transportWork) { + std::optional best; + if (!work.arrivals) { + observations.push_back({work.offerIndex, std::nullopt}); + continue; + } + for (const PlayerBotHuntTransportArrival& source : *work.arrivals) { + if (!source.topologyReachability || !PlayerBotTopology::instance().reachable( + *source.topologyReachability, work.offer.provider) || source.fare > work.funds) continue; + if (!playerBotNpcTravelOfferEligible(work.playerLevel, work.playerPremium, + work.funds - source.fare, work.offer.minimumLevel, work.offer.premiumRequired, + work.offer.price, work.offer.opaqueCondition, work.offer.opaqueAction)) continue; + const uint32_t legSteps = playerBotNavigationDistance(source.position, work.offer.provider) + 1; + PlayerBotHuntTransportArrival arrival{work.offer.destination, + source.fare + work.offer.price, + static_cast(std::min(UINT32_MAX, + static_cast(source.estimatedSteps) + legSteps)), {}}; + if (!best || std::tie(arrival.fare, arrival.estimatedSteps) < + std::tie(best->fare, best->estimatedSteps)) best = std::move(arrival); + } + if (best) { + best->topologyReachability = PlayerBotTopology::instance().reachabilityFrom( + best->position, work.canUseRope, work.canUseShovel, work.playerLevel); + } + observations.push_back({work.offerIndex, std::move(best)}); + } + outcome = huntCoordinator.completeTransportWork(observations); + } if (!outcome.scoreWork.empty()) { const auto started = std::chrono::steady_clock::now(); std::vector scores; @@ -862,10 +933,14 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit } if (outcome.command == PlayerBotHuntRuntimeCommand::PlanningStarted || outcome.staleRevision) { huntRouteCandidateIndex = 0; - huntRouteCandidatesValidated = 0; huntRouteValidationCandidate.reset(); + huntDepotExitApproaches.clear(); + huntDepotExitCandidateIndex = 0; + huntSupplyExitApproaches.clear(); + huntSupplyExitCandidateIndex = 0; huntRouteBestCandidate.reset(); huntRouteRejectedVariants.clear(); + huntRouteFailureCounts.clear(); } fixtureDriver.observeHuntPlanning(outcome); if (outcome.command == PlayerBotHuntRuntimeCommand::PlanningCancelled) { @@ -880,7 +955,8 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit } if (const auto planning = huntCoordinator.planningSession(); planning && outcome.command != PlayerBotHuntRuntimeCommand::RegionSelected) { - const char* phase = outcome.command == PlayerBotHuntRuntimeCommand::PlanningStarted ? "scoring_started" : + const char* phase = outcome.command == PlayerBotHuntRuntimeCommand::PlanningStarted ? "planning_started" : + planning->transportPlanning() ? "transport_yield" : outcome.command == PlayerBotHuntRuntimeCommand::PlanningYield ? "scoring_yield" : outcome.command == PlayerBotHuntRuntimeCommand::PlanningScored ? "scored" : "planning_yield"; emitHuntRegionPlanning(*planning, position, phase); @@ -898,13 +974,31 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit return false; } if (!outcome.selectedRegion) return false; - const auto& routeCandidates = outcome.routeCandidates; + std::vector fixtureRouteCandidates; + const std::vector* routeCandidateSource = &outcome.routeCandidates; + if (fixtureDriver.remoteHuntScenario()) { + // Fixture-only: force the real Carlin-to-Darashia transport path without + // changing production scoring or encoding a service-base mapping. + for (const PlayerBotHuntRegion& candidate : outcome.candidates) { + const bool darashiaFixtureArea = candidate.center.x >= 33100 && candidate.center.x <= 33350 && + candidate.center.y >= 32300 && candidate.center.y <= 32650; + if (!candidate.suitable || !candidate.reachable || candidate.topologyReachable || !darashiaFixtureArea) continue; + fixtureRouteCandidates.push_back(candidate); + } + routeCandidateSource = &fixtureRouteCandidates; + } + const auto& routeCandidates = *routeCandidateSource; const PlayerBotNavigationRiskProfile risk; auto rejectRouteCandidate = [&](PlayerBotHuntRegion& candidate) { emitHuntRegionCandidate(candidate, position); + ++huntRouteFailureCounts[candidate.rejectionReason.empty() ? "unspecified" : candidate.rejectionReason]; huntRouteRejectedVariants.push_back(candidate.atlasVariantId); ++huntRouteCandidateIndex; huntRouteValidationCandidate.reset(); + huntDepotExitApproaches.clear(); + huntDepotExitCandidateIndex = 0; + huntSupplyExitApproaches.clear(); + huntSupplyExitCandidateIndex = 0; }; auto applyRouteRejections = [&]() { for (uint64_t variantId : huntRouteRejectedVariants) { @@ -912,17 +1006,18 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit } huntRouteRejectedVariants.clear(); }; - while (!huntRouteValidationCandidate && huntRouteCandidateIndex < routeCandidates.size() && - (!routeCandidates[huntRouteCandidateIndex].suitable || - !routeCandidates[huntRouteCandidateIndex].reachable)) { - ++huntRouteCandidateIndex; + if (!huntRouteValidationCandidate) { + const PlayerBotHuntRegion* validated = huntRouteBestCandidate ? &*huntRouteBestCandidate : nullptr; + huntRouteCandidateIndex = playerBotNextHuntCandidateToValidate( + routeCandidates, huntRouteCandidateIndex, validated); } - if (!huntRouteValidationCandidate && huntRouteCandidateIndex < routeCandidates.size() && - huntRouteCandidatesValidated < maximumHuntRouteCandidates) { + if (!huntRouteValidationCandidate && huntRouteCandidateIndex < routeCandidates.size()) { PlayerBotHuntRegion routed = routeCandidates[huntRouteCandidateIndex]; - ++huntRouteCandidatesValidated; - const PlayerBotNavigationRoutePlan routePlan = planCompleteNavigationRoute(player, routed.destination); + const PlayerBotNavigationRoutePlan routePlan = planHuntTravelRoute(player, position, routed.destination); routed.travelSteps = static_cast(routePlan.metrics.steps); + routed.outboundFare = routePlan.metrics.fare; + routed.outboundNpcTravel = std::any_of(routePlan.steps.begin(), routePlan.steps.end(), + [](const PlayerBotNavigationStep& step) { return step.action == PlayerBotNavigationAction::NpcTravel; }); routed.routeDangerCost = routePlan.metrics.dangerCost; routed.maximumRouteDanger = routePlan.metrics.maximumHealthLossPerSecond; const double travelSeconds = routePlan.metrics.estimatedTravelSeconds > 0 ? @@ -947,29 +1042,90 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit return false; } huntRouteValidationCandidate = std::move(routed); + huntDepotExitApproaches = huntDepotExitCandidates(player, huntRouteValidationCandidate->destination); + huntDepotExitCandidateIndex = 0; if (retryAfter) *retryAfter = std::chrono::milliseconds(SCHEDULER_MINTICKS); return false; } if (huntRouteValidationCandidate) { PlayerBotHuntRegion routed = *huntRouteValidationCandidate; - const PlayerBotNavigationRoutePlan returnPlan = planCompleteNavigationRoute(player, routed.destination, position); - if (returnPlan.metrics.result != PlayerBotNavigationResult::Reached) { - routed.suitable = false; - routed.rejectionReason = "return_route_unreachable"; - } else if (!playerBotNavigationRiskAccepts(risk, returnPlan.metrics.dangerCost, - returnPlan.metrics.maximumHealthLossPerSecond)) { - routed.suitable = false; - routed.rejectionReason = returnPlan.metrics.dangerCost > - static_cast(risk.maximumRouteHealthLoss * risk.healthLossCost) ? - "return_route_danger_above_tolerance" : "return_route_peak_danger_above_tolerance"; + if (!routed.routeValidated) { + if (huntDepotExitCandidateIndex >= huntDepotExitApproaches.size()) { + routed.suitable = false; + routed.rejectionReason = "safe_depot_exit_unavailable"; + rejectRouteCandidate(routed); + if (retryAfter) *retryAfter = std::chrono::milliseconds(SCHEDULER_MINTICKS); + return false; + } + const Position depotApproach = huntDepotExitApproaches[huntDepotExitCandidateIndex]; + PlayerBotNavigationRoutePlan returnPlan = planHuntTravelRoute(player, routed.destination, depotApproach); + const bool safeExit = returnPlan.metrics.result == PlayerBotNavigationResult::Reached && + playerBotNavigationRiskAccepts(risk, returnPlan.metrics.dangerCost, + returnPlan.metrics.maximumHealthLossPerSecond); + if (!safeExit) { + ++huntDepotExitCandidateIndex; + if (huntDepotExitCandidateIndex >= huntDepotExitApproaches.size()) { + routed.suitable = false; + routed.rejectionReason = "safe_depot_exit_unavailable"; + rejectRouteCandidate(routed); + } + if (retryAfter) *retryAfter = std::chrono::milliseconds(SCHEDULER_MINTICKS); + return false; + } + routed.routeValidated = true; + routed.returnRouteDangerCost = returnPlan.metrics.dangerCost; + routed.exitDepotDestination = depotApproach; + routed.exitFare = returnPlan.metrics.fare; + routed.exitNpcTravel = std::any_of(returnPlan.steps.begin(), returnPlan.steps.end(), + [](const PlayerBotNavigationStep& step) { return step.action == PlayerBotNavigationAction::NpcTravel; }); + const uint32_t returnReserve = recoveryPotionRouteReserve(player.getVocationId(), player.getMaxHealth(), + routed.returnRouteDangerCost, static_cast(risk.healthLossCost)); + const uint32_t outboundReserve = routed.routeDangerCost == 0 ? 0 : + recoveryPotionRouteReserve(player.getVocationId(), player.getMaxHealth(), routed.routeDangerCost, + static_cast(risk.healthLossCost)); + const uint32_t candidateReserve = static_cast(std::min(UINT32_MAX, + static_cast(returnReserve) + outboundReserve)); + routed.supplyProfile = huntPlanningFacts(player, huntCombatProfile(player)).supply; + routed.reconcileRecovery(candidateReserve, player.getMoney() + player.getBankBalance(), + recoverySpendingReserve(player, recoveryPotionRestockTargetForReserve(candidateReserve))); + const bool needsSupplyRoute = playerBotHuntNeedsSupplyRoute( + routed.supplyBudget.expectedPotions, routed.supplyProfile.potions, candidateReserve); + huntSupplyExitApproaches = needsSupplyRoute ? huntSupplyExitCandidates(player, depotApproach) : + std::vector{}; + huntSupplyExitCandidateIndex = 0; + huntRouteValidationCandidate = routed; + if (needsSupplyRoute && !huntSupplyExitApproaches.empty()) { + if (retryAfter) *retryAfter = std::chrono::milliseconds(SCHEDULER_MINTICKS); + return false; + } } - if (!routed.suitable) { - rejectRouteCandidate(routed); - if (retryAfter) *retryAfter = std::chrono::milliseconds(SCHEDULER_MINTICKS); - return false; + const bool needsSupplyRoute = playerBotHuntNeedsSupplyRoute( + routed.supplyBudget.expectedPotions, routed.supplyProfile.potions, + routed.supplyBudget.reservedPotions); + if (needsSupplyRoute && routed.supplyDestination == Position()) { + if (huntSupplyExitCandidateIndex >= huntSupplyExitApproaches.size()) { + routed.suitable = false; + routed.rejectionReason = "recovery_supply_route_unavailable"; + rejectRouteCandidate(routed); + if (retryAfter) *retryAfter = std::chrono::milliseconds(SCHEDULER_MINTICKS); + return false; + } + const Position supplyApproach = huntSupplyExitApproaches[huntSupplyExitCandidateIndex]; + PlayerBotNavigationRoutePlan supplyPlan = planHuntTravelRoute( + player, routed.exitDepotDestination, supplyApproach); + const bool safeSupply = supplyPlan.metrics.result == PlayerBotNavigationResult::Reached && + playerBotNavigationRiskAccepts(risk, supplyPlan.metrics.dangerCost, + supplyPlan.metrics.maximumHealthLossPerSecond); + if (!safeSupply) { + ++huntSupplyExitCandidateIndex; + if (retryAfter) *retryAfter = std::chrono::milliseconds(SCHEDULER_MINTICKS); + return false; + } + routed.supplyDestination = supplyApproach; + routed.supplyFare = supplyPlan.metrics.fare; + routed.supplyNpcTravel = std::any_of(supplyPlan.steps.begin(), supplyPlan.steps.end(), + [](const PlayerBotNavigationStep& step) { return step.action == PlayerBotNavigationAction::NpcTravel; }); } - routed.routeValidated = true; - routed.returnRouteDangerCost = returnPlan.metrics.dangerCost; const uint32_t returnReserve = recoveryPotionRouteReserve(player.getVocationId(), player.getMaxHealth(), routed.returnRouteDangerCost, static_cast(risk.healthLossCost)); const uint32_t outboundReserve = routed.routeDangerCost == 0 ? 0 : @@ -978,40 +1134,76 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit routed.supplyProfile = huntPlanningFacts(player, huntCombatProfile(player)).supply; const uint32_t candidateReserve = static_cast(std::min(UINT32_MAX, static_cast(returnReserve) + outboundReserve)); - routed.reconcileRecovery(candidateReserve, player.getMoney() + player.getBankBalance(), - recoverySpendingReserve(player, recoveryPotionRestockTargetForReserve(candidateReserve))); + const uint64_t recoveryReserve = recoverySpendingReserve( + player, recoveryPotionRestockTargetForReserve(candidateReserve)); + routed.recoveryPotionReserve = candidateReserve; + routed.reconcileRecovery(candidateReserve, player.getMoney() + player.getBankBalance(), recoveryReserve); + const uint64_t funds = player.getMoney() + player.getBankBalance(); + if (!playerBotHuntTravelAffordable(funds, recoveryReserve, routed.outboundFare, + routed.exitFare, routed.supplyFare)) { + routed.suitable = false; + routed.rejectionReason = "travel_fare_breaks_recovery_reserve"; + rejectRouteCandidate(routed); + if (retryAfter) *retryAfter = std::chrono::milliseconds(SCHEDULER_MINTICKS); + return false; + } emitHuntRegionCandidate(routed, position); if (!huntRouteBestCandidate || playerBotPreferHuntRegion(routed, *huntRouteBestCandidate)) { huntRouteBestCandidate = std::move(routed); } ++huntRouteCandidateIndex; huntRouteValidationCandidate.reset(); - if (huntRouteCandidateIndex < routeCandidates.size() && huntRouteCandidatesValidated < maximumHuntRouteCandidates) { + huntDepotExitApproaches.clear(); + huntDepotExitCandidateIndex = 0; + huntSupplyExitApproaches.clear(); + huntSupplyExitCandidateIndex = 0; + huntRouteCandidateIndex = playerBotNextHuntCandidateToValidate( + routeCandidates, huntRouteCandidateIndex, &*huntRouteBestCandidate); + if (huntRouteCandidateIndex < routeCandidates.size()) { if (retryAfter) *retryAfter = std::chrono::milliseconds(SCHEDULER_MINTICKS); return false; } } + auto routeFailureCounts = [&]() { + std::ostringstream fields; + fields << '{'; + bool first = true; + for (const auto& entry : huntRouteFailureCounts) { + if (!first) fields << ','; + first = false; + fields << jsonString(entry.first) << ':' << entry.second; + } + fields << '}'; + return fields.str(); + }; std::optional safeSelection = std::move(huntRouteBestCandidate); huntRouteBestCandidate.reset(); if (!safeSelection) { applyRouteRejections(); huntRouteCandidateIndex = 0; - huntRouteCandidatesValidated = 0; emit("hunt_region_selection", position, - "\"result\":\"failed\",\"reason\":\"no_safe_route_in_shortlist\""); + "\"result\":\"failed\",\"reason\":\"no_safe_route_candidate\",\"route_rejection_counts\":" + + routeFailureCounts()); huntCoordinator.cancelPlanning(); if (retryAfter) *retryAfter = std::chrono::seconds(1); return false; } applyRouteRejections(); huntRouteCandidateIndex = 0; - huntRouteCandidatesValidated = 0; huntRouteValidationCandidate.reset(); + huntDepotExitApproaches.clear(); + huntDepotExitCandidateIndex = 0; + huntSupplyExitApproaches.clear(); + huntSupplyExitCandidateIndex = 0; huntPatrolValidationDestination.reset(); huntPatrolValidatedDestination.reset(); PlayerBotHuntRegion selected = std::move(*safeSelection); huntReturnRouteDangerCost = selected.returnRouteDangerCost; - huntReturnDestination = position; + huntReturnDestination = selected.exitDepotDestination; + huntTravelBudgetPhase = HuntTravelBudgetPhase::Outbound; + huntExitFareReserve = selected.exitFare; + huntSupplyFareReserve = selected.supplyFare; + huntRecoveryPotionReserve = selected.recoveryPotionReserve; const uint32_t healthLossCost = static_cast(risk.healthLossCost); huntPotionReturnThreshold = recoveryPotionRouteReserve( player.getVocationId(), player.getMaxHealth(), huntReturnRouteDangerCost, healthLossCost); @@ -1026,6 +1218,7 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit huntCoordinator.selectPlanningRegion(selected, huntPlayerObservation(player), now); emit("hunt_region_selection", position, "\"result\":\"selected\",\"region_id\":" + std::to_string(selected.id) + ",\"selection_rule\":" + jsonString(playerBotHuntSelectionRule(selected)) + + ",\"route_rejection_counts\":" + routeFailureCounts() + ",\"atlas_site_id\":" + std::to_string(selected.atlasSiteId) + ",\"atlas_variant_id\":" + std::to_string(selected.atlasVariantId) + ",\"atlas_pockets\":" + std::to_string(selected.atlasPocketCount) + ",\"atlas_spawns\":" + std::to_string(selected.atlasSpawnCount) + ",\"atlas_floors\":" + @@ -1090,6 +1283,10 @@ void PlayerBotController::processTraversal(Player* player, const Position& curre ",\"waypoint\":" + std::to_string(patrol.waypoint) + ",\"destination\":{\"x\":" + std::to_string(patrol.destination.x) + ",\"y\":" + std::to_string(patrol.destination.y) + ",\"z\":" + std::to_string(patrol.destination.z) + "}"); + if (fixtureDriver.remoteHuntScenario()) { + finishHuntAndReturn(player, currentPosition, "remote_hunt_fixture_complete"); + return; + } } if (progressionRuntime.readinessEquipmentPending()) { processReadinessEquipment(player, currentPosition); @@ -1101,9 +1298,9 @@ void PlayerBotController::processTraversal(Player* player, const Position& curre finishLootFailure(player, currentPosition, "corpse_inaccessible"); } processDefensiveCombat(player, currentPosition); - return; + if (!huntCoordinator.transitBreakoutActive()) return; } - if (attackDefensiveThreat(player, currentPosition)) { + if (attackDefensiveThreat(player, currentPosition) && !huntCoordinator.transitBreakoutActive()) { schedule(navigationInterval); return; } @@ -1287,19 +1484,33 @@ void PlayerBotController::processTraversal(Player* player, const Position& curre } const Position routeStart = validateReturn ? patrol.destination : currentPosition; const Position routeDestination = validateReturn ? huntReturnDestination : patrol.destination; - const PlayerBotNavigationRoutePlan preflight = planCompleteNavigationRoute( - *player, routeStart, routeDestination, huntPatrolPreflightBlockedPositions, - playerBotNavigationMaximumExpandedNodes); + // The outbound plan is installed below, so it must include a real approach + // to the first transport NPC. Return preflight consumes metrics only. + const PlayerBotNavigationRoutePlan preflight = planHuntTravelRoute( + *player, routeStart, routeDestination, huntPatrolPreflightBlockedPositions, + validateReturn); telemetry.recordPathfinding(preflight.metrics.elapsed, preflight.metrics.result == PlayerBotNavigationResult::Reached); const bool routeReached = preflight.metrics.result == PlayerBotNavigationResult::Reached; - const bool routeSafe = routeReached && playerBotNavigationRiskAccepts( + const HuntTravelBudgetPhase budgetPhase = validateReturn ? HuntTravelBudgetPhase::Exit : + HuntTravelBudgetPhase::Outbound; + const bool routeAffordable = routeReached && huntTravelFareAffordable( + *player, preflight.metrics.fare, budgetPhase); + const bool routeSafe = routeAffordable && playerBotNavigationRiskAccepts( patrolRisk, preflight.metrics.dangerCost, preflight.metrics.maximumHealthLossPerSecond); if (!routeSafe) { navigation.plan = preflight.metrics; navigation.routeUnavailable = true; - navigation.routeUnsafe = routeReached; - if (navigation.routeUnsafe) { + navigation.routeUnsafe = routeAffordable; + if (routeReached && !routeAffordable) { + emit("navigation_progress", currentPosition, + "\"result\":\"skipped\",\"reason\":\"route_fare_breaks_hunt_reserve\",\"phase\":\"patrol_preflight\",\"direction\":" + + jsonString(validateReturn ? "return" : "outbound") + ",\"fare\":" + + std::to_string(preflight.metrics.fare) + ",\"future_fare_reserve\":" + + std::to_string(huntTravelFutureFareReserve(budgetPhase)) + + ",\"recovery_funds_reserve\":" + std::to_string(recoverySpendingReserve( + *player, recoveryPotionRestockTargetForReserve(huntRecoveryPotionReserve)))); + } else if (navigation.routeUnsafe) { emit("navigation_progress", currentPosition, "\"result\":\"skipped\",\"reason\":\"route_danger_above_tolerance\",\"phase\":\"patrol_preflight\",\"direction\":" + jsonString(validateReturn ? "return" : "outbound") + ",\"destination\":{\"x\":" + diff --git a/server/src/playerbotcontroller.cpp b/server/src/playerbotcontroller.cpp index b145a206..c3047d81 100644 --- a/server/src/playerbotcontroller.cpp +++ b/server/src/playerbotcontroller.cpp @@ -15,6 +15,8 @@ #include "playerbotnpccapabilities.h" #include "playerbottopology.h" +#include + // Playerbot lifecycle, scheduling, navigation execution, and telemetry. using namespace playerbot; @@ -537,10 +539,22 @@ bool PlayerBotController::executeNavigationStep(Player* player, const PlayerBotN !offer.hasOpaqueAction && offer.price == step.price && offer.level == step.minimumLevel && offer.premium == step.premium; }); - if (!offerAvailable || player->getLevel() < step.minimumLevel || (step.premium && !player->isPremium()) || - player->getMoney() + player->getBankBalance() < step.price || npc->getPosition().z != player->getPosition().z || + if (!offerAvailable || !playerBotNpcTravelOfferEligible( + player->getLevel(), player->isPremium(), player->getMoney() + player->getBankBalance(), + step.minimumLevel, step.premium, step.price, false, false) || + npc->getPosition().z != player->getPosition().z || std::max(Position::getDistanceX(npc->getPosition(), player->getPosition()), Position::getDistanceY(npc->getPosition(), player->getPosition())) > 3) return false; + if (!huntTravelFareAffordable(*player, step.price, huntTravelBudgetPhase)) { + telemetry.emit("npc_travel", player->getPosition(), + "\"result\":\"refused\",\"reason\":\"fare_breaks_hunt_reserve\",\"npc_id\":" + + std::to_string(step.npcId) + ",\"npc_name\":" + jsonString(npc->getName()) + + ",\"fare\":" + std::to_string(step.price) + ",\"future_fare_reserve\":" + + std::to_string(huntTravelFutureFareReserve(huntTravelBudgetPhase)) + + ",\"recovery_funds_reserve\":" + std::to_string(recoverySpendingReserve( + *player, recoveryPotionRestockTargetForReserve(huntRecoveryPotionReserve)))); + return false; + } npc->receiveSpeech(player, TALKTYPE_PRIVATE_PN, "hi"); for (const std::string& phrase : step.dialogue) { npc->receiveSpeech(player, TALKTYPE_PRIVATE_PN, phrase); @@ -552,7 +566,7 @@ bool PlayerBotController::executeNavigationStep(Player* player, const PlayerBotN } telemetry.emit("npc_travel", player->getPosition(), std::string("\"result\":") + jsonString(travelled ? "success" : "failed") + - ",\"npc_id\":" + std::to_string(step.npcId) + + ",\"npc_id\":" + std::to_string(step.npcId) + ",\"npc_name\":" + jsonString(npc->getName()) + ",\"destination\":{\"x\":" + std::to_string(step.expectedPosition.x) + ",\"y\":" + std::to_string(step.expectedPosition.y) + ",\"z\":" + std::to_string(static_cast(step.expectedPosition.z)) + "}"); @@ -795,7 +809,10 @@ PlayerBotNavigationRoutePlan PlayerBotController::planNavigationRoute(Player& pl std::numeric_limits::max(); const double directTravelSeconds = directSteps == std::numeric_limits::max() ? std::numeric_limits::infinity() : directSteps * player.getStepDuration() / 1000.0; - if (travelRoute->metrics.estimatedTravelSeconds < directTravelSeconds) { + const double paidCost = travelRoute->metrics.estimatedTravelSeconds + + travelRoute->metrics.fare / 10.0 + travelRoute->metrics.dangerCost / 10.0; + const double directCost = directTravelSeconds + (topologyRoute ? topologyRoute->dangerCost / 10.0 : 0); + if (paidCost < directCost) { travelRoute->metrics.elapsed = std::chrono::duration_cast( std::chrono::steady_clock::now() - startedAt); travelRoute->metrics.attempted = true; @@ -878,10 +895,13 @@ std::optional PlayerBotController::planNpcTravelRo std::vector states{source}; std::map stateIndices{{source, 0}}; const Position currentPosition = source; + const uint64_t availableMoney = player.getMoney() + player.getBankBalance(); for (Npc* npc : playerBotNpcProviders(g_game.getNpcs(), PlayerBotNpcCapability::Travel, currentPosition)) { for (const NpcTravelOffer& offer : npc->getTravelOffers()) { - if ((offer.level != 0 && player.getLevel() < offer.level) || (offer.premium && !player.isPremium()) || - offer.hasOpaqueCondition || offer.hasOpaqueAction || offer.destination == currentPosition || + if (!playerBotNpcTravelOfferEligible(player.getLevel(), player.isPremium(), availableMoney, + offer.level, offer.premium, offer.price, + offer.hasOpaqueCondition, offer.hasOpaqueAction) || + offer.destination == currentPosition || blockedPositions.find(npc->getPosition()) != blockedPositions.end()) continue; const auto unavailable = unavailableTravelOffers.find({npc->getID(), offer.destination}); if (unavailable != unavailableTravelOffers.end() && unavailable->second > std::chrono::steady_clock::now()) continue; @@ -919,7 +939,6 @@ std::optional PlayerBotController::planNpcTravelRo std::vector>> labels(states.size()); labels[0].push_back({0, 0}); queue.push({0, 0, 0, 0, 0, 0, 0, std::numeric_limits::max()}); - const uint64_t availableMoney = player.getMoney() + player.getBankBalance(); const PlayerBotNavigator navigator; const PlayerBotNavigationCostPolicy costPolicy = navigationCostPolicy(player); const uint64_t graphNodeBudget = estimateOnly ? maximumExpandedNodes : maximumExpandedNodes / 2; @@ -1112,12 +1131,19 @@ std::optional PlayerBotController::planNpcTravelRo bool firstSegmentComplete = true; if (!estimateOnly && firstSegment.coarse && firstSegment.destination != player.getPosition()) { if (graphExpandedNodes >= maximumExpandedNodes) return std::nullopt; + const bool localApproach = playerBotNpcTravelUsesLocalApproach( + player.getPosition(), selected.npc->getPosition(), npcLocalApproachDistance + 3); const uint64_t remainingNodeBudget = std::min( - maximumExpandedNodes - graphExpandedNodes, playerBotNavigationMaximumExpandedNodes - 1); + maximumExpandedNodes - graphExpandedNodes, + localApproach ? maximumNpcLocalPathNodes : playerBotNavigationMaximumExpandedNodes - 1); + const PlayerBotNavigationGoal approachGoal = localApproach ? + PlayerBotNavigationGoal::withinRange(selected.npc->getPosition(), 3, 3) : + PlayerBotNavigationGoal::exact(firstSegment.destination); PlayerBotNavigationRoutePlan incremental = planNavigationRoute( - player, PlayerBotNavigationGoal::exact(firstSegment.destination), blockedPositions, remainingNodeBudget); + player, approachGoal, blockedPositions, remainingNodeBudget, localApproach); graphExpandedNodes += incremental.metrics.expandedNodes; - if (incremental.metrics.result != PlayerBotNavigationResult::Reached || incremental.steps.empty()) { + if (incremental.metrics.result != PlayerBotNavigationResult::Reached || + (!localApproach && incremental.steps.empty())) { std::ostringstream fields; fields << "\"result\":\"failed\",\"npc_id\":" << selected.npc->getID() << ",\"route_result\":" << static_cast(incremental.metrics.result) @@ -1131,7 +1157,9 @@ std::optional PlayerBotController::planNpcTravelRo emit("npc_travel_approach", player.getPosition(), fields.str()); return std::nullopt; } - firstSegmentComplete = incremental.steps.back().expectedPosition == firstSegment.destination; + firstSegmentComplete = playerBotNpcTravelApproachComplete( + localApproach, !incremental.steps.empty(), + !incremental.steps.empty() && incremental.steps.back().expectedPosition == firstSegment.destination); PlayerBotNavigationRoutePlan firstPlan = std::move(incremental); firstSegment.stepCount = static_cast(firstPlan.steps.size()); firstSegment.movementCost = firstPlan.metrics.movementCost; @@ -1175,11 +1203,175 @@ std::optional PlayerBotController::planNpcTravelRo return route; } +uint64_t PlayerBotController::huntTravelFutureFareReserve(HuntTravelBudgetPhase phase) const +{ + if (phase == HuntTravelBudgetPhase::Outbound) { + return huntExitFareReserve > UINT64_MAX - huntSupplyFareReserve ? UINT64_MAX : + huntExitFareReserve + huntSupplyFareReserve; + } + return phase == HuntTravelBudgetPhase::Exit ? huntSupplyFareReserve : 0; +} + +bool PlayerBotController::huntTravelFareAffordable( + const Player& player, uint64_t fare, HuntTravelBudgetPhase phase) const +{ + if (phase == HuntTravelBudgetPhase::None || fare == 0) return true; + const uint64_t recoveryReserve = recoverySpendingReserve( + player, recoveryPotionRestockTargetForReserve(huntRecoveryPotionReserve)); + return playerBotHuntTravelPaymentAffordable(player.getMoney() + player.getBankBalance(), + recoveryReserve, fare, + huntTravelFutureFareReserve(phase)); +} + +PlayerBotNavigationRoutePlan PlayerBotController::planHuntTravelRoute( + Player& player, const Position& source, const Position& destination, + const std::set& blockedPositions, bool estimateOnly) const +{ + PlayerBotNavigationRoutePlan walking = planCompleteNavigationRoute(player, source, destination, blockedPositions); + auto paid = planNpcTravelRoute(player, source, destination, blockedPositions, + playerBotNavigationMaximumExpandedNodes, estimateOnly); + const PlayerBotNavigationRiskProfile risk; + auto safe = [&risk](const PlayerBotNavigationRoutePlan& route) { + return route.metrics.result == PlayerBotNavigationResult::Reached && + playerBotNavigationRiskAccepts(risk, route.metrics.dangerCost, + route.metrics.maximumHealthLossPerSecond); + }; + if (!paid || !safe(*paid)) return walking; + if (!safe(walking)) return std::move(*paid); + auto cost = [](const PlayerBotNavigationRoutePlan& route) { + return route.metrics.estimatedTravelSeconds + route.metrics.fare / 10.0 + route.metrics.dangerCost / 10.0; + }; + return cost(*paid) < cost(walking) ? std::move(*paid) : std::move(walking); +} + +std::vector PlayerBotController::huntDepotExitCandidates(Player& player, const Position& source) const +{ + struct Candidate { + Position approach; + uint32_t cost = 0; + bool topologyReachable = false; + }; + const bool canUseRope = g_game.findItemOfType(&player, playerbot::ropeItemId, true) != nullptr; + const bool canUseShovel = g_game.findItemOfType(&player, shovelToolItemId, true) != nullptr; + const PlayerBotTopologyDistances distances = PlayerBotTopology::instance().distancesFrom( + source, canUseRope, canUseShovel, player.getLevel()); + std::vector candidates; + for (const auto& entry : g_game.map.getDepotLockerPositions()) { + std::optional bestForDepot; + for (const Position& locker : entry.second) { + uint16_t lockerItemId = 0; + if (!findDepotLocker(locker, entry.first, lockerItemId)) continue; + for (int32_t xOffset = -1; xOffset <= 1; ++xOffset) { + for (int32_t yOffset = -1; yOffset <= 1; ++yOffset) { + if (xOffset == 0 && yOffset == 0) continue; + const Position approach(locker.x + xOffset, locker.y + yOffset, locker.z); + Tile* tile = g_game.map.getTile(approach); + if (!tile || tile->queryAdd(0, player, 1, FLAG_IGNOREBLOCKCREATURE) != RETURNVALUE_NOERROR) continue; + const auto topologyCost = PlayerBotTopology::instance().distanceTo(distances, approach); + Candidate candidate{approach, topologyCost.value_or(static_cast( + playerBotNavigationDistance(source, approach))), topologyCost.has_value()}; + if (!bestForDepot || std::make_tuple(!candidate.topologyReachable, candidate.cost, candidate.approach) < + std::make_tuple(!bestForDepot->topologyReachable, bestForDepot->cost, bestForDepot->approach)) { + bestForDepot = candidate; + } + } + } + } + if (bestForDepot) candidates.push_back(*bestForDepot); + } + std::sort(candidates.begin(), candidates.end(), [](const Candidate& left, const Candidate& right) { + return std::make_tuple(!left.topologyReachable, left.cost, left.approach) < + std::make_tuple(!right.topologyReachable, right.cost, right.approach); + }); + std::vector approaches; + approaches.reserve(candidates.size()); + for (const Candidate& candidate : candidates) approaches.push_back(candidate.approach); + return approaches; +} + +std::vector PlayerBotController::huntSupplyExitCandidates(Player& player, const Position& source) const +{ + struct Candidate { + Position approach; + uint32_t cost = 0; + bool topologyReachable = false; + }; + const bool canUseRope = g_game.findItemOfType(&player, playerbot::ropeItemId, true) != nullptr; + const bool canUseShovel = g_game.findItemOfType(&player, shovelToolItemId, true) != nullptr; + const PlayerBotTopologyDistances distances = PlayerBotTopology::instance().distancesFrom( + source, canUseRope, canUseShovel, player.getLevel()); + const uint16_t potionId = playerbot::recoveryPotionItemId(player.getVocationId()); + std::vector candidates; + for (Npc* npc : playerBotNpcProviders(g_game.getNpcs(), PlayerBotNpcCapability::Shop, source)) { + const bool sellsPotion = std::any_of(npc->getShopOffers().begin(), npc->getShopOffers().end(), + [potionId](const ShopInfo& offer) { return offer.itemId == potionId && offer.buyPrice != 0; }); + if (!sellsPotion) continue; + std::optional best; + for (int32_t xOffset = -3; xOffset <= 3; ++xOffset) { + for (int32_t yOffset = -3; yOffset <= 3; ++yOffset) { + if (xOffset == 0 && yOffset == 0) continue; + const Position approach(npc->getPosition().x + xOffset, npc->getPosition().y + yOffset, + npc->getPosition().z); + Tile* tile = g_game.map.getTile(approach); + if (!tile || tile->queryAdd(0, player, 1, FLAG_IGNOREBLOCKCREATURE) != RETURNVALUE_NOERROR) continue; + const auto topologyCost = PlayerBotTopology::instance().distanceTo(distances, approach); + Candidate candidate{approach, topologyCost.value_or(static_cast( + playerBotNavigationDistance(source, approach))), topologyCost.has_value()}; + if (!best || std::make_tuple(!candidate.topologyReachable, candidate.cost, candidate.approach) < + std::make_tuple(!best->topologyReachable, best->cost, best->approach)) best = candidate; + } + } + if (best) candidates.push_back(*best); + } + std::sort(candidates.begin(), candidates.end(), [](const Candidate& left, const Candidate& right) { + return std::make_tuple(!left.topologyReachable, left.cost, left.approach) < + std::make_tuple(!right.topologyReachable, right.cost, right.approach); + }); + std::vector approaches; + approaches.reserve(candidates.size()); + for (const Candidate& candidate : candidates) approaches.push_back(candidate.approach); + return approaches; +} + void PlayerBotController::onHealthGain(Creature* healer, const Creature& target, uint32_t gain) { survivalRuntime.observeHealthGain(healer && healer->getID() == playerId, target.getID() == playerId, gain); } +bool PlayerBotController::handleFixedTargetRouteExhausted(Player* player, const Position& currentPosition, + const PlayerBotNavigationRuntimeOutcome& outcome, std::chrono::steady_clock::time_point now, bool allowStop) +{ + if (!outcome.fixedTargetRouteExhausted) return false; + bool adjacentConfirmedHostileBlocker = false; + SpectatorVec spectators; + g_game.map.getSpectators(spectators, currentPosition); + for (Creature* creature : spectators) { + if (creature->getMonster() && !creature->isRemoved() && !creature->isDead() && + player->canSee(creature->getPosition()) && + Position::areInRange<1, 1, 0>(currentPosition, creature->getPosition()) && + navigationRuntime.isRouteCritical(creature->getID(), creature->getPosition(), now)) { + adjacentConfirmedHostileBlocker = true; + break; + } + } + const bool breakoutStarted = huntCoordinator.beginTransitBreakout( + outcome.fixedTargetRouteExhausted, adjacentConfirmedHostileBlocker, + outcome.routeUnavailable, now); + if (huntCoordinator.transitBreakoutActive()) { + if (breakoutStarted) { + emit("navigation_progress", currentPosition, + "\"result\":\"recovering\",\"reason\":\"transit_breakout\",\"duration_seconds\":30"); + } + return true; + } + // A previous breakout failed to free the tile and no blocker is currently + // attackable: continuing would loop the same no-progress plan sequence. + if (allowStop || huntCoordinator.transitBreakoutAttempted()) { + stop("navigation_route_unavailable", currentPosition); + } + return true; +} + bool PlayerBotController::processNavigation(Player* player, const Position& currentPosition, const Position& destination, PlayerBotNavigationRuntimeOutcome* navigationOutcome, uint64_t maximumExpandedNodes, bool sameFloorOnly, @@ -1220,8 +1412,26 @@ bool PlayerBotController::processNavigation(Player* player, const Position& curr std::min(fixturePlan.maximumExpandedNodes, routeNodeBudget), sameFloorOnly); } const PlayerBotPendingMovementResult movementResult = outcome.movementResult; + const bool positionalProgress = outcome.positionalProgress; outcome = navigationRuntime.observePlan({goal, std::move(routePlan), player->canDoAction(), false, now}); outcome.movementResult = movementResult; + outcome.positionalProgress = positionalProgress; + } + if (outcome.plan.attempted && !outcome.routeUnavailable && + !huntTravelFareAffordable(*player, outcome.plan.fare, huntTravelBudgetPhase)) { + outcome.routeUnavailable = true; + navigationRuntime.reset(); + if (navigationOutcome) *navigationOutcome = outcome; + telemetry.emit("navigation_progress", currentPosition, + "\"result\":\"skipped\",\"reason\":\"route_fare_breaks_hunt_reserve\",\"destination\":{\"x\":" + + std::to_string(destination.x) + ",\"y\":" + std::to_string(destination.y) + + ",\"z\":" + std::to_string(static_cast(destination.z)) + + "},\"fare\":" + std::to_string(outcome.plan.fare) + + ",\"future_fare_reserve\":" + std::to_string(huntTravelFutureFareReserve(huntTravelBudgetPhase)) + + ",\"recovery_funds_reserve\":" + std::to_string(recoverySpendingReserve( + *player, recoveryPotionRestockTargetForReserve(huntRecoveryPotionReserve)))); + schedule(blockedRouteRetryInterval); + return false; } if (risk && outcome.plan.attempted && !outcome.routeUnavailable) { if (!playerBotNavigationRiskAccepts(*risk, outcome.plan.dangerCost, @@ -1241,6 +1451,27 @@ bool PlayerBotController::processNavigation(Player* player, const Position& curr return false; } } + bool immediateEscapeStepOpen = outcome.nextStep.has_value(); + if (immediateEscapeStepOpen && outcome.nextStep->action == PlayerBotNavigationAction::Move) { + SpectatorVec spectators; + g_game.map.getSpectators(spectators, currentPosition); + immediateEscapeStepOpen = std::none_of(spectators.begin(), spectators.end(), [&outcome](Creature* creature) { + return creature->getMonster() && !creature->isRemoved() && !creature->isDead() && + creature->getPosition() == outcome.nextStep->target; + }); + } + if (huntCoordinator.observeTransitBreakoutNavigation(outcome.positionalProgress, immediateEscapeStepOpen)) { + if (huntCoordinator.hasDefensiveCombat()) { + finishDefensiveCombat(player, currentPosition, "skipped", "transit_breakout_route_open"); + } else { + resetNavigation(); + } + emit("navigation_progress", currentPosition, + "\"result\":\"recovered\",\"reason\":\"transit_breakout_route_open\""); + schedule(SCHEDULER_MINTICKS); + if (navigationOutcome) *navigationOutcome = outcome; + return false; + } if (navigationOutcome) *navigationOutcome = outcome; fixtureDriver.observeNavigationPlan(outcome.plan.attempted); if (outcome.destinationReached) { @@ -1307,11 +1538,42 @@ bool PlayerBotController::processNavigation(Player* player, const Position& curr ",\"z\":" + std::to_string(static_cast(outcome.plan.waypoint.z)) + "}"); telemetry.logActionFailure("navigate", "route_unavailable", currentPosition); if (outcome.fixedTargetRouteExhausted) { + bool adjacentConfirmedHostileBlocker = false; + SpectatorVec spectators; + g_game.map.getSpectators(spectators, currentPosition); + for (Creature* creature : spectators) { + if (creature->getMonster() && !creature->isRemoved() && !creature->isDead() && + player->canSee(creature->getPosition()) && + Position::areInRange<1, 1, 0>(currentPosition, creature->getPosition()) && + navigationRuntime.isRouteCritical(creature->getID(), creature->getPosition(), now)) { + adjacentConfirmedHostileBlocker = true; + break; + } + } + const bool breakoutStarted = huntCoordinator.beginTransitBreakout( + outcome.fixedTargetRouteExhausted, adjacentConfirmedHostileBlocker, + outcome.routeUnavailable, now); + if (huntCoordinator.transitBreakoutActive()) { + if (breakoutStarted) { + emit("navigation_progress", currentPosition, + "\"result\":\"recovering\",\"reason\":\"transit_breakout\",\"duration_seconds\":30"); + } + schedule(blockedRouteRetryInterval); + return false; + } stop("navigation_route_unavailable", currentPosition); } schedule(blockedRouteRetryInterval); return false; } + // Exhaustion can also accumulate while plans keep nominally succeeding + // (the only corridor step is occupied by a stationary monster). Escalate + // before dispatching another doomed step. + if (handleFixedTargetRouteExhausted(player, currentPosition, outcome, now, false)) { + schedule(blockedRouteRetryInterval); + if (navigationOutcome) *navigationOutcome = outcome; + return false; + } std::ostringstream fields; fields << "\"action\":\"plan\",\"result\":\"success\",\"steps\":" << outcome.plan.steps << ",\"expanded_nodes\":" << outcome.plan.expandedNodes @@ -1438,7 +1700,16 @@ void PlayerBotController::navigate() finishTraversalCombat(player, currentPosition, "transit_goal_changed"); } } - if (huntCoordinator.transitDefenseExpired(std::chrono::steady_clock::now())) { + const auto transitNow = std::chrono::steady_clock::now(); + if (huntCoordinator.transitBreakoutExpired(transitNow)) { + huntCoordinator.finishTransitBreakout(); + if (huntCoordinator.hasDefensiveCombat()) { + finishDefensiveCombat(player, currentPosition, "skipped", "transit_breakout_timeout"); + } + stop("navigation_route_unavailable", currentPosition); + return; + } + if (huntCoordinator.transitDefenseExpired(transitNow)) { finishDefensiveCombat(player, currentPosition, "skipped", "transit_combat_budget"); } const bool accessingReward = progressionRuntime.session().active(PlayerBotProgressionProcedure::PickupReward) && diff --git a/server/src/playerbotcontroller.h b/server/src/playerbotcontroller.h index 1c701663..3a6400da 100644 --- a/server/src/playerbotcontroller.h +++ b/server/src/playerbotcontroller.h @@ -25,6 +25,7 @@ #include "playerbotprogressionruntime.h" #include "playerbotprogressionplanners.h" #include "playerbotsurvivalruntime.h" +#include "playerbotsupplyrecovery.h" #include "playerbottestpolicy.h" #include "playerbotfixturedriver.h" #include "playerbottelemetry.h" @@ -157,6 +158,7 @@ class PlayerBotController : public std::enable_shared_from_this& blockedPositions, uint64_t maximumExpandedNodes, bool estimateOnly = false) const; + PlayerBotNavigationRoutePlan planHuntTravelRoute(Player& player, const Position& source, + const Position& destination, + const std::set& blockedPositions = {}, + bool estimateOnly = true) const; + std::vector huntDepotExitCandidates(Player& player, const Position& source) const; + std::vector huntSupplyExitCandidates(Player& player, const Position& source) const; + uint64_t huntTravelFutureFareReserve(HuntTravelBudgetPhase phase) const; + bool huntTravelFareAffordable(const Player& player, uint64_t fare, + HuntTravelBudgetPhase phase) const; uint32_t navigationDecisionDelay(const Player& player) const; @@ -472,6 +492,7 @@ class PlayerBotController : public std::enable_shared_from_this serviceTopologyDistances; Position serviceTopologyOrigin; bool serviceTopologyCanUseRope = false; @@ -532,10 +555,18 @@ class PlayerBotController : public std::enable_shared_from_this huntRouteValidationCandidate; + std::vector huntDepotExitApproaches; + size_t huntDepotExitCandidateIndex = 0; + std::vector huntSupplyExitApproaches; + size_t huntSupplyExitCandidateIndex = 0; std::optional huntRouteBestCandidate; std::vector huntRouteRejectedVariants; + std::map huntRouteFailureCounts; + HuntTravelBudgetPhase huntTravelBudgetPhase = HuntTravelBudgetPhase::None; + uint64_t huntExitFareReserve = 0; + uint64_t huntSupplyFareReserve = 0; + uint32_t huntRecoveryPotionReserve = 0; Position huntReturnDestination; uint32_t huntReturnRouteDangerCost = 0; std::optional huntPatrolValidationDestination; diff --git a/server/src/playerbotdepotworkflow.h b/server/src/playerbotdepotworkflow.h index 17ef38cb..2339dcf2 100644 --- a/server/src/playerbotdepotworkflow.h +++ b/server/src/playerbotdepotworkflow.h @@ -21,6 +21,11 @@ enum class PlayerBotDepotCommandType : uint8_t { }; enum class PlayerBotDepotOutcome : uint8_t { Pending, Ready, Success, Retry, Moved, Partial, Deferred, Rejected, Unavailable }; enum class PlayerBotDepotRouteResult : uint8_t { NotObserved, Reached, Unsafe, Unreachable }; +inline bool playerBotDepotRouteSafetyAccepted(bool routeSafe, bool validatingRiskFallback, + bool liquidationSource, bool protectedHuntExit) +{ + return routeSafe || (validatingRiskFallback && !liquidationSource && !protectedHuntExit); +} enum class PlayerBotDepotActionResult : uint8_t { None, SelectedLockerUnavailable, diff --git a/server/src/playerboteconomy.cpp b/server/src/playerboteconomy.cpp index 363689d1..5fba0969 100644 --- a/server/src/playerboteconomy.cpp +++ b/server/src/playerboteconomy.cpp @@ -78,29 +78,6 @@ uint32_t PlayerBotDispositionPolicy::sellQuantity(const PlayerBotEconomyInventor return inventory.itemCount > reserve ? inventory.itemCount - reserve : 0; } -PlayerBotEconomyRestockDecision PlayerBotDispositionPolicy::restock(const PlayerBotEconomyInventorySnapshot& inventory, - uint32_t unitPrice, uint32_t unitWeight, uint32_t returnThreshold, uint32_t restockTarget) const -{ - if (inventory.itemCount >= restockTarget || unitPrice == 0) { - return {}; - } - const uint32_t targetGap = restockTarget - inventory.itemCount; - const uint64_t totalMoney = inventory.money + inventory.bankBalance; - const uint32_t requiredGap = inventory.itemCount <= returnThreshold ? returnThreshold + 1 - inventory.itemCount : 0; - if (totalMoney / unitPrice < requiredGap) { - return {0, true}; - } - uint32_t amount = totalMoney / unitPrice >= targetGap ? targetGap : static_cast(std::min( - targetGap, totalMoney > carriedGoldReserve ? (totalMoney - carriedGoldReserve) / unitPrice : 0)); - if (inventory.itemCount <= returnThreshold) { - amount = std::max(amount, static_cast(std::min(requiredGap, totalMoney / unitPrice))); - } - if (unitWeight != 0) { - amount = std::min(amount, inventory.freeCapacity / unitWeight); - } - return {amount, false}; -} - uint32_t PlayerBotDispositionPolicy::bankWithdrawal(const PlayerBotEconomyInventorySnapshot& inventory, uint32_t coinWeight) const { uint32_t amount = static_cast(std::min(carriedGoldReserve, inventory.bankBalance)); diff --git a/server/src/playerboteconomy.h b/server/src/playerboteconomy.h index d7f391b8..fc0ac64c 100644 --- a/server/src/playerboteconomy.h +++ b/server/src/playerboteconomy.h @@ -68,9 +68,32 @@ class PlayerBotDispositionPolicy uint32_t protectedReserve(uint16_t itemId, bool food, uint16_t potionItemId) const; uint32_t sellQuantity(const PlayerBotEconomyInventorySnapshot& inventory, uint16_t reserve) const; PlayerBotEconomyRestockDecision restock(const PlayerBotEconomyInventorySnapshot& inventory, - uint32_t unitPrice, uint32_t unitWeight, - uint32_t returnThreshold = potionReturnThreshold, - uint32_t restockTarget = potionRestockTarget) const; + uint32_t unitPrice, uint32_t unitWeight, uint32_t returnThreshold = potionReturnThreshold, + uint32_t restockTarget = potionRestockTarget, bool survival = false) const + { + if (inventory.itemCount >= restockTarget || unitPrice == 0) { + return {}; + } + const uint32_t targetGap = restockTarget - inventory.itemCount; + const uint64_t totalMoney = inventory.money + inventory.bankBalance; + // Survival restock drops the unusable-partial-reserve guard and buys + // what the available gold affords, possibly nothing, instead of + // failing service. + const uint32_t requiredGap = !survival && inventory.itemCount <= returnThreshold ? + returnThreshold + 1 - inventory.itemCount : 0; + if (totalMoney / unitPrice < requiredGap) { + return {0, true}; + } + uint32_t amount = totalMoney / unitPrice >= targetGap ? targetGap : static_cast(std::min( + targetGap, totalMoney > carriedGoldReserve ? (totalMoney - carriedGoldReserve) / unitPrice : 0)); + if (inventory.itemCount <= returnThreshold) { + amount = std::max(amount, static_cast(std::min(requiredGap, totalMoney / unitPrice))); + } + if (unitWeight != 0) { + amount = std::min(amount, inventory.freeCapacity / unitWeight); + } + return {amount, false}; + } uint32_t bankWithdrawal(const PlayerBotEconomyInventorySnapshot& inventory, uint32_t coinWeight) const; }; diff --git a/server/src/playerbotfixturedriver.cpp b/server/src/playerbotfixturedriver.cpp index 3fcb3dca..5b35cab3 100644 --- a/server/src/playerbotfixturedriver.cpp +++ b/server/src/playerbotfixturedriver.cpp @@ -198,7 +198,8 @@ std::vector playerbot::PlayerBotFixtureDriver: PlayerBotHuntPolicy adaptivePolicy; auto evidence = [&](double seconds, uint32_t kills, uint32_t recoveries, bool death = false) { adaptivePolicy.resetCombatEvidence(); - adaptivePolicy.observeCombat({seconds != 0, seconds, player.getMaxHealth(), player.getMaxHealth(), 1}); + adaptivePolicy.observeCombat({seconds != 0, seconds, player.getMaxHealth(), player.getMaxHealth(), + player.getMana(), player.getMaxMana(), 1}); for (uint32_t i = 0; i < kills; ++i) adaptivePolicy.observeKill(); for (uint32_t i = 0; i < recoveries; ++i) adaptivePolicy.observeRecovery(true); if (death) adaptivePolicy.observeDeath(); @@ -210,16 +211,24 @@ std::vector playerbot::PlayerBotFixtureDriver: << ",\"active_combat_seconds\":" << update.combat.activeSeconds << ",\"active_combat_uptime\":" << update.activeCombatUptime << ",\"kills\":" << update.combat.kills << ",\"minimum_active_combat_seconds\":" << update.minimumActiveCombatSeconds << ",\"minimum_kills\":" << update.minimumKills << ",\"minimum_health\":" << (update.combat.minimumHealth == std::numeric_limits::max() ? 0 : update.combat.minimumHealth) - << ",\"verified_recoveries\":" << update.verifiedRecoveries << ",\"retreat\":false,\"danger\":" << (update.combat.dangerObserved ? "true" : "false") + << ",\"p10_health_percent\":" << static_cast(update.combat.p10HealthPercent) + << ",\"minimum_mana\":" << (update.combat.minimumMana == std::numeric_limits::max() ? 0 : update.combat.minimumMana) + << ",\"p10_mana_percent\":" << static_cast(update.combat.p10ManaPercent) + << ",\"verified_recoveries\":" << update.verifiedRecoveries + << ",\"potion_recoveries_per_active_minute\":" << update.potionRecoveriesPerActiveMinute + << ",\"retreat\":false,\"danger\":" << (update.combat.dangerObserved ? "true" : "false") << ",\"death\":" << (update.combat.deathObserved ? "true" : "false"); events.push_back({"hunt_challenge_frontier", fields.str()}); }; adaptivePolicy.resetCombatEvidence(); - adaptivePolicy.observeCombat({false, 30, player.getMaxHealth(), player.getMaxHealth(), 1}); + adaptivePolicy.observeCombat({false, 30, player.getMaxHealth(), player.getMaxHealth(), + player.getMana(), player.getMaxMana(), 1}); const double idle = adaptivePolicy.combatSummary().activeSeconds; - adaptivePolicy.observeCombat({true, 30, player.getMaxHealth(), player.getMaxHealth(), 1}); + adaptivePolicy.observeCombat({true, 30, player.getMaxHealth(), player.getMaxHealth(), + player.getMana(), player.getMaxMana(), 1}); const double active = adaptivePolicy.combatSummary().activeSeconds; - evidence(0, 0, 0); evidence(30, 0, 0); evidence(30, 1, 0); evidence(30, 1, 0); evidence(30, 1, 1); evidence(30, 1, 0); evidence(30, 1, 0); evidence(30, 1, 0); evidence(0, 0, 0, true); + evidence(0, 0, 0); evidence(60, 0, 0); evidence(60, 3, 0); evidence(60, 3, 0); evidence(60, 3, 3); + evidence(60, 3, 0); evidence(60, 3, 0); evidence(60, 3, 1); evidence(0, 0, 0, true); const Item* weapon = player.getWeapon(true); const PlayerBotCombatProfile profile{player.getLevel(), player.getMaxHealth(), player.getArmor(), player.getDefense(), weapon ? weapon->getAttack() : 7, weapon ? player.getWeaponSkill(weapon) : player.getSkillLevel(SKILL_FIST), player.getAttackFactor()}; PlayerBotHuntRegion current, equipped; diff --git a/server/src/playerbotfixturedriver.h b/server/src/playerbotfixturedriver.h index 1802d7e9..593d6408 100644 --- a/server/src/playerbotfixturedriver.h +++ b/server/src/playerbotfixturedriver.h @@ -57,6 +57,11 @@ namespace playerbot { bool mapRewardsEnabled() const { return !policy.equipmentPurchaseFixture; } PlayerBotFixtureHuntObservation huntObservation() const; bool startInHunt() const { return policy.startInHunt; } + bool remoteHuntScenario() const { return policy.remoteHuntFixture; } + uint32_t huntPlanningDuration(uint32_t configuredDurationSeconds) const + { + return playerBotFixtureHuntPlanningDuration(policy.mainlandLoopFixture, configuredDurationSeconds); + } std::vector huntPatrol() const; bool depotScenario() const { return policy.depotFixture; } bool spellCalibrationScenario() const { return policy.spellCalibrationFixture; } diff --git a/server/src/playerbothuntcoordinator.cpp b/server/src/playerbothuntcoordinator.cpp index b9e1551f..b4c214c9 100644 --- a/server/src/playerbothuntcoordinator.cpp +++ b/server/src/playerbothuntcoordinator.cpp @@ -89,6 +89,11 @@ PlayerBotHuntRuntimeOutcome PlayerBotHuntCoordinator::advancePlanning(const Play { return huntRuntime.advancePlanning(input, now, observation); } +PlayerBotHuntRuntimeOutcome PlayerBotHuntCoordinator::completeTransportWork( + const std::vector& observations) +{ + return huntRuntime.completeTransportWork(observations); +} PlayerBotHuntRuntimeOutcome PlayerBotHuntCoordinator::completeScoreWork(const std::vector& observations, uint64_t elapsedUs) { return huntRuntime.completeScoreWork(observations, elapsedUs); } std::optional PlayerBotHuntCoordinator::planningSession() const { return huntRuntime.planningSession(); } diff --git a/server/src/playerbothuntcoordinator.h b/server/src/playerbothuntcoordinator.h index d05917a1..9a58e2aa 100644 --- a/server/src/playerbothuntcoordinator.h +++ b/server/src/playerbothuntcoordinator.h @@ -95,13 +95,33 @@ class PlayerBotHuntCoordinator { return hasDefensiveCombat() && transitCombat.defenseExpired(now); } + bool beginTransitBreakout(bool routeExhausted, bool adjacentConfirmedHostileBlocker, + bool routeUnavailable, std::chrono::steady_clock::time_point now) + { + return transitCombat.beginBreakout( + routeExhausted, adjacentConfirmedHostileBlocker, routeUnavailable, now); + } + bool transitBreakoutActive() const { return transitCombat.breakoutActive(); } + bool transitBreakoutAttempted() const { return transitCombat.breakoutWasAttempted(); } + bool transitBreakoutExpired(std::chrono::steady_clock::time_point now) const + { + return transitCombat.breakoutExpired(now); + } + bool observeTransitBreakoutNavigation(bool positionalProgress, bool routeAvailable) + { + return transitCombat.observeBreakoutNavigation(positionalProgress, routeAvailable); + } + void finishTransitBreakout() { transitCombat.finishBreakout(); } void cancelPlanning(); + void setSupplyRecovery(bool degraded) { huntRuntime.setSupplyRecovery(degraded); } bool planningStartRequired(std::chrono::steady_clock::time_point now) const; bool planningActive() const; PlayerBotHuntRuntimeOutcome advancePlanning(const PlayerBotHuntRuntimePlanningInput& input, std::chrono::steady_clock::time_point now, const PlayerBotHuntPlanningObservation& observation = {}); + PlayerBotHuntRuntimeOutcome completeTransportWork( + const std::vector& observations); PlayerBotHuntRuntimeOutcome completeScoreWork(const std::vector& observations, uint64_t elapsedUs); std::optional planningSession() const; diff --git a/server/src/playerbothuntplanningsession.cpp b/server/src/playerbothuntplanningsession.cpp index e5658fad..dac44311 100644 --- a/server/src/playerbothuntplanningsession.cpp +++ b/server/src/playerbothuntplanningsession.cpp @@ -14,14 +14,19 @@ #include PlayerBotHuntPlanningSession::PlayerBotHuntPlanningSession(PlayerBotHuntPlanningStart start) : - candidateIndices(std::move(start.scan.candidateIndices)), planningReason(std::move(start.reason)), - planningStarted(start.started), planningProfile(std::move(start.profile)), planningSnapshot(std::move(start.snapshot)), - topologyDistances(std::move(start.topologyDistances)), + candidateIndices(std::move(start.scan.candidateIndices)), transportOffers(std::move(start.transportOffers)), + planningReason(std::move(start.reason)), planningStarted(start.started), planningProfile(std::move(start.profile)), + planningSnapshot(std::move(start.snapshot)), topologyDistances(std::move(start.topologyDistances)), candidateCount(static_cast(start.scan.candidateCount)), scanCacheHit(start.scan.cacheHit), scanSnapshotTimeUs(start.scan.snapshotTimeUs), scanClusteringTimeUs(start.scan.clusteringTimeUs), topologyDistanceTimeUs(start.topologyDistanceTimeUs) { scoredRegions.reserve(candidateCount); + if (start.originReachability && transportOffers && !transportOffers->empty()) { + transportArrivals.push_back({planningSnapshot.playerPosition, 0, 0, std::move(start.originReachability)}); + } else { + phase = Phase::Scoring; + } } bool PlayerBotHuntPlanningSession::invalidated(const PlayerBotHuntPlanningSnapshot& current) const @@ -31,12 +36,66 @@ bool PlayerBotHuntPlanningSession::invalidated(const PlayerBotHuntPlanningSnapsh current.potions != planningSnapshot.potions || current.mana < planningSnapshot.mana || current.funds != planningSnapshot.funds || current.topologyGeneration != planningSnapshot.topologyGeneration || + current.npcGeneration != planningSnapshot.npcGeneration || current.canUseRope != planningSnapshot.canUseRope || current.canUseShovel != planningSnapshot.canUseShovel || - current.excludedVariants != planningSnapshot.excludedVariants || current.cacheRevision != planningSnapshot.cacheRevision; + current.premium != planningSnapshot.premium || current.excludedVariants != planningSnapshot.excludedVariants || current.cacheRevision != planningSnapshot.cacheRevision; } void PlayerBotHuntPlanningSession::beginTurn() { + if (phase == Phase::Transport) { + transportTurnArrivals = std::make_shared>(transportArrivals); + } +} + +std::optional PlayerBotHuntPlanningSession::nextTransportWork( + uint32_t maximumOffers) +{ + if (phase != Phase::Transport || !transportOffers || transportOffersThisTurn >= maximumOffers || + nextTransportOffer >= transportOffers->size()) return std::nullopt; + ++transportOffersThisTurn; + const size_t index = nextTransportOffer++; + return PlayerBotHuntPlanningTransportWork{index, transportOffers->at(index), transportTurnArrivals}; +} + +void PlayerBotHuntPlanningSession::transportCompleted( + size_t offerIndex, std::optional arrival) +{ + if (phase != Phase::Transport || offerIndex >= (transportOffers ? transportOffers->size() : 0) || !arrival) return; + auto found = std::find_if(transportArrivals.begin(), transportArrivals.end(), [&arrival](const auto& current) { + return current.position == arrival->position; + }); + if (found == transportArrivals.end()) { + transportArrivals.push_back(std::move(*arrival)); + transportPassChanged = true; + } else if (std::tie(arrival->fare, arrival->estimatedSteps) < + std::tie(found->fare, found->estimatedSteps)) { + *found = std::move(*arrival); + transportPassChanged = true; + } +} + +PlayerBotHuntPlanningProgress PlayerBotHuntPlanningSession::completeTransport() +{ + transportOffersThisTurn = 0; + transportTurnArrivals.reset(); + if (phase != Phase::Transport) return PlayerBotHuntPlanningProgress::Scored; + if (transportOffers && nextTransportOffer < transportOffers->size()) { + ++yieldCount; + return PlayerBotHuntPlanningProgress::ScoringYield; + } + if (transportPassChanged) { + transportPassChanged = false; + nextTransportOffer = 0; + ++yieldCount; + return PlayerBotHuntPlanningProgress::ScoringYield; + } + planningProfile.transportArrivals.clear(); + for (const auto& arrival : transportArrivals) { + if (arrival.position != planningSnapshot.playerPosition) planningProfile.transportArrivals.push_back(arrival); + } + phase = Phase::Scoring; + return PlayerBotHuntPlanningProgress::Scored; } std::optional PlayerBotHuntPlanningSession::nextScoringWork(uint32_t maximumCandidates) @@ -67,7 +126,7 @@ PlayerBotHuntPlanningProgress PlayerBotHuntPlanningSession::completeScoring() region.id = regionId++; } refreshSuitableCandidates(); - routeShortlist = playerBotHuntRouteShortlist(scoredRegions, planningProfile.diversifyIncomeRoutes); + routeShortlist = playerBotHuntRouteCandidates(scoredRegions); phase = Phase::Ready; return PlayerBotHuntPlanningProgress::Scored; } diff --git a/server/src/playerbothuntplanningsession.h b/server/src/playerbothuntplanningsession.h index ef7b1301..f50e8293 100644 --- a/server/src/playerbothuntplanningsession.h +++ b/server/src/playerbothuntplanningsession.h @@ -27,9 +27,11 @@ struct PlayerBotHuntPlanningSnapshot { uint16_t staminaMinutes = 0; uint64_t cacheRevision = 0; uint64_t topologyGeneration = 0; + uint64_t npcGeneration = 0; std::set excludedVariants; bool canUseRope = false; bool canUseShovel = false; + bool premium = false; uint32_t potions = 0; uint32_t mana = 0; uint64_t funds = 0; @@ -40,11 +42,19 @@ struct PlayerBotHuntPlanningStart { PlayerBotHuntPlanningProfile profile; PlayerBotHuntPlanningSnapshot snapshot; std::shared_ptr topologyDistances; + std::shared_ptr originReachability; + std::shared_ptr> transportOffers; uint64_t topologyDistanceTimeUs = 0; std::string reason; std::chrono::steady_clock::time_point started; }; +struct PlayerBotHuntPlanningTransportWork { + size_t offerIndex = 0; + PlayerBotHuntTransportOffer offer; + std::shared_ptr> arrivals; +}; + struct PlayerBotHuntPlanningScoreWork { size_t candidateIndex = 0; }; @@ -66,6 +76,7 @@ class PlayerBotHuntPlanningSession explicit PlayerBotHuntPlanningSession(PlayerBotHuntPlanningStart start); bool invalidated(const PlayerBotHuntPlanningSnapshot& current) const; + bool transportPlanning() const { return phase == Phase::Transport; } bool scoring() const { return phase == Phase::Scoring; } const PlayerBotHuntPlanningProfile& profile() const { return planningProfile; } const PlayerBotHuntPlanningSnapshot& snapshot() const { return planningSnapshot; } @@ -78,12 +89,17 @@ class PlayerBotHuntPlanningSession uint64_t topologyTimeUs() const { return topologyDistanceTimeUs; } uint64_t scoringTimeUs() const { return totalScoringTimeUs; } uint32_t totalCandidates() const { return candidateCount; } + uint32_t transportOfferCount() const { return transportOffers ? static_cast(transportOffers->size()) : 0; } + uint32_t transportArrivalCount() const { return static_cast(transportArrivals.size()); } uint32_t scoredCandidates() const { return scoredCandidateCount; } uint32_t suitableCandidates() const { return suitableCandidateCount; } uint32_t yields() const { return yieldCount; } bool topologySelection() const { return topologyDistances != nullptr; } void beginTurn(); + std::optional nextTransportWork(uint32_t maximumOffers); + void transportCompleted(size_t offerIndex, std::optional arrival); + PlayerBotHuntPlanningProgress completeTransport(); std::optional nextScoringWork(uint32_t maximumCandidates); void scoreCompleted(PlayerBotHuntRegion region); void addScoringTime(uint64_t elapsedUs) { totalScoringTimeUs += elapsedUs; } @@ -96,6 +112,7 @@ class PlayerBotHuntPlanningSession void refreshSuitableCandidates(); enum class Phase : uint8_t { + Transport, Scoring, Ready, }; @@ -103,11 +120,17 @@ class PlayerBotHuntPlanningSession std::vector scoredRegions; std::vector routeShortlist; std::vector candidateIndices; + std::shared_ptr> transportOffers; + std::vector transportArrivals; + std::shared_ptr> transportTurnArrivals; std::string planningReason; std::chrono::steady_clock::time_point planningStarted; PlayerBotHuntPlanningProfile planningProfile; PlayerBotHuntPlanningSnapshot planningSnapshot; std::shared_ptr topologyDistances; + size_t nextTransportOffer = 0; + uint32_t transportOffersThisTurn = 0; + bool transportPassChanged = false; size_t nextScoringCandidate = 0; uint32_t scoringCandidatesThisTurn = 0; uint32_t yieldCount = 0; @@ -119,7 +142,7 @@ class PlayerBotHuntPlanningSession uint64_t scanClusteringTimeUs = 0; uint64_t topologyDistanceTimeUs = 0; uint64_t totalScoringTimeUs = 0; - Phase phase = Phase::Scoring; + Phase phase = Phase::Transport; }; #endif diff --git a/server/src/playerbothuntpolicy.cpp b/server/src/playerbothuntpolicy.cpp index 0251d4ea..392a1b5f 100644 --- a/server/src/playerbothuntpolicy.cpp +++ b/server/src/playerbothuntpolicy.cpp @@ -6,16 +6,26 @@ #include "playerbothuntpolicy.h" #include +#include #include namespace { constexpr double minimumChallengeFrontier = 0.10; - constexpr double maximumChallengeFrontier = 0.40; - constexpr double challengeEscalation = 0.025; - constexpr double challengeBackoff = 0.05; - constexpr double challengeHealthSafetyPercent = 85; - constexpr double minimumChallengeActiveSeconds = 30; - constexpr uint32_t minimumChallengeKills = 1; + // Keep exploration above the old 0.40 ceiling, but below parity threat. + // Predicted lethal and route danger remain independent hard gates. + constexpr double maximumChallengeFrontier = 0.60; + constexpr double challengeEscalation = 0.05; + constexpr double strongChallengeEscalation = 0.10; + constexpr double challengeBackoff = 0.10; + constexpr double challengeHealthSafetyPercent = 70; + constexpr double challengeCriticalHealthPercent = 35; + constexpr double challengeManaSafetyPercent = 35; + constexpr double challengeCriticalManaPercent = 20; + constexpr double minimumChallengeActiveSeconds = 60; + constexpr uint32_t minimumChallengeKills = 3; + constexpr double minimumPerformanceActiveSeconds = 60; + constexpr uint32_t minimumPerformanceKills = 3; + constexpr uint32_t minimumPerformanceOutingSeconds = 120; constexpr auto dangerObservationWindow = std::chrono::minutes(2); } @@ -49,9 +59,13 @@ void PlayerBotHuntPolicy::observeCombat(const PlayerBotHuntCombatSample& sample) } evidence.activeSeconds += std::max(0.0, sample.elapsedSeconds); evidence.minimumHealth = std::min(evidence.minimumHealth, sample.health); + evidence.minimumMana = std::min(evidence.minimumMana, sample.mana); const uint8_t healthPercent = sample.maximumHealth <= 0 ? 0 : static_cast(std::clamp( sample.health * 100 / sample.maximumHealth, 0, 100)); + const uint8_t manaPercent = sample.maximumMana == 0 ? 100 : static_cast(std::min( + static_cast(sample.mana) * 100 / sample.maximumMana, 100)); ++evidence.healthPercentSamples[healthPercent]; + ++evidence.manaPercentSamples[manaPercent]; evidence.maximumAttackerOverlap = std::max(evidence.maximumAttackerOverlap, sample.attackers); } @@ -62,7 +76,8 @@ void PlayerBotHuntPolicy::sampleCombat(const PlayerBotHuntCombatSnapshot& snapsh elapsedSeconds = std::chrono::duration(snapshot.observedAt - lastSample).count(); } lastSample = snapshot.observedAt; - observeCombat({snapshot.active, elapsedSeconds, snapshot.health, snapshot.maximumHealth, snapshot.attackers}); + observeCombat({snapshot.active, elapsedSeconds, snapshot.health, snapshot.maximumHealth, + snapshot.mana, snapshot.maximumMana, snapshot.attackers}); } void PlayerBotHuntPolicy::observeKill() @@ -108,23 +123,24 @@ PlayerBotHuntCombatSummary PlayerBotHuntPolicy::combatSummary() const summary.spellRecoveries = evidence.spellRecoveries; summary.maximumAttackerOverlap = evidence.maximumAttackerOverlap; summary.minimumHealth = evidence.minimumHealth; + summary.minimumMana = evidence.minimumMana; summary.dangerObserved = evidence.dangerObserved; summary.deathObserved = evidence.deathObserved; summary.healthPercentSamples = evidence.healthPercentSamples; - const uint32_t samples = std::accumulate(evidence.healthPercentSamples.begin(), evidence.healthPercentSamples.end(), 0U); - if (samples == 0) { - return summary; - } - const uint32_t threshold = (samples + 9) / 10; - uint32_t cumulative = 0; - for (uint8_t percent = 0; percent <= 100; ++percent) { - cumulative += evidence.healthPercentSamples[percent]; - if (cumulative >= threshold) { - summary.p10HealthPercent = percent; - return summary; + summary.manaPercentSamples = evidence.manaPercentSamples; + auto percentile10 = [](const std::array& samples, uint8_t fallback) { + const uint32_t count = std::accumulate(samples.begin(), samples.end(), 0U); + if (count == 0) return fallback; + const uint32_t threshold = (count + 9) / 10; + uint32_t cumulative = 0; + for (uint16_t percent = 0; percent <= 100; ++percent) { + cumulative += samples[percent]; + if (cumulative >= threshold) return static_cast(percent); } - } - summary.p10HealthPercent = 100; + return static_cast(100); + }; + summary.p10HealthPercent = percentile10(evidence.healthPercentSamples, 0); + summary.p10ManaPercent = percentile10(evidence.manaPercentSamples, 100); return summary; } @@ -139,11 +155,22 @@ PlayerBotHuntChallengeUpdate PlayerBotHuntPolicy::updateChallengeFrontier(const update.minimumKills = minimumChallengeKills; const bool enoughActiveCombat = update.combat.activeSeconds >= minimumChallengeActiveSeconds && update.combat.kills >= minimumChallengeKills; - const bool nearFullHealth = update.combat.minimumHealth != std::numeric_limits::max() && - static_cast(update.combat.minimumHealth) * 100 >= - static_cast(sample.maximumHealth) * challengeHealthSafetyPercent; - const bool backoff = update.combat.dangerObserved || update.combat.deathObserved || update.verifiedRecoveries != 0; - const bool qualifyingEasy = enoughActiveCombat && nearFullHealth && !backoff; + const bool lowHealthPressure = update.combat.p10HealthPercent < challengeHealthSafetyPercent; + const bool criticalHealth = update.combat.minimumHealth != std::numeric_limits::max() && + static_cast(update.combat.minimumHealth) * 100 < + static_cast(sample.maximumHealth) * challengeCriticalHealthPercent; + const bool manaPressure = update.combat.spellRecoveries != 0 && + update.combat.p10ManaPercent < challengeManaSafetyPercent; + const bool criticalMana = update.combat.spellRecoveries != 0 && + update.combat.p10ManaPercent < challengeCriticalManaPercent; + update.potionRecoveriesPerActiveMinute = update.combat.activeSeconds == 0 ? 0 : + update.combat.potionRecoveries * 60.0 / update.combat.activeSeconds; + const bool heavyRecovery = update.combat.potionRecoveries >= 3 && + update.potionRecoveriesPerActiveMinute >= 1.0; + const bool backoff = update.combat.dangerObserved || update.combat.deathObserved || criticalHealth || + criticalMana || heavyRecovery; + const bool qualifyingEasy = enoughActiveCombat && !lowHealthPressure && !manaPressure && !backoff && + update.combat.potionRecoveries <= 2; if (backoff && (update.combat.activeSeconds > 0 || update.combat.deathObserved)) { frontier = std::max(minimumChallengeFrontier, frontier - challengeBackoff); qualifyingHuntsToHold = 2; @@ -152,42 +179,69 @@ PlayerBotHuntChallengeUpdate PlayerBotHuntPolicy::updateChallengeFrontier(const --qualifyingHuntsToHold; update.result = PlayerBotHuntChallengeResult::Hold; } else if (qualifyingEasy) { - frontier = std::min(maximumChallengeFrontier, frontier + challengeEscalation); + const bool strongEvidence = update.combat.potionRecoveries <= 2 && update.combat.p10ManaPercent >= 50; + frontier = std::min(maximumChallengeFrontier, frontier + + (strongEvidence ? strongChallengeEscalation : challengeEscalation)); update.result = frontier == update.frontierBefore ? PlayerBotHuntChallengeResult::Clamped : PlayerBotHuntChallengeResult::Escalated; + } else if (enoughActiveCombat) { + update.result = PlayerBotHuntChallengeResult::Hold; } update.frontierAfter = frontier; update.qualifyingHuntsToHold = qualifyingHuntsToHold; return update; } -PlayerBotHuntPerformanceUpdate PlayerBotHuntPolicy::observePerformance(uint64_t variantId, +PlayerBotHuntPerformanceUpdate PlayerBotHuntPolicy::observePerformance(uint64_t variantId, uint64_t atlasRevision, const PlayerBotHuntPerformanceSample& sample) { PlayerBotHuntPerformanceUpdate update; update.updatedCorrection = sample.observedCorrection; - if (sample.durationSeconds < 30 || sample.kills == 0) { + update.actualExperiencePerMinute = sample.durationSeconds == 0 ? 0 : + sample.experienceGained * 60.0 / sample.durationSeconds; + const uint64_t requiredOutingSeconds = std::min( + std::max(sample.configuredHuntDurationSeconds, 1), minimumPerformanceOutingSeconds); + if (sample.dangerObserved || sample.deathObserved) { + update.evidenceReason = "unsafe_outing"; + return update; + } + if (sample.durationSeconds < requiredOutingSeconds || + sample.activeCombatSeconds < minimumPerformanceActiveSeconds || + sample.kills < minimumPerformanceKills) { + update.evidenceReason = "insufficient_combat_evidence"; + return update; + } + if (sample.experienceGained == 0 || !std::isfinite(sample.projectedExperience) || + !std::isfinite(sample.observedCorrection) || sample.observedCorrection < 0.25 || + sample.observedCorrection > 2.0) { + update.evidenceReason = "invalid_performance_sample"; return update; } - update.actualExperiencePerMinute = sample.experienceGained * 60.0 / sample.durationSeconds; const double predictedNetRate = sample.projectedExperience * 60.0 / std::max(1, sample.configuredHuntDurationSeconds); - if (predictedNetRate <= 0) { + if (!std::isfinite(update.actualExperiencePerMinute) || !std::isfinite(predictedNetRate) || + predictedNetRate <= 0) { + update.evidenceReason = "invalid_performance_sample"; return update; } PlayerBotHuntRegionPerformance& regionPerformance = performance[variantId]; const double sampleCorrection = std::clamp( sample.observedCorrection * update.actualExperiencePerMinute / predictedNetRate, 0.25, 2.0); - if (regionPerformance.samples == 0) { + if (!regionPerformance.reliable || regionPerformance.samples == 0 || + regionPerformance.atlasRevision != atlasRevision || !std::isfinite(regionPerformance.correction)) { regionPerformance.observedExperiencePerMinute = update.actualExperiencePerMinute; regionPerformance.correction = sampleCorrection; + regionPerformance.samples = 0; } else { regionPerformance.observedExperiencePerMinute = regionPerformance.observedExperiencePerMinute * 0.65 + update.actualExperiencePerMinute * 0.35; regionPerformance.correction = regionPerformance.correction * 0.65 + sampleCorrection * 0.35; } ++regionPerformance.samples; + regionPerformance.atlasRevision = atlasRevision; + regionPerformance.reliable = true; update.updatedCorrection = regionPerformance.correction; + update.evidenceReason = "reliable_combat_outing"; update.observed = true; return update; } diff --git a/server/src/playerbothuntpolicy.h b/server/src/playerbothuntpolicy.h index 156e36b1..4012dda1 100644 --- a/server/src/playerbothuntpolicy.h +++ b/server/src/playerbothuntpolicy.h @@ -21,9 +21,11 @@ struct PlayerBotHuntCombatEvidence { uint32_t spellRecoveries = 0; uint32_t maximumAttackerOverlap = 0; int32_t minimumHealth = std::numeric_limits::max(); + uint32_t minimumMana = std::numeric_limits::max(); bool dangerObserved = false; bool deathObserved = false; std::array healthPercentSamples{}; + std::array manaPercentSamples{}; }; struct PlayerBotHuntCombatSample { @@ -31,6 +33,8 @@ struct PlayerBotHuntCombatSample { double elapsedSeconds = 0; int32_t health = 0; int32_t maximumHealth = 0; + uint32_t mana = 0; + uint32_t maximumMana = 0; uint32_t attackers = 0; }; @@ -39,11 +43,14 @@ struct PlayerBotHuntCombatSnapshot { std::chrono::steady_clock::time_point observedAt; int32_t health = 0; int32_t maximumHealth = 0; + uint32_t mana = 0; + uint32_t maximumMana = 0; uint32_t attackers = 0; }; struct PlayerBotHuntCombatSummary : PlayerBotHuntCombatEvidence { uint8_t p10HealthPercent = 0; + uint8_t p10ManaPercent = 100; }; enum class PlayerBotHuntChallengeResult : uint8_t { @@ -66,6 +73,7 @@ struct PlayerBotHuntChallengeUpdate { uint8_t qualifyingHuntsToHold = 0; double activeCombatUptime = 0; uint32_t verifiedRecoveries = 0; + double potionRecoveriesPerActiveMinute = 0; double minimumActiveCombatSeconds = 0; uint32_t minimumKills = 0; PlayerBotHuntCombatSummary combat; @@ -73,16 +81,22 @@ struct PlayerBotHuntChallengeUpdate { struct PlayerBotHuntPerformanceSample { uint64_t durationSeconds = 0; + double activeCombatSeconds = 0; uint32_t kills = 0; uint64_t experienceGained = 0; double projectedExperience = 0; double observedCorrection = 1; uint32_t configuredHuntDurationSeconds = 0; + bool dangerObserved = false; + bool deathObserved = false; + uint64_t coinGoldAcquired = 0; }; struct PlayerBotHuntPerformanceUpdate { double actualExperiencePerMinute = 0; + double actualCoinGoldPerMinute = 0; double updatedCorrection = 1; + const char* evidenceReason = "insufficient_evidence"; bool observed = false; }; @@ -102,7 +116,7 @@ class PlayerBotHuntPolicy PlayerBotHuntCombatSummary combatSummary() const; PlayerBotHuntChallengeUpdate updateChallengeFrontier(const PlayerBotHuntChallengeSample& sample); - PlayerBotHuntPerformanceUpdate observePerformance(uint64_t variantId, + PlayerBotHuntPerformanceUpdate observePerformance(uint64_t variantId, uint64_t atlasRevision, const PlayerBotHuntPerformanceSample& sample); double challengeFrontier() const { return frontier; } diff --git a/server/src/playerbothuntregionadapter.cpp b/server/src/playerbothuntregionadapter.cpp index 15e46477..aeca3cdd 100644 --- a/server/src/playerbothuntregionadapter.cpp +++ b/server/src/playerbothuntregionadapter.cpp @@ -426,13 +426,20 @@ namespace { const PlayerBotCombatProfile& profile = planningProfile.combat; const uint16_t staminaMinutes = player.getStaminaMinutes(); const CachedVariant& cached = huntAtlas.variants[candidateIndex]; + // Static topology describes walking/tool portals only. A disconnected + // variant remains a candidate because the bounded route preflight can + // reach it through a verified NPC travel offer. + const bool locallyReachable = topologyDistances && topologyRegionReachable(player, cached, *topologyDistances); + const PlayerBotTopologyDistances* memberDistances = locallyReachable ? topologyDistances : nullptr; PlayerBotHuntRegion region; region.atlasSiteId = cached.siteId; region.atlasVariantId = cached.variantId; + region.atlasRevision = huntAtlasRevision; region.atlasPocketCount = static_cast(cached.pockets.size()); region.atlasSpawnCount = static_cast(cached.members.size()); region.atlasFloorCount = cached.floorCount; region.cashPressure = planningProfile.cashPressure; + region.supplyRecovery = planningProfile.supplyRecovery; region.floor = cached.center.z; region.center = cached.center; std::map profiles; @@ -445,7 +452,7 @@ namespace { double crowdDamageInflation = 1; for (size_t member : cached.members) { const CachedSpawnBlock& spawn = huntAtlas.spawns[member]; - const auto approach = nearestApproach(player, spawn.position, topologyDistances); + const auto approach = nearestApproach(player, spawn.position, memberDistances); if (!approach) continue; reachableMembers.insert(member); region.patrolPoints.push_back(*approach); @@ -588,6 +595,25 @@ namespace { region.topologyReachable = bestDistance != std::numeric_limits::max(); if (region.topologyReachable) region.topologyTravelSteps = bestDistance; } + uint32_t transportTravelSteps = std::numeric_limits::max(); + if (!region.topologyReachable) { + region.transportPlausible = false; + for (const PlayerBotHuntTransportArrival& arrival : planningProfile.transportArrivals) { + if (!arrival.topologyReachability) continue; + for (const Position& patrolPoint : region.patrolPoints) { + if (!PlayerBotTopology::instance().reachable(*arrival.topologyReachability, patrolPoint)) continue; + const uint32_t localSteps = Position::getDistanceX(arrival.position, patrolPoint) + + Position::getDistanceY(arrival.position, patrolPoint) + + Position::getDistanceZ(arrival.position, patrolPoint) * 20; + const uint32_t totalSteps = arrival.estimatedSteps > UINT32_MAX - localSteps ? + UINT32_MAX : arrival.estimatedSteps + localSteps; + if (totalSteps >= transportTravelSteps) continue; + transportTravelSteps = totalSteps; + region.destination = patrolPoint; + region.transportPlausible = true; + } + } + } for (auto& [name, monsterProfile] : profiles) { (void)name; region.monsters.push_back(std::move(monsterProfile)); @@ -606,14 +632,12 @@ namespace { region.challengeBandMaximum = planningProfile.challengeFrontier + challengeHeadroom; region.inChallengeBand = region.threatRatio <= region.challengeBandMaximum; region.predictedLethal = playerBotPredictedLethal(planningProfile.currentHealth, worstFightDamage); - withinPlanningScope = !topologyDistances || region.topologyReachable; + withinPlanningScope = true; region.suitable = region.sustainedEligible && !region.predictedLethal && - region.threatRatio <= region.challengeBandMaximum && withinPlanningScope; + region.threatRatio <= region.challengeBandMaximum; if (excludedVariants.find(region.atlasVariantId) != excludedVariants.end()) { region.suitable = false; region.rejectionReason = "observed_danger_cooldown"; - } else if (!withinPlanningScope) { - region.rejectionReason = "topology_unreachable"; } else if (region.predictedLethal) { region.rejectionReason = "predicted_lethal"; } else if (!region.sustainedEligible) { @@ -626,11 +650,19 @@ namespace { region.spawnExperiencePerMinute *= experienceStage; region.clearExperiencePerMinute *= experienceStage; region.staminaMinutes = staminaMinutes; - if (auto observed = performance.find(region.atlasVariantId); observed != performance.end()) { - region.observedExperiencePerMinute = observed->second.observedExperiencePerMinute; - region.observedCorrection = observed->second.correction; + const PlayerBotHuntCorrection correction = playerBotHuntCorrectionForVariant( + region.atlasVariantId, region.atlasRevision, performance); + region.observedExperiencePerMinute = correction.observedExperiencePerMinute; + region.observedCoinGoldPerMinute = correction.observedCoinGoldPerMinute; + region.observedCorrection = correction.correction; + region.calibrationSampleCount = correction.sampleCount; + region.calibrationSource = correction.source; + if (!region.topologyReachable && !region.transportPlausible) { + region.suitable = false; + region.rejectionReason = "transport_requirements_unavailable"; } - const uint32_t estimatedTravelSteps = topologyDistances ? region.topologyTravelSteps : geometricDistance; + const uint32_t estimatedTravelSteps = region.topologyReachable ? region.topologyTravelSteps : + region.transportPlausible ? transportTravelSteps : geometricDistance; region.estimatedTravelSeconds = estimatedTravelSteps * player.getStepDuration() / 1000.0; region.availableHuntSeconds = std::max(0.0, huntDurationSeconds - region.estimatedTravelSeconds); region.staminaExperienceMultiplier = projectedStaminaExperienceMultiplier(player, region.availableHuntSeconds); @@ -678,6 +710,8 @@ uint64_t PlayerBotHuntRegionAdapter::getCacheRevision() PlayerBotHuntRegionScan PlayerBotHuntRegionAdapter::beginScan( Player& player, const PlayerBotTopologyDistances* topologyDistances) { + (void)player; + (void)topologyDistances; PlayerBotHuntRegionScan scan; scan.cacheHit = huntAtlas.initialized && huntAtlas.lootRate == g_config.getNumber(ConfigManager::RATE_LOOT) && huntAtlas.spawnGeneration == g_game.map.spawns.getGeneration() && @@ -685,7 +719,6 @@ PlayerBotHuntRegionScan PlayerBotHuntRegionAdapter::beginScan( if (!scan.cacheHit) buildHuntAtlas(scan.snapshotTimeUs, scan.clusteringTimeUs); scan.revision = huntAtlasRevision; for (size_t index = 0; index < huntAtlas.variants.size(); ++index) { - if (topologyDistances && !topologyRegionReachable(player, huntAtlas.variants[index], *topologyDistances)) continue; scan.candidateIndices.push_back(index); } scan.candidateCount = scan.candidateIndices.size(); diff --git a/server/src/playerbothuntregions.h b/server/src/playerbothuntregions.h index 5aa6e20e..ae9bab2b 100644 --- a/server/src/playerbothuntregions.h +++ b/server/src/playerbothuntregions.h @@ -17,6 +17,7 @@ #include "position.h" #include +#include #include #include #include @@ -26,6 +27,7 @@ class Player; struct PlayerBotTopologyDistances; +class PlayerBotTopologyReachability; struct PlayerBotRecoveryPrediction { double spellMinimumHealing = 0; @@ -40,6 +42,23 @@ struct PlayerBotRecoveryPrediction { bool lightHealingLegal = false; }; +struct PlayerBotHuntTransportArrival { + Position position; + uint64_t fare = 0; + uint32_t estimatedSteps = 0; + std::shared_ptr topologyReachability; +}; + +struct PlayerBotHuntTransportOffer { + Position provider; + Position destination; + uint32_t price = 0; + uint32_t minimumLevel = 0; + bool premiumRequired = false; + bool opaqueCondition = false; + bool opaqueAction = false; +}; + struct PlayerBotHuntPlanningProfile { PlayerBotCombatProfile combat; int32_t currentHealth = 0; @@ -53,7 +72,8 @@ struct PlayerBotHuntPlanningProfile { double challengeFrontier = 0; bool lightHealingLegal = false; bool cashPressure = false; - bool diversifyIncomeRoutes = false; + bool supplyRecovery = false; + std::vector transportArrivals; PlayerBotSupplyProfile supply; }; @@ -77,6 +97,7 @@ struct PlayerBotHuntRegion { uint32_t id = 0; uint64_t atlasSiteId = 0; uint64_t atlasVariantId = 0; + uint64_t atlasRevision = 0; uint32_t atlasPocketCount = 0; uint32_t atlasSpawnCount = 0; uint32_t atlasFloorCount = 0; @@ -86,7 +107,9 @@ struct PlayerBotHuntRegion { std::vector patrolPoints; std::vector monsters; double coinGoldPerMinute = 0; + double observedCoinGoldPerMinute = 0; bool cashPressure = false; + bool supplyRecovery = false; // Synthetic policy fixtures have no atlas geometry; engine candidates set this. bool sustainedEligible = true; PlayerBotHuntViability viability; @@ -97,6 +120,8 @@ struct PlayerBotHuntRegion { double availableHuntSeconds = 0; double observedExperiencePerMinute = 0; double observedCorrection = 1; + uint32_t calibrationSampleCount = 0; + const char* calibrationSource = "default"; uint16_t staminaMinutes = 0; double staminaExperienceMultiplier = 1; double projectedExperience = 0; @@ -118,6 +143,15 @@ struct PlayerBotHuntRegion { double expectedDamagePerSecond = 0; double combatFraction = 0; uint32_t returnRouteDangerCost = 0; + uint64_t outboundFare = 0; + uint64_t exitFare = 0; + uint64_t supplyFare = 0; + uint32_t recoveryPotionReserve = 0; + Position exitDepotDestination; + Position supplyDestination; + bool outboundNpcTravel = false; + bool exitNpcTravel = false; + bool supplyNpcTravel = false; bool routeValidated = false; double score = 0; uint32_t travelSteps = 0; @@ -127,6 +161,7 @@ struct PlayerBotHuntRegion { bool suitable = false; bool reachable = false; bool topologyReachable = false; + bool transportPlausible = true; bool inChallengeBand = false; bool predictedLethal = false; std::string rejectionReason; @@ -159,10 +194,65 @@ struct PlayerBotHuntRegion { struct PlayerBotHuntRegionPerformance { double observedExperiencePerMinute = 0; + double observedCoinGoldPerMinute = 0; double correction = 1; uint32_t samples = 0; + uint64_t atlasRevision = 0; + bool reliable = false; +}; + +struct PlayerBotHuntSharedCorrection { + double correction = 1; + uint32_t testedVariants = 0; }; +inline PlayerBotHuntSharedCorrection playerBotSharedHuntCorrection( + uint64_t atlasRevision, const std::map& performance) +{ + PlayerBotHuntSharedCorrection result; + double total = 0; + for (const auto& entry : performance) { + const PlayerBotHuntRegionPerformance& observed = entry.second; + if (!observed.reliable || observed.samples == 0 || observed.atlasRevision != atlasRevision || + !std::isfinite(observed.correction) || + observed.correction < 0.25 || observed.correction > 2.0) continue; + total += observed.correction; + ++result.testedVariants; + } + if (result.testedVariants != 0) { + result.correction = std::clamp(total / result.testedVariants, 0.25, 2.0); + } + return result; +} + +struct PlayerBotHuntCorrection { + double correction = 1; + double observedExperiencePerMinute = 0; + double observedCoinGoldPerMinute = 0; + uint32_t sampleCount = 0; + const char* source = "default"; +}; + +inline PlayerBotHuntCorrection playerBotHuntCorrectionForVariant( + uint64_t variantId, uint64_t atlasRevision, + const std::map& performance) +{ + if (auto found = performance.find(variantId); found != performance.end()) { + const PlayerBotHuntRegionPerformance& observed = found->second; + if (observed.reliable && observed.samples != 0 && observed.atlasRevision == atlasRevision && + std::isfinite(observed.correction) && + observed.correction >= 0.25 && observed.correction <= 2.0) { + return {observed.correction, observed.observedExperiencePerMinute, + observed.observedCoinGoldPerMinute, observed.samples, "variant_observed"}; + } + } + const PlayerBotHuntSharedCorrection shared = playerBotSharedHuntCorrection(atlasRevision, performance); + if (shared.testedVariants != 0) { + return {shared.correction, 0, 0, shared.testedVariants, "shared_observed"}; + } + return {}; +} + struct PlayerBotHuntRegionScan { bool cacheHit = false; uint64_t revision = 0; @@ -204,50 +294,90 @@ inline bool playerBotPreferHuntRegion(const PlayerBotHuntRegion& left, const Pla if (!left.supplyBudget.fits && left.supplyBudget.expectedPotions != right.supplyBudget.expectedPotions) { return left.supplyBudget.expectedPotions < right.supplyBudget.expectedPotions; } - const double leftIncome = left.cashPressure ? left.coinGoldPerMinute : 0; - const double rightIncome = right.cashPressure ? right.coinGoldPerMinute : 0; + const bool recovery = left.supplyRecovery && right.supplyRecovery; + const double leftIncome = (left.cashPressure || recovery) ? + (left.observedCoinGoldPerMinute > 0 ? left.observedCoinGoldPerMinute : left.coinGoldPerMinute) : 0; + const double rightIncome = (right.cashPressure || recovery) ? + (right.observedCoinGoldPerMinute > 0 ? right.observedCoinGoldPerMinute : right.coinGoldPerMinute) : 0; if (leftIncome != rightIncome) return leftIncome > rightIncome; - return left.score > right.score; + if (recovery && left.threatRatio != right.threatRatio) return left.threatRatio < right.threatRatio; + if (left.score != right.score) return left.score > right.score; + // A zero-duration estimate must not let an unproven disconnected region win + // a tie merely because it forecasts no combat consumption. + if (left.topologyReachable != right.topologyReachable) return left.topologyReachable; + return left.estimatedTravelSeconds < right.estimatedTravelSeconds; +} +inline bool playerBotHuntTravelAffordable(uint64_t funds, uint64_t recoveryReserve, + uint64_t outboundFare, uint64_t exitFare, + uint64_t supplyFare = 0) +{ + uint64_t totalFare = outboundFare > UINT64_MAX - exitFare ? UINT64_MAX : outboundFare + exitFare; + totalFare = totalFare > UINT64_MAX - supplyFare ? UINT64_MAX : totalFare + supplyFare; + if (totalFare == 0) return true; + return totalFare <= funds && recoveryReserve <= funds - totalFare; } -inline constexpr size_t playerBotMaximumHuntRouteCandidates = 8; -// Input retains the planner's normal stable ordering and region IDs. Diversify -// only an oversubscribed supply tier: income must not displace a safer budget. -inline std::vector playerBotHuntRouteShortlist( - const std::vector& orderedRegions, bool diversifyIncome) +inline bool playerBotHuntTravelPaymentAffordable(uint64_t funds, uint64_t recoveryReserve, + uint64_t fare, uint64_t futureFareReserve) +{ + return fare == 0 || playerBotHuntTravelAffordable(funds, recoveryReserve, fare, futureFareReserve); +} + +inline bool playerBotHuntNeedsSupplyRoute(double expectedPotions, uint32_t availablePotions, + uint32_t routeReserve) +{ + return expectedPotions > 0 || availablePotions <= routeReserve; +} + +// Preserve the normal policy order across every cheap viable candidate. Route +// validation consumes this finite queue incrementally; it does not reserve +// slots for local, remote, observed, unobserved, or income candidates. +inline std::vector playerBotHuntRouteCandidates( + const std::vector& orderedRegions) { - std::vector eligible, chosen; - for (const auto& region : orderedRegions) { - if (region.suitable && region.reachable) eligible.push_back(®ion); - } - for (size_t begin = 0; begin < eligible.size() && chosen.size() < playerBotMaximumHuntRouteCandidates;) { - size_t end = begin + 1; - const auto& tier = eligible[begin]->supplyBudget; - while (end < eligible.size() && eligible[end]->supplyBudget.fits == tier.fits && - (tier.fits || eligible[end]->supplyBudget.expectedPotions == tier.expectedPotions)) ++end; - const size_t slots = playerBotMaximumHuntRouteCandidates - chosen.size(); - const bool diversify = diversifyIncome && end - begin > slots; - const size_t normalSlots = diversify ? (slots + 1) / 2 : std::min(slots, end - begin); - chosen.insert(chosen.end(), eligible.begin() + begin, eligible.begin() + begin + normalSlots); - if (diversify) { - std::vector income(eligible.begin() + begin, eligible.begin() + end); - std::stable_sort(income.begin(), income.end(), [](const auto* left, const auto* right) { - return left->coinGoldPerMinute > right->coinGoldPerMinute; - }); - for (const auto* region : income) { - if (chosen.size() == playerBotMaximumHuntRouteCandidates) break; - if (std::find(chosen.begin(), chosen.end(), region) == chosen.end()) chosen.push_back(region); - } - } - begin = end; - } std::vector result; - for (const auto* region : chosen) result.push_back(*region); + for (const PlayerBotHuntRegion& region : orderedRegions) { + if (region.suitable && region.reachable) result.push_back(region); + } return result; } +inline bool playerBotHuntCandidateCanBeatValidated( + const PlayerBotHuntRegion& candidate, const PlayerBotHuntRegion& validated) +{ + if (!candidate.suitable || !candidate.reachable) return false; + // Travel can improve the candidate's supply tier, but it cannot improve past + // an incumbent that already fits. Compare that best case with the candidate's + // no-travel XP bound before spending another route budget. + if (!validated.supplyBudget.fits) return true; + if (validated.cashPressure && candidate.coinGoldPerMinute > validated.coinGoldPerMinute) return true; + if (validated.cashPressure && candidate.coinGoldPerMinute < validated.coinGoldPerMinute) return false; + return candidate.optimisticProjectedExperience >= validated.score; +} + +inline size_t playerBotNextHuntCandidateToValidate( + const std::vector& candidates, size_t begin, + const PlayerBotHuntRegion* validated = nullptr) +{ + for (size_t index = std::min(begin, candidates.size()); index < candidates.size(); ++index) { + const PlayerBotHuntRegion& candidate = candidates[index]; + if (!candidate.suitable || !candidate.reachable) continue; + if (!validated || playerBotHuntCandidateCanBeatValidated(candidate, *validated)) return index; + } + return candidates.size(); +} + +inline bool playerBotHuntRemainingCanBeatValidated( + const std::vector& candidates, size_t begin, + const PlayerBotHuntRegion& validated) +{ + return playerBotNextHuntCandidateToValidate(candidates, begin, &validated) < candidates.size(); +} + inline const char* playerBotHuntSelectionRule(const PlayerBotHuntRegion& region) { + if (region.supplyRecovery) return region.supplyBudget.fits ? + "supply_recovery_coin_safety_then_xp" : "supply_recovery_potions_then_coin_safety_then_xp"; if (region.cashPressure) return region.supplyBudget.fits ? "supply_budget_then_coin_income_then_xp" : "lowest_potion_consumption_then_coin_income_then_xp"; return region.supplyBudget.fits ? "supply_budget_then_xp" : "lowest_potion_consumption_then_xp"; diff --git a/server/src/playerbothuntruntime.cpp b/server/src/playerbothuntruntime.cpp index 465abe73..7f8e1e92 100644 --- a/server/src/playerbothuntruntime.cpp +++ b/server/src/playerbothuntruntime.cpp @@ -13,8 +13,9 @@ PlayerBotHuntRuntime::PlayerBotHuntRuntime(std::vector fallbackPatrol) PlayerBotHuntPlanningSnapshot PlayerBotHuntRuntime::snapshot(const PlayerBotHuntRuntimePlayerObservation& player, uint64_t revision) { - return {player.position, player.level, player.health, player.staminaMinutes, revision, player.topologyGeneration, player.excludedVariants, - player.canUseRope, player.canUseShovel, player.potions, player.mana, player.funds}; + return {player.position, player.level, player.health, player.staminaMinutes, revision, player.topologyGeneration, + player.npcGeneration, player.excludedVariants, + player.canUseRope, player.canUseShovel, player.premium, player.potions, player.mana, player.funds}; } bool PlayerBotHuntRuntime::planningStartRequired(std::chrono::steady_clock::time_point now) const @@ -29,6 +30,7 @@ PlayerBotHuntRuntimeOutcome PlayerBotHuntRuntime::advancePlanning(const PlayerBo PlayerBotHuntRuntimeOutcome outcome; if (observation.invalidateCacheRevision) { planning.reset(); + pendingTransportOffers.clear(); pendingScoreCandidates.clear(); outcome.staleRevision = true; outcome.invalidateCache = true; @@ -43,10 +45,13 @@ PlayerBotHuntRuntimeOutcome PlayerBotHuntRuntime::advancePlanning(const PlayerBo if (planning && planning->invalidated(current)) { outcome.staleRevision = planning->snapshot().cacheRevision != input.cacheRevision; planning.reset(); + pendingTransportOffers.clear(); pendingScoreCandidates.clear(); } - if (planning && observation.cancelAtScoreBarrier && !planning->scoring()) { + if (planning && observation.cancelAtScoreBarrier && pendingTransportOffers.empty() && + pendingScoreCandidates.empty()) { planning.reset(); + pendingTransportOffers.clear(); pendingScoreCandidates.clear(); outcome.command = PlayerBotHuntRuntimeCommand::PlanningCancelled; return outcome; @@ -56,7 +61,8 @@ PlayerBotHuntRuntimeOutcome PlayerBotHuntRuntime::advancePlanning(const PlayerBo PlayerBotHuntPlanningProfile profile = planningProfile(input.start->profile); planning.emplace(PlayerBotHuntPlanningStart{input.start->scan, std::move(profile), snapshot(input.player, input.start->scan.revision), - input.start->topologyDistances, input.start->topologyDistanceTimeUs, + input.start->topologyDistances, input.start->originReachability, + input.start->transportOffers, input.start->topologyDistanceTimeUs, input.reason, now}); plannedHuntDurationSeconds = input.huntDurationSeconds; outcome.command = PlayerBotHuntRuntimeCommand::PlanningStarted; @@ -64,6 +70,20 @@ PlayerBotHuntRuntimeOutcome PlayerBotHuntRuntime::advancePlanning(const PlayerBo } planning->beginTurn(); + if (planning->transportPlanning()) { + while (const auto work = planning->nextTransportWork(8)) { + pendingTransportOffers.push_back(work->offerIndex); + outcome.transportWork.push_back({work->offerIndex, work->offer, std::move(work->arrivals), + planning->snapshot().playerLevel, planning->snapshot().funds, + planning->snapshot().premium, planning->snapshot().canUseRope, + planning->snapshot().canUseShovel}); + } + if (outcome.transportWork.empty()) { + outcome.command = planning->completeTransport() == PlayerBotHuntPlanningProgress::ScoringYield ? + PlayerBotHuntRuntimeCommand::PlanningYield : PlayerBotHuntRuntimeCommand::PlanningScored; + } + return outcome; + } if (planning->scoring()) { while (const auto work = planning->nextScoringWork(256)) { pendingScoreCandidates.push_back(work->candidateIndex); @@ -94,6 +114,32 @@ PlayerBotHuntRuntimeOutcome PlayerBotHuntRuntime::advancePlanning(const PlayerBo return outcome; } +PlayerBotHuntRuntimeOutcome PlayerBotHuntRuntime::completeTransportWork( + const std::vector& observations) +{ + PlayerBotHuntRuntimeOutcome outcome; + if (!planning || !planning->transportPlanning() || observations.size() != pendingTransportOffers.size()) return outcome; + for (size_t index = 0; index < observations.size(); ++index) { + if (observations[index].offerIndex != pendingTransportOffers[index]) { + outcome.staleRevision = true; + planning.reset(); + pendingTransportOffers.clear(); + pendingScoreCandidates.clear(); + return outcome; + } + planning->transportCompleted(observations[index].offerIndex, observations[index].arrival); + } + pendingTransportOffers.clear(); + if (planning->completeTransport() == PlayerBotHuntPlanningProgress::ScoringYield) { + outcome.command = PlayerBotHuntRuntimeCommand::PlanningYield; + return outcome; + } + // Scoring starts on the next scheduler turn; do not combine the transport + // barrier with a 256-candidate scoring batch. + outcome.command = PlayerBotHuntRuntimeCommand::PlanningYield; + return outcome; +} + PlayerBotHuntRuntimeOutcome PlayerBotHuntRuntime::completeScoreWork(const std::vector& observations, uint64_t elapsedUs) { @@ -103,6 +149,7 @@ PlayerBotHuntRuntimeOutcome PlayerBotHuntRuntime::completeScoreWork(const std::v if (!observations[index].valid || observations[index].candidateIndex != pendingScoreCandidates[index]) { outcome.staleRevision = true; planning.reset(); + pendingTransportOffers.clear(); pendingScoreCandidates.clear(); return outcome; } @@ -123,17 +170,21 @@ PlayerBotHuntRuntimeOutcome PlayerBotHuntRuntime::completeScoreWork(const std::v void PlayerBotHuntRuntime::applyCandidateSuitability(PlayerBotHuntRegion& region, const PlayerBotHuntRuntimeScoreObservation& observation) const { - region.challengeFrontier = policy.challengeFrontier(); + const double frontier = playerBotSupplyRecoveryChallengeFrontier(policy.challengeFrontier(), supplyRecoveryDegraded); + region.challengeFrontier = frontier; region.challengeBandMinimum = 0; - region.challengeBandMaximum = region.challengeFrontier + 0.05; + region.challengeBandMaximum = frontier + 0.05; region.inChallengeBand = region.threatRatio <= region.challengeBandMaximum; region.suitable = observation.candidateFactsAvailable && region.sustainedEligible && !region.predictedLethal && - region.threatRatio <= region.challengeBandMaximum && observation.withinPlanningScope; + region.threatRatio <= region.challengeBandMaximum && observation.withinPlanningScope && + region.transportPlausible; if (planning->snapshot().excludedVariants.find(region.atlasVariantId) != planning->snapshot().excludedVariants.end()) { region.suitable = false; region.rejectionReason = "observed_danger_cooldown"; } else if (!observation.withinPlanningScope) { region.rejectionReason = "travel_distance"; + } else if (!region.transportPlausible) { + region.rejectionReason = "transport_requirements_unavailable"; } else if (region.predictedLethal) { region.rejectionReason = "predicted_lethal"; } else if (!region.sustainedEligible) { @@ -157,6 +208,7 @@ PlayerBotHuntRuntimeOutcome PlayerBotHuntRuntime::exhaustScope(std::chrono::stea outcome.retryAfter = retryAfter; scopeReevaluationAfter = now + retryAfter; planning.reset(); + pendingTransportOffers.clear(); pendingScoreCandidates.clear(); return outcome; } @@ -221,7 +273,8 @@ bool PlayerBotHuntRuntime::insideHuntArea(const Position& position, uint32_t wes PlayerBotHuntPlanningProfile PlayerBotHuntRuntime::planningProfile(PlayerBotHuntPlanningProfile profile) const { - profile.challengeFrontier = policy.challengeFrontier(); + profile.challengeFrontier = playerBotSupplyRecoveryChallengeFrontier(policy.challengeFrontier(), supplyRecoveryDegraded); + profile.supplyRecovery = supplyRecoveryDegraded; return profile; } @@ -262,8 +315,10 @@ std::optional PlayerBotHuntRuntime::complete(con result.levelBefore = huntStartLevel; result.combat = policy.combatSummary(); result.coinGoldAcquired = coinGoldAcquired; - result.performance = policy.observePerformance(activeRegion->atlasVariantId, {result.durationSeconds, result.combat.kills, result.experienceGained, - activeRegion->projectedExperience, activeRegion->observedCorrection, configuredDurationSeconds}); + result.performance = policy.observePerformance(activeRegion->atlasVariantId, activeRegion->atlasRevision, + {result.durationSeconds, result.combat.activeSeconds, result.combat.kills, result.experienceGained, + activeRegion->projectedExperience, activeRegion->observedCorrection, configuredDurationSeconds, + result.combat.dangerObserved, result.combat.deathObserved}); result.challenge = policy.updateChallengeFrontier({result.durationSeconds, player.maximumHealth}); activeRegion.reset(); capacityPressureStarted = {}; diff --git a/server/src/playerbothuntruntime.h b/server/src/playerbothuntruntime.h index 91e80f3c..74a6a199 100644 --- a/server/src/playerbothuntruntime.h +++ b/server/src/playerbothuntruntime.h @@ -8,6 +8,7 @@ #include "playerbothuntplanningsession.h" #include "playerbothuntpolicy.h" #include "playerbotnavigationruntime.h" +#include "playerbotsupplyrecovery.h" #include #include @@ -38,9 +39,11 @@ struct PlayerBotHuntRuntimePlayerObservation { uint16_t staminaMinutes = 0; uint64_t experience = 0; uint64_t topologyGeneration = 0; + uint64_t npcGeneration = 0; std::set excludedVariants; bool canUseRope = false; bool canUseShovel = false; + bool premium = false; uint32_t potions = 0; uint32_t mana = 0; uint64_t funds = 0; @@ -55,6 +58,8 @@ struct PlayerBotHuntRuntimePlanningStartObservation { PlayerBotHuntRegionScan scan; PlayerBotHuntPlanningProfile profile; std::shared_ptr topologyDistances; + std::shared_ptr originReachability; + std::shared_ptr> transportOffers; uint64_t topologyDistanceTimeUs = 0; }; @@ -66,6 +71,22 @@ struct PlayerBotHuntRuntimePlanningInput { std::optional start; }; +struct PlayerBotHuntRuntimeTransportWork { + size_t offerIndex = 0; + PlayerBotHuntTransportOffer offer; + std::shared_ptr> arrivals; + uint32_t playerLevel = 0; + uint64_t funds = 0; + bool playerPremium = false; + bool canUseRope = false; + bool canUseShovel = false; +}; + +struct PlayerBotHuntRuntimeTransportObservation { + size_t offerIndex = 0; + std::optional arrival; +}; + struct PlayerBotHuntRuntimeScoreWork { size_t candidateIndex = 0; PlayerBotHuntPlanningProfile profile; @@ -86,6 +107,7 @@ struct PlayerBotHuntRuntimeScoreObservation { struct PlayerBotHuntRuntimeOutcome { PlayerBotHuntRuntimeCommand command = PlayerBotHuntRuntimeCommand::None; + std::vector transportWork; std::vector scoreWork; std::optional selectedRegion; std::vector candidates; @@ -142,17 +164,21 @@ class PlayerBotHuntRuntime public: explicit PlayerBotHuntRuntime(std::vector fallbackPatrol); + void setSupplyRecovery(bool degraded) { supplyRecoveryDegraded = degraded; } + bool planningStartRequired(std::chrono::steady_clock::time_point now) const; PlayerBotHuntRuntimeOutcome advancePlanning(const PlayerBotHuntRuntimePlanningInput& input, std::chrono::steady_clock::time_point now, const PlayerBotHuntPlanningObservation& observation = {}); + PlayerBotHuntRuntimeOutcome completeTransportWork( + const std::vector& observations); PlayerBotHuntRuntimeOutcome completeScoreWork(const std::vector& observations, uint64_t elapsedUs); // Keep the completed session through final telemetry, then release it. - void completePlanningSelection() { planning.reset(); pendingScoreCandidates.clear(); } + void completePlanningSelection() { planning.reset(); pendingTransportOffers.clear(); pendingScoreCandidates.clear(); } void selectPlanningRegion(PlayerBotHuntRegion region, const PlayerBotHuntRuntimePlayerObservation& player, std::chrono::steady_clock::time_point now) { activate(std::move(region), player, now); } - void cancelPlanning() { planning.reset(); pendingScoreCandidates.clear(); } + void cancelPlanning() { planning.reset(); pendingTransportOffers.clear(); pendingScoreCandidates.clear(); } bool planningActive() const { return planning.has_value(); } std::optional planningSession() const { @@ -207,6 +233,7 @@ class PlayerBotHuntRuntime std::optional planning; PlayerBotHuntPolicy policy; + bool supplyRecoveryDegraded = false; std::optional activeRegion; std::vector fallbackPatrol; std::chrono::steady_clock::time_point scopeReevaluationAfter; @@ -225,6 +252,7 @@ class PlayerBotHuntRuntime uint64_t huntStartExperience = 0; uint32_t huntStartLevel = 0; uint32_t plannedHuntDurationSeconds = 0; + std::vector pendingTransportOffers; std::vector pendingScoreCandidates; }; diff --git a/server/src/playerbotnavigation.cpp b/server/src/playerbotnavigation.cpp index e7771bfc..bd1934a7 100644 --- a/server/src/playerbotnavigation.cpp +++ b/server/src/playerbotnavigation.cpp @@ -295,17 +295,6 @@ uint32_t PlayerBotNavigationGoal::distance(const Position& candidate) const return remainingCost(candidate, *this) / cardinalCost; } -Position PlayerBotNavigationGoal::representative() const -{ - return position; -} - -bool PlayerBotNavigationGoal::operator==(const PlayerBotNavigationGoal& other) const -{ - return type == other.type && position == other.position && rangeX == other.rangeX && rangeY == other.rangeY && - rangeZ == other.rangeZ && positions == other.positions; -} - bool playerBotIsTraversableDoor(const Item& item) { const ItemType& type = Item::items[item.getID()]; diff --git a/server/src/playerbotnavigation.h b/server/src/playerbotnavigation.h index d53c6fd0..9b92a4a7 100644 --- a/server/src/playerbotnavigation.h +++ b/server/src/playerbotnavigation.h @@ -11,6 +11,7 @@ #ifndef FS_PLAYERBOTNAVIGATION_H #define FS_PLAYERBOTNAVIGATION_H +#include #include #include #include @@ -26,6 +27,14 @@ class Item; inline constexpr uint64_t playerBotNavigationMaximumExpandedNodes = 100000; +inline bool playerBotNpcTravelOfferEligible(uint32_t playerLevel, bool playerPremium, uint64_t availableMoney, + uint32_t minimumLevel, bool premiumRequired, uint64_t fare, + bool opaqueCondition, bool opaqueAction) +{ + return playerLevel >= minimumLevel && (!premiumRequired || playerPremium) && fare <= availableMoney && + !opaqueCondition && !opaqueAction; +} + inline uint32_t playerBotNavigationDistance(const Position& from, const Position& destination) { return Position::getDistanceX(from, destination) + Position::getDistanceY(from, destination) + @@ -62,11 +71,41 @@ struct PlayerBotNavigationGoal { static PlayerBotNavigationGoal anyOf(std::vector positions); bool reached(const Position& candidate) const; uint32_t distance(const Position& candidate) const; - Position representative() const; - bool operator==(const PlayerBotNavigationGoal& other) const; + Position representative() const { return position; } + bool operator==(const PlayerBotNavigationGoal& other) const + { + return type == other.type && position == other.position && rangeX == other.rangeX && rangeY == other.rangeY && + rangeZ == other.rangeZ && positions == other.positions; + } bool operator!=(const PlayerBotNavigationGoal& other) const { return !(*this == other); } }; +inline bool playerBotNpcTravelUsesLocalApproach(const Position& current, const Position& provider, + int32_t maximumDistance) +{ + return current.z == provider.z && + std::max(Position::getDistanceX(current, provider), Position::getDistanceY(current, provider)) <= maximumDistance; +} + +// Depot discovery can alternate between adjacent standable approach tiles of +// the same depot every decision tick. Treat such flapping as one fixed +// objective so failure accounting and confirmed route blockers survive it. +inline bool playerBotNavigationSameFixedObjective(const PlayerBotNavigationGoal& previous, + const PlayerBotNavigationGoal& current) +{ + if (previous == current) return true; + const Position previousRepresentative = previous.representative(); + const Position currentRepresentative = current.representative(); + return previousRepresentative.z == currentRepresentative.z && + Position::areInRange<3, 3, 0>(previousRepresentative, currentRepresentative); +} + +inline bool playerBotNpcTravelApproachComplete(bool localApproach, bool hasSteps, + bool exactDestinationReached) +{ + return localApproach || (hasSteps && exactDestinationReached); +} + enum class PlayerBotNavigationAction : uint8_t { Move, Use, diff --git a/server/src/playerbotnavigationruntime.cpp b/server/src/playerbotnavigationruntime.cpp index 54689fc0..22c5b06a 100644 --- a/server/src/playerbotnavigationruntime.cpp +++ b/server/src/playerbotnavigationruntime.cpp @@ -15,6 +15,12 @@ PlayerBotNavigationRuntimeOutcome PlayerBotNavigationRuntime::process(const PlayerBotNavigationRuntimeInput& input) { PlayerBotNavigationRuntimeOutcome outcome; + const bool sameFixedTarget = fixedTargetGoal && playerBotNavigationSameFixedObjective(*fixedTargetGoal, input.goal); + outcome.positionalProgress = fixedTargetFailures.observePosition( + sameFixedTarget, input.goal.distance(input.currentPosition)); + fixedTargetGoal = input.goal; + outcome.fixedTargetRouteFailures = fixedTargetFailures.count(); + outcome.fixedTargetRouteExhausted = fixedTargetFailures.exhausted(); if (input.goal.reached(input.currentPosition)) { session.clear(); outcome.destinationReached = true; @@ -70,19 +76,22 @@ PlayerBotNavigationRuntimeOutcome PlayerBotNavigationRuntime::observePlan(Player (!observation.startsNavigation && observation.plan.steps.empty())) { outcome.routeUnavailable = true; const std::set activeBlockers = session.activeBlockedPositions(observation.now); - if (activeBlockers.empty()) ++fixedTargetRouteFailures; - else session.confirmRequiredRouteBlocker(); - outcome.fixedTargetRouteFailures = fixedTargetRouteFailures; - outcome.fixedTargetRouteExhausted = fixedTargetRouteFailures >= 20; + fixedTargetFailures.observePlan(false); + if (!activeBlockers.empty()) session.confirmRequiredRouteBlocker(); + outcome.fixedTargetRouteFailures = fixedTargetFailures.count(); + outcome.fixedTargetRouteExhausted = fixedTargetFailures.exhausted(); outcome.command = outcome.fixedTargetRouteExhausted ? PlayerBotNavigationRuntimeCommand::Fail : PlayerBotNavigationRuntimeCommand::Retry; outcome.stepFailureCount = session.stepFailureCount(); return outcome; } - fixedTargetRouteFailures = 0; + fixedTargetFailures.observePlan(true); // A planned route replaces existing work only after planning completed. + // Only process() can clear fixed-goal failures after observing real progress. if (observation.startsNavigation) session.adopt(observation.goal, std::move(observation.plan.steps)); else session.installRoute(observation.goal, std::move(observation.plan.steps)); + outcome.fixedTargetRouteFailures = fixedTargetFailures.count(); + outcome.fixedTargetRouteExhausted = fixedTargetFailures.exhausted(); dispatchNextStep(observation.canDoAction, outcome); outcome.stepFailureCount = session.stepFailureCount(); return outcome; diff --git a/server/src/playerbotnavigationruntime.h b/server/src/playerbotnavigationruntime.h index 81abf379..12779a82 100644 --- a/server/src/playerbotnavigationruntime.h +++ b/server/src/playerbotnavigationruntime.h @@ -67,8 +67,60 @@ struct PlayerBotNavigationRuntimeInput { PlayerBotNavigationRuntimeTiming timing; }; +// Fixed-goal route failures are cleared by observed positional progress, not by +// a planner claiming that a route exists. This keeps immediately blocked plans +// in one bounded failure sequence. +class PlayerBotFixedTargetFailureTracker +{ + public: + bool observePosition(bool sameGoal, uint32_t distance) + { + if (!goalSet || !sameGoal) { + goalSet = true; + bestDistance = distance; + failures = 0; + blockedPlanPendingFailure = false; + return false; + } + if (distance >= bestDistance) return false; + bestDistance = distance; + failures = 0; + blockedPlanPendingFailure = false; + return true; + } + void observePlan(bool routeAvailable) + { + if (routeAvailable) { + blockedPlanPendingFailure = false; + return; + } + if (!blockedPlanPendingFailure) ++failures; + blockedPlanPendingFailure = false; + } + void observeBlockedPlan() + { + ++failures; + blockedPlanPendingFailure = true; + } + void reset() + { + goalSet = false; + failures = 0; + blockedPlanPendingFailure = false; + } + uint32_t count() const { return failures; } + bool exhausted() const { return failures >= 20; } + + private: + uint32_t bestDistance = 0; + uint32_t failures = 0; + bool goalSet = false; + bool blockedPlanPendingFailure = false; +}; + struct PlayerBotNavigationRuntimeOutcome { bool destinationReached = false; + bool positionalProgress = false; PlayerBotPendingMovementResult movementResult = PlayerBotPendingMovementResult::None; uint32_t stepFailureCount = 0; std::optional oscillation; @@ -129,7 +181,12 @@ class PlayerBotNavigationRuntime PlayerBotNavigationRuntimeOutcome observeStep(const PlayerBotNavigationStepObservation& observation); PlayerBotNavigationRuntimeOutcome observeWorldChange(const PlayerBotNavigationWorldChangeObservation& observation); - void reset() { session.clear(); fixedTargetRouteFailures = 0; } + void reset() + { + session.clear(); + fixedTargetFailures.reset(); + fixedTargetGoal.reset(); + } void resetPatrolRecovery() { session.clearBlockedPositions(); session.resetStepFailures(); } void clearBlockedPositions() { session.clearBlockedPositions(); } @@ -138,18 +195,21 @@ class PlayerBotNavigationRuntime bool avoidPendingRouteBlocker(uint32_t blockerId, const Position& position, std::chrono::steady_clock::time_point now, std::chrono::steady_clock::duration suppression) { - return session.avoidPendingRouteBlocker(blockerId, position, now, suppression); + if (!session.avoidPendingRouteBlocker(blockerId, position, now, suppression)) return false; + fixedTargetFailures.observeBlockedPlan(); + return true; } bool isRouteCritical(uint32_t blockerId, const Position& position, std::chrono::steady_clock::time_point now) const { return session.isRouteCritical(blockerId, position, now); } std::set activeBlockedPositions(std::chrono::steady_clock::time_point now) { return session.activeBlockedPositions(now); } bool hasActiveRouteBlock(std::chrono::steady_clock::time_point now) const { return session.hasActiveRouteBlock(now); } bool oscillationDetected() const { return session.oscillationDetected(); } uint32_t stepFailureCount() const { return session.stepFailureCount(); } - uint32_t fixedTargetRouteFailureCount() const { return fixedTargetRouteFailures; } + uint32_t fixedTargetRouteFailureCount() const { return fixedTargetFailures.count(); } private: void dispatchNextStep(bool canDoAction, PlayerBotNavigationRuntimeOutcome& outcome) const; PlayerBotNavigationSession session; - uint32_t fixedTargetRouteFailures = 0; + PlayerBotFixedTargetFailureTracker fixedTargetFailures; + std::optional fixedTargetGoal; }; #endif diff --git a/server/src/playerbotnavigationsession.cpp b/server/src/playerbotnavigationsession.cpp index db848265..eff99a14 100644 --- a/server/src/playerbotnavigationsession.cpp +++ b/server/src/playerbotnavigationsession.cpp @@ -18,6 +18,7 @@ void PlayerBotNavigationSession::clear() movementPending = false; worldChangePending = false; target = PlayerBotNavigationGoal(); + targetSet = false; blockedStepCount = 0; pendingRouteBlocker.reset(); pendingRouteBlockerId.reset(); @@ -27,6 +28,7 @@ void PlayerBotNavigationSession::clear() void PlayerBotNavigationSession::adopt(const PlayerBotNavigationGoal& goal, std::deque newSteps) { target = goal; + targetSet = true; steps = std::move(newSteps); movementPending = false; worldChangePending = false; @@ -34,21 +36,28 @@ void PlayerBotNavigationSession::adopt(const PlayerBotNavigationGoal& goal, std: void PlayerBotNavigationSession::prepareGoal(const PlayerBotNavigationGoal& goal) { - if (target == goal) { + if (targetSet && target == goal) { return; } steps.clear(); + // Adjacent-approach goal flapping (depot discovery alternating between + // standable tiles of the same depot) must not erase confirmed route + // blockers, or a corridor blocker never becomes attackable. + if (!targetSet || !playerBotNavigationSameFixedObjective(target, goal)) { + clearRequiredRouteBlockers(); + pendingRouteBlocker.reset(); + pendingRouteBlockerId.reset(); + } target = goal; + targetSet = true; blockedStepCount = 0; - clearRequiredRouteBlockers(); - pendingRouteBlocker.reset(); - pendingRouteBlockerId.reset(); } void PlayerBotNavigationSession::installRoute(const PlayerBotNavigationGoal& goal, std::deque newSteps) { target = goal; + targetSet = true; steps = std::move(newSteps); } @@ -139,8 +148,14 @@ bool PlayerBotNavigationSession::avoidPendingRouteBlocker( if (!movementPending || position != stepTarget) return false; movementPending = false; steps.clear(); + // A detour onto a tile that is already suppressed means the planner had no + // alternative around it (one-tile corridor): the blocker is persistent and + // must become attackable instead of looping as an optional detour. + const auto existing = temporarilyBlockedPositions.find(position); + const bool persistent = existing != temporarilyBlockedPositions.end() && now < existing->second; temporarilyBlockedPositions[position] = now + suppression; - requiredRouteBlockerIds.erase(blockerId); + if (persistent) requiredRouteBlockerIds.insert(blockerId); + else requiredRouteBlockerIds.erase(blockerId); pendingRouteBlocker = position; pendingRouteBlockerId = blockerId; return true; diff --git a/server/src/playerbotnavigationsession.h b/server/src/playerbotnavigationsession.h index a007027a..0c0e1eaf 100644 --- a/server/src/playerbotnavigationsession.h +++ b/server/src/playerbotnavigationsession.h @@ -77,6 +77,7 @@ class PlayerBotNavigationSession private: std::deque steps; PlayerBotNavigationGoal target; + bool targetSet = false; Position expectedPosition; Position stepTarget; PlayerBotNavigationGoal progressTarget; diff --git a/server/src/playerbotservice.cpp b/server/src/playerbotservice.cpp index b6a7523c..d06a6524 100644 --- a/server/src/playerbotservice.cpp +++ b/server/src/playerbotservice.cpp @@ -146,6 +146,10 @@ bool PlayerBotController::planSellLootTrip(Player& player, uint16_t currentDepot }); if (sellLootItemScanRemaining == 0) sellLootItemScanRemaining = itemWindowCount; if (sellLootRouteScanRemaining == 0) sellLootRouteScanRemaining = candidates.size(); + // Supply recovery keeps route availability, safety, and fare affordability + // but drops the profit requirement: an unprofitable local sale beats a + // bankrupt bot. + const bool survivalSell = supplyRecovery.active(); const size_t routeScanCount = std::min({candidates.size(), sellLootRouteScanRemaining, maximumSellLootRouteValidationsPerDecision}); const size_t routeScanStart = candidates.empty() ? 0 : sellLootRouteScanOffset % candidates.size(); @@ -265,7 +269,7 @@ bool PlayerBotController::planSellLootTrip(Player& player, uint16_t currentDepot continue; } const uint64_t tripCost = fare + timeCost + danger / 10; - if (candidate.revenue <= tripCost) { + if (!survivalSell && candidate.revenue <= tripCost) { reject("non_positive_utility", nullptr, fare, tripCost, danger); continue; } @@ -329,11 +333,12 @@ bool PlayerBotController::planSellLootTrip(Player& player, uint16_t currentDepot } std::ostringstream fields; fields << "\"action\":\"sell_loot_plan\",\"result\":" << jsonString(found ? "candidate" : "deferred") - << ",\"reason\":" << jsonString(found ? "profitable_trip_validated" : + << ",\"reason\":" << jsonString(found ? (survivalSell ? "survival_trip_validated" : "profitable_trip_validated") : sellLootSearchPending ? "candidate_scan_pending" : "no_profitable_trip") << ",\"top_level_items\":" << scannedItems << ",\"top_level_item_budget\":" << maximumSellLootItems << ",\"route_validations\":" << routeValidations - << ",\"route_validation_budget\":" << maximumSellLootRouteValidationsPerDecision; + << ",\"route_validation_budget\":" << maximumSellLootRouteValidationsPerDecision + << (survivalSell ? ",\"survival\":true" : ""); if (found) { const SellLootPlan& plan = *sellLootPlan; fields << ",\"source_depot_id\":" << plan.sourceDepotId << ",\"npc_id\":" << plan.providerId @@ -469,6 +474,12 @@ void PlayerBotController::setCyclePhase(CyclePhase phase, const Position& positi if (turnRouter.cyclePhase() == phase) { return; } + if (phase == CyclePhase::Hunt) { + huntTravelBudgetPhase = HuntTravelBudgetPhase::None; + huntExitFareReserve = 0; + huntSupplyFareReserve = 0; + huntRecoveryPotionReserve = 0; + } const char* previous = cyclePhaseName(); if (turnRouter.cyclePhase() == CyclePhase::Hunt && phase != CyclePhase::Hunt) { huntCoordinator.cancelPlanning(); @@ -488,6 +499,9 @@ void PlayerBotController::setCyclePhase(CyclePhase phase, const Position& positi void PlayerBotController::beginReturn(Player* player, const Position& position, const char* reason) { + if (huntTravelBudgetPhase != HuntTravelBudgetPhase::None) { + huntTravelBudgetPhase = HuntTravelBudgetPhase::Exit; + } pendingHuntCompletionReason.clear(); const auto traversalTarget = huntCoordinator.traversalTarget(); const uint32_t previousTarget = traversalTarget ? traversalTarget->id : 0; @@ -542,9 +556,36 @@ void PlayerBotController::beginService(Player* player, const Position& position, player->closeContainer(corpseContainerId); setStage(ScenarioStage::Traverse, position); serviceWorkflow.reset(); + serviceWorkflow.setSurvivalRestock(supplyRecovery.active()); beginReturn(player, position, reason); } +void PlayerBotController::updateSupplyRecovery(const Player& player, const Position& position) +{ + const uint64_t funds = player.getMoney() + player.getBankBalance(); + const uint64_t budget = recoverySpendingReserve(player, playerbot::healthPotionRestockTarget); + if (!supplyRecovery.update(funds, budget)) return; + huntCoordinator.setSupplyRecovery(supplyRecovery.active()); + serviceWorkflow.setSurvivalRestock(supplyRecovery.active()); + emit("action_result", position, + std::string("\"action\":\"supply_recovery\",\"result\":\"") + (supplyRecovery.active() ? "started" : "ended") + + "\",\"funds\":" + std::to_string(funds) + ",\"potion_budget\":" + + (budget == std::numeric_limits::max() ? std::string("\"unknown\"") : std::to_string(budget))); +} + +void PlayerBotController::enterSupplyRecovery(const Position& position, uint64_t funds, uint64_t potionBudget, + const char* reason) +{ + if (supplyRecovery.enter()) { + huntCoordinator.setSupplyRecovery(true); + serviceWorkflow.setSurvivalRestock(true); + } + emit("action_result", position, + std::string("\"action\":\"supply_recovery\",\"result\":\"started\",\"reason\":") + jsonString(reason) + + ",\"funds\":" + std::to_string(funds) + ",\"potion_budget\":" + + (potionBudget == std::numeric_limits::max() ? std::string("\"unknown\"") : std::to_string(potionBudget))); +} + void PlayerBotController::finishHuntAndReturn(Player* player, const Position& position, const char* reason) { finishHuntRegion(*player, position, reason); @@ -801,11 +842,15 @@ void PlayerBotController::processService(Player* player, const Position& current const PlayerBotNavigationRiskProfile risk; const bool routeSafe = command.destination == currentPosition || playerBotNavigationRiskAccepts( risk, routePlan.metrics.dangerCost, routePlan.metrics.maximumHealthLossPerSecond); - lastRouteResult = routeSafe ? routePlan.metrics.result : PlayerBotNavigationResult::Unreachable; + const bool routeAffordable = command.destination == currentPosition || + huntTravelFareAffordable(*player, routePlan.metrics.fare, + huntTravelBudgetPhase); + lastRouteResult = routeSafe && routeAffordable ? routePlan.metrics.result : PlayerBotNavigationResult::Unreachable; lastRouteExpandedNodes = routePlan.metrics.expandedNodes; lastRouteSteps = routePlan.metrics.steps; const bool reached = command.destination == currentPosition || - (routePlan.metrics.result == PlayerBotNavigationResult::Reached && !routePlan.steps.empty() && routeSafe); + (routePlan.metrics.result == PlayerBotNavigationResult::Reached && !routePlan.steps.empty() && + routeSafe && routeAffordable); telemetry.recordPathfinding(std::chrono::duration_cast(std::chrono::steady_clock::now() - startedAt), reached); routeObservation.approachRoute.result = reached ? PlayerBotServiceRouteResult::Reached : PlayerBotServiceRouteResult::Unreachable; routeObservation.approachRoute.steps = routePlan.metrics.steps; @@ -859,6 +904,7 @@ void PlayerBotController::processService(Player* player, const Position& current say(*player, "Deposited " + std::to_string(transaction.money) + " gold. Bank: " + std::to_string(observation.bankBalance) + '.'); } + updateSupplyRecovery(*player, currentPosition); } if (command.type == PlayerBotServiceCommandType::Fail) { if (sellingLocalLoot) { @@ -870,7 +916,22 @@ void PlayerBotController::processService(Player* player, const Position& current beginReturn(player, currentPosition, "sell_trip_insufficient_funds"); return; } - stop(command.outcome == PlayerBotServiceOutcome::InsufficientFunds ? "insufficient_potion_funds" : "required_shop_offer_unavailable", currentPosition); + if (command.outcome == PlayerBotServiceOutcome::InsufficientFunds) { + // Unaffordable potions degrade operation instead of stopping: hunt + // and sell under supply recovery until loot funds a restock. + enterSupplyRecovery(currentPosition, observation.money + observation.bankBalance, + recoverySpendingReserve(*player, playerbot::healthPotionRestockTarget), + "insufficient_potion_funds"); + serviceWorkflow.reset(); + if (fixtureDriver.progressionGoalLoop(true).selectGoal) { + selectTopLevelGoal(*player, currentPosition, "supply_recovery_potions_unaffordable"); + } else { + startHunt(player, currentPosition, "supply_recovery_potions_unaffordable"); + } + schedule(SCHEDULER_MINTICKS); + return; + } + stop("required_shop_offer_unavailable", currentPosition); return; } if (command.type == PlayerBotServiceCommandType::Wait && command.itemId != 0 && @@ -1089,7 +1150,8 @@ bool PlayerBotController::discoverDepot(Player& player, const Position& currentP ++result.standableCandidates; const std::optional routeCost = topology.distanceTo( distances, PlayerBotNavigationGoal::exact(approach)); - if (!routeCost && (!sellLootPlan || entry.first != sellLootPlan->sourceDepotId)) continue; + // Keep disconnected lockers in the bounded route workflow. Normal + // navigation may reach them through a verified NPC travel offer. result.candidates.push_back({entry.first, lockerItemId, lockerPosition, approach, routeCost.value_or(playerBotNavigationDistance(currentPosition, approach))}); } @@ -1135,18 +1197,25 @@ bool PlayerBotController::discoverDepot(Player& player, const Position& currentP routePlan = std::move(*paidRoute); } } else { - routePlan = planCompleteNavigationRoute(player, candidate.approachPosition); + // Respect the navigation session's temporarily suppressed tiles so + // a detour loop cannot re-install routes straight through a blocker. + routePlan = planNavigationRoute(player, candidate.approachPosition, + navigationRuntime.activeBlockedPositions(startedAt)); } } const PlayerBotNavigationRiskProfile risk; const bool routeSafe = candidate.approachPosition == currentPosition || playerBotNavigationRiskAccepts( risk, routePlan.metrics.dangerCost, routePlan.metrics.maximumHealthLossPerSecond); - const bool fareAccepted = !sellLootPlan || candidate.depotId != sellLootPlan->sourceDepotId || - routePlan.metrics.fare <= sellLootPlan->sourceFare; + const bool fareAccepted = (!sellLootPlan || candidate.depotId != sellLootPlan->sourceDepotId || + routePlan.metrics.fare <= sellLootPlan->sourceFare) && + huntTravelFareAffordable(player, routePlan.metrics.fare, + huntTravelBudgetPhase); const bool executable = valid && fareAccepted && (candidate.approachPosition == currentPosition || (routePlan.metrics.result == PlayerBotNavigationResult::Reached && !routePlan.steps.empty())); const bool liquidationSource = sellLootPlan && candidate.depotId == sellLootPlan->sourceDepotId; - const bool reached = executable && (routeSafe || (!liquidationSource && command.snapshot.validatingRiskFallback)); + const bool protectedHuntExit = huntTravelBudgetPhase == HuntTravelBudgetPhase::Exit; + const bool reached = executable && playerBotDepotRouteSafetyAccepted( + routeSafe, command.snapshot.validatingRiskFallback, liquidationSource, protectedHuntExit); telemetry.recordPathfinding(std::chrono::duration_cast(std::chrono::steady_clock::now() - startedAt), executable); observation.routeResult = reached ? PlayerBotDepotRouteResult::Reached : executable ? PlayerBotDepotRouteResult::Unsafe : PlayerBotDepotRouteResult::Unreachable; @@ -1154,7 +1223,23 @@ bool PlayerBotController::discoverDepot(Player& player, const Position& currentP observation.expandedNodes = routePlan.metrics.expandedNodes; observation.dangerCost = routePlan.metrics.dangerCost; observation.maximumHealthLossPerSecond = routePlan.metrics.maximumHealthLossPerSecond; - if (reached) steps = std::move(routePlan.steps); + if (reached) { + steps = std::move(routePlan.steps); + } else if (routePlan.metrics.attempted && + !navigationRuntime.activeBlockedPositions(startedAt).empty()) { + // A temporarily suppressed route blocker makes the validated approach + // unreachable. Feed the failure into the runtime so the blocker + // becomes route-critical and exhaustion can escalate. + PlayerBotNavigationRoutePlan failure; + failure.metrics = routePlan.metrics; + const PlayerBotNavigationRuntimeOutcome fed = navigationRuntime.observePlan( + {PlayerBotNavigationGoal::exact(candidate.approachPosition), std::move(failure), + false, false, startedAt}); + if (handleFixedTargetRouteExhausted(&player, currentPosition, fed, startedAt, false)) { + schedule(blockedRouteRetryInterval); + return false; + } + } command = advance(observation); } if (command.type == PlayerBotDepotCommandType::ValidateRoute) { @@ -1194,8 +1279,17 @@ bool PlayerBotController::discoverDepot(Player& player, const Position& currentP const char* reason = command.snapshot.inScopeCandidates == 0 ? "no_local_locker" : command.snapshot.standableCandidates == 0 ? "no_standable_approach" : "no_reachable_locker"; logActionFailure("depot_discover", reason, currentPosition); - if (sellLootPlan) deferSellLoot(player, currentPosition, reason); - else stop("depot_unavailable", currentPosition); + if (sellLootPlan) { + deferSellLoot(player, currentPosition, reason); + } else if (!navigationRuntime.activeBlockedPositions(now).empty()) { + // A temporarily suppressed route blocker (for example a monster in a + // one-tile cave corridor) makes every approach unreachable. Transit + // defense and breakout handle the blocker; retry discovery instead of + // the terminal stop. + schedule(blockedRouteRetryInterval); + } else { + stop("depot_unavailable", currentPosition); + } return false; } const uint32_t retryDelay = command.snapshot.retryAt ? static_cast(std::max(1, @@ -1393,6 +1487,9 @@ void PlayerBotController::processDeposit(Player* player, const Position& current PlayerBotDepotObservation observation; if (command.snapshot.hasSelectedDepot) { observation.atApproach = fixtureDepot.synthetic || Position::areInRange<1, 1, 0>(currentPosition, command.snapshot.selected.lockerPosition); + if (observation.atApproach && huntTravelBudgetPhase == HuntTravelBudgetPhase::Exit) { + huntTravelBudgetPhase = HuntTravelBudgetPhase::Supply; + } observation.lockerOpen = fixtureDepot.synthetic || player->getContainerByID(depotLockerContainerId) != nullptr; observation.chestOpen = fixtureDepot.synthetic || player->getContainerByID(depotChestContainerId) != nullptr; observation.canDoAction = player->canDoAction(); diff --git a/server/src/playerbotserviceworkflow.cpp b/server/src/playerbotserviceworkflow.cpp index 6aa39a9b..d7db11ad 100644 --- a/server/src/playerbotserviceworkflow.cpp +++ b/server/src/playerbotserviceworkflow.cpp @@ -527,8 +527,8 @@ PlayerBotServiceCommand PlayerBotServiceWorkflow::advanceImpl(const PlayerBotSer if (offer == selected->offers.end()) { serviceStage = PlayerBotServiceStage::Failed; return {PlayerBotServiceCommandType::Fail, PlayerBotServiceOutcome::Unavailable}; } const auto restock = disposition.restock({count, observation.freeCapacity, observation.money, observation.bankBalance}, offer->buyPrice, observation.healthPotionWeight, observation.healthPotionReturnThreshold, - observation.healthPotionRestockTarget); - if (restock.insufficientFunds) { serviceStage = PlayerBotServiceStage::Failed; return {PlayerBotServiceCommandType::Fail, PlayerBotServiceOutcome::InsufficientFunds}; } + observation.healthPotionRestockTarget, survivalRestock); + if (restock.insufficientFunds && !survivalRestock) { serviceStage = PlayerBotServiceStage::Failed; return {PlayerBotServiceCommandType::Fail, PlayerBotServiceOutcome::InsufficientFunds}; } if (restock.amount == 0) { serviceStage = PlayerBotServiceStage::Bank; return advanceImpl(observation, catalog, disposition); } targetProvider(selected->id); PlayerBotServiceCommand focus = establishNpc(observation, true); diff --git a/server/src/playerbotserviceworkflow.h b/server/src/playerbotserviceworkflow.h index e9421d3b..27fc556b 100644 --- a/server/src/playerbotserviceworkflow.h +++ b/server/src/playerbotserviceworkflow.h @@ -131,6 +131,7 @@ class PlayerBotServiceWorkflow public: void reset(PlayerBotServiceIntent intent = PlayerBotServiceIntent::Resupply); void setLiquidationPlan(PlayerBotServiceLiquidationPlan plan) { liquidationPlan = plan; } + void setSurvivalRestock(bool survival) { survivalRestock = survival; } const std::optional& liquidation() const { return liquidationPlan; } PlayerBotServiceStage stage() const { return serviceStage; } PlayerBotServiceIntent intent() const { return serviceIntent; } @@ -185,6 +186,7 @@ class PlayerBotServiceWorkflow std::map providerRouteCosts; std::set providersRequiringNpcTravel; std::optional liquidationPlan; + bool survivalRestock = false; }; #endif diff --git a/server/src/playerbotsupplyrecovery.h b/server/src/playerbotsupplyrecovery.h new file mode 100644 index 00000000..9a1bf2e3 --- /dev/null +++ b/server/src/playerbotsupplyrecovery.h @@ -0,0 +1,60 @@ +#ifndef FS_PLAYERBOTSUPPLYRECOVERY_H +#define FS_PLAYERBOTSUPPLYRECOVERY_H + +#include +#include +#include + +/** Degraded operation while a potion restock is unaffordable. The bot keeps + * hunting and selling instead of stopping; region selection clamps toward + * safe, coin-income hunts until funds cover a restock again. */ +class PlayerBotSupplyRecoveryState +{ + public: + bool active() const { return degraded; } + + bool enter() + { + if (degraded) return false; + degraded = true; + return true; + } + + // Returns true when the mode changed. An unknown budget cannot safely + // clear recovery mode, but an observed shortfall may still enter it. + bool update(uint64_t funds, uint64_t potionBudget) + { + if (potionBudget == std::numeric_limits::max()) return false; + const bool next = funds < potionBudget; + if (next == degraded) return false; + degraded = next; + return true; + } + + private: + bool degraded = false; +}; + +inline double playerBotSupplyRecoveryChallengeFrontier(double frontier, bool degraded) +{ + return degraded ? std::min(frontier, 0.20) : frontier; +} + +struct PlayerBotSurvivalSellCandidate { + bool normalPlanAccepted = false; + bool sourceReachable = false; + bool sellerReachable = false; + bool routeSafe = false; + uint64_t fare = 0; + uint64_t funds = 0; +}; + +// Survival selling accepts a reachable, safe merchant even when the trip is +// not profitable; route availability, safety, and fare affordability still bind. +inline bool playerBotSurvivalSellAccepted(bool degraded, const PlayerBotSurvivalSellCandidate& candidate) +{ + return degraded && !candidate.normalPlanAccepted && candidate.sourceReachable && + candidate.sellerReachable && candidate.routeSafe && candidate.fare <= candidate.funds; +} + +#endif diff --git a/server/src/playerbottestpolicy.cpp b/server/src/playerbottestpolicy.cpp index 15aea3db..00da28b7 100644 --- a/server/src/playerbottestpolicy.cpp +++ b/server/src/playerbottestpolicy.cpp @@ -53,6 +53,7 @@ const playerbot::PlayerBotTestPolicy& playerbot::playerBotTestPolicyFromEnvironm (std::strncmp(gameplayMode, "magic_training", 14) == 0 && std::strcmp(gameplayMode, "magic_training_hunt") != 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; const bool startInHunt = gameplayMode && (std::strcmp(gameplayMode, "navigation") == 0 || std::strcmp(gameplayMode, "navigation_recovery") == 0 || @@ -66,7 +67,7 @@ const playerbot::PlayerBotTestPolicy& playerbot::playerBotTestPolicyFromEnvironm std::strcmp(gameplayMode, "healing") == 0 || std::strcmp(gameplayMode, "healing_resupply") == 0 || std::strcmp(gameplayMode, "value") == 0 || std::strcmp(gameplayMode, "departure_interrupt") == 0 || std::strcmp(gameplayMode, "stamina_bonus") == 0 || std::strcmp(gameplayMode, "stamina_boundary") == 0 || - std::strcmp(gameplayMode, "stamina_normal") == 0 || std::strcmp(gameplayMode, "hunt_planning") == 0 || huntAreaArrivalFixture || + std::strcmp(gameplayMode, "stamina_normal") == 0 || std::strcmp(gameplayMode, "hunt_planning") == 0 || huntAreaArrivalFixture || remoteHuntFixture || std::strcmp(gameplayMode, "adaptive_challenge") == 0 || std::strcmp(gameplayMode, "sell_loot") == 0 || std::strcmp(gameplayMode, "sell_loot_remote_depot") == 0 || @@ -84,7 +85,7 @@ const playerbot::PlayerBotTestPolicy& playerbot::playerBotTestPolicyFromEnvironm const bool fixedFixtureRoute = gameplayMode && std::strcmp(gameplayMode, "stamina_bonus") != 0 && std::strcmp(gameplayMode, "stamina_boundary") != 0 && std::strcmp(gameplayMode, "stamina_normal") != 0 && - std::strcmp(gameplayMode, "hunt_planning") != 0 && !huntAreaArrivalFixture && + std::strcmp(gameplayMode, "hunt_planning") != 0 && !huntAreaArrivalFixture && !remoteHuntFixture && std::strcmp(gameplayMode, "adaptive_challenge") != 0 && std::strcmp(gameplayMode, "equipment_shadow") != 0 && std::strcmp(gameplayMode, "equipment_shadow_unaffordable") != 0 && @@ -149,6 +150,8 @@ const playerbot::PlayerBotTestPolicy& playerbot::playerBotTestPolicyFromEnvironm gameplayMode && std::strcmp(gameplayMode, "carlin_service_route") == 0, gameplayMode && std::strcmp(gameplayMode, "mutable_portal_route") == 0, depotRiskFallbackFixture, + remoteHuntFixture, + gameplayMode && std::strcmp(gameplayMode, "mainland") == 0, }; }(); return policy; diff --git a/server/src/playerbottestpolicy.h b/server/src/playerbottestpolicy.h index 82770bfc..10296791 100644 --- a/server/src/playerbottestpolicy.h +++ b/server/src/playerbottestpolicy.h @@ -11,6 +11,7 @@ #ifndef FS_PLAYERBOTTESTPOLICY_H #define FS_PLAYERBOTTESTPOLICY_H +#include #include namespace playerbot { @@ -59,8 +60,18 @@ namespace playerbot { bool carlinServiceRouteFixture; bool mutablePortalRouteFixture; bool depotRiskFallbackFixture; + bool remoteHuntFixture; + bool mainlandLoopFixture; }; + inline uint32_t playerBotFixtureHuntPlanningDuration(bool mainlandLoopFixture, + uint32_t configuredDurationSeconds) + { + constexpr uint32_t mainlandPlanningDurationSeconds = 900; + return mainlandLoopFixture ? std::max(configuredDurationSeconds, mainlandPlanningDurationSeconds) : + configuredDurationSeconds; + } + const PlayerBotTestPolicy& playerBotTestPolicyFromEnvironment(); } diff --git a/server/src/playerbottopology.cpp b/server/src/playerbottopology.cpp index 79fc0eaa..cc5a39d7 100644 --- a/server/src/playerbottopology.cpp +++ b/server/src/playerbottopology.cpp @@ -12,7 +12,6 @@ #include "tile.h" #include -#include #include #include @@ -100,6 +99,8 @@ void PlayerBotTopology::build(const Map& map) walkNodes.clear(); nodeComponents.clear(); edges.clear(); + componentEdges.clear(); + reachabilityCache.clear(); topologyPortals.clear(); components = 0; size_t walkableTiles = 0; @@ -189,23 +190,6 @@ void PlayerBotTopology::build(const Map& map) } edges.resize(nodeCount); - std::vector nodeParents(nodeCount); - std::iota(nodeParents.begin(), nodeParents.end(), 0); - auto findNodeRoot = [&nodeParents](uint32_t node) { - uint32_t root = node; - while (nodeParents[root] != root) root = nodeParents[root]; - while (nodeParents[node] != node) { - const uint32_t parent = nodeParents[node]; - nodeParents[node] = root; - node = parent; - } - return root; - }; - auto joinNodes = [&nodeParents, &findNodeRoot](uint32_t left, uint32_t right) { - left = findNodeRoot(left); - right = findNodeRoot(right); - if (left != right) nodeParents[right] = left; - }; auto addEdge = [this](uint32_t from, uint32_t to, const PlayerBotTopologyPortal& portal) { auto& outgoing = edges[from]; if (std::none_of(outgoing.begin(), outgoing.end(), [to, &portal](const Edge& edge) { @@ -217,7 +201,7 @@ void PlayerBotTopology::build(const Map& map) topologyPortals.push_back(portal); } }; - map.forEachTile([this, &map, &addEdge, &joinNodes](const Tile& tile) { + map.forEachTile([this, &map, &addEdge](const Tile& tile) { const Position& position = tile.getPosition(); const auto current = walkNodes.find(positionKey(position)); if (current == walkNodes.end()) return; @@ -239,9 +223,16 @@ void PlayerBotTopology::build(const Map& map) portal.minimumLevel = doorMinimumLevel(*targetDoor); } addEdge(from, entry->second, portal); - joinNodes(from, entry->second); } }); + std::vector walkArcs; + for (uint32_t source = 0; source < edges.size(); ++source) { + for (const Edge& edge : edges[source]) { + walkArcs.push_back({source, edge.destinationNode, edge.portal.minimumLevel == 0}); + } + } + const std::vector nodeRoots = + playerBotTopologyBidirectionalComponents(nodeCount, walkArcs); auto upperDestination = [this](const Position& target) -> std::optional { if (target.z == 0) return std::nullopt; const Position upper(target.x, target.y, target.z - 1); @@ -296,11 +287,21 @@ void PlayerBotTopology::build(const Map& map) std::unordered_map componentIds; componentIds.reserve(nodeCount); for (uint32_t node = 0; node < nodeCount; ++node) { - const uint32_t root = findNodeRoot(node); + const uint32_t root = nodeRoots[node]; auto [entry, inserted] = componentIds.emplace(root, components); if (inserted) ++components; nodeComponents[node] = entry->second; } + componentEdges.resize(components); + for (uint32_t sourceNode = 0; sourceNode < edges.size(); ++sourceNode) { + const uint32_t sourceComponent = nodeComponents[sourceNode]; + for (const Edge& edge : edges[sourceNode]) { + const uint32_t destinationComponent = nodeComponents[edge.destinationNode]; + if (sourceComponent == destinationComponent) continue; + componentEdges[sourceComponent].push_back( + {destinationComponent, edge.portal.action, edge.portal.minimumLevel}); + } + } } std::optional PlayerBotTopology::walkComponent(const Position& position) const @@ -391,6 +392,55 @@ std::optional PlayerBotTopology::distanceTo(const PlayerBotTopologyDis return best; } +std::shared_ptr PlayerBotTopology::reachabilityFrom( + const Position& start, bool canUseRope, bool canUseShovel, uint32_t playerLevel) const +{ + const auto startNode = walkNodes.find(positionKey(start)); + if (startNode == walkNodes.end()) return {}; + const uint32_t startComponent = nodeComponents[startNode->second]; + const auto key = std::make_tuple(startComponent, canUseRope, canUseShovel, playerLevel); + if (auto found = reachabilityCache.find(key); found != reachabilityCache.end()) { + if (auto cached = found->second.lock()) return cached; + reachabilityCache.erase(found); + } + constexpr size_t maximumReachabilityCacheEntries = 512; + if (reachabilityCache.size() >= maximumReachabilityCacheEntries) { + for (auto it = reachabilityCache.begin(); it != reachabilityCache.end();) { + if (it->second.expired()) it = reachabilityCache.erase(it); + else ++it; + } + if (reachabilityCache.size() >= maximumReachabilityCacheEntries) reachabilityCache.erase(reachabilityCache.begin()); + } + auto result = std::make_shared(); + result->generation = topologyGeneration; + result->components.assign(components, 0); + std::queue open; + result->components[startComponent] = 1; + open.push(startComponent); + while (!open.empty()) { + const uint32_t current = open.front(); + open.pop(); + for (const ComponentEdge& edge : componentEdges[current]) { + if ((edge.action == PlayerBotTopologyPortalAction::UseRope && !canUseRope) || + (edge.action == PlayerBotTopologyPortalAction::UseShovel && !canUseShovel) || + edge.minimumLevel > playerLevel || result->components[edge.destination]) continue; + result->components[edge.destination] = 1; + open.push(edge.destination); + } + } + reachabilityCache.emplace(key, result); + return result; +} + +bool PlayerBotTopology::reachable( + const PlayerBotTopologyReachability& reachability, const Position& destination) const +{ + if (reachability.generation != topologyGeneration) return false; + const auto node = walkNodes.find(positionKey(destination)); + return node != walkNodes.end() && nodeComponents[node->second] < reachability.components.size() && + reachability.components[nodeComponents[node->second]] != 0; +} + std::optional PlayerBotTopology::route( const Position& start, const Position& destination, const std::set& blockedPositions, bool canUseRope, bool canUseShovel, uint32_t playerLevel, diff --git a/server/src/playerbottopology.h b/server/src/playerbottopology.h index c8e03694..8a58bdf7 100644 --- a/server/src/playerbottopology.h +++ b/server/src/playerbottopology.h @@ -5,9 +5,12 @@ #include #include #include +#include +#include #include #include #include +#include #include #include @@ -25,6 +28,14 @@ class PlayerBotTopologyDistances { uint64_t generation = 0; }; +class PlayerBotTopologyReachability { + friend class PlayerBotTopology; + + private: + std::vector components; + uint64_t generation = 0; +}; + enum class PlayerBotTopologyPortalAction : uint8_t { Move, Use, @@ -50,6 +61,45 @@ struct PlayerBotTopologyRoute { double maximumHealthLossPerSecond = 0; }; +struct PlayerBotTopologyComponentArc { + uint32_t source = 0; + uint32_t destination = 0; + bool unconditional = false; +}; + +inline std::vector playerBotTopologyBidirectionalComponents( + uint32_t nodeCount, const std::vector& arcs) +{ + std::vector parents(nodeCount); + for (uint32_t node = 0; node < nodeCount; ++node) parents[node] = node; + auto findRoot = [&parents](uint32_t node) { + uint32_t root = node; + while (parents[root] != root) root = parents[root]; + while (parents[node] != node) { + const uint32_t parent = parents[node]; + parents[node] = root; + node = parent; + } + return root; + }; + auto join = [&parents, &findRoot](uint32_t left, uint32_t right) { + left = findRoot(left); + right = findRoot(right); + if (left != right) parents[right] = left; + }; + std::set> unconditional; + for (const PlayerBotTopologyComponentArc& arc : arcs) { + if (arc.unconditional && arc.source < nodeCount && arc.destination < nodeCount) { + unconditional.emplace(arc.source, arc.destination); + } + } + for (const auto& [source, destination] : unconditional) { + if (unconditional.find({destination, source}) != unconditional.end()) join(source, destination); + } + for (uint32_t node = 0; node < nodeCount; ++node) parents[node] = findRoot(node); + return parents; +} + class PlayerBotTopology { public: @@ -71,6 +121,10 @@ class PlayerBotTopology const Position& destination) const; std::optional distanceTo(const PlayerBotTopologyDistances& distances, const PlayerBotNavigationGoal& goal) const; + std::shared_ptr reachabilityFrom( + const Position& start, bool canUseRope = true, bool canUseShovel = true, + uint32_t playerLevel = std::numeric_limits::max()) const; + bool reachable(const PlayerBotTopologyReachability& reachability, const Position& destination) const; const std::vector& portals() const { return topologyPortals; } size_t tileCount() const { return walkNodes.size(); } uint32_t componentCount() const { return components; } @@ -82,10 +136,18 @@ class PlayerBotTopology uint32_t destinationNode = 0; PlayerBotTopologyPortal portal; }; + struct ComponentEdge { + uint32_t destination = 0; + PlayerBotTopologyPortalAction action = PlayerBotTopologyPortalAction::Move; + uint32_t minimumLevel = 0; + }; std::unordered_map walkNodes; std::vector nodeComponents; std::vector> edges; + std::vector> componentEdges; + mutable std::map, + std::weak_ptr> reachabilityCache; std::vector topologyPortals; uint32_t components = 0; uint64_t topologyGeneration = 0; diff --git a/server/src/playerbottransitcombat.h b/server/src/playerbottransitcombat.h index 9495675d..29ed7992 100644 --- a/server/src/playerbottransitcombat.h +++ b/server/src/playerbottransitcombat.h @@ -4,7 +4,6 @@ #include #include -#include #include "playerbotturnrouter.h" @@ -31,29 +30,64 @@ class PlayerBotTransitCombat return changed; } void begin() { travelling = true; } - void finish() { travelling = false; attempted.clear(); } + void finish() + { + travelling = false; + defenseAttempted = false; + breakoutAttempted = false; + breakout = false; + } bool active() const { return travelling; } - bool allowsDefense(uint32_t id, bool routeCritical) const + bool allowsDefense(uint32_t, bool routeCritical) const { - return !travelling || (routeCritical && attempted.count(id) == 0); + return !travelling || (routeCritical && (!defenseAttempted || breakout)); } - void beginDefense(uint32_t id, std::chrono::steady_clock::time_point now) + void beginDefense(uint32_t, std::chrono::steady_clock::time_point now) { if (!travelling) return; - attempted.insert(id); - deadline = now + std::chrono::seconds(5); + if (!defenseAttempted) defenseDeadline = now + std::chrono::seconds(5); + defenseAttempted = true; } bool defenseExpired(std::chrono::steady_clock::time_point now) const { - return travelling && !attempted.empty() && now >= deadline; + return travelling && defenseAttempted && !breakout && now >= defenseDeadline; + } + bool beginBreakout(bool routeExhausted, bool adjacentConfirmedHostileBlocker, + [[maybe_unused]] bool routeUnavailable, + std::chrono::steady_clock::time_point now, + std::chrono::steady_clock::duration duration = std::chrono::seconds(30)) + { + if (!travelling || breakoutAttempted || !routeExhausted || !adjacentConfirmedHostileBlocker) { + return false; + } + breakoutAttempted = true; + breakout = true; + breakoutDeadline = now + duration; + return true; + } + bool breakoutWasAttempted() const { return breakoutAttempted; } + bool breakoutActive() const { return travelling && breakout; } + bool breakoutExpired(std::chrono::steady_clock::time_point now) const + { + return breakoutActive() && now >= breakoutDeadline; + } + bool observeBreakoutNavigation(bool positionalProgress, bool routeAvailable) + { + if (!breakoutActive() || (!positionalProgress && !routeAvailable)) return false; + breakout = false; + return true; } + void finishBreakout() { breakout = false; } private: bool travelling = false; + bool defenseAttempted = false; + bool breakoutAttempted = false; + bool breakout = false; uint64_t goalId = 0; PlayerBotCyclePhase cyclePhase = PlayerBotCyclePhase::Idle; - std::set attempted; - std::chrono::steady_clock::time_point deadline{}; + std::chrono::steady_clock::time_point defenseDeadline{}; + std::chrono::steady_clock::time_point breakoutDeadline{}; }; #endif diff --git a/server/tests/playerbot-gameplay/includes/combat.inc b/server/tests/playerbot-gameplay/includes/combat.inc index 6624ba7d..c19c5ac0 100644 --- a/server/tests/playerbot-gameplay/includes/combat.inc +++ b/server/tests/playerbot-gameplay/includes/combat.inc @@ -295,6 +295,11 @@ function F.spawnCorpseMonster(playerId, monsterName) if tile and tile:isWalkable() then local monster = Game.createMonster(monsterName, position, true, true) assert(monster, "corpse test monster could not be created: " .. monsterName) + if monsterName == F.lootMonsterName then + -- This case tests proactive corpse looting, not defensive combat. + monster:setTarget(nil) + monster:setIdle(true) + end print("PLAYERBOT_GAMEPLAY_TEST SPAWNED " .. monsterName) return end diff --git a/server/tests/playerbot-gameplay/includes/login.inc b/server/tests/playerbot-gameplay/includes/login.inc index f42a09cd..22856466 100644 --- a/server/tests/playerbot-gameplay/includes/login.inc +++ b/server/tests/playerbot-gameplay/includes/login.inc @@ -37,7 +37,7 @@ function F.login.onLogin(player) end end F.selectHealthPotion(player) - 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 == "hunt_area_arrival" or mode == "corpse" or mode == "corpse_detour" or mode == "corpse_inaccessible" or mode == "death" or mode == "healing" or mode == "spell_use" or + 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 == "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 == "spell_use" or mode == "healing_resupply" or mode == "value" or mode == "progression" or mode == "progression_bundle" or mode == "progression_nested" or mode == "progression_resume" or mode == "progression_nested_resume" or mode == "progression_space" or mode == "arbitration" or mode == "arbitration_interrupt" or mode == "departure" or @@ -357,7 +357,7 @@ function F.login.onLogin(player) print("PLAYERBOT_GAMEPLAY_TEST " .. string.upper(mode) .. "_START") return true end - if mode ~= "hunt_planning" and mode ~= "hunt_area_arrival" then + if mode ~= "hunt_planning" and mode ~= "hunt_area_arrival" and mode ~= "remote_hunt" then F.restoreRookgaardBaseline(player, mode ~= "cycle") end F.selectHealthPotion(player) @@ -425,7 +425,7 @@ function F.login.onLogin(player) print("PLAYERBOT_GAMEPLAY_TEST READINESS_" .. string.upper(mode:gsub("readiness_", "")) .. "_START") return true end - if mode == "hunt_planning" or mode == "hunt_area_arrival" then + if mode == "hunt_planning" or mode == "hunt_area_arrival" or mode == "remote_hunt" then local carlin = Town(F.carlinTownId) assert(carlin and player:setTown(carlin) and player:setVocation(4), "hunt planning fixture could not select the Carlin Knight") @@ -440,10 +440,16 @@ function F.login.onLogin(player) assert(player:getLevel() == 8 and player:teleportTo(F.carlinTemplePosition), "hunt planning fixture could not prepare the level-8 Carlin Knight") F.installSeededHuntLoadout(player, mode) + if mode == "remote_hunt" then + local remoteExperience = Game.getExperienceForLevel(15) - player:getExperience() + if remoteExperience > 0 then player:addExperience(remoteExperience) end + assert(player:getLevel() == 15, "remote hunt fixture could not prepare the level-15 Knight") + end assert(player:getItemCount(F.healthPotionItemId) == 10 and player:getItemCount(F.smallHealthPotionItemId) == 0, "hunt planning fixture has the wrong healing supplies") if mode == "hunt_planning" then F.suppressNearbyMonsters(player:getId()) end print(mode == "hunt_area_arrival" and "PLAYERBOT_GAMEPLAY_TEST HUNT_AREA_ARRIVAL_START" or + mode == "remote_hunt" and "PLAYERBOT_GAMEPLAY_TEST REMOTE_HUNT_START" or "PLAYERBOT_GAMEPLAY_TEST HUNT_PLANNING_START") return true end diff --git a/server/tests/playerbot_contracts.cpp b/server/tests/playerbot_contracts.cpp index 2d6e9d54..95a845e0 100644 --- a/server/tests/playerbot_contracts.cpp +++ b/server/tests/playerbot_contracts.cpp @@ -4,11 +4,17 @@ #include #include +#include "playerbotdepotworkflow.h" +#include "playerboteconomy.h" #include "playerbotgoalplanner.h" +#include "playerbotnavigationruntime.h" #include "playerbottransitcombat.h" +#include "playerbottopology.h" #include "playerbothuntregions.h" #include "playerbothuntruntime.h" #include "playerbotinventorypolicy.h" +#include "playerbotsupplyrecovery.h" +#include "playerbottestpolicy.h" #include "playerbotturnrouter.h" namespace { @@ -175,14 +181,13 @@ void mixedSustainedYield() assert(playerBotSustainedHuntYield(points, viability, 1200).clearExperiencePerMinute == 3); } -PlayerBotHuntRuntimeOutcome planRuntimeHunts(const std::vector& candidates, bool diversify = false) +PlayerBotHuntRuntimeOutcome planRuntimeHunts(const std::vector& candidates) { PlayerBotHuntRuntime runtime({}); PlayerBotHuntRuntimePlanningInput input; input.cacheRevision = 1; input.start.emplace(); input.start->scan.revision = 1; - input.start->profile.diversifyIncomeRoutes = diversify; input.start->scan.candidateCount = candidates.size(); for (size_t i = 0; i < candidates.size(); ++i) input.start->scan.candidateIndices.push_back(i); const auto now = std::chrono::steady_clock::time_point{}; @@ -241,78 +246,147 @@ void raisedHuntRecoveryReserve() assert(selectRuntimeHunt({income, xp}).atlasVariantId == 2); } -void diversifiedRouteShortlist() +void incrementalHuntValidationPipeline() { - using namespace playerbot; - std::vector candidates(12); - for (size_t i = 0; i < candidates.size(); ++i) { - auto& region = candidates[i]; - region.atlasVariantId = i + 1; + std::vector candidates(10); + for (size_t index = 0; index < candidates.size(); ++index) { + PlayerBotHuntRegion& region = candidates[index]; + region.atlasVariantId = index + 1; region.suitable = region.reachable = true; - region.supplyProfile.potions = 10; - region.score = 100 - i; + region.supplyBudget.fits = true; + region.score = 100 - index; + region.optimisticProjectedExperience = 100 - index; + region.topologyReachable = index % 2 == 0; } - candidates.back().coinGoldPerMinute = 100; - const uint32_t maximumLegalReserve = 12; - auto recoveryCost = [&](uint32_t potions, uint32_t reserve) { - return playerBotRecoverySpendingReserve(potions, recoveryPotionRestockTargetForReserve(reserve), 45, carriedGoldReserve); - }; - auto diversify = [&](uint32_t potions, uint64_t funds) { - return playerBotHuntCashPressure(potions, maximumLegalReserve, funds, recoveryCost(potions, maximumLegalReserve)); - }; - assert(diversify(10, 200)); - assert(!diversify(10, 235)); // can fund even the largest legal route reserve - assert(!diversify(14, 0)); // plentiful supplies do not need income probes - const auto ordinary = planRuntimeHunts(candidates); - assert(ordinary.routeCandidates.size() == 8); - for (size_t i = 0; i < 8; ++i) assert(ordinary.routeCandidates[i].atlasVariantId == i + 1); - const auto diversified = planRuntimeHunts(candidates, diversify(10, 200)); - assert(diversified.routeCandidates.size() == 8 && diversified.candidates.size() == 12); - assert(diversified.selectedRegion->atlasVariantId == ordinary.selectedRegion->atlasVariantId); - for (size_t i = 0; i < 12; ++i) assert(diversified.candidates[i].atlasVariantId == ordinary.candidates[i].atlasVariantId); - for (size_t i = 0; i < 4; ++i) assert(diversified.routeCandidates[i].atlasVariantId == i + 1); - auto containsIncome = [](const auto& regions) { - return std::any_of(regions.begin(), regions.end(), [](const auto& region) { return region.atlasVariantId == 12; }); - }; - assert(containsIncome(diversified.routeCandidates)); - assert(!containsIncome(ordinary.routeCandidates)); - assert(!containsIncome(planRuntimeHunts(candidates, diversify(10, 235)).routeCandidates)); - // Execute the same reconciliation and final comparison used after routing. - auto routed = diversified.routeCandidates; - for (auto& region : routed) { - region.routeValidated = true; - region.reconcileRecovery(maximumLegalReserve, 200, recoveryCost(10, maximumLegalReserve)); + const auto planned = planRuntimeHunts(candidates); + assert(planned.routeCandidates.size() == candidates.size()); + for (size_t index = 0; index < candidates.size(); ++index) { + assert(planned.routeCandidates[index].atlasVariantId == index + 1); + } + // There is no fixed-eight, local/remote, observed/unobserved, or income slot. + assert(std::count_if(planned.routeCandidates.begin(), planned.routeCandidates.end(), + [](const auto& region) { return region.topologyReachable; }) == 5); + + // Nine route failures still leave the tenth candidate available to win. + size_t validated = 0; + std::optional winner; + for (const PlayerBotHuntRegion& candidate : planned.routeCandidates) { + ++validated; + if (candidate.atlasVariantId == 10) winner = candidate; + } + assert(validated == 10 && winner && winner->atlasVariantId == 10); + + PlayerBotHuntRegion incumbent = candidates.front(); + incumbent.score = 95; + incumbent.optimisticProjectedExperience = 95; + assert(!playerBotHuntRemainingCanBeatValidated(candidates, 6, incumbent)); + candidates[6].optimisticProjectedExperience = incumbent.score; + assert(playerBotHuntRemainingCanBeatValidated(candidates, 6, incumbent)); + candidates[6].optimisticProjectedExperience = 94; + incumbent.supplyBudget.fits = false; + assert(playerBotHuntRemainingCanBeatValidated(candidates, 6, incumbent)); + + // A validated fitting winner skips each dominated candidate without hiding a + // later candidate whose optimistic bound can still win in another supply tier. + std::vector sparseCompetition(52); + for (size_t index = 0; index < sparseCompetition.size(); ++index) { + sparseCompetition[index].atlasVariantId = index + 1; + sparseCompetition[index].suitable = sparseCompetition[index].reachable = true; + sparseCompetition[index].supplyBudget.fits = true; + sparseCompetition[index].optimisticProjectedExperience = 499; } - auto best = [&]() { return std::min_element(routed.begin(), routed.end(), playerBotPreferHuntRegion)->atlasVariantId; }; - assert(best() == 12); // the initially twelfth XP option actually reaches routing - for (auto& region : routed) if (region.atlasVariantId == 12) region.suitable = false; - assert(best() == 1); // rejected outbound/return danger cannot be overridden - for (auto& region : routed) { - region.suitable = true; - region.reconcileRecovery(maximumLegalReserve, 235, recoveryCost(10, maximumLegalReserve)); + PlayerBotHuntRegion earlyWinner = sparseCompetition.front(); + earlyWinner.score = 548; + earlyWinner.supplyBudget.fits = true; + sparseCompetition.back().supplyBudget.fits = false; + sparseCompetition.back().optimisticProjectedExperience = 600; + assert(playerBotNextHuntCandidateToValidate(sparseCompetition, 1, &earlyWinner) == 51); + assert(playerBotHuntRemainingCanBeatValidated(sparseCompetition, 1, earlyWinner)); + sparseCompetition.back().optimisticProjectedExperience = 547; + assert(playerBotNextHuntCandidateToValidate(sparseCompetition, 1, &earlyWinner) == + sparseCompetition.size()); + + // Scoring remains bounded per turn and cancellation is honored before a + // second 256-candidate batch starts. + PlayerBotHuntRuntime runtime({}); + PlayerBotHuntRuntimePlanningInput input; + input.cacheRevision = 1; + input.start.emplace(); + input.start->scan.revision = 1; + input.start->scan.candidateCount = 300; + for (size_t index = 0; index < 300; ++index) input.start->scan.candidateIndices.push_back(index); + const auto now = std::chrono::steady_clock::time_point{}; + assert(runtime.advancePlanning(input, now).command == PlayerBotHuntRuntimeCommand::PlanningStarted); + const auto firstTurn = runtime.advancePlanning(input, now); + assert(firstTurn.scoreWork.size() == 256); + std::vector observations; + for (const auto& work : firstTurn.scoreWork) { + PlayerBotHuntRegion region; + region.suitable = region.reachable = true; + observations.push_back({work.candidateIndex, true, true, true, region}); } - assert(best() == 1); // exact affordability retains XP even after diversification - for (auto& region : routed) region.reconcileRecovery(1, 200, recoveryCost(10, 1)); - assert(best() == 1); // an inexpensive actual route likewise retains XP - - candidates.back().supplyBudget.fits = false; - assert(!containsIncome(planRuntimeHunts(candidates, true).routeCandidates)); - for (auto& region : candidates) { - region.supplyBudget.fits = false; - region.supplyBudget.expectedPotions = 1; + assert(runtime.completeScoreWork(observations, 0).command == PlayerBotHuntRuntimeCommand::PlanningYield); + PlayerBotHuntPlanningObservation cancel; + cancel.cancelAtScoreBarrier = true; + assert(runtime.advancePlanning(input, now, cancel).command == PlayerBotHuntRuntimeCommand::PlanningCancelled); + assert(!runtime.planningActive()); + + // Transport closure is a separate bounded phase and cancellation is honored + // between its eight-offer batches. + PlayerBotHuntRuntime boundedTransport({}); + PlayerBotHuntRuntimePlanningInput boundedTransportInput; + boundedTransportInput.cacheRevision = 1; + boundedTransportInput.start.emplace(); + boundedTransportInput.start->scan.revision = 1; + boundedTransportInput.start->originReachability = std::make_shared(); + boundedTransportInput.start->transportOffers = std::make_shared>(10); + assert(boundedTransport.advancePlanning(boundedTransportInput, now).command == + PlayerBotHuntRuntimeCommand::PlanningStarted); + const auto boundedTransportTurn = boundedTransport.advancePlanning(boundedTransportInput, now); + assert(boundedTransportTurn.transportWork.size() == 8); + std::vector emptyTransportObservations; + for (const auto& work : boundedTransportTurn.transportWork) { + emptyTransportObservations.push_back({work.offerIndex, std::nullopt}); } - candidates.back().supplyBudget.expectedPotions = 2; - assert(!containsIncome(planRuntimeHunts(candidates, true).routeCandidates)); - candidates.back().supplyBudget.expectedPotions = 1; - candidates[0].supplyBudget.fits = candidates[1].supplyBudget.fits = true; - const auto mixedTiers = planRuntimeHunts(candidates, true).routeCandidates; - assert(mixedTiers.size() == 8 && containsIncome(mixedTiers)); - assert(mixedTiers[0].atlasVariantId == 1 && mixedTiers[1].atlasVariantId == 2); - candidates.back().predictedLethal = true; - assert(!containsIncome(planRuntimeHunts(candidates, true).routeCandidates)); - candidates.back().predictedLethal = false; - candidates.back().reachable = false; - assert(!containsIncome(planRuntimeHunts(candidates, true).routeCandidates)); + assert(boundedTransport.completeTransportWork(emptyTransportObservations).command == + PlayerBotHuntRuntimeCommand::PlanningYield); + PlayerBotHuntPlanningObservation cancelBoundedTransport; + cancelBoundedTransport.cancelAtScoreBarrier = true; + assert(boundedTransport.advancePlanning(boundedTransportInput, now, cancelBoundedTransport).command == + PlayerBotHuntRuntimeCommand::PlanningCancelled); + + // A newly reached transport destination becomes available to later offers on + // the next pass, preserving affordable multihop labels without one synchronous + // all-NPC closure. + PlayerBotHuntRuntime transportRuntime({}); + PlayerBotHuntRuntimePlanningInput transportInput; + transportInput.cacheRevision = 1; + transportInput.player.level = 20; + transportInput.player.funds = 100; + transportInput.start.emplace(); + transportInput.start->scan.revision = 1; + transportInput.start->scan.candidateCount = 1; + transportInput.start->scan.candidateIndices = {0}; + transportInput.start->originReachability = std::make_shared(); + transportInput.start->transportOffers = std::make_shared>( + std::initializer_list{{Position(1, 1, 7), Position(2, 2, 7), 10}, + {Position(2, 2, 7), Position(3, 3, 7), 20}}); + assert(transportRuntime.advancePlanning(transportInput, now).command == PlayerBotHuntRuntimeCommand::PlanningStarted); + auto transportTurn = transportRuntime.advancePlanning(transportInput, now); + assert(transportTurn.transportWork.size() == 2 && transportTurn.transportWork[0].arrivals && + transportTurn.transportWork[0].arrivals->size() == 1); + auto reachability = std::make_shared(); + std::vector transportObservations = { + {0, PlayerBotHuntTransportArrival{Position(2, 2, 7), 10, 1, reachability}}, {1, std::nullopt}}; + assert(transportRuntime.completeTransportWork(transportObservations).command == PlayerBotHuntRuntimeCommand::PlanningYield); + transportTurn = transportRuntime.advancePlanning(transportInput, now); + assert(transportTurn.transportWork.size() == 2 && transportTurn.transportWork[1].arrivals && + transportTurn.transportWork[1].arrivals->size() == 2); + PlayerBotHuntPlanningObservation cancelTransport; + cancelTransport.cancelAtScoreBarrier = true; + transportRuntime.completeTransportWork({{0, std::nullopt}, {1, std::nullopt}}); + assert(transportRuntime.advancePlanning(transportInput, now, cancelTransport).command == + PlayerBotHuntRuntimeCommand::PlanningCancelled); } void lootArithmeticMemo() @@ -368,6 +442,24 @@ void modeledPatrolFailure() assert(fallback.patrolTarget().destination == points[1]); } +void navigationFailureAccounting() +{ + PlayerBotFixedTargetFailureTracker failures; + assert(!failures.observePosition(false, 20)); + for (int attempt = 0; attempt < 20; ++attempt) { + failures.observePlan(true); + failures.observeBlockedPlan(); // the dispatched route is immediately blocked + failures.observePlan(false); // the same cycle's unavailable replan is not double-counted + assert(failures.count() == static_cast(attempt + 1)); + } + assert(failures.exhausted()); + assert(failures.observePosition(true, 19)); + assert(failures.count() == 0 && !failures.exhausted()); + failures.observePlan(false); + failures.observePosition(false, 200); + assert(failures.count() == 0); // a different fixed goal starts a new sequence +} + void transitCombat() { using Phase = PlayerBotCyclePhase; @@ -384,6 +476,7 @@ void transitCombat() for (int turn = 0; turn < 100; ++turn) { assert(!episode.observe(true, 1, Phase::ReturnToDepot)); assert(!episode.allowsDefense(42, true)); + assert(!episode.allowsDefense(43, true)); // the budget belongs to the episode, not the monster ID for (uint32_t id = 43; id < 48; ++id) assert(!episode.allowsDefense(id, false)); } // Wall-clock expiry is independent of how many turns healing consumes. @@ -426,11 +519,26 @@ void transitCombat() retreat.begin(); assert(!retreat.allowsDefense(42, true)); assert(retreat.defenseExpired(now + std::chrono::seconds(75))); - assert(retreat.allowsDefense(43, true)); - retreat.beginDefense(43, now + std::chrono::seconds(75)); - assert(!retreat.defenseExpired(now + std::chrono::seconds(79))); - assert(retreat.defenseExpired(now + std::chrono::seconds(80))); - assert(!retreat.allowsDefense(42, true)); + assert(!retreat.allowsDefense(43, true)); + + // Route exhaustion can spend a separate bounded escape window. It permits + // route-critical targets regardless of the planner's nominal success (a + // corridor plan can "succeed" straight through a blocked tile), exits on + // real progress, and never renews on replans. + assert(!retreat.beginBreakout(false, true, true, now + std::chrono::seconds(75))); + assert(!retreat.beginBreakout(true, false, true, now + std::chrono::seconds(75))); + assert(retreat.beginBreakout(true, true, false, now + std::chrono::seconds(75))); + assert(retreat.breakoutActive() && retreat.allowsDefense(43, true)); + assert(!retreat.beginBreakout(true, true, true, now + std::chrono::seconds(76))); + assert(!retreat.breakoutExpired(now + std::chrono::seconds(104))); + assert(retreat.breakoutExpired(now + std::chrono::seconds(105))); + assert(retreat.observeBreakoutNavigation(true, false)); + assert(!retreat.breakoutActive() && !retreat.allowsDefense(43, true)); + assert(!retreat.beginBreakout(true, true, true, now + std::chrono::seconds(110))); + assert(retreat.observe(true, 2, Phase::ReturnToDepot)); + assert(retreat.beginBreakout(true, true, true, now + std::chrono::seconds(110))); + assert(retreat.observeBreakoutNavigation(false, true)); + assert(!retreat.breakoutActive()); retreat.finish(); assert(!retreat.active()); assert(!retreat.defenseExpired(now + std::chrono::hours(1))); @@ -474,6 +582,269 @@ void crowdDamageInflation() assert(playerBotCrowdDamageInflation(9, 10) == 1); } +void adaptiveChallenge() +{ + auto observe = [](PlayerBotHuntPolicy& policy, double seconds, uint32_t kills, + int32_t health, uint32_t mana, uint32_t potions, uint32_t spells) { + policy.resetCombatEvidence(); + policy.observeCombat({true, seconds, health, 100, mana, 100, 1}); + for (uint32_t index = 0; index < kills; ++index) policy.observeKill(); + for (uint32_t index = 0; index < potions; ++index) policy.observeRecovery(true); + for (uint32_t index = 0; index < spells; ++index) policy.observeRecovery(false); + return policy.updateChallengeFrontier({300, 100}); + }; + + PlayerBotHuntPolicy exura; + auto update = observe(exura, 120, 4, 90, 80, 0, 1); + assert(update.result == PlayerBotHuntChallengeResult::Escalated); + assert(std::abs(update.frontierAfter - 0.30) < 1e-12); + assert(update.combat.p10ManaPercent == 80); + + PlayerBotHuntPolicy routinePotion; + update = observe(routinePotion, 120, 4, 90, 80, 1, 0); + assert(update.result == PlayerBotHuntChallengeResult::Escalated); + assert(std::abs(update.frontierAfter - 0.30) < 1e-12); + PlayerBotHuntPolicy twoPotions; + assert(observe(twoPotions, 180, 5, 80, 80, 2, 0).result == PlayerBotHuntChallengeResult::Escalated); + + PlayerBotHuntPolicy insufficient; + update = observe(insufficient, 30, 1, 100, 100, 0, 0); + assert(update.result == PlayerBotHuntChallengeResult::InsufficientActiveCombat); + assert(update.frontierAfter == update.frontierBefore); + + PlayerBotHuntPolicy routineLongHunt; + update = observe(routineLongHunt, 300, 8, 80, 80, 3, 0); + assert(update.result == PlayerBotHuntChallengeResult::Hold); + assert(update.frontierAfter == update.frontierBefore); + + PlayerBotHuntPolicy heavyPotions; + update = observe(heavyPotions, 60, 3, 75, 80, 3, 0); + assert(update.result == PlayerBotHuntChallengeResult::Backoff); + assert(std::abs(update.frontierAfter - 0.10) < 1e-12); + PlayerBotHuntPolicy manaPressure; + assert(observe(manaPressure, 90, 3, 80, 10, 0, 1).result == PlayerBotHuntChallengeResult::Backoff); + PlayerBotHuntPolicy criticalHealth; + assert(observe(criticalHealth, 90, 3, 30, 80, 0, 0).result == PlayerBotHuntChallengeResult::Backoff); + PlayerBotHuntPolicy danger; + danger.observeCombat({true, 90, 80, 100, 80, 100, 2}); + for (uint32_t index = 0; index < 3; ++index) danger.observeKill(); + danger.observeDamage(100); + assert(danger.observeDanger(100, std::chrono::seconds(30))); + assert(danger.updateChallengeFrontier({300, 100}).result == PlayerBotHuntChallengeResult::Backoff); + PlayerBotHuntPolicy death; + death.observeDeath(); + assert(death.updateChallengeFrontier({300, 100}).result == PlayerBotHuntChallengeResult::Backoff); + + PlayerBotHuntPolicy bounded; + for (uint32_t index = 0; index < 10; ++index) observe(bounded, 120, 4, 90, 90, 0, 0); + assert(std::abs(bounded.challengeFrontier() - 0.60) < 1e-12); +} + +void sharedHuntPerformanceCalibration() +{ + auto reliableSample = [](uint64_t experience) { + return PlayerBotHuntPerformanceSample{120, 90, 4, experience, 100, 1, 120, false, false}; + }; + PlayerBotHuntPolicy policy; + constexpr uint64_t atlasRevision = 7; + auto update = policy.observePerformance(1, atlasRevision, reliableSample(200)); + assert(update.observed && std::abs(update.updatedCorrection - 2.0) < 1e-12); + update = policy.observePerformance(2, atlasRevision, reliableSample(100)); + assert(update.observed && std::abs(update.updatedCorrection - 1.0) < 1e-12); + + auto performance = policy.regionPerformance(); + const auto tested = playerBotHuntCorrectionForVariant(1, atlasRevision, performance); + assert(std::string(tested.source) == "variant_observed"); + assert(tested.sampleCount == 1 && std::abs(tested.correction - 2.0) < 1e-12); + const auto untested = playerBotHuntCorrectionForVariant(3, atlasRevision, performance); + assert(std::string(untested.source) == "shared_observed"); + assert(untested.sampleCount == 2 && std::abs(untested.correction - 1.5) < 1e-12); + + // Repeated outings refine their own variant but do not multiply its weight in + // the shared prior: there are still two tested variants. + assert(policy.observePerformance(1, atlasRevision, reliableSample(200)).observed); + performance = policy.regionPerformance(); + const auto repeated = playerBotHuntCorrectionForVariant(3, atlasRevision, performance); + assert(repeated.sampleCount == 2 && std::abs(repeated.correction - 1.5) < 1e-12); + + // Default, malformed, stale, and explicitly unreliable entries are not observations. + performance.emplace(4, PlayerBotHuntRegionPerformance{}); + performance.emplace(5, PlayerBotHuntRegionPerformance{10, 9, 1, atlasRevision, true}); + performance.emplace(6, PlayerBotHuntRegionPerformance{10, 1.25, 1, atlasRevision, false}); + performance.emplace(8, PlayerBotHuntRegionPerformance{10, 1.75, 1, atlasRevision - 1, true}); + const auto filtered = playerBotHuntCorrectionForVariant(7, atlasRevision, performance); + assert(filtered.sampleCount == 2 && std::abs(filtered.correction - 1.5) < 1e-12); + assert(std::string(playerBotHuntCorrectionForVariant(4, atlasRevision, {}).source) == "default"); + + PlayerBotHuntPolicy insufficient; + PlayerBotHuntPerformanceSample sample = reliableSample(200); + sample.activeCombatSeconds = 59; + assert(!insufficient.observePerformance(1, atlasRevision, sample).observed); + sample = reliableSample(200); + sample.kills = 2; + assert(!insufficient.observePerformance(1, atlasRevision, sample).observed); + sample = reliableSample(200); + sample.durationSeconds = 119; + assert(!insufficient.observePerformance(1, atlasRevision, sample).observed); + sample = reliableSample(200); + sample.dangerObserved = true; + const auto unsafe = insufficient.observePerformance(1, atlasRevision, sample); + assert(!unsafe.observed && unsafe.actualExperiencePerMinute == 100); + sample.durationSeconds = 0; + assert(insufficient.observePerformance(1, atlasRevision, sample).actualExperiencePerMinute == 0); + assert(insufficient.regionPerformance().empty()); +} + +void supplyRecoveryMode() +{ + PlayerBotSupplyRecoveryState recovery; + assert(!recovery.active()); + assert(recovery.enter()); + assert(recovery.active() && !recovery.enter()); + // An unknown budget must not clear the degraded state. + assert(!recovery.update(std::numeric_limits::max(), std::numeric_limits::max())); + assert(recovery.update(500, 400)); // Funds above budget end recovery. + assert(!recovery.active()); + assert(recovery.update(100, 400)); // A shortfall enters recovery. + assert(recovery.active()); + + assert(playerBotSupplyRecoveryChallengeFrontier(0.6, true) == 0.20); + assert(playerBotSupplyRecoveryChallengeFrontier(0.1, true) == 0.1); + assert(playerBotSupplyRecoveryChallengeFrontier(0.6, false) == 0.6); + + PlayerBotEconomyInventorySnapshot inventory{1, 100, 36, 0}; + PlayerBotDispositionPolicy policy; + // Normal restock refuses gold that cannot lift the stock past the return + // threshold; survival restock buys what is affordable instead. + const auto normal = policy.restock(inventory, 45, 0, 1, 10); + assert(normal.insufficientFunds && normal.amount == 0); + const auto survival = policy.restock(inventory, 45, 0, 1, 10, true); + assert(!survival.insufficientFunds && survival.amount == 0); + PlayerBotEconomyInventorySnapshot richer{1, 100, 146, 0}; + const auto partial = policy.restock(richer, 45, 0, 1, 10, true); + assert(!partial.insufficientFunds && partial.amount == 1); // 146 - 100 reserve = 46 gp. + + const PlayerBotSurvivalSellCandidate rejected{ + true, true, true, true, 10, 500}; + assert(!playerBotSurvivalSellAccepted(true, rejected)); + const PlayerBotSurvivalSellCandidate unreachable{false, false, false, true, 0, 500}; + assert(!playerBotSurvivalSellAccepted(true, unreachable)); + const PlayerBotSurvivalSellCandidate unsafe{false, true, true, false, 0, 500}; + assert(!playerBotSurvivalSellAccepted(true, unsafe)); + const PlayerBotSurvivalSellCandidate unaffordable{false, true, true, true, 600, 500}; + assert(!playerBotSurvivalSellAccepted(true, unaffordable)); + const PlayerBotSurvivalSellCandidate viable{false, true, true, true, 10, 500}; + assert(playerBotSurvivalSellAccepted(true, viable)); + assert(!playerBotSurvivalSellAccepted(false, viable)); + + // Recovery ordering puts coin income ahead of experience even for regions + // that are not cash-pressed, and breaks income ties toward lower threat. + PlayerBotHuntRegion gold, xp; + gold.suitable = xp.suitable = gold.reachable = xp.reachable = true; + gold.supplyRecovery = xp.supplyRecovery = true; + gold.coinGoldPerMinute = 5; + gold.score = 10; + xp.score = 100; + assert(playerBotPreferHuntRegion(gold, xp)); + PlayerBotHuntRegion safer = xp; + safer.coinGoldPerMinute = 5; + safer.threatRatio = 0.1; + xp.threatRatio = 0.4; + assert(playerBotPreferHuntRegion(safer, xp)); +} + +void navigationFixedObjective() +{ + PlayerBotNavigationGoal depot; + depot.position = Position(32341, 32229, 8); + PlayerBotNavigationGoal flap; + flap.position = Position(32343, 32229, 8); + PlayerBotNavigationGoal distant; + distant.position = Position(32105, 32191, 8); + assert(playerBotNavigationSameFixedObjective(depot, depot)); + // Adjacent approach tiles of the same depot are one objective. + assert(playerBotNavigationSameFixedObjective(depot, flap)); + assert(!playerBotNavigationSameFixedObjective(depot, distant)); +} + +void topologyComponentCompression() +{ + const auto bidirectional = playerBotTopologyBidirectionalComponents( + 3, {{0, 1, true}, {1, 0, true}, {1, 2, true}}); + assert(bidirectional[0] == bidirectional[1]); + assert(bidirectional[1] != bidirectional[2]); // A one-way edge stays directed. + + const auto gated = playerBotTopologyBidirectionalComponents( + 2, {{0, 1, true}, {1, 0, false}}); + assert(gated[0] != gated[1]); // A level-gated reverse edge cannot merge components. + + const auto gatedBothWays = playerBotTopologyBidirectionalComponents( + 2, {{0, 1, false}, {1, 0, false}}); + assert(gatedBothWays[0] != gatedBothWays[1]); +} + +void fixtureHuntHorizons() +{ + assert(playerbot::playerBotFixtureHuntPlanningDuration(false, 10) == 10); + assert(playerbot::playerBotFixtureHuntPlanningDuration(true, 10) == 900); + assert(playerbot::playerBotFixtureHuntPlanningDuration(true, 1500) == 1500); +} + +void remoteHuntTravelGuards() +{ + // Once a coarse route reaches the provider's local area, finish against the + // live interaction range instead of an arbitrary exact topology approach tile. + const Position provider(32386, 31820, 6); + assert(playerBotNpcTravelUsesLocalApproach(Position(32382, 31816, 6), provider, 11)); + assert(playerBotNpcTravelUsesLocalApproach(Position(32375, 31820, 6), provider, 11)); + assert(!playerBotNpcTravelUsesLocalApproach(Position(32374, 31820, 6), provider, 11)); + assert(!playerBotNpcTravelUsesLocalApproach(Position(32382, 31816, 7), provider, 11)); + assert(playerBotNpcTravelApproachComplete(true, false, false)); + assert(playerBotNpcTravelApproachComplete(false, true, true)); + assert(!playerBotNpcTravelApproachComplete(false, false, false)); + assert(!playerBotNpcTravelApproachComplete(false, true, false)); + + // Free routes need no cash reserve; paid routes still preserve recovery funds. + assert(playerBotHuntTravelAffordable(0, 100, 0, 0, 0)); + assert(playerBotHuntTravelAffordable(50, 100, 0, 0, 0)); + assert(!playerBotHuntTravelAffordable(50, 100, 1, 0, 0)); + assert(playerBotHuntTravelAffordable(500, 100, 100, 200, 100)); + assert(!playerBotHuntTravelAffordable(499, 100, 100, 200, 100)); + assert(!playerBotHuntTravelAffordable(500, 100, 250, 200)); + assert(playerBotHuntTravelAffordable(550, 100, 250, 200)); + assert(playerBotHuntTravelPaymentAffordable(0, 100, 0, 200)); + assert(!playerBotHuntTravelPaymentAffordable(500, 100, 250, 200)); + assert(playerBotHuntTravelPaymentAffordable(550, 100, 250, 200)); + // Exit validation reserves only the later supplier fare; counting the exit + // fare again as future money would reject this exactly funded route. + assert(playerBotHuntTravelPaymentAffordable(400, 100, 200, 100)); + assert(!playerBotHuntTravelPaymentAffordable(400, 100, 200, 300)); + const uint64_t recoveryBeforeRestock = playerBotRecoverySpendingReserve(2, 10, 45, 100); + const uint64_t recoveryAfterRestock = playerBotRecoverySpendingReserve(10, 10, 45, 100); + assert(recoveryBeforeRestock == 460 && recoveryAfterRestock == 100); + assert(!playerBotHuntTravelPaymentAffordable(200, recoveryBeforeRestock, 50, 0)); + assert(playerBotHuntTravelPaymentAffordable(200, recoveryAfterRestock, 50, 0)); + assert(playerBotDepotRouteSafetyAccepted(true, false, false, true)); + assert(playerBotDepotRouteSafetyAccepted(false, true, false, false)); + assert(!playerBotDepotRouteSafetyAccepted(false, true, false, true)); + assert(!playerBotDepotRouteSafetyAccepted(false, true, true, false)); + assert(!playerBotHuntTravelAffordable(500, 200, UINT64_MAX, 1)); + assert(!playerBotHuntNeedsSupplyRoute(0, 3, 2)); + assert(playerBotHuntNeedsSupplyRoute(1, 3, 2)); + assert(playerBotHuntNeedsSupplyRoute(0, 2, 2)); + assert(playerBotHuntNeedsSupplyRoute(0, 1, 2)); + assert(playerBotNpcTravelOfferEligible(15, false, 100, 10, false, 100, false, false)); + assert(!playerBotNpcTravelOfferEligible(9, true, 100, 10, false, 100, false, false)); + assert(!playerBotNpcTravelOfferEligible(15, false, 100, 10, true, 100, false, false)); + assert(!playerBotNpcTravelOfferEligible(15, true, 99, 10, true, 100, false, false)); + assert(!playerBotNpcTravelOfferEligible(15, true, 100, 10, true, 100, true, false)); + assert(!playerBotNpcTravelOfferEligible(15, true, 100, 10, true, 100, false, true)); + const PlayerBotNavigationRiskProfile risk; + assert(playerBotNavigationRiskAccepts(risk, 500, 0.08)); + assert(!playerBotNavigationRiskAccepts(risk, 501, 0.08)); + assert(!playerBotNavigationRiskAccepts(risk, 500, 0.081)); +} + void overBudgetHunts() { PlayerBotHuntRegion easy, costly; @@ -717,11 +1088,19 @@ int main() patrolOpportunity(); mixedSustainedYield(); raisedHuntRecoveryReserve(); - diversifiedRouteShortlist(); + incrementalHuntValidationPipeline(); lootArithmeticMemo(); modeledPatrolFailure(); + navigationFailureAccounting(); transitCombat(); crowdDamageInflation(); + adaptiveChallenge(); + sharedHuntPerformanceCalibration(); + supplyRecoveryMode(); + navigationFixedObjective(); + topologyComponentCompression(); + fixtureHuntHorizons(); + remoteHuntTravelGuards(); overBudgetHunts(); supplyBudget(); recoverySpellPriority(); diff --git a/server/tests/playerbotdepotworkflow_test.cpp b/server/tests/playerbotdepotworkflow_test.cpp index 30b7c61d..56e5f7c9 100644 --- a/server/tests/playerbotdepotworkflow_test.cpp +++ b/server/tests/playerbotdepotworkflow_test.cpp @@ -17,10 +17,11 @@ const Position origin(32345, 32225, 7); const PlayerBotDepotCandidate nearest{2, 2591, {32352, 32225, 7}, {32352, 32226, 7}, 0}; const PlayerBotDepotCandidate upstairs{2, 2591, {32344, 32225, 6}, {32344, 32226, 6}, 0}; -PlayerBotDepotCommand scan(PlayerBotDepotWorkflow& workflow, std::vector candidates) +PlayerBotDepotCommand scan(PlayerBotDepotWorkflow& workflow, std::vector candidates, + const Position& currentPosition = origin) { PlayerBotDepotObservation observation; - observation.currentPosition = origin; + observation.currentPosition = currentPosition; observation.scan.observed = true; observation.scan.candidates = std::move(candidates); return workflow.advance(observation, 2, 4, std::chrono::seconds(2)); @@ -43,6 +44,11 @@ void expectFirst(std::vector candidates, const PlayerBo int main() { + assert(playerBotDepotRouteSafetyAccepted(true, false, false, true)); + assert(playerBotDepotRouteSafetyAccepted(false, true, false, false)); + assert(!playerBotDepotRouteSafetyAccepted(false, true, false, true)); + assert(!playerBotDepotRouteSafetyAccepted(false, true, true, false)); + // Actual real_depot origin and endpoints: the upstairs route took 16 steps, // but both topology costs were zero. The old floor-first tie chose upstairs. assert(playerBotNavigationDistance(origin, nearest.approachPosition) == 8); @@ -65,6 +71,17 @@ int main() lowerApproach.approachPosition = Position(32353, 32225, 7); expectFirst({nearest, lowerApproach}, lowerApproach); + // Post-hunt discovery ranks from the current hunting area, not the + // departure city. A Darashia-area bot therefore selects its viable local + // depot even if the candidate list still contains Carlin. + const Position darashiaHunt(33200, 32400, 7); + PlayerBotDepotCandidate carlinDepot{2, 2591, {32352, 32225, 7}, {32352, 32226, 7}, 12}; + PlayerBotDepotCandidate darashiaDepot{7, 2591, {33210, 32400, 7}, {33209, 32400, 7}, 1}; + PlayerBotDepotWorkflow remoteWorkflow; + auto remoteCommand = scan(remoteWorkflow, {carlinDepot, darashiaDepot}, darashiaHunt); + assert(remoteCommand.type == PlayerBotDepotCommandType::ValidateRoute); + assert(remoteCommand.snapshot.routeCandidate.depotId == darashiaDepot.depotId); + // Fallback is revalidated rather than selected directly when all // candidates are unsafe. PlayerBotDepotWorkflow workflow; From d91bf42de5353fbc489627e9211a1e94d0d37623 Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Fri, 11 Sep 2026 22:32:50 +0200 Subject: [PATCH 2/4] server: escalate fixed-goal route failures with adjacent hostiles A full box-in produces plan failures instead of detours, so the detour-driven escalation never accumulated: failed depot route validations (including failed NPC-travel approach legs) now feed fixed-goal failure accounting, and any failed fixed-goal route with adjacent hostiles confirms those monsters route-critical so transit defense engages immediately. Exhaustion, breakout, and the bounded terminal fallback follow as before. --- docs/playerbots.md | 10 +++-- server/src/playerbotcontroller.cpp | 49 +++++++++++------------ server/src/playerbotcontroller.h | 5 +++ server/src/playerbotnavigationruntime.h | 5 +++ server/src/playerbotnavigationsession.cpp | 12 ++++++ server/src/playerbotnavigationsession.h | 2 + server/src/playerbotservice.cpp | 16 +++++--- 7 files changed, 66 insertions(+), 33 deletions(-) diff --git a/docs/playerbots.md b/docs/playerbots.md index bb8cf114..c90a357a 100644 --- a/docs/playerbots.md +++ b/docs/playerbots.md @@ -167,9 +167,13 @@ no-chase defense attempt against a route-critical blocker; replans and intermediate route legs do not renew that budget, while a goal or phase change starts a new episode. A repeated detour onto an already suppressed tile proves a persistent corridor blocker and confirms it as route-critical -immediately. Adjacent-approach goal flapping (for example, depot discovery -alternating between standable tiles of the same depot) does not reset fixed -goal failure accounting or erase confirmed blockers. If 20 fixed-goal route +immediately, as does any failed fixed-goal route with adjacent hostiles: +when the planner cannot route out, the monsters sealing the exits become +attackable instead of waiting for a dispatched step to collide. Adjacent +approach goal flapping (for example, depot discovery alternating between +standable tiles of the same depot) does not reset fixed goal failure +accounting or erase confirmed blockers, and failed depot route validations +count toward the same exhaustion. If 20 fixed-goal route failures confirm that adjacent hostile blockers have sealed the exits, the bot enters a final 30-second no-chase breakout, and exhaustion is also honored when nominally successful plans would keep dispatching a blocked corridor step. diff --git a/server/src/playerbotcontroller.cpp b/server/src/playerbotcontroller.cpp index c3047d81..e44830e4 100644 --- a/server/src/playerbotcontroller.cpp +++ b/server/src/playerbotcontroller.cpp @@ -1338,6 +1338,26 @@ void PlayerBotController::onHealthGain(Creature* healer, const Creature& target, survivalRuntime.observeHealthGain(healer && healer->getID() == playerId, target.getID() == playerId, gain); } +bool PlayerBotController::confirmAdjacentRouteBlockers(Player* player, const Position& currentPosition, + std::chrono::steady_clock::time_point now) +{ + if (!player) return false; + bool confirmed = false; + SpectatorVec spectators; + g_game.map.getSpectators(spectators, currentPosition); + for (Creature* creature : spectators) { + if (!creature->getMonster() || creature->isRemoved() || creature->isDead() || + !player->canSee(creature->getPosition()) || + !Position::areInRange<1, 1, 0>(currentPosition, creature->getPosition())) { + continue; + } + navigationRuntime.confirmRouteBlocker(creature->getID(), creature->getPosition(), now, + navigationBlockSuppression); + confirmed = true; + } + return confirmed; +} + bool PlayerBotController::handleFixedTargetRouteExhausted(Player* player, const Position& currentPosition, const PlayerBotNavigationRuntimeOutcome& outcome, std::chrono::steady_clock::time_point now, bool allowStop) { @@ -1537,31 +1557,10 @@ bool PlayerBotController::processNavigation(Player* player, const Position& curr std::to_string(outcome.plan.waypoint.x) + ",\"y\":" + std::to_string(outcome.plan.waypoint.y) + ",\"z\":" + std::to_string(static_cast(outcome.plan.waypoint.z)) + "}"); telemetry.logActionFailure("navigate", "route_unavailable", currentPosition); - if (outcome.fixedTargetRouteExhausted) { - bool adjacentConfirmedHostileBlocker = false; - SpectatorVec spectators; - g_game.map.getSpectators(spectators, currentPosition); - for (Creature* creature : spectators) { - if (creature->getMonster() && !creature->isRemoved() && !creature->isDead() && - player->canSee(creature->getPosition()) && - Position::areInRange<1, 1, 0>(currentPosition, creature->getPosition()) && - navigationRuntime.isRouteCritical(creature->getID(), creature->getPosition(), now)) { - adjacentConfirmedHostileBlocker = true; - break; - } - } - const bool breakoutStarted = huntCoordinator.beginTransitBreakout( - outcome.fixedTargetRouteExhausted, adjacentConfirmedHostileBlocker, - outcome.routeUnavailable, now); - if (huntCoordinator.transitBreakoutActive()) { - if (breakoutStarted) { - emit("navigation_progress", currentPosition, - "\"result\":\"recovering\",\"reason\":\"transit_breakout\",\"duration_seconds\":30"); - } - schedule(blockedRouteRetryInterval); - return false; - } - stop("navigation_route_unavailable", currentPosition); + confirmAdjacentRouteBlockers(player, currentPosition, now); + if (handleFixedTargetRouteExhausted(player, currentPosition, outcome, now, true)) { + schedule(blockedRouteRetryInterval); + return false; } schedule(blockedRouteRetryInterval); return false; diff --git a/server/src/playerbotcontroller.h b/server/src/playerbotcontroller.h index 3a6400da..50cc4e25 100644 --- a/server/src/playerbotcontroller.h +++ b/server/src/playerbotcontroller.h @@ -362,6 +362,11 @@ class PlayerBotController : public std::enable_shared_from_this activeBlockedPositions(std::chrono::steady_clock::time_point now) { return session.activeBlockedPositions(now); } bool hasActiveRouteBlock(std::chrono::steady_clock::time_point now) const { return session.hasActiveRouteBlock(now); } bool oscillationDetected() const { return session.oscillationDetected(); } diff --git a/server/src/playerbotnavigationsession.cpp b/server/src/playerbotnavigationsession.cpp index eff99a14..1f0fc8cc 100644 --- a/server/src/playerbotnavigationsession.cpp +++ b/server/src/playerbotnavigationsession.cpp @@ -169,6 +169,18 @@ void PlayerBotNavigationSession::confirmRequiredRouteBlocker() } } +void PlayerBotNavigationSession::confirmRouteBlocker(uint32_t blockerId, const Position& position, + std::chrono::steady_clock::time_point now, std::chrono::steady_clock::duration suppression) +{ + requiredRouteBlockerIds.insert(blockerId); + auto expires = temporarilyBlockedPositions.find(position); + if (expires == temporarilyBlockedPositions.end()) { + temporarilyBlockedPositions.emplace(position, now + suppression); + } else if (expires->second < now + suppression) { + expires->second = now + suppression; + } +} + std::optional PlayerBotNavigationSession::observeProgress( const Position& currentPosition, const PlayerBotNavigationGoal& goal, std::chrono::steady_clock::time_point now, std::chrono::steady_clock::duration suppression) diff --git a/server/src/playerbotnavigationsession.h b/server/src/playerbotnavigationsession.h index 0c0e1eaf..b3d8a811 100644 --- a/server/src/playerbotnavigationsession.h +++ b/server/src/playerbotnavigationsession.h @@ -61,6 +61,8 @@ class PlayerBotNavigationSession bool avoidPendingRouteBlocker(uint32_t blockerId, const Position& position, std::chrono::steady_clock::time_point now, std::chrono::steady_clock::duration suppression); void confirmRequiredRouteBlocker(); + void confirmRouteBlocker(uint32_t blockerId, const Position& position, std::chrono::steady_clock::time_point now, + std::chrono::steady_clock::duration suppression); void clearRequiredRouteBlockers() { requiredRouteBlockerIds.clear(); } void clearBlockedPositions() { temporarilyBlockedPositions.clear(); clearRequiredRouteBlockers(); pendingRouteBlocker.reset(); pendingRouteBlockerId.reset(); } diff --git a/server/src/playerbotservice.cpp b/server/src/playerbotservice.cpp index d06a6524..5e96ac22 100644 --- a/server/src/playerbotservice.cpp +++ b/server/src/playerbotservice.cpp @@ -1195,6 +1195,11 @@ bool PlayerBotController::discoverDepot(Player& player, const Position& currentP if (auto paidRoute = planNpcTravelRoute(player, candidate.approachPosition, {}, playerBotNavigationMaximumExpandedNodes)) { routePlan = std::move(*paidRoute); + } else { + // A failed travel-approach plan is still a real attempt: mark it + // so the failure feeds exhaustion like any other route failure. + routePlan.metrics.attempted = true; + routePlan.metrics.result = PlayerBotNavigationResult::Unreachable; } } else { // Respect the navigation session's temporarily suppressed tiles so @@ -1225,11 +1230,12 @@ bool PlayerBotController::discoverDepot(Player& player, const Position& currentP observation.maximumHealthLossPerSecond = routePlan.metrics.maximumHealthLossPerSecond; if (reached) { steps = std::move(routePlan.steps); - } else if (routePlan.metrics.attempted && - !navigationRuntime.activeBlockedPositions(startedAt).empty()) { - // A temporarily suppressed route blocker makes the validated approach - // unreachable. Feed the failure into the runtime so the blocker - // becomes route-critical and exhaustion can escalate. + } else if (routePlan.metrics.attempted) { + // A failed fixed-goal validation is a no-progress failure toward the + // depot. Feed it into the runtime: adjacent hostiles confirm as + // route-critical so transit defense engages, and repeated failures + // accumulate toward exhaustion and breakout. + confirmAdjacentRouteBlockers(&player, currentPosition, startedAt); PlayerBotNavigationRoutePlan failure; failure.metrics = routePlan.metrics; const PlayerBotNavigationRuntimeOutcome fed = navigationRuntime.observePlan( From 4affef9c50624afc0166b3536d68d709fde53db4 Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Sat, 12 Sep 2026 03:27:42 +0200 Subject: [PATCH 3/4] server: restock hunt ammo and delay capacity returns Keep a 2-potion safety floor only while Light Healing is level-eligible and unlearned. Otherwise leftover gold buys up to 20 flasks so 40-minute hunts are not stuck on two-flask trash spawns. Capacity pressure still replaces cargo but cannot end a hunt before 30 minutes. --- docs/playerbots.md | 25 ++++++++++---- docs/testing.md | 2 +- server/src/playerbotcombat.cpp | 12 +++---- server/src/playerbotcontroller.cpp | 7 ++-- server/src/playerbotcontroller.h | 5 ++- server/src/playerboteconomy.h | 3 +- server/src/playerbotfixturedriver.cpp | 6 +++- server/src/playerbothuntcoordinator.cpp | 6 ++-- server/src/playerbothuntcoordinator.h | 2 ++ server/src/playerbothuntruntime.cpp | 7 +++- server/src/playerbothuntruntime.h | 3 +- server/src/playerbotinventorypolicy.cpp | 2 +- server/src/playerbotinventorypolicy.h | 9 +++-- server/src/playerbotprogression.cpp | 10 +++--- server/src/playerbotservice.cpp | 10 +++--- server/src/playerbotspells.cpp | 20 ++++++++++- .../playerbot-gameplay/includes/login.inc | 6 ++-- .../playerbot-gameplay/includes/verifiers.inc | 4 +-- server/tests/playerbot_contracts.cpp | 34 +++++++++++++++++-- 19 files changed, 128 insertions(+), 45 deletions(-) diff --git a/docs/playerbots.md b/docs/playerbots.md index c90a357a..b8e2eab1 100644 --- a/docs/playerbots.md +++ b/docs/playerbots.md @@ -51,7 +51,7 @@ docker compose -f server/compose.yaml logs playerbot-setup server The controller evaluates top-level goals only at safe boundaries: startup, completed or failed pickup, completed service/depot work, and hunt deadline or -capacity return. Hunt completion enters the neutral `Idle` phase before goal +capacity return after at least 30 minutes in the hunt. Hunt completion enters the neutral `Idle` phase before goal selection so a non-hunt opportunity can compete with the next hunt. The controller does not switch goals during movement, combat, looting, dialogue, transactions, or pending item verification. Safety can force service; @@ -318,8 +318,14 @@ healing before combat, then support. Unsupported utility spells such as loaded NPC scripts and rejects offers with a registry mismatch, wrong vocation, level, premium status, learned state, missing supply reserve, insufficient funds after the 100 gp carried -reserve plus the cost of the current stock gap to the 10-potion restock target, -or an unavailable route. +reserve plus the cost of the current stock gap to the potion target, or an +unavailable route. Light Healing uses only the 1-2 potion safety floor as that +reserve, and only once the bot is high enough level to learn it. Below that +level, leftover gold buys up to 20 potions (or more when a return route needs a +larger reserve) so hunts are not stuck on two-flask trash spawns. After Light +Healing is learned, leftover gold buys that ammo stack again. Hunt selection +then uses potions on the belt: a hunt must fit that stock, and among hunts that +fit, XP wins. A selected spell uses normal `hi`, keyword, and `yes` dialogue. Completion requires both learned state and the exact total-money delta. Learned-spell persistence reconstructs completion after restart and @@ -458,9 +464,12 @@ arbitration, and normal engine casts under those conditions, not frequency or utility on an ordinary long-running server. The service cycle sells known surplus and returns for the selected recovery -potion when the carried count reaches one. It buys enough to carry at least two -and targets 10. Only the selected potion is reserved toward that target; other -health potions may be deposited. A complete restock takes priority when total +potion when the carried count reaches one. It restocks to the 2-potion safety +floor only while Light Healing is level-eligible and still unlearned. Otherwise +leftover gold buys hunt ammo up to 20 potions, or the return-route reserve if +that is larger. Only the +selected potion is reserved toward that target; other health potions may be +deposited. A complete restock takes priority when total carried and bank gold can pay for it; an optional partial restock preserves the carried-gold reserve. If total gold cannot raise stock above the return threshold, service enters supply recovery instead of stopping: it buys whatever @@ -518,7 +527,9 @@ items are carried, further food is skipped. Any food remains replaceable by a more valuable known item because the preference is not a reserve. Known unequipped equipment is also replaceable; equipped gear is outside the backpack cargo path. Unknown items, containers, currency, tools, and the potion reserve -are not replacement candidates. Food count, weight, reclaimable capacity, and preference +are not replacement candidates. A full backpack starts capacity pressure but does +not end the hunt until 30 minutes have elapsed; the 40-minute deadline remains +the hard stop. Food count, weight, reclaimable capacity, and preference utility are observable, but food does not yet select a separate acquisition goal. Later goal arbitration can weigh measured regeneration benefit against travel, capacity, and service costs without restoring a hard requirement. diff --git a/docs/testing.md b/docs/testing.md index 4bd2692d..b39aabd4 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -505,7 +505,7 @@ the changed behavior: | `-StaminaProjection` | Premium bonus, low-stamina penalty, and ordinary stamina projections. | | `-HuntRegionPlanning` | Cached atlas batching, threat rejection, reachability, selected-return reserve, cooldowns, and observed correction. | | `-AdaptiveChallenge` | Synthetic frontier and planner-helper fixture covering idle and no-kill exclusion, sparse-combat escalation, hysteresis, recovery backoff, equipment/recovery prediction, lethal rejection, local exhaustion, terminal finality, and no post-terminal controller events. It does not validate real combat sampling or long-running hunt behavior. | -| `-CombatReadiness` | Equipment, the one-potion return threshold and 10-potion restock target, optional-food hunting, generic food consumption and reclaimable capacity, low-wealth banking, carried-upgrade retention through service, and restart reconstruction. It does not cover the terminal case where total funds cannot buy enough potions to exceed the return threshold. | +| `-CombatReadiness` | Equipment, the one-potion return threshold, safety-floor restock while Light Healing is level-eligible and unlearned, 20-potion ammo restock otherwise, optional-food hunting, generic food consumption and reclaimable capacity, low-wealth banking, carried-upgrade retention through service, and restart reconstruction. It does not cover the terminal case where total funds cannot buy enough potions to exceed the return threshold. | | `-EquipmentPurchases` | Justified purchase and equip verification, clean restart persistence, carried-upgrade recovery, displaced-item-space rejection, and rejected transactions. | | `-MainlandRewards` | Real Thais reward object from a teleported, high-capacity fixture; scale-armor claim and equip, displaced-item and bundle preservation, restart reconstruction, and non-null battle-axe rejection evidence. It does not prove normal traversal, realistic capacity limits, or a specific rejection reason. | | `-Depot` | Real Thais locker/chest discovery from Naji, including exact nearest-locker selection, carried-upgrade equipment, displaced and inferior equipment deposits, one carried rope and shovel, surplus tool deposits, nested loot, move verification, retries, and restart checkpoints. Both normal cycles, all five checkpoint recoveries, and partial moves verify completed inventory while paused at Depart, before optional selling. Rejected moves retain separate exact delta/retry/discard assertions. | diff --git a/server/src/playerbotcombat.cpp b/server/src/playerbotcombat.cpp index 4bb67b4e..87fb183d 100644 --- a/server/src/playerbotcombat.cpp +++ b/server/src/playerbotcombat.cpp @@ -870,7 +870,7 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit PlayerBotHuntRegionScan scan = planner.beginScan(player, topologyDistances.get()); auto profile = huntPlanningFacts(player, huntCombatProfile(player)); profile.cashPressure = playerBotHuntCashPressure(profile.potionCount, huntPotionReturnThreshold, - input.player.funds, recoverySpendingReserve(player, huntPotionRestockTarget)); + input.player.funds, recoverySpendingReserve(player, potionStockTarget(player))); input.start = {{std::move(scan), std::move(profile), std::move(topologyDistances), std::move(topologyReachability), huntTransportCatalog(), topologyTimeUs}}; @@ -1087,7 +1087,7 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit static_cast(returnReserve) + outboundReserve)); routed.supplyProfile = huntPlanningFacts(player, huntCombatProfile(player)).supply; routed.reconcileRecovery(candidateReserve, player.getMoney() + player.getBankBalance(), - recoverySpendingReserve(player, recoveryPotionRestockTargetForReserve(candidateReserve))); + recoverySpendingReserve(player, potionStockTarget(player, candidateReserve))); const bool needsSupplyRoute = playerBotHuntNeedsSupplyRoute( routed.supplyBudget.expectedPotions, routed.supplyProfile.potions, candidateReserve); huntSupplyExitApproaches = needsSupplyRoute ? huntSupplyExitCandidates(player, depotApproach) : @@ -1135,7 +1135,7 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit const uint32_t candidateReserve = static_cast(std::min(UINT32_MAX, static_cast(returnReserve) + outboundReserve)); const uint64_t recoveryReserve = recoverySpendingReserve( - player, recoveryPotionRestockTargetForReserve(candidateReserve)); + player, potionStockTarget(player, candidateReserve)); routed.recoveryPotionReserve = candidateReserve; routed.reconcileRecovery(candidateReserve, player.getMoney() + player.getBankBalance(), recoveryReserve); const uint64_t funds = player.getMoney() + player.getBankBalance(); @@ -1207,7 +1207,7 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit const uint32_t healthLossCost = static_cast(risk.healthLossCost); huntPotionReturnThreshold = recoveryPotionRouteReserve( player.getVocationId(), player.getMaxHealth(), huntReturnRouteDangerCost, healthLossCost); - huntPotionRestockTarget = recoveryPotionRestockTargetForReserve(huntPotionReturnThreshold); + huntPotionRestockTarget = potionStockTarget(player, huntPotionReturnThreshold); emit("hunt_supply_reserve", position, "\"source\":\"selected_return_route\",\"route_danger_cost\":" + std::to_string(huntReturnRouteDangerCost) + ",\"health_loss_cost\":" + std::to_string(healthLossCost) + @@ -1509,7 +1509,7 @@ void PlayerBotController::processTraversal(Player* player, const Position& curre std::to_string(preflight.metrics.fare) + ",\"future_fare_reserve\":" + std::to_string(huntTravelFutureFareReserve(budgetPhase)) + ",\"recovery_funds_reserve\":" + std::to_string(recoverySpendingReserve( - *player, recoveryPotionRestockTargetForReserve(huntRecoveryPotionReserve)))); + *player, potionStockTarget(*player, huntRecoveryPotionReserve)))); } else if (navigation.routeUnsafe) { emit("navigation_progress", currentPosition, "\"result\":\"skipped\",\"reason\":\"route_danger_above_tolerance\",\"phase\":\"patrol_preflight\",\"direction\":" + @@ -1535,7 +1535,7 @@ void PlayerBotController::processTraversal(Player* player, const Position& curre static_cast(patrolRisk.healthLossCost)); if (requiredReserve > huntPotionReturnThreshold) { huntPotionReturnThreshold = requiredReserve; - huntPotionRestockTarget = recoveryPotionRestockTargetForReserve(requiredReserve); + huntPotionRestockTarget = potionStockTarget(*player, requiredReserve); emit("hunt_supply_reserve", currentPosition, "\"source\":\"patrol_return_route\",\"route_danger_cost\":" + std::to_string(preflight.metrics.dangerCost) + ",\"return_threshold\":" + diff --git a/server/src/playerbotcontroller.cpp b/server/src/playerbotcontroller.cpp index e44830e4..60725db3 100644 --- a/server/src/playerbotcontroller.cpp +++ b/server/src/playerbotcontroller.cpp @@ -167,6 +167,7 @@ PlayerBotController::PlayerBotController(const Player& player, std::chrono::milliseconds(corpseNavigationRetryInterval), corpseLootTimeout, preferredFoodCount}, fixtureDriver.huntPatrol(), huntCapacityPressureGrace, + huntCapacityPressureMinimum, }, sharedHuntRegionCooldowns) {} @@ -552,7 +553,7 @@ bool PlayerBotController::executeNavigationStep(Player* player, const PlayerBotN ",\"fare\":" + std::to_string(step.price) + ",\"future_fare_reserve\":" + std::to_string(huntTravelFutureFareReserve(huntTravelBudgetPhase)) + ",\"recovery_funds_reserve\":" + std::to_string(recoverySpendingReserve( - *player, recoveryPotionRestockTargetForReserve(huntRecoveryPotionReserve)))); + *player, potionStockTarget(*player, huntRecoveryPotionReserve)))); return false; } npc->receiveSpeech(player, TALKTYPE_PRIVATE_PN, "hi"); @@ -1217,7 +1218,7 @@ bool PlayerBotController::huntTravelFareAffordable( { if (phase == HuntTravelBudgetPhase::None || fare == 0) return true; const uint64_t recoveryReserve = recoverySpendingReserve( - player, recoveryPotionRestockTargetForReserve(huntRecoveryPotionReserve)); + player, potionStockTarget(player, huntRecoveryPotionReserve)); return playerBotHuntTravelPaymentAffordable(player.getMoney() + player.getBankBalance(), recoveryReserve, fare, huntTravelFutureFareReserve(phase)); @@ -1449,7 +1450,7 @@ bool PlayerBotController::processNavigation(Player* player, const Position& curr "},\"fare\":" + std::to_string(outcome.plan.fare) + ",\"future_fare_reserve\":" + std::to_string(huntTravelFutureFareReserve(huntTravelBudgetPhase)) + ",\"recovery_funds_reserve\":" + std::to_string(recoverySpendingReserve( - *player, recoveryPotionRestockTargetForReserve(huntRecoveryPotionReserve)))); + *player, potionStockTarget(*player, huntRecoveryPotionReserve)))); schedule(blockedRouteRetryInterval); return false; } diff --git a/server/src/playerbotcontroller.h b/server/src/playerbotcontroller.h index 50cc4e25..a70ad826 100644 --- a/server/src/playerbotcontroller.h +++ b/server/src/playerbotcontroller.h @@ -111,6 +111,7 @@ namespace playerbot { inline constexpr int32_t foodPreferenceUtility = 20; inline constexpr uint32_t returnCapacityThreshold = 30 * 100; inline constexpr std::chrono::minutes huntCapacityPressureGrace(5); + inline constexpr std::chrono::minutes huntCapacityPressureMinimum(30); inline constexpr uint32_t maximumServiceAttempts = 3; // Prevent a rejected slotted-item move from blocking the service/depot loop. inline constexpr std::chrono::seconds unavailableDispositionCooldown(60); @@ -303,6 +304,8 @@ class PlayerBotController : public std::enable_shared_from_this huntPatrolPreflightBlockedPositions; std::optional huntPatrolValidatedDestination; uint32_t huntPotionReturnThreshold = playerbot::healthPotionReturnThreshold; - uint32_t huntPotionRestockTarget = playerbot::healthPotionRestockTarget; + uint32_t huntPotionRestockTarget = playerbot::healthPotionSafetyTarget; struct { uint32_t npcId = 0; Position coarseDestination; diff --git a/server/src/playerboteconomy.h b/server/src/playerboteconomy.h index fc0ac64c..494308e4 100644 --- a/server/src/playerboteconomy.h +++ b/server/src/playerboteconomy.h @@ -6,6 +6,7 @@ #include #include +#include "playerbotinventorypolicy.h" #include "position.h" struct PlayerBotEconomyOffer { @@ -62,7 +63,7 @@ class PlayerBotDispositionPolicy { public: static constexpr uint32_t potionReturnThreshold = 1; - static constexpr uint32_t potionRestockTarget = 10; + static constexpr uint32_t potionRestockTarget = playerbot::healthPotionAmmoTarget; static constexpr uint32_t carriedGoldReserve = 100; uint32_t protectedReserve(uint16_t itemId, bool food, uint16_t potionItemId) const; diff --git a/server/src/playerbotfixturedriver.cpp b/server/src/playerbotfixturedriver.cpp index 5b35cab3..5ead7ac7 100644 --- a/server/src/playerbotfixturedriver.cpp +++ b/server/src/playerbotfixturedriver.cpp @@ -255,6 +255,10 @@ std::vector playerbot::PlayerBotFixtureDriver: capacityStarted + std::chrono::minutes(5) - std::chrono::milliseconds(1), std::chrono::minutes(5)); const bool capacityAtGrace = capacityRuntime.capacityPressureElapsed( capacityStarted + std::chrono::minutes(5), std::chrono::minutes(5)); + const bool capacityAtGraceBeforeMinimum = capacityRuntime.capacityPressureElapsed( + capacityStarted + std::chrono::minutes(5), std::chrono::minutes(5), std::chrono::minutes(30)); + const bool capacityAtMinimumHunt = capacityRuntime.capacityPressureElapsed( + capacityStarted + std::chrono::minutes(30), std::chrono::minutes(5), std::chrono::minutes(30)); capacityRuntime.beginCycle(capacityStarted + std::chrono::minutes(5), 900); const bool capacityReset = !capacityRuntime.capacityPressureActive(); const uint32_t knightRouteReserve = recoveryPotionRouteReserve(4, 1000, 500); @@ -328,7 +332,7 @@ std::vector playerbot::PlayerBotFixtureDriver: const auto preferredTarget = targeting.selectTraversalAttack(std::move(targets), Position(0, 0, 7), std::chrono::steady_clock::now()); std::vector exhausted(1); exhausted.front().suitable = true; std::ostringstream fields; - fields << std::fixed << std::setprecision(2) << "\"recovery_total\":" << recovery.totalMinimumHealing << ",\"recovery_spell_legal\":" << (recovery.lightHealingLegal ? "true" : "false") << ",\"recovery_spell_casts\":" << recovery.spellCasts << ",\"equipment_pressure_before\":" << current.threatRatio << ",\"equipment_pressure_after\":" << equipped.threatRatio << ",\"idle_observed_seconds\":" << idle << ",\"active_observed_seconds\":" << active << ",\"higher_score_preferred\":" << (playerBotPreferHuntRegion(higherScore, lowerScore) ? "true" : "false") << ",\"closest_target_preferred\":" << (preferredTarget && preferredTarget->target.id == 1 ? "true" : "false") << ",\"wounded_lethal\":" << (playerBotPredictedLethal(40, 40) ? "true" : "false") << ",\"zero_health_lethal\":" << (playerBotPredictedLethal(0, 0) ? "true" : "false") << ",\"helper_scope_exhausted\":" << (playerBotHuntScopeExhausted(exhausted) ? "true" : "false") << ",\"capacity_before_grace\":" << (capacityBeforeGrace ? "true" : "false") << ",\"capacity_at_grace\":" << (capacityAtGrace ? "true" : "false") << ",\"capacity_cycle_reset\":" << (capacityReset ? "true" : "false") << ",\"knight_route_reserve\":" << knightRouteReserve << ",\"rook_route_reserve\":" << rookRouteReserve << ",\"high_health_route_reserve\":" << highHealthRouteReserve << ",\"high_health_restock_target\":" << highHealthRestockTarget << ",\"net_value_loss_rejected\":" << (netValueLossRejected ? "true" : "false") << ",\"currency_priority_override\":" << (currencyPriorityOverride ? "true" : "false") << ",\"currency_hunt_capacity_excluded\":" << (currencyExcludedFromHuntCapacity ? "true" : "false") << ",\"large_restock_batched\":" << (largeRestockBatched ? "true" : "false") << ",\"preferred_food_consumed\":" << (preferredFoodConsumed ? "true" : "false") << ",\"missing_food_ignored\":" << (missingFoodIgnored ? "true" : "false") << ",\"food_replenished_after_eating\":" << (foodReplenishedAfterEating ? "true" : "false"); + fields << std::fixed << std::setprecision(2) << "\"recovery_total\":" << recovery.totalMinimumHealing << ",\"recovery_spell_legal\":" << (recovery.lightHealingLegal ? "true" : "false") << ",\"recovery_spell_casts\":" << recovery.spellCasts << ",\"equipment_pressure_before\":" << current.threatRatio << ",\"equipment_pressure_after\":" << equipped.threatRatio << ",\"idle_observed_seconds\":" << idle << ",\"active_observed_seconds\":" << active << ",\"higher_score_preferred\":" << (playerBotPreferHuntRegion(higherScore, lowerScore) ? "true" : "false") << ",\"closest_target_preferred\":" << (preferredTarget && preferredTarget->target.id == 1 ? "true" : "false") << ",\"wounded_lethal\":" << (playerBotPredictedLethal(40, 40) ? "true" : "false") << ",\"zero_health_lethal\":" << (playerBotPredictedLethal(0, 0) ? "true" : "false") << ",\"helper_scope_exhausted\":" << (playerBotHuntScopeExhausted(exhausted) ? "true" : "false") << ",\"capacity_before_grace\":" << (capacityBeforeGrace ? "true" : "false") << ",\"capacity_at_grace\":" << (capacityAtGrace ? "true" : "false") << ",\"capacity_at_grace_before_minimum\":" << (capacityAtGraceBeforeMinimum ? "true" : "false") << ",\"capacity_at_minimum_hunt\":" << (capacityAtMinimumHunt ? "true" : "false") << ",\"capacity_cycle_reset\":" << (capacityReset ? "true" : "false") << ",\"knight_route_reserve\":" << knightRouteReserve << ",\"rook_route_reserve\":" << rookRouteReserve << ",\"high_health_route_reserve\":" << highHealthRouteReserve << ",\"high_health_restock_target\":" << highHealthRestockTarget << ",\"net_value_loss_rejected\":" << (netValueLossRejected ? "true" : "false") << ",\"currency_priority_override\":" << (currencyPriorityOverride ? "true" : "false") << ",\"currency_hunt_capacity_excluded\":" << (currencyExcludedFromHuntCapacity ? "true" : "false") << ",\"large_restock_batched\":" << (largeRestockBatched ? "true" : "false") << ",\"preferred_food_consumed\":" << (preferredFoodConsumed ? "true" : "false") << ",\"missing_food_ignored\":" << (missingFoodIgnored ? "true" : "false") << ",\"food_replenished_after_eating\":" << (foodReplenishedAfterEating ? "true" : "false"); events.push_back({"adaptive_challenge_fixture", fields.str()}); // Exercise the real bounded scoring session and runtime selection with // deterministic candidate facts, not an authored live hunt list. diff --git a/server/src/playerbothuntcoordinator.cpp b/server/src/playerbothuntcoordinator.cpp index b4c214c9..bf8bea39 100644 --- a/server/src/playerbothuntcoordinator.cpp +++ b/server/src/playerbothuntcoordinator.cpp @@ -10,7 +10,8 @@ PlayerBotHuntCoordinator::PlayerBotHuntCoordinator( PlayerBotHuntCoordinatorConfig config, std::map& sharedCooldowns) : combatRuntime(std::move(config.combat)), lootWorkflow(std::move(config.loot)), huntRuntime(std::move(config.fallbackPatrol)), huntRegionCooldowns(sharedCooldowns), - capacityPressureGrace(config.capacityPressureGrace) + capacityPressureGrace(config.capacityPressureGrace), + capacityPressureMinimumHunt(config.capacityPressureMinimumHunt) {} std::optional PlayerBotHuntCoordinator::selectTraversalAttack( @@ -130,7 +131,8 @@ void PlayerBotHuntCoordinator::observeCapacityPressure(std::chrono::steady_clock PlayerBotHuntTurnObservation PlayerBotHuntCoordinator::observeTurn(bool inHuntPhase, bool selectRegion, std::chrono::steady_clock::time_point now) const { - const bool pressureElapsed = inHuntPhase && huntRuntime.capacityPressureElapsed(now, capacityPressureGrace); + const bool pressureElapsed = inHuntPhase && + huntRuntime.capacityPressureElapsed(now, capacityPressureGrace, capacityPressureMinimumHunt); return {inHuntPhase && selectRegion && !huntRuntime.active() && !huntRuntime.planningActive(), huntRuntime.planningActive(), lootWorkflow.navigationSuspended(), huntRuntime.capacityPressureActive(), pressureElapsed, diff --git a/server/src/playerbothuntcoordinator.h b/server/src/playerbothuntcoordinator.h index 9a58e2aa..0a13d4ab 100644 --- a/server/src/playerbothuntcoordinator.h +++ b/server/src/playerbothuntcoordinator.h @@ -23,6 +23,7 @@ struct PlayerBotHuntCoordinatorConfig { PlayerBotLootWorkflowConfig loot; std::vector fallbackPatrol; std::chrono::steady_clock::duration capacityPressureGrace = std::chrono::minutes(5); + std::chrono::steady_clock::duration capacityPressureMinimumHunt = std::chrono::minutes(30); }; struct PlayerBotHuntTurnObservation { @@ -173,6 +174,7 @@ class PlayerBotHuntCoordinator PlayerBotHuntRuntime huntRuntime; std::map& huntRegionCooldowns; std::chrono::steady_clock::duration capacityPressureGrace; + std::chrono::steady_clock::duration capacityPressureMinimumHunt; }; #endif diff --git a/server/src/playerbothuntruntime.cpp b/server/src/playerbothuntruntime.cpp index 7f8e1e92..b3113c09 100644 --- a/server/src/playerbothuntruntime.cpp +++ b/server/src/playerbothuntruntime.cpp @@ -245,9 +245,14 @@ void PlayerBotHuntRuntime::observeCapacityPressure(std::chrono::steady_clock::ti } bool PlayerBotHuntRuntime::capacityPressureElapsed(std::chrono::steady_clock::time_point now, - std::chrono::steady_clock::duration grace) const + std::chrono::steady_clock::duration grace, + std::chrono::steady_clock::duration minimumHunt) const { if (!activeRegion || capacityPressureStarted == std::chrono::steady_clock::time_point{}) return false; + if (minimumHunt > std::chrono::steady_clock::duration{} && huntStarted != std::chrono::steady_clock::time_point{} && + now - huntStarted < minimumHunt) { + return false; + } return now - capacityPressureStarted >= grace; } diff --git a/server/src/playerbothuntruntime.h b/server/src/playerbothuntruntime.h index 74a6a199..59ab87e8 100644 --- a/server/src/playerbothuntruntime.h +++ b/server/src/playerbothuntruntime.h @@ -190,7 +190,8 @@ class PlayerBotHuntRuntime bool deadlineReached(std::chrono::steady_clock::time_point now) const { return huntDeadline != std::chrono::steady_clock::time_point{} && now >= huntDeadline; } void observeCapacityPressure(std::chrono::steady_clock::time_point now); bool capacityPressureElapsed(std::chrono::steady_clock::time_point now, - std::chrono::steady_clock::duration grace) const; + std::chrono::steady_clock::duration grace, + std::chrono::steady_clock::duration minimumHunt = {}) const; bool capacityPressureActive() const { return capacityPressureStarted != std::chrono::steady_clock::time_point{}; } uint32_t completedCycles() const { return cycles; } bool active() const { return activeRegion.has_value(); } diff --git a/server/src/playerbotinventorypolicy.cpp b/server/src/playerbotinventorypolicy.cpp index 613d5106..243d709e 100644 --- a/server/src/playerbotinventorypolicy.cpp +++ b/server/src/playerbotinventorypolicy.cpp @@ -137,7 +137,7 @@ uint32_t PlayerBotInventoryPolicy::protectedItemReserve(const Player& player, ui return preferredFoodCount; } if (itemId == recoveryPotionItemId(player.getVocationId())) { - return healthPotionRestockTarget; + return healthPotionAmmoTarget; } return 0; } diff --git a/server/src/playerbotinventorypolicy.h b/server/src/playerbotinventorypolicy.h index da8ed573..716e2a65 100644 --- a/server/src/playerbotinventorypolicy.h +++ b/server/src/playerbotinventorypolicy.h @@ -23,7 +23,9 @@ namespace playerbot { inline constexpr uint16_t smallHealthPotionItemId = 8704; inline constexpr uint16_t healthPotionItemId = 7618; inline constexpr uint32_t healthPotionReturnThreshold = 1; - inline constexpr uint32_t healthPotionRestockTarget = 10; + inline constexpr uint32_t healthPotionSafetyTarget = 2; + inline constexpr uint32_t healthPotionAmmoTarget = 20; + inline constexpr uint32_t healthPotionRestockTarget = healthPotionAmmoTarget; inline constexpr uint32_t preferredFoodCount = 2; inline constexpr uint16_t ropeItemId = 2120; inline constexpr uint32_t carriedGoldReserve = 100; @@ -45,10 +47,11 @@ namespace playerbot { uint32_t recoveryPotionRouteReserve(uint16_t vocationId, int32_t maximumHealth, uint32_t routeDangerCost, uint32_t healthLossCost = 1000); - constexpr uint32_t recoveryPotionRestockTargetForReserve(uint32_t returnReserve) + constexpr uint32_t recoveryPotionRestockTargetForReserve(uint32_t returnReserve, + uint32_t stockTarget = healthPotionAmmoTarget) { const uint32_t minimumTarget = returnReserve == UINT32_MAX ? returnReserve : returnReserve + 1; - return minimumTarget > healthPotionRestockTarget ? minimumTarget : healthPotionRestockTarget; + return minimumTarget > stockTarget ? minimumTarget : stockTarget; } struct PlayerBotFoodInventory { diff --git a/server/src/playerbotprogression.cpp b/server/src/playerbotprogression.cpp index 9feff8eb..fe463667 100644 --- a/server/src/playerbotprogression.cpp +++ b/server/src/playerbotprogression.cpp @@ -700,7 +700,7 @@ bool PlayerBotController::findPickupReward(Player& player, const Position& posit const PlayerBotRewardInspectionContext context{currentLoadout, inventoryPolicy.inventoryItemCount(player, potionItemId), inventoryPolicy.foodInventory(player).count, g_game.findItemOfType(&player, ropeItemId, true) != nullptr, g_game.findItemOfType(&player, 2554, true) != nullptr, - potionItemId, healthPotionRestockTarget, preferredFoodCount, ropeItemId, 2554, + potionItemId, huntPotionRestockTarget, preferredFoodCount, ropeItemId, 2554, missingPotionUtility, foodPreferenceUtility}; return rewardPlanner.inspect(snapshot, context); }; @@ -894,7 +894,7 @@ void PlayerBotController::emitGoalCandidate(const Player& player, const GoalCand fields << ",\"potion_item_id\":" << potionItemId << ",\"potion_count\":" << inventoryPolicy.inventoryItemCount(player, potionItemId) << ",\"potion_return_threshold\":" << healthPotionReturnThreshold - << ",\"potion_restock_target\":" << healthPotionRestockTarget + << ",\"potion_restock_target\":" << huntPotionRestockTarget << ",\"food_count\":" << food.count << ",\"food_weight\":" << food.weight << ",\"food_preferred\":" << preferredFoodCount << ",\"food_gap\":" << foodGap << ",\"food_utility\":" << foodGap * foodPreferenceUtility @@ -974,9 +974,11 @@ bool PlayerBotController::selectTopLevelGoal(Player& player, const Position& pos } const uint16_t potionItemId = recoveryPotionItemId(player.getVocationId()); const uint32_t potionCount = inventoryPolicy.inventoryItemCount(player, potionItemId); - const uint32_t missingPotions = potionCount <= huntPotionReturnThreshold ? + huntPotionRestockTarget = potionStockTarget(player); + const uint32_t missingPotions = potionCount < huntPotionRestockTarget ? huntPotionRestockTarget - potionCount : 0; - const bool criticalHealing = survivalRuntime.needsHealing(survivalSnapshot(player)) && missingPotions != 0; + const bool criticalHealing = survivalRuntime.needsHealing(survivalSnapshot(player)) && + potionCount <= huntPotionReturnThreshold; const auto requiredGoal = PlayerBotGoalPlanner::requiredGoal( departurePlanner.required(departureSnapshot(player)), criticalHealing); if (requiredGoal == TopLevelGoal::Service) { diff --git a/server/src/playerbotservice.cpp b/server/src/playerbotservice.cpp index 5e96ac22..bab43ec9 100644 --- a/server/src/playerbotservice.cpp +++ b/server/src/playerbotservice.cpp @@ -563,7 +563,7 @@ void PlayerBotController::beginService(Player* player, const Position& position, void PlayerBotController::updateSupplyRecovery(const Player& player, const Position& position) { const uint64_t funds = player.getMoney() + player.getBankBalance(); - const uint64_t budget = recoverySpendingReserve(player, playerbot::healthPotionRestockTarget); + const uint64_t budget = recoverySpendingReserve(player, potionStockTarget(player)); if (!supplyRecovery.update(funds, budget)) return; huntCoordinator.setSupplyRecovery(supplyRecovery.active()); serviceWorkflow.setSurvivalRestock(supplyRecovery.active()); @@ -664,7 +664,7 @@ void PlayerBotController::processService(Player* player, const Position& current observation.healthPotionItemId = recoveryPotionItemId(player->getVocationId()); observation.healthPotionWeight = Item::items[observation.healthPotionItemId].weight; observation.healthPotionReturnThreshold = huntPotionReturnThreshold; - observation.healthPotionRestockTarget = huntPotionRestockTarget; + observation.healthPotionRestockTarget = potionStockTarget(*player); Item* serviceBackpackItem = player->getInventoryItem(CONST_SLOT_BACKPACK); Container* serviceBackpack = serviceBackpackItem ? serviceBackpackItem->getContainer() : nullptr; observation.actionAvailable = player->canDoAction(); @@ -920,7 +920,7 @@ void PlayerBotController::processService(Player* player, const Position& current // Unaffordable potions degrade operation instead of stopping: hunt // and sell under supply recovery until loot funds a restock. enterSupplyRecovery(currentPosition, observation.money + observation.bankBalance, - recoverySpendingReserve(*player, playerbot::healthPotionRestockTarget), + recoverySpendingReserve(*player, potionStockTarget(*player)), "insufficient_potion_funds"); serviceWorkflow.reset(); if (fixtureDriver.progressionGoalLoop(true).selectGoal) { @@ -1078,7 +1078,7 @@ bool PlayerBotController::findDepositableItem(const Player& player, Container* c } const uint32_t carried = inventoryPolicy.inventoryItemCount(player, item->getID()); const uint32_t reserve = item->getID() == recoveryPotionItemId(player.getVocationId()) ? - std::max(inventoryPolicy.protectedItemReserve(player, item->getID()), huntPotionRestockTarget) : + std::max(inventoryPolicy.protectedItemReserve(player, item->getID()), potionStockTarget(player)) : inventoryPolicy.protectedItemReserve(player, item->getID()); const uint32_t movable = item->isStackable() && carried > reserve ? std::min(item->getItemCount(), carried - reserve) : (carried > reserve ? 1 : 0); @@ -1730,7 +1730,7 @@ void PlayerBotController::processDeposit(Player* player, const Position& current if (depositItem) { const uint32_t carried = inventoryPolicy.inventoryItemCount(*player, depositItem->getID()); const uint32_t reserve = depositItem->getID() == recoveryPotionItemId(player->getVocationId()) ? - std::max(inventoryPolicy.protectedItemReserve(*player, depositItem->getID()), huntPotionRestockTarget) : + std::max(inventoryPolicy.protectedItemReserve(*player, depositItem->getID()), potionStockTarget(*player)) : inventoryPolicy.protectedItemReserve(*player, depositItem->getID()); const uint32_t movable = depositItem->isStackable() && carried > reserve ? std::min(depositItem->getItemCount(), carried - reserve) : (carried > reserve ? 1 : 0); diff --git a/server/src/playerbotspells.cpp b/server/src/playerbotspells.cpp index d4b1a63d..aab1133c 100644 --- a/server/src/playerbotspells.cpp +++ b/server/src/playerbotspells.cpp @@ -278,12 +278,30 @@ bool PlayerBotController::tryOffensiveSpell(Player* player, const Position& curr survivalRuntime.decideOffensiveSpell(survivalSnapshot(*player, target), std::chrono::steady_clock::now())); } +uint32_t PlayerBotController::potionStockTarget(const Player& player, uint32_t returnReserve) const +{ + const Spell* lightHealing = g_spells ? g_spells->getSpellByName("Light Healing") : nullptr; + const uint32_t lightHealingLevel = lightHealing ? lightHealing->getLevel() : 9; + // Hold gold for Light Healing only once it is actually learnable. Below that + // level leftover gold buys hunt ammo so a 40-minute hunt is not stuck on + // two-flask trash spawns. + const bool saveGoldForLightHealing = !player.hasLearnedInstantSpell("Light Healing") && + player.getLevel() >= lightHealingLevel; + const uint32_t base = saveGoldForLightHealing ? healthPotionSafetyTarget : healthPotionAmmoTarget; + return recoveryPotionRestockTargetForReserve(returnReserve, base); +} + +uint32_t PlayerBotController::potionStockTarget(const Player& player) const +{ + return potionStockTarget(player, huntPotionReturnThreshold); +} + uint64_t PlayerBotController::spellTrainingReserve(const Player& player, bool emergencyOnly) const { const uint16_t potionItemId = recoveryPotionItemId(player.getVocationId()); const uint32_t potionCount = inventoryPolicy.inventoryItemCount(player, potionItemId); const uint32_t reserveTarget = emergencyOnly ? - (huntPotionReturnThreshold == UINT32_MAX ? UINT32_MAX : huntPotionReturnThreshold + 1) : healthPotionRestockTarget; + (huntPotionReturnThreshold == UINT32_MAX ? UINT32_MAX : huntPotionReturnThreshold + 1) : potionStockTarget(player); if (emergencyOnly && potionCount >= reserveTarget) return carriedGoldReserve; return recoverySpendingReserve(player, reserveTarget); } diff --git a/server/tests/playerbot-gameplay/includes/login.inc b/server/tests/playerbot-gameplay/includes/login.inc index 22856466..7bded858 100644 --- a/server/tests/playerbot-gameplay/includes/login.inc +++ b/server/tests/playerbot-gameplay/includes/login.inc @@ -189,8 +189,9 @@ function F.login.onLogin(player) assert(eva and not eva:isRemoved(), "Carlin banker Eva was not spawned") F.removeAll(player, F.smallHealthPotionItemId) F.removeAll(player, F.healthPotionItemId) + assert(player:learnSpell("Light Healing"), "Carlin service fixture could not unlock ammo restock") assert(player:addItem(F.healthPotionItemId, 1), "Carlin service fixture could not seed the return threshold") - assert(player:removeMoney(player:getMoney()) and player:setBankBalance(505), + assert(player:removeMoney(player:getMoney()) and player:setBankBalance(955), "Carlin service fixture could not fund the restock target") F.suppressNearbyMonsters(player:getId()) addEvent(F.verifyCarlinLocalService, 250, player:getId(), 480) @@ -382,8 +383,9 @@ function F.login.onLogin(player) "readiness supplies fixture could not reach the Thais depot") F.removeAll(player, F.potionItemId) F.removeAll(player, F.meatItemId) + assert(player:learnSpell("Light Healing"), "readiness supplies fixture could not unlock ammo restock") assert(player:addItem(F.potionItemId, 1), "readiness supplies fixture could not seed the return threshold") - assert(player:removeMoney(player:getMoney()) and player:addMoney(100) and player:setBankBalance(405), + assert(player:removeMoney(player:getMoney()) and player:addMoney(100) and player:setBankBalance(855), "readiness supplies fixture could not fund the restock target") addEvent(F.verifyReadiness, 500, player:getId(), "supplies", 240) elseif mode == "readiness_no_food" then diff --git a/server/tests/playerbot-gameplay/includes/verifiers.inc b/server/tests/playerbot-gameplay/includes/verifiers.inc index 9e2df694..2bec2bd0 100644 --- a/server/tests/playerbot-gameplay/includes/verifiers.inc +++ b/server/tests/playerbot-gameplay/includes/verifiers.inc @@ -5,7 +5,7 @@ function F.verifyReadiness(playerId, mode, attempts) assert(player and not player:isRemoved(), "Bot One disappeared during readiness fixture") local ready = player:getVocation():getId() == 4 and player:getSlotItem(CONST_SLOT_ARMOR) and player:getSlotItem(CONST_SLOT_LEFT) and player:getItemCount(F.potionItemId) > 1 - if mode == "supplies" then ready = ready and player:getItemCount(F.potionItemId) >= 10 end + if mode == "supplies" then ready = ready and player:getItemCount(F.potionItemId) >= 20 end if mode == "upgrade" then ready = ready and player:getSlotItem(CONST_SLOT_LEFT):getId() == F.pickupRewardId end if mode == "retention" then ready = ready and player:getItemCount(2050) >= 1 and player:getItemCount(F.starterWeaponId) >= 1 end if mode == "no_food" then ready = ready and player:getItemCount(F.meatItemId) == 0 end @@ -236,7 +236,7 @@ end function F.verifyCarlinLocalService(playerId, attempts) local player = Player(playerId) assert(player and not player:isRemoved(), "Bot One disappeared during Carlin service") - local complete = player:getItemCount(F.healthPotionItemId) >= 10 and player:getMoney() == 100 and + local complete = player:getItemCount(F.healthPotionItemId) >= 20 and player:getMoney() == 100 and player:getBankBalance() == 0 if not complete and attempts > 0 then addEvent(F.verifyCarlinLocalService, 250, playerId, attempts - 1) diff --git a/server/tests/playerbot_contracts.cpp b/server/tests/playerbot_contracts.cpp index 95a845e0..e86e4093 100644 --- a/server/tests/playerbot_contracts.cpp +++ b/server/tests/playerbot_contracts.cpp @@ -228,9 +228,12 @@ void raisedHuntRecoveryReserve() reconcile(xp, 1, funds); assert(!income.cashPressure && income.supplyBudget.fits); assert(selectRuntimeHunt({income, xp}).atlasVariantId == 2); - // Candidate outbound + return route facts raise the target beyond fixed ten. + assert(recoveryPotionRestockTargetForReserve(1, healthPotionSafetyTarget) == 2); + assert(recoveryPotionRestockTargetForReserve(1) == healthPotionAmmoTarget); + // Candidate outbound + return route facts raise the target beyond the ammo stack. const uint32_t candidateReserve = 2 + 10; - assert(recoveryPotionRestockTargetForReserve(candidateReserve) == 13); + assert(recoveryPotionRestockTargetForReserve(candidateReserve) == healthPotionAmmoTarget); + assert(recoveryPotionRestockTargetForReserve(healthPotionAmmoTarget + 5) == healthPotionAmmoTarget + 6); assert(recoveryPotionRestockTargetForReserve(UINT32_MAX) == UINT32_MAX); reconcile(income, candidateReserve, funds); reconcile(xp, candidateReserve, funds); @@ -239,7 +242,8 @@ void raisedHuntRecoveryReserve() income.predictedLethal = true; assert(selectRuntimeHunt({income, xp}).atlasVariantId == 2); income.predictedLethal = false; - const uint64_t exactFunds = carriedGoldReserve + 3 * price; + const uint64_t exactFunds = carriedGoldReserve + + static_cast(healthPotionAmmoTarget - income.supplyProfile.potions) * price; reconcile(income, candidateReserve, exactFunds); reconcile(xp, candidateReserve, exactFunds); assert(!income.cashPressure && !income.supplyBudget.fits); @@ -974,6 +978,7 @@ void recoverySpellPriority() using Goal = PlayerBotGoalArbiter::TopLevelGoal; assert(playerBotRecoverySpendingReserve(2, 2, 45, 100) == 100); assert(playerBotRecoverySpendingReserve(2, 10, 45, 100) == 460); + assert(playerBotRecoverySpendingReserve(2, 20, 45, 100) == 910); assert(playerBotRecoverySpendingReserve(2, 4, 45, 100) == 190); assert(playerBotAffordableAfterReserve(270, 100, 170)); assert(!playerBotAffordableAfterReserve(269, 100, 170)); @@ -996,6 +1001,12 @@ void recoverySpellPriority() for (Goal goal : {Goal::PickupReward, Goal::BuyEquipment, Goal::SellLoot, Goal::Hunt, Goal::MagicTraining, Goal::Service}) { assert(!candidate(goal).feasible && candidate(goal).reason == "deferred_recovery_spell"); } + snapshot.missingPotions = 2; + candidates = planner.candidates(snapshot); + assert(candidate(Goal::LearnSpell).feasible && candidate(Goal::LearnSpell).reason == "priority_recovery_spell"); + assert(candidate(Goal::Service).feasible && candidate(Goal::Service).reason == "healing_reserve"); + assert(!candidate(Goal::Hunt).feasible && candidate(Goal::Hunt).reason == "deferred_recovery_spell"); + snapshot.missingPotions = 0; snapshot.lowCapacity = true; candidates = planner.candidates(snapshot); assert(candidate(Goal::Service).feasible && candidate(Goal::Service).utility > candidate(Goal::LearnSpell).utility); @@ -1038,6 +1049,22 @@ void projection() assert(region.score == 0); } +void capacityPressureHuntFloor() +{ + PlayerBotHuntRuntime runtime({}); + const auto started = std::chrono::steady_clock::now(); + runtime.selectPlanningRegion({}, {}, started); + runtime.beginCycle(started, 2400); + runtime.observeCapacityPressure(started); + const auto grace = std::chrono::minutes(5); + const auto minimumHunt = std::chrono::minutes(30); + assert(!runtime.capacityPressureElapsed(started + grace - std::chrono::milliseconds(1), grace)); + assert(runtime.capacityPressureElapsed(started + grace, grace)); + assert(!runtime.capacityPressureElapsed(started + grace, grace, minimumHunt)); + assert(!runtime.capacityPressureElapsed(started + minimumHunt - std::chrono::milliseconds(1), grace, minimumHunt)); + assert(runtime.capacityPressureElapsed(started + minimumHunt, grace, minimumHunt)); +} + void oracleRecovery() { using Goal = PlayerBotGoalArbiter::TopLevelGoal; @@ -1105,6 +1132,7 @@ int main() supplyBudget(); recoverySpellPriority(); projection(); + capacityPressureHuntFloor(); oracleRecovery(); std::cout << "playerbot contracts passed\n"; } From 26f7064f0a878a0ebbedb6dac4df8c24b9051ba7 Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Sun, 13 Sep 2026 03:22:41 +0200 Subject: [PATCH 4/4] server: recover hunt income without mandatory potion stock --- docs/playerbots.md | 60 ++++++--- docs/testing.md | 29 +++- .../assertions-progression.ps1 | 2 +- .../assertions-readiness.ps1 | 2 +- .../scenarios-progression.ps1 | 16 +++ scripts/test-playerbot-gameplay.ps1 | 6 +- server/src/playerbotcombat.cpp | 39 ++++-- server/src/playerboteconomy.h | 3 +- server/src/playerbotgoalplanner.cpp | 2 +- server/src/playerbothuntpolicy.cpp | 36 +++++ server/src/playerbothuntpolicy.h | 3 + server/src/playerbothuntregionadapter.cpp | 14 ++ server/src/playerbothuntregions.h | 45 +++++++ server/src/playerbothuntruntime.cpp | 15 ++- server/src/playerbothuntruntime.h | 2 + server/src/playerbotprogression.cpp | 9 +- server/src/playerbotservice.cpp | 26 +++- server/src/playerbotsupplypolicy.h | 11 +- server/src/playerbotsupplyrecovery.h | 23 +++- server/src/playerbottestpolicy.cpp | 6 +- .../playerbot-gameplay/includes/login.inc | 26 +++- server/tests/playerbot_contracts.cpp | 125 +++++++++++++++++- 22 files changed, 439 insertions(+), 61 deletions(-) diff --git a/docs/playerbots.md b/docs/playerbots.md index b8e2eab1..c6eefdad 100644 --- a/docs/playerbots.md +++ b/docs/playerbots.md @@ -118,7 +118,10 @@ A disconnected region remains viable only when currently eligible registered NPC offers provide a plausible affordable multihop chain. Local, remote, tested, and untested hunts then share one policy ranking; there are no reserved slots or exploration quotas. -Observed potion use does not calibrate the static supply forecast. +Qualified potion-use observations can replace the static supply forecast for a +variant within the controller session. Capability changes currently invalidate +these observations aggressively; ordinary leveling outings may produce no +usable samples. Route preflight compares complete static navigation with registered NPC travel. It honors loaded dialogue, fare, level and premium requirements, opaque-condition @@ -324,8 +327,9 @@ reserve, and only once the bot is high enough level to learn it. Below that level, leftover gold buys up to 20 potions (or more when a return route needs a larger reserve) so hunts are not stuck on two-flask trash spawns. After Light Healing is learned, leftover gold buys that ammo stack again. Hunt selection -then uses potions on the belt: a hunt must fit that stock, and among hunts that -fit, XP wins. +then prefers hunts that fit carried supplies, with XP breaking ties. Normal +selection falls back to the lowest-consumption safe hunt when none fits; +supply-recovery outings must fit their bounded recovery budget. A selected spell uses normal `hi`, keyword, and `yes` dialogue. Completion requires both learned state and the exact total-money delta. Learned-spell persistence reconstructs completion after restart and @@ -463,21 +467,41 @@ All scenarios use controlled Lua setup. They prove forecast arithmetic, arbitration, and normal engine casts under those conditions, not frequency or utility on an ordinary long-running server. -The service cycle sells known surplus and returns for the selected recovery -potion when the carried count reaches one. It restocks to the 2-potion safety -floor only while Light Healing is level-eligible and still unlearned. Otherwise -leftover gold buys hunt ammo up to 20 potions, or the return-route reserve if -that is larger. Only the -selected potion is reserved toward that target; other health potions may be -deposited. A complete restock takes priority when total -carried and bank gold can pay for it; an optional partial restock preserves the -carried-gold reserve. If total gold cannot raise stock above the return -threshold, service enters supply recovery instead of stopping: it buys whatever -the gold affords, sells eligible loot at the nearest reachable safe merchant -even without a profitable trip, and hunts under a lowered challenge frontier -that prefers observed coin income and safety. Normal profit-seeking selling and -the full challenge frontier resume once gold covers the restock again. The -cycle deposits carried money and withdraws up to +The service cycle sells known surplus. Potion-driven goal selection requests +service only below the 2-potion safety floor, not for every shortage below the +shopping target. While already shopping, leftover gold buys up to 20 potions +(or a larger route reserve). The shopping target stays at two while Light +Healing is level-eligible and unlearned. Other health potions may be deposited. + +If the safety stock is unaffordable, the bot enters supply recovery. Survival +purchases may spend the 100-gold cash reserve. A completed service visit that +still leaves stock below two defers another restock until total funds or potion +count changes; bank transfers alone do not count as progress. While healing is +still needed, the bot waits at the completed service site for normal recovery. + +Recovery hunts prefer coin income under a lowered challenge frontier and last +at most 120 seconds. They must fit the available supplies and produce coin +income; small replenishing spawns need not meet normal long-hunt throughput +requirements. With zero potions, the combined static hunt and outbound/depot-return +route estimates may spend only health above 80% of maximum. An unaffordable supplier visit is not a prerequisite for earning +money. If no candidate qualifies, normal bounded planning retries apply; the bot +does not select an unsafe hunt just because it is broke. Recovery selling may +use a reachable safe merchant without requiring a profitable trip. Normal +selection resumes once the safety stock becomes affordable and no unchanged +restock failure remains. + +Potion estimates learn per variant within the controller lifetime. Verified +potion use is measured per active combat second and projected over the next +outing's expected combat time. Higher consumption raises the estimate promptly. +Lower estimates require a completed outing of at least 120 seconds, 60 active +combat seconds, three kills, healthy health/mana floors, and no material net +health or mana depletion. Downward updates blend 20% new evidence; repeated +stable zero-use outings can establish a potion-free estimate. Death, danger, +or exhausted supplies cannot teach cheaper hunting. Atlas or combat/healing +capability changes invalidate the relevant observations. Learning never relaxes +immediate combat or route-safety checks. + +The banking cycle deposits carried money and withdraws up to 100 gp without exceeding the bot's total available gold. It does not buy food merely because none is carried. The low-wealth regression isolates this banking contract with 56 bank gp, zero carried gp, ten selected health potions, and no diff --git a/docs/testing.md b/docs/testing.md index b39aabd4..c2ce6a52 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -74,7 +74,7 @@ the lowest expected potion consumption among safe candidates, with XP breaking ties. This is not a new hunt eligibility gate: existing readiness and emergency return rules still apply. A budget is not permission to spend the reserve. -The uncalibrated static estimate uses spawn probabilities and intervals, existing +The initial static estimate uses spawn probabilities and intervals, existing combat/clear throughput, and conservative local-crowd inflation. Each modeled crowd's fight damage is divided by that same crowd's summed isolated damage; the largest local ratio is applied to spawn-rate damage, with a floor of one. @@ -84,8 +84,21 @@ using minimum healing. It credits only legal Exura's audited minimum and the act condition. Regeneration uses whole ticks during estimated combat time, expires after its current lifetime, and gets no travel credit. Exura retains its mana reserve and receives at most one mana pool of credit, not repeated refill cycles. -No current-health spending, future food, looted supplies, other spells, equipment -regeneration, expected profit, or observed supply calibration is assumed. +The static estimate assumes no future food, looted supplies, other spells, +equipment regeneration, or expected profit. Zero-potion recovery outings are +capped at 120 seconds; combined hunt and outbound/depot-return route estimates +may spend only health above 80% of maximum. Recovery candidates must fit supplies +and yield coin income, but may use smaller spawns than normal long hunts. + +Per-variant supply calibration uses verified potions per active combat second. +It raises estimates promptly and lowers them gradually only after substantial, +completed combat with stable health and mana. Zero inventory is not evidence +of zero healing demand: depletion, low health, death, and danger cannot lower +estimates. Changed combat/healing capabilities or atlas revisions invalidate +observations. See `supplyCalibration()` and `supplyRecoveryMode()` in +`server/tests/playerbot_contracts.cpp` for calibration, zero-supply budgets, +emergency spending, safety-only service eligibility, and unchanged-restock +regressions. Before scoring, transport feasibility processes eight loaded offers per turn against an immutable player/resource snapshot. Shared compact component @@ -105,7 +118,9 @@ potion reserves update the budget before final selection. Potion changes, mana loss, cancellation, or snapshot invalidation stop the incremental session safely. -`hunt_region_candidate` reports `supply_estimate_source=static_duration_budget`, +`hunt_region_candidate` reports `supply_estimate_source=static_duration_budget` +or `observed_combat_consumption`, `supply_calibration_samples`, +`supply_potions_per_combat_minute`, `supply_budget_fits`, `supply_expected_damage`, `supply_regeneration_healing`, `supply_spell_healing`, `supply_expected_potions`, `supply_reserved_potions`, and `supply_routine_potions`. `score` remains projected XP; it is not a composite @@ -133,9 +148,13 @@ checks the live verifier's exact potion/payment counts and bounded wait. Parent/live validation (resets the disposable test stack): ```powershell -pwsh -File scripts/test-playerbot-gameplay.ps1 -Scenario adaptive_challenge,spell_training_low_supplies,spell_training_low_supplies_unaffordable,spell_training,spell_training_shortlist,hunt_region_planning +pwsh -File scripts/test-playerbot-gameplay.ps1 -Scenario supply_recovery,adaptive_challenge,spell_training_low_supplies,spell_training_low_supplies_unaffordable,spell_training,spell_training_shortlist,hunt_region_planning ``` +`supply_recovery` starts a seeded level-8 Carlin Knight with zero potions and +zero carried/bank gold. It requires selected-region arrival and normal corpse +coin looting, and rejects repeated Service selection. + `adaptive_challenge` additionally runs deterministic synthetic candidate facts through the real bounded hunt scoring session/runtime: two potions select the one-potion, lower-XP candidate; ten select the eight-potion, higher-XP candidate. diff --git a/scripts/playerbot-gameplay/assertions-progression.ps1 b/scripts/playerbot-gameplay/assertions-progression.ps1 index 1d4c0788..12c14b96 100644 --- a/scripts/playerbot-gameplay/assertions-progression.ps1 +++ b/scripts/playerbot-gameplay/assertions-progression.ps1 @@ -538,7 +538,7 @@ function Assert-AdaptiveChallengeEvents { -not $fixture[0].zero_health_lethal -or -not $fixture[0].helper_scope_exhausted -or $fixture[0].capacity_before_grace -or -not $fixture[0].capacity_at_grace -or -not $fixture[0].capacity_cycle_reset -or $fixture[0].knight_route_reserve -ne 4 -or $fixture[0].rook_route_reserve -ne 9 -or - $fixture[0].high_health_route_reserve -ne 12 -or $fixture[0].high_health_restock_target -ne 13 -or + $fixture[0].high_health_route_reserve -ne 12 -or $fixture[0].high_health_restock_target -ne 20 -or -not $fixture[0].net_value_loss_rejected -or -not $fixture[0].currency_priority_override -or -not $fixture[0].currency_hunt_capacity_excluded -or -not $fixture[0].large_restock_batched -or diff --git a/scripts/playerbot-gameplay/assertions-readiness.ps1 b/scripts/playerbot-gameplay/assertions-readiness.ps1 index 8caf7106..8fb624a8 100644 --- a/scripts/playerbot-gameplay/assertions-readiness.ps1 +++ b/scripts/playerbot-gameplay/assertions-readiness.ps1 @@ -33,7 +33,7 @@ function Assert-CombatReadinessEvents { if ($Mode -eq "supplies") { $service = @($events | Where-Object { $_.event -eq "combat_readiness" -and $_.selected_recovery -eq "service" }) $potions = @($events | Where-Object { - $_.action -eq "buy_potions" -and $_.result -eq "success" -and $_.item_id -eq 7618 -and $_.count -eq 9 + $_.action -eq "buy_potions" -and $_.result -eq "success" -and $_.item_id -eq 7618 -and $_.count -eq 19 }) $food = @($events | Where-Object { $_.action -eq "buy_meat" -and $_.result -eq "success" }) if ($service.Count -lt 1 -or $potions.Count -lt 1 -or $food.Count -ne 0) { throw "Missing healing supplies did not select potion-only service recovery." } diff --git a/scripts/playerbot-gameplay/scenarios-progression.ps1 b/scripts/playerbot-gameplay/scenarios-progression.ps1 index d5e5db0c..7c8f8fda 100644 --- a/scripts/playerbot-gameplay/scenarios-progression.ps1 +++ b/scripts/playerbot-gameplay/scenarios-progression.ps1 @@ -119,6 +119,22 @@ } if ($HuntRegionPlanning) { + Invoke-Scenario -Name "supply_recovery" -DefaultTimeoutSeconds 360 -Body { + Invoke-Compose down --volumes --remove-orphans + $env:PLAYERBOT_GAMEPLAY_MODE = "supply_recovery" + $env:PLAYERBOT_HUNT_DURATION_SECONDS = "2400" + Invoke-Compose up --detach + Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST SUPPLY_RECOVERY_START' | Out-Null + $logs = Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST SUPPLY_RECOVERY_INCOME_PASS' + if ($logs -notmatch '"event":"hunt_region_selection".*"result":"selected"' -or + $logs -notmatch '"event":"hunt_area_entered"' -or + $logs -notmatch '"action":"loot","result":"success","item_id":2148') { + throw "Broke-bot income must follow actual selected-region combat, not a reward or fixture grant." + } + if ($logs -match '"from_goal":"service","to_goal":"service"') { + throw "Broke bot repeated Service instead of earning hunt income." + } + } Invoke-Scenario -Name "hunt_region_planning" -DefaultTimeoutSeconds 180 -Body { Invoke-Compose down --volumes --remove-orphans $env:PLAYERBOT_GAMEPLAY_MODE = "hunt_planning" diff --git a/scripts/test-playerbot-gameplay.ps1 b/scripts/test-playerbot-gameplay.ps1 index f7a3b11e..142013e6 100644 --- a/scripts/test-playerbot-gameplay.ps1 +++ b/scripts/test-playerbot-gameplay.ps1 @@ -73,7 +73,7 @@ $scenarioCatalog = @( "real_depot_restart_deposit", "real_depot_restart_depart", "real_depot_partial_move", "real_depot_rejected_move", "pickup_progression", "pickup_progression_bundle", "pickup_progression_nested", "pickup_progression_resume", "pickup_progression_nested_resume", "pickup_progression_space", "goal_arbitration", "goal_arbitration_interrupt", - "stamina_bonus_projection", "stamina_boundary_projection", "stamina_normal_projection", "hunt_region_planning", + "stamina_bonus_projection", "stamina_boundary_projection", "stamina_normal_projection", "hunt_region_planning", "supply_recovery", "combat_readiness_ready", "combat_readiness_upgrade", "combat_readiness_missing_weapon", "combat_readiness_supplies", "combat_readiness_no_food", "combat_readiness_low_wealth", "combat_readiness_food_capacity", "combat_readiness_retention", "hunt_area_arrival", "remote_hunt", "equipment_offer_shadow_upgrade", "equipment_offer_shadow_unaffordable", @@ -94,8 +94,8 @@ foreach ($scenarioName in $scenarioCatalog) { throw "Duplicate gameplay scenario name: $scenarioName" } } -if ($scenarioCatalog.Count -ne 91) { - throw "The gameplay scenario catalog must contain 91 scenarios; found $($scenarioCatalog.Count)." +if ($scenarioCatalog.Count -ne 92) { + throw "The gameplay scenario catalog must contain 92 scenarios; found $($scenarioCatalog.Count)." } $requestedScenarioNames = @($Scenario | ForEach-Object { $_ -split ',' } | Where-Object { $_ }) $exactScenarioSelection = $requestedScenarioNames.Count -gt 0 diff --git a/server/src/playerbotcombat.cpp b/server/src/playerbotcombat.cpp index 87fb183d..66f71e89 100644 --- a/server/src/playerbotcombat.cpp +++ b/server/src/playerbotcombat.cpp @@ -607,7 +607,7 @@ void PlayerBotController::processTraversalCombat(Player* player, const Position& bool PlayerBotController::isActiveHuntCombat(const Player& player) const { - return huntCoordinator.huntActive() && turnRouter.cyclePhase() == CyclePhase::Hunt && + return huntCoordinator.huntActive() && huntRegionReached && turnRouter.cyclePhase() == CyclePhase::Hunt && turnRouter.scenarioStage() == ScenarioStage::TraversalCombat && const_cast(player).getAttackedCreature() != nullptr; } @@ -633,7 +633,7 @@ void PlayerBotController::recordActiveHuntCombat(const Player& player) void PlayerBotController::recordHuntRecovery(bool potion) { Player* player = g_game.getPlayerByID(playerId); - if (!player || !huntCoordinator.huntActive() || !isActiveHuntCombat(*player)) { + if (!player || !huntCoordinator.huntActive() || !huntRegionReached || turnRouter.cyclePhase() != CyclePhase::Hunt) { return; } if (potion) { @@ -691,6 +691,8 @@ void PlayerBotController::emitHuntRegionCandidate(const PlayerBotHuntRegion& reg << ",\"coin_estimate_source\":\"static_loaded_loot_gross\"" << ",\"expected_coin_gold_per_minute\":" << region.coinGoldPerMinute << ",\"cash_pressure\":" << (region.cashPressure ? "true" : "false") + << ",\"supply_recovery\":" << (region.supplyRecovery ? "true" : "false") + << ",\"recovery_route_health_loss\":" << region.recoveryRouteHealthLoss << ",\"sustained_eligible\":" << (region.sustainedEligible ? "true" : "false") << ",\"reachable_spawns\":" << region.viability.reachableSpawns << ",\"replenishing_spawns\":" << region.viability.replenishingSpawns @@ -711,12 +713,15 @@ void PlayerBotController::emitHuntRegionCandidate(const PlayerBotHuntRegion& reg << ",\"projected_experience\":" << region.projectedExperience << ",\"optimistic_projected_experience\":" << region.optimisticProjectedExperience << ",\"route_validated\":" << (region.routeValidated ? "true" : "false") - << ",\"supply_estimate_source\":\"static_duration_budget\"" + << ",\"supply_estimate_source\":" << jsonString(region.supplyCalibration.samples != 0 ? + "observed_combat_consumption" : "static_duration_budget") << ",\"supply_budget_fits\":" << (region.supplyBudget.fits ? "true" : "false") << ",\"supply_expected_damage\":" << region.supplyBudget.expectedDamage << ",\"supply_regeneration_healing\":" << region.supplyBudget.regenerationHealing << ",\"supply_spell_healing\":" << region.supplyBudget.spellHealing << ",\"supply_expected_potions\":" << region.supplyBudget.expectedPotions + << ",\"supply_calibration_samples\":" << region.supplyCalibration.samples + << ",\"supply_potions_per_combat_minute\":" << region.supplyCalibration.potionsPerCombatSecond * 60 << ",\"supply_reserved_potions\":" << region.supplyBudget.reservedPotions << ",\"supply_routine_potions\":" << region.supplyBudget.routinePotions << ",\"threat_ratio\":" << region.threatRatio @@ -802,7 +807,10 @@ void PlayerBotController::emitHuntRegionPlanning(const PlayerBotHuntPlanningSess void PlayerBotController::finishHuntRegion(const Player& player, const Position& position, const char* reason) { Player& mutablePlayer = const_cast(player); - const auto completion = huntCoordinator.finishHunt(huntPlayerObservation(mutablePlayer), std::chrono::steady_clock::now(), + auto observation = huntPlayerObservation(mutablePlayer); + observation.supplyCapability = playerBotSupplyCapability(huntPlanningFacts(mutablePlayer, huntCombatProfile(mutablePlayer))); + observation.supplyInterrupted = std::strcmp(reason, "hunt_deadline") != 0 && std::strcmp(reason, "capacity") != 0; + const auto completion = huntCoordinator.finishHunt(observation, std::chrono::steady_clock::now(), static_cast(std::max(1, g_config.getNumber(ConfigManager::PLAYERBOT_HUNT_DURATION_SECONDS)))); if (!completion) return; const auto& combat = completion->combat; @@ -823,6 +831,8 @@ void PlayerBotController::finishHuntRegion(const Player& player, const Position& << ",\"updated_observed_correction\":" << completion->performance.updatedCorrection << ",\"performance_observed\":" << (completion->performance.observed ? "true" : "false") << ",\"performance_evidence_reason\":" << jsonString(completion->performance.evidenceReason) + << ",\"supply_calibration_samples\":" << completion->region.supplyCalibration.samples + << ",\"supply_potions_per_combat_minute\":" << completion->region.supplyCalibration.potionsPerCombatSecond * 60 << ",\"kills\":" << combat.kills << ",\"damage_taken\":" << combat.damageTaken << ",\"active_combat_seconds\":" << combat.activeSeconds << ",\"active_combat_uptime\":" << (completion->durationSeconds == 0 ? 0 : combat.activeSeconds / completion->durationSeconds) @@ -846,7 +856,8 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit const PlayerBotHuntPlanningObservation fixtureObservation = fixtureDriver.huntPlanningObservation(); const uint32_t configuredDuration = static_cast( std::max(1, g_config.getNumber(ConfigManager::PLAYERBOT_HUNT_DURATION_SECONDS))); - const uint32_t duration = fixtureDriver.huntPlanningDuration(configuredDuration); + const uint32_t duration = fixtureDriver.huntPlanningDuration( + supplyRecovery.active() ? std::min(configuredDuration, 120) : configuredDuration); auto planningInput = [&]() { PlayerBotHuntRuntimePlanningInput input; input.player = huntPlayerObservation(player); @@ -969,7 +980,7 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit } if (outcome.command == PlayerBotHuntRuntimeCommand::ScopeExhausted) { emit("hunt_region_selection", position, "\"result\":\"failed\",\"reason\":\"no_suitable_reachable_region\""); - emit("hunt_scope_exhausted", position, "\"reason\":\"local_scope_exhausted\",\"attempt\":" + std::to_string(outcome.scopeExhaustionAttempt) + ",\"maximum_attempts\":3,\"retry_delay_ms\":" + std::to_string(std::chrono::duration_cast(outcome.retryAfter).count())); + emit("hunt_scope_exhausted", position, "\"reason\":\"local_scope_exhausted\",\"attempt\":" + std::to_string(outcome.scopeExhaustionAttempt) + ",\"maximum_attempts\":" + (supplyRecovery.active() ? "null" : "3") + ",\"retry_delay_ms\":" + std::to_string(std::chrono::duration_cast(outcome.retryAfter).count())); if (outcome.stopForScopeExhaustion) stop("hunt_scope_exhausted", position); return false; } @@ -1088,7 +1099,7 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit routed.supplyProfile = huntPlanningFacts(player, huntCombatProfile(player)).supply; routed.reconcileRecovery(candidateReserve, player.getMoney() + player.getBankBalance(), recoverySpendingReserve(player, potionStockTarget(player, candidateReserve))); - const bool needsSupplyRoute = playerBotHuntNeedsSupplyRoute( + const bool needsSupplyRoute = !routed.supplyRecovery && playerBotHuntNeedsSupplyRoute( routed.supplyBudget.expectedPotions, routed.supplyProfile.potions, candidateReserve); huntSupplyExitApproaches = needsSupplyRoute ? huntSupplyExitCandidates(player, depotApproach) : std::vector{}; @@ -1099,7 +1110,7 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit return false; } } - const bool needsSupplyRoute = playerBotHuntNeedsSupplyRoute( + const bool needsSupplyRoute = !routed.supplyRecovery && playerBotHuntNeedsSupplyRoute( routed.supplyBudget.expectedPotions, routed.supplyProfile.potions, routed.supplyBudget.reservedPotions); if (needsSupplyRoute && routed.supplyDestination == Position()) { @@ -1137,7 +1148,16 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit const uint64_t recoveryReserve = recoverySpendingReserve( player, potionStockTarget(player, candidateReserve)); routed.recoveryPotionReserve = candidateReserve; + routed.recoveryRouteHealthLoss = (static_cast(routed.routeDangerCost) + routed.returnRouteDangerCost) * + player.getMaxHealth() / risk.healthLossCost; routed.reconcileRecovery(candidateReserve, player.getMoney() + player.getBankBalance(), recoveryReserve); + if (!routed.recoverySustainable()) { + routed.suitable = false; + routed.rejectionReason = "recovery_hunt_not_sustainable"; + rejectRouteCandidate(routed); + if (retryAfter) *retryAfter = std::chrono::milliseconds(SCHEDULER_MINTICKS); + return false; + } const uint64_t funds = player.getMoney() + player.getBankBalance(); if (!playerBotHuntTravelAffordable(funds, recoveryReserve, routed.outboundFare, routed.exitFare, routed.supplyFare)) { @@ -1239,7 +1259,8 @@ void PlayerBotController::beginHuntCycle(Player* player, const Position& positio { pendingHuntCompletionReason.clear(); const uint32_t duration = static_cast(std::max(1, g_config.getNumber(ConfigManager::PLAYERBOT_HUNT_DURATION_SECONDS))); - huntCoordinator.beginHuntCycle(std::chrono::steady_clock::now(), duration); + huntCoordinator.beginHuntCycle(std::chrono::steady_clock::now(), + supplyRecovery.active() ? std::min(duration, 120) : duration); // A fallback patrol is already a deliberate hunt; selected regions have a // separate outbound leg before the bot enters their observed hunt area. huntRegionReached = !fixtureDriver.huntObservation().selectRegion; diff --git a/server/src/playerboteconomy.h b/server/src/playerboteconomy.h index 494308e4..e1099cbb 100644 --- a/server/src/playerboteconomy.h +++ b/server/src/playerboteconomy.h @@ -85,7 +85,8 @@ class PlayerBotDispositionPolicy if (totalMoney / unitPrice < requiredGap) { return {0, true}; } - uint32_t amount = totalMoney / unitPrice >= targetGap ? targetGap : static_cast(std::min( + uint32_t amount = survival ? static_cast(std::min(targetGap, totalMoney / unitPrice)) : + totalMoney / unitPrice >= targetGap ? targetGap : static_cast(std::min( targetGap, totalMoney > carriedGoldReserve ? (totalMoney - carriedGoldReserve) / unitPrice : 0)); if (inventory.itemCount <= returnThreshold) { amount = std::max(amount, static_cast(std::min(requiredGap, totalMoney / unitPrice))); diff --git a/server/src/playerbotgoalplanner.cpp b/server/src/playerbotgoalplanner.cpp index 292c339f..57f97798 100644 --- a/server/src/playerbotgoalplanner.cpp +++ b/server/src/playerbotgoalplanner.cpp @@ -34,7 +34,7 @@ PlayerBotGoalArbiter::GoalCandidate PlayerBotGoalPlanner::departureCandidate(con PlayerBotGoalArbiter::GoalCandidate PlayerBotGoalPlanner::serviceCandidate(const PlayerBotGoalPlannerSnapshot& snapshot) const { using Goal = PlayerBotGoalArbiter::TopLevelGoal; - const bool feasible = snapshot.lowCapacity || snapshot.missingPotions != 0 || snapshot.cashAdjustment; + const bool feasible = snapshot.criticalHealing || snapshot.lowCapacity || snapshot.missingPotions != 0 || snapshot.cashAdjustment; int32_t utility = feasible ? serviceGoalBaseUtility : 0; utility += static_cast(snapshot.missingPotions) * missingPotionUtility; utility += snapshot.cashAdjustment ? 10 : 0; diff --git a/server/src/playerbothuntpolicy.cpp b/server/src/playerbothuntpolicy.cpp index 392a1b5f..44d32c96 100644 --- a/server/src/playerbothuntpolicy.cpp +++ b/server/src/playerbothuntpolicy.cpp @@ -192,6 +192,41 @@ PlayerBotHuntChallengeUpdate PlayerBotHuntPolicy::updateChallengeFrontier(const return update; } +PlayerBotSupplyCalibration PlayerBotHuntPolicy::observeSupplies(const PlayerBotHuntRegion& region, + uint64_t durationSeconds, int32_t health, int32_t maximumHealth, uint32_t mana, uint32_t potions, + bool interrupted) +{ + const auto combat = combatSummary(); + auto& history = performance[region.atlasVariantId]; + if (history.atlasRevision != region.atlasRevision) history = {}; + history.atlasRevision = region.atlasRevision; + auto& learned = history.supply; + if (learned.capability != region.supplyCapability) learned = {region.supplyCapability, 0, 0}; + if (combat.activeSeconds <= 0) return learned; + const double exposure = region.availableHuntSeconds * std::clamp(region.combatFraction, 0.0, 1.0); + const double prior = learned.samples != 0 ? learned.potionsPerCombatSecond : + exposure > 0 ? region.supplyBudget.expectedPotions / exposure : 0; + const double observed = combat.potionRecoveries / combat.activeSeconds; + const bool depleted = region.supplyProfile.potions > 0 && potions == 0; + const bool unsafe = combat.dangerObserved || combat.deathObserved || depleted || combat.p10HealthPercent < 70; + // Short/failed outings may raise demand, but cannot teach that healing was free. + if (unsafe || observed > prior) { + learned.potionsPerCombatSecond = std::max(prior, observed); + if (unsafe) learned.potionsPerCombatSecond = std::max(learned.potionsPerCombatSecond, 1.0 / 60.0); + ++learned.samples; + } else if (!interrupted && durationSeconds >= 120 && combat.activeSeconds >= 60 && combat.kills >= 3 && + combat.p10HealthPercent >= 80 && combat.p10ManaPercent >= 50 && + health >= region.currentHealth - maximumHealth / 20 && + mana + region.supplyProfile.maximumMana / 20 >= region.supplyProfile.mana) { + learned.potionsPerCombatSecond = prior * 0.8 + observed * 0.2; + ++learned.samples; + // Repeated stable, zero-use combat can establish a genuinely potion-free hunt. + if (observed == 0 && learned.samples >= 3 && learned.potionsPerCombatSecond * exposure < 1) + learned.potionsPerCombatSecond = 0; + } + return learned; +} + PlayerBotHuntPerformanceUpdate PlayerBotHuntPolicy::observePerformance(uint64_t variantId, uint64_t atlasRevision, const PlayerBotHuntPerformanceSample& sample) { @@ -225,6 +260,7 @@ PlayerBotHuntPerformanceUpdate PlayerBotHuntPolicy::observePerformance(uint64_t return update; } PlayerBotHuntRegionPerformance& regionPerformance = performance[variantId]; + if (regionPerformance.atlasRevision != atlasRevision) regionPerformance = {}; const double sampleCorrection = std::clamp( sample.observedCorrection * update.actualExperiencePerMinute / predictedNetRate, 0.25, 2.0); if (!regionPerformance.reliable || regionPerformance.samples == 0 || diff --git a/server/src/playerbothuntpolicy.h b/server/src/playerbothuntpolicy.h index 4012dda1..4e7f2985 100644 --- a/server/src/playerbothuntpolicy.h +++ b/server/src/playerbothuntpolicy.h @@ -119,6 +119,9 @@ class PlayerBotHuntPolicy PlayerBotHuntPerformanceUpdate observePerformance(uint64_t variantId, uint64_t atlasRevision, const PlayerBotHuntPerformanceSample& sample); + PlayerBotSupplyCalibration observeSupplies(const PlayerBotHuntRegion& region, uint64_t durationSeconds, + int32_t health, int32_t maximumHealth, uint32_t mana, uint32_t potions, bool interrupted); + double challengeFrontier() const { return frontier; } const std::map& regionPerformance() const { return performance; } diff --git a/server/src/playerbothuntregionadapter.cpp b/server/src/playerbothuntregionadapter.cpp index aeca3cdd..f3261295 100644 --- a/server/src/playerbothuntregionadapter.cpp +++ b/server/src/playerbothuntregionadapter.cpp @@ -566,10 +566,19 @@ namespace { // time, danger and supply costs, but never count them as recurring yield. const auto yield = playerBotSustainedHuntYield(replenishment, region.viability, cycleSeconds); region.coinGoldPerMinute = yield.coinGoldPerMinute; + // Small replenishing spawns can fund recovery without long-hunt throughput. + if (region.supplyRecovery && region.coinGoldPerMinute > 0 && expectedCycleExperience > 0) + region.sustainedEligible = true; region.spawnExperiencePerMinute = yield.spawnExperiencePerMinute; region.clearExperiencePerMinute = yield.clearExperiencePerMinute; region.experiencePerMinute = std::min(yield.spawnExperiencePerMinute, yield.clearExperiencePerMinute); region.supplyProfile = planningProfile.supply; + region.supplyCapability = playerBotSupplyCapability(planningProfile); + if (const auto found = performance.find(region.atlasVariantId); + found != performance.end() && found->second.atlasRevision == region.atlasRevision && + found->second.supply.capability == region.supplyCapability) { + region.supplyCalibration = found->second.supply; + } region.destination = *std::min_element(region.patrolPoints.begin(), region.patrolPoints.end(), [&player](const Position& left, const Position& right) { const uint32_t leftDistance = Position::getDistanceX(player.getPosition(), left) + @@ -623,6 +632,7 @@ namespace { Position::getDistanceZ(player.getPosition(), region.destination) * 20; region.predictedFightSeconds = worstFightSeconds; region.currentHealth = planningProfile.currentHealth; + region.maximumHealth = profile.maximumHealth; region.recovery = playerBotPredictRecovery(planningProfile, worstFightSeconds); region.rawThreatRatio = worstFightDamage / std::max(profile.maximumHealth, 1); region.threatRatio = std::max(0.0, worstFightDamage - region.recovery.totalMinimumHealing) / @@ -672,6 +682,10 @@ namespace { 1.5 * huntDurationSeconds / 60.0; region.score = region.projectedExperience; region.reconcileSupplies(planningProfile.supply.reserve); + if (!region.recoverySustainable()) { + region.suitable = false; + region.rejectionReason = "recovery_hunt_not_sustainable"; + } region.reachable = withinPlanningScope; region.travelSteps = estimatedTravelSteps; return region; diff --git a/server/src/playerbothuntregions.h b/server/src/playerbothuntregions.h index ae9bab2b..82441554 100644 --- a/server/src/playerbothuntregions.h +++ b/server/src/playerbothuntregions.h @@ -77,6 +77,22 @@ struct PlayerBotHuntPlanningProfile { PlayerBotSupplyProfile supply; }; +inline uint64_t playerBotSupplyCapability(const PlayerBotHuntPlanningProfile& profile) +{ + uint64_t key = 14695981039346656037ULL; + for (uint64_t value : {uint64_t(profile.combat.level), uint64_t(profile.combat.maximumHealth), + uint64_t(profile.combat.armor), uint64_t(profile.combat.defense), uint64_t(profile.combat.attack), + uint64_t(profile.combat.attackSkill), uint64_t(profile.combat.attackFactor * 1000), + uint64_t(profile.magicLevel), uint64_t(profile.supply.maximumMana), uint64_t(profile.supply.spellLegal), + uint64_t(profile.supply.spellHealing), uint64_t(profile.supply.spellMana), + uint64_t(profile.supply.potionHealing), uint64_t(profile.supply.healthGain), uint64_t(profile.supply.manaGain), + uint64_t(profile.supply.spellInterval * 1000), uint64_t(profile.supply.healthInterval * 1000), + uint64_t(profile.supply.manaInterval * 1000), uint64_t(profile.supply.regenerationSeconds > 0)}) { + key = (key ^ value) * 1099511628211ULL; + } + return key; +} + struct PlayerBotHuntMonsterProfile { std::string name; double expectedSpawns = 0; @@ -133,6 +149,7 @@ struct PlayerBotHuntRegion { uint32_t corridorSpawnBlocks = 0; bool corridorDangerAvailable = false; int32_t currentHealth = 0; + int32_t maximumHealth = 0; double predictedFightSeconds = 0; double challengeFrontier = 0; double challengeBandMinimum = 0; @@ -140,9 +157,12 @@ struct PlayerBotHuntRegion { PlayerBotRecoveryPrediction recovery; PlayerBotSupplyProfile supplyProfile; PlayerBotSupplyBudget supplyBudget; + PlayerBotSupplyCalibration supplyCalibration; + uint64_t supplyCapability = 0; double expectedDamagePerSecond = 0; double combatFraction = 0; uint32_t returnRouteDangerCost = 0; + double recoveryRouteHealthLoss = 0; uint64_t outboundFare = 0; uint64_t exitFare = 0; uint64_t supplyFare = 0; @@ -166,6 +186,12 @@ struct PlayerBotHuntRegion { bool predictedLethal = false; std::string rejectionReason; + bool recoverySustainable() const + { + return !supplyRecovery || (coinGoldPerMinute > 0 && supplyBudget.fits && + availableHuntSeconds > 0 && currentHealth >= maximumHealth * 0.8); + } + void reconcileTravel(double durationSeconds, double travelSeconds, double staminaMultiplier) { estimatedTravelSeconds = travelSeconds; @@ -189,6 +215,24 @@ struct PlayerBotHuntRegion { supplyProfile.reserve = reserve; supplyBudget = playerBotSupplyBudget(supplyProfile, expectedDamagePerSecond, combatFraction, availableHuntSeconds, estimatedTravelSeconds); + if (supplyRecovery && supplyProfile.potions <= reserve) { + // A short recovery outing may spend only health above the 80% floor. + const double healthBudget = std::max(0.0, currentHealth - maximumHealth * 0.8 - recoveryRouteHealthLoss); + const double deficit = std::max(0.0, supplyBudget.expectedDamage - supplyBudget.regenerationHealing - + supplyBudget.spellHealing - healthBudget); + supplyBudget.expectedPotions = deficit == 0 ? 0 : supplyProfile.potionHealing > 0 ? + std::ceil(deficit / supplyProfile.potionHealing) : std::numeric_limits::max(); + supplyBudget.fits = supplyBudget.expectedPotions == 0 && + recoveryRouteHealthLoss <= currentHealth - maximumHealth * 0.8; + } + if (supplyCalibration.samples != 0 && supplyCalibration.capability == supplyCapability) { + supplyBudget.expectedPotions = std::ceil(supplyCalibration.potionsPerCombatSecond * + availableHuntSeconds * std::clamp(combatFraction, 0.0, 1.0)); + supplyBudget.fits = (supplyRecovery && supplyBudget.expectedPotions == 0 && + recoveryRouteHealthLoss <= currentHealth - maximumHealth * 0.8) || + ((supplyProfile.potions > reserve || reserve == 0) && + supplyBudget.expectedPotions <= supplyBudget.routinePotions); + } } }; @@ -199,6 +243,7 @@ struct PlayerBotHuntRegionPerformance { uint32_t samples = 0; uint64_t atlasRevision = 0; bool reliable = false; + PlayerBotSupplyCalibration supply; }; struct PlayerBotHuntSharedCorrection { diff --git a/server/src/playerbothuntruntime.cpp b/server/src/playerbothuntruntime.cpp index b3113c09..25bf003a 100644 --- a/server/src/playerbothuntruntime.cpp +++ b/server/src/playerbothuntruntime.cpp @@ -177,7 +177,7 @@ void PlayerBotHuntRuntime::applyCandidateSuitability(PlayerBotHuntRegion& region region.inChallengeBand = region.threatRatio <= region.challengeBandMaximum; region.suitable = observation.candidateFactsAvailable && region.sustainedEligible && !region.predictedLethal && region.threatRatio <= region.challengeBandMaximum && observation.withinPlanningScope && - region.transportPlausible; + region.transportPlausible && region.recoverySustainable(); if (planning->snapshot().excludedVariants.find(region.atlasVariantId) != planning->snapshot().excludedVariants.end()) { region.suitable = false; region.rejectionReason = "observed_danger_cooldown"; @@ -189,6 +189,8 @@ void PlayerBotHuntRuntime::applyCandidateSuitability(PlayerBotHuntRegion& region region.rejectionReason = "predicted_lethal"; } else if (!region.sustainedEligible) { region.rejectionReason = playerBotHuntViabilityRejection(region.viability); + } else if (!region.recoverySustainable()) { + region.rejectionReason = "recovery_hunt_not_sustainable"; } else if (!region.suitable) { region.rejectionReason = "challenge_frontier"; } else { @@ -201,10 +203,10 @@ PlayerBotHuntRuntimeOutcome PlayerBotHuntRuntime::exhaustScope(std::chrono::stea { PlayerBotHuntRuntimeOutcome outcome; outcome.candidates = planning->regions(); - ++scopeExhaustions; + scopeExhaustions = std::min(scopeExhaustions + 1, 3); outcome.command = PlayerBotHuntRuntimeCommand::ScopeExhausted; outcome.scopeExhaustionAttempt = scopeExhaustions; - outcome.stopForScopeExhaustion = scopeExhaustions >= 3; + outcome.stopForScopeExhaustion = !supplyRecoveryDegraded && scopeExhaustions >= 3; outcome.retryAfter = retryAfter; scopeReevaluationAfter = now + retryAfter; planning.reset(); @@ -322,8 +324,13 @@ std::optional PlayerBotHuntRuntime::complete(con result.coinGoldAcquired = coinGoldAcquired; result.performance = policy.observePerformance(activeRegion->atlasVariantId, activeRegion->atlasRevision, {result.durationSeconds, result.combat.activeSeconds, result.combat.kills, result.experienceGained, - activeRegion->projectedExperience, activeRegion->observedCorrection, configuredDurationSeconds, + activeRegion->projectedExperience, activeRegion->observedCorrection, + activeRegion->supplyRecovery ? std::min(configuredDurationSeconds, 120) : configuredDurationSeconds, result.combat.dangerObserved, result.combat.deathObserved}); + if (player.supplyCapability == activeRegion->supplyCapability) { + result.region.supplyCalibration = policy.observeSupplies(*activeRegion, result.durationSeconds, + player.health, player.maximumHealth, player.mana, player.potions, player.supplyInterrupted); + } result.challenge = policy.updateChallengeFrontier({result.durationSeconds, player.maximumHealth}); activeRegion.reset(); capacityPressureStarted = {}; diff --git a/server/src/playerbothuntruntime.h b/server/src/playerbothuntruntime.h index 59ab87e8..7ae28fc2 100644 --- a/server/src/playerbothuntruntime.h +++ b/server/src/playerbothuntruntime.h @@ -47,6 +47,8 @@ struct PlayerBotHuntRuntimePlayerObservation { uint32_t potions = 0; uint32_t mana = 0; uint64_t funds = 0; + uint64_t supplyCapability = 0; + bool supplyInterrupted = false; }; struct PlayerBotHuntRuntimeCooldownCommand { diff --git a/server/src/playerbotprogression.cpp b/server/src/playerbotprogression.cpp index fe463667..c04d55be 100644 --- a/server/src/playerbotprogression.cpp +++ b/server/src/playerbotprogression.cpp @@ -90,7 +90,7 @@ PlayerBotEquipmentReadinessInput PlayerBotController::equipmentReadinessInput(co const Item* backpack = player.getInventoryItem(CONST_SLOT_BACKPACK); const uint16_t potionItemId = recoveryPotionItemId(player.getVocationId()); return {backpack && backpack->getContainer(), - inventoryPolicy.inventoryItemCount(player, potionItemId) > huntPotionReturnThreshold, + supplyRecovery.active() || inventoryPolicy.inventoryItemCount(player, potionItemId) > huntPotionReturnThreshold, inventoryPolicy.huntFreeCapacity(player), returnCapacityThreshold}; } @@ -974,9 +974,10 @@ bool PlayerBotController::selectTopLevelGoal(Player& player, const Position& pos } const uint16_t potionItemId = recoveryPotionItemId(player.getVocationId()); const uint32_t potionCount = inventoryPolicy.inventoryItemCount(player, potionItemId); + updateSupplyRecovery(player, position); huntPotionRestockTarget = potionStockTarget(player); - const uint32_t missingPotions = potionCount < huntPotionRestockTarget ? - huntPotionRestockTarget - potionCount : 0; + const uint32_t missingPotions = playerBotMandatoryPotionDeficit( + potionCount, healthPotionSafetyTarget, supplyRecovery.active()); const bool criticalHealing = survivalRuntime.needsHealing(survivalSnapshot(player)) && potionCount <= huntPotionReturnThreshold; const auto requiredGoal = PlayerBotGoalPlanner::requiredGoal( @@ -1025,7 +1026,7 @@ bool PlayerBotController::selectTopLevelGoal(Player& player, const Position& pos player.getVocation()->getId() != 0, player.getLevel() < oracleMinimumLevel, player.getLevel() > oracleMaximumLevel, lowCapacity, criticalHealing, missingPotions, sellable, - player.getMoney() != inventoryPolicy.desiredCarriedGold(player), + !supplyRecovery.active() && player.getMoney() != inventoryPolicy.desiredCarriedGold(player), pickupCoolingDown, pickupFound, pickupUtility, spellTrainingCoolingDown, spellTrainingFound, equipmentPurchaseCoolingDown, fixtureDriver.observeEquipmentOffer(true).available, equipmentFound, diff --git a/server/src/playerbotservice.cpp b/server/src/playerbotservice.cpp index bab43ec9..c2a0a937 100644 --- a/server/src/playerbotservice.cpp +++ b/server/src/playerbotservice.cpp @@ -534,6 +534,7 @@ void PlayerBotController::onNpcReply(uint32_t replyingPlayerId, uint32_t npcId, void PlayerBotController::beginService(Player* player, const Position& position, const char* reason) { + updateSupplyRecovery(*player, position); const bool interruptedHunt = fixtureDriver.progressionGoalLoop(true).selectGoal && progressionRuntime.activeGoal() == TopLevelGoal::Hunt && !departurePlanner.hasCompleted(departureSnapshot(*player)); finishHuntRegion(*player, position, reason); @@ -563,8 +564,13 @@ void PlayerBotController::beginService(Player* player, const Position& position, void PlayerBotController::updateSupplyRecovery(const Player& player, const Position& position) { const uint64_t funds = player.getMoney() + player.getBankBalance(); - const uint64_t budget = recoverySpendingReserve(player, potionStockTarget(player)); - if (!supplyRecovery.update(funds, budget)) return; + const uint32_t potions = inventoryPolicy.inventoryItemCount(player, recoveryPotionItemId(player.getVocationId())); + const bool wasActive = supplyRecovery.active(); + supplyRecovery.restockBlocked(funds, potions); + const uint64_t spending = recoverySpendingReserve(player, healthPotionSafetyTarget); + const uint64_t budget = spending == UINT64_MAX ? spending : spending - carriedGoldReserve; + supplyRecovery.update(funds, budget); + if (wasActive == supplyRecovery.active()) return; huntCoordinator.setSupplyRecovery(supplyRecovery.active()); serviceWorkflow.setSurvivalRestock(supplyRecovery.active()); emit("action_result", position, @@ -917,6 +923,8 @@ void PlayerBotController::processService(Player* player, const Position& current return; } if (command.outcome == PlayerBotServiceOutcome::InsufficientFunds) { + supplyRecovery.deferRestock(observation.money + observation.bankBalance, + inventoryPolicy.inventoryItemCount(*player, observation.healthPotionItemId)); // Unaffordable potions degrade operation instead of stopping: hunt // and sell under supply recovery until loot funds a restock. enterSupplyRecovery(currentPosition, observation.money + observation.bankBalance, @@ -1043,6 +1051,20 @@ void PlayerBotController::processService(Player* player, const Position& current return; } if (command.type == PlayerBotServiceCommandType::Complete) { + const uint32_t potions = inventoryPolicy.inventoryItemCount(*player, observation.healthPotionItemId); + if (potions < healthPotionSafetyTarget && + !supplyRecovery.restockBlocked(player->getMoney() + player->getBankBalance(), potions)) { + supplyRecovery.deferRestock(player->getMoney() + player->getBankBalance(), potions); + huntCoordinator.setSupplyRecovery(true); + emit("action_result", currentPosition, + "\"action\":\"restock\",\"result\":\"deferred\",\"reason\":\"safety_stock_unmet\""); + } + if (supplyRecovery.active() && survivalRuntime.needsHealing(survivalSnapshot(*player))) { + // Stay at the completed service site while normal food/spell recovery runs. + // Re-entering goal selection here would immediately interrupt another hunt. + schedule(1000); + return; + } if (fixtureDriver.progressionGoalLoop(true).selectGoal) { emit("goal_result", currentPosition, "\"decision_id\":" + std::to_string(progressionRuntime.decisionId()) + diff --git a/server/src/playerbotsupplypolicy.h b/server/src/playerbotsupplypolicy.h index 8bc47e4e..0d6ddcd8 100644 --- a/server/src/playerbotsupplypolicy.h +++ b/server/src/playerbotsupplypolicy.h @@ -1,4 +1,4 @@ -/** Pure, uncalibrated hunt-duration consumption and recovery-spending policy. +/** Pure hunt-duration consumption and recovery-spending policy. * This preference never replaces immediate combat or navigation safety gates. */ #ifndef FS_PLAYERBOTSUPPLYPOLICY_H @@ -34,6 +34,12 @@ struct PlayerBotSupplyProfile { double manaInterval = 0; }; +struct PlayerBotSupplyCalibration { + uint64_t capability = 0; + double potionsPerCombatSecond = 0; + uint32_t samples = 0; +}; + struct PlayerBotSupplyBudget { double expectedDamage = 0; double regenerationHealing = 0; @@ -74,7 +80,8 @@ inline PlayerBotSupplyBudget playerBotSupplyBudget(const PlayerBotSupplyProfile& const double deficit = std::max(0.0, result.expectedDamage - result.regenerationHealing - result.spellHealing); result.expectedPotions = deficit == 0 ? 0 : profile.potionHealing > 0 ? std::ceil(deficit / profile.potionHealing) : std::numeric_limits::max(); - result.fits = profile.potions > profile.reserve && result.expectedPotions <= result.routinePotions; + result.fits = (profile.potions > profile.reserve || profile.reserve == 0) && + result.expectedPotions <= result.routinePotions; return result; } diff --git a/server/src/playerbotsupplyrecovery.h b/server/src/playerbotsupplyrecovery.h index 9a1bf2e3..eef94e82 100644 --- a/server/src/playerbotsupplyrecovery.h +++ b/server/src/playerbotsupplyrecovery.h @@ -11,7 +11,20 @@ class PlayerBotSupplyRecoveryState { public: - bool active() const { return degraded; } + bool active() const { return degraded || restockDeferred; } + + void deferRestock(uint64_t funds, uint32_t potions) + { + restockDeferred = true; + deferredFunds = funds; + deferredPotions = potions; + } + + bool restockBlocked(uint64_t funds, uint32_t potions) + { + if (funds != deferredFunds || potions != deferredPotions) restockDeferred = false; + return restockDeferred; + } bool enter() { @@ -33,8 +46,16 @@ class PlayerBotSupplyRecoveryState private: bool degraded = false; + bool restockDeferred = false; + uint64_t deferredFunds = 0; + uint32_t deferredPotions = 0; }; +inline uint32_t playerBotMandatoryPotionDeficit(uint32_t potions, uint32_t safetyTarget, bool recovery) +{ + return !recovery && potions < safetyTarget ? safetyTarget - potions : 0; +} + inline double playerBotSupplyRecoveryChallengeFrontier(double frontier, bool degraded) { return degraded ? std::min(frontier, 0.20) : frontier; diff --git a/server/src/playerbottestpolicy.cpp b/server/src/playerbottestpolicy.cpp index 00da28b7..a1f96c20 100644 --- a/server/src/playerbottestpolicy.cpp +++ b/server/src/playerbottestpolicy.cpp @@ -20,7 +20,8 @@ const playerbot::PlayerBotTestPolicy& playerbot::playerBotTestPolicyFromEnvironm const char* gameplayMode = gameplayModeValue && *gameplayModeValue != '\0' ? gameplayModeValue : nullptr; const char* regressionMode = regressionModeValue && *regressionModeValue != '\0' ? regressionModeValue : nullptr; const bool progressionMode = gameplayMode && - (std::strcmp(gameplayMode, "progression") == 0 || + (std::strcmp(gameplayMode, "supply_recovery") == 0 || + std::strcmp(gameplayMode, "progression") == 0 || std::strcmp(gameplayMode, "progression_bundle") == 0 || std::strcmp(gameplayMode, "progression_nested") == 0 || std::strcmp(gameplayMode, "progression_resume") == 0 || @@ -82,7 +83,8 @@ const playerbot::PlayerBotTestPolicy& playerbot::playerBotTestPolicyFromEnvironm const bool adaptiveChallengeFixture = gameplayMode && std::strcmp(gameplayMode, "adaptive_challenge") == 0; const bool spellCalibrationFixture = gameplayMode && std::strcmp(gameplayMode, "spell_calibration") == 0; const bool magicTrainingFixture = gameplayMode && std::strncmp(gameplayMode, "magic_training", 14) == 0; - const bool fixedFixtureRoute = gameplayMode && std::strcmp(gameplayMode, "stamina_bonus") != 0 && + const bool fixedFixtureRoute = gameplayMode && std::strcmp(gameplayMode, "supply_recovery") != 0 && + std::strcmp(gameplayMode, "stamina_bonus") != 0 && std::strcmp(gameplayMode, "stamina_boundary") != 0 && std::strcmp(gameplayMode, "stamina_normal") != 0 && std::strcmp(gameplayMode, "hunt_planning") != 0 && !huntAreaArrivalFixture && !remoteHuntFixture && diff --git a/server/tests/playerbot-gameplay/includes/login.inc b/server/tests/playerbot-gameplay/includes/login.inc index 7bded858..27f57f8a 100644 --- a/server/tests/playerbot-gameplay/includes/login.inc +++ b/server/tests/playerbot-gameplay/includes/login.inc @@ -44,7 +44,7 @@ function F.login.onLogin(player) mode == "departure_interrupt" or mode == "departure_recovery" or mode == "spell_training" or mode == "spell_training_shortlist" or mode == "spell_training_low_supplies" or mode == "spell_training_low_supplies_unaffordable" or mode == "stamina_bonus" or - mode == "stamina_boundary" or mode == "stamina_normal" or mode == "hunt_planning" or mode == "spell_calibration" or + mode == "stamina_boundary" or mode == "stamina_normal" or mode == "hunt_planning" or mode == "supply_recovery" or mode == "spell_calibration" or mode == "readiness_ready" or mode == "adaptive_challenge" or mode == "readiness_upgrade" or mode == "readiness_missing_weapon" or mode == "readiness_supplies" or mode == "readiness_no_food" or mode == "readiness_low_wealth" or mode == "readiness_food_capacity" or mode == "readiness_retention" or @@ -358,7 +358,7 @@ function F.login.onLogin(player) print("PLAYERBOT_GAMEPLAY_TEST " .. string.upper(mode) .. "_START") return true end - if mode ~= "hunt_planning" and mode ~= "hunt_area_arrival" and mode ~= "remote_hunt" then + if mode ~= "hunt_planning" and mode ~= "hunt_area_arrival" and mode ~= "remote_hunt" and mode ~= "supply_recovery" then F.restoreRookgaardBaseline(player, mode ~= "cycle") end F.selectHealthPotion(player) @@ -427,7 +427,7 @@ function F.login.onLogin(player) print("PLAYERBOT_GAMEPLAY_TEST READINESS_" .. string.upper(mode:gsub("readiness_", "")) .. "_START") return true end - if mode == "hunt_planning" or mode == "hunt_area_arrival" or mode == "remote_hunt" then + if mode == "hunt_planning" or mode == "hunt_area_arrival" or mode == "remote_hunt" or mode == "supply_recovery" then local carlin = Town(F.carlinTownId) assert(carlin and player:setTown(carlin) and player:setVocation(4), "hunt planning fixture could not select the Carlin Knight") @@ -449,6 +449,26 @@ function F.login.onLogin(player) end assert(player:getItemCount(F.healthPotionItemId) == 10 and player:getItemCount(F.smallHealthPotionItemId) == 0, "hunt planning fixture has the wrong healing supplies") + if mode == "supply_recovery" then + F.removeAllHealthPotions(player) + if player:getMoney() > 0 then assert(player:removeMoney(player:getMoney())) end + assert(player:setBankBalance(0)) + assert(player:getMoney() == 0 and player:getBankBalance() == 0 and player:getItemCount(F.potionItemId) == 0, + "supply recovery fixture must start broke and without potions") + local function verifyIncome(id, attempts) + local bot = Player(id) + assert(bot, "supply recovery bot disappeared") + if bot:getMoney() + bot:getBankBalance() > 0 then + print("PLAYERBOT_GAMEPLAY_TEST SUPPLY_RECOVERY_INCOME_PASS") + return + end + assert(attempts > 0, "supply recovery did not earn money") + addEvent(verifyIncome, 1000, id, attempts - 1) + end + addEvent(verifyIncome, 1000, player:getId(), 300) + print("PLAYERBOT_GAMEPLAY_TEST SUPPLY_RECOVERY_START") + return true + end if mode == "hunt_planning" then F.suppressNearbyMonsters(player:getId()) end print(mode == "hunt_area_arrival" and "PLAYERBOT_GAMEPLAY_TEST HUNT_AREA_ARRIVAL_START" or mode == "remote_hunt" and "PLAYERBOT_GAMEPLAY_TEST REMOTE_HUNT_START" or diff --git a/server/tests/playerbot_contracts.cpp b/server/tests/playerbot_contracts.cpp index e86e4093..5e6001a9 100644 --- a/server/tests/playerbot_contracts.cpp +++ b/server/tests/playerbot_contracts.cpp @@ -644,6 +644,99 @@ void adaptiveChallenge() assert(std::abs(bounded.challengeFrontier() - 0.60) < 1e-12); } +void supplyCalibration() +{ + PlayerBotHuntPolicy policy; + PlayerBotHuntRegion region; + region.atlasVariantId = 1; + region.atlasRevision = 2; + region.supplyCapability = 3; + region.supplyRecovery = true; + region.currentHealth = region.maximumHealth = 100; + region.supplyProfile.potionHealing = 125; + region.supplyProfile.maximumMana = region.supplyProfile.mana = 100; + region.combatFraction = 0.5; + region.availableHuntSeconds = 120; + region.supplyBudget.expectedPotions = 1; + auto observe = [&](uint32_t potions, int32_t health, uint32_t mana, bool interrupted = false) { + policy.resetCombatEvidence(); + policy.observeCombat({true, 60, health, 100, mana, 100, 1}); + for (unsigned i = 0; i < 3; ++i) policy.observeKill(); + for (unsigned i = 0; i < potions; ++i) policy.observeRecovery(true); + return policy.observeSupplies(region, 120, health, 100, mana, 0, interrupted); + }; + assert(observe(0, 90, 100).samples == 0); // Health loss is unpaid demand. + assert(observe(0, 100, 80).samples == 0); // Spending the mana pool is not sustain. + assert(observe(0, 100, 100, true).samples == 0); + assert(observe(0, 100, 100).potionsPerCombatSecond > 0); + observe(0, 100, 100); + region.supplyCalibration = observe(0, 100, 100); + assert(region.supplyCalibration.potionsPerCombatSecond == 0); + region.reconcileSupplies(1); + assert(region.supplyBudget.fits && region.supplyBudget.expectedPotions == 0); + region.supplyCalibration = observe(2, 100, 100); + region.reconcileSupplies(1); + assert(region.supplyBudget.expectedPotions == 2 && !region.supplyBudget.fits); + region.reconcileTravel(240, 0, 1); + assert(region.supplyBudget.expectedPotions == 4); + const auto unsafe = observe(0, 50, 100); + assert(unsafe.potionsPerCombatSecond >= region.supplyCalibration.potionsPerCombatSecond); + region.supplyCapability = 4; + assert(observe(0, 100, 100, true).samples == 0); + region.atlasRevision = 3; + assert(observe(0, 100, 100, true).samples == 0); + + PlayerBotHuntRuntime runtime({}); + PlayerBotHuntRuntimePlayerObservation player; + player.health = player.maximumHealth = player.mana = 100; + player.supplyCapability = region.supplyCapability; + const auto start = std::chrono::steady_clock::time_point{}; + runtime.selectPlanningRegion(region, player, start); + runtime.sampleCombat({true, start + std::chrono::seconds(1), 100, 100, 100, 100, 1}); + runtime.sampleCombat({true, start + std::chrono::seconds(61), 100, 100, 100, 100, 1}); + for (unsigned i = 0; i < 3; ++i) runtime.observeKill(); + const auto completed = runtime.complete(player, start + std::chrono::seconds(120), 2400); + assert(completed && completed->region.supplyCalibration.samples == 1); + assert(runtime.regionPerformance().at(region.atlasVariantId).supply.samples == 1); + assert(!runtime.complete(player, start + std::chrono::seconds(121), 2400)); + + PlayerBotHuntPlanningProfile capabilities; + const auto original = playerBotSupplyCapability(capabilities); + capabilities.supply.spellLegal = true; + assert(playerBotSupplyCapability(capabilities) != original); + capabilities.supply.spellLegal = false; + capabilities.combat.armor = 5; + assert(playerBotSupplyCapability(capabilities) != original); + + PlayerBotHuntRegion recovery; + recovery.supplyRecovery = true; + recovery.currentHealth = recovery.maximumHealth = 100; + recovery.supplyProfile.potionHealing = 125; + recovery.expectedDamagePerSecond = 0.1; + recovery.reconcileTravel(120, 0, 1); + assert(recovery.supplyBudget.expectedPotions == 0 && recovery.supplyBudget.fits); + recovery.supplyProfile.potions = 1; + recovery.reconcileSupplies(1); + assert(recovery.supplyBudget.expectedPotions == 0 && recovery.supplyBudget.fits); + recovery.supplyProfile.potions = 0; + recovery.coinGoldPerMinute = 1; + recovery.atlasVariantId = 1; + recovery.reachable = true; + assert(recovery.recoverySustainable()); + recovery.recoveryRouteHealthLoss = 10; + recovery.reconcileSupplies(1); + assert(!recovery.supplyBudget.fits); // 12 hunt + 10 travel damage exceeds 20 HP. + auto affordable = recovery; + affordable.atlasVariantId = 2; + affordable.recoveryRouteHealthLoss = 5; + affordable.reconcileSupplies(1); + assert(affordable.recoverySustainable()); + assert(selectRuntimeHunt({recovery, affordable}).atlasVariantId == 2); + recovery.currentHealth = 80; + recovery.reconcileSupplies(1); + assert(!recovery.supplyBudget.fits); +} + void sharedHuntPerformanceCalibration() { auto reliableSample = [](uint64_t experience) { @@ -673,9 +766,9 @@ void sharedHuntPerformanceCalibration() // Default, malformed, stale, and explicitly unreliable entries are not observations. performance.emplace(4, PlayerBotHuntRegionPerformance{}); - performance.emplace(5, PlayerBotHuntRegionPerformance{10, 9, 1, atlasRevision, true}); - performance.emplace(6, PlayerBotHuntRegionPerformance{10, 1.25, 1, atlasRevision, false}); - performance.emplace(8, PlayerBotHuntRegionPerformance{10, 1.75, 1, atlasRevision - 1, true}); + performance.emplace(5, PlayerBotHuntRegionPerformance{10, 0, 9, 1, atlasRevision, true, {}}); + performance.emplace(6, PlayerBotHuntRegionPerformance{10, 0, 1.25, 1, atlasRevision, false, {}}); + performance.emplace(8, PlayerBotHuntRegionPerformance{10, 0, 1.75, 1, atlasRevision - 1, true, {}}); const auto filtered = playerBotHuntCorrectionForVariant(7, atlasRevision, performance); assert(filtered.sampleCount == 2 && std::abs(filtered.correction - 1.5) < 1e-12); assert(std::string(playerBotHuntCorrectionForVariant(4, atlasRevision, {}).source) == "default"); @@ -726,7 +819,27 @@ void supplyRecoveryMode() assert(!survival.insufficientFunds && survival.amount == 0); PlayerBotEconomyInventorySnapshot richer{1, 100, 146, 0}; const auto partial = policy.restock(richer, 45, 0, 1, 10, true); - assert(!partial.insufficientFunds && partial.amount == 1); // 146 - 100 reserve = 46 gp. + assert(!partial.insufficientFunds && partial.amount == 3); // Survival may spend the cash reserve. + assert(policy.restock({0, 100, 45, 0}, 45, 0, 1, 20, true).amount == 1); + assert(policy.restock({0, 0, 45, 0}, 45, 10, 1, 20, true).amount == 0); + + PlayerBotSupplyRecoveryState deferred; + deferred.deferRestock(126, 0); + assert(deferred.active() && deferred.restockBlocked(126, 0)); + assert(!deferred.restockBlocked(171, 0) && !deferred.active()); + deferred.deferRestock(126, 0); + assert(!deferred.restockBlocked(126, 1)); + PlayerBotGoalPlanner planner; + PlayerBotGoalPlannerSnapshot goal; + for (uint32_t potions : {0U, 1U, 2U, 19U, 20U}) { + goal.missingPotions = playerBotMandatoryPotionDeficit(potions, 2, false); + assert(planner.serviceCandidate(goal).feasible == (potions < 2)); + goal.missingPotions = playerBotMandatoryPotionDeficit(potions, 2, true); + assert(!planner.serviceCandidate(goal).feasible); + } + + goal.criticalHealing = true; + assert(planner.serviceCandidate(goal).feasible); const PlayerBotSurvivalSellCandidate rejected{ true, true, true, true, 10, 500}; @@ -938,6 +1051,9 @@ void supplyBudget() assert(!playerBotSupplyBudget(profile, 0, 0, 900, 0).fits); profile.potions = 0; assert(!playerBotSupplyBudget(profile, 0, 0, 900, 0).fits); + profile.reserve = 0; + assert(playerBotSupplyBudget(profile, 0, 0, 900, 0).fits); + profile.reserve = 1; profile.potions = 2; profile.regenerationSeconds = 900; profile.healthGain = 10; @@ -1124,6 +1240,7 @@ int main() adaptiveChallenge(); sharedHuntPerformanceCalibration(); supplyRecoveryMode(); + supplyCalibration(); navigationFixedObjective(); topologyComponentCompression(); fixtureHuntHorizons();