From 265c939d77d53b9c2314e49aa24ac81972a19054 Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:36:40 +0200 Subject: [PATCH] server: unblock hunt preparation and spell training --- docs/playerbots.md | 20 +- docs/testing.md | 8 + .../playerbot-gameplay/assertions-spells.ps1 | 9 +- .../playerbot-gameplay/scenarios-spells.ps1 | 2 +- scripts/test-maealil-healing-keywords.lua | 146 ++++ scripts/test-playerbot-fixture-isolation.lua | 49 +- scripts/test-playerbot-supply-assertions.ps1 | 40 +- server/data/npc/scripts/Maealil.lua | 6 +- server/src/playerbotcombat.cpp | 340 +++++++- server/src/playerbotcontroller.cpp | 63 +- server/src/playerbotcontroller.h | 71 +- server/src/playerbotgoalarbiter.cpp | 6 +- server/src/playerbotgoalarbiter.h | 4 + server/src/playerbotgoalplanner.cpp | 23 +- server/src/playerbotgoalplanner.h | 3 + server/src/playerbothuntregions.h | 96 ++- server/src/playerbothuntrouteselection.cpp | 44 +- server/src/playerbothuntrouteselection.h | 18 + server/src/playerbothuntruntime.cpp | 2 +- server/src/playerbothuntsupplyhandoff.h | 106 +++ server/src/playerbothunttravel.cpp | 35 +- server/src/playerbothunttravelevidence.h | 30 + server/src/playerbothuntwalk.h | 26 +- server/src/playerbotnavigation.cpp | 1 + server/src/playerbotnavigation.h | 30 + server/src/playerbotpathsearch.h | 14 +- server/src/playerbotpreparation.h | 94 ++ server/src/playerbotprogression.cpp | 81 +- server/src/playerbotprogressionplanners.cpp | 13 +- server/src/playerbotprogressionplanners.h | 3 + server/src/playerbotprogressionruntime.cpp | 6 + server/src/playerbotprogressionruntime.h | 1 + server/src/playerbotprogressionsession.h | 1 + server/src/playerbotservice.cpp | 80 +- server/src/playerbotserviceworkflow.cpp | 53 +- server/src/playerbotserviceworkflow.h | 22 +- server/src/playerbotspells.cpp | 805 +++++++++++++----- server/src/playerbotsupplypolicy.h | 17 +- server/src/playerbotsupplyrecovery.h | 2 + server/src/playerbotsupplystock.h | 26 + server/src/playerbotsurvivalruntime.cpp | 40 +- server/src/playerbotsurvivalruntime.h | 8 + server/src/playerbottrainerroute.h | 178 ++++ server/src/playerbotturnrouter.cpp | 3 + server/src/playerbotturnrouter.h | 6 + .../playerbot-gameplay/includes/login.inc | 4 +- .../playerbot-gameplay/includes/verifiers.inc | 2 +- server/tests/playerbot_contracts.cpp | 591 ++++++++++++- server/tests/playerbotrouting_contracts.cpp | 108 +++ .../tests/playerbotserviceworkflow_test.cpp | 299 +++++++ server/tests/playerbotsurvival_contracts.cpp | 121 +++ server/tests/playerbottrainer_contracts.cpp | 659 ++++++++++++++ server/tests/playerbottrainer_contracts.sh | 14 + 53 files changed, 4097 insertions(+), 332 deletions(-) create mode 100644 scripts/test-maealil-healing-keywords.lua create mode 100644 server/src/playerbothuntsupplyhandoff.h create mode 100644 server/src/playerbotpreparation.h create mode 100644 server/src/playerbottrainerroute.h create mode 100644 server/tests/playerbottrainer_contracts.cpp create mode 100644 server/tests/playerbottrainer_contracts.sh diff --git a/docs/playerbots.md b/docs/playerbots.md index 5d1bee82..7bfadefa 100644 --- a/docs/playerbots.md +++ b/docs/playerbots.md @@ -16,14 +16,14 @@ This design is intended to scale without one thread, graphical client, renderer, | Area | Status and evidence | Current boundary | | --- | --- | --- | -| Goal selection | Ordinary | Chooses among survival, service, rewards, spell learning, equipment, magic training, and hunting only at safe boundaries; it is not a general-purpose planner. | +| Goal selection | Ordinary | Chooses among survival, service, rewards, spell learning, equipment, magic training, hunt preparation, and hunting at safe boundaries. Typed readiness distinguishes pending evaluation, executable hunts, and blocked requirements; this is not a general-purpose dependency planner. | | Hunting | Ordinary; map-derived selection has focused real-map coverage | Scores loaded hostile spawns against equipment, skills, health, supplies, route safety, expected XP, and cash pressure. Each accepted outing can adjust one session-local global potion multiplier, so normal evidence transfers proportionally to every area, including mixed-species areas and new equipment snapshots, while the static model preserves relative area difficulty. The observed area also gets a stronger final local estimate; planning uses that estimate instead of multiplying it by the global correction again. Local cheaper evidence remains bounded by compatible capability progression. Food exposure is reported, but calibration currently pools fed and unfed combat; distinguishing their demand is deferred. Supply observations start at hunt-area arrival. Ending health and mana, including fully depleted mana, are reconciled with level restoration into potion-equivalent demand. A vocation that stocks mana potions repays mana debt as mana-potion demand instead. Interrupted, dangerous, fatal, potion-exhausted, short, low-exposure, and sub-30%-health outings cannot lower health-potion estimates. Higher observed health-potion demand applies rapidly. Learned supply cost never certifies combat viability, danger, or route safety. Observations reset with the controller, bounded exploration trials are not implemented, and no long progression soak is established. | -| Combat and healing | Ordinary | Approaches reachable same-floor targets, handles attackers, uses melee, potions, and supported Knight spells, with focused Paladin healing evidence. Emergency combat recovery starts at 60% health. Before a supply-recovery hunt starts, and again on arrival, a peaceful bot can request normal healing to satisfy the planner’s departure-health requirement. These preparation requests are bounded and do not raise the combat trigger or require food. At or below 30%, a carried health potion takes priority even over an affordable spell. Otherwise it selects an eligible learned heal by calibrated expected useful HP per mana, excluding overheal and unaffordable casts. Health potions remain the emergency path, not a reason to prefer stronger spells. If only mana is missing, it prefers a carried mana potion outside active combat, or when no health potion remains. Each turn reevaluates health and combat rather than committing to mana then spell. This favors spell use and magic-level progress, not guaranteed cheapest healing. While hunting or in combat, it also tops up mana below the healing reserve. After an attempted step or movement-related route plan fails without positional progress, it can stop and fight one adjacent attacker without chase when the existing combat estimates say the fight is survivable. It prefers an attacker on the failed step, otherwise the easiest safe attacker, and retains that target until movement can resume, the target dies or moves out of reach, safety changes, or the workflow is interrupted. A bot with a usable ranged weapon tries to keep its hunt target at the weapon's range through one-step retreats. It prefers holding against a target with a shorter current step duration, including terrain and the engine's near-target monster slowdown. Retreats must preserve a clear shot, avoid nearby hostiles, stay within the hunt area, and not increase estimated navigation danger. Requested steps are verified; failed tiles are suppressed briefly. If no admissible step exists it fights in place and records why. Only hunt-target fights are positioned. Spawn-based danger can veto retreat into a recently cleared area; local memory, sideways escape planning, range-aware danger estimates, and group tactics remain unimplemented. Focused fixtures cover a spear Paladin against one slower melee chaser, not general bow/crossbow autonomy. Group tactics and other vocations are not developed. | +| Combat and healing | Ordinary | Approaches reachable same-floor targets, handles attackers, uses melee, potions, and supported Knight spells, with focused Paladin healing evidence. Emergency combat recovery starts at 60% health. Before a supply-recovery hunt starts, and again on arrival, a peaceful bot can request normal healing to satisfy the planner’s departure-health requirement. Preparation is bounded and does not raise the combat trigger. Supported alternatives include a usable learned heal, a carried potion, and observed food regeneration at a validated safe non-PZ rest tile. At or below 30%, a carried health potion takes priority even over an affordable spell. Otherwise it selects an eligible learned heal by calibrated expected useful HP per mana, excluding overheal and unaffordable casts. Health potions remain the emergency path, not a reason to prefer stronger spells. If only mana is missing, it prefers a carried mana potion outside active combat, or when no health potion remains. Each turn reevaluates health and combat rather than committing to mana then spell. This favors spell use and magic-level progress, not guaranteed cheapest healing. While hunting or in combat, it also tops up mana below the healing reserve. After an attempted step or movement-related route plan fails without positional progress, it can stop and fight one adjacent attacker without chase when the existing combat estimates say the fight is survivable. It prefers an attacker on the failed step, otherwise the easiest safe attacker, and retains that target until movement can resume, the target dies or moves out of reach, safety changes, or the workflow is interrupted. A bot with a usable ranged weapon tries to keep its hunt target at the weapon's range through one-step retreats. It prefers holding against a target with a shorter current step duration, including terrain and the engine's near-target monster slowdown. Retreats must preserve a clear shot, avoid nearby hostiles, stay within the hunt area, and not increase estimated navigation danger. Requested steps are verified; failed tiles are suppressed briefly. If no admissible step exists it fights in place and records why. Only hunt-target fights are positioned. Spawn-based danger can veto retreat into a recently cleared area; local memory, sideways escape planning, range-aware danger estimates, and group tactics remain unimplemented. Focused fixtures cover a spear Paladin against one slower melee chaser, not general bow/crossbow autonomy. Group tactics and other vocations are not developed. | | Loot | Ordinary; cargo edge cases have focused evidence | Opens owned corpse containers through normal item use before inspecting contents. It merges stacks, opens carried nested containers normally, uses legal free slots, accepts weight-limited partial stacks, and can replace lower-density saleable cargo. Weight and slots are planned separately; currency may replace heavier low-value vendor loot. Equipment, containers, tools, supplies, currency, and unknown items remain protected. `no_capacity` and `no_slot` skip that item without forcing the hunt to end. Skinning and other secondary corpse actions are unsupported. | | Navigation | Ordinary; transition and recovery scenarios have focused evidence | Routes across the loaded map using a shared coarse topology, then plans and executes local paths and supported transitions. Supports tools, declared ordinary/level/house doors, floor changes, teleports, and registered NPC travel. Known shovel passages keep one topology identity while their live tile opens or decays closed: an open passage is walked without requiring a shovel, while a closed passage requires and verifies normal shovel use before descent. Door access is resolved from the action that would execute; level and house permission checks remain live, and opening must leave the full tile enterable. Locked, quest, unknown, window, and unannotated action-overridden doors are unsupported. Explicitly blocked transition approaches are excluded so local routing can select another approach without discarding the destination. Exhausting a local search budget does not by itself trigger paid travel, and fare or safety rejection counts toward bounded no-progress recovery. Detailed search and recovery have budgets; unsupported transitions and changing obstacles can still prevent a journey. Successful reloads of scripts, item metadata, or global door data rebuild the static topology; failed action, script, or item reloads invalidate it rather than retaining stale routes. | -| NPC service | Ordinary | Discovers loaded shops, bankers, spell trainers, and registered travel offers; buys supplies, sells known loot, banks, and verifies transactions. Supplies are typed stock (health potions, mana potions, ammunition, spare throwing weapons) with per-kind floors, targets, and learned hunt demand, bought from one cash and capacity budget with floors first. Health potions, Paladin mana potions, and a spear Paladin's spare spears have nonzero targets today; Knight mana potions and launcher ammunition do not. Shopping allocates the available budget to each active kind's survival floor before filling targets, while preserving the same free-capacity buffer required to hunt. Targets are optional ceilings, not departure requirements; a capacity-limited top-up can finish below them. Hunt budgets and supplier exits account for mana demand as well as health demand; separate reachable shops can cover different supply kinds. Custom dialogue or opaque travel conditions are excluded unless explicitly modeled. | +| NPC service | Ordinary | Discovers loaded shops, bankers, spell trainers, and registered travel offers; buys supplies, sells known loot, banks, and verifies transactions. Supplies are typed stock (health potions, mana potions, ammunition, spare throwing weapons) with per-kind floors, targets, and learned hunt demand, bought from one cash and capacity budget with floors first. Health potions, Paladin mana potions, and a spear Paladin's spare spears have nonzero targets today; Knight mana potions and launcher ammunition do not. Shopping allocates the available budget to every active kind's survival floor first, then prefers ten carried mana potions for a Paladin before filling full targets, while preserving the same free-capacity buffer required to hunt. Twenty remains the full mana target; ten is an optional shopping preference, not a departure minimum. Before learning a first usable heal, health stocking can save toward that spell, but a validated hunt's required productive stock overrides this preference. Targets are optional ceilings, not departure requirements; a capacity-limited top-up can finish below them. Hunt budgets and supplier exits account for mana demand as well as health demand; separate reachable shops can cover different supply kinds. Custom dialogue or opaque travel conditions are excluded unless explicitly modeled. | | Equipment and depots | Ordinary; restart, remote liquidation, and tool-replenishment paths have focused evidence | Buys or equips supported upgrades, deposits retained loot, and can sell from local or remote depots. A managed Knight or Paladin without a legal weapon or body armor buys the cheapest offer that fills the gap from a discovered shop; it stops with `combat_readiness_missing_*` only when no affordable, reachable offer exists. Paladins use a one-handed throwing weapon (spear family) beside a shield and stock spares of the wielded weapon: a level-scaled target and a floor of 3, counting the stack in hand. The target starts at the floor at level 8, adds one every two levels, and stops growing at level 16; the values live in `playerbotsupplystock.h`. Spears stack, so a break takes one from the hand stack and bought spears join it. Spares in the backpack, such as looted spears, are equipped through a normal item move once the hand empties. Outside supply recovery, reaching one remaining spear ends the hunt with `throwing_weapon_exhausted` so the bot can restock. When restocking is unaffordable, the existing short recovery hunt may budget that remaining spear instead of reserving it. If the last spear is lost, an unarmed Paladin without a usable carried spare falls back to ordinary spears through supply service, including after restart. These purchases may spend the cash reserve; optional equipment upgrades still preserve it. If completed service cannot restore any weapon and funds and stock remain unchanged, readiness stops with `combat_readiness_throwing_weapon_restock_blocked` rather than repeating service or hunting unarmed. Throwing-weapon demand pools consumed stock and combat exposure across valid outings, including interrupted ones, and waits for minimum exposure before applying a rate. Death and net stock gains are excluded. Stock deltas cannot identify breaks offset by loot. The estimate is checked in hunt selection, but breakage is not yet a cost in weapon comparisons. Weapon comparisons use expected damage, including the loaded distance hit chance; a weapon that breaks more often is a trade-off, not an upgrade. Bow and crossbow loadouts are modeled for readiness but never acquired ([#255](https://github.com/adrunkhuman/tibia-playerbots-project/issues/255)). A managed bot with an empty back slot can buy a standard backpack from a discovered shop. Once that backpack is equipped, it deep-searches carried containers and buys one missing rope (2120), then one missing shovel (2554), from discovered shops only. Each tool purchase requires a free backpack slot, carrying capacity, the normal recovery reserve and route-risk budget, and an exact shop receipt; it does not buy a duplicate found in a nested bag. To upgrade a basic bag, it stages the complete bag in a discovered depot, buys with the empty back slot as the verified receipt destination, then retrieves the old bag into the new backpack. The bot stages only after its bank balance can pay the backpack price and preserve the configured reserve; carried coins inside the bag do not count for this preflight. Bounded failures restore the bag before purchase when possible. An unresolved depot recovery is stored on the player and tagged bag, stops the controller with an explicit failure, and resumes before normal goals after reload. If death equips a replacement basic bag, recovery deposits that complete bag before restoring the tagged original. Tool replacement uses this ordinary equipment workflow and has no separate death-recovery path. Other containers and two-handed loadout trade-offs are unsupported and protected. | -| Spells | Ordinary for audited Knight spells; Paladin healing, calibration, and overflow training have focused evidence | Can learn and use selected healing, support, and offensive spells through normal speech. Mana is one budget: healing may spend all of it, while offense, support, and magic training spend only mana above the healing reserve, the cost of the preferred economical eligible learned heal, independent of current mana or overheal. A healing spell is worth buying only if it improves economy over the learned options; an unavailable better spell does not block learning a usable alternative. Offensive spells are chosen from those the vocation can cast. Loaded spell rules remain authoritative. Observations may rank legal casts but never weaken safety or legality. Calibration resets when a spell's effect bounds change. | +| Spells | Ordinary for audited Knight spells; Paladin healing, calibration, and overflow training have focused evidence | Can learn and use selected healing, support, and offensive spells through normal speech. Mana is one budget: healing may spend all of it, while offense, support, and magic training spend only mana above the healing reserve, the cost of the preferred economical eligible learned heal, independent of current mana or overheal. A healing spell is worth buying only if it improves economy over the learned options; an unavailable better spell does not block learning a usable alternative. As a temporary bootstrap until supply-shopping and net-income planning avoid starving first-heal capital, the first usable heal may spend the generic cash buffer when no eligible learned heal exists, regardless of current mana. Computed mandatory supply-floor replenishment, unknown-price blocking, route potion reserves, trainer risk, loaded eligibility, and exact learning receipts remain unchanged. Later heal upgrades and nonhealing spells keep the cash buffer. Trainer discovery and travel use the shared sliced walking/NPC-travel planner, independent pending work, and live conversation destinations. Discovery preserves spell ranking and completed route proofs across budget waits, with bounded retries and elapsed time. Selection protects the spell price and mandatory supply replenishment beside the total outbound fare; first-heal travel does not restore the generic cash buffer. Execution uses normal movement and registered travel dialogue, verifies each landing and exact payment, and replans after landing within the remaining fare quote. It validates the chosen leg rather than unrelated providers or ordinary ground-speed changes; changed actor capabilities, risk, route geometry, boarding offers, and conversation access still require proof. Temporary blocked tiles can produce a validated detour without repeating a paid leg. An isolated loaded-world check established Edron-to-Ab’Dendriel travel followed by paid Light Healing learning and persistence, not reliable unattended progression. Offensive spells are chosen from those the vocation can cast. Loaded spell rules remain authoritative. Observations may rank legal casts but never weaken safety or legality. Calibration resets when a spell's effect bounds change. | | Rewards and Oracle | Ordinary goal selection; claiming and departure have focused evidence | Claims supported shared container rewards and one legacy doublet case. The tagged Oracle departure path supports a level 8–10 character with no vocation. Scripted quests, levers, hazardous quest transit, and general dialogue reasoning are unsupported. | | Death and restart | Ordinary; failure paths have focused evidence | Uses normal death, save, temple login, and bounded relog recovery. It reconstructs intent from persisted state rather than resuming an interrupted route, conversation, open container, or transaction in place. | | Social behavior | Planned | The bots act independently. Parties, guilds, trading between players, relationships, personalities, and generated login rhythms are not implemented. | @@ -33,7 +33,7 @@ At hunt selection, total carried and bank gold below the cash buffer enables a c XP calibration applies a bounded observed-to-predicted correction locally and to untested areas. Its range allows substantial underestimation to be corrected beyond the former twofold ceiling; it does not relax combat, supply, or route safety checks. -Supply calibration is deliberately asymmetric. One accepted observation updates the policy-global multiplier once, regardless of how many atlas variants planning later scores. Safe downward evidence blends at `0.10` and cannot reduce the multiplier below `0.25`; this transfers useful evidence without letting easy hunts erase the static model. The observed area's final local estimate blends at `0.20`, so it adapts faster and decays toward zero naturally without a sample-count snap. Higher demand raises both applicable estimates immediately. Local estimates are absolute final rates and override the global estimate, which prevents double application. These constants affect supply preference only; static threat, lethal-risk, challenge, emergency reserve, and route checks remain authoritative. +Supply calibration is deliberately asymmetric. One accepted observation updates the policy-global multiplier once, regardless of how many atlas variants planning later scores. Safe downward evidence blends at `0.10` and cannot reduce the multiplier below `0.25`; this transfers useful evidence without letting easy hunts erase the static model. The observed area's final local estimate blends at `0.20`, so it adapts faster and decays toward zero naturally without a sample-count snap. Higher demand raises both applicable estimates immediately. Local estimates are absolute final rates and override the global estimate, which prevents double application. Static and global forecasts retain fractional potion-equivalent demand and round up only the final stock requirement, so calibration does not round a small positive demand up twice. These constants affect supply preference only; static threat, lethal-risk, challenge, emergency reserve, and route checks remain authoritative. Focused scenarios often use fixed destinations, controlled monsters, teleports, or seeded state. Dynamic hunt, route, and target-approach scenarios exercise loaded-world planning, but none proves unattended progression, stable economics, or hundreds-of-bots performance. Plans and temporary gaps belong in the [issue tracker](https://github.com/adrunkhuman/tibia-playerbots-project/issues), not this document. @@ -51,7 +51,13 @@ Hunt departure reuses the selected outbound plan when its origin, actor facts, r Hunt duration is productive time starting at accepted area arrival, not a deadline that includes outbound travel. A separate absolute transit bound supplements the existing stall guards. Detailed forecasts use route terrain and engine movement rounding; a supply-fitting window can shorten the configured productive maximum. Typed budgets include modeled defensive combat during travel, and arrival rechecks current stock and health. Before consumption learning is available, throwing weapons use loaded break chance and attack timing. A non-arrival deadline reports `hunt_arrival_timeout` and failed-trip feedback, applies a bounded variant cooldown, and does not update combat calibration. -Supply recovery uses the same hunt planner and actual loadout, not a special monster list or unarmed fallback. Recovery outings may spend non-health reserve stock when expected demand fits the carried count; zero weapon or ammunition stock remains unusable. Health and return-route safety checks remain in force. Readiness and hunt budgets use the same effective reserve policy. Healing preparation precedes candidate scans; missing resources or exhausted preparation report an explicit failure. Three exhausted candidate scans stop with `hunt_scope_exhausted`, including during supply recovery, rather than retrying indefinitely. Forecasts and stochastic weapon breakage do not guarantee a profitable outing. +When no executable hunt is selected, an otherwise safe outbound, depot-exit, and supplier itinerary can report `needs_supplies`. Its mandatory stock covers the smallest productive window under the current demand model, including transit exposure and return reserves, rather than the full configured hunt. Service funds these floors before optional mana preferences. An unaffordable requirement remains part of supply recovery even when generic safety floors are already met. Stock and economy shortages do not apply danger/unreachable cooldowns. Shopping discards executable hunt routes; selection validates again afterward. Capability, topology, provider, or tool changes discard dependent shortage evidence. Three completed unsuccessful selections returning to the same funds, stock, health, and semantic planning dependencies stop with `hunt_supply_no_progress`; pending searches do not count. Banking does not change combat capability, and temporary supply transfers do not erase shortage or failure evidence. New completed facts permit retry, while returning to a recently failed state retains its attempt count in a bounded history. An additional preparation budget ignores currency and stock identities: only improvement in the requirement gap or first observed regeneration resets attempts. Pure contracts cover this handoff, not sustained live-world progression. + +The [readiness contract](../server/src/playerbotpreparation.h) carries stock or health/regeneration constraints, never an executable hunt route. After detailed route validation, the hunt evaluator can report the minimum higher health needed for a productive recovery outing, or request observation of regeneration when its effect is not yet known. Existing survival policy supplies legal carried recovery options, including mana-assisted learned healing, with actions re-evaluated each turn; goal arbitration selects preparation rather than declaring a known-blocked hunt feasible. Stock preparation uses existing service and supply-income recovery. In-flight route work consumes no failure attempt. A proved actionable requirement is published even when other candidate routes remain unknown. A completed scan with only unresolved routes retains pending readiness but stops after three exhausted scans with `hunt_planning_incomplete`, without treating unknown routes as unreachable or adding danger cooldowns. + +Food preparation uses the existing food predicate and normal NPC offers. It buys one affordable item through the verified service transaction, as a neutral purchase, separately from potion floors and optional top-ups, then verifies consumption through inventory loss and increased regeneration ticks. It does not predict a meal's duration from an item ID. Neutral food trades leave emergency healing enabled. Timeout and interruption observe any pending purchase receipt before retiring the preparation's workflow and route search. Rest requires a stable, reachable non-PZ tile with zero modeled damage; PZ regeneration is never credited. Discovery and execution exclude damage-bearing arcs under the same zero-risk policy, and shared route segments distinguish cost/risk limits. Rest and food discovery cover 32 horizontal tiles and up to two floors from the bot, using normal floor transitions rather than a same-floor filter. Rest discovery retains at most 512 safe endpoints. Provider and rest proofs share eight requests and 16,384 expanded nodes, at most 4,096 per request. Preparation planning uses the shared cooperative budget; admission waits resume arbitration and are not infeasibility. Discovery proofs are synchronous; worst-case dispatcher latency is not established. These conservative limits can miss an available preparation elsewhere. Each preparation has a 60-second deadline and repeated cycles without new health or regeneration evidence stop after three completed attempts. A verified purchase, meal, or health change only triggers a fresh authoritative hunt evaluation; it does not guarantee a viable outing. Combat survival preempts preparation. A loaded-world observation confirmed an upstairs food purchase, verified consumption, non-PZ health restoration, and a fresh productive recovery hunt for a cash-constrained Paladin. That outing ended early on a patrol-route failure; sustained progression is not established. + +Supply recovery uses the same hunt planner and actual loadout, not a special monster list or unarmed fallback. Recovery outings may spend non-health reserve stock when expected demand fits the carried count; zero weapon or ammunition stock remains unusable. Health and return-route safety checks remain in force. Readiness and hunt budgets use the same effective reserve policy. Departure-health preparation precedes candidate scans; supported alternatives return through goal arbitration, while missing resources or exhausted preparation report an explicit failure. Three exhausted candidate scans stop with `hunt_scope_exhausted`, including during supply recovery, rather than retrying indefinitely. Forecasts and stochastic weapon breakage do not guarantee a profitable outing. Depot discovery checks the validated hunt exit before scanning other lockers; liquidation sources remain subject to the normal risk limit. Within a depot, occupied approaches rank last. If its selected approach is occupied nearby or navigation stalls, the bot scans for another locker; a creature that stalls the walk is first waited out within the shared approach bound; a remote liquidation trip waits for its validated source approach. Exact NPC interaction, depot, and spawn patrol approaches exclude floor-change and teleport tiles: passing through a transition does not mean the bot can finish standing there. Depot route validation checks this again against the live tile. These endpoint rules do not prohibit intentional transition traversal. Patrols reuse return-route evidence across complete, same-floor walking paths with no modeled danger; added danger, transitions, route failures, or changed route inputs require revalidation. Rejected patrol plans enter bounded recovery rather than retrying indefinitely. On the way to the hunt area, blocked steps do not end the hunt: the bot retries the leg until it has reached no new tile for 45 seconds of navigation; time spent fighting or looting does not count. An adjacent monster that attacks while it blocks the route is fought through the existing route-critical defense; other blockers are waited out. A proved peak-unsafe patrol endpoint ends the assessed circuit with a bounded variant cooldown rather than treating it as a transient blocker. Inside the hunt area, the bot passes over up to a quarter of the circuit’s waypoints per hunt without removing them; the next failure ends the region with a 10-minute cooldown. A patrol never avoids its own waypoint because the final step to it failed. A validated paid route boards the offer it validated, using the route engine's own walk to that captain; a captain that wandered since planning is followed to its live tiles within the shared approach bound. After a waypoint's trip is validated, later legs toward it, including legs after traversal combat or around a blocked tile, use the ordinary planner under the same risk checks. A leg may board only the validated itinerary's boats, within its fare, while the cached return validation still holds; anything else, or 32 legs, revalidates the whole trip. Depot-bound travel needs enough money for the trip, not for a later potion restock; route safety rules still apply. Supplier travel after reaching the depot retains the restock reserve. @@ -83,7 +89,7 @@ Playerbot telemetry is JSON Lines on server stdout. Every record has this stable Manager lifecycle records identify the affected registered bot and use `related_controller_id` for its preceding controller when available. Failed roster identities may use `player_id: 0`; a failure for one identity does not prevent another bot from starting. -Event-specific fields are conditional. Machine-readable states, actions, results, statuses, and reasons use lowercase values. The main families cover lifecycle and goals, action verification, navigation and NPC travel, hunting and supplies, rewards and equipment, spells, liquidation, and periodic operational summaries. Summaries include `player_state_available`, the current goal, phase, activity, target, inexpensive character resources, and applicable waiting or recovery context. Resource fields are `null` unless the controller can resolve its expected server-owned player identity. Successful movement is not emitted once per tile, but emitted JSONL events are not repeat-suppressed. Completed hunt planning emits every scored candidate once per `planning_pass` and `scoring_revision`; later candidate records carry `candidate_phase: "route_validation"` only when route validation changes that candidate. Hunt candidates report `static`, `global`, or `local` supply estimate sources, the reason, the global multiplier and sample count, the static and applied rates, and any local-capability rejection. Every `hunt_region_outcome` reports observation acceptance and reason, separate global and local update directions, global multiplier and sample counts before and after, capability changes, exposure guards, resource debt, and the resulting local sample count. Selection, failure, cancellation, and stale-revision records carry the same planning attribution. Telemetry does not apply a separate candidate cap, while planner and per-turn work budgets still apply normally. A controller's `terminal` event is final; later manager lifecycle records are outside that terminated controller stream. +Event-specific fields are conditional. Machine-readable states, actions, results, statuses, and reasons use lowercase values. The main families cover lifecycle and goals, action verification, navigation and NPC travel, hunting and supplies, rewards and equipment, spells, liquidation, and periodic operational summaries. Summaries include `player_state_available`, the current goal, phase, activity, target, inexpensive character resources, and applicable waiting or recovery context. Resource fields are `null` unless the controller can resolve its expected server-owned player identity. Successful movement is not emitted once per tile, but emitted JSONL events are not repeat-suppressed. Completed hunt planning emits every scored candidate once per `planning_pass` and `scoring_revision`; later candidate records carry `candidate_phase: "route_validation"` only when route validation changes that candidate. Hunt candidates report `static`, `global`, or `local` supply estimate sources, the reason, the global multiplier and sample count, the static and applied rates, and any local-capability rejection. Every `hunt_region_outcome` reports observation acceptance and reason, separate global and local update directions, global multiplier and sample counts before and after, capability changes, exposure guards, resource debt, and the resulting local sample count. Selection, failure, cancellation, and stale-revision records carry the same planning attribution. Telemetry does not apply a separate candidate cap, while planner and per-turn work budgets still apply normally. `trainer_route` identifies discovery or journey work and reports pending, completed, or exhausted proof, expanded nodes, fare, protected funds, slices, blocked-tile count, and invalidations. Journey invalidation reports its material reason and remaining fare before a bounded retry. Candidate rejection distinguishes supply/risk failure, exhausted proof, and fare-constrained reachability. `route_fare_limited` means eligible registered offers were excluded by the protected-money budget; it does not prove that an unaffordable offer would reach the trainer. `trainer_travel_receipt` reports the observed landing/payment result and remaining fare allowance. A controller's `terminal` event is final; later manager lifecycle records are outside that terminated controller stream. ```powershell docker compose -f server/compose.yaml logs --no-log-prefix --since 30m server | Where-Object { $_ -match '"component":"playerbot"' } diff --git a/docs/testing.md b/docs/testing.md index 4e1ad8f0..14c09f2c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -28,6 +28,8 @@ Useful subsystem switches include `-Healing`, `-ValueLoot` (value replacement pl `-Scenario door_passages` also checks the shared stable-approach admission used by NPC boarding, hunt depot/supply exits, sell-loot source/seller selection, service providers, and depot scans/revalidation. A lower-cost teleport is rejected while an ordinary tile is selected; adding a teleport to the selected tile invalidates it. Floor changes and solid blockers are rejected, occupied ordinary tiles remain eligible, and the movement resolver still permits intentional teleport traversal. The same scenario checks coarse portal danger-sample reuse, unchanged cost/peak results, peak rejection, and tile-dependency invalidation. The same scenario checks first-stage troll, spider, rat, and poison-spider corpse transforms against loaded item definitions: both stages remain walkable and preserve a watched route, while closing a door still invalidates it. This tests loaded engine tiles, not every controller workflow independently. +The Knight scenarios `spell_training_low_supplies` and `spell_training_low_supplies_unaffordable` seed two health potions, meeting the mandatory safety floor. First-heal bootstrap releases only the 100-gold cash buffer, so their computed spell reserve is 0. The affordable case must prioritize Light Healing, pay exactly 170 gold (270→100), and retain both potions. The underfunded case starts at 169 gold (`170 price + 0 mandatory reserve - 1`) and must reject learning with `unaffordable_after_reserves` without a purchase. `pwsh -File scripts/test-playerbot-supply-assertions.ps1` checks these telemetry contracts; `lua scripts/test-playerbot-fixture-isolation.lua` checks the native setup and payment/stock verifier without Docker. These checks do not replace a loaded-world scenario run. + Paladin healing has exact scenarios `paladin_spell_training` (paid learning, casting, and restart), `paladin_healing_economy` (spell choice and reserve), `paladin_mana_healing`, `paladin_healing_fallback`, and `paladin_supply_restock`. They seed Bot One as a Paladin, omit the other three bots, and do not establish sustained progression. `-SpellTraining`, `-SpellUse`, and `-Healing` include the relevant Paladin cases alongside Knight checks. `-Scenario paladin_spear_restock` checks paid restocking and hunt resumption in fresh level-8 and level-16 runs; `paladin_spear_break` checks equipping a backpack spare, ending the hunt at the reserve, and restocking. `equipment_purchase_spear` checks supply recovery from zero spears at startup in both hand orientations with only 39 gold, plus a zero-funds case that must stop after one unsuccessful service rather than loop or hunt unarmed. `paladin_spear_last_break` checks losing the final spear after hunting starts. Funded cases must buy three spears, equip them, and resume hunting without a terminal event. `paladin_spear_recovery_income` starts at 128/185 health with one spear and six gold. It requires a verified departure-healing potion use, a planner-selected stock-fitting recovery hunt, area arrival, combat, and coin looting without a reserve-one interruption. It controls corpse gold, not weapon breakage or sustainable economics. These scenarios use normalized gear and capacity, not the normal seed's capacity budget. `lua scripts/test-playerbot-spear-fixture.lua` checks their setup and verifiers without Docker. @@ -78,6 +80,7 @@ sh server/tests/playerbotrangedposition_contracts.sh sh server/tests/playerbothunttiming_contracts.sh sh server/tests/playerbotdamage_contracts.sh sh server/tests/playerbotrouting_contracts.sh +sh server/tests/playerbottrainer_contracts.sh sh server/tests/playerbotselllootapproach_contracts.sh sh server/tests/playerbotselllootbasket_contracts.sh sh server/tests/playerbotselllootfilter_contracts.sh @@ -99,8 +102,13 @@ lua scripts/test-playerbot-death-fixture.lua lua scripts/test-playerbot-transit-fixture.lua lua scripts/test-playerbot-supply-fixture.lua lua scripts/test-playerbot-coin-estimation.lua +luajit scripts/test-maealil-healing-keywords.lua ``` +The trainer contracts exercise paid-fare plus spell/supply reserve boundaries, sliced transport continuation, independent trainer/hunt/service work slots, world/provider/fund invalidation, turn priority, and the real paid-learning session after a verified travel receipt. They also link the production trainer-arbitration phase with the shared planning budget: after another controller denies preparation admission, both positive and negative trainer decisions are reused and the next admission advances preparation. A real navigation-runtime/path-search contract blocks the direct post-landing step, resumes a four-step sliced detour with the request's exclusions, and verifies that no second fare is authorized. On Linux, use `CPPFLAGS='-I/tmp/angelion-test-includes -I/usr/include/luajit-2.1'` when those development headers are outside the default include paths. These checks do not run NPC dialogue or the loaded world. Existing `-SpellTraining` scenarios still cover local trainer learning; they do not establish Edron-to-mainland trainer travel. + +The Maealil keyword check loads the actual NPC script and keyword handler. It verifies that healing spell names reach their paid offers while the word `heal` still provides free healing and condition removal. Use LuaJIT or Lua 5.4: the existing NPC modules do not load under Lua 5.5. + A zero exit status and each script's explicit pass marker are the pass signal. These checks prove policy, arithmetic, parsing, and fixture contracts; they do not execute an ordinary live world. ### Depot liquidation timing diff --git a/scripts/playerbot-gameplay/assertions-spells.ps1 b/scripts/playerbot-gameplay/assertions-spells.ps1 index ec868eaf..f22b784e 100644 --- a/scripts/playerbot-gameplay/assertions-spells.ps1 +++ b/scripts/playerbot-gameplay/assertions-spells.ps1 @@ -114,12 +114,13 @@ function Assert-LowSupplySpellTrainingEvents { param([string]$Logs, [switch]$Unaffordable) $events = @(ConvertFrom-PlayerbotLogs -Logs $Logs) $candidates = @($events | Where-Object { - $_.event -eq 'spell_candidate' -and $_.spell -eq 'Light Healing' -and $_.price -eq 170 -and $_.reserve -eq 100 + # Stock meets the mandatory floor; first-heal bootstrap releases the cash buffer. + $_.event -eq 'spell_candidate' -and $_.spell -eq 'Light Healing' -and $_.price -eq 170 -and $_.reserve -eq 0 }) $purchases = @($events | Where-Object { $_.event -eq 'action_result' -and $_.action -eq 'learn_spell' }) if ($Unaffordable) { if (@($candidates | Where-Object { $_.result -eq 'rejected' -and $_.reason -eq 'unaffordable_after_reserves' }).Count -lt 1 -or $purchases.Count -ne 0) { - throw 'Exura did not respect the one-gold-short emergency reserve boundary.' + throw 'Exura did not respect the 169-gold boundary (170 price + 0 mandatory reserve - 1).' } return } @@ -127,10 +128,10 @@ function Assert-LowSupplySpellTrainingEvents { $priority = @($events | Where-Object { $_.event -eq 'goal_candidate' -and $_.goal -eq 'learn_spell' -and $_.feasible -and $_.reason -eq 'priority_recovery_spell' }) if (@($candidates | Where-Object { $_.result -eq 'feasible' }).Count -lt 1 -or $selection.Count -ne 1 -or $priority.Count -lt 1 -or $purchases.Count -ne 1 -or - $purchases[0].result -ne 'success' -or $purchases[0].spell -ne 'Light Healing' -or + $purchases[0].result -ne 'success' -or $purchases[0].spell -ne 'Light Healing' -or $purchases[0].price -ne 170 -or $purchases[0].money_before -ne 270 -or $purchases[0].money_after -ne 100 -or $Logs -notmatch 'PLAYERBOT_GAMEPLAY_TEST SPELL_TRAINING_LOW_SUPPLIES_PASS') { - throw 'Low-supply Exura learning did not preserve exact payment, priority, and potion reserves.' + throw 'Low-supply Exura learning did not preserve exact payment, priority, and the potion safety floor with bootstrap reserve 0.' } $firstSelection = @($events | Where-Object { $_.event -eq 'goal_selection' } | Select-Object -First 1) if ($firstSelection.Count -ne 1 -or $firstSelection[0].to_goal -ne 'learn_spell') { diff --git a/scripts/playerbot-gameplay/scenarios-spells.ps1 b/scripts/playerbot-gameplay/scenarios-spells.ps1 index 1a784906..829dc5be 100644 --- a/scripts/playerbot-gameplay/scenarios-spells.ps1 +++ b/scripts/playerbot-gameplay/scenarios-spells.ps1 @@ -54,7 +54,7 @@ $trisha = @($events | Where-Object { $_.event -eq "spell_candidate" -and $_.npc_name -eq "Trisha" -and $_.spell -eq "Light Healing" -and $_.result -eq "rejected" -and $_.reason -eq "unaffordable_after_reserves" -and - $_.price -eq 170 -and $_.reserve -eq 100 -and $_.travel_steps -eq 0 + $_.price -eq 170 -and $_.reserve -eq 0 -and $_.travel_steps -eq 0 }) $nearbyWrongVocation = @($events | Where-Object { $_.event -eq "spell_candidate" -and $_.npc_name -in @("Faluae", "Eroth", "Maealil", "Elathriel") diff --git a/scripts/test-maealil-healing-keywords.lua b/scripts/test-maealil-healing-keywords.lua new file mode 100644 index 00000000..3c4535df --- /dev/null +++ b/scripts/test-maealil-healing-keywords.lua @@ -0,0 +1,146 @@ +-- Run from the repository root: luajit scripts/test-maealil-healing-keywords.lua +-- Lua 5.1/5.4 also work; existing modules.lua is not compatible with Lua 5.5. +-- Load production routing/callbacks; stub only server/player/NPC boundaries. +CONDITION_FIRE, CONDITION_POISON, CONDITION_ENERGY = 1, 2, 3 +CONST_ME_MAGIC_GREEN, CONST_ME_MAGIC_RED = 1, 2 +MESSAGE_GREET, MESSAGE_WALKAWAY, MESSAGE_FAREWELL = 1, 2, 3 +TAG_PLAYERNAME = '|PLAYERNAME|' + +function table.contains(values, expected) + for _, value in ipairs(values) do + if value == expected then return true end + end + return false +end + +local player = { health = 100, conditions = {}, effects = {}, money = 2000, learned = {} } +function Player(cid) + assert(cid == 1) + return player +end +function player:getName() return 'Test Player' end +function player:getCondition(condition) return self.conditions[condition] end +function player:removeCondition(condition) self.conditions[condition] = nil end +function player:getHealth() return self.health end +function player:addHealth(amount) self.health = self.health + amount end +function player:getPosition() + return { sendMagicEffect = function(_, effect) + self.effects[#self.effects + 1] = effect + end } +end +function player:getVocation() + return { getBase = function() return { getId = function() return 3 end } end } +end +function player:hasLearnedSpell(name) return self.learned[name] == true end +function player:canLearnSpell() return true end +function player:isPremium() return true end +function player:removeTotalMoney(amount) + assert(self.money >= amount) + self.money = self.money - amount + return true +end +function player:learnSpell(name) self.learned[name] = true end + +local npc = { addSpellOffer = function() end } +function Npc() return npc end +local handler +NpcHandler = {} +function NpcHandler:new(keywordHandler) + handler = setmetatable({ keywordHandler = keywordHandler }, { __index = self }) + return handler +end +function NpcHandler:isFocused(cid) return cid == 1 end +function NpcHandler:parseMessage(text) return text end +function NpcHandler:say(text, cid) + assert(cid == 1) + self.response = text +end +function NpcHandler:resetNpc(cid) self.keywordHandler:reset(cid) end +function NpcHandler:setMessage() end +function NpcHandler:addModule(module) module:init(self) end +NpcSystem = { parseParameters = function() end } + +dofile('server/data/npc/lib/npcsystem/keywordhandler.lua') +dofile('server/data/npc/lib/npcsystem/modules.lua') +dofile('server/data/npc/lib/npcsystem/customModules.lua') +dofile('server/data/npc/scripts/Maealil.lua') + +local function reset(health, conditions) + handler:resetNpc(1) + handler.response = nil + player.health, player.conditions = health, conditions or {} + player.effects, player.learned, player.money = {}, {}, 2000 +end +local function request(message) + assert(handler.keywordHandler:processMessage(1, message), 'No keyword matched: ' .. message) + return handler.keywordHandler:getLastNode(1) +end + +-- A free-heal condition must not steal a spell request, even before the fallback. +local states = { + { health = 100 }, + { health = 20 }, + { health = 100, condition = CONDITION_FIRE }, + { health = 100, condition = CONDITION_POISON }, + { health = 100, condition = CONDITION_ENERGY }, +} +local spells = { { 'Light Healing', 170 }, { 'Intense Healing', 350 }, { 'Ultimate Healing', 1000 } } +for _, state in ipairs(states) do + for _, spell in ipairs(spells) do + reset(state.health, state.condition and { [state.condition] = true }) + local node = request(spell[1]) + assert(node.parameters.spellName == spell[1], 'Free healing intercepted ' .. spell[1]) + assert(node.callback == StdModule.say) + assert(handler.response == 'Do you want to learn the spell ' .. spell[1] .. ' for ' .. spell[2] .. ' gold?') + assert(player.health == state.health and #player.effects == 0) + if state.condition then assert(player.conditions[state.condition]) end + request('yes') + assert(player.learned[spell[1]] and player.money == 2000 - spell[2]) + assert(handler.response == 'You have learned ' .. spell[1] .. '.') + end +end + +for _, phrase in ipairs({ 'heal', 'please heal me', 'HEAL!', 'please (heal) me' }) do + reset(100) + local node = request(phrase) + assert(not node.parameters.spellName) + assert(handler.response:find("You aren't looking that bad.", 1, true) == 1) + assert(player.health == 100 and #player.effects == 0 and player.money == 2000) + + reset(20) + request(phrase) + assert(handler.response == 'You are hurt, my child. I will heal your wounds.') + assert(player.health == 40 and #player.effects == 1 and player.effects[1] == CONST_ME_MAGIC_GREEN) + + for _, condition in ipairs({ CONDITION_FIRE, CONDITION_POISON, CONDITION_ENERGY }) do + reset(20, { [condition] = true }) + request(phrase) + assert(player.conditions[condition] == nil and player.health == 20) + assert(#player.effects == 1) + assert(player.effects[1] == (condition == CONDITION_POISON and CONST_ME_MAGIC_RED or CONST_ME_MAGIC_GREEN)) + local expected = { + [CONDITION_FIRE] = 'You are burning. Let me quench those flames.', + [CONDITION_POISON] = 'You are poisoned. Let me soothe your pain.', + [CONDITION_ENERGY] = 'You are electrified, my child. Let me help you to stop trembling.', + } + assert(handler.response == expected[condition]) + end +end + +-- Preserve condition priority and the strict health threshold. +reset(20, { [CONDITION_FIRE] = true, [CONDITION_POISON] = true }) +request('heal') +assert(not player.conditions[CONDITION_FIRE] and player.conditions[CONDITION_POISON]) +request('heal') +assert(not player.conditions[CONDITION_POISON] and player.health == 20) +request('heal') +assert(player.health == 40) +request('heal') +assert(handler.response:find("You aren't looking that bad.", 1, true) == 1) + +for _, phrase in ipairs({ 'healing', 'unheal', 'healer', 'prehealpost' }) do + reset(20, { [CONDITION_FIRE] = true }) + assert(not handler.keywordHandler:processMessage(1, phrase), 'Substring matched: ' .. phrase) + assert(handler.response == nil and player.health == 20 and player.conditions[CONDITION_FIRE]) +end +print('PASS Maealil: loaded spell routing/payment, whole-word free healing, fire/poison/energy priority and low-HP threshold') diff --git a/scripts/test-playerbot-fixture-isolation.lua b/scripts/test-playerbot-fixture-isolation.lua index b3524445..d17754e6 100644 --- a/scripts/test-playerbot-fixture-isolation.lua +++ b/scripts/test-playerbot-fixture-isolation.lua @@ -138,11 +138,12 @@ Town = function() return {} end Game = {getExperienceForLevel = function() return 0 end} Condition = function() return {setParameter = function() end} end Npc = function() return {getPosition = function() return Position(1, 2, 7) end} end -F.verifyLowWealth = function() end +dofile('server/tests/playerbot-gameplay/includes/verifiers.inc') local function economicLogin(mode) selectedMode = mode + local spellTraining = mode == 'spell_training_low_supplies' or mode == 'spell_training_low_supplies_unaffordable' local inventory = {[7618] = 7, [F.meatItemId] = 3, [F.saleItemId] = 3} - local storage, spells = {}, {} + local storage, spells = {}, spellTraining and {['Light Healing'] = true, ['Find Person'] = true} or {} local vocation, money, bank, mana, capacity, scheduled = 0, 17, 23, 0, 10000, 0 local backpack = {addItem = function(_, id, amount) inventory[id] = (inventory[id] or 0) + amount @@ -151,7 +152,7 @@ local function economicLogin(mode) local player = { getName = function() return F.botName end, getId = function() return 3 end, - getLevel = function() return mode == 'readiness_low_wealth' and 1 or 20 end, + getLevel = function() return spellTraining and 9 or mode == 'readiness_low_wealth' and 1 or 20 end, getExperience = function() return 0 end, getVocation = function() return {getId = function() return vocation end} end, setVocation = function(_, id) vocation = id; return true end, @@ -163,6 +164,11 @@ local function economicLogin(mode) removeItem = function(_, id, amount) inventory[id] = inventory[id] - amount; return true end, getMoney = function() return money end, removeMoney = function(_, amount) money = money - amount; return true end, + removeTotalMoney = function(_, amount) + local carried = math.min(money, amount) + money, bank = money - carried, bank - (amount - carried) + return true + end, addMoney = function(_, amount) money = money + amount; return true end, getBankBalance = function() return bank end, setBankBalance = function(_, amount) bank = amount; return true end, @@ -187,14 +193,22 @@ local function economicLogin(mode) addCondition = function() return true end, } addEvent = function(callback) - assert(callback == F.verifyLowWealth) + assert(callback == (spellTraining and F.verifyLowSupplySpellTraining or F.verifyLowWealth)) scheduled = scheduled + 1 end assert(F.login.onLogin(player), mode .. ' login failed') assert(F.potionItemId == 7618) assert(storage[50082] == ((mode == 'readiness_low_wealth' or mode == 'magic_training_progression') and 1 or nil)) for _, key in ipairs({50076, 50083, 64120}) do assert(storage[key] == nil) end - if mode == 'readiness_low_wealth' then + if spellTraining then + local underfunded = mode == 'spell_training_low_supplies_unaffordable' + assert(money + bank == (underfunded and 169 or 270), 'incorrect first-heal funding boundary') + assert(vocation == 4 and inventory[7618] == 2 and inventory[F.meatItemId] == 2, + 'first-heal setup changed the mandatory survival stock') + assert(not spells['Light Healing'] and not spells['Find Person']) + assert(scheduled == (underfunded and 0 or 1), 'incorrect first-heal verifier scheduling') + assert(storage[F.fixtureReadyStorage] == 1) + elseif mode == 'readiness_low_wealth' then assert(money == 0 and bank == 56 and inventory[F.saleItemId] == 0) assert(inventory[F.pickupRewardId] == 1 and inventory[7618] == 10 and capacity == 9000 and scheduled == 1) local freeCapacity = capacity - 8000 -- Initial used capacity from the player double. @@ -210,7 +224,8 @@ local function economicLogin(mode) assert(mana == (mode == 'magic_training_reserve' and 39 or 1000)) end end -for _, mode in ipairs({'readiness_low_wealth', 'magic_training_progression', 'magic_training_reserve', 'magic_training_service'}) do +for _, mode in ipairs({'readiness_low_wealth', 'magic_training_progression', 'magic_training_reserve', 'magic_training_service', + 'spell_training_low_supplies', 'spell_training_low_supplies_unaffordable'}) do economicLogin(mode) end -- Exercise the real magic reserve setup, stopping before unrelated level/spell APIs. @@ -256,7 +271,25 @@ for mode in pairs(modes) do end -- Persisted counts must not be repaired, even if empty or below the return threshold. for _, potions in ipairs({0, 1, 2, 7}) do magicReserve("magic_training_restart", true, potions) end -dofile('server/tests/playerbot-gameplay/includes/verifiers.inc') +local function verifyLowSupply(potions, money, bank, learned, utility) + Player = function() return { + isRemoved = function() return false end, + hasLearnedSpell = function(_, name) return name == 'Light Healing' and learned or name == 'Find Person' and utility end, + getMoney = function() return money end, + getBankBalance = function() return bank end, + getItemCount = function(_, id) assert(id == F.healthPotionItemId); return potions end, + } end + return pcall(F.verifyLowSupplySpellTraining, 3, 0) +end +assert(verifyLowSupply(2, 100, 0, true, false)) +assert(verifyLowSupply(2, 77, 23, true, false)) +for _, case in ipairs({ + {1, 100, 0, true, false}, {3, 100, 0, true, false}, + {2, 99, 0, true, false}, {2, 101, 0, true, false}, {2, 100, 1, true, false}, + {2, 270, 0, false, false}, {2, 100, 0, true, true}, +}) do + assert(not verifyLowSupply((table.unpack or unpack)(case)), 'invalid first-heal payment/stock/priority passed') +end local function verifyWealth(money, bank, weapon, cargo) Player = function() return { isRemoved = function() return false end, @@ -287,4 +320,4 @@ assert(verifyCarlin(20, 100, 0)) assert(not verifyCarlin(19, 100, 0)) assert(not verifyCarlin(20, 99, 0)) assert(not verifyCarlin(20, 100, 1)) -print("PASS fixture isolation: " .. count .. " modes, rapier setup/restart, economic login/verification, Carlin 20-potion service receipt, magic reserves/service/restart, other-player guard, failed writes") +print("PASS fixture isolation: " .. count .. " modes, rapier setup/restart, economic login/verification, first-heal 270/169 funding and exact payment/two-potion floor, Carlin 20-potion service receipt, magic reserves/service/restart, other-player guard, failed writes") diff --git a/scripts/test-playerbot-supply-assertions.ps1 b/scripts/test-playerbot-supply-assertions.ps1 index 72f642cb..38ab3d7d 100644 --- a/scripts/test-playerbot-supply-assertions.ps1 +++ b/scripts/test-playerbot-supply-assertions.ps1 @@ -45,24 +45,52 @@ foreach ($mutation in @( Test-Evidence $supply[0..1] $checkSupply $true Test-Evidence $supply $checkSupply +# First-heal bootstrap: two potions meet the floor, so the cash buffer is released. $spell = @( - @{ event = 'spell_candidate'; spell = 'Light Healing'; price = 170; reserve = 100; result = 'feasible' } + @{ event = 'spell_candidate'; spell = 'Light Healing'; price = 170; reserve = 0; result = 'feasible' } @{ event = 'goal_candidate'; goal = 'learn_spell'; feasible = $true; reason = 'priority_recovery_spell' } @{ event = 'goal_selection'; to_goal = 'learn_spell' } - @{ event = 'action_result'; action = 'learn_spell'; result = 'success'; spell = 'Light Healing'; money_before = 270; money_after = 100 } + @{ event = 'action_result'; action = 'learn_spell'; result = 'success'; spell = 'Light Healing'; price = 170; money_before = 270; money_after = 100 } ) $checkSpell = { param($logs) Assert-LowSupplySpellTrainingEvents -Logs $logs } $marker = 'PLAYERBOT_GAMEPLAY_TEST SPELL_TRAINING_LOW_SUPPLIES_PASS' Test-Evidence $spell $checkSpell $false $marker Test-Evidence $spell $checkSpell $true -$spell[3].money_after = 99 -Test-Evidence $spell $checkSpell $true $marker -$spell[3].money_after = 100 +foreach ($case in @( + @{ Index = 0; Field = 'reserve'; Value = 100 } + @{ Index = 0; Field = 'price'; Value = 169 } + @{ Index = 0; Field = 'result'; Value = 'rejected' } + @{ Index = 1; Field = 'feasible'; Value = $false } + @{ Index = 1; Field = 'reason'; Value = 'optional_spell' } + @{ Index = 3; Field = 'money_before'; Value = 269 } + @{ Index = 3; Field = 'money_after'; Value = 99 } + @{ Index = 3; Field = 'price'; Value = 169 } + @{ Index = 3; Field = 'result'; Value = 'failed' } +)) { + $original = $spell[$case.Index][$case.Field] + $spell[$case.Index][$case.Field] = $case.Value + Test-Evidence $spell $checkSpell $true $marker + $spell[$case.Index][$case.Field] = $original +} Test-Evidence (@(@{ event = 'goal_selection'; to_goal = 'buy_equipment' }) + $spell) $checkSpell $true $marker -$rejected = @(@{ event = 'spell_candidate'; spell = 'Light Healing'; price = 170; reserve = 100; result = 'rejected'; reason = 'unaffordable_after_reserves' }) +# Native fixture seeds 169 = 170 price + 0 mandatory reserve - 1. +$rejected = @(@{ event = 'spell_candidate'; spell = 'Light Healing'; price = 170; reserve = 0; result = 'rejected'; reason = 'unaffordable_after_reserves' }) $checkRejected = { param($logs) Assert-LowSupplySpellTrainingEvents -Logs $logs -Unaffordable } Test-Evidence $rejected $checkRejected Test-Evidence ($rejected + $spell[3]) $checkRejected $true +foreach ($case in @( + @{ Field = 'reserve'; Value = 100 } + @{ Field = 'price'; Value = 169 } + @{ Field = 'reason'; Value = 'trainer_unreachable' } + @{ Field = 'result'; Value = 'feasible' } +)) { + $original = $rejected[0][$case.Field] + $rejected[0][$case.Field] = $case.Value + Test-Evidence $rejected $checkRejected $true + $rejected[0][$case.Field] = $original +} +Test-Evidence $spell $checkSpell $false $marker +Test-Evidence $rejected $checkRejected # The complete scored-candidate stream can be much larger than the route queue. # Only candidates marked route_validated consume the selected scan's route budget. diff --git a/server/data/npc/scripts/Maealil.lua b/server/data/npc/scripts/Maealil.lua index 88623707..c1513596 100644 --- a/server/data/npc/scripts/Maealil.lua +++ b/server/data/npc/scripts/Maealil.lua @@ -33,7 +33,7 @@ keywordHandler:addKeyword({'stake'}, StdModule.say, {npcHandler = npcHandler, te -- Healing local function addHealKeyword(text, condition, effect) - keywordHandler:addKeyword({'heal'}, StdModule.say, {npcHandler = npcHandler, text = text}, + keywordHandler:addKeyword({'%f[%a]heal%f[%A]'}, StdModule.say, {npcHandler = npcHandler, text = text}, function(player) return player:getCondition(condition) ~= nil end, function(player) player:removeCondition(condition) @@ -46,7 +46,7 @@ addHealKeyword('You are burning. Let me quench those flames.', CONDITION_FIRE, C addHealKeyword('You are poisoned. Let me soothe your pain.', CONDITION_POISON, CONST_ME_MAGIC_RED) addHealKeyword('You are electrified, my child. Let me help you to stop trembling.', CONDITION_ENERGY, CONST_ME_MAGIC_GREEN) -keywordHandler:addKeyword({'heal'}, StdModule.say, {npcHandler = npcHandler, text = 'You are hurt, my child. I will heal your wounds.'}, +keywordHandler:addKeyword({'%f[%a]heal%f[%A]'}, StdModule.say, {npcHandler = npcHandler, text = 'You are hurt, my child. I will heal your wounds.'}, function(player) return player:getHealth() < 40 end, function(player) local health = player:getHealth() @@ -54,7 +54,7 @@ keywordHandler:addKeyword({'heal'}, StdModule.say, {npcHandler = npcHandler, tex player:getPosition():sendMagicEffect(CONST_ME_MAGIC_GREEN) end ) -keywordHandler:addKeyword({'heal'}, StdModule.say, {npcHandler = npcHandler, text = 'You aren\'t looking that bad. Sorry, I can\'t help you. But if you are looking for additional protection you should go on the {pilgrimage} of ashes or get the protection of the {twist of fate} here.'}) +keywordHandler:addKeyword({'%f[%a]heal%f[%A]'}, StdModule.say, {npcHandler = npcHandler, text = 'You aren\'t looking that bad. Sorry, I can\'t help you. But if you are looking for additional protection you should go on the {pilgrimage} of ashes or get the protection of the {twist of fate} here.'}) -- Basic keywordHandler:addKeyword({'pilgrimage'}, StdModule.say, {npcHandler = npcHandler, text = 'Whenever you receive a lethal wound, your vital force is damaged and there is a chance that you lose some of your equipment. With every single of the five {blessings} you have, this damage and chance of loss will be reduced.'}) diff --git a/server/src/playerbotcombat.cpp b/server/src/playerbotcombat.cpp index d8fb11ad..8390975a 100644 --- a/server/src/playerbotcombat.cpp +++ b/server/src/playerbotcombat.cpp @@ -283,14 +283,17 @@ PlayerBotSurvivalSnapshot PlayerBotController::survivalSnapshot(const Player& pl } } snapshot.canDoAction = player.canDoAction(); - snapshot.buyingPotions = serviceWorkflow.stage() == PlayerBotServiceStage::BuySupplies; - snapshot.lootMovePending = huntCoordinator.hasPendingLootMove(); + snapshot.buyingPotions = serviceWorkflow.buyingPotions(); + snapshot.lootMovePending = huntCoordinator.hasPendingLootMove() || + preparationOption == PlayerBotPreparationOption::BuyFood; // Do not consume a receipt before trade verification. snapshot.progressionActive = progressionRuntime.session().active() != PlayerBotProgressionProcedure::None; snapshot.progressionDeparture = progressionRuntime.session().active(PlayerBotProgressionProcedure::OracleDeparture); snapshot.hunting = turnRouter.cyclePhase() == CyclePhase::Hunt; snapshot.combatActive = turnRouter.scenarioStage() != ScenarioStage::Traverse || huntCoordinator.hasActiveCombat() || const_cast(player).getAttackedCreature() != nullptr; - snapshot.departureHealthTarget = huntDepartureHealth.active() && supplyRecovery.active() && + snapshot.departureHealthTarget = preparationOption == PlayerBotPreparationOption::RestoreHealth && preparationRequirement && + std::chrono::steady_clock::now() < preparationDeadline ? + preparationRequirement->health : huntDepartureHealth.active() && supplyRecovery.active() && !huntCoordinator.huntArrived() ? huntDepartureHealth.target() : 0; snapshot.navigationPending = navigationRuntime.hasPendingWork(); snapshot.healingExhausted = player.hasCondition(CONDITION_EXHAUST_HEAL); @@ -384,8 +387,241 @@ void PlayerBotController::logHealResult(uint16_t itemId, const char* result, con emit("action_result", position, fields.str()); } +bool PlayerBotController::safePreparationRest(Player& player, const Position& position) const +{ + Tile* tile = g_game.map.getTile(position); + return tile && !tile->hasFlag(TILESTATE_PROTECTIONZONE) && playerBotStableApproachTile(tile, player) && + navigationCostPolicy(player).dangerAt(position) == 0; +} + +std::vector PlayerBotController::evaluatePreparation(Player& player, const Position& position) +{ + std::vector options; + preparationEvaluationPending = false; + preparationRest.reset(); + preparationFoodItem = 0; + preparationFoodProvider = 0; + const auto& readiness = huntSupplyHandoff.readiness(); + if (readiness.state != PlayerBotReadiness::Blocked || !readiness.requirement) return options; + const auto& requirement = *readiness.requirement; + if (requirement.kind == PlayerBotRequirementKind::Stock) { + const auto facts = huntSupplyFacts(player); + // The existing service executor validates providers/routes before trade. + // An unaffordable floor instead requests the existing recovery evaluator. + const uint64_t spending = supplyFloorSpendingReserve(player); + const uint64_t floorCost = spending == UINT64_MAX ? spending : spending - carriedGoldReserve; + options.push_back(!supplyRecovery.restockBlocked(facts.funds, facts.stockKey) && + floorCost <= facts.funds ? PlayerBotPreparationOption::ServiceStock : + PlayerBotPreparationOption::RecoveryIncome); + return options; + } + const auto snapshot = survivalSnapshot(player); + options = survivalRuntime.healthPreparations(snapshot, requirement, false, false); + // A hunt callback may already own this controller's slot. Outside it, + // defer arbitration rather than treating budget denial as infeasibility. + auto& budget = playerBotHuntPlanningBudget(); + std::optional charge; + if (!budget.isActive(playerId)) { + const auto admission = budget.request(playerId, PlayerBotPlanningBudget::Clock::now()); + if (!admission.admitted) { + preparationEvaluationPending = true; + schedule(static_cast(std::max(SCHEDULER_MINTICKS, + (admission.wait.count() + 999) / 1000))); + return options; + } + charge.emplace(budget, playerId); + } + PlayerBotPreparationRouteBudget proofs; + PlayerBotNavigationCostPolicy cost = navigationCostPolicy(player); + cost.risk.maximumRouteHealthLoss = cost.risk.maximumHealthLossPerSecond = 0; + const PlayerBotNavigator navigator; + auto prove = [&](const Position& from, const PlayerBotNavigationGoal& goal) -> std::optional { + const uint64_t allowance = proofs.allowance(); + if (allowance == 0) return std::nullopt; + std::deque steps; + uint64_t nodes = 0; + PlayerBotNavigationCostSummary summary; + const auto result = navigator.planFrom(player, from, goal, {}, steps, nodes, allowance, + nullptr, &cost, &summary, false); // Normal stairs, doors, and item-use transitions. + proofs.observed(nodes); + const Position reached = steps.empty() ? from : steps.back().expectedPosition; + if (!playerBotPreparationRouteAccepted(result, summary.dangerCost, summary.maximumHealthLossPerSecond, + 0, goal.reached(reached))) return std::nullopt; + return reached; + }; + const auto restPositions = playerBotPreparationRestPositions(position, [&](const Position& candidate) { + Tile* tile = g_game.map.getTile(candidate); + return tile && !tile->hasFlag(TILESTATE_PROTECTIONZONE) && playerBotStableApproachTile(tile, player) && + cost.dangerAt(candidate) == 0; + }); + if (restPositions.empty()) return options; + preparationRest = prove(position, PlayerBotNavigationGoal::anyOf(restPositions)); + if (!preparationRest) return options; + if ((snapshot.regenerationActive && snapshot.foodTicks > 0) || snapshot.foodClientId != 0) { + return survivalRuntime.healthPreparations(snapshot, requirement, true, false); + } + // The inventory food predicate is the supported set. Lua food.lua remains + // authoritative for use and duration; purchase only one, then observe feed. + for (Npc* npc : playerBotNpcProviders(g_game.getNpcs(), PlayerBotNpcCapability::Shop, position)) { + if (!playerBotPreparationProviderInRange(position, npc->getPosition())) continue; + for (const auto& offer : npc->getShopOffers()) { + if (!PlayerBotInventoryPolicy::isFoodItem(offer.itemId) || offer.buyPrice == 0 || + offer.buyPrice > player.getMoney() + player.getBankBalance() || + Item::items[offer.itemId].weight > player.getFreeCapacity()) continue; + std::vector approaches; + for (int dx = -3; dx <= 3; ++dx) for (int dy = -3; dy <= 3; ++dy) { + Position approach(npc->getPosition().x + dx, npc->getPosition().y + dy, npc->getPosition().z); + if (playerBotStableApproachTile(g_game.map.getTile(approach), player)) approaches.push_back(approach); + } + if (approaches.empty()) continue; + if (proofs.allowance() == 0) return options; + const auto approach = prove(position, PlayerBotNavigationGoal::anyOf(approaches)); + if (!approach || !prove(*approach, PlayerBotNavigationGoal::exact(*preparationRest))) continue; + preparationFoodItem = offer.itemId; + preparationFoodProvider = npc->getID(); + return survivalRuntime.healthPreparations(snapshot, requirement, true, true); + } + } + return options; +} + +void PlayerBotController::retirePreparationService(Player* player, const Position& position, const char* reason) +{ + if (serviceWorkflow.intent() != PlayerBotServiceIntent::PreparationPurchase) return; + PlayerBotServiceObservation receipt; + if (const auto* pending = serviceWorkflow.pendingPreparationPurchase(); pending && player) { + receipt.inventoryCounts[pending->itemId] = inventoryPolicy.inventoryItemCount(*player, pending->itemId); + receipt.money = player->getMoney(); + receipt.bankBalance = player->getBankBalance(); + } + const auto result = serviceWorkflow.retirePreparationPurchase(receipt); + if (result.verification) { + const auto& before = result.verification->before; + emit("action_result", position, "\"action\":\"buy_food\",\"result\":" + + jsonString(result.verification->result == PlayerBotServiceVerificationResult::Success ? "success" : "failed") + + ",\"reason\":" + jsonString(reason) + ",\"item_id\":" + std::to_string(before.itemId) + + ",\"count\":" + std::to_string(before.amount) + ",\"carried_before\":" + std::to_string(before.money) + + ",\"carried_after\":" + std::to_string(receipt.money) + ",\"bank_before\":" + + std::to_string(before.balance) + ",\"bank_after\":" + std::to_string(receipt.bankBalance)); + } + serviceRouteSearch.reset(); + auto& budget = playerBotHuntPlanningBudget(); + if (!budget.isActive(playerId)) budget.cancel(playerId, PlayerBotPlanningBudget::Clock::now()); +} + +void PlayerBotController::retirePreparation(Player* player, const Position& position, const char* reason) +{ + if (!preparationEvaluationPending && preparationOption == PlayerBotPreparationOption::None && + serviceWorkflow.intent() != PlayerBotServiceIntent::PreparationPurchase) return; + retirePreparationService(player, position, reason); + serviceRouteSearch.reset(); + auto& budget = playerBotHuntPlanningBudget(); + if (!budget.isActive(playerId)) budget.cancel(playerId, PlayerBotPlanningBudget::Clock::now()); + preparationEvaluationPending = false; + preparationOption = PlayerBotPreparationOption::None; + preparationRequirement.reset(); + preparationRest.reset(); + preparationFoodItem = 0; + preparationFoodProvider = 0; + resetNavigation(); +} + +void PlayerBotController::finishPreparation(Player& player, const Position& position, const char* reason) +{ + if (!turnRouter.running() || telemetry.terminalLogged()) return; + const auto snapshot = survivalSnapshot(player); + const auto requirement = preparationRequirement; + uint64_t gap = requirement ? requirement->health - std::min(requirement->health, std::max(0, player.getHealth())) : 0; + gap += requirement && requirement->observeRegeneration && (!snapshot.regenerationActive || snapshot.foodTicks <= 0) ? 1 : 0; + // Track actual health improvement, not a moving request target. Reaching a + // higher successive target must count even when both completed gaps are zero. + const uint64_t healthGap = std::max(0, player.getMaxHealth() - player.getHealth()); + if (requirement && !requirement->observeRegeneration && player.getHealth() >= static_cast(requirement->health)) { + emit("action_result", position, "\"action\":\"hunt_departure_health\",\"result\":\"ready\",\"health\":" + + std::to_string(player.getHealth()) + ",\"required_health\":" + std::to_string(requirement->health)); + } + const bool exhausted = preparationBudget.completed(healthGap, + snapshot.regenerationActive && snapshot.foodTicks > 0 && !snapshot.protectionZone && + safePreparationRest(player, position)); + emit("goal_result", position, std::string("\"goal\":\"prepare\",\"reason\":") + jsonString(reason) + + ",\"remaining_gap\":" + std::to_string(gap)); + retirePreparation(&player, position, reason); + if (exhausted) { stop("preparation_no_progress", position); return; } + huntSupplyHandoff.replan(); + // Only a fresh authoritative hunt evaluation can authorize departure. + startHunt(&player, position, "preparation_observed"); +} + +void PlayerBotController::processPreparation(Player* player, const Position& position) +{ + if (preparationEvaluationPending) { + if (selectTopLevelGoal(*player, position, "preparation_budget_retry") && + !trainerDiscovery && !preparationEvaluationPending) schedule(SCHEDULER_MINTICKS); + return; + } + if (std::chrono::steady_clock::now() >= preparationDeadline) { + finishPreparation(*player, position, "preparation_timeout"); + return; + } + if (preparationOption == PlayerBotPreparationOption::BuyFood) { + processService(player, position); + return; + } + const auto snapshot = survivalSnapshot(*player); + if (!preparationRequirement || survivalRuntime.healthPreparationSatisfied(snapshot, *preparationRequirement, + safePreparationRest(*player, position))) { + finishPreparation(*player, position, "requirement_observed"); + return; + } + if (preparationOption == PlayerBotPreparationOption::RestoreHealth) { + // Peaceful target is supplied to the existing survival evaluator. It owns + // spell/potion legality, preference, action delay, and effect verification. + if (!handleHealing(player, position) && preparationOption != PlayerBotPreparationOption::None) + finishPreparation(*player, position, "recovery_unavailable"); + else schedule(1000); + return; + } + if (!preparationRest || !safePreparationRest(*player, *preparationRest)) { + finishPreparation(*player, position, "rest_invalidated"); + return; + } + if (position != *preparationRest) { + PlayerBotNavigationRuntimeOutcome result; + PlayerBotNavigationRiskProfile safe = riskProfile; + safe.maximumRouteHealthLoss = safe.maximumHealthLossPerSecond = 0; + auto& budget = playerBotHuntPlanningBudget(); + const auto admission = budget.request(playerId, PlayerBotPlanningBudget::Clock::now()); + if (!admission.admitted) { + schedule(static_cast(std::max(SCHEDULER_MINTICKS, + (admission.wait.count() + 999) / 1000))); + return; + } + PlayerBotPlanningBudget::Charge charge(budget, playerId); + processNavigation(player, position, *preparationRest, &result, 4096, false, &safe); + if (telemetry.terminalLogged()) return; + if (result.fixedTargetRouteFailures >= maximumProgressionAttempts) + finishPreparation(*player, position, "rest_unreachable"); + return; + } + if (!snapshot.regenerationActive && snapshot.foodClientId == 0) { + finishPreparation(*player, position, "regeneration_unavailable"); + return; + } + schedule(1000); +} + bool PlayerBotController::prepareHuntDeparture(Player* player, const Position& position) { + const uint32_t floor = supplyRecovery.active() ? + playerBotSupplyRecoveryRequiredHealth(std::max(0, player->getMaxHealth())) : 0; + if (player->getHealth() < static_cast(floor) && preparationOption == PlayerBotPreparationOption::None) { + PlayerBotPreparationRequirement requirement; + requirement.kind = PlayerBotRequirementKind::HealthRegeneration; + requirement.health = floor; + huntSupplyHandoff.blocked({PlayerBotReadiness::Blocked, requirement}, huntSupplyFacts(*player)); + selectTopLevelGoal(*player, position, "departure_health_requirement"); + return false; + } const bool wasActive = huntDepartureHealth.active(); const uint32_t target = supplyRecovery.active() ? playerBotSupplyRecoveryRequiredHealth(std::max(0, player->getMaxHealth())) : 0; @@ -431,6 +667,14 @@ bool PlayerBotController::handleHealing(Player* player, const Position& currentP if (command.type == PlayerBotSurvivalCommandType::CastSpell) return dispatchSpellCommand(*player, currentPosition, command); if (command.type == PlayerBotSurvivalCommandType::Wait) return true; if (command.type == PlayerBotSurvivalCommandType::InterruptForService) { + if (preparationOption != PlayerBotPreparationOption::None && !snapshot.combatActive && + static_cast(snapshot.health) * 100 > static_cast(snapshot.healthMaximum) * 30) { + if (preparationOption == PlayerBotPreparationOption::RestoreHealth) { + finishPreparation(*player, currentPosition, "recovery_unavailable"); + return true; + } + return false; + } if (huntDepartureHealth.active()) { if (snapshot.regenerationActive) return true; // Bounded by the preparation deadline. stop("hunt_departure_healing_unavailable", currentPosition); @@ -1490,6 +1734,17 @@ void PlayerBotController::finishHuntRegion(const Player& player, const Position& if (Player* speakingPlayer = g_game.getPlayerByID(playerId)) say(*speakingPlayer, "Leaving hunt: " + std::string(reason) + ". " + std::to_string(combat.kills) + " kills, " + std::to_string(completion->experienceGained) + " experience."); } +PlayerBotHuntSupplyFacts PlayerBotController::huntSupplyFacts(Player& player) const +{ + auto dependencies = huntReturnCoverageContext(player, Position{}); + // Shopping changes the return destination/fare, not these dependencies. + dependencies.depotDestination = Position{}; + dependencies.fare = 0; + return {playerBotSupplyCapability(huntPlanningFacts(player, huntCombatProfile(player))), dependencies, + PlayerBotHuntRegionPlanner::getCacheRevision(), player.getMoney() + player.getBankBalance(), + playerBotSupplyStockKey(supplyStocks(player)), player.getHealth()}; +} + bool PlayerBotController::selectHuntRegion(Player& player, const Position& position, const char* reason, std::chrono::steady_clock::duration* retryAfter) { @@ -1949,13 +2204,58 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit if (!safeSelection) { huntRouteRetention.clear(); selectedHuntRoute.reset(); - sliceResult = "failed"; - emit("hunt_region_selection", position, - "\"result\":\"failed\",\"reason\":\"no_safe_route_candidate\",\"route_rejection_counts\":" + - routeFailureCounts() + "," + planningAttribution(routeResult.planningPass, routeResult.scoringRevision)); + sliceResult = routeResult.outcome == PlayerBotHuntRouteOutcome::Incomplete ? "incomplete" : "failed"; + if (routeResult.outcome != PlayerBotHuntRouteOutcome::NeedsSupplies && + routeResult.outcome != PlayerBotHuntRouteOutcome::NeedsPreparation) + emit("hunt_region_selection", position, + std::string(routeResult.outcome == PlayerBotHuntRouteOutcome::Incomplete ? + "\"result\":\"incomplete\",\"reason\":\"planning_incomplete\"" : + "\"result\":\"failed\",\"reason\":\"no_safe_route_candidate\"") + ",\"route_rejection_counts\":" + + routeFailureCounts() + "," + planningAttribution(routeResult.planningPass, routeResult.scoringRevision)); huntCoordinator.completePlanningSelection(); - // Rejected variants stay on a 10-minute cooldown, so a prompt rescan - // repeats the same failure and its full candidate log. + const auto facts = huntSupplyFacts(player); + huntSupplyHandoff.blocked(routeResult.readiness, facts); + bool preparationExhausted = false; + if (routeResult.outcome != PlayerBotHuntRouteOutcome::Pending && + routeResult.outcome != PlayerBotHuntRouteOutcome::Incomplete && + (!routeResult.readiness.requirement || routeResult.readiness.requirement->kind == PlayerBotRequirementKind::Stock)) { + uint64_t gap = 0; + for (const auto& stock : supplyStocks(player)) { + const uint32_t floor = std::max(stock.rule.safetyFloor, stock.rule.returnThreshold + 1); + gap += floor - std::min(floor, stock.count); + } + preparationExhausted = preparationBudget.completed(gap, false, PlayerBotRequirementKind::Stock); + } + if (huntSupplyHandoff.noProgress(routeResult.outcome, facts) || preparationExhausted) { + stop(routeResult.outcome == PlayerBotHuntRouteOutcome::Incomplete ? + "hunt_planning_incomplete" : "hunt_supply_no_progress", position); + return false; + } + if (routeResult.outcome == PlayerBotHuntRouteOutcome::NeedsSupplies && routeResult.supplyShortage) { + huntSupplyHandoff.require(std::move(routeResult.supplyShortage->requirements), facts); + std::ostringstream requirements; + requirements << '['; + bool first = true; + for (const auto& requirement : huntSupplyHandoff.requirements()) { + if (!first) requirements << ','; + first = false; + requirements << "{\"kind\":" << jsonString(playerBotSupplyKindName(requirement.kind)) + << ",\"required_stock\":" << requirement.count << '}'; + } + requirements << ']'; + emit("hunt_region_selection", position, + "\"result\":\"needs_supplies\",\"reason\":\"hunt_supply_shortage\",\"atlas_variant_id\":" + + std::to_string(routeResult.supplyShortage->atlasVariantId) + ",\"requirements\":" + + requirements.str() + ",\"route_rejection_counts\":" + routeFailureCounts() + "," + + planningAttribution(routeResult.planningPass, routeResult.scoringRevision)); + } + if (routeResult.readiness.requirement) { + selectTopLevelGoal(player, position, "hunt_blocked_requirement"); + if (retryAfter) *retryAfter = std::chrono::milliseconds(navigationInterval); + return false; + } + // No supported requirement: a completed recovery/selection pass without progress is bounded; + // unfinished route work above never consumes this retry allowance. if (retryAfter) *retryAfter = std::chrono::seconds(30); return false; } @@ -1964,6 +2264,9 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit huntPatrolTrip.reset(); huntTransitProgress.reset(); PlayerBotHuntRegion selected = std::move(*safeSelection); + preparationBudget.reset(); + huntSupplyHandoff.selected(selected.supplyRecovery && + playerBotMandatorySupplyDeficit(supplyStocks(player), false).missing != 0); selectedHuntRoute = huntRouteRetention.take(selected); if (selectedHuntRoute) { selectedHuntRoute->revision = selected.atlasRevision; @@ -2086,6 +2389,7 @@ void PlayerBotController::startHunt(Player* player, const Position& position, co } std::chrono::steady_clock::duration retryAfter{}; if (!selectHuntRegion(*player, position, "hunt_started", &retryAfter)) { + if (telemetry.terminalLogged()) return; const int64_t delay = std::chrono::duration_cast(retryAfter).count(); schedule(static_cast(delay > 0 ? delay : SCHEDULER_MINTICKS)); return; @@ -2200,17 +2504,21 @@ void PlayerBotController::processTraversal(Player* player, const Position& curre schedule(navigationInterval); return; } + interruptTrainerRouteWork(); processDefensiveCombat(player, currentPosition); return; } if (attackDefensiveThreat(player, currentPosition)) { + interruptTrainerRouteWork(); schedule(navigationInterval); return; } PlayerBotTurnObservation turn; + turn.trainerDiscoveryActive = trainerGoalSelectionPending || trainerDiscovery.has_value(); turn.progressionActive = progressionRuntime.session().active() != PlayerBotProgressionProcedure::None; - turn.magicTrainingActive = progressionRuntime.activeGoal() == TopLevelGoal::MagicTraining; - if (!turn.progressionActive && !turn.magicTrainingActive) { + turn.magicTrainingActive = !preparationEvaluationPending && progressionRuntime.activeGoal() == TopLevelGoal::MagicTraining; + turn.preparationActive = preparationEvaluationPending || preparationOption != PlayerBotPreparationOption::None; + if (!turn.trainerDiscoveryActive && !turn.progressionActive && !turn.magicTrainingActive && !turn.preparationActive) { const bool inHuntPhase = turnRouter.cyclePhase() == CyclePhase::Hunt; const PlayerBotHuntTurnObservation hunt = huntCoordinator.observeTurn( inHuntPhase, fixtureDriver.huntObservation().selectRegion, std::chrono::steady_clock::now()); @@ -2224,10 +2532,19 @@ void PlayerBotController::processTraversal(Player* player, const Position& curre // same controller's budget slot. if (turnCommand != PlayerBotTurnCommand::Hunt) patrolSearchGuard.cleanup(); + if (turnCommand == PlayerBotTurnCommand::TrainerDiscovery) { + if (selectTopLevelGoal(*player, currentPosition, "trainer_scan_continuation") && + !trainerDiscovery && !preparationEvaluationPending) schedule(SCHEDULER_MINTICKS); + return; + } if (turnCommand == PlayerBotTurnCommand::Progression) { processProgression(player, currentPosition); return; } + if (turnCommand == PlayerBotTurnCommand::Prepare) { + processPreparation(player, currentPosition); + return; + } if (turnCommand == PlayerBotTurnCommand::MagicTraining) { processMagicTraining(*player, currentPosition); return; @@ -2245,6 +2562,7 @@ void PlayerBotController::processTraversal(Player* player, const Position& curre if (selectHuntRegion(*player, currentPosition, "hunt_planning", &retryAfter)) { beginHuntCycle(player, currentPosition, "hunt_region_selected"); } else { + if (telemetry.terminalLogged()) return; const int64_t delay = std::chrono::duration_cast(retryAfter).count(); schedule(static_cast(delay > 0 ? delay : SCHEDULER_MINTICKS)); } diff --git a/server/src/playerbotcontroller.cpp b/server/src/playerbotcontroller.cpp index 5f6a5d62..460d18e0 100644 --- a/server/src/playerbotcontroller.cpp +++ b/server/src/playerbotcontroller.cpp @@ -465,6 +465,8 @@ void PlayerBotController::stop(const char* reason, const Position& position) return; } + clearTrainerWork(); + retirePreparation(g_game.getPlayerByID(playerId), position, reason); emitDepotDiscovery(playerbot::PlayerBotDepotTelemetry::Result::Cancelled, position, reason); cancelHuntPlanning(reason, position); const bool wasRunning = turnRouter.running(); @@ -489,6 +491,15 @@ void PlayerBotController::stop(const char* reason, const Position& position) void PlayerBotController::pause(const Position& position) { if (!turnRouter.running()) return; + const bool trainerInterrupted = trainerDiscovery.has_value() || trainerArbitration.completed() || trainerGoalSelectionPending || + progressionRuntime.session().active(PlayerBotProgressionProcedure::LearnSpell); + clearTrainerWork(); + if (trainerInterrupted) { + progressionRuntime.finish(); + resetNavigation(); + trainerGoalSelectionPending = true; // Resume at arbitration, never a stale paid leg. + } + retirePreparation(g_game.getPlayerByID(playerId), position, "paused"); PlayerBotPlanningBudget& budget = playerBotHuntPlanningBudget(); if (!budget.isActive(playerId)) budget.cancel(playerId, PlayerBotPlanningBudget::Clock::now()); const char* previous = turnRouter.stateName(); @@ -509,6 +520,7 @@ bool PlayerBotController::findPath(Player* player, const Position& target, std:: void PlayerBotController::resetNavigation() { navigationRuntime.reset(); + navigationPlanningRisk.reset(); rangedMovement.clear(); rangedMovement.clearBlockedPositions(); huntPatrolValidationDestination.reset(); @@ -647,11 +659,27 @@ bool PlayerBotController::executeNavigationStep(Player* player, const PlayerBotN std::to_string(huntTravelRecoveryFundsReserve(*player, huntTravelBudgetPhase))); return false; } + const bool trainerTravel = progressionRuntime.session().active(PlayerBotProgressionProcedure::LearnSpell); + const uint64_t moneyBefore = player->getMoney() + player->getBankBalance(); npc->receiveSpeech(player, TALKTYPE_PRIVATE_PN, "hi"); for (const std::string& phrase : step.dialogue) { npc->receiveSpeech(player, TALKTYPE_PRIVATE_PN, phrase); } const bool travelled = player->getPosition() == step.expectedPosition; + const uint64_t moneyAfter = player->getMoney() + player->getBankBalance(); + if (trainerTravel) { + const bool receipt = playerBotTrainerTravelReceipt(travelled, moneyBefore, moneyAfter, step.price); + trainerTravelReceiptFailed = !receipt; + if (receipt) trainerFareRemaining -= step.price; // Admission checked the complete remaining quote. + trainerJourneyNeedsRoute = true; + if (trainerJourney) trainerJourney->evidence.reset(); + emit("trainer_travel_receipt", player->getPosition(), + "\"result\":" + jsonString(receipt ? "verified" : "failed") + + ",\"fare\":" + std::to_string(step.price) + + ",\"remaining_fare\":" + std::to_string(trainerFareRemaining) + + ",\"money_before\":" + std::to_string(moneyBefore) + + ",\"money_after\":" + std::to_string(moneyAfter)); + } if (!travelled) { unavailableTravelOffers[{step.npcId, step.expectedPosition}] = std::chrono::steady_clock::now() + std::chrono::minutes(5); @@ -838,15 +866,19 @@ PlayerBotNavigationRoutePlan PlayerBotController::planCompleteNavigationRoute( PlayerBotNavigationRoutePlan PlayerBotController::planNavigationRoute(Player& player, const PlayerBotNavigationGoal& goal, const std::set& blockedPositions, uint64_t maximumExpandedNodes, - bool sameFloorOnly) const + bool sameFloorOnly, + const PlayerBotNavigationRiskProfile* risk) const { PlayerBotNavigationRoutePlan routePlan; routePlan.metrics.attempted = true; const auto startedAt = std::chrono::steady_clock::now(); const PlayerBotNavigator navigator; - const PlayerBotNavigationCostPolicy costPolicy = navigationCostPolicy(player); + PlayerBotNavigationCostPolicy costPolicy = navigationCostPolicy(player); + if (risk) costPolicy.risk = *risk; PlayerBotNavigationCostSummary costSummary; - if (sameFloorOnly || goal.type != PlayerBotNavigationGoalType::Exact) { + // A bounded zero-risk local goal uses the complete normal movement graph, + // with no paid/coarse prefix that could differ from the discovery proof. + if ((risk && risk->requiresZeroRisk()) || sameFloorOnly || goal.type != PlayerBotNavigationGoalType::Exact) { routePlan.metrics.waypoint = goal.representative(); routePlan.metrics.result = navigator.plan(player, goal, blockedPositions, routePlan.steps, routePlan.metrics.expandedNodes, maximumExpandedNodes, @@ -1357,6 +1389,13 @@ uint64_t PlayerBotController::huntTravelRecoveryFundsReserve( bool PlayerBotController::huntTravelFareAffordable( const Player& player, uint64_t fare, HuntTravelBudgetPhase phase) const { + if (progressionRuntime.session().active(PlayerBotProgressionProcedure::LearnSpell)) { + const auto& training = progressionRuntime.spellTraining().plan(); + const auto* descriptor = playerBotSpellDescriptor(training.spellName.c_str()); + const bool healing = descriptor && descriptor->role == PlayerBotSpellRole::Healing; + return playerBotTrainerFareAffordable(player.getMoney() + player.getBankBalance(), + std::max(training.reserve, spellTrainingReserve(player, healing)), training.price, trainerFareRemaining, fare); + } return playerBotHuntTravelPaymentAffordable(player.getMoney() + player.getBankBalance(), huntTravelRecoveryFundsReserve(player, phase), fare, huntExitFareReserve, phase); @@ -1566,6 +1605,12 @@ bool PlayerBotController::processNavigation(Player* player, const Position& curr { const Position destination = goal.representative(); const auto now = std::chrono::steady_clock::now(); + if (allowRoutePlanning) { + const auto& requestedRisk = risk ? *risk : riskProfile; + if ((navigationPlanningRisk && *navigationPlanningRisk != requestedRisk) || + (!navigationPlanningRisk && risk && risk->requiresZeroRisk())) resetNavigation(); + navigationPlanningRisk = requestedRisk; + } const PlayerBotNavigationRuntimeTiming timing = { now, navigationStepTimeout, navigationBlockSuppression, navigationOscillationSuppression, }; @@ -1600,7 +1645,7 @@ bool PlayerBotController::processNavigation(Player* player, const Position& curr routePlan.steps.push_back({PlayerBotNavigationAction::Move, DIRECTION_NONE, currentPosition, currentPosition}); } else { routePlan = planNavigationRoute(*player, outcome.routeRequest->goal, blockedPositions, - std::min(fixturePlan.maximumExpandedNodes, routeNodeBudget), sameFloorOnly); + std::min(fixturePlan.maximumExpandedNodes, routeNodeBudget), sameFloorOnly, risk); } const PlayerBotPendingMovementResult movementResult = outcome.movementResult; const std::optional failedMovementTarget = outcome.failedMovementTarget; @@ -1846,6 +1891,16 @@ bool PlayerBotController::processNavigation(Player* player, const Position& curr const Npc* captain = g_game.getNpcByID(resolvedStep->npcId); if (captain && captain->getPosition().z == currentPosition.z && !Position::areInRange<3, 3, 0>(captain->getPosition(), currentPosition)) { + if (progressionRuntime.session().active(PlayerBotProgressionProcedure::LearnSpell)) { + // Trainer boarding never escapes its quoted, risk-validated sliced + // route through the generic synchronous captain-follow fallback. + ++trainerJourneyRestarts; + trainerJourneyNeedsRoute = true; + trainerRouteSearch.reset(); + resetNavigation(); + schedule(routeSearchContinuationInterval); + return false; + } if (boardingApproachNpcId != resolvedStep->npcId) { boardingApproach.reset(); boardingApproachNpcId = resolvedStep->npcId; diff --git a/server/src/playerbotcontroller.h b/server/src/playerbotcontroller.h index d3ac8b0a..9716048e 100644 --- a/server/src/playerbotcontroller.h +++ b/server/src/playerbotcontroller.h @@ -14,6 +14,7 @@ // Internal controller contract shared by the responsibility-specific playerbot implementation units. #include "playerbotapproach.h" +#include "playerbottrainerroute.h" #include "playerbotrangedposition.h" #include "playerbot.h" #include "playerbothuntcoordinator.h" @@ -34,6 +35,7 @@ #include "playerbotprogressionplanners.h" #include "playerbotsurvivalruntime.h" #include "playerbotsupplyrecovery.h" +#include "playerbothuntsupplyhandoff.h" #include "playerbottestpolicy.h" #include "playerbotfixturedriver.h" #include "playerbottelemetry.h" @@ -251,6 +253,12 @@ class PlayerBotController : public std::enable_shared_from_this evaluatePreparation(Player& player, const Position& position); + bool safePreparationRest(Player& player, const Position& position) const; + void processPreparation(Player* player, const Position& position); + void finishPreparation(Player& player, const Position& position, const char* reason); + void retirePreparationService(Player* player, const Position& position, const char* reason); + void retirePreparation(Player* player, const Position& position, const char* reason); playerbot::PlayerBotTelemetrySummary telemetrySummary() const; @@ -347,6 +355,7 @@ class PlayerBotController : public std::enable_shared_from_this learningPriority = std::nullopt) const; - bool findSpellTraining(Player& player, const Position& position, PlayerBotSpellTrainingPlan& plan, - std::deque& steps); - void beginSpellTraining(Player& player, const Position& position, PlayerBotSpellTrainingPlan plan, - std::deque steps); + std::optional learningPriority = std::nullopt, uint64_t fare = 0) const; + bool findSpellTraining(Player& player, const Position& position, PlayerBotSpellTrainingPlan& plan); + void beginSpellTraining(Player& player, const Position& position, PlayerBotSpellTrainingPlan plan); void finishSpellTraining(Player* player, const Position& position, const char* result, const char* reason); void processSpellTraining(Player* player, const Position& currentPosition); + std::string trainerDecisionFacts(Player& player, const Position& position, bool coolingDown); + void clearTrainerWork(); + void interruptTrainerRouteWork(); + std::optional advanceTrainerRoute(Player& player, + const std::vector& destinations, uint64_t reserve, + std::shared_ptr& evidence); bool processMagicTraining(Player& player, const Position& position); void finishMagicTraining(Player& player, const Position& position, const char* result, const char* reason); @@ -448,7 +461,8 @@ class PlayerBotController : public std::enable_shared_from_this& blockedPositions = {}, uint64_t maximumExpandedNodes = playerBotNavigationMaximumExpandedNodes, - bool sameFloorOnly = false) const; + bool sameFloorOnly = false, + const PlayerBotNavigationRiskProfile* risk = nullptr) const; // Plans the next leg of the validated patrol trip with the ordinary planner. // Returns nullptr once the leg is installed, otherwise why the trip needs // full revalidation. @@ -471,10 +485,12 @@ class PlayerBotController : public std::enable_shared_from_this advanceHuntTravelRoute(Player& player, const PlayerBotHuntRouteRequest& request, const Position& source, PlayerBotHuntRouteTiming& timing, uint64_t transportReserve, bool sellEconomy, PlayerBotNavigationRoutePlan* walkingAlternative = nullptr, - std::shared_ptr* completedEvidence = nullptr); + std::shared_ptr* completedEvidence = nullptr, + const PlayerBotNavigationRiskProfile* risk = nullptr); bool huntTravelEvidenceValid(Player& player, const Position& source, const Position& destination, PlayerBotHuntTravelEvidence& evidence); bool huntTravelExitValid(Player& player, const PlayerBotHuntTravelEvidence& evidence) const; + const char* trainerTravelExecutionChange(Player& player, PlayerBotHuntTravelEvidence& evidence); PlayerBotNavigationRoutePlan planHuntTravelRoute(Player& player, const Position& source, const Position& destination, const std::set& blockedPositions = {}, @@ -600,6 +616,16 @@ class PlayerBotController : public std::enable_shared_from_this navigationPlanningRisk; + PlayerBotHuntSupplyHandoff huntSupplyHandoff; + PlayerBotPreparationBudget preparationBudget; + bool preparationEvaluationPending = false; + PlayerBotPreparationOption preparationOption = PlayerBotPreparationOption::None; + std::optional preparationRequirement; + std::optional preparationRest; + uint16_t preparationFoodItem = 0; + uint32_t preparationFoodProvider = 0; + std::chrono::steady_clock::time_point preparationDeadline; PlayerBotDepotWorkflow depotWorkflow; playerbot::PlayerBotDepotTelemetry depotDiscoveryTelemetry; int32_t lastDepotDiscoveryHealth = 0; @@ -710,6 +736,37 @@ class PlayerBotController : public std::enable_shared_from_this serviceRouteSearch; uint64_t serviceRouteSerial = 0; + // Trainer discovery and execution never borrow service/hunt frontiers. + struct TrainerRouteSearch : ServiceRouteSearch { + uint64_t expandedNodes = 0; + std::set blockedPositions; + }; + std::optional trainerRouteSearch; + uint64_t trainerRouteSerial = 0; + bool trainerGoalSelectionPending = false; + PlayerBotTrainerArbitration trainerArbitration; + PlayerBotRouteChanges::Watch trainerDecisionWatch; + struct TrainerRoute { + PlayerBotNavigationRoutePlan plan; + std::shared_ptr evidence; + std::vector destinations; + }; + struct TrainerDiscovery { + Position origin; + uint64_t money = 0, reserve = 0, healingReserve = 0; + uint32_t potionCount = 0; + size_t next = 0; + PlayerBotTrainerScanProgress progress; + std::vector offers; + std::vector routes; + }; + std::optional trainerDiscovery; + std::optional selectedTrainerRoute; + std::optional trainerJourney; + uint64_t trainerFareRemaining = 0; + uint32_t trainerJourneyRestarts = 0; + bool trainerJourneyNeedsRoute = false, trainerTravelReceiptFailed = false; + std::chrono::steady_clock::time_point trainerJourneyDeadline; struct DepotSourceRouteSearch { Position origin, destination, locker; uint16_t depotId = 0, lockerItemId = 0; diff --git a/server/src/playerbotgoalarbiter.cpp b/server/src/playerbotgoalarbiter.cpp index 950d11b4..1bf92ca3 100644 --- a/server/src/playerbotgoalarbiter.cpp +++ b/server/src/playerbotgoalarbiter.cpp @@ -24,9 +24,10 @@ namespace { case PlayerBotGoalArbiter::TopLevelGoal::BuyEquipment: return 4; case PlayerBotGoalArbiter::TopLevelGoal::MagicTraining: return 5; case PlayerBotGoalArbiter::TopLevelGoal::SellLoot: return 6; - case PlayerBotGoalArbiter::TopLevelGoal::Hunt: return 7; + case PlayerBotGoalArbiter::TopLevelGoal::Prepare: return 7; + case PlayerBotGoalArbiter::TopLevelGoal::Hunt: return 8; } - return 8; + return 9; } } @@ -134,6 +135,7 @@ const char* PlayerBotGoalArbiter::goalName(TopLevelGoal goal) case TopLevelGoal::MagicTraining: return "magic_training"; case TopLevelGoal::SellLoot: return "sell_loot"; case TopLevelGoal::Hunt: return "hunt"; + case TopLevelGoal::Prepare: return "prepare"; } return "unknown"; } diff --git a/server/src/playerbotgoalarbiter.h b/server/src/playerbotgoalarbiter.h index 0d554a54..b5eadb2a 100644 --- a/server/src/playerbotgoalarbiter.h +++ b/server/src/playerbotgoalarbiter.h @@ -11,6 +11,8 @@ #ifndef FS_PLAYERBOTGOALARBITER_H #define FS_PLAYERBOTGOALARBITER_H +#include "playerbotpreparation.h" + #include #include #include @@ -28,6 +30,7 @@ class PlayerBotGoalArbiter { MagicTraining, SellLoot, Hunt, + Prepare, }; struct GoalCandidate { @@ -35,6 +38,7 @@ class PlayerBotGoalArbiter { bool feasible; int32_t utility; std::string reason; + PlayerBotPreparationOption preparation = PlayerBotPreparationOption::None; }; struct GoalDecision { diff --git a/server/src/playerbotgoalplanner.cpp b/server/src/playerbotgoalplanner.cpp index 1259c094..ea9333e0 100644 --- a/server/src/playerbotgoalplanner.cpp +++ b/server/src/playerbotgoalplanner.cpp @@ -56,7 +56,16 @@ std::vector PlayerBotGoalPlanner::candidate { using Goal = PlayerBotGoalArbiter::TopLevelGoal; const auto departure = departureCandidate(snapshot); - const auto service = serviceCandidate(snapshot); + auto service = serviceCandidate(snapshot); + const bool blocked = snapshot.huntReadiness.state == PlayerBotReadiness::Blocked; + auto available = [&snapshot](PlayerBotPreparationOption option) { + return std::find(snapshot.preparations.begin(), snapshot.preparations.end(), option) != snapshot.preparations.end(); + }; + if (blocked && !snapshot.criticalHealing && !snapshot.lowCapacity) { + service.feasible = available(PlayerBotPreparationOption::ServiceStock); + service.reason = service.feasible ? "prepare_stock" : "blocked_service_unavailable"; + if (service.feasible) service.utility = std::max(service.utility, serviceGoalBaseUtility); + } const auto pickup = PlayerBotGoalArbiter::GoalCandidate{Goal::PickupReward, !snapshot.pickupCoolingDown && snapshot.rewardPlanAvailable, snapshot.pickupCoolingDown || !snapshot.rewardPlanAvailable ? 0 : snapshot.rewardUtility, snapshot.pickupCoolingDown ? "cooldown" : snapshot.rewardPlanAvailable ? "useful_reachable_reward" : "no_useful_reward"}; @@ -79,7 +88,17 @@ std::vector PlayerBotGoalPlanner::candidate snapshot.sellLootCoolingDown ? "cooldown" : snapshot.sellLootPlanAvailable && snapshot.sellLootUtility > huntGoalUtility ? snapshot.sellLootReason : snapshot.sellLootPlanAvailable ? "utility_below_hunt" : snapshot.sellLootReason}; std::vector result{departure, service, pickup, spell, equipment, magic, sellLoot, - {Goal::Hunt, true, huntGoalUtility, "autonomous_hunting_available"}}; + {Goal::Hunt, !blocked || available(PlayerBotPreparationOption::RecoveryIncome), huntGoalUtility, + blocked ? (available(PlayerBotPreparationOption::RecoveryIncome) ? "evaluate_recovery_income" : "blocked_requirement") : + snapshot.huntReadiness.state == PlayerBotReadiness::Pending ? "pending_hunt_evaluation" : "autonomous_hunting_available"}}; + // Providers publish independently supported options; arbitration owns the + // preference. No provider executes a failure-specific fallback chain. + result.push_back({Goal::Prepare, blocked && available(PlayerBotPreparationOption::Regenerate), + 650, "prepare_regeneration", PlayerBotPreparationOption::Regenerate}); + result.push_back({Goal::Prepare, blocked && available(PlayerBotPreparationOption::RestoreHealth), + 660, "prepare_health", PlayerBotPreparationOption::RestoreHealth}); + result.push_back({Goal::Prepare, blocked && available(PlayerBotPreparationOption::BuyFood), + 640, "prepare_food", PlayerBotPreparationOption::BuyFood}); if (snapshot.recoverySpellPlanAvailable && spell.feasible) { for (auto& candidate : result) { if (candidate.goal == Goal::LearnSpell) { diff --git a/server/src/playerbotgoalplanner.h b/server/src/playerbotgoalplanner.h index 7c1e9ea4..c5f153e5 100644 --- a/server/src/playerbotgoalplanner.h +++ b/server/src/playerbotgoalplanner.h @@ -5,6 +5,7 @@ #define FS_PLAYERBOTGOALPLANNER_H #include "playerbotgoalarbiter.h" +#include "playerbotpreparation.h" #include @@ -45,6 +46,8 @@ struct PlayerBotGoalPlannerSnapshot { int32_t sellLootUtility = 0; std::string sellLootReason; bool recoverySpellPlanAvailable = false; + PlayerBotGoalReadiness huntReadiness; + std::vector preparations; }; class PlayerBotGoalPlanner { diff --git a/server/src/playerbothuntregions.h b/server/src/playerbothuntregions.h index 08ebc531..88f8db3c 100644 --- a/server/src/playerbothuntregions.h +++ b/server/src/playerbothuntregions.h @@ -12,9 +12,11 @@ #define FS_PLAYERBOTHUNTREGIONS_H #include "playerbotcombatprofile.h" +#include "const.h" #include "playerbothunteconomy.h" #include "playerbotsupplypolicy.h" #include "playerbotsupplyrecovery.h" +#include "playerbotpreparation.h" #include "position.h" #include @@ -112,6 +114,11 @@ inline PlayerBotSupplyCapabilitySnapshot playerBotSupplyCapability(const PlayerB capability.foodManaGain = profile.supply.manaGain; capability.foodManaIntervalMilliseconds = static_cast(profile.supply.manaInterval * 1000); capability.equipmentItemIds = profile.equipmentItemIds; + // Legacy equipment slots can hold coins. Banking or denomination changes + // alter liquidity, not combat capability; real ammunition still matters. + for (auto& itemId : capability.equipmentItemIds) { + if (itemId == ITEM_GOLD_COIN || itemId == ITEM_PLATINUM_COIN || itemId == ITEM_CRYSTAL_COIN) itemId = 0; + } return capability; } @@ -366,6 +373,77 @@ struct PlayerBotHuntRegion { score = projectedExperience; } + // Probe the same fitting window at higher health after route proof. Never + // lower the recovery floor or invent regeneration from a prospective meal. + std::optional healthPreparation(uint32_t reserve) const + { + if (!supplyRecovery || maximumHealth <= 0 || (productiveWindow() && supplyBudget.fits)) return std::nullopt; + const bool typedFit = std::all_of(supplyKindBudgets.begin(), supplyKindBudgets.end(), + [](const auto& budget) { return budget.fits; }); + const double fraction = std::clamp(combatFraction, 0.0, 1.0); + if (!typedFit || coinGoldPerMinute <= 0 || + recoveryRouteHealthLoss > maximumHealth - static_cast(playerBotSupplyRecoveryRequiredHealth(maximumHealth)) || + (predictedFightSeconds > 0 && (fraction <= 0 || predictedFightSeconds > maximumHuntSeconds * fraction))) + return std::nullopt; + PlayerBotHuntRegion probe = *this; + probe.currentHealth = maximumHealth; + probe.fitSupplyWindow(reserve); + PlayerBotPreparationRequirement requirement; + requirement.kind = PlayerBotRequirementKind::HealthRegeneration; + if (!probe.productiveWindow() || !probe.supplyBudget.fits || !probe.recoverySustainable()) { + // Only the next live evaluator can establish a food effect. + if (supplyProfile.regenerationSeconds > 0) return std::nullopt; + requirement.health = std::max(0, currentHealth); + requirement.observeRegeneration = true; + return requirement; + } + int32_t lower = currentHealth, upper = maximumHealth; + for (int i = 0; i < 32 && upper - lower > 1; ++i) { + probe.currentHealth = lower + (upper - lower) / 2; + probe.fitSupplyWindow(reserve); + if (probe.productiveWindow() && probe.supplyBudget.fits && probe.recoverySustainable()) upper = probe.currentHealth; + else lower = probe.currentHealth; + } + requirement.health = upper; + return requirement; + } + + // Smallest productive whole-second outing (one modeled fight), not the + // configured full hunt. Reuse the same learned/static demand and transit + // exposure as final admission, and verify the resulting stock with its fit check. + PlayerBotSupplyRequirements minimumProductiveSupplies(uint32_t reserve) const + { + if (supplyRecovery) return {}; // Recovery has a different health contract. + PlayerBotHuntRegion minimum = *this; + const double fraction = std::clamp(combatFraction, 0.0, 1.0); + if (predictedFightSeconds > 0 && fraction <= 0) return {}; + minimum.availableHuntSeconds = std::max(1.0, std::ceil( + predictedFightSeconds > 0 ? predictedFightSeconds / fraction : 1.0)); + if (minimum.availableHuntSeconds > maximumHuntSeconds) return {}; + minimum.reconcileSupplies(reserve); + auto required = [](uint32_t reserveStock, double expected, uint32_t minimumRoutine = 1) -> uint32_t { + if (!std::isfinite(expected) || expected < 0 || expected >= UINT32_MAX - reserveStock) + return UINT32_MAX; + return reserveStock + std::max(minimumRoutine, static_cast(std::ceil(expected))); + }; + PlayerBotSupplyRequirements requirements{{PlayerBotSupplyKind::HealthPotion, 0, + required(reserve, minimum.supplyBudget.expectedPotions)}}; + minimum.supplyProfile.potions = requirements.front().count; + for (size_t i = 0; i < minimum.supplyProfile.kinds.size(); ++i) { + auto& kind = minimum.supplyProfile.kinds[i]; + const bool needsStock = kind.returnThreshold != 0 || kind.kind == PlayerBotSupplyKind::Ammunition || + kind.kind == PlayerBotSupplyKind::ThrowingWeapon; + const uint32_t count = required(kind.returnThreshold, minimum.supplyKindBudgets[i].expected, needsStock ? 1 : 0); + requirements.push_back({kind.kind, kind.itemId, count}); + kind.count = count; + } + minimum.reconcileSupplies(reserve); + if (!minimum.productiveWindow() || !minimum.supplyBudget.fits || + std::any_of(requirements.begin(), requirements.end(), + [](const auto& requirement) { return requirement.count == UINT32_MAX; })) return {}; + return requirements; + } + void reconcileTravel(double durationSeconds, double travelSeconds, double staminaMultiplier) { estimatedTravelSeconds = std::max(0.0, travelSeconds); @@ -394,8 +472,9 @@ struct PlayerBotHuntRegion { const double healthBudget = std::max(0.0, currentHealth - static_cast(playerBotSupplyRecoveryRequiredHealth(std::max(0, maximumHealth))) - 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.potionEquivalentDemand = deficit == 0 ? 0 : supplyProfile.potionHealing > 0 ? + deficit / supplyProfile.potionHealing : std::numeric_limits::max(); + supplyBudget.expectedPotions = std::ceil(supplyBudget.potionEquivalentDemand); supplyBudget.fits = supplyBudget.expectedPotions == 0 && recoveryRouteHealthLoss <= currentHealth - static_cast(playerBotSupplyRecoveryRequiredHealth(std::max(0, maximumHealth))); } @@ -403,7 +482,7 @@ struct PlayerBotHuntRegion { supplyEstimateSource = "static"; supplyEstimateReason = "static_duration_budget"; supplyLocalRejectionReason = "no_local_evidence"; - supplyStaticPotionsPerCombatSecond = exposure > 0 ? supplyBudget.expectedPotions / exposure : 0; + supplyStaticPotionsPerCombatSecond = exposure > 0 ? supplyBudget.potionEquivalentDemand / exposure : 0; supplyAppliedPotionsPerCombatSecond = supplyStaticPotionsPerCombatSecond; bool learnedEstimate = false; if (playerBotSupplyCalibrationForCapability(supplyCalibration, supplyCapability)) { @@ -412,6 +491,7 @@ struct PlayerBotHuntRegion { supplyEstimateReason = "local_variant_learning"; supplyLocalRejectionReason = nullptr; supplyAppliedPotionsPerCombatSecond = supplyCalibration.potionsPerCombatSecond; + supplyBudget.potionEquivalentDemand = supplyAppliedPotionsPerCombatSecond * exposure; } else { if (supplyCalibration.samples != 0) { const auto change = playerBotCompareSupplyCapabilities( @@ -423,12 +503,16 @@ struct PlayerBotHuntRegion { learnedEstimate = true; supplyEstimateSource = "global"; supplyEstimateReason = "global_policy_multiplier"; - supplyAppliedPotionsPerCombatSecond = supplyStaticPotionsPerCombatSecond * - std::max(playerBotSupplyGlobalMinimumMultiplier, supplyGlobalLearning.multiplier); + const double multiplier = std::max(playerBotSupplyGlobalMinimumMultiplier, supplyGlobalLearning.multiplier); + supplyAppliedPotionsPerCombatSecond = supplyStaticPotionsPerCombatSecond * multiplier; + // Scale raw demand directly: converting to a rate and back can add + // floating-point error at an exact whole-potion boundary. + supplyBudget.potionEquivalentDemand = exposure > 0 ? + supplyBudget.potionEquivalentDemand * multiplier : 0; } } if (learnedEstimate) { - supplyBudget.expectedPotions = std::ceil(supplyAppliedPotionsPerCombatSecond * exposure); + supplyBudget.expectedPotions = std::ceil(supplyBudget.potionEquivalentDemand); supplyBudget.fits = (supplyRecovery && supplyBudget.expectedPotions == 0 && recoveryRouteHealthLoss <= currentHealth - static_cast(playerBotSupplyRecoveryRequiredHealth(std::max(0, maximumHealth)))) || ((supplyProfile.potions > reserve || reserve == 0) && diff --git a/server/src/playerbothuntrouteselection.cpp b/server/src/playerbothuntrouteselection.cpp index 6e2e7d7f..c842eaf9 100644 --- a/server/src/playerbothuntrouteselection.cpp +++ b/server/src/playerbothuntrouteselection.cpp @@ -36,7 +36,7 @@ PlayerBotHuntRouteRequest PlayerBotHuntRouteSelection::next() case PlayerBotHuntRouteStage::DiscoverSupply: request.from = current->exitDepotDestination; for (const auto& budget : current->supplyKindBudgets) { - if (budget.expected <= budget.routine) continue; + if (budget.fits) continue; const auto kind = std::find_if(current->supplyProfile.kinds.begin(), current->supplyProfile.kinds.end(), [&](const auto& profile) { return profile.kind == budget.kind; }); if (kind != current->supplyProfile.kinds.end() && kind->itemId != 0) @@ -75,10 +75,14 @@ PlayerBotHuntRouteResult PlayerBotHuntRouteSelection::reject(PlayerBotHuntRegion ++failureCounts[candidate.rejectionReason.empty() ? "unspecified" : candidate.rejectionReason]; // An exhausted search can be retried on the next planning pass. Do not // turn missing evidence into the ordinary unreachable-region cooldown. - if (!searchIncomplete) rejectedVariants.push_back(candidate.atlasVariantId); + const bool stockOrEconomy = candidate.rejectionReason == "hunt_supply_window_unavailable" || + candidate.rejectionReason == "travel_fare_breaks_recovery_reserve" || + candidate.rejectionReason == "recovery_hunt_not_sustainable"; + if (!searchIncomplete && !stockOrEconomy) rejectedVariants.push_back(candidate.atlasVariantId); PlayerBotHuntRouteResult result; result.accepted = result.yield = true; result.completedCandidate = std::move(candidate); + incompletePass = incompletePass || searchIncomplete; advanceCandidate(); return result; } @@ -95,6 +99,8 @@ PlayerBotHuntRouteResult PlayerBotHuntRouteSelection::finish(PlayerBotHuntRegion stage = PlayerBotHuntRouteStage::Done; result.terminal = true; result.selectedRouteRegion = best; + result.outcome = PlayerBotHuntRouteOutcome::Selected; + result.readiness.state = PlayerBotReadiness::Executable; result.failureCounts = failureCounts; result.rejectedVariants = rejectedVariants; finished = true; @@ -113,6 +119,19 @@ PlayerBotHuntRouteResult PlayerBotHuntRouteSelection::observe(const PlayerBotHun result.accepted = true; result.terminal = true; result.selectedRouteRegion = best; + result.supplyShortage = best ? std::nullopt : supplyShortage; + // Proof for one candidate is actionable even if another route remains + // unknown. Missing evidence neither vetoes that proof nor proves danger. + result.outcome = best ? PlayerBotHuntRouteOutcome::Selected : preparationRequirement ? + PlayerBotHuntRouteOutcome::NeedsPreparation : supplyShortage ? + PlayerBotHuntRouteOutcome::NeedsSupplies : incompletePass ? + PlayerBotHuntRouteOutcome::Incomplete : PlayerBotHuntRouteOutcome::Unavailable; + result.readiness.state = best ? PlayerBotReadiness::Executable : + !preparationRequirement && !supplyShortage && incompletePass ? + PlayerBotReadiness::Pending : PlayerBotReadiness::Blocked; + if (!best && preparationRequirement) result.readiness.requirement = preparationRequirement; + else if (!best && supplyShortage) result.readiness.requirement = PlayerBotPreparationRequirement{ + PlayerBotRequirementKind::Stock, supplyShortage->requirements, 0, false}; result.failureCounts = failureCounts; result.rejectedVariants = rejectedVariants; finished = true; @@ -189,7 +208,7 @@ PlayerBotHuntRouteResult PlayerBotHuntRouteSelection::observe(const PlayerBotHun current = std::move(routed); // Retain the depot-time decision across the final live supply refresh. const bool typedDeficit = std::any_of(current->supplyKindBudgets.begin(), current->supplyKindBudgets.end(), - [](const PlayerBotSupplyKindBudget& budget) { return budget.expected > budget.routine; }); + [](const PlayerBotSupplyKindBudget& budget) { return !budget.fits; }); const bool needsSupplyRoute = !current->supplyRecovery && (typedDeficit || playerBotHuntNeedsSupplyRoute( current->supplyBudget.expectedPotions, current->supplyProfile.potions, observation.potionReserve)); stage = needsSupplyRoute ? PlayerBotHuntRouteStage::DiscoverSupply : PlayerBotHuntRouteStage::Final; @@ -234,20 +253,31 @@ PlayerBotHuntRouteResult PlayerBotHuntRouteSelection::observe(const PlayerBotHun case PlayerBotHuntRouteStage::RejectSupply: routed.rejectionReason = searchIncomplete ? "supply_route_search_incomplete" : "recovery_supply_route_unavailable"; return reject(std::move(routed)); - case PlayerBotHuntRouteStage::Final: + case PlayerBotHuntRouteStage::Final: { refreshSupply(observation.supplyProfile); routed.recoveryPotionReserve = observation.potionReserve; routed.recoveryRouteHealthLoss = observation.recoveryRouteHealthLoss; routed.healthPotionUnitPrice = observation.healthPotionUnitPrice; routed.reconcileRecovery(observation.potionReserve, observation.funds); routed.fitSupplyWindow(observation.potionReserve); + const bool affordable = playerBotHuntTravelAffordable(observation.funds, observation.recoverySpendingReserve, + routed.outboundFare, routed.exitFare, routed.supplyFare); + if (affordable && !preparationRequirement) preparationRequirement = routed.healthPreparation(observation.potionReserve); if (!routed.recoverySustainable()) routed.rejectionReason = "recovery_hunt_not_sustainable"; - else if (!routed.productiveWindow() || !routed.supplyBudget.fits) routed.rejectionReason = "hunt_supply_window_unavailable"; - else if (!playerBotHuntTravelAffordable(observation.funds, observation.recoverySpendingReserve, - routed.outboundFare, routed.exitFare, routed.supplyFare)) + else if (!affordable) routed.rejectionReason = "travel_fare_breaks_recovery_reserve"; + else if (!routed.productiveWindow() || !routed.supplyBudget.fits) routed.rejectionReason = "hunt_supply_window_unavailable"; + if (routed.rejectionReason == "hunt_supply_window_unavailable" && !supplyShortage && + !supplyApproachGroups.empty() && supplyGroupIndex == supplyApproachGroups.size()) { + // Only final, otherwise safe outbound/depot/supplier evidence can + // request shopping. Never retain an executable route for this result. + auto requirements = routed.minimumProductiveSupplies(observation.potionReserve); + if (!requirements.empty()) + supplyShortage = PlayerBotHuntSupplyShortage{routed.atlasVariantId, std::move(requirements)}; + } if (!routed.rejectionReason.empty()) return reject(std::move(routed)); return finish(std::move(routed)); + } default: return {}; } } diff --git a/server/src/playerbothuntrouteselection.h b/server/src/playerbothuntrouteselection.h index 4787ddff..d0f1e6e8 100644 --- a/server/src/playerbothuntrouteselection.h +++ b/server/src/playerbothuntrouteselection.h @@ -3,6 +3,7 @@ #include "playerbothuntregions.h" #include "playerbotnavigation.h" +#include "playerbotpreparation.h" #include #include @@ -28,6 +29,9 @@ struct PlayerBotHuntRouteRequest { uint32_t returnDangerCost = 0; std::vector requiredSupplyItems; uint16_t supplyItemId = 0; + // Optional destination alternatives, never ordered checkpoints. + std::vector destinations; + std::set blockedPositions; }; struct PlayerBotHuntSupplyApproaches { @@ -60,7 +64,18 @@ struct PlayerBotHuntRouteObservation { bool endpointRiskRejected = false; // Necessary peak-risk proof, not physical disconnection. }; +// Pending is in-flight work. Incomplete is a completed scan with only unknown routes. +enum class PlayerBotHuntRouteOutcome { Pending, Selected, NeedsSupplies, Unavailable, NeedsPreparation, Incomplete }; + +struct PlayerBotHuntSupplyShortage { + uint64_t atlasVariantId = 0; + PlayerBotSupplyRequirements requirements; +}; + struct PlayerBotHuntRouteResult { + PlayerBotHuntRouteOutcome outcome = PlayerBotHuntRouteOutcome::Pending; + std::optional supplyShortage; + PlayerBotGoalReadiness readiness; uint64_t planningPass = 0; uint64_t scoringRevision = 0; std::optional completedCandidate; @@ -97,11 +112,14 @@ class PlayerBotHuntRouteSelection size_t supplyGroupIndex = 0; Position supplySource; std::optional best; + std::optional supplyShortage; + std::optional preparationRequirement; std::map failureCounts; std::vector rejectedVariants; PlayerBotHuntRouteStage stage = PlayerBotHuntRouteStage::Outbound; bool finished = false; bool searchIncomplete = false; + bool incompletePass = false; uint64_t sequence = 0; std::optional pending; }; diff --git a/server/src/playerbothuntruntime.cpp b/server/src/playerbothuntruntime.cpp index 47358133..f6c0b83d 100644 --- a/server/src/playerbothuntruntime.cpp +++ b/server/src/playerbothuntruntime.cpp @@ -398,7 +398,7 @@ bool PlayerBotHuntRuntime::enterHuntArea(const PlayerBotHuntRuntimePlayerObserva const PlayerBotSupplyBudget staticBudget = playerBotSupplyBudget( supplyProfile, activeRegion->expectedDamagePerSecond, activeRegion->combatFraction, plannedHuntSeconds, 0); - const double staticRate = exposure > 0 ? staticBudget.expectedPotions / exposure : 0; + const double staticRate = exposure > 0 ? staticBudget.potionEquivalentDemand / exposure : 0; supplyBaseline = PlayerBotHuntSupplyBaseline{ player, supplyProfile, now, plannedHuntSeconds, staticRate}; policy.resetCombatEvidence(); diff --git a/server/src/playerbothuntsupplyhandoff.h b/server/src/playerbothuntsupplyhandoff.h new file mode 100644 index 00000000..19abbe76 --- /dev/null +++ b/server/src/playerbothuntsupplyhandoff.h @@ -0,0 +1,106 @@ +#ifndef FS_PLAYERBOTHUNTSUPPLYHANDOFF_H +#define FS_PLAYERBOTHUNTSUPPLYHANDOFF_H + +#include "playerbothuntrouteselection.h" + +// Controller-local evidence. Position is deliberately absent: returning to the +// same shop is not progress. A successful hunt or changed planning facts is. +struct PlayerBotHuntSupplyFacts { + PlayerBotSupplyCapabilitySnapshot capability; + PlayerBotHuntReturnCoverageContext dependencies; + uint64_t atlasRevision = 0; + uint64_t funds = 0; + uint64_t stockKey = 0; + int32_t health = 0; + + bool sameDependencies(const PlayerBotHuntSupplyFacts& other) const + { + return capability == other.capability && dependencies == other.dependencies && + atlasRevision == other.atlasRevision; + } + bool operator==(const PlayerBotHuntSupplyFacts& other) const + { + return sameDependencies(other) && funds == other.funds && stockKey == other.stockKey && health == other.health; + } +}; + +class PlayerBotHuntSupplyHandoff +{ + public: + const PlayerBotSupplyRequirements& requirements() const { return required; } + const PlayerBotGoalReadiness& readiness() const { return goalReadiness; } + void blocked(PlayerBotGoalReadiness result, const PlayerBotHuntSupplyFacts& facts) + { + goalReadiness = std::move(result); + if (goalReadiness.state != PlayerBotReadiness::Pending) incompleteAttempts = 0; + evidence = facts; + if (goalReadiness.requirement && goalReadiness.requirement->kind == PlayerBotRequirementKind::Stock) + required = goalReadiness.requirement->stocks; + } + // Verified prerequisite effects permit a fresh evaluator, never execution + // of a route cached before shopping, item use, healing, or rest. + void replan() { goalReadiness = {}; } + bool refresh(const PlayerBotHuntSupplyFacts& facts) + { + if (!evidence || evidence->sameDependencies(facts)) return false; + required.clear(); + evidence.reset(); + goalReadiness = {}; + incompleteAttempts = 0; + return true; + } + void require(PlayerBotSupplyRequirements supplies, const PlayerBotHuntSupplyFacts& facts) + { + incompleteAttempts = 0; + required = std::move(supplies); + goalReadiness = {PlayerBotReadiness::Blocked, PlayerBotPreparationRequirement{ + PlayerBotRequirementKind::Stock, required, 0, false}}; + evidence = facts; + } + void selected(bool retainShortage = false) + { + // A recovery outing earns the missing stock; accepting that outing + // does not satisfy the ordinary hunt requirement it is funding. + if (!retainShortage) { + required.clear(); + evidence.reset(); + } + goalReadiness = {}; + failures.clear(); + incompleteAttempts = 0; + } + // Only a completed selection counts. Pending routes/search yields are + // neither failure nor progress, and cannot terminal-stop a controller. + bool noProgress(PlayerBotHuntRouteOutcome outcome, const PlayerBotHuntSupplyFacts& facts) + { + if (outcome == PlayerBotHuntRouteOutcome::Pending) return false; + // Completed allowance exhaustion has a retry bound, not a danger + // verdict. Currency/health fluctuations cannot grant unlimited scans. + if (outcome == PlayerBotHuntRouteOutcome::Incomplete) return ++incompleteAttempts >= 3; + if (outcome == PlayerBotHuntRouteOutcome::Selected) { selected(); return false; } + // Compare completed outcomes, not intermediate shopping snapshots. + // Returning to previously failed stock/funds is not new progress, + // even if another completed pass saw a temporarily larger stack. + for (auto& failure : failures) { + if (failure.facts == facts) return ++failure.attempts >= 3; + } + if (failures.size() == 8) failures.erase(failures.begin()); + failures.push_back({facts, 1}); + return false; + } + + private: + uint32_t incompleteAttempts = 0; + PlayerBotGoalReadiness goalReadiness; + PlayerBotSupplyRequirements required; + std::optional evidence; + struct Failure { + PlayerBotHuntSupplyFacts facts; + uint32_t attempts = 0; + }; + // Small controller-local history; real changes may introduce new facts + // indefinitely, but cannot grow retained failure evidence without bound. + std::vector failures; +}; + +#endif diff --git a/server/src/playerbothunttravel.cpp b/server/src/playerbothunttravel.cpp index 52908230..505da46d 100644 --- a/server/src/playerbothunttravel.cpp +++ b/server/src/playerbothunttravel.cpp @@ -65,6 +65,8 @@ namespace { struct PlayerBotHuntTravelWork { uint64_t pass = 0, revision = 0, sequence = 0, topologyGeneration = 0, riskRevision = 0; Position source, destination; + std::vector destinations; + std::set blockedPositions; ActorFact actor; std::vector offers; PlayerBotRouteChanges::Watch watch; @@ -92,8 +94,8 @@ struct PlayerBotHuntTravelWork { bool sameRequest(const PlayerBotHuntRouteRequest& r, Position from, uint64_t reserve, bool sell, const PlayerBotNavigationRiskProfile& risk) const { return pass == r.planningPass && revision == r.scoringRevision && sequence == r.sequence && - source == from && destination == r.to && transportReserve == reserve && sellEconomy == sell && - walkingOnly == r.walkingOnly && preferSafeWalking == r.preferSafeWalking && + source == from && destination == r.to && destinations == (r.destinations.empty() ? std::vector{r.to} : r.destinations) && transportReserve == reserve && sellEconomy == sell && + walkingOnly == r.walkingOnly && preferSafeWalking == r.preferSafeWalking && blockedPositions == r.blockedPositions && riskProfile.healthLossCost == risk.healthLossCost && riskProfile.maximumRouteHealthLoss == risk.maximumRouteHealthLoss && riskProfile.maximumHealthLossPerSecond == risk.maximumHealthLossPerSecond; @@ -185,11 +187,24 @@ bool PlayerBotController::huntTravelEvidenceValid(Player& player, const Position return true; } +const char* PlayerBotController::trainerTravelExecutionChange(Player& player, PlayerBotHuntTravelEvidence& evidence) +{ + const auto combat = equipmentPolicy.combatProfile(PlayerBotEquipmentAdapter::player(player), + PlayerBotEquipmentAdapter::loadout(player)); + const bool rope = g_game.findItemOfType(&player, ropeItemId, true) != nullptr; + const bool shovel = g_game.findItemOfType(&player, shovelToolItemId, true) != nullptr; + return evidence.executionContextChange(actorFacts(player, combat, rope, shovel), static_cast(player).getStepSpeed(), + PlayerBotTopology::instance().generation(), PlayerBotHuntRegionPlanner::getCacheRevision(), riskProfile); +} + std::optional PlayerBotController::advanceHuntTravelRoute( Player& player, const PlayerBotHuntRouteRequest& request, const Position& source, PlayerBotHuntRouteTiming& timing, uint64_t transportReserve, bool sellEconomy, PlayerBotNavigationRoutePlan* walkingAlternative, - std::shared_ptr* completedEvidence) + std::shared_ptr* completedEvidence, + const PlayerBotNavigationRiskProfile* riskOverride) { + // All request, cache, search, and acceptance dependencies use one policy. + const PlayerBotNavigationRiskProfile& riskProfile = riskOverride ? *riskOverride : this->riskProfile; if (completedEvidence) completedEvidence->reset(); const auto began = std::chrono::steady_clock::now(); timing.requestSequence = request.sequence; @@ -237,6 +252,8 @@ std::optional PlayerBotController::advanceHuntTrav auto& w = *huntTravelWork; w.pass = request.planningPass; w.revision = request.scoringRevision; w.sequence = request.sequence; w.source = source; w.destination = request.to; + w.destinations = request.destinations.empty() ? std::vector{request.to} : request.destinations; + w.blockedPositions = request.blockedPositions; w.transportReserve = transportReserve; w.sellEconomy = sellEconomy; w.walkingOnly = request.walkingOnly; w.preferSafeWalking = request.preferSafeWalking; w.actor = actor; w.topologyGeneration = generation; @@ -250,6 +267,7 @@ std::optional PlayerBotController::advanceHuntTrav w.restartBudget = restartBudget; w.riskRevision = riskRevision; w.policy = navigationCostPolicy(player); + w.policy.risk = riskProfile; w.riskProfile = riskProfile; w.walking.metrics.attempted = true; w.walking.metrics.result = PlayerBotNavigationResult::Reached; @@ -265,6 +283,7 @@ std::optional PlayerBotController::advanceHuntTrav evidence->actor = w.actor; evidence->topologyGeneration = w.topologyGeneration; evidence->riskRevision = w.riskRevision; evidence->transportReserve = w.transportReserve; evidence->risk = w.riskProfile; evidence->watch = w.watch; + evidence->retainExecutionLeg(route, static_cast(player).getStepSpeed()); evidence->paid = route.metrics.firstNpcTravelOffer.has_value(); evidence->walkingOnly = w.walkingOnly; if (evidence->paid) { evidence->offers = w.offers; evidence->providers = w.providerValidation; } @@ -327,7 +346,7 @@ std::optional PlayerBotController::advanceHuntTrav return peak; }; if (!w.walkingDone && !w.local) { - if (const auto peak = rejectedEndpoints(source, {w.destination}, "walking")) { + if (const auto peak = rejectedEndpoints(source, w.destinations, "walking")) { w.walking.metrics.result = PlayerBotNavigationResult::RiskRejected; w.walking.metrics.maximumHealthLossPerSecond = *peak; if (walkingAlternative) *walkingAlternative = w.walking; @@ -379,7 +398,7 @@ std::optional PlayerBotController::advanceHuntTrav w.connectionTime = {}; }; if (!w.walkingDone) { - if (!w.local) w.local = std::make_unique(source, std::vector{w.destination}, playerBotNavigationMaximumExpandedNodes); + if (!w.local) w.local = std::make_unique(source, w.destinations, playerBotNavigationMaximumExpandedNodes, w.blockedPositions); const auto before = w.local->expanded; const auto result = w.local->advance(player, policy, rope, shovel, w.profile, riskRevision, timing, localBudget, w.pass); timing.walkingExpandedNodes += w.local->expanded - before; @@ -396,6 +415,8 @@ std::optional PlayerBotController::advanceHuntTrav w.walking.metrics.estimatedTravelSeconds = w.local->seconds; w.walking.steps = std::move(w.local->steps); w.walking.metrics.steps = w.walking.steps.size(); + if (*result == PlayerBotNavigationResult::Reached) + w.walking.metrics.waypoint = w.walking.steps.empty() ? source : w.walking.steps.back().expectedPosition; w.walkingDone = true; timing.walkingReached += *result == PlayerBotNavigationResult::Reached; if (w.preferSafeWalking && safe(w.walking, riskProfile) && !w.walking.steps.empty()) { @@ -416,7 +437,7 @@ std::optional PlayerBotController::advanceHuntTrav const bool final = connection == SIZE_MAX; const auto from = w.states[state]; std::vector targets; - if (final) targets.push_back(w.destination); + if (final) targets = w.destinations; else { const auto provider = w.providerValidation.use(w.providers[connection]); for (int x = -1; x <= 1; ++x) for (int y = -1; y <= 1; ++y) { @@ -442,7 +463,7 @@ std::optional PlayerBotController::advanceHuntTrav if (w.npcNodes >= playerBotNavigationMaximumExpandedNodes) return PlayerBotTransportSegment{PlayerBotNavigationResult::NodeLimit}; w.local = std::make_unique(from, targets, - std::min(30000, playerBotNavigationMaximumExpandedNodes - w.npcNodes)); + std::min(30000, playerBotNavigationMaximumExpandedNodes - w.npcNodes), w.blockedPositions); } if (localBudget == 0) return pending(); const auto connectionBegan = std::chrono::steady_clock::now(); diff --git a/server/src/playerbothunttravelevidence.h b/server/src/playerbothunttravelevidence.h index 3efe593b..8272df96 100644 --- a/server/src/playerbothunttravelevidence.h +++ b/server/src/playerbothunttravelevidence.h @@ -4,6 +4,7 @@ #include "playerbothunttravelpolicy.h" #include "playerbothuntrouteselection.h" #include "playerbotroutechanges.h" +#include "playerbotnavigationruntime.h" #include #include @@ -21,6 +22,9 @@ struct PlayerBotHuntTravelEvidence { uint64_t topologyGeneration = 0, riskRevision = 0, transportReserve = 0; PlayerBotNavigationRiskProfile risk; PlayerBotRouteChanges::Watch watch; + // Execution depends on the chosen first leg, not explored alternatives. + PlayerBotRouteChanges::Watch executionWatch; + int32_t executionStepSpeed = 0; std::vector offers; PlayerBotHuntProviderValidation providers; bool paid = false, walkingOnly = false; @@ -35,6 +39,32 @@ struct PlayerBotHuntTravelEvidence { risk.maximumRouteHealthLoss == liveRisk.maximumRouteHealthLoss && risk.maximumHealthLossPerSecond == liveRisk.maximumHealthLossPerSecond && watch.valid(); } + void retainExecutionLeg(const PlayerBotNavigationRoutePlan& route, int32_t stepSpeed) { + executionStepSpeed = stepSpeed; + executionWatch = watch; + executionWatch.tiles.clear(); + executionWatch.imports.clear(); + PlayerBotRouteChanges::Scope execution(executionWatch); + PlayerBotRouteChanges::read(source); + for (const auto& step : route.steps) { + PlayerBotRouteChanges::read(step.target); + PlayerBotRouteChanges::read(step.expectedPosition); + } + } + const char* executionContextChange(Actor liveActor, int32_t liveStepSpeed, uint64_t topology, + uint64_t revision, const PlayerBotNavigationRiskProfile& liveRisk) { + // Position advances normally; these durations also change with the + // ground under the actor. Actual speed remains a material risk fact. + std::get<12>(liveActor) = std::get<12>(actor); + std::get<13>(liveActor) = std::get<13>(actor); + std::get<18>(liveActor) = std::get<18>(actor); + if (actor != liveActor) return "actor_facts_changed"; + if (executionStepSpeed != liveStepSpeed) return "actor_speed_changed"; + if (topologyGeneration != topology) return "topology_changed"; + if (riskRevision != revision || risk != liveRisk) return "risk_changed"; + if (!executionWatch.valid()) return "execution_world_changed"; + return nullptr; + } // Canonically sorted by provider ID and offer semantics. Provider motion // within talking range is allowed, but used anchors never drift. bool validOffers(const std::vector& liveOffers) { diff --git a/server/src/playerbothuntwalk.h b/server/src/playerbothuntwalk.h index 5dd86b02..aa4b7510 100644 --- a/server/src/playerbothuntwalk.h +++ b/server/src/playerbothuntwalk.h @@ -25,14 +25,16 @@ extern ConfigManager g_config; // Restricted failures never establish unreachability. class PlayerBotHuntWalkSearch { public: - PlayerBotHuntWalkSearch(Position from, std::vector goals, uint64_t allowance) - : source(from), current(from), goals(std::move(goals)), allowance(allowance) {} + PlayerBotHuntWalkSearch(Position from, std::vector goals, uint64_t allowance, + std::set blockedPositions = {}) + : source(from), current(from), goals(std::move(goals)), blockedPositions(std::move(blockedPositions)), allowance(allowance) {} std::optional advance(Player& player, const PlayerBotNavigationCostPolicy& policy, - bool rope, bool shovel, const std::string& profile, uint64_t riskRevision, + bool rope, bool shovel, const std::string& actorProfile, uint64_t riskRevision, PlayerBotHuntRouteTiming& timing, uint64_t& budget, uint64_t planningPass) { if (result) return result; + const std::string profile = playerBotNavigationBlockedIdentity(actorProfile, blockedPositions); const auto& topology = PlayerBotTopology::instance(); if (!initialized) { initialized = true; @@ -69,7 +71,7 @@ class PlayerBotHuntWalkSearch { } if (coarse == PlayerBotHuntCoarseVerdict::Reachable) { ++timing.topologyQueries; - itinerary = topology.routeToAny(source, goals, {}, rope, shovel, player.getLevel(), &policy, + itinerary = topology.routeToAny(source, goals, blockedPositions, rope, shovel, player.getLevel(), &policy, &timing.topologyExpandedNodes); } // Off-graph endpoints and restricted itinerary failures retain live @@ -86,7 +88,8 @@ class PlayerBotHuntWalkSearch { if (!local) { if (expanded >= allowance) return result = PlayerBotNavigationResult::NodeLimit; segmentWatch = {}; - segmentKey = key(current, segmentGoals, profile, topology.generation(), riskRevision); + segmentKey = key(current, segmentGoals, playerBotNavigationCostIdentity(profile, policy.risk), + topology.generation(), riskRevision); bool reused = false; if (!unrestricted) { if (const auto* cached = cache().lookup(segmentKey)) { @@ -94,7 +97,7 @@ class PlayerBotHuntWalkSearch { reused = true; for (const auto& step : *cached) { PlayerBotNavigationStep live; - if (!navigator.resolveMove(player, cursor, step.direction, {}, live) || + if (!navigator.resolveMove(player, cursor, step.direction, blockedPositions, live) || live.expectedPosition != step.expectedPosition || !PlayerBotPathSearch::ordinaryArc(cursor, live)) { reused = false; break; } @@ -122,7 +125,7 @@ class PlayerBotHuntWalkSearch { if (local) { PlayerBotRouteChanges::Scope segmentDependencies(segmentWatch); const uint64_t before = local->expanded; - const auto state = navigator.advance(player, *local, {}, budget, &policy); + const auto state = navigator.advance(player, *local, blockedPositions, budget, &policy); const uint64_t used = local->expanded - before; expanded += used; timing.localExpandedNodes += used; @@ -155,7 +158,7 @@ class PlayerBotHuntWalkSearch { step.target = portal.target; step.expectedPosition = portal.destination; step.direction = portal.direction; step.itemId = portal.itemId; step.expectedItemId = portal.expectedItemId; step.topologyPortal = true; - if (!navigator.validateStep(player, current, step, {})) { + if (!navigator.validateStep(player, current, step, blockedPositions)) { fallback(timing, "crossing_changed"); return std::nullopt; } @@ -235,7 +238,7 @@ class PlayerBotHuntWalkSearch { const std::set> excluded{ {itinerary->nodes[arc], itinerary->nodes[arc + 1]}}; ++timing.topologyQueries; - auto alternate = topology.routeToAny(source, goals, {}, rope, shovel, player.getLevel(), + auto alternate = topology.routeToAny(source, goals, blockedPositions, rope, shovel, player.getLevel(), &policy, &timing.topologyExpandedNodes, &excluded); if (alternate) { corridor->addSeeds(alternate->nodes); @@ -265,7 +268,7 @@ class PlayerBotHuntWalkSearch { if (corridorStage) ++timing.corridorWidenings; } const auto before = local->expanded; - const auto state = PlayerBotNavigator{}.advance(player, *local, {}, budget, &policy, false, &*heuristic, &*corridor); + const auto state = PlayerBotNavigator{}.advance(player, *local, blockedPositions, budget, &policy, false, &*heuristic, &*corridor); const auto used = local->expanded - before; expanded += used; timing.localExpandedNodes += used; budget = 0; if (!state) return std::nullopt; @@ -318,7 +321,7 @@ class PlayerBotHuntWalkSearch { } PlayerBotRouteChanges::Scope treeDependencies(tree->watch); const auto before = tree->search.expanded; - const auto state = PlayerBotNavigator{}.advance(player, tree->search, {}, budget, &policy, false, &*heuristic); + const auto state = PlayerBotNavigator{}.advance(player, tree->search, blockedPositions, budget, &policy, false, &*heuristic); const auto used = tree->search.expanded - before; expanded += used; timing.localExpandedNodes += used; budget = 0; if (!state) return std::nullopt; @@ -375,6 +378,7 @@ class PlayerBotHuntWalkSearch { } static uint32_t add(uint32_t a, uint32_t b) { return static_cast(std::min(UINT32_MAX, uint64_t(a) + b)); } std::vector goals; + std::set blockedPositions; uint64_t allowance; bool initialized = false; size_t portalIndex = 0, activePortal = 0; diff --git a/server/src/playerbotnavigation.cpp b/server/src/playerbotnavigation.cpp index 2367c59d..1e8ce453 100644 --- a/server/src/playerbotnavigation.cpp +++ b/server/src/playerbotnavigation.cpp @@ -554,6 +554,7 @@ std::optional PlayerBotNavigator::advance( { const auto& start = search.start; const auto& goal = search.goal; + if (costPolicy) search.constrain(costPolicy->risk); if (costPolicy && search.rejectUnsafeEndpoints([&](const Position& target) { return costPolicy->dangerAt(target); })) { return search.result; } diff --git a/server/src/playerbotnavigation.h b/server/src/playerbotnavigation.h index b2f661f3..857bfc72 100644 --- a/server/src/playerbotnavigation.h +++ b/server/src/playerbotnavigation.h @@ -20,6 +20,7 @@ #include #include #include +#include #include #include @@ -236,8 +237,37 @@ struct PlayerBotNavigationRiskProfile { double healthLossCost = 1000.0; double maximumHealthLossPerSecond = 0.08; double maximumRouteHealthLoss = 0.50; + + bool operator==(const PlayerBotNavigationRiskProfile& other) const { + return healthLossCost == other.healthLossCost && maximumHealthLossPerSecond == other.maximumHealthLossPerSecond && + maximumRouteHealthLoss == other.maximumRouteHealthLoss; + } + bool operator!=(const PlayerBotNavigationRiskProfile& other) const { return !(*this == other); } + bool requiresZeroRisk() const { return maximumRouteHealthLoss == 0 && maximumHealthLossPerSecond == 0; } }; +// Shared route labels must not cross cost/legality policies. Hexfloat retains +// the exact floating-point limits, unlike rounded decimal keys. +inline std::string playerBotNavigationCostIdentity(const std::string& actor, + const PlayerBotNavigationRiskProfile& risk) +{ + std::ostringstream key; + key << actor << ':' << std::hexfloat << risk.healthLossCost << ':' << risk.maximumRouteHealthLoss << ':' + << risk.maximumHealthLossPerSecond; + return key.str(); +} + +// Transient exclusions are part of route/source-tree identity, not just +// dispatch validation. Canonical set order makes the same graph reusable. +inline std::string playerBotNavigationBlockedIdentity(const std::string& actor, const std::set& blocked) +{ + if (blocked.empty()) return actor; + std::ostringstream key; + key << actor << ";blocked"; + for (const auto& position : blocked) key << ':' << position.x << ',' << position.y << ',' << unsigned(position.z); + return key.str(); +} + inline bool playerBotNavigationPeakAccepts(double maximum, double peak) { return peak <= maximum; diff --git a/server/src/playerbotpathsearch.h b/server/src/playerbotpathsearch.h index ec62c9ac..1c3eaf1a 100644 --- a/server/src/playerbotpathsearch.h +++ b/server/src/playerbotpathsearch.h @@ -27,6 +27,16 @@ class PlayerBotPathSearch { open.push({0, 0, start}); } + // Zero-risk preparation excludes unsafe arcs during search, not after a + // cheaper dangerous path has displaced a longer safe label. Other total + // risk tolerances retain the existing post-search acceptance policy. + void constrain(const PlayerBotNavigationRiskProfile& risk) { + if (risk.requiresZeroRisk()) { + maximumPeakDanger = 0; + zeroDangerOnly = true; + } + } + // Call with the same positional peak samples used by incoming arcs, before // expanding. This is independent of graph size and preserves unknown limits. template @@ -97,7 +107,8 @@ class PlayerBotPathSearch { } } for (const auto& arc : expand(current.position)) { - if ((ordinaryOnly && !ordinaryArc(current.position, arc.step)) || !playerBotNavigationPeakAccepts(maximumPeakDanger, arc.peak)) continue; + if ((ordinaryOnly && !ordinaryArc(current.position, arc.step)) || + (zeroDangerOnly && arc.danger != 0) || !playerBotNavigationPeakAccepts(maximumPeakDanger, arc.peak)) continue; const Position to = arc.step.expectedPosition; const uint32_t cost = add(current.cost, add(arc.movement, arc.danger)); const auto found = costs.find(key(to)); @@ -193,6 +204,7 @@ class PlayerBotPathSearch { bool unbounded = false; // Hunt-only necessary safety condition; generic navigation is unrestricted. double maximumPeakDanger = std::numeric_limits::infinity(); + bool zeroDangerOnly = false; private: struct Node { diff --git a/server/src/playerbotpreparation.h b/server/src/playerbotpreparation.h new file mode 100644 index 00000000..b4350a83 --- /dev/null +++ b/server/src/playerbotpreparation.h @@ -0,0 +1,94 @@ +#ifndef FS_PLAYERBOTPREPARATION_H +#define FS_PLAYERBOTPREPARATION_H + +#include "playerbotsupplystock.h" +#include "playerbotnavigation.h" + +#include +#include +#include + +// Domain evaluators own the requirement. Pending means missing evidence, not +// failure or permission to execute a route. Preparation never carries a hunt route. +enum class PlayerBotReadiness { Pending, Executable, Blocked }; +enum class PlayerBotRequirementKind { Stock, HealthRegeneration }; +struct PlayerBotPreparationRequirement { + PlayerBotRequirementKind kind = PlayerBotRequirementKind::Stock; + PlayerBotSupplyRequirements stocks; + uint32_t health = 0; + bool observeRegeneration = false; +}; +struct PlayerBotGoalReadiness { + PlayerBotReadiness state = PlayerBotReadiness::Pending; + std::optional requirement; +}; +enum class PlayerBotPreparationOption { None, RestoreHealth, Regenerate, BuyFood, ServiceStock, RecoveryIncome }; + +inline bool playerBotPreparationProviderInRange(const Position& from, const Position& provider) +{ + return Position::getDistanceZ(from, provider) <= 2 && + std::max(Position::getDistanceX(from, provider), Position::getDistanceY(from, provider)) <= 32; +} + +template +std::vector playerBotPreparationRestPositions(const Position& from, SafeRest safe) +{ + std::vector candidates; + // Geometry is only discovery, never reachability. One any-of route proof + // can find a reachable upstairs endpoint instead of testing roofs one by one. + for (int radius = 0; radius <= 32; ++radius) { + for (int dz : {0, -1, 1, -2, 2}) { + const int z = from.z + dz; + if (z < 0 || z >= 16) continue; // Loaded engine map has 16 layers. + for (int dx = -radius; dx <= radius; ++dx) for (int dy = -radius; dy <= radius; ++dy) { + if (std::max(std::abs(dx), std::abs(dy)) != radius) continue; + const int x = from.x + dx, y = from.y + dy; + if (x < 0 || x > UINT16_MAX || y < 0 || y > UINT16_MAX) continue; + const Position candidate(x, y, z); + if (safe(candidate)) candidates.push_back(candidate); + if (candidates.size() == 512) return candidates; + } + } + } + return candidates; +} + +// All rest, provider, and post-purchase rest proofs share one finite allowance. +// Work is also admitted/charged by the dispatcher-wide planning budget. +class PlayerBotPreparationRouteBudget { + public: + uint64_t allowance() const { return proofs < 8 ? std::min(4096, remaining) : 0; } + void observed(uint64_t nodes) { remaining -= std::min(remaining, nodes); ++proofs; } + private: + uint64_t remaining = 16384; + uint32_t proofs = 0; +}; + +inline bool playerBotPreparationRouteAccepted(PlayerBotNavigationResult result, uint32_t dangerCost, + double peakDanger, uint64_t fare, bool endpointReached) +{ + return result == PlayerBotNavigationResult::Reached && endpointReached && fare == 0 && + dangerCost == 0 && peakDanger == 0; +} + +// A finite observation/action cycle, not a dependency-search engine. Improvement +// is measured against the best requirement gap, not currency/inventory identity. +class PlayerBotPreparationBudget { + public: + bool completed(uint64_t gap, bool regenerationObserved = false, + PlayerBotRequirementKind kind = PlayerBotRequirementKind::HealthRegeneration) + { + auto& bestGap = bestGaps[kind == PlayerBotRequirementKind::Stock ? 0 : 1]; + if (!bestGap || gap < *bestGap || (regenerationObserved && !observedRegeneration)) attempts = 0; + bestGap = bestGap ? std::min(*bestGap, gap) : gap; + observedRegeneration = observedRegeneration || regenerationObserved; + return ++attempts >= 3; + } + void reset() { bestGaps = {}; attempts = 0; observedRegeneration = false; } + private: + std::array, 2> bestGaps; + uint32_t attempts = 0; + bool observedRegeneration = false; +}; + +#endif diff --git a/server/src/playerbotprogression.cpp b/server/src/playerbotprogression.cpp index c4090d7d..7c9f9119 100644 --- a/server/src/playerbotprogression.cpp +++ b/server/src/playerbotprogression.cpp @@ -940,6 +940,16 @@ void PlayerBotController::emitGoalCandidate(const Player& player, const GoalCand << ",\"evaluated\":" << (evaluated ? "true" : "false") << ",\"feasible\":" << (candidate.feasible ? "true" : "false") << ",\"utility\":" << candidate.utility << ",\"reason\":" << jsonString(candidate.reason); + if (candidate.goal == TopLevelGoal::Hunt) { + const auto& readiness = huntSupplyHandoff.readiness(); + fields << ",\"readiness\":" << jsonString(readiness.state == PlayerBotReadiness::Pending ? "pending" : + readiness.state == PlayerBotReadiness::Executable ? "executable" : "blocked"); + if (readiness.requirement) { + fields << ",\"requirement\":" << jsonString(readiness.requirement->kind == PlayerBotRequirementKind::Stock ? + "stock" : "health_regeneration") << ",\"required_health\":" << readiness.requirement->health + << ",\"observe_regeneration\":" << (readiness.requirement->observeRegeneration ? "true" : "false"); + } + } if (candidate.goal == TopLevelGoal::Service) { const PlayerBotFoodInventory food = inventoryPolicy.foodInventory(player); const uint16_t potionItemId = recoveryPotionItemId(player.getVocationId()); @@ -1018,6 +1028,10 @@ void PlayerBotController::beginPickupReward(Player& player, const Position& posi bool PlayerBotController::selectTopLevelGoal(Player& player, const Position& position, const char* decisionReason) { + trainerGoalSelectionPending = false; + if (preparationOption != PlayerBotPreparationOption::None || + serviceWorkflow.intent() == PlayerBotServiceIntent::PreparationPurchase) + retirePreparation(&player, position, "arbitration_reentered"); std::string completedHuntReason; completedHuntReason.swap(pendingHuntCompletionReason); // Carry the hunt outcome across depot work, but never relabel an explicit interruption. @@ -1040,8 +1054,31 @@ bool PlayerBotController::selectTopLevelGoal(Player& player, const Position& pos return true; } if (requiredGoal == TopLevelGoal::Departure) { + clearTrainerWork(); return forceOracleDeparture(player, position, decisionReason); } + const auto now = std::chrono::steady_clock::now(); + PlayerBotSpellTrainingPlan spellTraining; + const bool spellTrainingCoolingDown = progressionRuntime.isCoolingDown(TopLevelGoal::LearnSpell, now); + const std::string trainerFacts = trainerDecisionFacts(player, position, spellTrainingCoolingDown); + bool trainerProofValid = trainerDecisionWatch.valid(); + if (selectedTrainerRoute) { + trainerProofValid = trainerProofValid && selectedTrainerRoute->evidence && + huntTravelEvidenceValid(player, position, selectedTrainerRoute->destinations.front(), *selectedTrainerRoute->evidence); + } + const auto trainerDecision = trainerArbitration.advanceTrainer(trainerFacts, trainerProofValid, [&] { + if (!trainerDiscovery) { + selectedTrainerRoute.reset(); + trainerDecisionWatch = {}; + } + PlayerBotSpellTrainingPlan plan; + const bool found = !spellTrainingCoolingDown && findSpellTraining(player, position, plan); + return PlayerBotTrainerArbitration::Result{ + trainerDiscovery.has_value(), found ? std::optional(std::move(plan)) : std::nullopt}; + }); + if (trainerDecision.pending) return true; // Only preparation may request the next admission after completion. + const bool spellTrainingFound = trainerDecision.selected.has_value(); + if (spellTrainingFound) spellTraining = *trainerDecision.selected; refreshItemValues(); PlayerBotOracleDeparturePlan departure; std::deque departureRoute; @@ -1050,7 +1087,6 @@ bool PlayerBotController::selectTopLevelGoal(Player& player, const Position& pos const bool departureFound = departureEligible && findOracleDeparture(player, position, departure, departureRoute); PlayerBotRewardPlan reward; std::deque rewardSteps; - const auto now = std::chrono::steady_clock::now(); const bool pickupCoolingDown = progressionRuntime.isCoolingDown(TopLevelGoal::PickupReward, now); const bool pickupFound = !pickupCoolingDown && findPickupReward(player, position, reward, rewardSteps); const PlayerBotRewardPlannerSnapshot rewardSnapshot{ @@ -1059,10 +1095,6 @@ bool PlayerBotController::selectTopLevelGoal(Player& player, const Position& pos static_cast(riskProfile.maximumRouteHealthLoss * riskProfile.healthLossCost), {}, }; const int32_t pickupUtility = pickupFound ? rewardPlanner.utility(reward, rewardSnapshot) : 0; - PlayerBotSpellTrainingPlan spellTraining; - std::deque spellTrainingSteps; - const bool spellTrainingCoolingDown = progressionRuntime.isCoolingDown(TopLevelGoal::LearnSpell, now); - const bool spellTrainingFound = !spellTrainingCoolingDown && findSpellTraining(player, position, spellTraining, spellTrainingSteps); const bool equipmentPurchaseCoolingDown = progressionRuntime.isCoolingDown(TopLevelGoal::BuyEquipment, now); const std::optional equipment = equipmentPurchaseCoolingDown ? std::nullopt : evaluateEquipmentOffers(player, position); @@ -1073,7 +1105,7 @@ bool PlayerBotController::selectTopLevelGoal(Player& player, const Position& pos const bool sellLootCoolingDown = progressionRuntime.isCoolingDown(TopLevelGoal::SellLoot, now); const uint32_t sellable = saleableItemCount(player); const bool lowCapacity = inventoryPolicy.huntFreeCapacity(player) < returnCapacityThreshold; - const PlayerBotGoalPlannerSnapshot snapshot{ + PlayerBotGoalPlannerSnapshot snapshot{ departurePlanner.required(departureSnapshot(player)), departureEligible, departureFound, player.getVocation()->getId() != 0, player.getLevel() < oracleMinimumLevel, player.getLevel() > oracleMaximumLevel, @@ -1092,6 +1124,10 @@ bool PlayerBotController::selectTopLevelGoal(Player& player, const Position& pos spellTrainingFound && playerBotSpellDescriptor(spellTraining.spellName.c_str()) && playerBotSpellDescriptor(spellTraining.spellName.c_str())->role == PlayerBotSpellRole::Healing, }; + if (huntSupplyHandoff.refresh(huntSupplyFacts(player))) preparationBudget.reset(); + snapshot.huntReadiness = huntSupplyHandoff.readiness(); + snapshot.preparations = evaluatePreparation(player, position); + if (preparationEvaluationPending) return true; // Deferred work scheduled its budget retry. const PlayerBotGoalArbiter::GoalDecision decision = progressionRuntime.selectGoal(snapshot); emitGoalCandidate(player, decision.candidate(TopLevelGoal::Departure), decision.id, position, decisionReason, nullptr, departureFound ? &departure : nullptr); @@ -1103,6 +1139,9 @@ bool PlayerBotController::selectTopLevelGoal(Player& player, const Position& pos emitGoalCandidate(player, decision.candidate(TopLevelGoal::MagicTraining), decision.id, position, decisionReason); emitGoalCandidate(player, decision.candidate(TopLevelGoal::SellLoot), decision.id, position, decisionReason); emitGoalCandidate(player, decision.candidate(TopLevelGoal::Hunt), decision.id, position, decisionReason); + for (const auto& candidate : decision.candidates) { + if (candidate.goal == TopLevelGoal::Prepare) emitGoalCandidate(player, candidate, decision.id, position, decisionReason); + } if (fixtureDriver.equipmentStorageObservation().pause) { return false; } @@ -1115,6 +1154,7 @@ bool PlayerBotController::selectTopLevelGoal(Player& player, const Position& pos return false; } const GoalCandidate& selected = *decision.selected; + if (selected.goal != TopLevelGoal::LearnSpell) clearTrainerWork(); std::ostringstream fields; fields << "\"decision_id\":" << decision.id << ",\"decision_reason\":" << jsonString(decisionReason) << ",\"from_goal\":" << jsonString(PlayerBotGoalArbiter::goalName(decision.previousGoal)) @@ -1138,14 +1178,36 @@ bool PlayerBotController::selectTopLevelGoal(Player& player, const Position& pos << ",\"manifest_batches\":" << sellLootPlan->batches.size(); } emit("goal_selection", position, fields.str()); + if (selected.goal != TopLevelGoal::Prepare) retirePreparation(&player, position, "goal_changed"); if (selected.goal == TopLevelGoal::Departure) { beginOracleDeparture(player, position, std::move(departure), std::move(departureRoute)); } else if (selected.goal == TopLevelGoal::PickupReward) { beginPickupReward(player, position, std::move(reward), std::move(rewardSteps)); } else if (selected.goal == TopLevelGoal::LearnSpell) { - beginSpellTraining(player, position, std::move(spellTraining), std::move(spellTrainingSteps)); + beginSpellTraining(player, position, std::move(spellTraining)); } else if (selected.goal == TopLevelGoal::BuyEquipment) { beginEquipmentPurchase(player, position, *equipment); + } else if (selected.goal == TopLevelGoal::Prepare) { + finishHuntRegion(player, position, "preparation_selected"); + preparationOption = selected.preparation; + preparationRequirement = snapshot.huntReadiness.requirement; + if (preparationRequirement && !preparationRequirement->observeRegeneration && + player.getHealth() < static_cast(preparationRequirement->health)) { + emit("action_result", position, "\"action\":\"hunt_departure_health\",\"result\":\"started\",\"health\":" + + std::to_string(player.getHealth()) + ",\"required_health\":" + std::to_string(preparationRequirement->health)); + } + preparationDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(60); + cancelHuntPlanning("preparation_selected", position); + selectedHuntRoute.reset(); + huntRouteRetention.clear(); + resetNavigation(); + setCyclePhase(CyclePhase::Idle, position, "preparation_selected"); + setStage(ScenarioStage::Traverse, position); + if (preparationOption == PlayerBotPreparationOption::BuyFood) { + serviceWorkflow.reset(PlayerBotServiceIntent::PreparationPurchase); + serviceWorkflow.setSurvivalRestock(true); + } + schedule(SCHEDULER_MINTICKS); } else if (selected.goal == TopLevelGoal::Service) { beginService(&player, position, "goal_selected"); } else if (selected.goal == TopLevelGoal::MagicTraining) { @@ -1154,6 +1216,7 @@ bool PlayerBotController::selectTopLevelGoal(Player& player, const Position& pos setCyclePhase(CyclePhase::DepositLoot, position, "sell_loot_selected"); } else { sellLootPlan.reset(); + if (snapshot.huntReadiness.state == PlayerBotReadiness::Blocked) huntSupplyHandoff.replan(); startHunt(&player, position, "goal_selected"); } return true; @@ -1171,6 +1234,10 @@ const char* PlayerBotController::objectiveName() const void PlayerBotController::finishProgressionObjective(Player* player, const Position& position, const char* result, const char* reason, bool scheduleNext) { + if (progressionRuntime.session().active(PlayerBotProgressionProcedure::LearnSpell)) { + finishSpellTraining(player, position, result, reason); + return; + } const auto& reward = progressionRuntime.reward().plan(); std::ostringstream fields; fields << "\"goal\":\"pickup_reward\",\"acquisition_source\":\"map_reward\",\"candidate_id\":" << reward.uniqueId diff --git a/server/src/playerbotprogressionplanners.cpp b/server/src/playerbotprogressionplanners.cpp index 4de6cb38..a4b8e656 100644 --- a/server/src/playerbotprogressionplanners.cpp +++ b/server/src/playerbotprogressionplanners.cpp @@ -9,6 +9,7 @@ #include "playerbotprogressionplanners.h" #include "playerbotsupplypolicy.h" +#include "playerbottrainerroute.h" namespace { // TFS inventory-slot values; keep this pure planner independent of Creature. @@ -50,7 +51,9 @@ PlayerBotSpellTrainingDecision PlayerBotSpellTrainingPlanner::select(const Playe !offer.premiumEligible ? "premium_ineligible" : offer.known ? "already_learned" : !offer.worthLearning ? "uneconomical_healing_spell" : !offer.suppliesReady ? "supply_reserve_unmet" : !reserveAvailable ? "recovery_reserve_unavailable" : !playerBotAffordableAfterReserve(snapshot.totalMoney, reserve, offer.price) ? "unaffordable_after_reserves" : - !offer.route.reachable ? "trainer_unreachable" : + !offer.routeRejection.empty() ? offer.routeRejection.c_str() : + !offer.route.reachable ? "trainer_unreachable" : + !playerBotTrainerTripAffordable(snapshot.totalMoney, reserve, offer.price, offer.fare) ? "trip_fare_unaffordable" : offer.route.dangerCost > snapshot.maximumRouteDangerCost ? "route_danger_above_tolerance" : offer.route.maximumDanger > snapshot.maximumRouteDanger ? "route_peak_danger_above_tolerance" : nullptr; if (rejection) { @@ -58,13 +61,13 @@ PlayerBotSpellTrainingDecision PlayerBotSpellTrainingPlanner::select(const Playe continue; } PlayerBotSpellTrainingPlan candidate{offer.npcId, offer.npcPosition, offer.route.approachPosition, offer.spellName, - offer.keyword, offer.price, offer.level, offer.premium, offer.route.steps, reserve, offer.potionReserve}; + offer.keyword, offer.price, offer.level, offer.premium, offer.route.steps, reserve, offer.potionReserve, offer.fare}; const PlayerBotSpellOfferSnapshot* selectedOffer = decision.selectedOfferIndex ? &snapshot.offers[*decision.selectedOfferIndex] : nullptr; if (!selectedOffer || - std::tie(offer.learningPriority, offer.level, offer.price, candidate.travelSteps, offer.spellName, offer.npcId) < - std::tie(selectedOffer->learningPriority, selectedOffer->level, selectedOffer->price, - decision.selected->travelSteps, selectedOffer->spellName, selectedOffer->npcId)) { + std::tuple_cat(playerBotTrainerPriority(offer), std::tie(candidate.travelSteps, offer.spellName, offer.npcId)) < + std::tuple_cat(playerBotTrainerPriority(*selectedOffer), + std::tie(decision.selected->travelSteps, selectedOffer->spellName, selectedOffer->npcId))) { decision.selected = std::move(candidate); decision.selectedOfferIndex = offerIndex; } diff --git a/server/src/playerbotprogressionplanners.h b/server/src/playerbotprogressionplanners.h index c245e23f..47747fba 100644 --- a/server/src/playerbotprogressionplanners.h +++ b/server/src/playerbotprogressionplanners.h @@ -155,6 +155,9 @@ struct PlayerBotSpellOfferSnapshot { std::optional reserve; uint32_t potionReserve = 1; bool worthLearning = true; + uint64_t fare = 0; + // Empty for a completed route or a candidate rejected before route work. + std::string routeRejection; }; struct PlayerBotSpellTrainingPlannerSnapshot { diff --git a/server/src/playerbotprogressionruntime.cpp b/server/src/playerbotprogressionruntime.cpp index ac75a8f0..1c216ea3 100644 --- a/server/src/playerbotprogressionruntime.cpp +++ b/server/src/playerbotprogressionruntime.cpp @@ -151,6 +151,12 @@ void PlayerBotProgressionRuntime::restartSpellTrainingConversation() spellTrainingSession.resetRetries(); } +void PlayerBotProgressionRuntime::restartSpellTrainingTravel() +{ + npcSession.reset(spellTrainingSession.plan().npcId); + spellTrainingSession.setStage(PlayerBotSpellTrainingStage::Travel); +} + void PlayerBotProgressionRuntime::restartEquipmentConversation() { npcSession.reset(equipmentPurchaseSession.plan().npcId); diff --git a/server/src/playerbotprogressionruntime.h b/server/src/playerbotprogressionruntime.h index e4a8b495..c608d33e 100644 --- a/server/src/playerbotprogressionruntime.h +++ b/server/src/playerbotprogressionruntime.h @@ -177,6 +177,7 @@ class PlayerBotProgressionRuntime { void clearGreetingAcknowledgement() { npcSession.resetGreetingAcknowledgement(); } void restartDepartureConversation(); void restartSpellTrainingConversation(); + void restartSpellTrainingTravel(); void restartEquipmentConversation(); void beginEquipmentBackpackRecovery(const char* reason, bool purchased); bool equipmentBackpackRecoveryActive() const { return equipmentPurchaseSession.backpackRecoveryActive(); } diff --git a/server/src/playerbotprogressionsession.h b/server/src/playerbotprogressionsession.h index 0cde6cb0..ac1c0826 100644 --- a/server/src/playerbotprogressionsession.h +++ b/server/src/playerbotprogressionsession.h @@ -157,6 +157,7 @@ struct PlayerBotSpellTrainingPlan { uint32_t travelSteps = 0; uint64_t reserve = 0; uint32_t potionReserve = 1; + uint64_t fare = 0; }; enum class PlayerBotSpellTrainingStage : uint8_t { diff --git a/server/src/playerbotservice.cpp b/server/src/playerbotservice.cpp index ab9e6a58..94937633 100644 --- a/server/src/playerbotservice.cpp +++ b/server/src/playerbotservice.cpp @@ -815,7 +815,7 @@ void PlayerBotController::deferSellLoot(Player& player, const Position& position ",\"npc_id\":" + std::to_string(sellLootPlan->providerId) + ",\"cooldown_ms\":" + std::to_string(std::chrono::duration_cast(sellLootFailureCooldown).count())); progressionRuntime.completeSellLoot(sellLootFailureCooldown); - serviceWorkflow.reset(); + serviceWorkflow.reset(PlayerBotServiceIntent::Resupply, supplyRecovery.active()); sellLootPlan.reset(); beginReturn(&player, position, reason); } @@ -861,7 +861,7 @@ bool PlayerBotController::processSellLootWithdrawal(Player& player, const Positi for (const SellLootBatch& batch : plan.batches) { batches.push_back({batch.itemId, batch.count, batch.price, batch.subType}); } - serviceWorkflow.reset(PlayerBotServiceIntent::ResupplyWithLocalSale); + serviceWorkflow.reset(PlayerBotServiceIntent::ResupplyWithLocalSale, supplyRecovery.active()); serviceWorkflow.setLiquidationPlan({plan.providerId, std::move(batches), 0, 0, plan.fare - plan.sourceFare, plan.sellerAllowNpcTravel}); if (progressionRuntime.activeGoal() != TopLevelGoal::Service) { @@ -977,6 +977,8 @@ void PlayerBotController::setCyclePhase(CyclePhase phase, const Position& positi void PlayerBotController::beginReturn(Player* player, const Position& position, const char* reason) { + clearTrainerWork(); + retirePreparation(player, position, reason); emitDepotDiscovery(playerbot::PlayerBotDepotTelemetry::Result::Cancelled, position, reason); huntTravelBudgetPhase = HuntTravelBudgetPhase::ReturnToDepot; huntExitValidationAttempted = false; @@ -1017,6 +1019,8 @@ void PlayerBotController::onNpcReply(uint32_t replyingPlayerId, uint32_t npcId, void PlayerBotController::beginService(Player* player, const Position& position, const char* reason) { + clearTrainerWork(); + retirePreparation(player, position, reason); updateSupplyRecovery(*player, position); const bool interruptedHunt = fixtureDriver.progressionGoalLoop(true).selectGoal && progressionRuntime.activeGoal() == TopLevelGoal::Hunt && !departurePlanner.hasCompleted(departureSnapshot(*player)); @@ -1039,13 +1043,14 @@ void PlayerBotController::beginService(Player* player, const Position& position, huntCoordinator.resetLoot(); player->closeContainer(corpseContainerId); setStage(ScenarioStage::Traverse, position); - serviceWorkflow.reset(); - serviceWorkflow.setSurvivalRestock(supplyRecovery.active()); + serviceWorkflow.reset(PlayerBotServiceIntent::Resupply, supplyRecovery.active()); beginReturn(player, position, reason); } void PlayerBotController::updateSupplyRecovery(const Player& player, const Position& position) { + if (huntSupplyHandoff.refresh(huntSupplyFacts(const_cast(player)))) + supplyRecovery.clearRestockDeferral(); const uint64_t funds = player.getMoney() + player.getBankBalance(); const bool wasActive = supplyRecovery.active(); supplyRecovery.restockBlocked(funds, playerBotSupplyStockKey(supplyStocks(player))); @@ -1138,6 +1143,7 @@ uint32_t PlayerBotController::serviceDistance(const Position& from, const Servic void PlayerBotController::processService(Player* player, const Position& currentPosition) { const PlayerBotServiceSnapshot service = serviceWorkflow.snapshot(); + const bool preparationPurchase = serviceWorkflow.intent() == PlayerBotServiceIntent::PreparationPurchase; const bool localSaleService = serviceWorkflow.intent() == PlayerBotServiceIntent::ResupplyWithLocalSale; const bool sellingLocalLoot = localSaleService && serviceWorkflow.liquidation().has_value() && (service.stage == PlayerBotServiceStage::Discover || @@ -1166,7 +1172,14 @@ void PlayerBotController::processService(Player* player, const Position& current const PlayerBotSupplyStocks stocks = supplyStocks(*player); for (const PlayerBotSupplyStock& stock : stocks) { observation.supplies.push_back({stock.rule.kind, stock.rule.itemId, Item::items[stock.rule.itemId].weight, - stock.rule.returnThreshold, stock.rule.target, stock.rule.safetyFloor}); + stock.rule.returnThreshold, stock.rule.target, stock.rule.safetyFloor, + stock.rule.preferredStock}); + } + if (preparationPurchase) { + // One required preparation item, not optional food behind potion floors. + observation.supplies = {{std::nullopt, preparationFoodItem, + Item::items[preparationFoodItem].weight, 0, 1, 1, 0}}; + observation.supplyCapacityReserve = 0; } Item* serviceBackpackItem = player->getInventoryItem(CONST_SLOT_BACKPACK); Container* serviceBackpack = serviceBackpackItem ? serviceBackpackItem->getContainer() : nullptr; @@ -1228,6 +1241,7 @@ void PlayerBotController::processService(Player* player, const Position& current size_t retainedBankers = 0; std::vector matchingNpcs; for (Npc* npc : serviceNpcs) { + if (preparationPurchase && npc->getID() != preparationFoodProvider) continue; if (sellingLocalLoot && npc->getID() != serviceWorkflow.liquidation()->providerId) continue; if (unavailableProviderIds.find(npc->getID()) != unavailableProviderIds.end()) continue; const bool active = npc->getID() == approachProviderId || Position::areInRange<3, 3, 0>(currentPosition, npc->getPosition()); @@ -1382,6 +1396,7 @@ void PlayerBotController::processService(Player* player, const Position& current routeObservation.approachRoute.providerId = routeProviderId; routeObservation.approachRoute.destination = command.destination; const auto startedAt = std::chrono::steady_clock::now(); + std::optional routeCharge; PlayerBotNavigationRoutePlan routePlan; bool validatedNpcTravel = false; // Failed steps mark their tile blocked for a while, usually a wandering @@ -1435,7 +1450,7 @@ void PlayerBotController::processService(Player* player, const Position& current blockedRouteRetryInterval, 5000))); return; } - PlayerBotPlanningBudget::Charge charge(budget, playerId); + routeCharge.emplace(budget, playerId); // Includes any live-blocker detour below. { struct WorkSlot { std::shared_ptr& hunt; @@ -1448,12 +1463,16 @@ void PlayerBotController::processService(Player* player, const Position& current // A nearby safe walk needs no search through every paid connection, // including on a sale: a fare-free walk cannot exceed a quoted fare. request.preferSafeWalking = playerBotNavigationDistance(currentPosition, command.destination) <= 128; + request.walkingOnly = preparationPurchase; // Discovery proved a fare-free walking route. request.from = currentPosition; request.to = command.destination; PlayerBotHuntRouteTiming timing; PlayerBotNavigationRoutePlan walking; + PlayerBotNavigationRiskProfile preparationRisk = riskProfile; + preparationRisk.maximumRouteHealthLoss = preparationRisk.maximumHealthLossPerSecond = 0; auto planned = advanceHuntTravelRoute(*player, request, currentPosition, timing, reserve, - sellEconomy, &walking); + sellEconomy, &walking, nullptr, + preparationPurchase ? &preparationRisk : nullptr); if (timing.invalidations != 0 || timing.transportRestarts != 0 || timing.requestRestartLimits != 0 || timing.transportRestartLimits != 0) { emit("service_route_restarted", currentPosition, @@ -1505,9 +1524,13 @@ void PlayerBotController::processService(Player* player, const Position& current std::any_of(routePlan.steps.begin(), firstTravel, [&blockedPositions](const auto& step) { return blockedPositions.count(step.target) || blockedPositions.count(step.expectedPosition); })) { - auto detour = planCompleteNavigationRoute(*player, command.destination, blockedPositions); + PlayerBotNavigationRiskProfile detourRisk = riskProfile; + if (preparationPurchase) detourRisk.maximumRouteHealthLoss = detourRisk.maximumHealthLossPerSecond = 0; + auto detour = preparationPurchase ? planNavigationRoute(*player, + PlayerBotNavigationGoal::exact(command.destination), blockedPositions, 4096, false, &detourRisk) : + planCompleteNavigationRoute(*player, command.destination, blockedPositions); if (detour.metrics.result == PlayerBotNavigationResult::Reached && !detour.steps.empty() && - playerBotNavigationRiskVerdict(riskProfile, detour.metrics) == PlayerBotNavigationRiskVerdict::Accepted) { + playerBotNavigationRiskVerdict(detourRisk, detour.metrics) == PlayerBotNavigationRiskVerdict::Accepted) { routePlan = std::move(detour); validatedNpcTravel = false; } else if (serviceWorkflow.approach().blocked() == PlayerBotApproachVerdict::Wait) { @@ -1521,10 +1544,12 @@ void PlayerBotController::processService(Player* player, const Position& current } if (!routeBlocked) serviceWorkflow.approach().unblocked(); const auto* quote = sellingLocalLoot ? &*serviceWorkflow.liquidation() : nullptr; - const bool quoteAllowed = !quote || (routePlan.metrics.fare <= quote->maximumFare && - (quote->maximumRouteSteps == 0 || routePlan.metrics.steps <= quote->maximumRouteSteps)); + const bool quoteAllowed = (!preparationPurchase || routePlan.metrics.fare == 0) && + (!quote || (routePlan.metrics.fare <= quote->maximumFare && + (quote->maximumRouteSteps == 0 || routePlan.metrics.steps <= quote->maximumRouteSteps))); const bool routeSafe = command.destination == currentPosition || - playerBotNavigationRiskVerdict(riskProfile, routePlan.metrics) == PlayerBotNavigationRiskVerdict::Accepted; + (preparationPurchase ? routePlan.metrics.dangerCost == 0 && routePlan.metrics.maximumHealthLossPerSecond == 0 : + playerBotNavigationRiskVerdict(riskProfile, routePlan.metrics) == PlayerBotNavigationRiskVerdict::Accepted); const bool routeAffordable = command.destination == currentPosition || huntTravelFareAffordable(*player, routePlan.metrics.fare, huntTravelBudgetPhase); @@ -1620,8 +1645,9 @@ void PlayerBotController::processService(Player* player, const Position& current if (transaction.itemId != 0) { const auto supply = std::find_if(observation.supplies.begin(), observation.supplies.end(), [&transaction](const PlayerBotServiceSupply& value) { return value.itemId == transaction.itemId; }); - const PlayerBotSupplyKind kind = supply == observation.supplies.end() ? PlayerBotSupplyKind::HealthPotion : supply->kind; - const char* action = sellingLocalLoot ? "sell" : kind == PlayerBotSupplyKind::Ammunition ? "buy_ammunition" : + const PlayerBotSupplyKind kind = supply == observation.supplies.end() ? PlayerBotSupplyKind::HealthPotion : + supply->kind.value_or(PlayerBotSupplyKind::HealthPotion); + const char* action = preparationPurchase ? "buy_food" : sellingLocalLoot ? "sell" : kind == PlayerBotSupplyKind::Ammunition ? "buy_ammunition" : kind == PlayerBotSupplyKind::ThrowingWeapon ? "buy_throwing_weapons" : "buy_potions"; emit("action_result", currentPosition, "\"action\":" + jsonString(action) + ",\"result\":\"success\",\"item_id\":" + std::to_string(transaction.itemId) + ",\"count\":" + std::to_string(transaction.amount) + @@ -1648,12 +1674,16 @@ void PlayerBotController::processService(Player* player, const Position& current updateSupplyRecovery(*player, currentPosition); } if (command.type == PlayerBotServiceCommandType::Fail) { + if (preparationPurchase) { + finishPreparation(*player, currentPosition, "food_transaction_failed"); + return; + } if (sellingLocalLoot) { deferSellLoot(*player, currentPosition, "provider_or_transaction_invalidated"); return; } if (localSaleService && command.outcome == PlayerBotServiceOutcome::InsufficientFunds) { - serviceWorkflow.reset(); + serviceWorkflow.reset(PlayerBotServiceIntent::Resupply, supplyRecovery.active()); beginReturn(player, currentPosition, "sell_trip_insufficient_funds"); return; } @@ -1662,14 +1692,15 @@ void PlayerBotController::processService(Player* player, const Position& current playerBotSupplyStockKey(supplyStocks(*player))); const auto supply = std::find_if(observation.supplies.begin(), observation.supplies.end(), [&command](const PlayerBotServiceSupply& value) { return value.itemId == command.itemId; }); - const PlayerBotSupplyKind kind = supply == observation.supplies.end() ? PlayerBotSupplyKind::HealthPotion : supply->kind; + const PlayerBotSupplyKind kind = supply == observation.supplies.end() ? PlayerBotSupplyKind::HealthPotion : + supply->kind.value_or(PlayerBotSupplyKind::HealthPotion); const std::string reason = kind == PlayerBotSupplyKind::HealthPotion ? "insufficient_potion_funds" : "insufficient_" + std::string(playerBotSupplyKindName(kind)) + "_funds"; // Unaffordable supplies degrade operation instead of stopping: hunt // and sell under supply recovery until loot funds a restock. enterSupplyRecovery(currentPosition, observation.money + observation.bankBalance, recoverySpendingReserve(*player, potionStockTarget(*player)), reason.c_str()); - serviceWorkflow.reset(); + serviceWorkflow.reset(PlayerBotServiceIntent::Resupply, supplyRecovery.active()); if (fixtureDriver.progressionGoalLoop(true).selectGoal) { selectTopLevelGoal(*player, currentPosition, "supply_recovery_potions_unaffordable"); } else { @@ -1726,9 +1757,12 @@ void PlayerBotController::processService(Player* player, const Position& current // Execute only validated steps. An exhausted leg returns to detailed // service validation, never ordinary navigation planning. PlayerBotNavigationRuntimeOutcome navigation; + PlayerBotNavigationRiskProfile preparationRisk = riskProfile; + preparationRisk.maximumRouteHealthLoss = preparationRisk.maximumHealthLossPerSecond = 0; const bool reached = processNavigation(player, currentPosition, PlayerBotNavigationGoal::exact(command.destination), &navigation, - playerBotNavigationMaximumExpandedNodes, false, false, nullptr, false); + playerBotNavigationMaximumExpandedNodes, false, false, + preparationPurchase ? &preparationRisk : nullptr, false); if (reached || navigation.routeRequest) schedule(SCHEDULER_MINTICKS); // A failed step clears the route. The next turn revalidates around the // blocked tile and rejects this approach only if no route avoids it. @@ -1820,6 +1854,16 @@ void PlayerBotController::processService(Player* player, const Position& current return; } if (command.type == PlayerBotServiceCommandType::Complete) { + if (preparationPurchase) { + // Workflow completion follows the normal stock/currency verification. + // It does not prove regeneration or authorize a hunt. + retirePreparationService(player, currentPosition, "food_transaction_completed"); + preparationOption = PlayerBotPreparationOption::Regenerate; + resetNavigation(); + schedule(SCHEDULER_MINTICKS); + return; + } + huntSupplyHandoff.replan(); const PlayerBotSupplyStocks completedStocks = supplyStocks(*player); const uint64_t stockKey = playerBotSupplyStockKey(completedStocks); if (playerBotMandatorySupplyDeficit(completedStocks, false).missing != 0 && diff --git a/server/src/playerbotserviceworkflow.cpp b/server/src/playerbotserviceworkflow.cpp index 51297db3..9618b547 100644 --- a/server/src/playerbotserviceworkflow.cpp +++ b/server/src/playerbotserviceworkflow.cpp @@ -27,6 +27,20 @@ void PlayerBotServiceWorkflow::reset(PlayerBotServiceIntent intent) providerRouteCosts.clear(); providersRequiringNpcTravel.clear(); liquidationPlan.reset(); + survivalRestock = false; +} + +PlayerBotServiceCommand PlayerBotServiceWorkflow::retirePreparationPurchase(const PlayerBotServiceObservation& observation) +{ + if (serviceIntent != PlayerBotServiceIntent::PreparationPurchase) return {}; + PlayerBotServiceCommand result; + if (serviceSession.hasShopTransaction()) { + auto finalObservation = observation; + finalObservation.maximumAttempts = 1; + result = verifyShop(finalObservation, true); + } + reset(); + return result; } bool PlayerBotServiceWorkflow::reportNpcReply(uint32_t playerId, uint32_t replyingPlayerId, uint32_t npcId, uint8_t type) @@ -551,8 +565,40 @@ PlayerBotServiceCommand PlayerBotServiceWorkflow::advanceImpl(const PlayerBotSer // protected items to carry. All kinds share the remaining capacity. const uint32_t purchaseCapacity = observation.freeCapacity > observation.supplyCapacityReserve ? observation.freeCapacity - observation.supplyCapacityReserve : 0; - const auto restock = disposition.restockSupplies(requests, purchaseCapacity, observation.money, - observation.bankBalance, survivalRestock); + // Reserve every kind's floor, including stock at separate suppliers, + // before optional preferred stock can compete with full top-ups. + // Reuse the ordinary cash-reserve and partial-purchase policy in both + // passes; a preference must never become an insufficient-funds floor. + auto preferredRequests = requests; + bool hasPreference = false; + for (size_t index = 0; index < requests.size(); ++index) { + const auto& supply = observation.supplies[index]; + if (supply.preferredStock != 0) hasPreference = true; + const auto& request = requests[index]; + const uint32_t floor = request.returnThreshold == UINT32_MAX ? 0 : + std::max(request.safetyFloor, request.returnThreshold + 1); + preferredRequests[index].restockTarget = std::min(request.restockTarget, + std::max(floor, supply.preferredStock)); + } + auto restock = disposition.restockSupplies(hasPreference ? preferredRequests : requests, + purchaseCapacity, observation.money, observation.bankBalance, survivalRestock); + if (hasPreference && std::none_of(restock.begin(), restock.end(), + [](const auto& decision) { return decision.insufficientFunds; })) { + uint64_t remainingFunds = observation.money + observation.bankBalance; + uint32_t remainingCapacity = purchaseCapacity; + auto fullRequests = requests; + for (size_t index = 0; index < requests.size(); ++index) { + fullRequests[index].itemCount += restock[index].amount; + remainingFunds -= static_cast(restock[index].amount) * requests[index].unitPrice; + remainingCapacity -= restock[index].amount * requests[index].unitWeight; + } + const auto topUps = disposition.restockSupplies(fullRequests, remainingCapacity, + remainingFunds, 0, survivalRestock); + for (size_t index = 0; index < restock.size(); ++index) { + restock[index].amount += topUps[index].amount; + restock[index].insufficientFunds = topUps[index].insufficientFunds; + } + } for (size_t index = 0; index < restock.size(); ++index) { if (restock[index].insufficientFunds) { serviceStage = PlayerBotServiceStage::Failed; @@ -574,7 +620,8 @@ PlayerBotServiceCommand PlayerBotServiceWorkflow::advanceImpl(const PlayerBotSer command.transaction = transaction; return command; } - serviceStage = PlayerBotServiceStage::Bank; + serviceStage = serviceIntent == PlayerBotServiceIntent::PreparationPurchase ? + PlayerBotServiceStage::Complete : PlayerBotServiceStage::Bank; return advanceImpl(observation, catalog, disposition); } return advanceBank(observation, disposition); diff --git a/server/src/playerbotserviceworkflow.h b/server/src/playerbotserviceworkflow.h index b4ab06c1..45b81e3e 100644 --- a/server/src/playerbotserviceworkflow.h +++ b/server/src/playerbotserviceworkflow.h @@ -18,7 +18,7 @@ #include enum class PlayerBotServiceStage : uint8_t { Discover, SellLoot, BuySupplies, Bank, Complete, Failed }; -enum class PlayerBotServiceIntent : uint8_t { Resupply, ResupplyWithLocalSale }; +enum class PlayerBotServiceIntent : uint8_t { Resupply, ResupplyWithLocalSale, PreparationPurchase }; enum class PlayerBotServiceCommandType : uint8_t { None, ValidateProviderRoute, NavigateProvider, Speak, Sell, Buy, DepositAll, Withdraw, OpenBackpack, MoveSlottedSale, Complete, Fail, Wait, }; @@ -82,12 +82,15 @@ struct PlayerBotServiceCommand { }; struct PlayerBotServiceSupply { - PlayerBotSupplyKind kind = PlayerBotSupplyKind::HealthPotion; + // Null for a neutral preparation purchase, not a hunt consumable kind. + std::optional kind = PlayerBotSupplyKind::HealthPotion; uint16_t itemId = 0; uint32_t weight = 0; uint32_t returnThreshold = PlayerBotDispositionPolicy::potionReturnThreshold; uint32_t restockTarget = PlayerBotDispositionPolicy::potionRestockTarget; uint32_t safetyFloor = 0; + // Optional stock preference, never a departure or recovery floor. + uint32_t preferredStock = 0; }; // This is deliberately a value snapshot. It contains no Item, Container, Npc, @@ -144,11 +147,26 @@ class PlayerBotServiceWorkflow { public: void reset(PlayerBotServiceIntent intent = PlayerBotServiceIntent::Resupply); + // Resupply resets take the controller's current policy; neutral cleanup stays default-off. + void reset(PlayerBotServiceIntent intent, bool survival) { + reset(intent); + setSurvivalRestock(survival); + } 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; } + bool buyingPotions() const { + return serviceStage == PlayerBotServiceStage::BuySupplies && + serviceIntent != PlayerBotServiceIntent::PreparationPurchase; + } + const PlayerBotServiceTransaction* pendingPreparationPurchase() const { + return serviceIntent == PlayerBotServiceIntent::PreparationPurchase ? serviceSession.shopTransaction() : nullptr; + } + // Normal trade callbacks are synchronous. Observe any receipt once before + // retirement; do not issue another purchase or retry after interruption. + PlayerBotServiceCommand retirePreparationPurchase(const PlayerBotServiceObservation& observation); PlayerBotServiceSnapshot snapshot() const { return {serviceStage, npcSession.targetId()}; } const std::set& unavailableProviders() const { return unavailableProviderIds; } void setProviderUtilityProfile(PlayerBotProviderUtilityProfile profile) { providerUtilityProfile = profile; } diff --git a/server/src/playerbotspells.cpp b/server/src/playerbotspells.cpp index 4a5a6c97..be5d5db1 100644 --- a/server/src/playerbotspells.cpp +++ b/server/src/playerbotspells.cpp @@ -13,8 +13,12 @@ #include "playerbotcontroller.h" #include "playerbotnpccapabilities.h" #include "playerbotspellcalibration.h" +#include "playerbotplanningbudget.h" #include "condition.h" #include "spells.h" +#include "groups.h" +#include "guild.h" +#include // Runtime spell-trainer discovery and normal NPC learning dialogue. using namespace playerbot; @@ -24,9 +28,31 @@ extern Spells* g_spells; namespace { constexpr auto magicTrainingRetryDelay = std::chrono::seconds(2); constexpr size_t maximumSpellTrainerRoutes = 4; - constexpr size_t maximumSpellTrainerApproaches = 8; - constexpr uint64_t maximumSpellTrainerPathNodes = 20000; - constexpr uint64_t maximumSpellTrainerPathNodesPerApproach = 5000; + bool trainerFareLimited(Player& player, uint64_t protectedFunds) + { + const uint64_t funds = player.getMoney() + player.getBankBalance(); + const uint64_t spendable = funds >= protectedFunds ? funds - protectedFunds : 0; + for (Npc* npc : playerBotNpcProviders(g_game.getNpcs(), PlayerBotNpcCapability::Travel, player.getPosition())) { + for (const auto& offer : npc->getTravelOffers()) { + if (offer.price > spendable && playerBotNpcTravelOfferEligible(player.getLevel(), player.isPremium(), + UINT64_MAX, offer.level, offer.premium, offer.price, offer.hasOpaqueCondition, offer.hasOpaqueAction)) + return true; + } + } + return false; + } + + std::vector trainerApproaches(Player& player, const Npc& npc) + { + std::vector tiles; + for (int x = -3; x <= 3; ++x) for (int y = -3; y <= 3; ++y) { + if (!x && !y) continue; + const Position p(npc.getPosition().x + x, npc.getPosition().y + y, npc.getPosition().z); + if (playerBotStableApproachTile(g_game.map.getTile(p), player)) tiles.push_back(p); + } + std::sort(tiles.begin(), tiles.end()); + return tiles; + } const char* healthPotionFallback(uint16_t itemId) { @@ -314,13 +340,17 @@ PlayerBotSupplyStocks PlayerBotController::supplyStocks(const Player& player) co huntPotionReturnThreshold, potionStockTarget(player)}, inventoryPolicy.inventoryItemCount(player, potionItemId)}}; for (const PlayerBotSupplyStock& stock : PlayerBotInventoryPolicy::additionalSupplyStocks(player)) stocks.push_back(stock); + playerBotRequireSupplies(stocks, huntSupplyHandoff.requirements()); return stocks; } uint32_t PlayerBotController::carriedSupplyReserve(const Player& player, uint16_t itemId) const { - const uint32_t reserve = inventoryPolicy.protectedItemReserve(player, itemId); - return itemId == recoveryPotionItemId(player.getVocationId()) ? std::max(reserve, potionStockTarget(player)) : reserve; + uint32_t reserve = inventoryPolicy.protectedItemReserve(player, itemId); + for (const auto& stock : supplyStocks(player)) { + if (stock.rule.itemId == itemId) reserve = std::max(reserve, stock.rule.target); + } + return reserve; } bool PlayerBotController::huntSuppliesReady(const Player& player) const @@ -336,16 +366,23 @@ uint64_t PlayerBotController::spellTrainingReserve(const Player& player, bool em { 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) : potionStockTarget(player); + const auto stocks = supplyStocks(player); + const auto* health = playerBotSupplyStock(stocks, PlayerBotSupplyKind::HealthPotion); + const uint32_t reserveTarget = std::max(health ? health->rule.safetyFloor : 0, emergencyOnly ? + (huntPotionReturnThreshold == UINT32_MAX ? UINT32_MAX : huntPotionReturnThreshold + 1) : potionStockTarget(player)); const uint64_t healthReserve = emergencyOnly && potionCount >= reserveTarget ? carriedGoldReserve : recoverySpendingReserve(player, reserveTarget); std::vector others; - for (const auto& stock : PlayerBotInventoryPolicy::additionalSupplyStocks(player)) { + for (const auto& stock : stocks) { + if (stock.rule.kind == PlayerBotSupplyKind::HealthPotion) continue; others.push_back({stock.count, stock.rule.safetyFloor, stock.count < stock.rule.safetyFloor ? cheapestShopPrice(player, stock.rule.itemId) : 0}); } - return playerBotSupplyFloorSpendingReserve(healthReserve, others); + const uint64_t supplyReserve = playerBotSupplyFloorSpendingReserve(healthReserve, others); + // First-heal bootstrap only: the default query ignores current mana and + // overheal but still requires an eligible, usable learned healing spell. + const bool firstUsableHeal = emergencyOnly && !survivalRuntime.preferredHealingSpell(survivalSnapshot(player)); + return playerBotSpellTrainingSpendingReserve(supplyReserve, carriedGoldReserve, firstUsableHeal); } uint32_t PlayerBotController::cheapestShopPrice(const Player& player, uint16_t itemId) const @@ -372,17 +409,18 @@ uint64_t PlayerBotController::recoverySpendingReserve(const Player& player, uint uint64_t PlayerBotController::supplyFloorSpendingReserve(const Player& player) const { - std::vector others; - for (const PlayerBotSupplyStock& stock : PlayerBotInventoryPolicy::additionalSupplyStocks(player)) { - others.push_back({stock.count, stock.rule.safetyFloor, - stock.count < stock.rule.safetyFloor ? cheapestShopPrice(player, stock.rule.itemId) : 0}); + const auto stocks = supplyStocks(player); + std::vector costs; + for (const auto& stock : stocks) { + costs.push_back({stock.count, stock.rule.safetyFloor, + stock.count < stock.rule.safetyFloor ? cheapestShopPrice(player, stock.rule.itemId) : 0}); } - return playerBotSupplyFloorSpendingReserve(recoverySpendingReserve(player, healthPotionSafetyTarget), others); + return playerBotSupplyFloorSpendingReserve(carriedGoldReserve, costs); } void PlayerBotController::emitSpellCandidate(const Npc& npc, const NpcSpellOffer& offer, const Position& position, const char* result, const char* reason, uint64_t reserve, - uint32_t travelSteps, std::optional learningPriority) const + uint32_t travelSteps, std::optional learningPriority, uint64_t fare) const { std::ostringstream fields; fields << "\"goal\":\"learn_spell\",\"result\":" << jsonString(result) @@ -390,7 +428,7 @@ void PlayerBotController::emitSpellCandidate(const Npc& npc, const NpcSpellOffer << ",\"spell\":" << jsonString(offer.spellName) << ",\"keyword\":" << jsonString(offer.keyword) << ",\"price\":" << offer.price << ",\"level\":" << offer.level << ",\"premium\":" << (offer.premium ? "true" : "false") << ",\"reserve\":" << reserve - << ",\"travel_steps\":" << travelSteps + << ",\"fare\":" << fare << ",\"travel_steps\":" << travelSteps << ",\"implemented_use\":" << (learningPriority ? "true" : "false"); if (learningPriority) fields << ",\"learning_priority\":" << static_cast(*learningPriority); fields << ",\"provider_position\":{\"x\":" << npc.getPosition().x @@ -401,8 +439,179 @@ void PlayerBotController::emitSpellCandidate(const Npc& npc, const NpcSpellOffer emit("spell_candidate", position, fields.str()); } -bool PlayerBotController::findSpellTraining(Player& player, const Position& position, PlayerBotSpellTrainingPlan& plan, - std::deque& selectedSteps) +std::string PlayerBotController::trainerDecisionFacts(Player& player, const Position& position, bool coolingDown) +{ + // Do not include action delays, current mana/HP or regeneration ticks: + // they do not change trainer eligibility/proof and must not restart a + // completed phase on every preparation-budget retry. + const auto combat = equipmentPolicy.combatProfile(PlayerBotEquipmentAdapter::player(player), + PlayerBotEquipmentAdapter::loadout(player)); + const auto snapshot = survivalSnapshot(player); + std::ostringstream key; + key << std::setprecision(17) << position << ':' << coolingDown << ':' << player.getVocationId() << ':' << player.getVocation()->getFromVocation() + << ':' << player.getLevel() << ':' << player.isPremium() << ':' << player.isPzLocked() + << ':' << player.getMoney() << ':' << player.getBankBalance() + << ':' << spellTrainingReserve(player) << ':' << spellTrainingReserve(player, true) + << ':' << playerBotSupplyStockKey(supplyStocks(player)) << ':' << huntSuppliesReady(player) + << ':' << combat.maximumHealth << ':' << combat.armor << ':' << combat.defense << ':' << combat.attack + << ':' << combat.attackSkill << ':' << combat.attackFactor << ':' << combat.hitChance << ':' << combat.blockedByShield + << ':' << player.getStepDuration() << ':' << player.getStepDuration(DIRECTION_NORTHEAST) + << ':' << (g_game.findItemOfType(&player, ropeItemId, true) != nullptr) + << ':' << (g_game.findItemOfType(&player, 2554, true) != nullptr) + << ':' << (player.getGroup() && player.getGroup()->access) + << ':' << (player.getGroup() ? player.getGroup()->flags : 0) + << ':' << (player.getGuild() ? player.getGuild()->getId() : 0) + << ':' << (player.getGuildRank() ? player.getGuildRank()->id : 0) + << ':' << PlayerBotTopology::instance().generation() << ':' << PlayerBotHuntRegionPlanner::getCacheRevision() + << ':' << playerBotNavigationCostIdentity("", riskProfile); + for (Npc* npc : playerBotNpcProviders(g_game.getNpcs(), PlayerBotNpcCapability::SpellTrainer, position)) { + key << ";trainer:" << npc->getID() << ':' << npc->getPosition(); + for (const auto& offer : npc->getSpellOffers()) { + Spell* spell = g_spells ? g_spells->getSpellByName(offer.spellName) : nullptr; + key << ':' << std::quoted(offer.spellName) << ':' << std::quoted(offer.keyword) + << ':' << offer.price << ':' << offer.level << ':' << offer.premium + << ':' << player.hasLearnedInstantSpell(offer.spellName); + for (auto vocation : offer.vocationIds) key << ':' << vocation; + key << "|registry:" << (spell && spell->isInstant() && spell->isLearnable()) + << ':' << (spell ? spell->getLevel() : 0) << ':' << (spell && spell->isPremium()) + << ':' << (spell && spell->getVocMap().count(player.getVocationId())) + << ':' << survivalRuntime.healingSpellWorthLearning(snapshot, offer.spellName.c_str()); + } + } + for (Npc* npc : playerBotNpcProviders(g_game.getNpcs(), PlayerBotNpcCapability::Travel, position)) { + key << ";travel:" << npc->getID() << ':' << npc->getPosition(); + for (const auto& offer : npc->getTravelOffers()) { + key << ':' << offer.destination << ':' << offer.price << ':' << offer.level << ':' << offer.premium + << ':' << offer.hasOpaqueCondition << ':' << offer.hasOpaqueAction; + for (const auto& phrase : offer.dialogue) key << ':' << std::quoted(phrase); + } + } + for (const auto& [offer, until] : unavailableTravelOffers) { + if (until > std::chrono::steady_clock::now()) key << ";unavailable:" << offer.first << ':' << offer.second; + } + return playerBotNavigationBlockedIdentity(key.str(), + navigationRuntime.activeBlockedPositions(std::chrono::steady_clock::now())); +} + +void PlayerBotController::interruptTrainerRouteWork() +{ + const bool arbitrationWork = trainerArbitration.completed(); + if (arbitrationWork) { + trainerArbitration.reset(); + trainerDecisionWatch = {}; + selectedTrainerRoute.reset(); + } + const bool discoveryWork = trainerDiscovery && (trainerDiscovery->next != 0 || trainerRouteSearch); + const bool journeyWork = trainerJourney && !trainerJourneyNeedsRoute; + if (!arbitrationWork && !discoveryWork && !journeyWork && !trainerRouteSearch) return; + trainerRouteSearch.reset(); + if (discoveryWork) { + ++trainerDiscovery->progress.restarts; + trainerDiscovery->next = 0; + for (auto& offer : trainerDiscovery->offers) { + playerBotTrainerDiscardRouteProof(offer); + offer.routeRejection.clear(); + } + for (auto& route : trainerDiscovery->routes) route = {}; + } + if (journeyWork) { + trainerJourney->evidence.reset(); + trainerJourneyNeedsRoute = true; + resetNavigation(); + } + auto& budget = playerBotHuntPlanningBudget(); + if (!budget.isActive(playerId)) budget.cancel(playerId, std::chrono::steady_clock::now()); +} + +void PlayerBotController::clearTrainerWork() +{ + trainerDiscovery.reset(); + trainerArbitration.reset(); + trainerDecisionWatch = {}; + selectedTrainerRoute.reset(); + trainerJourney.reset(); + trainerRouteSearch.reset(); + trainerGoalSelectionPending = false; + trainerJourneyNeedsRoute = false; + trainerTravelReceiptFailed = false; + auto& budget = playerBotHuntPlanningBudget(); + if (!budget.isActive(playerId)) budget.cancel(playerId, std::chrono::steady_clock::now()); +} + +std::optional PlayerBotController::advanceTrainerRoute( + Player& player, const std::vector& destinations, uint64_t reserve, + std::shared_ptr& evidence) +{ + const Position source = player.getPosition(); + const auto blocked = navigationRuntime.activeBlockedPositions(std::chrono::steady_clock::now()); + if (!trainerRouteSearch || trainerRouteSearch->origin != source || + trainerRouteSearch->destination != destinations.front() || trainerRouteSearch->reserve != reserve || + trainerRouteSearch->blockedPositions != blocked) { + trainerRouteSearch.emplace(); + auto& search = *trainerRouteSearch; + search.origin = source; + search.destination = destinations.front(); + search.reserve = reserve; + search.blockedPositions = blocked; + search.pass = (uint64_t(1) << 61) | (uint64_t(playerId) << 24) | ++trainerRouteSerial; + } + auto& budget = playerBotHuntPlanningBudget(); + const auto admission = budget.request(playerId, std::chrono::steady_clock::now()); + if (!admission.admitted) { + schedule(static_cast(std::clamp( + std::chrono::duration_cast(admission.wait).count(), + blockedRouteRetryInterval, 5000))); + return std::nullopt; + } + PlayerBotPlanningBudget::Charge charge(budget, playerId); + auto& search = *trainerRouteSearch; + PlayerBotHuntRouteRequest request; + request.planningPass = search.pass; + request.from = source; + request.to = destinations.front(); + request.destinations = destinations; + request.blockedPositions = search.blockedPositions; + request.preferSafeWalking = std::any_of(destinations.begin(), destinations.end(), [&](Position p) { + return playerBotNavigationDistance(source, p) <= 128; + }); + PlayerBotHuntRouteTiming timing; + std::optional planned; + { + PlayerBotRouteWorkSlot slot(huntTravelWork, search.work); + planned = advanceHuntTravelRoute(player, request, source, timing, reserve, false, nullptr, &evidence); + } + search.expandedNodes += timing.localExpandedNodes; + const bool exhausted = !planned && ++search.turns >= maximumRouteSearchTurns; + if (planned || exhausted || search.turns == 1 || timing.invalidations != 0 || timing.transportRestarts != 0) { + emit("trainer_route", source, "\"npc_id\":" + std::to_string( + trainerDiscovery ? trainerDiscovery->offers[trainerDiscovery->next].npcId : + progressionRuntime.spellTraining().plan().npcId) + + ",\"spell\":" + jsonString(trainerDiscovery ? trainerDiscovery->offers[trainerDiscovery->next].spellName : + progressionRuntime.spellTraining().plan().spellName) + + ",\"phase\":" + jsonString(trainerDiscovery ? "discovery" : "journey") + + ",\"result\":" + jsonString(planned ? playerBotNavigationResultName(planned->metrics.result) : + exhausted ? "proof_exhausted" : "pending") + + (exhausted ? ",\"reason\":\"route_turn_limit\"" : "") + + (trainerDiscovery ? ",\"scan_continuations\":" + std::to_string(trainerDiscovery->progress.continuations) + + ",\"offer_index\":" + std::to_string(trainerDiscovery->next) : "") + + ",\"expanded_nodes\":" + std::to_string(search.expandedNodes) + + ",\"fare\":" + std::to_string(planned ? planned->metrics.fare : 0) + + ",\"protected_funds\":" + std::to_string(reserve) + + ",\"slices\":" + std::to_string(search.turns + (planned ? 1 : 0)) + + ",\"blocked_tiles\":" + std::to_string(search.blockedPositions.size()) + + ",\"invalidations\":" + std::to_string(timing.invalidations) + + ",\"transport_restarts\":" + std::to_string(timing.transportRestarts)); + } + if (exhausted) { + planned.emplace(); + planned->metrics.result = PlayerBotNavigationResult::NodeLimit; + } + if (planned) trainerRouteSearch.reset(); + else if (admission.admitted) schedule(routeSearchContinuationInterval); + return planned; +} + +bool PlayerBotController::findSpellTraining(Player& player, const Position& position, PlayerBotSpellTrainingPlan& plan) { const uint64_t reserve = spellTrainingReserve(player); const uint64_t healingReserve = spellTrainingReserve(player, true); @@ -420,183 +629,277 @@ bool PlayerBotController::findSpellTraining(Player& player, const Position& posi auto worthwhile = [&](const std::string& name) { return !healingOffer(name) || survivalRuntime.healingSpellWorthLearning(spellSnapshot, name.c_str()); }; - std::vector offers; - std::vector> routes; - uint64_t remainingPathNodes = maximumSpellTrainerPathNodes; - std::vector trainers = playerBotNpcProviders(g_game.getNpcs(), PlayerBotNpcCapability::SpellTrainer, position); - auto hasRelevantOffer = [&](const Npc* trainer) { - return std::any_of(trainer->getSpellOffers().begin(), trainer->getSpellOffers().end(), [&](const NpcSpellOffer& offer) { - Spell* spell = g_spells ? g_spells->getSpellByName(offer.spellName) : nullptr; - return playerBotSpellLearningPriority(offer.spellName.c_str()) && worthwhile(offer.spellName) && - spell && spell->isInstant() && spell->isLearnable() && spell->getLevel() == offer.level && - spell->isPremium() == offer.premium && player.getLevel() >= offer.level && - (!offer.premium || player.isPremium()) && !player.hasLearnedInstantSpell(offer.spellName) && - std::find(offer.vocationIds.begin(), offer.vocationIds.end(), baseVocationId) != offer.vocationIds.end() && - spell->getVocMap().find(vocationId) != spell->getVocMap().end(); + auto refresh = [&](PlayerBotSpellOfferSnapshot& snapshot) { + Npc* npc = g_game.getNpcByID(snapshot.npcId); + Spell* spell = g_spells ? g_spells->getSpellByName(snapshot.spellName) : nullptr; + const NpcSpellOffer* loadedOffer = nullptr; + if (npc) { + const auto found = std::find_if(npc->getSpellOffers().begin(), npc->getSpellOffers().end(), [&](const auto& offer) { + return offer.spellName == snapshot.spellName && offer.keyword == snapshot.keyword && + offer.price == snapshot.price && offer.level == snapshot.level && offer.premium == snapshot.premium; + }); + if (found != npc->getSpellOffers().end()) loadedOffer = &*found; + } + snapshot.registryMatches = npc && playerBotNpcHasCapability(*npc, PlayerBotNpcCapability::SpellTrainer) && + loadedOffer && spell && spell->isInstant() && spell->isLearnable() && + spell->getLevel() == snapshot.level && spell->isPremium() == snapshot.premium; + snapshot.vocationEligible = snapshot.registryMatches && spell->getVocMap().count(vocationId) != 0 && + std::find(loadedOffer->vocationIds.begin(), loadedOffer->vocationIds.end(), baseVocationId) != loadedOffer->vocationIds.end(); + snapshot.levelEligible = player.getLevel() >= snapshot.level; + snapshot.premiumEligible = !snapshot.premium || player.isPremium(); + snapshot.known = player.hasLearnedInstantSpell(snapshot.spellName); + snapshot.worthLearning = worthwhile(snapshot.spellName); + snapshot.suppliesReady = suppliesReady && potionCount > snapshot.potionReserve; + }; + const bool factsChanged = trainerDiscovery && (trainerDiscovery->origin != position || + trainerDiscovery->money != totalMoney || trainerDiscovery->reserve != reserve || + trainerDiscovery->healingReserve != healingReserve || trainerDiscovery->potionCount != potionCount); + auto progress = trainerDiscovery ? trainerDiscovery->progress : PlayerBotTrainerScanProgress{}; + if (!trainerDiscovery) progress.deadline = std::chrono::steady_clock::now() + std::chrono::minutes(10); + if (factsChanged) { + ++progress.restarts; + trainerDiscovery.reset(); + trainerRouteSearch.reset(); + } + const char* exhaustion = progress.exhaustion(std::chrono::steady_clock::now(), PlayerBotApproachLimits::providerMoves); + if (exhaustion) { + emit("trainer_route", position, "\"phase\":\"discovery\",\"result\":\"proof_exhausted\",\"reason\":" + + jsonString(exhaustion) + ",\"scan_continuations\":" + std::to_string(progress.continuations) + + ",\"evaluated_offers\":" + std::to_string(trainerDiscovery ? trainerDiscovery->next : 0)); + trainerRouteSearch.reset(); + if (!trainerDiscovery) { + clearTrainerWork(); + progressionRuntime.completeSpellTraining(false, spellTrainingFailureCooldown); + return false; + } + // End pending proofs, not completed candidates. Selection below still + // refreshes eligibility, risk, supplies, and each retained route proof. + playerBotTrainerEndPendingProofs(trainerDiscovery->offers, trainerDiscovery->next); + trainerDiscovery->next = trainerDiscovery->offers.size(); + } + if (!trainerDiscovery) { + trainerDiscovery.emplace(); + auto& scan = *trainerDiscovery; + scan.origin = position; scan.money = totalMoney; + scan.reserve = reserve; scan.healingReserve = healingReserve; scan.potionCount = potionCount; + scan.progress = progress; + std::vector trainers = playerBotNpcProviders(g_game.getNpcs(), PlayerBotNpcCapability::SpellTrainer, position); + auto hasRelevantOffer = [&](const Npc* trainer) { + return std::any_of(trainer->getSpellOffers().begin(), trainer->getSpellOffers().end(), [&](const NpcSpellOffer& offer) { + Spell* spell = g_spells ? g_spells->getSpellByName(offer.spellName) : nullptr; + return playerBotSpellLearningPriority(offer.spellName.c_str()) && worthwhile(offer.spellName) && + spell && spell->isInstant() && spell->isLearnable() && spell->getLevel() == offer.level && + spell->isPremium() == offer.premium && player.getLevel() >= offer.level && + (!offer.premium || player.isPremium()) && !player.hasLearnedInstantSpell(offer.spellName) && + std::find(offer.vocationIds.begin(), offer.vocationIds.end(), baseVocationId) != offer.vocationIds.end() && + spell->getVocMap().find(vocationId) != spell->getVocMap().end(); + }); + }; + const auto eligibleEnd = std::stable_partition(trainers.begin(), trainers.end(), hasRelevantOffer); + const size_t eligibleCount = static_cast(std::distance(trainers.begin(), eligibleEnd)); + if (eligibleCount > maximumSpellTrainerRoutes) { + const size_t rotatingCount = eligibleCount - 1; + const size_t offset = spellTrainerScanOffset % rotatingCount; + std::rotate(trainers.begin() + 1, trainers.begin() + 1 + offset, eligibleEnd); + spellTrainerScanOffset = (offset + maximumSpellTrainerRoutes - 1) % rotatingCount; + } else if (eligibleEnd != trainers.end()) { + const size_t remainingCount = static_cast(std::distance(eligibleEnd, trainers.end())); + const size_t offset = spellTrainerScanOffset % remainingCount; + std::rotate(eligibleEnd, eligibleEnd + offset, trainers.end()); + const size_t remainingSlots = maximumSpellTrainerRoutes > eligibleCount ? maximumSpellTrainerRoutes - eligibleCount : 0; + spellTrainerScanOffset = (offset + std::min(remainingSlots, remainingCount)) % remainingCount; + } + if (trainers.size() > maximumSpellTrainerRoutes) { + trainers.resize(maximumSpellTrainerRoutes); + } + + for (Npc* npc : trainers) { + emit("spell_trainer_discovered", position, "\"npc_id\":" + std::to_string(npc->getID()) + + ",\"npc_name\":" + jsonString(npc->getName()) + ",\"offers\":" + + std::to_string(npc->getSpellOffers().size()) + ",\"in_scope\":true"); + for (const NpcSpellOffer& offer : npc->getSpellOffers()) { + const auto priority = playerBotSpellLearningPriority(offer.spellName.c_str()); + PlayerBotSpellOfferSnapshot snapshot; + snapshot.npcId = npc->getID(); snapshot.npcPosition = npc->getPosition(); snapshot.npcName = npc->getName(); + snapshot.spellName = offer.spellName; snapshot.keyword = offer.keyword; snapshot.price = offer.price; + snapshot.level = offer.level; snapshot.premium = offer.premium; snapshot.inScope = true; + snapshot.implementedUse = priority.has_value(); snapshot.learningPriority = priority.value_or(UINT8_MAX); + snapshot.reserve = healingOffer(offer.spellName) ? healingReserve : reserve; + refresh(snapshot); + scan.offers.push_back(std::move(snapshot)); + } + } + std::stable_sort(scan.offers.begin(), scan.offers.end(), [](const auto& a, const auto& b) { + return playerBotTrainerPriority(a) < playerBotTrainerPriority(b); }); + scan.routes.resize(scan.offers.size()); + } + auto& scan = *trainerDiscovery; + PlayerBotRouteChanges::Scope decisionDependencies(trainerDecisionWatch); + ++scan.progress.continuations; + auto select = [&] { + return spellTrainingPlanner.select({reserve, totalMoney, reserve != UINT64_MAX, + static_cast(riskProfile.maximumRouteHealthLoss * riskProfile.healthLossCost), + riskProfile.maximumHealthLossPerSecond, scan.offers}); }; - const auto eligibleEnd = std::stable_partition(trainers.begin(), trainers.end(), hasRelevantOffer); - const size_t eligibleCount = static_cast(std::distance(trainers.begin(), eligibleEnd)); - if (eligibleCount > maximumSpellTrainerRoutes) { - const size_t rotatingCount = eligibleCount - 1; - const size_t offset = spellTrainerScanOffset % rotatingCount; - std::rotate(trainers.begin() + 1, trainers.begin() + 1 + offset, eligibleEnd); - spellTrainerScanOffset = (offset + maximumSpellTrainerRoutes - 1) % rotatingCount; - } else if (eligibleEnd != trainers.end()) { - const size_t remainingCount = static_cast(std::distance(eligibleEnd, trainers.end())); - const size_t offset = spellTrainerScanOffset % remainingCount; - std::rotate(eligibleEnd, eligibleEnd + offset, trainers.end()); - const size_t remainingSlots = maximumSpellTrainerRoutes > eligibleCount ? maximumSpellTrainerRoutes - eligibleCount : 0; - spellTrainerScanOffset = (offset + std::min(remainingSlots, remainingCount)) % remainingCount; - } - if (trainers.size() > maximumSpellTrainerRoutes) { - trainers.resize(maximumSpellTrainerRoutes); - } - - for (Npc* npc : trainers) { - const bool inScope = true; - emit("spell_trainer_discovered", position, "\"npc_id\":" + std::to_string(npc->getID()) + - ",\"npc_name\":" + jsonString(npc->getName()) + ",\"offers\":" + - std::to_string(npc->getSpellOffers().size()) + ",\"in_scope\":" + (inScope ? "true" : "false")); - bool routeEvaluated = false; - bool routeReachable = false; - Position trainerApproach; - std::deque trainerSteps; - PlayerBotRouteEstimate trainerRoute; - auto findTrainerApproach = [&]() { - if (routeEvaluated) { - return routeReachable; + // Cheap rejections do not request the shared planner. At most one route + // slice runs per turn; the offer cursor and all frontiers survive a yield. + while (scan.next < scan.offers.size()) { + auto& offer = scan.offers[scan.next]; + auto& route = scan.routes[scan.next]; + refresh(offer); + if (!offer.registryMatches || !offer.implementedUse || !offer.vocationEligible || !offer.levelEligible || + !offer.premiumEligible || offer.known || !offer.worthLearning || !offer.suppliesReady || + !playerBotTrainerTripAffordable(totalMoney, *offer.reserve, offer.price, 0)) { + ++scan.next; + continue; + } + Npc* npc = g_game.getNpcByID(offer.npcId); + auto destinations = trainerApproaches(player, *npc); + if (!trainerRouteSearch) { + // Refresh at candidate boundaries, not on every admitted search slice. + // Learning/loaded eligibility may change without moving the actor. + for (auto& candidate : scan.offers) refresh(candidate); + const auto incumbent = select(); + if (incumbent.selectedOfferIndex && playerBotTrainerPriority(scan.offers[*incumbent.selectedOfferIndex]) < + playerBotTrainerPriority(offer)) { + // Keep equal-ranked providers for the original travel-step tie break. + // Final proof validation can reopen deferred offers if this winner moved. + for (size_t i = scan.next; i < scan.offers.size(); ++i) + scan.offers[i].routeRejection = "lower_learning_priority"; + scan.next = scan.offers.size(); + break; } - routeEvaluated = true; - std::vector approaches; - for (int32_t xOffset = -3; xOffset <= 3; ++xOffset) { - for (int32_t yOffset = -3; yOffset <= 3; ++yOffset) { - if (xOffset != 0 || yOffset != 0) { - approaches.emplace_back(npc->getPosition().x + xOffset, npc->getPosition().y + yOffset, - npc->getPosition().z); - } - } + if (!offer.route.reachable && incumbent.selectedOfferIndex && + !playerBotTrainerOptimisticCanBeat(offer, playerBotTrainerMinimumSteps(position, destinations), + scan.offers[*incumbent.selectedOfferIndex])) { + offer.routeRejection = "route_rank_dominated"; + ++scan.next; + continue; } - std::sort(approaches.begin(), approaches.end(), [&position, npc](const Position& left, const Position& right) { - const int32_t leftNpcDistance = std::max(Position::getDistanceX(npc->getPosition(), left), - Position::getDistanceY(npc->getPosition(), left)); - const int32_t rightNpcDistance = std::max(Position::getDistanceX(npc->getPosition(), right), - Position::getDistanceY(npc->getPosition(), right)); - if (leftNpcDistance != rightNpcDistance) { - return leftNpcDistance > rightNpcDistance; - } - const int32_t leftDistance = std::max(Position::getDistanceX(position, left), Position::getDistanceY(position, left)); - const int32_t rightDistance = std::max(Position::getDistanceX(position, right), Position::getDistanceY(position, right)); - return leftDistance == rightDistance ? left < right : leftDistance < rightDistance; - }); - std::array evaluatedDirections{}; - size_t evaluatedApproaches = 0; - for (const Position& approach : approaches) { - if (evaluatedApproaches >= maximumSpellTrainerApproaches || remainingPathNodes == 0) { - break; - } - const int32_t xDirection = approach.x < npc->getPosition().x ? 0 : approach.x > npc->getPosition().x ? 2 : 1; - const int32_t yDirection = approach.y < npc->getPosition().y ? 0 : approach.y > npc->getPosition().y ? 2 : 1; - const int32_t direction = xDirection * 3 + yDirection; - if (evaluatedDirections[direction]) continue; - Tile* tile = g_game.map.getTile(approach); - if (!tile || tile->queryAdd(0, player, 1, 0) != RETURNVALUE_NOERROR) { - continue; - } - evaluatedDirections[direction] = true; - ++evaluatedApproaches; - std::deque steps; - uint64_t expandedNodes = 0; - const auto startedAt = std::chrono::steady_clock::now(); - const PlayerBotNavigationRoutePlan routePlan = approach == position ? PlayerBotNavigationRoutePlan{} : - planCompleteNavigationRoute(player, approach, {}, - std::min(remainingPathNodes, maximumSpellTrainerPathNodesPerApproach)); - const PlayerBotNavigationResult result = approach == position ? PlayerBotNavigationResult::Reached : routePlan.metrics.result; - if (approach != position) { - steps = routePlan.steps; - expandedNodes = routePlan.metrics.expandedNodes; - remainingPathNodes -= std::min(remainingPathNodes, expandedNodes); - } - telemetry.recordPathfinding(std::chrono::duration_cast( - std::chrono::steady_clock::now() - startedAt), result == PlayerBotNavigationResult::Reached); - if (result != PlayerBotNavigationResult::Reached || (approach != position && steps.empty())) { - continue; - } - trainerApproach = approach; - trainerSteps = std::move(steps); - trainerRoute = {true, false, approach, - approach == position ? 0 : static_cast(routePlan.metrics.steps), expandedNodes, - approach == position ? 0 : routePlan.metrics.dangerCost, - approach == position ? 0 : routePlan.metrics.maximumHealthLossPerSecond}; - routeReachable = true; - return true; + } + if (offer.route.reachable) { ++scan.next; continue; } // Retained proof is validated below. + offer.routeRejection.clear(); + if (destinations.empty()) { + offer.routeRejection = "trainer_approach_unavailable"; + } else { + if (route.destinations != destinations) { + route.destinations = destinations; + trainerRouteSearch.reset(); } + // Affordability above makes this addition safe. + auto planned = advanceTrainerRoute(player, destinations, *offer.reserve + offer.price, route.evidence); + if (!planned) return false; // Pending is not a rejection or a completed arbitration. + route.plan = std::move(*planned); + offer.fare = route.plan.metrics.fare; + const auto result = route.plan.metrics.result; + offer.route = {result == PlayerBotNavigationResult::Reached, result == PlayerBotNavigationResult::NodeLimit, + route.plan.metrics.firstNpcTravelOffer ? destinations.front() : route.plan.metrics.waypoint, + static_cast(route.plan.metrics.steps), route.plan.metrics.expandedNodes, + route.plan.metrics.dangerCost, route.plan.metrics.maximumHealthLossPerSecond}; + offer.npcPosition = npc->getPosition(); + offer.routeRejection = result == PlayerBotNavigationResult::NodeLimit ? "route_proof_exhausted" : + result == PlayerBotNavigationResult::RiskRejected ? "route_risk_rejected" : + result == PlayerBotNavigationResult::Unreachable ? + (trainerFareLimited(player, *offer.reserve + offer.price) ? "route_fare_limited" : "trainer_unreachable") : ""; + const uint32_t routeReserve = recoveryPotionRouteReserve(vocationId, player.getMaxHealth(), + offer.route.dangerCost, static_cast(riskProfile.healthLossCost)); + offer.potionReserve = std::max(huntPotionReturnThreshold, routeReserve); + offer.suppliesReady = suppliesReady && potionCount > offer.potionReserve; + } + ++scan.next; + if (scan.next < scan.offers.size()) { + schedule(routeSearchContinuationInterval); return false; - }; - for (const NpcSpellOffer& offer : npc->getSpellOffers()) { - Spell* spell = g_spells ? g_spells->getSpellByName(offer.spellName) : nullptr; - const std::optional learningPriority = playerBotSpellLearningPriority(offer.spellName.c_str()); - const bool registryMatches = spell && spell->isInstant() && spell->isLearnable() && - spell->getLevel() == offer.level && spell->isPremium() == offer.premium; - const bool vocationEligible = registryMatches && - std::find(offer.vocationIds.begin(), offer.vocationIds.end(), baseVocationId) != offer.vocationIds.end() && - spell->getVocMap().find(vocationId) != spell->getVocMap().end(); - const bool levelEligible = player.getLevel() >= offer.level; - const bool premiumEligible = !offer.premium || player.isPremium(); - const bool alreadyLearned = player.hasLearnedInstantSpell(offer.spellName); - const bool worthLearning = worthwhile(offer.spellName); - const uint64_t offerReserve = healingOffer(offer.spellName) ? healingReserve : reserve; - const bool affordable = playerBotAffordableAfterReserve(totalMoney, offerReserve, offer.price); - const bool routeReachable = registryMatches && vocationEligible && levelEligible && premiumEligible && - !alreadyLearned && worthLearning && suppliesReady && affordable && findTrainerApproach(); - offers.push_back({npc->getID(), npc->getPosition(), npc->getName(), offer.spellName, offer.keyword, - offer.price, offer.level, offer.premium, inScope, registryMatches, - learningPriority.has_value(), learningPriority.value_or(UINT8_MAX), vocationEligible, - levelEligible, premiumEligible, alreadyLearned, suppliesReady, - trainerRoute, offerReserve}); - offers.back().worthLearning = worthLearning; - if (routeReachable) { - const uint32_t routeReserve = recoveryPotionRouteReserve(vocationId, player.getMaxHealth(), - trainerRoute.dangerCost, static_cast(riskProfile.healthLossCost)); - offers.back().potionReserve = std::max(huntPotionReturnThreshold, routeReserve); - offers.back().suppliesReady = suppliesReady && potionCount > offers.back().potionReserve; + } + } + // Earlier candidates may have become stale while later candidates yielded. + // Never publish stale evidence, and never restart this scan without a bound. + size_t firstStale = scan.offers.size(); + for (size_t i = 0; i < scan.offers.size(); ++i) { + auto& offer = scan.offers[i]; + refresh(offer); + offer.suppliesReady = suppliesReady && potionCount > offer.potionReserve; + if (offer.route.reachable) { + Npc* npc = g_game.getNpcByID(offer.npcId); + auto& route = scan.routes[i]; + if (!npc || !playerBotTrainerDiscoveryDestinationRetained(route.plan, route.destinations, + trainerApproaches(player, *npc)) || !route.evidence || + !huntTravelEvidenceValid(player, position, route.evidence->destination, *route.evidence)) { + firstStale = std::min(firstStale, i); + playerBotTrainerDiscardRouteProof(offer); + offer.routeRejection = exhaustion ? "route_proof_exhausted" : ""; + route = {}; } - routes.push_back(trainerSteps); } } - const PlayerBotSpellTrainingDecision decision = spellTrainingPlanner.select({reserve, totalMoney, - reserve != std::numeric_limits::max(), - static_cast(riskProfile.maximumRouteHealthLoss * riskProfile.healthLossCost), - riskProfile.maximumHealthLossPerSecond, offers}); - for (size_t offerIndex = 0; offerIndex < offers.size(); ++offerIndex) { - const auto& offer = offers[offerIndex]; + if (firstStale < scan.offers.size() && !exhaustion) { + ++scan.progress.restarts; + scan.next = firstStale; + for (auto& offer : scan.offers) + if (offer.routeRejection == "lower_learning_priority" || offer.routeRejection == "route_rank_dominated") + offer.routeRejection.clear(); + schedule(routeSearchContinuationInterval); + return false; + } + const auto decision = select(); + if (!exhaustion) { + const auto deferred = playerBotTrainerDeferredNeeded(scan.offers, decision.selectedOfferIndex, [&](size_t index) { + Npc* npc = g_game.getNpcByID(scan.offers[index].npcId); + return npc ? playerBotTrainerMinimumSteps(position, trainerApproaches(player, *npc)) : uint32_t(0); + }); + if (deferred) { + scan.next = *deferred; + for (auto& offer : scan.offers) + if (offer.routeRejection == "lower_learning_priority" || offer.routeRejection == "route_rank_dominated") + offer.routeRejection.clear(); + schedule(routeSearchContinuationInterval); + return false; + } + } + for (size_t i = 0; i < scan.offers.size(); ++i) { + const auto& offer = scan.offers[i]; const auto rejection = std::find_if(decision.rejections.begin(), decision.rejections.end(), - [offerIndex](const PlayerBotPlannerOfferRejection& result) { - return result.offerIndex == offerIndex; - }); - Npc* npc = g_game.getNpcByID(offer.npcId); - if (npc) emitSpellCandidate(*npc, {offer.spellName, offer.keyword, offer.price, offer.level, offer.premium, {}}, position, - rejection == decision.rejections.end() ? "feasible" : "rejected", - rejection == decision.rejections.end() ? nullptr : rejection->reason.c_str(), offer.reserve.value_or(reserve), offer.route.steps, - offer.implementedUse ? std::optional(offer.learningPriority) : std::nullopt); - } - if (!decision.selected) return false; - plan = *decision.selected; - if (!decision.selectedOfferIndex || *decision.selectedOfferIndex >= routes.size()) return false; - selectedSteps = std::move(routes[*decision.selectedOfferIndex]); - return true; + [i](const auto& r) { return r.offerIndex == i; }); + if (Npc* npc = g_game.getNpcByID(offer.npcId)) + emitSpellCandidate(*npc, {offer.spellName, offer.keyword, offer.price, offer.level, offer.premium, {}}, position, + rejection == decision.rejections.end() ? "feasible" : "rejected", + rejection == decision.rejections.end() ? nullptr : rejection->reason.c_str(), + *offer.reserve, offer.route.steps, offer.implementedUse ? std::optional(offer.learningPriority) : std::nullopt, offer.fare); + } + if (decision.selected && decision.selectedOfferIndex) { + plan = *decision.selected; + selectedTrainerRoute = std::move(scan.routes[*decision.selectedOfferIndex]); + } + trainerRouteSearch.reset(); + trainerDiscovery.reset(); + if (exhaustion && !decision.selected) progressionRuntime.completeSpellTraining(false, spellTrainingFailureCooldown); + return decision.selected.has_value(); } -void PlayerBotController::beginSpellTraining(Player& player, const Position& position, PlayerBotSpellTrainingPlan plan, - std::deque steps) +void PlayerBotController::beginSpellTraining(Player& player, const Position& position, PlayerBotSpellTrainingPlan plan) { + trainerJourney = std::move(selectedTrainerRoute); + trainerArbitration.reset(); + trainerDecisionWatch = {}; + selectedTrainerRoute.reset(); + trainerDiscovery.reset(); + trainerGoalSelectionPending = false; + trainerFareRemaining = plan.fare; + trainerJourneyRestarts = 0; + trainerJourneyNeedsRoute = !trainerJourney.has_value(); + trainerTravelReceiptFailed = false; + trainerJourneyDeadline = std::chrono::steady_clock::now() + std::chrono::minutes(15); progressionRuntime.beginSpellTraining(std::move(plan)); const auto& training = progressionRuntime.spellTraining().plan(); - observeNavigationPlan(training.approachPosition, std::move(steps)); + resetNavigation(); + if (trainerJourney) navigationRuntime.observePlan({PlayerBotNavigationGoal::anyOf(trainerJourney->destinations), + trainerJourney->plan, player.canDoAction(), true, std::chrono::steady_clock::now()}); emit("strategy_selection", position, "\"goal\":\"learn_spell\",\"npc_id\":" + std::to_string(training.npcId) + ",\"spell\":" + jsonString(training.spellName) + ",\"keyword\":" + jsonString(training.keyword) + ",\"price\":" + - std::to_string(training.price) + ",\"reserve\":" + std::to_string(training.reserve) + + std::to_string(training.price) + ",\"fare\":" + std::to_string(training.fare) + + ",\"reserve\":" + std::to_string(training.reserve) + ",\"potion_reserve\":" + std::to_string(training.potionReserve) + ",\"travel_steps\":" + std::to_string(training.travelSteps)); say(player, "Going to learn " + training.spellName + "."); @@ -604,6 +907,7 @@ void PlayerBotController::beginSpellTraining(Player& player, const Position& pos void PlayerBotController::finishSpellTraining(Player* player, const Position& position, const char* result, const char* reason) { + clearTrainerWork(); emit("strategy_objective_result", position, "\"goal\":\"learn_spell\",\"spell\":" + jsonString(progressionRuntime.spellTraining().plan().spellName) + ",\"result\":" + jsonString(result) + ",\"reason\":" + jsonString(reason)); @@ -629,33 +933,140 @@ void PlayerBotController::processSpellTraining(Player* player, const Position& c const uint16_t vocationId = player->getVocationId(); const uint16_t baseVocationId = player->getVocation()->getFromVocation() == 0 ? vocationId : player->getVocation()->getFromVocation(); + Spell* spell = g_spells ? g_spells->getSpellByName(training.spellName) : nullptr; const bool offerAvailable = trainer && playerBotNpcHasCapability(*trainer, PlayerBotNpcCapability::SpellTrainer) && - std::any_of(trainer->getSpellOffers().begin(), trainer->getSpellOffers().end(), [&training, player, baseVocationId](const NpcSpellOffer& offer) { - return offer.spellName == training.spellName && offer.keyword == training.keyword && offer.price == training.price && - offer.level == training.level && offer.premium == training.premium && player->getLevel() >= offer.level && - (!offer.premium || player->isPremium()) && - std::find(offer.vocationIds.begin(), offer.vocationIds.end(), baseVocationId) != offer.vocationIds.end(); - }); + spell && spell->isInstant() && spell->isLearnable() && spell->getLevel() == training.level && + spell->isPremium() == training.premium && spell->getVocMap().count(vocationId) != 0 && + std::any_of(trainer->getSpellOffers().begin(), trainer->getSpellOffers().end(), [&training, player, baseVocationId](const NpcSpellOffer& offer) { + return offer.spellName == training.spellName && offer.keyword == training.keyword && offer.price == training.price && + offer.level == training.level && offer.premium == training.premium && player->getLevel() >= offer.level && + (!offer.premium || player->isPremium()) && + std::find(offer.vocationIds.begin(), offer.vocationIds.end(), baseVocationId) != offer.vocationIds.end(); + }); PlayerBotSpellTrainingObservation observation; observation.totalMoney = player->getMoney() + player->getBankBalance(); - if (progressionRuntime.spellTraining().stage() != PlayerBotSpellTrainingStage::Verify && - (!playerBotAffordableAfterReserve(observation.totalMoney, training.reserve, training.price) || - inventoryPolicy.inventoryItemCount(*player, recoveryPotionItemId(vocationId)) <= - std::max(huntPotionReturnThreshold, training.potionReserve))) { + const bool verifying = progressionRuntime.spellTraining().stage() == PlayerBotSpellTrainingStage::Verify; + const auto* descriptor = playerBotSpellDescriptor(training.spellName.c_str()); + const bool healing = descriptor && descriptor->role == PlayerBotSpellRole::Healing; + const uint64_t reserve = std::max(training.reserve, spellTrainingReserve(*player, healing)); + const uint32_t potionReserve = trainerJourney ? std::max(training.potionReserve, + recoveryPotionRouteReserve(vocationId, player->getMaxHealth(), trainerJourney->plan.metrics.dangerCost, + static_cast(riskProfile.healthLossCost))) : training.potionReserve; + if (!verifying && (!playerBotTrainerTripAffordable(observation.totalMoney, reserve, training.price, trainerFareRemaining) || + inventoryPolicy.inventoryItemCount(*player, recoveryPotionItemId(vocationId)) <= + std::max(huntPotionReturnThreshold, potionReserve))) { finishSpellTraining(player, currentPosition, "failed", "recovery_reserve_changed"); return; } + if (!verifying && (!offerAvailable || player->hasLearnedInstantSpell(training.spellName))) { + finishSpellTraining(player, currentPosition, "failed", "trainer_offer_changed"); + return; + } + if (trainerTravelReceiptFailed || trainerJourneyRestarts > PlayerBotApproachLimits::rejectedTiles || + (!verifying && std::chrono::steady_clock::now() >= trainerJourneyDeadline)) { + finishSpellTraining(player, currentPosition, "failed", + trainerTravelReceiptFailed ? "travel_payment_or_landing_mismatch" : + trainerJourneyRestarts > PlayerBotApproachLimits::rejectedTiles ? "trainer_route_retry_limit" : "trainer_journey_deadline"); + return; + } if (progressionRuntime.spellTraining().stage() == PlayerBotSpellTrainingStage::Travel) { - bool approachUnavailable = false; - observation.navigationReached = processNpcApproach(player, currentPosition, trainer, training.approachPosition, approachUnavailable); - observation.navigationFailed = approachUnavailable || - navigationRuntime.fixedTargetRouteFailureCount() >= maximumProgressionAttempts; - } else if (progressionRuntime.spellTraining().stage() != PlayerBotSpellTrainingStage::Verify && - offerAvailable && !Position::areInRange<3, 3, 0>(currentPosition, trainer->getPosition())) { - progressionRuntime.restartSpellTrainingConversation(); - bool approachUnavailable = false; - processNpcApproach(player, currentPosition, trainer, training.approachPosition, approachUnavailable); - if (approachUnavailable) finishSpellTraining(player, currentPosition, "failed", "route_unavailable"); + auto destinations = trainerApproaches(*player, *trainer); + if (destinations.empty()) { + finishSpellTraining(player, currentPosition, "failed", "trainer_approach_unavailable"); + return; + } + // Discovery proves a choice against the whole search graph. Execution + // keeps only material actor/risk/world facts and this boarding leg. + // Ordinary position/ground timing and unused providers are not changes + // to that leg. A landing always requests a fresh remaining-trip proof. + const char* invalidation = !trainerJourney ? "missing_route" : + !playerBotTrainerDestinationRetained(trainerJourney->plan, trainerJourney->destinations, destinations) ? + "trainer_approach_changed" : nullptr; + if (trainerJourney && !trainerJourneyNeedsRoute && trainerJourney->plan.metrics.firstNpcTravelOffer) { + const auto& quote = *trainerJourney->plan.metrics.firstNpcTravelOffer; + Npc* provider = g_game.getNpcByID(quote.npcId); + if (!provider || !playerBotNpcHasCapability(*provider, PlayerBotNpcCapability::Travel) || + std::none_of(provider->getTravelOffers().begin(), provider->getTravelOffers().end(), [&](const auto& offer) { + return offer.destination == quote.destination && offer.price == quote.price && + offer.level == quote.minimumLevel && offer.premium == quote.premium && + offer.dialogue == quote.dialogue && !offer.hasOpaqueCondition && !offer.hasOpaqueAction; + })) { + finishSpellTraining(player, currentPosition, "failed", "travel_offer_changed"); + return; + } + const auto approach = playerBotTrainerBoardingApproach(trainerJourney->plan, + trainerJourney->evidence ? trainerJourney->evidence->source : currentPosition); + if (!approach || !Position::areInRange<3, 3, 0>(*approach, provider->getPosition())) + invalidation = "boarding_approach_changed"; + } + if (!invalidation && !trainerJourneyNeedsRoute) { + invalidation = trainerJourney->evidence ? + trainerTravelExecutionChange(*player, *trainerJourney->evidence) : "missing_execution_proof"; + } + if (invalidation && !trainerJourneyNeedsRoute) { + emit("trainer_route", currentPosition, "\"phase\":\"journey\",\"result\":\"invalidated\",\"reason\":" + + jsonString(invalidation) + ",\"restarts\":" + std::to_string(trainerJourneyRestarts + 1) + + ",\"remaining_fare\":" + std::to_string(trainerFareRemaining)); + if (++trainerJourneyRestarts > PlayerBotApproachLimits::rejectedTiles) { + finishSpellTraining(player, currentPosition, "failed", "trainer_route_invalidation_limit"); + return; + } + trainerJourneyNeedsRoute = true; + trainerRouteSearch.reset(); + resetNavigation(); + } + if (trainerJourneyNeedsRoute) { + if (!trainerJourney) trainerJourney.emplace(); + trainerJourney->destinations = destinations; + // Preserve the quoted allowance for all remaining legs; never borrow + // spell/supply money, even if the actor gains cash during the journey. + auto planned = advanceTrainerRoute(*player, destinations, reserve + training.price, trainerJourney->evidence); + if (!planned) return; + const char* failure = planned->metrics.result == PlayerBotNavigationResult::NodeLimit ? "route_proof_exhausted" : + planned->metrics.result == PlayerBotNavigationResult::RiskRejected ? "route_risk_rejected" : + planned->metrics.result != PlayerBotNavigationResult::Reached ? + (trainerFareLimited(*player, reserve + training.price) ? "route_fare_limited" : "trainer_unreachable") : + planned->metrics.fare > trainerFareRemaining ? "fare_quote_overrun" : + playerBotNavigationRiskVerdict(riskProfile, planned->metrics) != PlayerBotNavigationRiskVerdict::Accepted ? "route_risk_rejected" : + inventoryPolicy.inventoryItemCount(*player, recoveryPotionItemId(vocationId)) <= + std::max(huntPotionReturnThreshold, recoveryPotionRouteReserve(vocationId, player->getMaxHealth(), + planned->metrics.dangerCost, static_cast(riskProfile.healthLossCost))) ? "route_potion_reserve_unmet" : nullptr; + if (failure) { + finishSpellTraining(player, currentPosition, "failed", failure); + return; + } + trainerJourney->plan = *planned; + navigationRuntime.observePlan({PlayerBotNavigationGoal::anyOf(destinations), std::move(*planned), + player->canDoAction(), true, std::chrono::steady_clock::now()}); + trainerJourneyNeedsRoute = false; + } + PlayerBotNavigationRuntimeOutcome navigation; + observation.navigationReached = processNavigation(player, currentPosition, + PlayerBotNavigationGoal::anyOf(trainerJourney->destinations), + &navigation, playerBotNavigationMaximumExpandedNodes, true, false, &riskProfile, false); + if (navigation.routeRequest) { + if (++trainerJourneyRestarts > PlayerBotApproachLimits::rejectedTiles) { + finishSpellTraining(player, currentPosition, "failed", "trainer_route_retry_limit"); + return; + } + trainerJourneyNeedsRoute = true; + schedule(blockedRouteRetryInterval); + return; + } + observation.navigationFailed = navigation.routeUnsafe || navigation.fixedTargetRouteExhausted; + if (observation.navigationReached) { + // Reaching a stable talking tile ends the itinerary; no future fare + // can be spent while the normal paid-learning conversation owns it. + trainerFareRemaining = 0; + trainerJourney.reset(); + } + } else if (!verifying && !Position::areInRange<3, 3, 0>(currentPosition, trainer->getPosition())) { + // A trainer wandering away must obtain a new destination route, not fall + // back to the old walking-only NPC approach planner. + progressionRuntime.restartSpellTrainingTravel(); + trainerJourneyNeedsRoute = true; + resetNavigation(); + schedule(SCHEDULER_MINTICKS); return; } else { observation.npcAvailable = offerAvailable && Position::areInRange<3, 3, 0>(currentPosition, trainer->getPosition()); diff --git a/server/src/playerbotsupplypolicy.h b/server/src/playerbotsupplypolicy.h index 85f2371e..b86f918b 100644 --- a/server/src/playerbotsupplypolicy.h +++ b/server/src/playerbotsupplypolicy.h @@ -277,6 +277,8 @@ struct PlayerBotSupplyBudget { double expectedDamage = 0; double regenerationHealing = 0; double spellHealing = 0; + // Calibrate fractional demand; expectedPotions remains the rounded stock count. + double potionEquivalentDemand = 0; double expectedPotions = 0; uint32_t reservedPotions = 1; uint32_t routinePotions = 0; @@ -313,8 +315,9 @@ inline PlayerBotSupplyBudget playerBotSupplyBudget(const PlayerBotSupplyProfile& result.spellHealing = std::min(result.expectedDamage - result.regenerationHealing, casts * profile.spellHealing); } 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.potionEquivalentDemand = deficit == 0 ? 0 : profile.potionHealing > 0 ? + deficit / profile.potionHealing : std::numeric_limits::max(); + result.expectedPotions = std::ceil(result.potionEquivalentDemand); result.fits = (profile.potions > profile.reserve || profile.reserve == 0) && result.expectedPotions <= result.routinePotions; return result; @@ -326,6 +329,16 @@ inline uint64_t playerBotRecoverySpendingReserve(uint32_t potions, uint32_t targ return goldReserve + static_cast(potions < target ? target - potions : 0) * price; } +// Temporary bootstrap until supply-shopping/net-income planning can avoid +// starving first-heal capital. The complete supply reserve already includes +// goldReserve once; release only that cash buffer, never replenishment money. +inline uint64_t playerBotSpellTrainingSpendingReserve(uint64_t supplyReserve, uint32_t goldReserve, + bool firstUsableHeal) +{ + if (supplyReserve == UINT64_MAX || supplyReserve < goldReserve) return UINT64_MAX; + return firstUsableHeal ? supplyReserve - goldReserve : supplyReserve; +} + inline bool playerBotAffordableAfterReserve(uint64_t money, uint64_t reserve, uint32_t price) { return money >= reserve && money - reserve >= price; diff --git a/server/src/playerbotsupplyrecovery.h b/server/src/playerbotsupplyrecovery.h index 28a3c2d0..49b3d5f3 100644 --- a/server/src/playerbotsupplyrecovery.h +++ b/server/src/playerbotsupplyrecovery.h @@ -57,6 +57,8 @@ class PlayerBotSupplyRecoveryState deferredStockKey = stockKey; } + void clearRestockDeferral() { restockDeferred = false; } + bool restockBlocked(uint64_t funds, uint64_t stockKey) { if (funds != deferredFunds || stockKey != deferredStockKey) restockDeferred = false; diff --git a/server/src/playerbotsupplystock.h b/server/src/playerbotsupplystock.h index 8588d7e3..a4f301e1 100644 --- a/server/src/playerbotsupplystock.h +++ b/server/src/playerbotsupplystock.h @@ -68,6 +68,9 @@ struct PlayerBotSupplyRule { // Stock at or below this is hunt reserve, not routine supply. uint32_t returnThreshold = 0; uint32_t target = 0; + // Optional shopping tier after all safety floors, before full targets. + // Zero leaves the kind's existing purchase priority unchanged. + uint32_t preferredStock = 0; bool active() const { return itemId != 0 && target != 0; } }; @@ -90,6 +93,7 @@ inline PlayerBotSupplyRule playerBotSupplyRule(PlayerBotSupplyKind kind, uint16_ rule.safetyFloor = 2; rule.returnThreshold = 1; rule.target = 20; + rule.preferredStock = 10; } } return rule; @@ -122,6 +126,28 @@ struct PlayerBotSupplyStock { // Ordered by playerBotSupplyKinds; inactive kinds may be omitted. using PlayerBotSupplyStocks = std::vector; +struct PlayerBotSupplyRequirement { + PlayerBotSupplyKind kind = PlayerBotSupplyKind::HealthPotion; + uint16_t itemId = 0; // Health is resolved against the current vocation. + uint32_t count = 0; +}; + +using PlayerBotSupplyRequirements = std::vector; + +// Route stock is a mandatory floor, not an optional shopping target. Do not +// transfer evidence to a different weapon or potion type after a loadout change. +inline void playerBotRequireSupplies(PlayerBotSupplyStocks& stocks, const PlayerBotSupplyRequirements& requirements) +{ + for (auto& stock : stocks) { + for (const auto& requirement : requirements) { + if (stock.rule.kind != requirement.kind || + (requirement.itemId != 0 && stock.rule.itemId != requirement.itemId)) continue; + stock.rule.safetyFloor = std::max(stock.rule.safetyFloor, requirement.count); + stock.rule.target = std::max(stock.rule.target, stock.rule.safetyFloor); + } + } +} + struct PlayerBotSupplyDeficit { const PlayerBotSupplyStock* first = nullptr; uint32_t missing = 0; diff --git a/server/src/playerbotsurvivalruntime.cpp b/server/src/playerbotsurvivalruntime.cpp index 424d5669..ac8f4b75 100644 --- a/server/src/playerbotsurvivalruntime.cpp +++ b/server/src/playerbotsurvivalruntime.cpp @@ -120,6 +120,43 @@ PlayerBotSurvivalCommand PlayerBotSurvivalRuntime::manaPotion(PlayerBotSurvivalC return command; } +bool PlayerBotSurvivalRuntime::canAssistHealingMana(const PlayerBotSurvivalSnapshot& snapshot, + const PlayerBotSurvivalSpellObservation* healing) const +{ + return healing && healing->metadataMatches && healing->learned && healing->vocationAllowed && + healing->requirementsMet && healing->targetReachable && snapshot.mana < healing->manaCost && + snapshot.manaPotionItemId != 0 && snapshot.manaPotionCount != 0 && + (!snapshot.combatActive || snapshot.potionCount == 0); +} + +bool PlayerBotSurvivalRuntime::canPrepareHealth(const PlayerBotSurvivalSnapshot& snapshot) const +{ + return snapshot.health < snapshot.healthMaximum && + (preferredHealingSpell(snapshot, true, true) != nullptr || + canAssistHealingMana(snapshot, preferredHealingSpell(snapshot, false, true)) || + (snapshot.potionItemId != 0 && snapshot.potionCount != 0)); +} + +std::vector PlayerBotSurvivalRuntime::healthPreparations( + const PlayerBotSurvivalSnapshot& snapshot, const PlayerBotPreparationRequirement& requirement, + bool validatedRest, bool validatedFoodPurchase) const +{ + std::vector options; + if (!requirement.observeRegeneration && canPrepareHealth(snapshot)) options.push_back(PlayerBotPreparationOption::RestoreHealth); + if (validatedRest && ((snapshot.regenerationActive && snapshot.foodTicks > 0) || snapshot.foodClientId != 0)) + options.push_back(PlayerBotPreparationOption::Regenerate); + if (validatedRest && validatedFoodPurchase) options.push_back(PlayerBotPreparationOption::BuyFood); + return options; +} + +bool PlayerBotSurvivalRuntime::healthPreparationSatisfied(const PlayerBotSurvivalSnapshot& snapshot, + const PlayerBotPreparationRequirement& requirement, bool safeRestHere) const +{ + return snapshot.health >= static_cast(requirement.health) && + (!requirement.observeRegeneration || (safeRestHere && !snapshot.protectionZone && + snapshot.regenerationActive && snapshot.foodTicks > 0)); +} + PlayerBotSurvivalCommand PlayerBotSurvivalRuntime::decideHealing(const PlayerBotSurvivalSnapshot& snapshot, std::chrono::steady_clock::time_point now) { @@ -165,8 +202,7 @@ PlayerBotSurvivalCommand PlayerBotSurvivalRuntime::decideHealing(const PlayerBot } // Reevaluate each turn: peaceful mana-assisted healing is not a promise to // cast next turn if combat starts or health becomes critical. - if (spellAttempt.reason == "insufficient_mana_reserve" && snapshot.manaPotionItemId != 0 && snapshot.manaPotionCount != 0 && - (!snapshot.combatActive || snapshot.potionCount == 0)) { + if (spellAttempt.reason == "insufficient_mana_reserve" && canAssistHealingMana(snapshot, healing)) { return manaPotion(command, snapshot, "healing_spell_mana"); } if (snapshot.potionCount == 0) { diff --git a/server/src/playerbotsurvivalruntime.h b/server/src/playerbotsurvivalruntime.h index acd9a1b4..332ef611 100644 --- a/server/src/playerbotsurvivalruntime.h +++ b/server/src/playerbotsurvivalruntime.h @@ -6,6 +6,7 @@ #define FS_PLAYERBOTSURVIVALRUNTIME_H #include "playerbotrecoverysession.h" +#include "playerbotpreparation.h" #include "playerbotspellruntime.h" #include @@ -137,6 +138,11 @@ class PlayerBotSurvivalRuntime { public: bool needsHealing(const PlayerBotSurvivalSnapshot& snapshot) const; + bool canPrepareHealth(const PlayerBotSurvivalSnapshot& snapshot) const; + std::vector healthPreparations(const PlayerBotSurvivalSnapshot& snapshot, + const PlayerBotPreparationRequirement& requirement, bool validatedRest, bool validatedFoodPurchase) const; + bool healthPreparationSatisfied(const PlayerBotSurvivalSnapshot& snapshot, + const PlayerBotPreparationRequirement& requirement, bool safeRestHere) const; // Mana left for the preferred economical learned heal, ignoring current // mana and overheal. Zero without an eligible learned healing spell. uint32_t healingManaReserve(const PlayerBotSurvivalSnapshot& snapshot) const; @@ -173,6 +179,8 @@ class PlayerBotSurvivalRuntime private: PlayerBotPotionAttempt potionObservation(const PlayerBotSurvivalSnapshot& snapshot, uint16_t itemId) const; bool needsManaPotion(const PlayerBotSurvivalSnapshot& snapshot) const; + bool canAssistHealingMana(const PlayerBotSurvivalSnapshot& snapshot, + const PlayerBotSurvivalSpellObservation* healing) const; PlayerBotSurvivalCommand manaPotion(PlayerBotSurvivalCommand command, const PlayerBotSurvivalSnapshot& snapshot, const char* reason) const; PlayerBotSurvivalCommand decideSpell(const PlayerBotSurvivalSnapshot& snapshot, const char* spellName, diff --git a/server/src/playerbottrainerroute.h b/server/src/playerbottrainerroute.h new file mode 100644 index 00000000..8d280c19 --- /dev/null +++ b/server/src/playerbottrainerroute.h @@ -0,0 +1,178 @@ +#ifndef FS_PLAYERBOTTRAINERROUTE_H +#define FS_PLAYERBOTTRAINERROUTE_H + +#include "playerbotnavigationruntime.h" + +#include +#include +#include +#include +#include +#include + +// The saved boarding walk remains executable while its endpoint is in the +// selected captain's live talking range. Never substitute a generic follow path. +inline std::optional playerBotTrainerBoardingApproach(const PlayerBotNavigationRoutePlan& route, Position source) +{ + for (const auto& step : route.steps) { + if (step.action == PlayerBotNavigationAction::NpcTravel) return source; + source = step.expectedPosition; + } + return std::nullopt; +} + +inline bool playerBotTrainerDestinationRetained(const PlayerBotNavigationRoutePlan& route, + const std::vector& quoted, const std::vector& live) +{ + if (route.metrics.firstNpcTravelOffer) { + // This leg ends at boarding. Common trainer goals remain destinations; + // the next leg obtains a fresh goal/itinerary proof after landing. + return std::any_of(quoted.begin(), quoted.end(), [&](Position p) { + return std::find(live.begin(), live.end(), p) != live.end(); + }); + } + const Position endpoint = route.steps.empty() ? route.metrics.waypoint : route.steps.back().expectedPosition; + return std::find(live.begin(), live.end(), endpoint) != live.end(); +} + +inline bool playerBotTrainerDiscoveryDestinationRetained(const PlayerBotNavigationRoutePlan& route, + const std::vector& quoted, const std::vector& live) +{ + // Paid discovery retains a whole-trip quote but not the final walking + // endpoint. Keep its destination-set validation conservative until landing. + return route.metrics.firstNpcTravelOffer ? quoted == live : playerBotTrainerDestinationRetained(route, quoted, live); +} + +inline uint32_t playerBotTrainerMinimumSteps(Position source, const std::vector& liveApproaches) +{ + // Walking, item-use and NPC travel all count as steps. If the source is + // not a live goal, no complete route can have zero steps (including boats). + return std::find(liveApproaches.begin(), liveApproaches.end(), source) != liveApproaches.end() ? 0 : 1; +} + +// Ranking before route-dependent steps is also the discovery work order. +// A strictly worse prefix cannot win even with a shorter or cheaper journey. +template auto playerBotTrainerPriority(const Offer& offer) +{ + return std::tie(offer.learningPriority, offer.level, offer.price); +} + +template bool playerBotTrainerOptimisticCanBeat(const Offer& candidate, uint32_t minimumSteps, + const Offer& incumbent) +{ + return std::tuple_cat(playerBotTrainerPriority(candidate), + std::tie(minimumSteps, candidate.spellName, candidate.npcId)) < + std::tuple_cat(playerBotTrainerPriority(incumbent), + std::tie(incumbent.route.steps, incumbent.spellName, incumbent.npcId)); +} + +// The potion requirement belongs to the discarded proof, not the offer. +// Retained completed candidates must keep theirs until that proof is replaced. +template void playerBotTrainerDiscardRouteProof(Offer& offer) +{ + offer.route = {}; + offer.potionReserve = 0; +} + +template void playerBotTrainerEndPendingProofs(Offers& offers, size_t next) +{ + for (size_t i = next; i < offers.size(); ++i) + if (!offers[i].route.reachable) offers[i].routeRejection = "route_proof_exhausted"; +} + +// If final live validation rejects the incumbent, deferred offers can compete +// again. Their old rejection was ranking, never a reachability proof. +template std::optional playerBotTrainerDeferredNeeded( + const Offers& offers, std::optional selected, MinimumSteps minimumSteps) +{ + for (size_t i = 0; i < offers.size(); ++i) { + if (offers[i].routeRejection == "lower_learning_priority" && + (!selected || playerBotTrainerPriority(offers[i]) <= playerBotTrainerPriority(offers[*selected]))) return i; + if (offers[i].routeRejection == "route_rank_dominated" && + (!selected || playerBotTrainerOptimisticCanBeat(offers[i], minimumSteps(i), offers[*selected]))) return i; + } + return std::nullopt; +} + +template std::optional playerBotTrainerDeferredNeeded( + const Offers& offers, std::optional selected) +{ + return playerBotTrainerDeferredNeeded(offers, selected, [](size_t) { return uint32_t(0); }); +} + +struct PlayerBotTrainerScanProgress { + using Clock = std::chrono::steady_clock; + uint64_t continuations = 0; // Includes admission waits; never a route-proof limit. + uint32_t restarts = 0; + Clock::time_point deadline; + + const char* exhaustion(Clock::time_point now, uint32_t maximumRestarts) const { + if (restarts > maximumRestarts) return "scan_restart_limit"; + if (now >= deadline) return "scan_elapsed_limit"; + return nullptr; + } +}; + +// Subtraction, not addition: quotes must remain safe even at UINT64_MAX. +inline bool playerBotTrainerTripAffordable(uint64_t funds, uint64_t reserve, uint64_t spell, uint64_t fare) +{ + return reserve != UINT64_MAX && funds >= reserve && funds - reserve >= spell && + funds - reserve - spell >= fare; +} + +inline bool playerBotTrainerFareAffordable(uint64_t funds, uint64_t reserve, uint64_t spell, + uint64_t remainingFare, uint64_t fare) +{ + return fare <= remainingFare && playerBotTrainerTripAffordable(funds, reserve, spell, remainingFare); +} + +inline bool playerBotTrainerTravelReceipt(bool landed, uint64_t before, uint64_t after, uint64_t fare) +{ + return landed && before >= fare && after == before - fare; +} + +// One arbitration episode has two admitted phases. A completed negative +// trainer decision is just as durable as a positive one while preparation +// waits for admission. The controller supplies semantic facts and route proof. +template class PlayerBotTrainerArbitration { +public: + struct Result { + bool pending = false; + std::optional selected; + }; + enum class Phase { Trainer, Preparation }; + Phase phase() const { return currentPhase; } + bool completed() const { return currentPhase == Phase::Preparation; } + void reset() { *this = {}; } + + template + Result advanceTrainer(const std::string& liveFacts, bool proofValid, Discover discover) { + if (completed() && facts == liveFacts && proofValid) return decision; + currentPhase = Phase::Trainer; + decision = discover(); + if (!decision.pending) { + facts = liveFacts; + currentPhase = Phase::Preparation; + } + return decision; + } +private: + Phase currentPhase = Phase::Trainer; + std::string facts; + Result decision; +}; + +// The route engine uses one active slot. Scoped substitution keeps independent +// owners' frontiers intact even when a slice yields or throws. +template class PlayerBotRouteWorkSlot { +public: + PlayerBotRouteWorkSlot(Work& active, Work& owned) : active(active), owned(owned) { active.swap(owned); } + ~PlayerBotRouteWorkSlot() { active.swap(owned); } + PlayerBotRouteWorkSlot(const PlayerBotRouteWorkSlot&) = delete; + PlayerBotRouteWorkSlot& operator=(const PlayerBotRouteWorkSlot&) = delete; +private: + Work& active; + Work& owned; +}; + +#endif diff --git a/server/src/playerbotturnrouter.cpp b/server/src/playerbotturnrouter.cpp index fe65c277..9b7ed671 100644 --- a/server/src/playerbotturnrouter.cpp +++ b/server/src/playerbotturnrouter.cpp @@ -20,6 +20,9 @@ namespace { observation.lootNavigationSuspended = true; if (router.route(observation) != PlayerBotTurnCommand::MagicTraining) return false; observation.magicTrainingActive = false; + observation.preparationActive = true; + if (router.route(observation) != PlayerBotTurnCommand::Prepare) return false; + observation.preparationActive = false; if (router.route(observation) != PlayerBotTurnCommand::PlanHunt) return false; observation.huntPlanningActive = false; if (router.route(observation) != PlayerBotTurnCommand::SuspendedLoot) return false; diff --git a/server/src/playerbotturnrouter.h b/server/src/playerbotturnrouter.h index ba4da87c..4e6c1294 100644 --- a/server/src/playerbotturnrouter.h +++ b/server/src/playerbotturnrouter.h @@ -26,7 +26,9 @@ enum class PlayerBotControllerLifecycle : uint8_t { enum class PlayerBotTurnCommand : uint8_t { None, Progression, + TrainerDiscovery, MagicTraining, + Prepare, StartHunt, PlanHunt, SuspendedLoot, @@ -46,6 +48,8 @@ struct PlayerBotTurnObservation { bool huntPlanningActive = false; bool lootNavigationSuspended = false; bool huntCycleFinished = false; + bool preparationActive = false; + bool trainerDiscoveryActive = false; }; class PlayerBotTurnRouter @@ -70,8 +74,10 @@ class PlayerBotTurnRouter constexpr PlayerBotTurnCommand route(const PlayerBotTurnObservation& observation) const { if (!running()) return PlayerBotTurnCommand::None; + if (observation.trainerDiscoveryActive) return PlayerBotTurnCommand::TrainerDiscovery; if (observation.progressionActive) return PlayerBotTurnCommand::Progression; if (observation.magicTrainingActive) return PlayerBotTurnCommand::MagicTraining; + if (observation.preparationActive) return PlayerBotTurnCommand::Prepare; if (observation.huntRegionSelectionRequired) return PlayerBotTurnCommand::StartHunt; if (observation.huntPlanningActive) return PlayerBotTurnCommand::PlanHunt; if (stage == PlayerBotScenarioStage::LootCorpse && observation.lootNavigationSuspended) { diff --git a/server/tests/playerbot-gameplay/includes/login.inc b/server/tests/playerbot-gameplay/includes/login.inc index 6f1cafb1..b061f091 100644 --- a/server/tests/playerbot-gameplay/includes/login.inc +++ b/server/tests/playerbot-gameplay/includes/login.inc @@ -876,7 +876,9 @@ function F.login.onLogin(player) end if mode == "spell_training" or mode == "spell_training_low_supplies" or mode == "spell_training_low_supplies_unaffordable" then local lowSupplies = mode ~= "spell_training" - local funding = mode == "spell_training_low_supplies_unaffordable" and 269 or lowSupplies and 270 or 300 + -- Two health potions meet the mandatory floor. First-heal bootstrap + -- releases only the cash buffer: 170 price + 0 reserve - 1 = 169. + local funding = mode == "spell_training_low_supplies_unaffordable" and 169 or lowSupplies and 270 or 300 if player:getStorageValue(F.spellTrainingStorage) == 1 then assert(player:hasLearnedSpell("Light Healing") and not player:hasLearnedSpell("Find Person"), "spell training priority did not persist across restart") diff --git a/server/tests/playerbot-gameplay/includes/verifiers.inc b/server/tests/playerbot-gameplay/includes/verifiers.inc index 26f1d5a2..d36366c4 100644 --- a/server/tests/playerbot-gameplay/includes/verifiers.inc +++ b/server/tests/playerbot-gameplay/includes/verifiers.inc @@ -189,7 +189,7 @@ function F.verifyLowSupplySpellTraining(playerId, attempts) assert(player:hasLearnedSpell("Light Healing") and not player:hasLearnedSpell("Find Person"), "low-supply training did not prioritize Light Healing") assert(player:getMoney() + player:getBankBalance() == 100 and player:getItemCount(F.potionItemId) == 2, - "low-supply training did not retain the exact emergency reserves") + "low-supply training did not preserve exact payment and the two-potion safety floor") print("PLAYERBOT_GAMEPLAY_TEST SPELL_TRAINING_LOW_SUPPLIES_PASS") end diff --git a/server/tests/playerbot_contracts.cpp b/server/tests/playerbot_contracts.cpp index 40618023..f838a8f6 100644 --- a/server/tests/playerbot_contracts.cpp +++ b/server/tests/playerbot_contracts.cpp @@ -24,6 +24,7 @@ #include "playerbothuntrouteretention.h" #include "playerbotinventorypolicy.h" #include "playerbotsupplyrecovery.h" +#include "playerbothuntsupplyhandoff.h" #include "playerbotsupplystock.h" #include "playerbottelemetry.h" #include "playerbottestpolicy.h" @@ -468,6 +469,385 @@ PlayerBotHuntRouteObservation routeFacts(bool reached = true) return observation; } +void preparationContracts() +{ + // A bank basement and a nearby upstairs provider/rest need ordinary floor + // transitions, not a same-floor whitelist. Coordinates are synthetic. + const Position basement(100, 100, 8), upstairsShop(86, 76, 7), upstairsRest(100, 90, 7); + assert(playerBotPreparationProviderInRange(basement, upstairsShop)); + const auto restCandidates = playerBotPreparationRestPositions(basement, + [&](const Position& candidate) { return candidate == upstairsRest; }); + assert(restCandidates == std::vector{upstairsRest}); + assert(!playerBotPreparationProviderInRange(basement, Position(133, 100, 7))); + assert(!playerBotPreparationProviderInRange(basement, Position(100, 100, 5))); + const auto boundedRest = playerBotPreparationRestPositions(Position(0, 0, 0), [](const Position&) { return true; }); + assert(boundedRest.size() == 512); + for (const Position& position : boundedRest) assert(position.x <= 32 && position.y <= 32 && position.z <= 2); + assert(playerBotPreparationRestPositions(basement, [](const Position&) { return false; }).empty()); // No safe non-PZ endpoints. + PlayerBotPreparationRouteBudget routes; + for (int i = 0; i < 4; ++i) { assert(routes.allowance() == 4096); routes.observed(4096); } + assert(routes.allowance() == 0); + PlayerBotPreparationRouteBudget cheapRoutes; + for (int i = 0; i < 8; ++i) { assert(cheapRoutes.allowance() == 4096); cheapRoutes.observed(0); } + assert(cheapRoutes.allowance() == 0); + assert(playerBotPreparationRouteAccepted(PlayerBotNavigationResult::Reached, 0, 0, 0, true)); + for (auto result : {PlayerBotNavigationResult::NodeLimit, PlayerBotNavigationResult::Unreachable, PlayerBotNavigationResult::RiskRejected}) + assert(!playerBotPreparationRouteAccepted(result, 0, 0, 0, true)); + assert(!playerBotPreparationRouteAccepted(PlayerBotNavigationResult::Reached, 1, 0, 0, true)); + assert(!playerBotPreparationRouteAccepted(PlayerBotNavigationResult::Reached, 0, 0.01, 0, true)); + assert(!playerBotPreparationRouteAccepted(PlayerBotNavigationResult::Reached, 0, 0, 1, true)); + assert(!playerBotPreparationRouteAccepted(PlayerBotNavigationResult::Reached, 0, 0, 0, false)); // A waypoint is not a complete proof. + PlayerBotGoalPlanner planner; + PlayerBotGoalPlannerSnapshot snapshot; + snapshot.magicTrainingReason = "no_regeneration"; + PlayerBotPreparationRequirement health; + health.kind = PlayerBotRequirementKind::HealthRegeneration; + health.health = 186; + snapshot.huntReadiness = {PlayerBotReadiness::Blocked, health}; + auto candidates = planner.candidates(snapshot); + auto hunt = [](const auto& candidates) -> const PlayerBotGoalArbiter::GoalCandidate& { + return *std::find_if(candidates.begin(), candidates.end(), [](const auto& candidate) { + return candidate.goal == PlayerBotGoalArbiter::TopLevelGoal::Hunt; + }); + }; + assert(!hunt(candidates).feasible); + assert(std::none_of(candidates.begin(), candidates.end(), [](const auto& candidate) { return candidate.feasible; })); + for (auto option : {PlayerBotPreparationOption::RestoreHealth, PlayerBotPreparationOption::Regenerate, + PlayerBotPreparationOption::BuyFood}) { + snapshot.preparations = {option}; + candidates = planner.candidates(snapshot); + assert(!hunt(candidates).feasible); + auto chosen = std::max_element(candidates.begin(), candidates.end(), [](const auto& left, const auto& right) { + return (left.feasible ? left.utility : 0) < (right.feasible ? right.utility : 0); + }); + assert(chosen->goal == PlayerBotGoalArbiter::TopLevelGoal::Prepare && chosen->preparation == option); + } + snapshot.preparations = {PlayerBotPreparationOption::RestoreHealth, PlayerBotPreparationOption::Regenerate}; + candidates = planner.candidates(snapshot); + const auto restore = std::find_if(candidates.begin(), candidates.end(), [](const auto& candidate) { + return candidate.preparation == PlayerBotPreparationOption::RestoreHealth; + }); + const auto regenerate = std::find_if(candidates.begin(), candidates.end(), [](const auto& candidate) { + return candidate.preparation == PlayerBotPreparationOption::Regenerate; + }); + assert(restore->feasible && regenerate->feasible && restore->utility > regenerate->utility); + snapshot.huntReadiness.state = PlayerBotReadiness::Pending; + snapshot.preparations.clear(); + assert(hunt(planner.candidates(snapshot)).feasible); // Request evidence, not executable travel. + snapshot.huntReadiness.state = PlayerBotReadiness::Blocked; + snapshot.huntReadiness.requirement->kind = PlayerBotRequirementKind::Stock; + snapshot.preparations = {PlayerBotPreparationOption::ServiceStock}; + assert(!hunt(planner.candidates(snapshot)).feasible); + assert(planner.candidates(snapshot)[1].feasible); + snapshot.preparations = {PlayerBotPreparationOption::RecoveryIncome}; + assert(hunt(planner.candidates(snapshot)).feasible); + + PlayerBotPreparationBudget budget; + assert(!budget.completed(10)); + assert(!budget.completed(10)); // Currency or unrelated stock identity is not a gap change. + assert(!budget.completed(9)); // Genuine requirement improvement resets attempts. + assert(!budget.completed(10)); // A regression does not reset the high-water mark. + assert(budget.completed(9)); + assert(!budget.completed(8)); // Successive higher health targets remain genuine improvement. + assert(!budget.completed(8, true)); // First verified regeneration is new evidence. + assert(!budget.completed(8, false)); + assert(budget.completed(8, true)); // Eating again is not another first observation. + budget.reset(); + assert(!budget.completed(0, false, PlayerBotRequirementKind::Stock)); + assert(!budget.completed(10)); // Health and stock gaps must not share a numerical scale. + assert(!budget.completed(9)); // Genuine health improvement still resets after stock work. + assert(!budget.completed(0, false, PlayerBotRequirementKind::Stock)); + assert(budget.completed(9)); // Switching kinds without improvement cannot loop. + budget.reset(); + assert(!budget.completed(0)); assert(!budget.completed(0)); assert(budget.completed(0)); + + auto region = routeFixture(75); + region.supplyRecovery = true; + region.coinGoldPerMinute = 1; + region.currentHealth = 180; + region.maximumHealth = 195; + region.recoveryRouteHealthLoss = 20; + region.predictedFightSeconds = 10; + region.combatFraction = 0.2; + region.expectedDamagePerSecond = 0.2; + region.supplyProfile.potions = 2; + region.fitSupplyWindow(2); + assert(!region.productiveWindow()); + const auto requirement = region.healthPreparation(2); + assert(requirement && !requirement->observeRegeneration && requirement->health > 180 && requirement->health < 195); + auto evaluate = [](PlayerBotHuntRegion candidate, bool incompleteAlternative = false) { + std::vector candidates{candidate}; + if (incompleteAlternative) candidates.insert(candidates.begin(), routeFixture(76)); + PlayerBotHuntRouteSelection selection(std::move(candidates)); + if (incompleteAlternative) { + auto unknown = routeFacts(false); + unknown.searchIncomplete = true; + selection.observe(selection.next(), unknown); + } + auto facts = routeFacts(); + facts.supplyProfile = candidate.supplyProfile; + facts.potionReserve = 2; + facts.recoveryRouteHealthLoss = 20; + facts.approaches = {Position(200, 100, 7)}; + PlayerBotHuntRouteResult result; + for (int turn = 0; turn < 8 && !result.terminal; ++turn) + result = selection.observe(selection.next(), facts); + assert(result.terminal); + return result; + }; + const auto mixed = evaluate(region, true); + assert(mixed.outcome == PlayerBotHuntRouteOutcome::NeedsPreparation && mixed.readiness.requirement); + assert(mixed.readiness.state == PlayerBotReadiness::Blocked && mixed.rejectedVariants.empty()); + assert(mixed.failureCounts.at("route_search_incomplete") == 1); + const auto blocked = evaluate(region); + assert(blocked.outcome == PlayerBotHuntRouteOutcome::NeedsPreparation && !blocked.selectedRouteRegion); + assert(blocked.readiness.state == PlayerBotReadiness::Blocked && blocked.readiness.requirement); + PlayerBotHuntSupplyHandoff handoff; + handoff.blocked(blocked.readiness, {}); + assert(handoff.readiness().state == PlayerBotReadiness::Blocked); + handoff.replan(); + assert(handoff.readiness().state == PlayerBotReadiness::Pending && !handoff.readiness().requirement); + // An actually observed regeneration profile is evaluated anew, not treated + // as a cached promise from purchasing or carrying food. + auto fed = region; + fed.supplyProfile.regenerationSeconds = 180; + fed.supplyProfile.healthGain = 5; + fed.supplyProfile.healthInterval = 4; + assert(evaluate(fed).outcome == PlayerBotHuntRouteOutcome::Selected); + region.currentHealth = requirement->health; + region.fitSupplyWindow(2); + assert(region.productiveWindow() && region.recoverySustainable()); + assert(!region.healthPreparation(2)); // Successful shortened hunts need no preparation. + region.currentHealth = 180; + region.expectedDamagePerSecond = 100; + region.fitSupplyWindow(2); + const auto unknownFoodEffect = region.healthPreparation(2); + assert(unknownFoodEffect && unknownFoodEffect->observeRegeneration); + region.supplyProfile.regenerationSeconds = 180; + region.fitSupplyWindow(2); + assert(!region.healthPreparation(2)); // Observing food alone does not promise a fitting hunt. + region.supplyProfile.regenerationSeconds = 0; + region.recoveryRouteHealthLoss = 40; + assert(!region.healthPreparation(2)); // Food cannot overcome a route above the maximum 80%-floor margin. +} + +void huntSupplyServiceReplayContracts() +{ + PlayerBotHuntPlanningProfile profile; + profile.combat.level = 9; + profile.combat.maximumHealth = 195; + profile.combat.attack = 25; + profile.combat.attackSkill = 40; + profile.supplyCapabilityDefense = 10; + profile.equipmentItemIds[5] = 2389; // wielded spear; count is stock, not equipment identity + profile.equipmentItemIds[6] = 2512; + profile.equipmentItemIds[10] = 2148; // currency in the legacy ammo slot + profile.supply.potionHealing = 125; + profile.supply.maximumMana = 50; + auto stocks = [](uint32_t spears) -> PlayerBotSupplyStocks { + return {{{PlayerBotSupplyKind::HealthPotion, 7618, 2, 1, 2}, 2}, + {{PlayerBotSupplyKind::ManaPotion, 7620, 2, 1, 20}, 2}, + {{PlayerBotSupplyKind::ThrowingWeapon, 2389, 3, 1, 3}, spears}}; + }; + auto facts = [&](uint64_t money, uint64_t bank, uint32_t spears, uint16_t slottedMoney) { + auto current = profile; + current.equipmentItemIds[10] = slottedMoney; + PlayerBotHuntSupplyFacts result; + result.capability = playerBotSupplyCapability(current); + result.funds = money + bank; + result.stockKey = playerBotSupplyStockKey(stocks(spears)); + result.health = 180; + return result; + }; + const auto start = facts(11, 0, 3, 2148); + assert(start.sameDependencies(facts(0, 11, 3, 0))); + assert(start.sameDependencies(facts(15, 0, 4, 2148))); + assert(start.sameDependencies(facts(0, 100, 3, 2152))); + assert(start.sameDependencies(facts(0, 10000, 3, 2160))); + // Real ammunition and weapon replacements still change loadout identity. + assert(!start.sameDependencies(facts(15, 0, 3, 2544))); + auto replacement = start; + replacement.capability.equipmentItemIds[5] = 2399; + assert(!start.sameDependencies(replacement)); + + PlayerBotHuntSupplyHandoff handoff; + PlayerBotSupplyRecoveryState recovery; + handoff.require({{PlayerBotSupplyKind::HealthPotion, 0, 3}}, start); + assert(!handoff.noProgress(PlayerBotHuntRouteOutcome::NeedsSupplies, start)); + recovery.enter(); + auto service = [&](const PlayerBotHuntSupplyFacts& current, uint32_t spears) { + assert(!handoff.refresh(current)); + auto carried = stocks(spears); + playerBotRequireSupplies(carried, handoff.requirements()); + assert(carried.front().rule.safetyFloor == 3 && carried.front().rule.target == 3); + const auto budget = playerBotSupplyFloorSpendingReserve(0, {{2, carried.front().rule.safetyFloor, 45}}); + assert(budget == 45); + recovery.restockBlocked(current.funds, current.stockKey); + recovery.update(current.funds, budget); + assert(recovery.active()); // Never clear against the generic health floor of two. + assert(!handoff.noProgress(PlayerBotHuntRouteOutcome::Pending, current)); + }; + service(facts(15, 0, 3, 2148), 3); // hat sale genuinely adds four gold + service(facts(15, 0, 4, 2148), 4); // depot withdraw merges one spear + service(facts(15, 0, 3, 2148), 3); // redeposit restores the same stock + service(facts(0, 15, 3, 0), 3); // deposit all + service(facts(15, 0, 3, 2148), 3); // withdraw all + const auto repeated = facts(15, 0, 3, 2148); + assert(!handoff.noProgress(PlayerBotHuntRouteOutcome::NeedsSupplies, repeated)); // genuinely new funds + for (int trip = 0; trip < 2; ++trip) { + service(facts(15, 0, 4, 2148), 4); + service(facts(15, 0, 3, 2148), 3); + service(facts(0, 15, 3, 0), 3); + service(repeated, 3); + assert(handoff.noProgress(PlayerBotHuntRouteOutcome::NeedsSupplies, repeated) == (trip == 1)); + } + // Even a completed failure at transient higher stock must not erase the + // previous completed failure when the next trip restores that old stock. + PlayerBotHuntSupplyHandoff alternating; + assert(!alternating.noProgress(PlayerBotHuntRouteOutcome::NeedsSupplies, repeated)); + assert(!alternating.noProgress(PlayerBotHuntRouteOutcome::NeedsSupplies, facts(15, 0, 4, 2148))); + assert(!alternating.noProgress(PlayerBotHuntRouteOutcome::NeedsSupplies, repeated)); + assert(!alternating.noProgress(PlayerBotHuntRouteOutcome::NeedsSupplies, facts(15, 0, 4, 2148))); + assert(alternating.noProgress(PlayerBotHuntRouteOutcome::NeedsSupplies, repeated)); + assert(!alternating.noProgress(PlayerBotHuntRouteOutcome::NeedsSupplies, facts(45, 0, 3, 2148))); +} + +void huntSupplyHandoffContracts() +{ + auto candidate = routeFixture(1); + candidate.predictedFightSeconds = 10; + candidate.combatFraction = 1; + candidate.expectedDamagePerSecond = 0; + candidate.supplyProfile.potionHealing = 100; + candidate.supplyProfile.potions = 2; + candidate.supplyProfile.reserve = 2; + candidate.maximumHuntSeconds = 2400; + candidate.availableHuntSeconds = 2400; + auto select = [&](uint32_t stock, bool supplierSafe = true, bool recovering = false, bool broke = false, + bool incompleteAlternative = false) { + auto region = candidate; + region.supplyRecovery = recovering; + region.coinGoldPerMinute = 1; + region.currentHealth = region.maximumHealth = 100; + std::vector candidates{region}; + if (incompleteAlternative) candidates.insert(candidates.begin(), routeFixture(99)); + PlayerBotHuntRouteSelection selector(std::move(candidates)); + if (incompleteAlternative) { + auto unknown = routeFacts(false); + unknown.searchIncomplete = true; + selector.observe(selector.next(), unknown); + } + auto facts = routeFacts(); + facts.huntDurationSeconds = 2400; + facts.supplyProfile = region.supplyProfile; + facts.supplyProfile.potions = stock; + facts.potionReserve = 2; + if (broke) { + facts.recoverySpendingReserve = facts.funds + 1; + facts.fare = 1; // Free travel remains eligible below the reserve. + } + facts.approaches = {Position(200, 100, 7)}; + PlayerBotHuntRouteResult result; + for (int turn = 0; turn < 16 && !result.terminal; ++turn) { + auto request = selector.next(); + auto observation = facts; + if (request.stage == PlayerBotHuntRouteStage::DiscoverSupply) + observation.approaches = {Position(300, 100, 7)}; + if (request.stage == PlayerBotHuntRouteStage::Supplier) observation.reached = supplierSafe; + result = selector.observe(request, observation); + } + assert(result.terminal); + return result; + }; + const auto mixedShortage = select(2, true, false, false, true); + assert(mixedShortage.outcome == PlayerBotHuntRouteOutcome::NeedsSupplies && mixedShortage.supplyShortage); + assert(mixedShortage.readiness.state == PlayerBotReadiness::Blocked && mixedShortage.rejectedVariants.empty()); + const auto shortage = select(2); + assert(shortage.outcome == PlayerBotHuntRouteOutcome::NeedsSupplies && shortage.supplyShortage); + assert(!shortage.selectedRouteRegion && shortage.rejectedVariants.empty()); + assert(shortage.failureCounts.at("hunt_supply_window_unavailable") == 1); + assert(shortage.supplyShortage->requirements.front().count == 3); // reserve + routine, even at zero DPS + assert(select(2, false).outcome == PlayerBotHuntRouteOutcome::Unavailable); // no unsafe supplier proof + assert(select(3).outcome == PlayerBotHuntRouteOutcome::Selected); + const auto economy = select(3, true, false, true); + assert(economy.outcome == PlayerBotHuntRouteOutcome::Unavailable && economy.rejectedVariants.empty()); + assert(economy.failureCounts.at("travel_fare_breaks_recovery_reserve") == 1); + const auto unaffordableShortage = select(2, true, false, true); + assert(unaffordableShortage.outcome == PlayerBotHuntRouteOutcome::Unavailable); + assert(!unaffordableShortage.supplyShortage && unaffordableShortage.rejectedVariants.empty()); + assert(unaffordableShortage.failureCounts.at("travel_fare_breaks_recovery_reserve") == 1); + assert(select(2, true, true).outcome == PlayerBotHuntRouteOutcome::Selected); // existing safe recovery + + PlayerBotSupplyStocks stocks{{{PlayerBotSupplyKind::HealthPotion, 7618, 2, 1, 2}, 2}}; + playerBotRequireSupplies(stocks, shortage.supplyShortage->requirements); + assert(stocks.front().rule.target == 3 && stocks.front().rule.safetyFloor == 3); + assert(playerBotMandatorySupplyDeficit(stocks, false).missing == 1); + PlayerBotSupplyRecoveryState recovery; + recovery.deferRestock(11, playerBotSupplyStockKey(stocks)); + recovery.enter(); + const auto budget = playerBotSupplyFloorSpendingReserve(0, {{2, stocks.front().rule.safetyFloor, 45}}); + recovery.update(11, budget); + assert(recovery.active() && recovery.restockBlocked(11, playerBotSupplyStockKey(stocks))); + recovery.restockBlocked(50, playerBotSupplyStockKey(stocks)); + recovery.update(50, budget); + assert(!recovery.active()); // changed funds allow service, not a generic-floor clear + + PlayerBotHuntSupplyHandoff handoff; + PlayerBotHuntSupplyFacts facts; + facts.stockKey = playerBotSupplyStockKey(stocks); + handoff.require(shortage.supplyShortage->requirements, facts); + for (int i = 0; i < 100; ++i) + assert(!handoff.noProgress(PlayerBotHuntRouteOutcome::Pending, facts)); + assert(!handoff.noProgress(PlayerBotHuntRouteOutcome::NeedsSupplies, facts)); + assert(!handoff.noProgress(PlayerBotHuntRouteOutcome::Unavailable, facts)); + assert(handoff.noProgress(PlayerBotHuntRouteOutcome::NeedsSupplies, facts)); + ++facts.funds; + assert(!handoff.noProgress(PlayerBotHuntRouteOutcome::NeedsSupplies, facts)); + ++facts.stockKey; + assert(!handoff.noProgress(PlayerBotHuntRouteOutcome::Unavailable, facts)); + handoff.refresh(facts); + assert(!handoff.requirements().empty()); // shopping keeps mandatory stock + ++facts.capability.level; + handoff.refresh(facts); + assert(handoff.requirements().empty()); // stale combat dependency discarded + handoff.require(shortage.supplyShortage->requirements, facts); + handoff.selected(true); // Accepting a recovery hunt does not erase its funding goal. + assert(!handoff.requirements().empty()); + recovery.deferRestock(11, facts.stockKey); + recovery.enter(); + recovery.restockBlocked(12, facts.stockKey); + recovery.update(12, budget); + assert(recovery.active()); // New income is still below the route requirement. + assert(!handoff.noProgress(PlayerBotHuntRouteOutcome::Selected, facts)); + assert(handoff.requirements().empty()); + + // Demand for one fight, not 2400 seconds. Current low stock still selects + // the same verified shortened window instead of taking a service trip. + candidate.expectedDamagePerSecond = 5; + candidate.supplyProfile.potions = 3; + candidate.reconcileSupplies(2); + const auto minimum = candidate.minimumProductiveSupplies(2); + assert(minimum.front().count == 3); + candidate.fitSupplyWindow(2); + assert(candidate.productiveWindow() && candidate.supplyBudget.fits && candidate.availableHuntSeconds < 2400); + const auto shortened = select(3); + assert(shortened.outcome == PlayerBotHuntRouteOutcome::Selected && !shortened.supplyShortage); + assert(shortened.selectedRouteRegion->availableHuntSeconds < 2400); + + // Typed demand includes detailed transit exposure and learned rates. A + // shortage at a reserve boundary still requires its item-specific supplier. + candidate.supplyProfile.kinds = {{PlayerBotSupplyKind::ThrowingWeapon, 2389, 1, 1, {0.2, 1}}}; + candidate.routeValidated = true; + candidate.estimatedTravelSeconds = 20; + candidate.recoveryRouteHealthLoss = 50; + const auto typedMinimum = candidate.minimumProductiveSupplies(2); + assert(typedMinimum.size() == 2 && typedMinimum[1].itemId == 2389); + assert(typedMinimum[1].count == 5); // one fight plus ten seconds of transit combat + const auto typedShortage = select(2); + assert(typedShortage.outcome == PlayerBotHuntRouteOutcome::NeedsSupplies && + typedShortage.supplyShortage->requirements.size() == 2 && typedShortage.rejectedVariants.empty()); +} + void routeSelectionContracts() { const Position depot(200, 100, 7), nextDepot(201, 100, 7), supplier(300, 100, 7); @@ -489,7 +869,18 @@ void routeSelectionContracts() (returnLeg ? "depot_route_search_incomplete" : "route_search_incomplete")); result = incomplete.observe(incomplete.next(), {}); assert(result.terminal && result.rejectedVariants.empty()); + assert(result.outcome == PlayerBotHuntRouteOutcome::Incomplete && result.readiness.state == PlayerBotReadiness::Pending); + } + PlayerBotHuntSupplyHandoff exhausted; + PlayerBotHuntSupplyFacts changing; + for (int attempt = 1; attempt <= 3; ++attempt) { + for (int yield = 0; yield < 100; ++yield) assert(!exhausted.noProgress(PlayerBotHuntRouteOutcome::Pending, changing)); + ++changing.funds; ++changing.stockKey; ++changing.health; + exhausted.blocked({PlayerBotReadiness::Pending, std::nullopt}, changing); + assert(exhausted.noProgress(PlayerBotHuntRouteOutcome::Incomplete, changing) == (attempt == 3)); } + exhausted.selected(); + assert(!exhausted.noProgress(PlayerBotHuntRouteOutcome::Incomplete, changing)); // Outbound failures remain distinct; a later candidate can still win. PlayerBotHuntRouteSelection selection({routeFixture(1), routeFixture(2), routeFixture(3), routeFixture(4)}); auto request = selection.next(); @@ -2534,8 +2925,10 @@ void supplyCalibration() assert(arrivalCompleted->supplyObservation.levelManaRestored == 30); assert(arrivalCompleted->supplyObservation.levelAdjustedHealthDebt == 10); assert(arrivalCompleted->supplyObservation.levelAdjustedManaDebt == 10); - assert(std::abs(arrivalCompleted->supplyObservation.staticPotionsPerCombatSecond - 1.0 / 60.0) < 1e-12); - assert(arrivalCompleted->region.supplyStaticPotionsPerCombatSecond == 1.0 / 60.0); + assert(std::abs(arrivalCompleted->supplyObservation.staticPotionsPerCombatSecond - + (120.0 / 125.0) / 60.0) < 1e-12); + assert(std::abs(arrivalCompleted->region.supplyStaticPotionsPerCombatSecond - + (120.0 / 125.0) / 60.0) < 1e-12); assert(arrivalCompleted->combat.damageTaken == 10 && arrivalCompleted->combat.kills == 3); assert((arrivalCompleted->supplyObservation.changedFields & PlayerBotSupplyCapabilityLevel) != 0); assert((arrivalCompleted->supplyObservation.changedFields & PlayerBotSupplyCapabilityEquipment) == 0); @@ -2561,8 +2954,8 @@ void supplyCalibration() regenerationSupply, regenerationRegion.expectedDamagePerSecond, regenerationRegion.combatFraction, 180, 60); regenerationRegion.supplyStaticPotionsPerCombatSecond = - pretravelBudget.expectedPotions / 90.0; - assert(std::abs(regenerationRegion.supplyStaticPotionsPerCombatSecond - 2.0 / 90.0) < 1e-12); + pretravelBudget.potionEquivalentDemand / 90.0; + assert(std::abs(regenerationRegion.supplyStaticPotionsPerCombatSecond - (150.0 / 125.0) / 90.0) < 1e-12); PlayerBotHuntRuntimePlayerObservation regenerationPlayer; regenerationPlayer.potions = 20; regenerationPlayer.health = regenerationPlayer.maximumHealth = 100; @@ -2582,9 +2975,10 @@ void supplyCalibration() assert(regenerationCompleted && regenerationCompleted->supplyObservation.accepted); assert(regenerationCompleted->supplyObservation.durationSeconds == 120); assert(std::abs(regenerationCompleted->supplyObservation.staticPotionsPerCombatSecond - - 1.0 / 90.0) < 1e-12); + (90.0 / 125.0) / 90.0) < 1e-12); assert(regenerationCompleted->region.availableHuntSeconds == 180); - assert(regenerationCompleted->region.supplyStaticPotionsPerCombatSecond == 1.0 / 90.0); + assert(std::abs(regenerationCompleted->region.supplyStaticPotionsPerCombatSecond - + (90.0 / 125.0) / 90.0) < 1e-12); PlayerBotHuntRuntime noArrivalRuntime({}); PlayerBotHuntRegion noArrivalRegion = regionFor(24, capability()); @@ -2690,6 +3084,7 @@ void globalAndLocalSupplyLearning() PlayerBotHuntPolicy policy; PlayerBotHuntRegion source = region(1, "Troll", "Swamp Troll"); const double sourceStatic = source.supplyStaticPotionsPerCombatSecond; + assert(std::abs(sourceStatic - (480.0 / 125.0) / 60.0) < 1e-12); const auto easy = easyOuting(policy, source); assert(easy.accepted && easy.localUpdated && easy.globalUpdated); assert(easy.globalSamplesBefore == 0 && easy.globalSamplesAfter == 1); @@ -3745,6 +4140,108 @@ void overBudgetHunts() assert(costly.score < easy.score && playerBotPreferHuntRegion(costly, easy)); } +void supplyCalibrationRounding() +{ + // Bot Four: no learned heal/food, three potions, and two reserved by the + // detailed route. A global correction must not turn every positive window + // into a two-potion budget just because the raw stock count rounded to one. + PlayerBotHuntRegion region; + region.supplyProfile.potions = 3; + region.supplyProfile.potionHealing = 125; + region.expectedDamagePerSecond = 1; + region.combatFraction = 0.5; + region.supplyGlobalLearning = {1.32, 1}; + region.reconcileTravel(120, 0, 1); + region.reconcileSupplies(2); + assert(region.supplyBudget.expectedPotions == 2 && !region.supplyBudget.fits); + region.fitSupplyWindow(2); + assert(region.availableHuntSeconds == 94 && region.supplyBudget.fits); + assert(region.supplyBudget.expectedPotions == 1); + assert(region.supplyBudget.reservedPotions == 2 && region.supplyBudget.routinePotions == 1); + assert(region.supplyProfile.potions == 3); // Planning does not spend the reserve. + assert(std::abs(region.supplyStaticPotionsPerCombatSecond - 1.0 / 62.5) < 1e-12); + assert(std::abs(region.supplyAppliedPotionsPerCombatSecond - 1.32 / 62.5) < 1e-12); + region.availableHuntSeconds = 95; + region.reconcileSupplies(2); + assert(region.supplyBudget.expectedPotions == 2 && !region.supplyBudget.fits); + region.availableHuntSeconds = 1; + region.reconcileSupplies(2); + assert(region.supplyBudget.expectedPotions == 1 && region.supplyBudget.fits); + region.supplyProfile.potions = 2; + region.reconcileSupplies(2); + assert(!region.supplyBudget.fits && region.supplyBudget.routinePotions == 0); + region.supplyProfile.potions = 4; + region.availableHuntSeconds = 120; + region.reconcileSupplies(2); + assert(region.supplyBudget.expectedPotions == 2 && region.supplyBudget.fits); + assert(region.supplyBudget.reservedPotions == 2 && region.supplyBudget.routinePotions == 2); + + // Local rates are final absolute estimates, not global-multiplier inputs. + region.supplyProfile.potions = 3; + region.supplyCalibration = {region.supplyCapability, 1.0 / 120.0, 1}; + region.reconcileSupplies(2); + assert(std::string(region.supplyEstimateSource) == "local"); + assert(region.supplyBudget.expectedPotions == 1 && region.supplyBudget.fits); + region.supplyCalibration.potionsPerCombatSecond = 1.0 / 60.0; + region.reconcileSupplies(2); + assert(region.supplyBudget.expectedPotions == 1 && region.supplyBudget.fits); + region.availableHuntSeconds = 121; + region.reconcileSupplies(2); + assert(region.supplyBudget.expectedPotions == 2 && !region.supplyBudget.fits); + + // Calibration cannot bypass typed stock or the recovery health/route floor. + region.availableHuntSeconds = 120; + region.supplyProfile.kinds = {{PlayerBotSupplyKind::ManaPotion, playerBotManaPotionItemId, 1, 1, {0.01, 1}}}; + region.reconcileSupplies(2); + assert(region.supplyBudget.expectedPotions == 1 && !region.supplyBudget.fits); + assert(region.supplyKindBudgets.size() == 1 && !region.supplyKindBudgets.front().fits); + region.supplyProfile.kinds.clear(); + region.supplyCalibration = {}; + region.supplyRecovery = true; + region.currentHealth = region.maximumHealth = 100; + region.supplyProfile.potions = 2; + region.availableHuntSeconds = 20; + region.reconcileSupplies(2); + assert(region.supplyBudget.expectedPotions == 0 && region.supplyBudget.fits); + region.availableHuntSeconds = 21; + region.reconcileSupplies(2); + assert(region.supplyBudget.expectedPotions == 1 && !region.supplyBudget.fits); + region.availableHuntSeconds = 0; + region.recoveryRouteHealthLoss = 21; + region.reconcileSupplies(2); + assert(!region.supplyBudget.fits); + + // Learning uses the fractional static denominator, while keeping immediate + // upward corrections and slower, guarded global/local downward blends. + region.supplyRecovery = false; + region.recoveryRouteHealthLoss = 0; + region.supplyGlobalLearning = {}; + region.supplyProfile.potions = 20; + region.availableHuntSeconds = 120; + region.reconcileSupplies(2); + assert(region.supplyBudget.expectedPotions == 1); + PlayerBotHuntPolicy upwardPolicy; + upwardPolicy.observeCombat({true, 60, 100, 100, 0, 0, 1}); + upwardPolicy.observeRecovery(true); + const auto upward = upwardPolicy.observeSupplies( + region, region.supplyCapability, 60, 100, 100, 0, 19, true); + assert(upward.accepted && upward.globalUpdated && upward.localUpdated); + assert(upward.globalEstimateDirection == PlayerBotSupplyEstimateDirection::Upward); + assert(upward.localEstimateDirection == PlayerBotSupplyEstimateDirection::Upward); + assert(std::abs(upward.globalMultiplierAfter - 1.0 / 0.96) < 1e-12); + assert(std::abs(upward.calibration.potionsPerCombatSecond - 1.0 / 60.0) < 1e-12); + PlayerBotHuntPolicy downwardPolicy; + downwardPolicy.observeCombat({true, 60, 100, 100, 0, 0, 1}); + for (unsigned kill = 0; kill < 3; ++kill) downwardPolicy.observeKill(); + const auto downward = downwardPolicy.observeSupplies( + region, region.supplyCapability, 120, 100, 100, 0, 20, false); + assert(downward.accepted && downward.globalUpdated && downward.localUpdated); + assert(downward.globalEstimateDirection == PlayerBotSupplyEstimateDirection::Downward); + assert(downward.localEstimateDirection == PlayerBotSupplyEstimateDirection::Downward); + assert(std::abs(downward.globalMultiplierAfter - 0.9) < 1e-12); + assert(std::abs(downward.calibration.potionsPerCombatSecond - 0.8 / 62.5) < 1e-12); +} + void supplyBudget() { PlayerBotSupplyProfile profile; @@ -3820,6 +4317,83 @@ void supplyBudget() assert(std::isfinite(budget.expectedPotions) && !budget.fits); } +void firstHealingBootstrap() +{ + constexpr uint32_t cashBuffer = 100, spellPrice = 170; + const auto stocked = playerBotRecoverySpendingReserve(3, 3, 45, cashBuffer); + const auto first = playerBotSpellTrainingSpendingReserve(stocked, cashBuffer, true); + const auto normal = playerBotSpellTrainingSpendingReserve(stocked, cashBuffer, false); + assert(first == 0 && normal == cashBuffer); + assert(playerBotAffordableAfterReserve(170, first, spellPrice)); + assert(!playerBotAffordableAfterReserve(169, first, spellPrice)); + assert(playerBotAffordableAfterReserve(270, normal, spellPrice)); + assert(!playerBotAffordableAfterReserve(269, normal, spellPrice)); + + // Releasing cash does not release the missing health potion's 45 gold. + const auto missingHealth = playerBotRecoverySpendingReserve(2, 3, 45, cashBuffer); + const auto healthFloor = playerBotSpellTrainingSpendingReserve(missingHealth, cashBuffer, true); + assert(healthFloor == 45); + assert(!playerBotAffordableAfterReserve(170, healthFloor, spellPrice)); + assert(!playerBotAffordableAfterReserve(214, healthFloor, spellPrice)); + assert(playerBotAffordableAfterReserve(215, healthFloor, spellPrice)); + assert(playerBotAffordableAfterReserve(217, healthFloor, spellPrice)); + + // One spear and two mana potions must also remain funded. + const auto allFloors = playerBotSupplyFloorSpendingReserve(missingHealth, {{2, 3, 10}, {0, 2, 50}}); + const auto firstFloors = playerBotSpellTrainingSpendingReserve(allFloors, cashBuffer, true); + assert(firstFloors == 155); + assert(!playerBotAffordableAfterReserve(324, firstFloors, spellPrice)); + assert(playerBotAffordableAfterReserve(325, firstFloors, spellPrice)); + assert(playerBotSpellTrainingSpendingReserve(allFloors, cashBuffer, false) == 255); + + // Unknown health reserve or a missing typed-supply quote must stay unknown. + for (const auto reserve : {UINT64_MAX, + playerBotSupplyFloorSpendingReserve(missingHealth, {{2, 3}}), + playerBotSupplyFloorSpendingReserve(missingHealth, {{0, 2}}), uint64_t{99}}) { + for (bool firstHeal : {false, true}) { + const auto blocked = playerBotSpellTrainingSpendingReserve(reserve, cashBuffer, firstHeal); + assert(blocked == UINT64_MAX); + assert(!playerBotAffordableAfterReserve(UINT64_MAX, blocked, spellPrice)); + } + } + + PlayerBotSpellTrainingPlanner planner; + PlayerBotSpellTrainingPlannerSnapshot state; + state.totalMoney = spellPrice; + state.maximumRouteDangerCost = 100; + state.maximumRouteDanger = 10; + PlayerBotSpellOfferSnapshot offer; + offer.spellName = "Light Healing"; + offer.price = spellPrice; + offer.reserve = first; + offer.inScope = offer.registryMatches = offer.implementedUse = offer.vocationEligible = true; + offer.levelEligible = offer.premiumEligible = offer.suppliesReady = offer.route.reachable = true; + offer.potionReserve = 2; + state.offers = {offer}; + const auto accepted = planner.select(state); + assert(accepted.selected && accepted.selected->reserve == 0 && accepted.selected->potionReserve == 2); + auto rejected = [&](const char* reason) { + const auto decision = planner.select(state); + assert(!decision.selected && decision.rejections.size() == 1); + assert(decision.rejections.front().reason == reason); + state.offers = {offer}; + }; + state.offers.front().reserve = normal; + rejected("unaffordable_after_reserves"); // Later heals and nonhealing spells. + state.offers.front().reserve = UINT64_MAX; + rejected("recovery_reserve_unavailable"); + state.offers.front().registryMatches = false; rejected("spell_registry_mismatch"); + state.offers.front().implementedUse = false; rejected("no_implemented_use"); + state.offers.front().vocationEligible = false; rejected("vocation_ineligible"); + state.offers.front().levelEligible = false; rejected("level_ineligible"); + state.offers.front().premiumEligible = false; rejected("premium_ineligible"); + state.offers.front().known = true; rejected("already_learned"); + state.offers.front().suppliesReady = false; rejected("supply_reserve_unmet"); + state.offers.front().route.reachable = false; rejected("trainer_unreachable"); + state.offers.front().route.dangerCost = 101; rejected("route_danger_above_tolerance"); + state.offers.front().route.maximumDanger = 11; rejected("route_peak_danger_above_tolerance"); +} + void recoverySpellPriority() { PlayerBotSpellTrainingPlanner training; @@ -4940,6 +5514,9 @@ int main() huntCandidateTelemetryCompleteness(); huntCandidateTelemetryDeltas(); incrementalHuntValidationPipeline(); + preparationContracts(); + huntSupplyServiceReplayContracts(); + huntSupplyHandoffContracts(); routeSelectionContracts(); typedSupplyRouteContracts(); selectedRouteRetention(); @@ -4961,6 +5538,7 @@ int main() supplyRecoveryMode(); typedSupplyStock(); supplyCalibration(); + supplyCalibrationRounding(); globalAndLocalSupplyLearning(); navigationFixedObjective(); topologyComponentCompression(); @@ -4971,6 +5549,7 @@ int main() typedSupplyFallback(); overBudgetHunts(); supplyBudget(); + firstHealingBootstrap(); recoverySpellPriority(); projection(); oracleRecovery(); diff --git a/server/tests/playerbotrouting_contracts.cpp b/server/tests/playerbotrouting_contracts.cpp index a41db010..8a7b9a3e 100644 --- a/server/tests/playerbotrouting_contracts.cpp +++ b/server/tests/playerbotrouting_contracts.cpp @@ -5,6 +5,7 @@ #include "playerbothunttravelpolicy.h" #include "playerbothuntregions.h" #include "playerbotroutechanges.h" +#include "playerbotroutecache.h" #include "playerbotrouteitemchange.h" #include "playerbottransportsearch.h" @@ -82,6 +83,111 @@ PlayerBotPathSearch gridPath(const PlayerBotNavigationGoal& goal, bool guided, u return search; } +void preparationFloorTransitions() +{ + const Position basement(100, 100, 8), stairs(101, 100, 8), shop(86, 76, 7), rest(100, 90, 7); + assert(playerBotPreparationProviderInRange(basement, shop)); + PlayerBotPreparationRouteBudget budget; + auto searchTo = [&](const Position& start, const Position& target, bool stairsAvailable = true) { + PlayerBotNavigationGoal goal; + goal.type = PlayerBotNavigationGoalType::AnyOf; + goal.positions = {target}; + goal.position = target; + PlayerBotPathSearch search(start, goal, budget.allowance()); + auto expand = [&](const Position& from) { + std::vector arcs; + PlayerBotNavigationStep step; + if (from.z == 8) { + if (!stairsAvailable) return arcs; + step.action = from == stairs ? PlayerBotNavigationAction::Use : PlayerBotNavigationAction::Move; + step.target = stairs; + step.expectedPosition = from == stairs ? Position(stairs.x, stairs.y, 7) : stairs; + } else { + step.action = PlayerBotNavigationAction::Move; + step.expectedPosition = from; + if (from.x != target.x) step.expectedPosition.x += from.x < target.x ? 1 : -1; + else if (from.y != target.y) step.expectedPosition.y += from.y < target.y ? 1 : -1; + step.target = step.expectedPosition; + } + arcs.push_back({step, 10, 0, 0}); + return arcs; + }; + while (!search.advance(8, expand, [&](const Position& p) { return p == target; }, [](const Position&) { return 0u; })) {} + budget.observed(search.expanded); + return search; + }; + const auto foodRoute = searchTo(basement, shop); + assert(foodRoute.result == Result::Reached && foodRoute.steps.back().expectedPosition == shop); + assert(std::any_of(foodRoute.steps.begin(), foodRoute.steps.end(), [](const auto& step) { + return step.action == PlayerBotNavigationAction::Use && step.expectedPosition.z == 7; + })); + const auto restRoute = searchTo(shop, rest); + const auto directRest = searchTo(basement, rest); + for (const auto* route : {&foodRoute, &restRoute, &directRest}) { + assert(playerBotPreparationRouteAccepted(*route->result, route->summary.dangerCost, + route->summary.maximumHealthLossPerSecond, 0, !route->steps.empty())); + } + const auto noStairs = searchTo(basement, shop, false); + assert(noStairs.result == Result::Unreachable); + assert(!playerBotPreparationRouteAccepted(*noStairs.result, 0, 0, 0, false)); +} + +void preparationSafeAlternative() +{ + const Position start(100, 100, 8), stairs(101, 100, 8), landing(101, 100, 7), goal(102, 100, 7); + PlayerBotNavigationRiskProfile ordinary, safe; + safe.maximumRouteHealthLoss = safe.maximumHealthLossPerSecond = 0; + auto route = [&](const PlayerBotNavigationRiskProfile& policy, uint64_t slice, bool stairsAvailable = true) { + PlayerBotNavigationGoal destination; + destination.position = goal; + PlayerBotPathSearch search(start, destination, 128); + search.constrain(policy); // Same policy entry used by the real navigator. + auto expand = [&](Position from) { + std::vector arcs; + auto add = [&](Position to, uint32_t movement, uint32_t danger, double peak) { + PlayerBotNavigationStep step; + step.action = from.z == to.z ? PlayerBotNavigationAction::Move : PlayerBotNavigationAction::Use; + step.target = step.expectedPosition = to; + arcs.push_back({step, movement, danger, peak}); + }; + if (from == start) { + add(goal, 1, 1, 0.01); // Cheaper shortcut accepted by ordinary hunt risk. + if (stairsAvailable) add(stairs, 10, 0, 0); + } else if (from == stairs) add(landing, 10, 0, 0); + else if (from == landing) add(goal, 10, 0, 0); + return arcs; + }; + while (!search.advance(slice, expand, [&](Position p) { return p == goal; }, [](Position) { return 0u; })) {} + return search; + }; + const auto shortcut = route(ordinary, 128); + assert(shortcut.result == Result::Reached && shortcut.steps.size() == 1 && shortcut.summary.dangerCost == 1); + assert(playerBotNavigationRiskAccepts(ordinary, 1, 0.01)); + assert(!playerBotPreparationRouteAccepted(*shortcut.result, 1, 0.01, 0, true)); + const auto discovery = route(safe, 128), foodExecution = route(safe, 1), restExecution = route(safe, 8); + for (const auto* path : {&discovery, &foodExecution, &restExecution}) { + assert(path->result == Result::Reached && path->steps.size() == 3); + assert(playerBotPreparationRouteAccepted(*path->result, path->summary.dangerCost, + path->summary.maximumHealthLossPerSecond, 0, path->steps.back().expectedPosition == goal)); + } + assert(route(safe, 1, false).result == Result::Unreachable); // No unsafe fallback after live obstruction. + + const auto ordinaryKey = playerBotNavigationCostIdentity("same_actor", ordinary); + const auto safeKey = playerBotNavigationCostIdentity("same_actor", safe); + assert(ordinaryKey != safeKey); + auto changed = safe; changed.healthLossCost = std::nextafter(safe.healthLossCost, INFINITY); + assert(playerBotNavigationCostIdentity("same_actor", changed) != safeKey); + PlayerBotRouteCache> cache; + PlayerBotRouteChanges::Watch watch; + { PlayerBotRouteChanges::Scope scope(watch); PlayerBotRouteChanges::read(stairs); } + assert(cache.insert(ordinaryKey, shortcut.steps, watch, 1024)); + assert(!cache.lookup(safeKey)); // Ordinary cached segment cannot hide the safe alternative. + assert(cache.insert(safeKey, discovery.steps, watch, 1024)); + assert(cache.lookup(safeKey)->size() == 3); + PlayerBotRouteChanges::changed(stairs); + assert(!cache.lookup(safeKey)); // Existing live dependency invalidation remains authoritative. +} + void dangerSampleContracts() { const Position position(100, 100, 7); @@ -1091,6 +1197,8 @@ void invalidationAndCancellation() int main() { + preparationFloorTransitions(); + preparationSafeAlternative(); resumedPaths(); dangerSampleContracts(); huntCoarseVerdictContracts(); diff --git a/server/tests/playerbotserviceworkflow_test.cpp b/server/tests/playerbotserviceworkflow_test.cpp index 90278d46..d45bf022 100644 --- a/server/tests/playerbotserviceworkflow_test.cpp +++ b/server/tests/playerbotserviceworkflow_test.cpp @@ -10,6 +10,7 @@ c++ -std=c++17 -Iserver/src -I/usr/include/luajit-2.1 \ #include #include "playerbotserviceworkflow.h" +#include "playerbotsupplyrecovery.h" namespace { constexpr uint32_t sellerId = 7; @@ -67,6 +68,70 @@ PlayerBotServiceObservation supplyShop(std::map counts, uint observation.supplies = {{PlayerBotSupplyKind::HealthPotion, 7618, 0, 1, 10}, {PlayerBotSupplyKind::ManaPotion, 7620, 0, 1, 10}}; return observation; } + +PlayerBotServiceObservation paladinShop(uint64_t money, uint16_t vocationId = 3, bool split = false) +{ + auto observation = supplyShop({}, money, + {{7618, 45, 0, 0}, {7620, 50, 0, 0}, {2389, 10, 0, 0}}); + const auto mana = playerBotSupplyRule(PlayerBotSupplyKind::ManaPotion, vocationId); + const auto spears = playerBotThrowingWeaponRule(2389, 8); + observation.supplyCapacityReserve = 3000; + observation.supplies = { + {PlayerBotSupplyKind::HealthPotion, 7618, 270, 1, 20, 2}, + {mana.kind, mana.itemId, 270, mana.returnThreshold, mana.target, mana.safetyFloor, mana.preferredStock}, + {spears.kind, spears.itemId, 2000, spears.returnThreshold, spears.target, spears.safetyFloor}}; + if (split) { + observation.shops = { + {1, observation.currentPosition, {{7618, 45, 0, 0}}}, + {2, observation.currentPosition, {{7620, 50, 0, 0}}}, + {3, observation.currentPosition, {{2389, 10, 0, 0}}}}; + for (uint32_t id : {2U, 3U}) observation.providers.emplace(id, + PlayerBotServiceProviderObservation{true, true, true, true}); + } + return observation; +} + +// Apply normal paid receipts and capacity changes, then let the workflow verify +// each purchase. Split shops stay in range to isolate allocation from routing. +void finishShopping(PlayerBotServiceObservation& observation, bool survival = false, bool split = false) +{ + PlayerBotServiceWorkflow workflow; + workflow.setSurvivalRestock(survival); + const PlayerBotEconomyCatalog catalog; + const PlayerBotDispositionPolicy disposition; + uint32_t purchases = 0; + uint32_t receipts = 0; + for (int turn = 0; turn < 40; ++turn) { + const auto command = workflow.advance(observation, catalog, disposition); + assert(command.type != PlayerBotServiceCommandType::Fail); + if (command.verification) { + assert(command.verification->result == PlayerBotServiceVerificationResult::Success); + ++receipts; + } + if (command.type == PlayerBotServiceCommandType::Complete) { + assert(purchases == receipts); + assert(workflow.advance(observation, catalog, disposition).type == PlayerBotServiceCommandType::Complete); + return; + } + if (command.type != PlayerBotServiceCommandType::Buy) continue; + ++purchases; + assert(command.transaction && command.transaction->itemCount == observation.inventoryCounts[command.itemId]); + if (split) assert(command.providerId == (command.itemId == 7618 ? 1U : command.itemId == 7620 ? 2U : 3U)); + const uint64_t cost = static_cast(command.amount) * command.transaction->unitPrice; + assert(cost <= observation.money + observation.bankBalance); + const uint64_t carriedPayment = std::min(observation.money, cost); + observation.money -= carriedPayment; + observation.bankBalance -= cost - carriedPayment; + const auto supply = std::find_if(observation.supplies.begin(), observation.supplies.end(), + [&](const auto& value) { return value.itemId == command.itemId; }); + assert(supply != observation.supplies.end()); + assert(command.amount * supply->weight <= observation.freeCapacity); + observation.freeCapacity -= command.amount * supply->weight; + assert(observation.freeCapacity >= observation.supplyCapacityReserve); + observation.inventoryCounts[command.itemId] += command.amount; + } + assert(false && "shopping did not complete"); +} } int main() @@ -151,6 +216,61 @@ int main() unavailable.outcome == PlayerBotServiceOutcome::Unavailable); } + // Recovery survives the production resupply/liquidation reset handoff. + // A verified 50-gp sale cannot fund the 90-gp floor, but buys one potion + // in recovery; the same sale under normal policy must still fail. + for (bool recovering : {false, true}) { + PlayerBotSupplyRecoveryState recovery; + if (recovering) assert(recovery.enter()); + PlayerBotServiceWorkflow supplies; + supplies.setSurvivalRestock(true); // A reset must also clear stale recovery policy. + auto observation = supplyShop({{7618, 0}}, 50, {{7618, 45, 0, 0}, {7634, 0, 5, 0}}); + observation.supplies = {{PlayerBotSupplyKind::HealthPotion, 7618, 270, 1, 20, 2}}; + supplies.reset(PlayerBotServiceIntent::Resupply, recovery.active()); + auto command = supplies.advance(observation, catalog, disposition); + assert(recovering ? command.type == PlayerBotServiceCommandType::Buy && command.amount == 1 : + command.type == PlayerBotServiceCommandType::Fail && + command.outcome == PlayerBotServiceOutcome::InsufficientFunds); + + observation.money = 0; + observation.inventoryCounts[7634] = observation.backpackSaleCounts[7634] = 10; + supplies.reset(PlayerBotServiceIntent::ResupplyWithLocalSale, recovery.active()); + supplies.setLiquidationPlan({1, {{7634, 10, 5, 0}}}); + command = supplies.advance(observation, catalog, disposition); + assert(command.type == PlayerBotServiceCommandType::Sell && command.itemId == 7634 && command.amount == 10); + assert(command.transaction && command.transaction->unitPrice == 5); + observation.inventoryCounts[7634] = observation.backpackSaleCounts[7634] = 0; + observation.money = 50; + command = supplies.advance(observation, catalog, disposition); + assert(command.verification && command.verification->result == PlayerBotServiceVerificationResult::Success); + assert(command.transaction && command.transaction->itemId == 7634); + assert(!supplies.liquidation() && supplies.stage() == PlayerBotServiceStage::BuySupplies); + if (recovering) { + // The controller's recovery update sees no mode transition, so it + // cannot repair a policy lost at reset after observing this receipt. + assert(!recovery.update(observation.money + observation.bankBalance, 2 * 45)); + assert(recovery.active()); + } + command = supplies.advance(observation, catalog, disposition); + assert(command.itemId == 7618); + if (!recovering) { + assert(command.type == PlayerBotServiceCommandType::Fail && + command.outcome == PlayerBotServiceOutcome::InsufficientFunds); + assert(observation.inventoryCounts[7618] == 0 && observation.money == 50); + continue; + } + assert(command.type == PlayerBotServiceCommandType::Buy && command.amount == 1); + assert(command.transaction && command.transaction->unitPrice == 45); + observation.inventoryCounts[7618] = 1; + observation.money = 5; + observation.freeCapacity -= 270; + command = supplies.advance(observation, catalog, disposition); + assert(command.verification && command.verification->result == PlayerBotServiceVerificationResult::Success); + assert(command.transaction && command.transaction->itemId == 7618); + assert(!recovery.update(observation.money + observation.bankBalance, 45) && recovery.active()); + assert(supplies.advance(observation, catalog, disposition).type == PlayerBotServiceCommandType::Complete); + } + // Missing spears are supplies, not an optional equipment upgrade: 39 gp // may buy the whole floor even though it is below the 100-gp cash reserve. for (bool survival : {false, true}) { @@ -225,5 +345,184 @@ int main() assert(supplies.advance(observation, catalog, disposition).type == PlayerBotServiceCommandType::Complete); } + // Both Paladin vocations prefer ten mana after ALL active floors, not + // instead of the spear floor or as a new mandatory departure requirement. + for (uint16_t vocation : {3, 7}) { + const auto mana = playerBotSupplyRule(PlayerBotSupplyKind::ManaPotion, vocation); + assert(mana.safetyFloor == 2 && mana.returnThreshold == 1 && mana.target == 20 && mana.preferredStock == 10); + const PlayerBotSupplyStocks floorStock{{mana, 2}}; + assert(playerBotMandatorySupplyDeficit(floorStock, false).missing == 0); + assert(playerBotExhaustedSupply(floorStock) == nullptr); + for (bool survival : {false, true}) { + for (bool split : {false, true}) { + auto poor = paladinShop(620, vocation, split); + finishShopping(poor, survival, split); + assert(poor.inventoryCounts[7618] == 2 && poor.inventoryCounts[7620] == 10 && poor.inventoryCounts[2389] == 3); + assert(poor.money == 0); + + // Less than ten is allowed: keep the floors, buy a partial + // preference, and finish without failure or another service. + auto partial = paladinShop(500, vocation, split); + finishShopping(partial, survival, split); + assert(partial.inventoryCounts[7618] == 2 && partial.inventoryCounts[7620] == (survival ? 7U : 5U)); + assert(partial.inventoryCounts[2389] == 3); + + auto ample = paladinShop(2000, vocation, split); + finishShopping(ample, survival, split); + assert(ample.inventoryCounts[7618] == 20 && ample.inventoryCounts[7620] == 20 && ample.inventoryCounts[2389] == 3); + assert(ample.money == 70); + } + } + } + { + // Extra funds top up health only AFTER reserving the mana preference. + auto optional = paladinShop(820); + finishShopping(optional); + assert(optional.inventoryCounts[7618] == 4 && optional.inventoryCounts[7620] == 10 && optional.inventoryCounts[2389] == 3); + + // Shared capacity follows the same tiers and keeps the hunting buffer. + for (uint32_t capacity : {11700U, 12780U}) { + auto limited = paladinShop(10000, 3, true); + limited.freeCapacity = capacity; + finishShopping(limited, false, true); + assert(limited.inventoryCounts[7618] == (capacity == 11700 ? 2U : 4U)); + assert(limited.inventoryCounts[7620] == (capacity == 11700 ? 8U : 10U)); + assert(limited.inventoryCounts[2389] == 3 && limited.freeCapacity == 3000); + } + + // Keep the first-heal saving target at two; route-required health + // reserves still take precedence over preferred mana. + auto saving = paladinShop(590); + saving.supplies.front().restockTarget = 2; + saving.inventoryCounts[2389] = 3; + finishShopping(saving); + assert(saving.inventoryCounts[7618] == 2 && saving.inventoryCounts[7620] == 10); + auto route = paladinShop(800, 7, true); + route.supplies.front().returnThreshold = 5; + route.supplies.front().restockTarget = 6; + finishShopping(route, false, true); + assert(route.inventoryCounts[7618] == 6 && route.inventoryCounts[7620] == 10 && route.inventoryCounts[2389] == 3); + + // Bank funds use the same allocation and exact payment receipts. + auto bank = paladinShop(90, 3, true); + bank.bankBalance = 530; + finishShopping(bank, false, true); + assert(bank.inventoryCounts[7618] == 2 && bank.inventoryCounts[7620] == 10 && bank.inventoryCounts[2389] == 3); + assert(bank.money == 0 && bank.bankBalance == 0); + + auto excess = paladinShop(0); + excess.supplies.front().restockTarget = 2; + excess.inventoryCounts = {{7618, 3}, {7620, 25}, {2389, 3}}; + finishShopping(excess); + assert(excess.inventoryCounts[7618] == 3 && excess.inventoryCounts[7620] == 25); + } + // A hunt requirement raises the mandatory floor above the first-heal + // saving ceiling. It is bought before the optional mana preference, even + // when only the exact required health cost is affordable. + for (bool survival : {false, true}) { + auto required = paladinShop(45); + required.inventoryCounts = {{7618, 2}, {7620, 2}, {2389, 3}}; + PlayerBotSupplyStocks stocks{{{PlayerBotSupplyKind::HealthPotion, 7618, 2, 1, 2}, 2}}; + playerBotRequireSupplies(stocks, {{PlayerBotSupplyKind::HealthPotion, 0, 3}}); + required.supplies.front().restockTarget = stocks.front().rule.target; + required.supplies.front().safetyFloor = stocks.front().rule.safetyFloor; + PlayerBotServiceWorkflow mandatory; + mandatory.setSurvivalRestock(survival); + const PlayerBotEconomyCatalog catalog; + const PlayerBotDispositionPolicy disposition; + auto command = mandatory.advance(required, catalog, disposition); + for (int i = 0; i < 10 && command.type != PlayerBotServiceCommandType::Buy; ++i) + command = mandatory.advance(required, catalog, disposition); + assert(command.type == PlayerBotServiceCommandType::Buy && command.itemId == 7618 && command.amount == 1); + finishShopping(required, survival); + assert(required.inventoryCounts[7618] == 3 && required.inventoryCounts[7620] == 2 && required.money == 0); + + auto poor = required; + poor.inventoryCounts[7618] = 2; + poor.money = 11; + if (!survival) { + PlayerBotServiceWorkflow unfunded; + auto failure = unfunded.advance(poor, catalog, disposition); + for (int i = 0; i < 10 && failure.type != PlayerBotServiceCommandType::Fail; ++i) + failure = unfunded.advance(poor, catalog, disposition); + assert(failure.type == PlayerBotServiceCommandType::Fail && + failure.outcome == PlayerBotServiceOutcome::InsufficientFunds && failure.itemId == 7618); + } else { + finishShopping(poor, true); + assert(poor.inventoryCounts[7618] == 2); // Controller retains/deferRestock's route shortage. + } + } + + // Knight and all other inactive mana rules retain zero preference. A + // Knight's health-only shopping still reaches its ordinary full target. + for (uint16_t vocation : {0, 1, 2, 4, 5, 6, 8}) { + const auto mana = playerBotSupplyRule(PlayerBotSupplyKind::ManaPotion, vocation); + assert(!mana.active() && mana.preferredStock == 0); + } + for (bool survival : {false, true}) { + auto knight = supplyShop({{7620, 4}}, 1000, {{7618, 45, 0, 0}}); + knight.supplies = {{PlayerBotSupplyKind::HealthPotion, 7618, 270, 1, 20, 2}}; + finishShopping(knight, survival); + assert(knight.inventoryCounts[7618] == 20 && knight.inventoryCounts[7620] == 4 && knight.money == 100); + } + + // Food is a required preparation transaction, independent of the normal + // unaffordable three-health-potion floor, and does not start a bank loop. + { + auto food = supplyShop({{7618, 2}, {2666, 0}}, 15, {{2666, 4, 0, 0}}); + food.supplies = {{std::nullopt, 2666, 0, 0, 1, 1}}; + assert(!food.supplies.front().kind); // Food is a neutral transaction, not a potion. + PlayerBotServiceWorkflow workflow; + workflow.reset(PlayerBotServiceIntent::PreparationPurchase); + workflow.setSurvivalRestock(true); + const PlayerBotEconomyCatalog catalog; + const PlayerBotDispositionPolicy disposition; + const Position upstairs(1, 1, 7); + food.currentPosition = Position(1, 1, 8); + food.providers[1] = PlayerBotServiceProviderObservation{true, false, false, true}; + food.providers[1].approaches = {{upstairs, 1}}; + auto command = workflow.advance(food, catalog, disposition); + assert(command.type == PlayerBotServiceCommandType::ValidateProviderRoute && command.destination == upstairs); + assert(!workflow.buyingPotions()); // Survival stays available during approach, not only after delivery. + food.approachRoute = {1, upstairs, PlayerBotServiceRouteResult::Reached, 40}; + command = workflow.advance(food, catalog, disposition); + command = workflow.advance(food, catalog, disposition); + assert(command.type == PlayerBotServiceCommandType::NavigateProvider && command.destination == upstairs); + food.currentPosition = upstairs; + food.providers[1].inRange = food.providers[1].shopOpen = true; // Observed arrival and ordinary trade. + for (int i = 0; i < 10 && command.type != PlayerBotServiceCommandType::Buy; ++i) + command = workflow.advance(food, catalog, disposition); + assert(command.type == PlayerBotServiceCommandType::Buy && command.itemId == 2666 && command.amount == 1); + assert(workflow.stage() == PlayerBotServiceStage::BuySupplies); + assert(!workflow.buyingPotions()); // Neutral approach/trade cannot disable survival. + assert(workflow.pendingPreparationPurchase()); + auto undelivered = workflow; + command = undelivered.retirePreparationPurchase(food); + assert(command.verification && command.verification->result == PlayerBotServiceVerificationResult::Rejected); + assert(undelivered.intent() == PlayerBotServiceIntent::Resupply && + undelivered.stage() == PlayerBotServiceStage::Discover && !undelivered.pendingPreparationPurchase()); + food.inventoryCounts[2666] = 1; + food.money = 11; + // Timeout/interruption between synchronous delivery and the next service + // turn must observe the exact receipt before releasing food consumption. + auto timedOut = workflow; + command = timedOut.retirePreparationPurchase(food); + assert(command.verification && command.verification->result == PlayerBotServiceVerificationResult::Success); + assert(command.transaction && command.transaction->itemId == 2666); + assert(timedOut.intent() == PlayerBotServiceIntent::Resupply && + timedOut.stage() == PlayerBotServiceStage::Discover && !timedOut.buyingPotions() && + !timedOut.pendingPreparationPurchase()); + assert(timedOut.retirePreparationPurchase(food).type == PlayerBotServiceCommandType::None); + auto mismatched = workflow; + auto badReceipt = food; badReceipt.money = 10; + command = mismatched.retirePreparationPurchase(badReceipt); + assert(command.verification && command.verification->result == PlayerBotServiceVerificationResult::Mismatch); + assert(!mismatched.pendingPreparationPurchase() && !mismatched.buyingPotions()); + command = workflow.advance(food, catalog, disposition); + assert(command.verification && command.verification->result == PlayerBotServiceVerificationResult::Success); + command = workflow.advance(food, catalog, disposition); + assert(command.type == PlayerBotServiceCommandType::Complete); + assert(food.inventoryCounts[7618] == 2 && food.money == 11); + } std::cout << "service workflow regression tests passed\n"; } diff --git a/server/tests/playerbotsurvival_contracts.cpp b/server/tests/playerbotsurvival_contracts.cpp index 412907b0..e8dcbc36 100644 --- a/server/tests/playerbotsurvival_contracts.cpp +++ b/server/tests/playerbotsurvival_contracts.cpp @@ -1,6 +1,7 @@ // Real survival runtime policy checks; see playerbotsurvival_contracts.sh. #include "playerbotsurvivalruntime.h" #include "playerbotsupplyrecovery.h" +#include "playerbotsupplypolicy.h" #include #include @@ -59,6 +60,94 @@ void expectHealingSpell(const PlayerBotSurvivalRuntime& runtime, const PlayerBot assert(runtime.pendingSpell()->name == "Light Healing"); } +void typedHealthPreparation() +{ + PlayerBotSurvivalRuntime runtime; + auto state = snapshot(); + state.health = 75; + PlayerBotPreparationRequirement requirement; + requirement.kind = PlayerBotRequirementKind::HealthRegeneration; + requirement.health = 80; + auto options = runtime.healthPreparations(state, requirement, false, false); + assert(options == std::vector{PlayerBotPreparationOption::RestoreHealth}); + state.departureHealthTarget = 80; + expectHealingSpell(runtime, runtime.decideHealing(state, now)); + // Admission must include the executor's mana-potion -> re-evaluated heal, + // even above the ordinary healing trigger and without a health potion. + auto assisted = snapshot(); + assisted.health = 75; assisted.departureHealthTarget = 80; + assisted.mana = 0; assisted.potionCount = 0; assisted.manaPotionCount = 1; + PlayerBotSurvivalRuntime manaAssist; + assert(manaAssist.healthPreparations(assisted, requirement, false, false) == + std::vector{PlayerBotPreparationOption::RestoreHealth}); + auto assistedCommand = manaAssist.decideHealing(assisted, now); + expectPotion(assistedCommand, manaPotion); + assert(assistedCommand.reason == "healing_spell_mana"); + manaAssist.beginPotion(assisted, manaPotion, assistedCommand.reason); + assisted.mana = 20; assisted.manaPotionCount = 0; + assistedCommand = manaAssist.decideHealing(assisted, now + std::chrono::seconds(3)); + assert(assistedCommand.potionVerification && + assistedCommand.potionVerification->result == PlayerBotPotionVerificationResult::Success); + expectHealingSpell(manaAssist, assistedCommand); + assisted.spells.front().learned = false; + assert(!manaAssist.canPrepareHealth(assisted)); + assisted.spells.front().learned = true; assisted.mana = 0; + assert(!manaAssist.canPrepareHealth(assisted)); // No carried mana left. + state.spells.front().learned = false; + PlayerBotSurvivalRuntime potionRuntime; + assert(potionRuntime.canPrepareHealth(state)); + expectPotion(potionRuntime.decideHealing(state, now), healthPotion); + state.potionCount = 0; + assert(runtime.healthPreparations(state, requirement, false, false).empty()); + state.regenerationActive = true; + state.foodTicks = 180000; + assert(runtime.healthPreparations(state, requirement, true, false) == + std::vector{PlayerBotPreparationOption::Regenerate}); + state.protectionZone = true; + requirement.observeRegeneration = true; + state.health = 80; + assert(!runtime.healthPreparationSatisfied(state, requirement, true)); // Engine pauses in PZ. + state.protectionZone = false; + assert(!runtime.healthPreparationSatisfied(state, requirement, false)); // Rest still needs live safety proof. + assert(runtime.healthPreparationSatisfied(state, requirement, true)); + state.regenerationActive = false; + state.foodTicks = 0; + state.foodClientId = 100; + state.foodItemId = 2666; + state.foodCount = 1; + assert(runtime.healthPreparations(state, requirement, true, false) == + std::vector{PlayerBotPreparationOption::Regenerate}); + assert(!runtime.healthPreparationSatisfied(state, requirement, true)); // Carried food is not an effect. + PlayerBotSurvivalRuntime eating; + state.lootMovePending = true; // Unobserved neutral purchase receipt: do not consume it. + auto command = eating.decideFood(state, now); + assert(command.type == Command::None); + state.lootMovePending = false; + command = eating.decideFood(state, now); + assert(command.type == Command::UseFood); + state.foodClientId = 0; state.foodCount = 0; state.pendingFoodCount = 0; + state.regenerationActive = true; state.foodTicks = 180000; + command = eating.decideFood(state, now + std::chrono::seconds(1)); + assert(command.foodVerification && command.foodVerification->result == PlayerBotFoodVerificationResult::Success); + assert(runtime.healthPreparationSatisfied(state, requirement, true)); // Observed, then hunt must re-evaluate. + state.regenerationActive = false; state.foodTicks = 0; + assert(runtime.healthPreparations(state, requirement, true, true) == + std::vector{PlayerBotPreparationOption::BuyFood}); + assert(runtime.healthPreparations(state, requirement, true, false).empty()); // Unaffordable/no offer. + assert(runtime.healthPreparations(state, requirement, false, true).empty()); // No safe reachable rest. + + // Food service derives buyingPotions=false; healing preempts preparation, + // independently of the receipt's consumption guard. + auto emergency = snapshot(); + emergency.health = 25; emergency.combatActive = true; + emergency.buyingPotions = false; emergency.lootMovePending = true; + PlayerBotSurvivalRuntime criticalPotion; + expectPotion(criticalPotion.decideHealing(emergency, now), healthPotion); + emergency.potionCount = 0; + PlayerBotSurvivalRuntime criticalSpell; + expectHealingSpell(criticalSpell, criticalSpell.decideHealing(emergency, now)); +} + void departureHealthPreparation() { assert(playerBotSupplyRecoveryRequiredHealth(185) == 148); @@ -361,6 +450,36 @@ void healingFormulaBounds() } } +void firstHealingBootstrapEligibility() +{ + PlayerBotSurvivalRuntime runtime; + auto state = snapshot(); + state.mana = 0; + state.health = state.healthMaximum; + auto reserve = [&](bool emergencyOnly) { + return playerBotSpellTrainingSpendingReserve(100, 100, + emergencyOnly && !runtime.preferredHealingSpell(state)); + }; + // A usable learned heal restores the cash buffer even at zero mana/full HP. + assert(runtime.preferredHealingSpell(state)); + assert(!runtime.preferredHealingSpell(state, true)); + assert(reserve(true) == 100 && reserve(false) == 100); + for (int gate = 0; gate < 7; ++gate) { + state = snapshot(); + state.mana = 0; + auto& heal = state.spells.front(); + if (gate == 0) heal.learned = false; + if (gate == 1) heal.metadataMatches = false; + if (gate == 2) heal.vocationAllowed = false; + if (gate == 3) heal.requirementsMet = false; + if (gate == 4) heal.targetReachable = false; + if (gate == 5) heal.envelope = {}; + if (gate == 6) heal.name = "Magic Shield"; + assert(!runtime.preferredHealingSpell(state)); + assert(reserve(true) == 0 && reserve(false) == 100); + } +} + void economicalHealing() { auto state = snapshot(); @@ -503,6 +622,7 @@ int main() { spellFirstAndCriticalOverride(); percentageBoundaries(); + typedHealthPreparation(); departureHealthPreparation(); manaAndHealthFallbacks(); manaThenReevaluate(); @@ -510,6 +630,7 @@ int main() actionAndRetryDelays(); buyingPotionsAndHealthyTopups(); healingFormulaBounds(); + firstHealingBootstrapEligibility(); economicalHealing(); calibratedHealingEconomy(); healingCalibrationProgression(); diff --git a/server/tests/playerbottrainer_contracts.cpp b/server/tests/playerbottrainer_contracts.cpp new file mode 100644 index 00000000..4b103de1 --- /dev/null +++ b/server/tests/playerbottrainer_contracts.cpp @@ -0,0 +1,659 @@ +#include + +#include "playerbottrainerroute.h" +#include "playerbotplanningbudget.h" +#include "playerbotpathsearch.h" +#include "playerbotnavigationruntime.h" +#include "playerbotprogressionplanners.h" +#include "playerbotprogressionruntime.h" +#include "playerbottransportsearch.h" +#include "playerbothunttravelevidence.h" +#include "playerbotturnrouter.h" +#include "const.h" + +#include +#include + +namespace { +void paidQuoteBoundaries() +{ + PlayerBotSpellTrainingPlanner planner; + PlayerBotSpellTrainingPlannerSnapshot snapshot; + snapshot.reserveAvailable = true; + snapshot.maximumRouteDangerCost = 100; + snapshot.maximumRouteDanger = 10; + PlayerBotSpellOfferSnapshot offer; + offer.npcId = 12; + offer.spellName = "Light Healing"; + offer.price = 170; + offer.fare = 40; + offer.reserve = 45; + offer.inScope = offer.registryMatches = offer.implementedUse = offer.vocationEligible = true; + offer.levelEligible = offer.premiumEligible = offer.suppliesReady = offer.route.reachable = true; + snapshot.offers = {offer}; + snapshot.totalMoney = 254; + assert(!planner.select(snapshot).selected); + assert(planner.select(snapshot).rejections.front().reason == "trip_fare_unaffordable"); + snapshot.totalMoney = 255; + const auto selected = planner.select(snapshot).selected; + assert(selected && selected->fare == 40 && selected->reserve == 45); + // Releasing the first-heal cash buffer does not release missing supply money. + snapshot.offers.front().reserve = 0; + snapshot.totalMoney = 210; + assert(planner.select(snapshot).selected); + snapshot.totalMoney = 209; + assert(!planner.select(snapshot).selected); + snapshot.totalMoney = 177; // Spell-only capital is not a boat ticket. + assert(!planner.select(snapshot).selected); + snapshot.totalMoney = 113; + assert(planner.select(snapshot).rejections.front().reason == "unaffordable_after_reserves"); + snapshot.totalMoney = 1000; + snapshot.offers.front().routeRejection = "route_proof_exhausted"; + assert(planner.select(snapshot).rejections.front().reason == "route_proof_exhausted"); + snapshot.offers.front().routeRejection = "route_fare_limited"; + assert(planner.select(snapshot).rejections.front().reason == "route_fare_limited"); + snapshot.offers.front().routeRejection.clear(); + snapshot.offers.front().route.dangerCost = 101; + assert(planner.select(snapshot).rejections.front().reason == "route_danger_above_tolerance"); + snapshot.offers.front().route.dangerCost = 0; + snapshot.offers.front().suppliesReady = false; + assert(planner.select(snapshot).rejections.front().reason == "supply_reserve_unmet"); + assert(!playerBotTrainerTripAffordable(UINT64_MAX, UINT64_MAX, 0, 0)); + assert(!playerBotTrainerTripAffordable(UINT64_MAX, UINT64_MAX - 1, 2, 0)); + assert(!playerBotTrainerTripAffordable(UINT64_MAX, 0, UINT64_MAX, 1)); + assert(playerBotTrainerTripAffordable(UINT64_MAX, 0, UINT64_MAX - 40, 40)); + assert(playerBotTrainerFareAffordable(285, 45, 170, 70, 40)); + assert(!playerBotTrainerFareAffordable(284, 45, 170, 70, 40)); + assert(!playerBotTrainerFareAffordable(1000, 45, 170, 40, 41)); // Extra income cannot extend the quote. + assert(playerBotTrainerFareAffordable(245, 45, 170, 30, 30)); // Remaining itinerary after paying 40. + assert(!playerBotTrainerFareAffordable(244, 45, 170, 30, 30)); +} + +void scanAccountingAndRanking() +{ + using namespace std::chrono_literals; + using Progress = PlayerBotTrainerScanProgress; + const auto start = Progress::Clock::time_point{}; + Progress progress; + progress.deadline = start + 10min; + // Replay the observed first proof and a second request across the old + // whole-scan 4096 boundary. Queued admission waits are not failed proofs. + progress.continuations = 3442; + uint32_t secondRequestSlices = 0; + for (unsigned continuation = 0; continuation < 4096; ++continuation) { + ++progress.continuations; + if (continuation % 2 == 0) ++secondRequestSlices; + assert(!progress.exhaustion(start + 33s, 2)); + } + assert(progress.continuations > 4096 && secondRequestSlices < 4096); + assert(std::string(progress.exhaustion(start + 10min, 2)) == "scan_elapsed_limit"); + progress.restarts = 2; + assert(!progress.exhaustion(start + 1min, 2)); + progress.restarts = 3; + assert(std::string(progress.exhaustion(start + 1min, 2)) == "scan_restart_limit"); + + PlayerBotSpellTrainingPlanner planner; + PlayerBotSpellTrainingPlannerSnapshot snapshot; + snapshot.reserveAvailable = true; + snapshot.totalMoney = 1000; + snapshot.maximumRouteDangerCost = 100; + snapshot.maximumRouteDanger = 10; + PlayerBotSpellOfferSnapshot support; + support.npcId = 1; support.spellName = "support"; + support.learningPriority = 2; support.level = 14; support.price = 600; support.reserve = 100; + support.inScope = support.registryMatches = support.implementedUse = support.vocationEligible = true; + support.levelEligible = support.premiumEligible = support.suppliesReady = true; + auto heal = support; + heal.npcId = 2; heal.spellName = "heal"; + heal.learningPriority = 0; heal.level = 9; heal.price = 170; heal.reserve = 0; + auto alternate = heal; + alternate.npcId = 3; + snapshot.offers = {support, heal, alternate}; // Loaded provider/script order is not ranking. + std::stable_sort(snapshot.offers.begin(), snapshot.offers.end(), [](const auto& a, const auto& b) { + return playerBotTrainerPriority(a) < playerBotTrainerPriority(b); + }); + assert(snapshot.offers[0].npcId == 2 && snapshot.offers[1].npcId == 3); + assert(playerBotTrainerPriority(snapshot.offers[0]) == playerBotTrainerPriority(snapshot.offers[1])); + assert(playerBotTrainerPriority(snapshot.offers[0]) < playerBotTrainerPriority(snapshot.offers[2])); + // Equal static rank still compares travel steps, including paid routes. + snapshot.offers[0].route.reachable = snapshot.offers[1].route.reachable = true; + snapshot.offers[0].route.steps = 200; + snapshot.offers[0].fare = 40; + snapshot.offers[1].route.steps = 100; + snapshot.offers[1].fare = 70; + assert(planner.select(snapshot).selected->npcId == 3); + snapshot.offers[2].routeRejection = "lower_learning_priority"; + assert(!playerBotTrainerDeferredNeeded(snapshot.offers, planner.select(snapshot).selectedOfferIndex)); + assert(playerBotTrainerDeferredNeeded(snapshot.offers, std::nullopt) == 2); + snapshot.offers[2].routeRejection.clear(); + // Deadline ends only pending proofs. Even a retained route beyond a + // reopened stale cursor must remain selectable, not silently discarded. + playerBotTrainerEndPendingProofs(snapshot.offers, 0); + assert(snapshot.offers[0].routeRejection.empty() && snapshot.offers[1].routeRejection.empty()); + assert(snapshot.offers[2].routeRejection == "route_proof_exhausted"); + assert(planner.select(snapshot).selected->npcId == 3); + snapshot.offers[1].registryMatches = false; + assert(planner.select(snapshot).selected->npcId == 2); // Loaded-state validation still authoritative. + snapshot.offers[0].suppliesReady = false; + assert(!planner.select(snapshot).selected); // A retained route cannot erase its route potion reserve. + snapshot.offers[2].routeRejection = "lower_learning_priority"; + assert(playerBotTrainerDeferredNeeded(snapshot.offers, planner.select(snapshot).selectedOfferIndex) == 2); + snapshot.offers[0].suppliesReady = true; + snapshot.offers[2].routeRejection = "route_proof_exhausted"; + snapshot.totalMoney = 209; + assert(!planner.select(snapshot).selected); // Deadline cannot release spell/fare protection. +} + +void localTrainerProofAndRankBound() +{ + const Position source(100, 100, 7), representative(99, 99, 7), remote(200, 200, 7); + const std::vector original{representative, source}; + const std::vector wandered{source, Position(103, 101, 7)}; + assert(original != wandered); + PlayerBotNavigationRoutePlan local; + local.metrics.result = PlayerBotNavigationResult::Reached; + local.metrics.waypoint = source; // Zero-step proof endpoint is not the any-of representative. + assert(playerBotTrainerDiscoveryDestinationRetained(local, original, wandered)); + assert(!playerBotTrainerDiscoveryDestinationRetained(local, original, {remote})); + PlayerBotHuntTravelEvidence evidence; + evidence.source = source; evidence.destination = representative; + evidence.topologyGeneration = 1; evidence.riskRevision = 2; + std::get<18>(evidence.actor) = source; + assert(evidence.validContext(source, evidence.destination, evidence.actor, 1, 2, evidence.risk)); + assert(!evidence.validContext(source, wandered.front(), evidence.actor, 1, 2, evidence.risk)); + assert(playerBotTrainerMinimumSteps(source, wandered) == 0); + assert(playerBotTrainerMinimumSteps(source, {remote}) == 1); + assert(playerBotTrainerMinimumSteps(source, {}) == 1); + + PlayerBotSpellTrainingPlanner planner; + PlayerBotSpellTrainingPlannerSnapshot snapshot; + snapshot.reserveAvailable = true; snapshot.totalMoney = 1000; + snapshot.maximumRouteDangerCost = 100; snapshot.maximumRouteDanger = 10; + PlayerBotSpellOfferSnapshot incumbent; + incumbent.npcId = 20; incumbent.spellName = "M"; + incumbent.learningPriority = 0; incumbent.level = 9; incumbent.price = 170; incumbent.reserve = 0; + incumbent.inScope = incumbent.registryMatches = incumbent.implementedUse = incumbent.vocationEligible = true; + incumbent.levelEligible = incumbent.premiumEligible = incumbent.suppliesReady = incumbent.route.reachable = true; + incumbent.route.approachPosition = source; incumbent.route.steps = 0; + auto candidate = incumbent; + candidate.npcId = 19; // Better suffix cannot beat zero steps from a remote source. + candidate.route.reachable = false; + assert(!playerBotTrainerOptimisticCanBeat(candidate, 1, incumbent)); + assert(playerBotTrainerOptimisticCanBeat(candidate, 0, incumbent)); // Co-located equal-rank lower ID can win. + candidate.routeRejection = "route_rank_dominated"; + snapshot.offers = {incumbent, candidate}; + const auto selected = planner.select(snapshot).selectedOfferIndex; + assert(selected == 0); + assert(!playerBotTrainerDeferredNeeded(snapshot.offers, selected, [](size_t) { return uint32_t(1); })); + assert(playerBotTrainerDeferredNeeded(snapshot.offers, selected, [](size_t) { return uint32_t(0); }) == 1); + assert(playerBotTrainerDeferredNeeded(snapshot.offers, std::nullopt, [](size_t) { return uint32_t(1); }) == 1); + + // Compare every pruning verdict to the actual original six-field planner + // rank, including spell/provider suffixes, zero-step ties and boat steps. + // If the optimistic route cannot win, no longer completed route may win. + for (uint8_t priority : {0, 1}) for (uint32_t level : {9, 10}) for (uint32_t price : {170, 171}) + for (const char* name : {"A", "M", "Z"}) for (uint32_t id : {19, 20, 21}) for (uint32_t minimum : {0, 1}) { + candidate = incumbent; + candidate.learningPriority = priority; candidate.level = level; candidate.price = price; + candidate.spellName = name; candidate.npcId = id; + if (playerBotTrainerOptimisticCanBeat(candidate, minimum, incumbent)) continue; + for (uint32_t actual = minimum; actual <= 3; ++actual) { + candidate.route.steps = actual; + candidate.fare = actual == 0 ? 0 : 40; + snapshot.offers = {incumbent, candidate}; + assert(planner.select(snapshot).selectedOfferIndex == 0); + } + } + // A better static rank still gets proof, regardless of distance. Equal + // positive-step ranks still get proof when their suffix could win. + candidate = incumbent; candidate.level = 8; + assert(playerBotTrainerOptimisticCanBeat(candidate, 1, incumbent)); + candidate = incumbent; candidate.npcId = 19; + incumbent.route.steps = 1; + assert(playerBotTrainerOptimisticCanBeat(candidate, 1, incumbent)); +} + +void discardedRoutePotionReserve() +{ + PlayerBotSpellTrainingPlanner planner; + PlayerBotSpellTrainingPlannerSnapshot snapshot; + snapshot.reserveAvailable = true; + snapshot.totalMoney = 250; + snapshot.maximumRouteDangerCost = 1000; + snapshot.maximumRouteDanger = 10; + PlayerBotSpellOfferSnapshot offer; + offer.npcId = 12; offer.spellName = "heal"; offer.price = 170; offer.fare = 40; offer.reserve = 0; + offer.inScope = offer.registryMatches = offer.implementedUse = offer.vocationEligible = true; + offer.levelEligible = offer.premiumEligible = true; + const uint32_t potions = 3; + auto refreshStock = [&](auto& candidate) { candidate.suppliesReady = potions > candidate.potionReserve; }; + + // The completed route is legal but needs more stock than is carried. + // While retained it must continue to block selection; a refresh cannot + // silently replace its four-potion requirement with the ordinary floor. + offer.route.reachable = true; + offer.route.dangerCost = 500; + offer.potionReserve = 4; + refreshStock(offer); + snapshot.offers = {offer}; + assert(planner.select(snapshot).rejections.front().reason == "supply_reserve_unmet"); + refreshStock(snapshot.offers.front()); + assert(!planner.select(snapshot).selected); + + // Both stale-world/provider invalidation and interruption use this exact + // production reset. The old requirement must not fail the cheap admission + // check before a safer replacement can be proved by the sliced engine. + auto& candidate = snapshot.offers.front(); + playerBotTrainerDiscardRouteProof(candidate); + assert(!candidate.route.reachable && candidate.potionReserve == 0); + refreshStock(candidate); + assert(candidate.suppliesReady); // Admits reproof, not selection. + assert(!planner.select(snapshot).selected); // No proof is still no route. + candidate.route.reachable = true; + candidate.route.dangerCost = 0; + candidate.potionReserve = 1; + refreshStock(candidate); + const auto selected = planner.select(snapshot).selected; + assert(selected && selected->potionReserve == 1 && selected->fare == 40); + assert(selected->price == 170 && selected->reserve == 0); +} + +void pendingOwnership() +{ + using Search = PlayerBotTransportSearch; + using Segment = PlayerBotTransportSegment; + using Result = PlayerBotNavigationResult; + auto trainer = std::make_shared(2, std::vector{{0, 1, 40}}, 40); + auto hunt = std::make_shared(1, std::vector{}, 0); + auto service = std::make_shared(1, std::vector{}, 0); + const auto huntIdentity = hunt.get(), serviceIdentity = service.get(), trainerIdentity = trainer.get(); + bool allowConnection = false; + auto query = [&](size_t state, size_t connection, bool) -> std::optional { + if (!allowConnection) return std::nullopt; + if (connection == SIZE_MAX) return Segment{state == 1 ? Result::Reached : Result::Unreachable, 2, 2}; + return Segment{state == 0 ? Result::Reached : Result::Unreachable, 1, 1}; + }; + { + PlayerBotRouteWorkSlot slot(hunt, trainer); + assert(hunt.get() == trainerIdentity); + for (unsigned turn = 0; turn < 4; ++turn) assert(!hunt->advance(query)); + } + assert(hunt.get() == huntIdentity && trainer.get() == trainerIdentity && service.get() == serviceIdentity); + assert(!trainer->bestPaid); // In-flight work is not unreachable evidence. + allowConnection = true; + unsigned turns = 0; + { + PlayerBotRouteWorkSlot slot(hunt, trainer); + while (!hunt->advance(query)) assert(++turns < 32); + assert(hunt->bestPaid && hunt->bestPaid->fare == 40); + } + assert(hunt.get() == huntIdentity && service.get() == serviceIdentity); + trainer.reset(); // Cancellation owns no callback and cannot reset another owner's frontier. + assert(hunt.get() == huntIdentity && service.get() == serviceIdentity); + + PlayerBotTurnRouter router; + PlayerBotTurnObservation observation; + observation.trainerDiscoveryActive = observation.huntPlanningActive = observation.preparationActive = true; + for (auto phase : {PlayerBotCyclePhase::Service, PlayerBotCyclePhase::Hunt, PlayerBotCyclePhase::ReturnToDepot}) { + router.setCyclePhase(phase); + assert(router.route(observation) == PlayerBotTurnCommand::TrainerDiscovery); + } + router.pause(); + assert(router.route(observation) == PlayerBotTurnCommand::None); + router.start(); + router.stop(); + assert(router.route(observation) == PlayerBotTurnCommand::None); +} + +void invalidation() +{ + PlayerBotHuntTravelEvidence evidence; + evidence.paid = true; + evidence.source = Position(100, 100, 7); + evidence.destination = Position(300, 300, 7); + evidence.topologyGeneration = 1; + evidence.riskRevision = 2; + evidence.actor = {}; + evidence.offers = {{9, Position(101, 100, 7), Position(200, 200, 7), 40, 0, false, {"mainland", "yes"}}}; + evidence.providers.reset({{9, Position(101, 100, 7)}}); + evidence.providers.use(9); + assert(evidence.validContext(evidence.source, evidence.destination, evidence.actor, 1, 2, evidence.risk)); + auto actor = evidence.actor; + std::get<7>(actor) = 211; + assert(!evidence.validContext(evidence.source, evidence.destination, actor, 1, 2, evidence.risk)); + assert(!evidence.validContext(evidence.source, evidence.destination, evidence.actor, 3, 2, evidence.risk)); + assert(evidence.validOffers(evidence.offers)); + auto offers = evidence.offers; + std::get<3>(offers.front()) = 41; + assert(!evidence.validOffers(offers)); + offers = evidence.offers; + std::get<1>(offers.front()).x += 3; + assert(!evidence.validOffers(offers)); + { PlayerBotRouteChanges::Scope watch(evidence.watch); PlayerBotRouteChanges::read(Position(102, 100, 7)); } + PlayerBotRouteChanges::changed(Position(102, 100, 7)); + assert(!evidence.validContext(evidence.source, evidence.destination, evidence.actor, 1, 2, evidence.risk)); +} + +template void executionMaterialActorFacts(PlayerBotHuntTravelEvidence& evidence) +{ + if constexpr (Field < std::tuple_size::value) { + if constexpr (Field != 12 && Field != 13 && Field != 18) { + auto actor = evidence.actor; + auto& value = std::get(actor); + if constexpr (std::is_same_v, bool>) value = !value; + else ++value; + assert(std::string(evidence.executionContextChange(actor, 546, 1, 2, evidence.risk)) == "actor_facts_changed"); + } + executionMaterialActorFacts(evidence); + } +} + +void executionLegValidity() +{ + using Evidence = PlayerBotHuntTravelEvidence; + const Position source(100, 100, 7), approach(102, 100, 7), landing(200, 200, 7); + const Position trainerTile(202, 200, 7), alternativeTile(500, 500, 7); + Evidence evidence; + evidence.source = source; evidence.destination = trainerTile; + evidence.topologyGeneration = 1; evidence.riskRevision = 2; + evidence.actor = {14, 245, 13, 20, 25, 40, 1.0f, 250, true, false, true, true, + 200, 600, false, 0, 0, 0, source, 76, true}; + evidence.paid = true; + evidence.offers = {{9, approach, landing, 70, 0, false, {"destination", "yes"}}, + {10, alternativeTile, Position(600, 600, 7), 40, 0, false, {"elsewhere", "yes"}}}; + evidence.providers.reset({{9, approach}, {10, alternativeTile}}); + evidence.providers.use(9); + evidence.providers.use(10); // Visited alternative, not chosen itinerary. + { + PlayerBotRouteChanges::Scope reads(evidence.watch); + PlayerBotRouteChanges::read(approach); + PlayerBotRouteChanges::read(alternativeTile); + } + PlayerBotNavigationRoutePlan route; + PlayerBotNavigationStep walk; + walk.action = PlayerBotNavigationAction::Move; + walk.target = walk.expectedPosition = approach; + PlayerBotNavigationStep boat; + boat.action = PlayerBotNavigationAction::NpcTravel; + boat.npcId = 9; boat.target = approach; boat.expectedPosition = landing; boat.price = 70; + boat.dialogue = {"destination", "yes"}; + route.steps = {walk, boat}; + route.metrics.firstNpcTravelOffer = PlayerBotNpcTravelOfferIdentity{9, landing, 70, 0, false, boat.dialogue}; + evidence.retainExecutionLeg(route, 546); // Exact production first-leg dependency capture. + assert(!evidence.executionContextChange(evidence.actor, 546, 1, 2, evidence.risk)); + + // Replay the loaded-map durations at the five observed invalidations. + // The old planning context fails even after its position-only patch; + // execution accepts ordinary walking while preserving actual actor speed. + for (uint32_t duration : {300, 200, 250, 200, 250}) { + auto actor = evidence.actor; + std::get<12>(actor) = duration; + std::get<13>(actor) = duration * 3; + std::get<18>(actor) = approach; + assert(!evidence.executionContextChange(actor, 546, 1, 2, evidence.risk)); + if (duration != 200) { + std::get<18>(actor) = source; + assert(!evidence.validContext(source, trainerTile, actor, 1, 2, evidence.risk)); + } + } + assert(std::string(evidence.executionContextChange(evidence.actor, 545, 1, 2, evidence.risk)) == "actor_speed_changed"); + executionMaterialActorFacts(evidence); // Gear/combat, funds, eligibility, tools, permissions remain material. + assert(std::string(evidence.executionContextChange(evidence.actor, 546, 3, 2, evidence.risk)) == "topology_changed"); + assert(std::string(evidence.executionContextChange(evidence.actor, 546, 1, 3, evidence.risk)) == "risk_changed"); + auto risk = evidence.risk; + risk.maximumHealthLossPerSecond += 0.01; + assert(std::string(evidence.executionContextChange(evidence.actor, 546, 1, 2, risk)) == "risk_changed"); + + // Broad planning proof is correctly stale when an explored captain moves. + // This is not a change to the accepted first leg or its exact fare quote. + auto movedOffers = evidence.offers; + std::get<1>(movedOffers[1]).x += 3; + assert(!evidence.validOffers(movedOffers)); + PlayerBotRouteChanges::changed(alternativeTile); + assert(!evidence.watch.valid()); + assert(!evidence.executionContextChange(evidence.actor, 546, 1, 2, evidence.risk)); + const auto boarding = playerBotTrainerBoardingApproach(route, source); + assert(boarding && *boarding == approach); + assert(Position::areInRange<3, 3, 0>(*boarding, Position(103, 100, 7))); + assert(!Position::areInRange<3, 3, 0>(*boarding, Position(106, 100, 7))); + assert(!Position::areInRange<3, 3, 0>(*boarding, Position(102, 100, 6))); + assert(playerBotTrainerDestinationRetained(route, {trainerTile, Position(203, 200, 7)}, + {trainerTile, Position(201, 200, 7)})); + assert(!playerBotTrainerDestinationRetained(route, {trainerTile}, {alternativeTile})); + // A walking-only trainer leg must retain its actual conversation endpoint. + auto finalWalk = route; + finalWalk.metrics.firstNpcTravelOffer.reset(); + finalWalk.steps = {walk}; + assert(playerBotTrainerDestinationRetained(finalWalk, {approach}, {approach})); + assert(!playerBotTrainerDestinationRetained(finalWalk, {approach, trainerTile}, {trainerTile})); + + PlayerBotRouteChanges::changed(approach, PlayerBotRouteChanges::Cause::TileFlag); + assert(std::string(evidence.executionContextChange(evidence.actor, 546, 1, 2, evidence.risk)) == "execution_world_changed"); + // Landing consumes exactly one fare, then the remaining trip needs fresh + // shared proof. The old actor context cannot survive the payment change. + assert(playerBotTrainerFareAffordable(250, 0, 170, 70, 70)); + assert(!playerBotTrainerFareAffordable(250, 0, 170, 70, 71)); + assert(playerBotTrainerTravelReceipt(true, 250, 180, 70)); + assert(playerBotTrainerTripAffordable(180, 0, 170, 0)); + auto landedActor = evidence.actor; + std::get<7>(landedActor) = 180; std::get<18>(landedActor) = landing; + assert(std::string(evidence.executionContextChange(landedActor, 546, 1, 2, evidence.risk)) == "actor_facts_changed"); +} + +void arbitrationContention() +{ + using Arbitration = PlayerBotTrainerArbitration; + using Clock = PlayerBotPlanningBudget::Clock; + using namespace std::chrono_literals; + for (const bool found : {false, true}) { + const auto start = Clock::time_point{}; + PlayerBotPlanningBudget budget(start); + Arbitration arbitration; + unsigned trainerScans = 0, preparationEvaluations = 0; + auto discover = [&] { + ++trainerScans; + assert(budget.request(1, start).admitted); // Real shared admission for completed trainer proof. + assert(!budget.request(2, start).admitted); // Another controller queues while it is owned. + assert(budget.finish(1, start)); + return Arbitration::Result{false, + found ? std::optional(PlayerBotSpellTrainingPlan{}) : std::nullopt}; + }; + auto result = arbitration.advanceTrainer("unchanged semantic facts", true, discover); + assert(!result.pending && result.selected.has_value() == found); + assert(arbitration.phase() == Arbitration::Phase::Preparation); + assert(!budget.request(1, start).admitted); // Preparation loses to controller 2, not infeasibility. + assert(budget.request(2, start + 1ms).admitted); + assert(budget.finish(2, start + 1ms)); + result = arbitration.advanceTrainer("unchanged semantic facts", true, [&]() -> Arbitration::Result { + assert(false && "preparation retry must not rescan trainers or consume its admission"); + return {}; + }); + assert(result.selected.has_value() == found); + assert(budget.request(1, start + 2ms).admitted); + ++preparationEvaluations; + assert(budget.finish(1, start + 2ms)); + assert(trainerScans == 1 && preparationEvaluations == 1); + + // Changed facts, changed proof, and explicit interruption each invalidate, + // but elapsed waiting or an unchanged negative result never does. + for (const auto& facts : {"changed funds", "changed learned state"}) { + arbitration.advanceTrainer(facts, true, [&] { ++trainerScans; return Arbitration::Result{}; }); + } + arbitration.advanceTrainer("changed learned state", false, [&] { ++trainerScans; return Arbitration::Result{}; }); + arbitration.reset(); + arbitration.advanceTrainer("changed learned state", true, [&] { ++trainerScans; return Arbitration::Result{}; }); + assert(trainerScans == 5); + } +} + +void blockedLandingDetour() +{ + using namespace std::chrono_literals; + const Position landing(100, 100, 7), direct(101, 100, 7), trainer(102, 100, 7); + const auto goal = PlayerBotNavigationGoal::exact(trainer); + PlayerBotNavigationRuntime navigation; + PlayerBotNavigationRoutePlan original; + original.metrics.result = PlayerBotNavigationResult::Reached; + auto move = [](Position target, Direction direction) { + PlayerBotNavigationStep step; + step.action = PlayerBotNavigationAction::Move; + step.direction = direction; + step.target = step.expectedPosition = target; + return step; + }; + original.steps = {move(direct, DIRECTION_EAST), move(trainer, DIRECTION_EAST)}; + const auto now = std::chrono::steady_clock::time_point{}; + navigation.observePlan({goal, original, true, true, now}); + + // One paid landing has happened. A failed *walking* step after it must + // propagate its exclusion into replanning, not re-board/pay the captain. + uint64_t funds = 210, remainingFare = 40; + unsigned paidTrips = 0; + auto dispatch = [&](const PlayerBotNavigationStep& step) { + if (step.action != PlayerBotNavigationAction::NpcTravel) return true; + if (!playerBotTrainerFareAffordable(funds, 0, 170, remainingFare, step.price)) return false; + const auto before = funds; + funds -= step.price; + assert(playerBotTrainerTravelReceipt(true, before, funds, step.price)); + remainingFare -= step.price; + ++paidTrips; + return true; + }; + PlayerBotNavigationStep boat; + boat.action = PlayerBotNavigationAction::NpcTravel; + boat.price = 40; + assert(dispatch(boat)); + navigation.observeStep({original.steps.front(), PlayerBotNavigationStepResult::Dispatched, now, 30s}); + auto outcome = navigation.process({landing, goal, false, true, {now + 1s, 1s, 30s, 30s}}); + assert(outcome.routeRequest && outcome.routeRequest->blockedPositions.count(direct)); + navigation.reset(); // Trainer invalidation resets steps, not short-lived block evidence. + assert(navigation.activeBlockedPositions(now + 1s) == outcome.routeRequest->blockedPositions); + + PlayerBotHuntRouteRequest request; + request.from = landing; request.to = trainer; + request.blockedPositions = navigation.activeBlockedPositions(now + 1s); + const auto identity = playerBotNavigationBlockedIdentity("actor/risk", request.blockedPositions); + assert(identity != playerBotNavigationBlockedIdentity("actor/risk", {})); + assert(identity == playerBotNavigationBlockedIdentity("actor/risk", {direct})); + assert(identity != playerBotNavigationBlockedIdentity("actor/risk", {trainer})); + assert(PlayerBotHuntRouteRequest{}.blockedPositions.empty()); // Existing exact-route callers unchanged. + + // Use the real sliced path search with this request's graph exclusions. + // The direct path is shorter, but its first step is occupied. A four-step + // cardinal detour exists; the immediate two-step sidestep cannot take it. + PlayerBotPathSearch search(landing, goal, 32, true); + unsigned slices = 0; + auto expand = [&](Position from) { + std::vector arcs; + for (auto direction : {DIRECTION_NORTH, DIRECTION_EAST, DIRECTION_SOUTH, DIRECTION_WEST}) { + Position to = from; + if (direction == DIRECTION_NORTH) --to.y; + else if (direction == DIRECTION_SOUTH) ++to.y; + else if (direction == DIRECTION_EAST) ++to.x; + else --to.x; + if (to.x < landing.x || to.x > trainer.x || to.y < landing.y || to.y > landing.y + 1 || + request.blockedPositions.count(to)) continue; + arcs.push_back({move(to, direction), 10, 0, 0}); + } + return arcs; + }; + while (!search.advance(1, expand, [&](Position p) { return p == trainer; }, [](Position) { return 0u; })) + assert(++slices < 32); + assert(search.result == PlayerBotNavigationResult::Reached && slices > 1 && search.steps.size() == 4); + PlayerBotNavigationRoutePlan detour; + detour.metrics.result = PlayerBotNavigationResult::Reached; + detour.steps = search.steps; + assert(playerBotNavigationRiskVerdict(PlayerBotNavigationRiskProfile{}, detour.metrics) == PlayerBotNavigationRiskVerdict::Accepted); + assert(playerBotTrainerFareAffordable(funds, 0, 170, remainingFare, detour.metrics.fare)); + assert(!playerBotTrainerFareAffordable(funds, 0, 170, remainingFare, 40)); + navigation.observePlan({goal, std::move(detour), true, true, now + 1s}); + Position cursor = landing; + for (const auto& step : search.steps) { + auto next = navigation.process({cursor, goal, false, true, {now + 2s, 1s, 30s, 30s}}); + assert(next.nextStep && next.nextStep->target == step.target && step.target != direct); + assert(next.nextStep->action == PlayerBotNavigationAction::Move); // No second NPC trip. + assert(dispatch(*next.nextStep)); + navigation.observeStep({step, PlayerBotNavigationStepResult::Dispatched, now + 2s, 30s}); + cursor = step.expectedPosition; + } + assert(navigation.process({cursor, goal, false, true, {now + 3s, 1s, 30s, 30s}}).destinationReached); + assert(paidTrips == 1 && funds == 170 && remainingFare == 0); + assert(!dispatch(boat)); // A repeated paid leg cannot be authorized after the detour. + assert(paidTrips == 1 && funds == 170); + assert(navigation.activeBlockedPositions(now + 31s).empty()); +} + +void paidTravelThenLearning() +{ + PlayerBotProgressionRuntime runtime; + PlayerBotSpellTrainingPlan plan; + plan.npcId = 12; + plan.spellName = "Light Healing"; + plan.keyword = "light healing"; + plan.price = 170; + plan.fare = 40; + runtime.beginSpellTraining(plan); + PlayerBotSpellTrainingObservation observation; + observation.totalMoney = 210; + // Pending travel leaves the normal learning session in Travel without spending retries. + for (unsigned turn = 0; turn < 10; ++turn) { + assert(runtime.advanceSpellTraining(observation).type == PlayerBotProgressionOutcomeType::Pending); + assert(runtime.spellTraining().retries() == 0); + } + assert(playerBotTrainerTravelReceipt(true, 210, 170, 40)); + assert(!playerBotTrainerTravelReceipt(false, 210, 170, 40)); + assert(!playerBotTrainerTravelReceipt(true, 210, 169, 40)); + assert(!playerBotTrainerTravelReceipt(true, 39, 0, 40)); + observation.totalMoney = 170; + observation.navigationReached = true; + assert(runtime.advanceSpellTraining(observation).command.reason == std::string("hi")); + assert(runtime.reportNpcReply(1, 1, plan.npcId, TALKTYPE_PRIVATE_NP)); + observation.npcAvailable = observation.greetingAcknowledged = true; + assert(runtime.advanceSpellTraining(observation).command.reason == std::string("request")); + assert(runtime.advanceSpellTraining(observation).command.reason == std::string("yes")); + observation.totalMoney = 0; + observation.learned = true; + assert(runtime.advanceSpellTraining(observation).type == PlayerBotProgressionOutcomeType::Succeeded); + runtime.finish(); + assert(runtime.session().active() == PlayerBotProgressionProcedure::None); + + // A learned spell without the exact quoted payment remains a failed receipt. + runtime.beginSpellTraining(plan); + observation = {}; + observation.navigationReached = true; + observation.npcAvailable = observation.greetingAcknowledged = true; + observation.totalMoney = 170; + runtime.advanceSpellTraining(observation); + runtime.advanceSpellTraining(observation); + runtime.advanceSpellTraining(observation); + observation.learned = true; + observation.totalMoney = 1; + assert(runtime.advanceSpellTraining(observation).type == PlayerBotProgressionOutcomeType::Failed); + + // Lost focus and rejected learning are bounded, not blocking loops. + runtime.beginSpellTraining(plan); + observation = {}; + observation.navigationReached = observation.npcAvailable = true; + runtime.advanceSpellTraining(observation); + for (unsigned retry = 0; retry < 2; ++retry) + assert(runtime.advanceSpellTraining(observation).type == PlayerBotProgressionOutcomeType::Retry); + assert(runtime.advanceSpellTraining(observation).type == PlayerBotProgressionOutcomeType::Failed); + runtime.restartSpellTrainingTravel(); + assert(runtime.spellTraining().stage() == PlayerBotSpellTrainingStage::Travel); +} +} + +int main() +{ + paidQuoteBoundaries(); + scanAccountingAndRanking(); + localTrainerProofAndRankBound(); + discardedRoutePotionReserve(); + pendingOwnership(); + invalidation(); + executionLegValidity(); + paidTravelThenLearning(); + arbitrationContention(); + blockedLandingDetour(); + std::cout << "playerbot trainer contracts passed\n"; +} diff --git a/server/tests/playerbottrainer_contracts.sh b/server/tests/playerbottrainer_contracts.sh new file mode 100644 index 00000000..7152a9b3 --- /dev/null +++ b/server/tests/playerbottrainer_contracts.sh @@ -0,0 +1,14 @@ +#!/bin/sh +# Paid trainer-route policy and real progression runtime; no server or database. +set -eu +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +build=$(mktemp -d) +trap 'rm -rf "$build"' EXIT HUP INT TERM +"${CXX:-c++}" ${CPPFLAGS:-} ${CXXFLAGS:-} -std=c++17 -Wall -Wextra -Werror \ + -Wno-error=reorder -ffunction-sections -fdata-sections -I"$root/src" \ + "$root/tests/playerbottrainer_contracts.cpp" "$root/src/playerbotprogressionplanners.cpp" \ + "$root/src/playerbotprogressionruntime.cpp" "$root/src/playerbotprogressionsession.cpp" \ + "$root/src/playerbotnpcsession.cpp" "$root/src/playerbotservicesession.cpp" \ + "$root/src/playerbotnavigationruntime.cpp" "$root/src/playerbotnavigationsession.cpp" "$root/src/playerbotnavigation.cpp" \ + ${LDFLAGS:-} -Wl,--gc-sections -o "$build/trainer" +"$build/trainer"