From 38e7d93c89411cc207e4548edf7d820201346f0c Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:05:37 +0200 Subject: [PATCH 1/8] infra: support Linux PowerShell development tooling --- README.md | 12 +++++++---- docs/client-runtime.md | 28 +++++++++++++++++--------- scripts/bootstrap-client.ps1 | 13 ++++++++++-- scripts/playerbot-gameplay/runtime.ps1 | 2 ++ scripts/test-knight-spell-contract.ps1 | 2 ++ scripts/test-playerbot-gameplay.ps1 | 13 +++++++----- 6 files changed, 49 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index dae9565e..b848a1d6 100644 --- a/README.md +++ b/README.md @@ -97,9 +97,12 @@ changing either subtree. ## Running it -The supported local environment uses Docker Desktop and PowerShell 7+. GitHub -CLI authentication is needed only when the bootstrap script must download the -pinned private client executable. +Use PowerShell 7+ (`pwsh`) with Docker Desktop on Windows or Docker Engine with +Compose v2 on Linux. Linux bootstrap uses a locally built `client/otclient` and +installs the same verified assets; it does not download a Windows executable. +GitHub CLI authentication is needed only to download the pinned Windows runtime. +See [client runtime](docs/client-runtime.md) and [testing](docs/testing.md) for +Linux usage and Docker privilege limits. ```powershell pwsh -File scripts/bootstrap-client.ps1 @@ -107,7 +110,8 @@ docker compose -f server/compose.yaml up --build --detach docker compose -f server/compose.yaml logs --follow server ``` -Launch `client/launch-angelion-redemption.cmd`. Ports `7171` and `7172` bind to +On Windows, launch `client/launch-angelion-redemption.cmd`; on Linux, run +`(cd client && ./otclient)` from a POSIX shell. Ports `7171` and `7172` bind to localhost only. The tracked accounts are local development defaults: diff --git a/docs/client-runtime.md b/docs/client-runtime.md index 9277d591..996f7236 100644 --- a/docs/client-runtime.md +++ b/docs/client-runtime.md @@ -3,24 +3,32 @@ ## Bootstrap The OTClient executable and Tibia DAT/SPR assets are excluded from Git. Install -and verify the pinned runtime with: +and verify the runtime assets with PowerShell 7+ on Windows or Linux: ```powershell pwsh -File scripts/bootstrap-client.ps1 ``` -GitHub CLI authentication is required only when the private executable must be -downloaded. The script verifies SHA-256 for the executable, asset archive, -`Tibia.dat`, and `Tibia.spr`; do not weaken these checks. +On Windows, GitHub CLI authentication is required only when the pinned private +executable must be downloaded. On Linux, build `client/otclient` locally first; +bootstrap requires that file and leaves it untouched, including with `-Force`. +No Linux binary is published or downloaded, and the local build has no pinned +hash. The script preserves SHA-256 verification for the Windows executable, +asset archive, `Tibia.dat`, and `Tibia.spr`. `-Force` reinstalls assets on both +platforms and the executable on Windows only. | File | Runtime path | | ---- | ------------ | -| OTClient | `client/otclient_gl_x64.exe` | +| OTClient (Windows) | `client/otclient_gl_x64.exe` | +| OTClient (Linux local build) | `client/otclient` | | DAT | `client/data/things/860/Tibia.dat` | | SPR | `client/data/things/860/Tibia.spr` | -Launch `client/launch-angelion-redemption.cmd`. The isolated profile is -`angelion-redemption`. +On Windows, launch `client/launch-angelion-redemption.cmd`. On Linux, run +`(cd client && ./otclient)` from a POSIX shell, or `Push-Location client; +./otclient; Pop-Location` in PowerShell. The isolated profile remains +`angelion-redemption`. Bootstrap does not validate native build dependencies, +executable permissions, display/input behavior, or in-game compatibility. ## Compatibility contract @@ -43,9 +51,9 @@ feature boundaries in `client/modules/game_features/features.lua`. ## Asset boundary Client asset auto-installation is disabled. `scripts/bootstrap-client.ps1` is -the only supported runtime source; the client's auto-install documentation +the supported asset installer (and Windows runtime source); the client's auto-install documentation describes an upstream capability, not project behavior. Never commit `Tibia.dat`, `Tibia.spr`, OTClient executables, logs, screenshots, -or minimap caches. When changing the runtime, update the durable private release -asset, expected hashes, and this documentation together. +or minimap caches. When changing the pinned Windows runtime or assets, update +the durable private release asset, expected hashes, and this documentation together. diff --git a/scripts/bootstrap-client.ps1 b/scripts/bootstrap-client.ps1 index f143952d..617e21a6 100644 --- a/scripts/bootstrap-client.ps1 +++ b/scripts/bootstrap-client.ps1 @@ -1,3 +1,5 @@ +#Requires -Version 7.0 + param( [switch]$Force ) @@ -7,7 +9,7 @@ $ErrorActionPreference = "Stop" $projectRoot = Split-Path -Parent $PSScriptRoot $clientRoot = Join-Path $projectRoot "client" $thingsRoot = Join-Path $clientRoot "data\things\860" -$executable = Join-Path $clientRoot "otclient_gl_x64.exe" +$executable = Join-Path $clientRoot $(if ($IsLinux) { "otclient" } else { "otclient_gl_x64.exe" }) $tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) "angelion-client-$PID" $expectedExecutableHash = "3054ec603454cc71536851da979d11a02546f0348473b1f2a931ae4c10bf6b55" @@ -29,10 +31,17 @@ if (-not (Test-Path -LiteralPath $clientRoot)) { throw "Client source directory not found: $clientRoot" } +if (-not $IsWindows -and -not $IsLinux) { + throw "Client bootstrap supports Windows and Linux only." +} +if ($IsLinux -and -not (Test-Path -LiteralPath $executable -PathType Leaf)) { + throw "Build the Linux client at $executable first. Bootstrap installs assets, not a Linux executable." +} + New-Item -ItemType Directory -Path $tempRoot | Out-Null try { - if ($Force -or -not (Test-ExpectedHash -Path $executable -Expected $expectedExecutableHash)) { + if ($IsWindows -and ($Force -or -not (Test-ExpectedHash -Path $executable -Expected $expectedExecutableHash))) { if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { throw "GitHub CLI is required to access the private client runtime release." } diff --git a/scripts/playerbot-gameplay/runtime.ps1 b/scripts/playerbot-gameplay/runtime.ps1 index 34f547a8..8e32f86e 100644 --- a/scripts/playerbot-gameplay/runtime.ps1 +++ b/scripts/playerbot-gameplay/runtime.ps1 @@ -336,6 +336,8 @@ function Save-ScenarioFailureArtifacts { $directory = Join-Path (Join-Path $FailureArtifactsPath $scenarioRunId) $safeName $collectionErrors = [System.Collections.Generic.List[string]]::new() try { + # .NET file APIs use the process directory, not PowerShell's current location. + $directory = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($directory) [void][System.IO.Directory]::CreateDirectory($directory) } catch { diff --git a/scripts/test-knight-spell-contract.ps1 b/scripts/test-knight-spell-contract.ps1 index 2a60c96b..ca5bcc43 100644 --- a/scripts/test-knight-spell-contract.ps1 +++ b/scripts/test-knight-spell-contract.ps1 @@ -1,3 +1,5 @@ +#Requires -Version 7.0 + param() $ErrorActionPreference = "Stop" diff --git a/scripts/test-playerbot-gameplay.ps1 b/scripts/test-playerbot-gameplay.ps1 index 35590648..18ed819f 100644 --- a/scripts/test-playerbot-gameplay.ps1 +++ b/scripts/test-playerbot-gameplay.ps1 @@ -1,3 +1,5 @@ +#Requires -Version 7.0 + <# .SYNOPSIS Runs disposable playerbot gameplay scenarios against the local Compose stack. @@ -159,12 +161,13 @@ if (-not $Focused) { $Depot = $SlottedLoot = $SellLoot = $MainlandLoop = $SpellTraining = $SpellUse = $SpellCalibration = $MagicTraining = $true } -try { - & docker info *> $null - if ($LASTEXITCODE -ne 0) { - throw "Docker is not running." - } +# Fail before entering stack cleanup when the daemon is unavailable or access is denied. +& docker info *> $null +if ($LASTEXITCODE -ne 0) { + throw "Docker is unavailable or access is denied. Start Docker and use an explicitly authorized shell with daemon access; this script does not elevate privileges." +} +try { if ($SkipBuild) { & docker image inspect angelion-server:latest *> $null if ($LASTEXITCODE -ne 0) { From 5588a10c422b5717cce84a907d458b4846267ed8 Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:05:49 +0200 Subject: [PATCH 2/8] playerbot: correct depot ranking and goal transition contracts --- docs/playerbots.md | 5 +- server/src/playerbotcombat.cpp | 8 +- server/src/playerbotcontroller.cpp | 4 +- server/src/playerbotcontroller.h | 1 + server/src/playerbotdepotworkflow.cpp | 12 ++- server/src/playerbotdepotworkflow.h | 2 +- server/src/playerbotgoalplanner.h | 14 +++ server/src/playerbothuntregions.h | 12 +++ server/src/playerbotprogression.cpp | 25 ++++-- server/src/playerbotservice.cpp | 8 +- server/tests/playerbot_contracts.cpp | 90 ++++++++++++++++++++ server/tests/playerbot_contracts.sh | 9 ++ server/tests/playerbotdepotworkflow_test.cpp | 88 +++++++++++++++++++ 13 files changed, 258 insertions(+), 20 deletions(-) create mode 100644 server/tests/playerbot_contracts.cpp create mode 100644 server/tests/playerbot_contracts.sh create mode 100644 server/tests/playerbotdepotworkflow_test.cpp diff --git a/docs/playerbots.md b/docs/playerbots.md index a506504e..6ee03d1c 100644 --- a/docs/playerbots.md +++ b/docs/playerbots.md @@ -413,8 +413,9 @@ effective capacity above the reserve. Remaining top-level backpack loot is moved locker into that player's real depot chest. Depot locality and locker identity are independent: discovery enumerates all map-indexed lockers, then validates that each candidate still has its indexed -depot ID. It ranks all standable adjacent squares by weighted current-position -distance, locker ID, locker position, and approach position. It validates at +depot ID. It ranks standable adjacent squares by topology distance, then breaks +equal costs by floor-weighted distance from the current position, depot ID, +locker position, and approach position. It validates at most two routes per scheduler decision and resumes the sorted queue on the next decision. A moved player rebuilds the queue from a new anchor. Failed approaches are suppressed for two seconds, then become diff --git a/server/src/playerbotcombat.cpp b/server/src/playerbotcombat.cpp index 84388251..57dab3e9 100644 --- a/server/src/playerbotcombat.cpp +++ b/server/src/playerbotcombat.cpp @@ -881,11 +881,8 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit const double travelSeconds = routePlan.metrics.estimatedTravelSeconds > 0 ? routePlan.metrics.estimatedTravelSeconds : routePlan.metrics.steps * player.getStepDuration() / 1000.0; if (travelSeconds > 0) { - const double projectedPerSecond = routed.availableHuntSeconds > 0 ? - routed.projectedExperience / routed.availableHuntSeconds : 0; - routed.estimatedTravelSeconds = travelSeconds; - routed.availableHuntSeconds = std::max(0.0, duration - travelSeconds); - routed.projectedExperience = projectedPerSecond * routed.availableHuntSeconds; + routed.reconcileTravel(duration, travelSeconds, + projectedHuntStaminaMultiplier(player, std::max(0.0, duration - travelSeconds))); } if (routePlan.metrics.result != PlayerBotNavigationResult::Reached) { routed.suitable = false; @@ -978,6 +975,7 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit void PlayerBotController::beginHuntCycle(Player* player, const Position& position, const char* reason) { + pendingHuntCompletionReason.clear(); const uint32_t duration = static_cast(std::max(1, g_config.getNumber(ConfigManager::PLAYERBOT_HUNT_DURATION_SECONDS))); huntCoordinator.beginHuntCycle(std::chrono::steady_clock::now(), duration); huntRegionReached = false; diff --git a/server/src/playerbotcontroller.cpp b/server/src/playerbotcontroller.cpp index 43e3582a..e5133c02 100644 --- a/server/src/playerbotcontroller.cpp +++ b/server/src/playerbotcontroller.cpp @@ -1429,8 +1429,8 @@ void PlayerBotController::navigate() schedule(blockedRouteRetryInterval); return; } - const bool waitingForRecovery = turnRouter.cyclePhase() == CyclePhase::Service && - survivalRuntime.needsHealing(survivalSnapshot(*player)); + const bool waitingForRecovery = PlayerBotGoalPlanner::shouldContinueRecovery( + progressionRuntime.activeGoal(), survivalRuntime.needsHealing(survivalSnapshot(*player))); if (!accessingReward && !progressionRuntime.session().active(PlayerBotProgressionProcedure::OracleDeparture) && departurePlanner.required(departureSnapshot(*player)) && !waitingForRecovery) { if (selectTopLevelGoal(*player, currentPosition, "level_eight_interrupt")) { diff --git a/server/src/playerbotcontroller.h b/server/src/playerbotcontroller.h index 2a7c00c0..099bd5e8 100644 --- a/server/src/playerbotcontroller.h +++ b/server/src/playerbotcontroller.h @@ -474,6 +474,7 @@ class PlayerBotController : public std::enable_shared_from_this takeNextCandidate(); diff --git a/server/src/playerbotgoalplanner.h b/server/src/playerbotgoalplanner.h index 2776f7b3..f0f1f686 100644 --- a/server/src/playerbotgoalplanner.h +++ b/server/src/playerbotgoalplanner.h @@ -45,6 +45,20 @@ struct PlayerBotGoalPlannerSnapshot { class PlayerBotGoalPlanner { public: + static std::optional requiredGoal( + bool departureRequired, bool criticalHealing) + { + if (!departureRequired) return std::nullopt; + return criticalHealing ? PlayerBotGoalArbiter::TopLevelGoal::Service : + PlayerBotGoalArbiter::TopLevelGoal::Departure; + } + + static bool shouldContinueRecovery(PlayerBotGoalArbiter::TopLevelGoal activeGoal, bool needsHealing) + { + // Service includes the depot trip, not only the NPC transaction phase. + return activeGoal == PlayerBotGoalArbiter::TopLevelGoal::Service && needsHealing; + } + std::vector candidates(const PlayerBotGoalPlannerSnapshot& snapshot) const; PlayerBotGoalArbiter::GoalCandidate departureCandidate(const PlayerBotGoalPlannerSnapshot& snapshot) const; PlayerBotGoalArbiter::GoalCandidate serviceCandidate(const PlayerBotGoalPlannerSnapshot& snapshot) const; diff --git a/server/src/playerbothuntregions.h b/server/src/playerbothuntregions.h index 86e7bbf6..524bc883 100644 --- a/server/src/playerbothuntregions.h +++ b/server/src/playerbothuntregions.h @@ -14,6 +14,7 @@ #include "playerbotcombatprofile.h" #include "position.h" +#include #include #include #include @@ -113,6 +114,17 @@ struct PlayerBotHuntRegion { bool inChallengeBand = false; bool predictedLethal = false; std::string rejectionReason; + + void reconcileTravel(double durationSeconds, double travelSeconds, double staminaMultiplier) + { + estimatedTravelSeconds = travelSeconds; + availableHuntSeconds = std::max(0.0, durationSeconds - travelSeconds); + staminaExperienceMultiplier = staminaMultiplier; + projectedExperience = experiencePerMinute * observedCorrection * + staminaExperienceMultiplier * availableHuntSeconds / 60.0; + // The scorer uses projected experience directly; route danger is an acceptance gate. + score = projectedExperience; + } }; struct PlayerBotHuntRegionPerformance { diff --git a/server/src/playerbotprogression.cpp b/server/src/playerbotprogression.cpp index 4f6706ff..74f0063f 100644 --- a/server/src/playerbotprogression.cpp +++ b/server/src/playerbotprogression.cpp @@ -965,7 +965,25 @@ void PlayerBotController::beginPickupReward(Player& player, const Position& posi bool PlayerBotController::selectTopLevelGoal(Player& player, const Position& position, const char* decisionReason) { - if (departurePlanner.required(departureSnapshot(player))) { + std::string completedHuntReason; + completedHuntReason.swap(pendingHuntCompletionReason); + // Carry the hunt outcome across depot work, but never relabel an explicit interruption. + if (!completedHuntReason.empty() && (std::strcmp(decisionReason, "service_complete") == 0 || + std::strcmp(decisionReason, "depot_deposit_complete") == 0)) { + decisionReason = completedHuntReason.c_str(); + } + const uint16_t potionItemId = recoveryPotionItemId(player.getVocationId()); + const uint32_t potionCount = inventoryPolicy.inventoryItemCount(player, potionItemId); + const uint32_t missingPotions = potionCount <= huntPotionReturnThreshold ? + huntPotionRestockTarget - potionCount : 0; + const bool criticalHealing = survivalRuntime.needsHealing(survivalSnapshot(player)) && missingPotions != 0; + const auto requiredGoal = PlayerBotGoalPlanner::requiredGoal( + departurePlanner.required(departureSnapshot(player)), criticalHealing); + if (requiredGoal == TopLevelGoal::Service) { + beginService(&player, position, "healing_supply_missing"); + return true; + } + if (requiredGoal == TopLevelGoal::Departure) { return forceOracleDeparture(player, position, decisionReason); } refreshItemValues(); @@ -998,13 +1016,8 @@ bool PlayerBotController::selectTopLevelGoal(Player& player, const Position& pos const char* magicTrainingReason = magicTrainingCoolingDown ? "cooldown" : survivalRuntime.magicTrainingReason(survivalSnapshot(player)); const bool sellLootCoolingDown = progressionRuntime.isCoolingDown(TopLevelGoal::SellLoot, now); - const uint16_t potionItemId = recoveryPotionItemId(player.getVocationId()); - const uint32_t potionCount = inventoryPolicy.inventoryItemCount(player, potionItemId); - const uint32_t missingPotions = potionCount <= huntPotionReturnThreshold ? - huntPotionRestockTarget - potionCount : 0; const uint32_t sellable = saleableItemCount(player); const bool lowCapacity = inventoryPolicy.huntFreeCapacity(player) < returnCapacityThreshold; - const bool criticalHealing = survivalRuntime.needsHealing(survivalSnapshot(player)) && missingPotions != 0; const PlayerBotGoalPlannerSnapshot snapshot{ departurePlanner.required(departureSnapshot(player)), departureEligible, departureFound, player.getVocation()->getId() != 0, player.getLevel() < oracleMinimumLevel, diff --git a/server/src/playerbotservice.cpp b/server/src/playerbotservice.cpp index eec4a35a..f0f2d174 100644 --- a/server/src/playerbotservice.cpp +++ b/server/src/playerbotservice.cpp @@ -485,6 +485,7 @@ void PlayerBotController::setCyclePhase(CyclePhase phase, const Position& positi void PlayerBotController::beginReturn(Player* player, const Position& position, const char* reason) { + pendingHuntCompletionReason.clear(); const auto traversalTarget = huntCoordinator.traversalTarget(); const uint32_t previousTarget = traversalTarget ? traversalTarget->id : 0; g_game.playerCancelAttackAndFollow(playerId); @@ -548,6 +549,7 @@ void PlayerBotController::finishHuntAndReturn(Player* player, const Position& po "\"decision_id\":" + std::to_string(progressionRuntime.decisionId()) + ",\"goal\":\"hunt\",\"result\":\"success\",\"reason\":" + jsonString(reason)); beginReturn(player, position, reason); + pendingHuntCompletionReason = reason; } void PlayerBotController::refreshItemValues() @@ -1339,6 +1341,10 @@ bool PlayerBotController::pauseDepotFixtureForRestart(Player& player, DepotResta if (!fixtureDriver.depotRestartObservation(player, checkpoint).pause) { return false; } + if (checkpoint == DepotRestartCheckpoint::Depart) { + player.closeContainer(depotChestContainerId); + player.closeContainer(depotLockerContainerId); + } const char* phase = checkpoint == DepotRestartCheckpoint::Approach ? "approach" : checkpoint == DepotRestartCheckpoint::Locker ? "locker" : checkpoint == DepotRestartCheckpoint::Chest ? "chest" : @@ -1534,6 +1540,7 @@ void PlayerBotController::processDeposit(Player* player, const Position& current fields << "\"action\":\"deposit\",\"result\":\"complete\",\"depot_id\":" << command.snapshot.selected.depotId << ",\"container_id\":" << static_cast(depotChestContainerId) << ",\"cycle\":" << huntCoordinator.completedHuntCycles(); emit("action_result", currentPosition, fields.str()); + if (pauseDepotFixtureForRestart(*player, DepotRestartCheckpoint::Depart, currentPosition)) return; Container* chest = player->getContainerByID(depotChestContainerId); bool selectedAfterDeposit = false; if (!fixtureDepot.synthetic && chest) { @@ -1571,7 +1578,6 @@ void PlayerBotController::processDeposit(Player* player, const Position& current } player->closeContainer(depotChestContainerId); player->closeContainer(depotLockerContainerId); - if (pauseDepotFixtureForRestart(*player, DepotRestartCheckpoint::Depart, currentPosition)) return; if (selectedAfterDeposit) { schedule(SCHEDULER_MINTICKS); return; diff --git a/server/tests/playerbot_contracts.cpp b/server/tests/playerbot_contracts.cpp new file mode 100644 index 00000000..15186b66 --- /dev/null +++ b/server/tests/playerbot_contracts.cpp @@ -0,0 +1,90 @@ +// Standalone pure-contract regression: see playerbot_contracts.sh. +#include +#include +#include +#include + +#include "playerbotgoalplanner.h" +#include "playerbothuntregions.h" +#include "playerbotturnrouter.h" + +namespace { +void projection() +{ + PlayerBotHuntRegion region; + region.experiencePerMinute = 3; + region.projectedExperience = region.score = 64.62; + region.availableHuntSeconds = 861.6; + region.routeDangerCost = 17; + region.reconcileTravel(900, 23.6, 1.5); + assert(std::abs(region.projectedExperience - 65.73) < 1e-9); + assert(region.score == region.projectedExperience); + assert(region.routeDangerCost == 17); + region.reconcileTravel(300, 23.6, 1); + assert(std::abs(region.score - 13.82) < 1e-9); + region.reconcileTravel(900, 23.6, 1); + assert(std::abs(region.score - 43.82) < 1e-9); + + // A changed horizon needs a fresh weighted stamina multiplier, not the old XP/s. + region.observedCorrection = 0.8; + region.reconcileTravel(900, 100, 1 + 0.5 * 180 / 800); + assert(std::abs(region.score - 35.6) < 1e-9); + assert(region.staminaExperienceMultiplier == 1.1125); + region.reconcileTravel(900, 1000, 1); + assert(region.availableHuntSeconds == 0 && region.score == 0); + region.reconcileTravel(900, 0, 0.5); + assert(std::abs(region.score - 18) < 1e-9); + region.reconcileTravel(900, 0, 0); + assert(region.score == 0); +} + +void oracleRecovery() +{ + using Goal = PlayerBotGoalArbiter::TopLevelGoal; + // Only missing emergency supplies may defer mandatory departure. There is + // no optional-reward/learning candidate search on either mandatory branch. + assert(PlayerBotGoalPlanner::requiredGoal(true, true) == Goal::Service); + assert(PlayerBotGoalPlanner::requiredGoal(true, false) == Goal::Departure); + assert(!PlayerBotGoalPlanner::requiredGoal(false, true)); + assert(!PlayerBotGoalPlanner::requiredGoal(false, false)); + + // Startup selects Service once. Subsequent turns must execute the existing + // depot/service route rather than re-enter goal selection and reset it. + PlayerBotTurnRouter router; + Goal activeGoal = *PlayerBotGoalPlanner::requiredGoal(true, true); + const PlayerBotCyclePhase phases[] = {PlayerBotCyclePhase::ReturnToDepot, + PlayerBotCyclePhase::DepositLoot, + PlayerBotCyclePhase::Service}; + const PlayerBotTurnCommand commands[] = {PlayerBotTurnCommand::ReturnToDepot, + PlayerBotTurnCommand::DepositLoot, + PlayerBotTurnCommand::Service}; + for (size_t i = 0; i < 3; ++i) { + router.setCyclePhase(phases[i]); + for (int turn = 0; turn < 3; ++turn) { + assert(PlayerBotGoalPlanner::shouldContinueRecovery(activeGoal, true)); + assert(router.route({}) == commands[i]); + assert(router.cyclePhase() == phases[i]); + } + } + // Supplied but still hurt: normal healing keeps its existing turn priority. + assert(PlayerBotGoalPlanner::shouldContinueRecovery(activeGoal, true)); + // Recovered, or selecting a goal at service completion: mandatory departure + // resumes before any optional candidate search can run. + assert(!PlayerBotGoalPlanner::shouldContinueRecovery(activeGoal, false)); + activeGoal = *PlayerBotGoalPlanner::requiredGoal(true, false); + assert(activeGoal == Goal::Departure); + for (Goal optional : {Goal::PickupReward, Goal::LearnSpell, Goal::BuyEquipment, + Goal::MagicTraining, Goal::SellLoot, Goal::Hunt}) { + assert(!PlayerBotGoalPlanner::shouldContinueRecovery(optional, true)); + assert(PlayerBotGoalPlanner::requiredGoal(true, false) == Goal::Departure); + } +} + +} + +int main() +{ + projection(); + oracleRecovery(); + std::cout << "playerbot contracts passed\n"; +} diff --git a/server/tests/playerbot_contracts.sh b/server/tests/playerbot_contracts.sh new file mode 100644 index 00000000..4fe16319 --- /dev/null +++ b/server/tests/playerbot_contracts.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# Pure C++ checks; no server process, database, Docker, or generated repository files. +set -eu +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +build=$(mktemp -d) +trap 'rm -rf "$build"' EXIT HUP INT TERM +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -I"$root/src" \ + "$root/tests/playerbot_contracts.cpp" -o "$build/playerbot_contracts" +"$build/playerbot_contracts" diff --git a/server/tests/playerbotdepotworkflow_test.cpp b/server/tests/playerbotdepotworkflow_test.cpp new file mode 100644 index 00000000..30b7c61d --- /dev/null +++ b/server/tests/playerbotdepotworkflow_test.cpp @@ -0,0 +1,88 @@ +/* Run from the repository root: +c++ -std=c++17 -Iserver/src $(pkg-config --cflags luajit) \ + server/tests/playerbotdepotworkflow_test.cpp server/src/playerbotdepotworkflow.cpp \ + server/src/playerbotdepotsession.cpp -o /tmp/playerbotdepotworkflow_test && /tmp/playerbotdepotworkflow_test +*/ +#include +#include +#include +#include +#include "playerbotdepotworkflow.h" +#include "playerbotnavigation.h" + +#include + +namespace { +const Position origin(32345, 32225, 7); +const PlayerBotDepotCandidate nearest{2, 2591, {32352, 32225, 7}, {32352, 32226, 7}, 0}; +const PlayerBotDepotCandidate upstairs{2, 2591, {32344, 32225, 6}, {32344, 32226, 6}, 0}; + +PlayerBotDepotCommand scan(PlayerBotDepotWorkflow& workflow, std::vector candidates) +{ + PlayerBotDepotObservation observation; + observation.currentPosition = origin; + observation.scan.observed = true; + observation.scan.candidates = std::move(candidates); + return workflow.advance(observation, 2, 4, std::chrono::seconds(2)); +} + +void expectFirst(std::vector candidates, const PlayerBotDepotCandidate& expected) +{ + for (int order = 0; order < 2; ++order) { + PlayerBotDepotWorkflow workflow; + const auto command = scan(workflow, candidates); + assert(command.type == PlayerBotDepotCommandType::ValidateRoute); + assert(!command.snapshot.hasSelectedDepot); + assert(command.snapshot.routeCandidate.depotId == expected.depotId); + assert(command.snapshot.routeCandidate.lockerPosition == expected.lockerPosition); + assert(command.snapshot.routeCandidate.approachPosition == expected.approachPosition); + std::reverse(candidates.begin(), candidates.end()); + } +} +} + +int main() +{ + // Actual real_depot origin and endpoints: the upstairs route took 16 steps, + // but both topology costs were zero. The old floor-first tie chose upstairs. + assert(playerBotNavigationDistance(origin, nearest.approachPosition) == 8); + assert(playerBotNavigationDistance(origin, upstairs.approachPosition) == 22); + expectFirst({upstairs, nearest}, nearest); + + auto cheaperTopology = upstairs; + auto closerSpatially = nearest; + cheaperTopology.distance = 2; + closerSpatially.distance = 3; + expectFirst({closerSpatially, cheaperTopology}, cheaperTopology); + + auto lowerId = nearest; + lowerId.depotId = 1; + expectFirst({nearest, lowerId}, lowerId); + auto lowerLocker = nearest; + lowerLocker.lockerPosition.x = 32351; + expectFirst({nearest, lowerLocker}, lowerLocker); + auto lowerApproach = nearest; + lowerApproach.approachPosition = Position(32353, 32225, 7); + expectFirst({nearest, lowerApproach}, lowerApproach); + + // Fallback is revalidated rather than selected directly when all + // candidates are unsafe. + PlayerBotDepotWorkflow workflow; + scan(workflow, {upstairs, nearest}); + PlayerBotDepotObservation observation; + observation.currentPosition = origin; + observation.routeResult = PlayerBotDepotRouteResult::Unsafe; + observation.dangerCost = 10; + auto command = workflow.advance(observation, 2, 4, std::chrono::seconds(2)); + assert(command.snapshot.routeCandidate.lockerPosition == upstairs.lockerPosition); + observation.dangerCost = 20; + command = workflow.advance(observation, 2, 4, std::chrono::seconds(2)); + assert(command.type == PlayerBotDepotCommandType::ValidateRoute); + assert(command.snapshot.validatingRiskFallback); + assert(command.snapshot.routeCandidate.lockerPosition == nearest.lockerPosition); + observation.routeResult = PlayerBotDepotRouteResult::Reached; + command = workflow.advance(observation, 2, 4, std::chrono::seconds(2)); + assert(command.type == PlayerBotDepotCommandType::Navigate); + assert(command.telemetry.riskFallback); + std::cout << "depot workflow regression tests passed\n"; +} From de5c49ff735529567059a76a9623aa247fb677c4 Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:05:49 +0200 Subject: [PATCH 3/8] testing: correct gameplay fixtures and restart evidence --- docs/linux-validation.md | 61 ++++++ docs/testing.md | 45 +++++ .../assertions-magic-training.ps1 | 30 ++- .../assertions-navigation.ps1 | 7 +- .../assertions-readiness.ps1 | 24 ++- scripts/playerbot-gameplay/log-parsing.ps1 | 13 +- scripts/playerbot-gameplay/runtime.ps1 | 2 + .../scenarios-combat-loot.ps1 | 3 +- .../playerbot-gameplay/scenarios-service.ps1 | 14 +- .../playerbot-gameplay/scenarios-spells.ps1 | 3 +- scripts/test-playerbot-depot-fixture.lua | 48 +++++ scripts/test-playerbot-fixture-isolation.lua | 179 ++++++++++++++++++ scripts/test-playerbot-log-parsing.ps1 | 45 +++++ ...st-playerbot-magic-training-assertions.ps1 | 65 +++++++ .../test-playerbot-navigation-assertions.ps1 | 131 +++++++++++++ .../test-playerbot-readiness-assertions.ps1 | 45 +++++ scripts/test-playerbot-scenario-isolation.ps1 | 25 +++ .../playerbot-gameplay/includes/login.inc | 39 ++++ .../playerbot-gameplay/includes/verifiers.inc | 6 +- 19 files changed, 759 insertions(+), 26 deletions(-) create mode 100644 docs/linux-validation.md create mode 100644 scripts/test-playerbot-depot-fixture.lua create mode 100644 scripts/test-playerbot-fixture-isolation.lua create mode 100644 scripts/test-playerbot-log-parsing.ps1 create mode 100644 scripts/test-playerbot-magic-training-assertions.ps1 create mode 100644 scripts/test-playerbot-navigation-assertions.ps1 create mode 100644 scripts/test-playerbot-readiness-assertions.ps1 create mode 100644 scripts/test-playerbot-scenario-isolation.ps1 diff --git a/docs/linux-validation.md b/docs/linux-validation.md new file mode 100644 index 00000000..9145d3d3 --- /dev/null +++ b/docs/linux-validation.md @@ -0,0 +1,61 @@ +# Linux validation baseline + +This records the Linux development migration, not a claim that every gameplay +scenario passes. Commands and prerequisites are in [testing.md](testing.md) and +[client-runtime.md](client-runtime.md). + +## Verified scope + +- PowerShell 7 runs the shared bootstrap and gameplay driver on Linux. +- Bootstrap preserves the locally built client and checks the pinned asset hashes. +- The server builds in Docker. Fresh database health, bot provisioning, online + lifecycle telemetry, and local ports 7171/7172 passed the startup check. +- Pure C++ contracts and the depot workflow regression passed with real server + headers in the Docker builder. +- The client was built and launched locally. This is not full client compatibility + coverage; in-game testing remains necessary. Client fixes remain separate from + the tooling work. + +The last full 87-scenario run reported **75 passed, 8 failed assertions, and +4 timeouts**. Subsequent targeted runs validate corrections; they do not replace +a fresh full-suite result. The 22-case follow-up passed 20 cases initially. +After fixture corrections, all five depot restart checkpoints and arbitration +passed a seven-case rerun; `real_depot` then passed its final one-case rerun. +Thus all 22 selected cases have passing follow-up evidence, across separate runs. +The selection includes all five full-navigation cases, all three corpse cases, +restart log consumers, and the affected depot/service/progression scenarios. + +## Remaining work outside portability + +Do not skip these scenarios silently or loosen their assertions to make the +migration green. Reproduce them independently before changing gameplay policy. + +| Scenario | Observed problem / next decision | +| --- | --- | +| `hunt_area_arrival` | The selected destination is reachable, but patrol safety rejects the later route. Define bounded safe-region fallback without relaxing danger limits. | +| `combat_readiness_low_wealth` | Sale cargo goes to the depot before service; the fixture expects 56 gp including proceeds but observes a 50 gp withdrawal. Isolate sale/service inputs. | +| `magic_training_progression` | Equipment/service work consumes the funds intended for spell learning. Isolate the learning/affordability contract. | +| `magic_training_post_hunt`, `magic_training_post_hunt_no_overflow` | The controller visits the depot before arbitration. Tests expect immediate post-hunt `Idle` arbitration; the detour also changes mana overflow. Decide intended hunt-end behavior first. | +| `death` | The third fixture kill can precede service discovery after recovery. Replace the fixed delay with a bounded milestone wait. | +| `real_depot` Lua verifier | After deposit completion, optional liquidation can withdraw a tool before the asynchronous inventory verifier runs. Separate deposit-boundary inventory checks from post-departure activity. A passing PowerShell scenario alone does not resolve this verifier race. | + +The ordinary `corpse` scenario has both passing and failing traces: a defensive +attacker classification previously prevented normal looting. The latest targeted +run passed all three corpse cases; this does not establish timing stability. + +## Test isolation follow-up + +Keep PowerShell as the cross-platform runner. Refactor by behavior, not language: + +1. Put priority, scoring, reserve arithmetic, and state-transition rules into + small controlled contract tests where practical. +2. Give engine-path fixtures explicit inventory, rewards, services, and cleanup. + Checkpoint flags must not leak into the next scenario. +3. Wait for observable milestones with deadlines, not wall-clock guesses. +4. Retain a small real-map smoke suite and selected integration routes. Exact + coordinates belong in those map-specific tests, not generic planner contracts. +5. Assert outcomes and safety guarantees; require an internal phase sequence only + when that sequence is an agreed behavior contract. + +Preserve exact ownership, persistence, protocol, safety, and timeout guarantees. +Use failures to distinguish broken fixtures from actual behavior regressions. diff --git a/docs/testing.md b/docs/testing.md index e22a05e7..8274dbb4 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,5 +1,50 @@ # Testing +See [Linux validation baseline](linux-validation.md) for migration evidence, +known gameplay failures, and the test-isolation follow-up. + +Use PowerShell 7+ (`pwsh`) on Windows or Linux. The same scripts, scenario +catalog, and assertions apply on both platforms. Linux needs Docker Engine and +Compose v2; bootstrap also requires a locally built `client/otclient`. +Commands containing `$env:` or `Remove-Item` below run inside PowerShell, not Bash. +The `pwsh -File ...` commands work from either shell at the repository root. + +If Docker requires elevation on your Linux setup, run Docker commands and the +gameplay driver from an explicitly authorized elevated shell; the scripts do not +elevate themselves or require changes to socket permissions or group membership. +Elevated test runs can leave root-owned failure artifacts. The suite resets the +disposable `angelion` database and removes its stack unless `-KeepStack` is set; +do not run it alongside a normal development session. A failed daemon-access preflight does not attempt stack cleanup. + +The spell contract check needs neither Docker nor elevation: + +```powershell +pwsh -File scripts/test-knight-spell-contract.ps1 +``` + +Run the non-Docker telemetry assertion regressions: + +```powershell +pwsh -File scripts/test-playerbot-navigation-assertions.ps1 +pwsh -File scripts/test-playerbot-readiness-assertions.ps1 +pwsh -File scripts/test-playerbot-log-parsing.ps1 +pwsh -File scripts/test-playerbot-scenario-isolation.ps1 +pwsh -File scripts/test-playerbot-magic-training-assertions.ps1 +``` + +On Linux, pure contract checks require a C++17 compiler; fixture-isolation checks +require Lua or LuaJIT. Run these from the repository root: + +```sh +sh server/tests/playerbot_contracts.sh +lua scripts/test-playerbot-fixture-isolation.lua +lua scripts/test-playerbot-depot-fixture.lua +``` + +`server/tests/playerbotdepotworkflow_test.cpp` includes its compile command and +requires the server development headers. These checks supplement, not replace, +live gameplay validation. + ## Server smoke test For server, infrastructure, or cross-stack changes, run: diff --git a/scripts/playerbot-gameplay/assertions-magic-training.ps1 b/scripts/playerbot-gameplay/assertions-magic-training.ps1 index c8f0781a..c6f6ae73 100644 --- a/scripts/playerbot-gameplay/assertions-magic-training.ps1 +++ b/scripts/playerbot-gameplay/assertions-magic-training.ps1 @@ -35,15 +35,33 @@ function Assert-MagicTrainingEvents { } if ($Mode -eq "restart") { $online = @($events | Where-Object { $_.event -eq "lifecycle" -and $_.status -eq "online" }) - $forecast = @($events | Where-Object { - $_.event -eq "magic_training_fixture" -and $_.case -eq "active_default" -and $_.active -and $_.remaining -eq $_.interval + # The startup window ends at selection; later decisions may legitimately cast. + $selection = @($events | Where-Object { + $_.event -eq "goal_selection" -and $_.decision_id -eq 1 -and $_.decision_reason -eq "startup" }) - $guard = @($candidates | Where-Object { - -not $_.feasible -and $_.reason -eq "next_tick_not_overflow" -and $_.mana_tick_remaining -gt 0 -and + $window = @( + foreach ($event in $events) { + $event + if ($event.event -eq "goal_selection" -and $event.decision_id -eq 1 -and $event.decision_reason -eq "startup") { break } + } + ) + $forecast = @($window | Where-Object { + $_.event -eq "magic_training_fixture" -and $_.source -eq "authoritative_forecast" -and + $_.case -eq "active_default" -and $_.active -eq $true -and $_.remaining -gt 0 -and $_.remaining -eq $_.interval + }) + $candidates = @($window | Where-Object { $_.event -eq "goal_candidate" -and $_.goal -eq "magic_training" }) + $startup = @($candidates | Where-Object { $_.decision_reason -eq "startup" }) + $guard = @($candidates | Select-Object -First 1 | Where-Object { + $_.decision_id -eq 1 -and $_.decision_reason -eq "startup" -and $_.feasible -eq $false -and + $_.reason -in @("spell_cooldown", "next_tick_not_overflow") -and $_.mana_tick_remaining -gt 0 -and $_.mana_tick_remaining -le $_.mana_tick_interval -and - $_.mana + $_.mana_gain -le $_.mana_max + $null -ne $_.mana -and $null -ne $_.mana_gain -and $null -ne $_.mana_max -and + $_.mana_gain -gt 0 -and $_.mana + $_.mana_gain -le $_.mana_max }) - if ($online.Count -eq 1 -and $forecast.Count -eq 1 -and $guard.Count -ge 1 -and $actions.Count -eq 0) { return } + $actions = @($window | Where-Object { $_.event -eq "action_result" -and $_.action -eq "magic_training" }) + if ($online.Count -eq 1 -and $forecast.Count -eq 1 -and $candidates.Count -eq 1 -and + $startup.Count -eq 1 -and $guard.Count -eq 1 -and $actions.Count -eq 0 -and + $selection.Count -eq 1 -and $selection[0].to_goal -and $selection[0].to_goal -ne "magic_training") { return } throw "Restart did not recompute the fresh regeneration forecast from persisted player state. online=$($online.Count), forecast=$($forecast.Count), guard=$($guard.Count), actions=$($actions.Count)." } $reason = $Mode -eq "reserve" ? "no_audited_safe_spell" : $Mode -eq "pz" ? "regeneration_paused" : diff --git a/scripts/playerbot-gameplay/assertions-navigation.ps1 b/scripts/playerbot-gameplay/assertions-navigation.ps1 index b2187c83..d496ee4b 100644 --- a/scripts/playerbot-gameplay/assertions-navigation.ps1 +++ b/scripts/playerbot-gameplay/assertions-navigation.ps1 @@ -392,7 +392,8 @@ function Assert-SlottedLootEvents { $_.count -eq 1 -and ($_.carried_after + $_.bank_after) -gt ($_.carried_before + $_.bank_before) }) $localPlans = @($events | Where-Object { - $_.event -eq "sell_loot_plan" -and $_.result -eq "candidate" -and $_.item_id -eq 2398 -and $_.utility -lt 0 + $_.event -eq "sell_loot_plan" -and $_.result -eq "candidate" -and + $_.source_depot_id -eq 2 -and $_.manifest_batches -ge 1 -and $_.utility -gt 0 }) $deposits = @($events | Where-Object { $_.action -eq "deposit" -and $_.result -in @("success", "partial") -and $_.item_id -eq 2398 -and @@ -417,7 +418,7 @@ function Assert-SlottedLootEvents { throw "Slotted loot did not use the expected local disposition. requests=$($depositRequests.Count), deposits=$($deposits.Count), sales=$($sales.Count)/$expectedSales, sell_moves=$($sellMoves.Count), seller=$SellerAvailable, restarted=$Restarted." } if ($SellerAvailable -and $localPlans.Count -lt 1) { - throw "The local sale was still gated by global utility." + throw "The slotted loot sale lacked a candidate plan from depot 2 with at least one manifest batch and positive utility." } if ($protectedMoves.Count -ne 0 -or $reselectedService.Count -ne 0 -or $terminal.Count -ne 0) { throw "Slotted disposition did not preserve protected state or bounded service. protected=$($protectedMoves.Count), repeated=$($reselectedService.Count), terminal=$($terminal.Count), restarted=$Restarted." @@ -436,7 +437,7 @@ function Assert-MainlandLoopEvents { }) $realDepot = @($events | Where-Object { $_.event -eq "action_result" -and $_.action -eq "depot_discover" -and $_.result -eq "success" -and - $_.depot_id -eq 2 -and $_.locker_item_id -eq 2589 -and + $_.depot_id -eq 2 -and $_.locker_item_id -in @(2589, 2590, 2591, 2592) -and [Math]::Abs($_.locker.x - $_.approach.x) -le 1 -and [Math]::Abs($_.locker.y - $_.approach.y) -le 1 -and $_.locker.z -eq $_.approach.z }) diff --git a/scripts/playerbot-gameplay/assertions-readiness.ps1 b/scripts/playerbot-gameplay/assertions-readiness.ps1 index f1434e22..bb6552e6 100644 --- a/scripts/playerbot-gameplay/assertions-readiness.ps1 +++ b/scripts/playerbot-gameplay/assertions-readiness.ps1 @@ -52,19 +52,27 @@ function Assert-CombatReadinessEvents { if ($equip.Count -ne 1) { throw "Carried legal weapon was not equipped and verified." } } if ($Mode -eq "food_capacity") { - $capacity = @($latest.requirements | Where-Object { - $_.name -eq "free_capacity" -and $_.ready -and $_.current -lt $_.minimum -and - $_.reclaimable_food -ge 3200 -and $_.effective -ge $_.minimum + $initial = $readiness[0] + $capacity = @($initial.requirements | Where-Object { + $_.name -eq "free_capacity" -and $_.ready -and $_.current -eq 0 -and + $_.reclaimable_food -eq 3200 -and $_.effective -ge $_.minimum }) - $food = @($latest.requirements | Where-Object { - $_.name -eq "food" -and $_.count -eq 8 -and $_.reclaimable_weight -ge 3200 + $food = @($initial.requirements | Where-Object { + $_.name -eq "food" -and $_.count -eq 8 -and $_.reclaimable_weight -eq 3200 }) - $service = @($events | Where-Object { $_.selected_recovery -eq "service" }) + $service = @($events | Where-Object { $_.selected_recovery -eq "service" -or $_.action -eq "buy_meat" }) $hunts = @($events | Where-Object { $_.action -eq "hunt_cycle" -and $_.result -eq "started" }) - $eaten = @($events | Where-Object { $_.action -eq "eat" -and $_.result -eq "success" -and $_.item_id -eq 2696 }) - if ($capacity.Count -ne 1 -or $food.Count -ne 1 -or $service.Count -ne 0 -or $hunts.Count -lt 1 -or $eaten.Count -ne 6) { + $eaten = @($events | Where-Object { $_.action -eq "eat" }) + if ($capacity.Count -ne 1 -or $food.Count -ne 1 -or $service.Count -ne 0 -or $hunts.Count -lt 1 -or $eaten.Count -ne 8) { throw "Food weight did not remain reclaimable when physical capacity was exhausted." } + for ($i = 0; $i -lt 8; $i++) { + $eat = $eaten[$i] + if ($eat.event -ne "action_result" -or $eat.result -ne "success" -or $eat.item_id -ne 2696 -or + $eat.count -ne 1 -or $eat.inventory_count -ne (7 - $i) -or $eat.food_ticks -le 0) { + throw "Food consumption did not verify all eight initial cheeses in order." + } + } } if ($Mode -eq "retention") { $depositedUnknown = @($events | Where-Object { $_.action -eq "deposit" -and $_.item_id -eq 2050 }) diff --git a/scripts/playerbot-gameplay/log-parsing.ps1 b/scripts/playerbot-gameplay/log-parsing.ps1 index 8d4d4bce..b18b9987 100644 --- a/scripts/playerbot-gameplay/log-parsing.ps1 +++ b/scripts/playerbot-gameplay/log-parsing.ps1 @@ -29,8 +29,17 @@ function Get-LatestServerGenerationLogs { $lines = @($Logs -split "`r?`n") for ($index = $lines.Count - 1; $index -ge 0; --$index) { - if ($lines[$index] -match '"event":"lifecycle"' -and $lines[$index] -match '"status":"online"') { - return ($lines[$index..($lines.Count - 1)] -join "`n") + if ($lines[$index] -match '^The Forgotten Server - Version .+') { + # Startup decisions can precede online; recovery can emit online again. + $generationLogs = $lines[$index..($lines.Count - 1)] -join "`n" + $onlineBot = ConvertFrom-PlayerbotLogs -Logs $generationLogs | Where-Object { + $_.event -eq "lifecycle" -and $_.status -eq "online" + } | Select-Object -First 1 + if ($onlineBot) { + return $generationLogs + } + # Never fall back to a previous process while the newest one starts. + return "" } } return "" diff --git a/scripts/playerbot-gameplay/runtime.ps1 b/scripts/playerbot-gameplay/runtime.ps1 index 8e32f86e..8a2081f4 100644 --- a/scripts/playerbot-gameplay/runtime.ps1 +++ b/scripts/playerbot-gameplay/runtime.ps1 @@ -445,6 +445,8 @@ function Invoke-Scenario { $script:currentScenarioDeadline = [DateTime]::UtcNow.AddSeconds($currentWaitTimeoutSeconds) $startedAt = [DateTime]::UtcNow try { + # Checkpoint scenarios must opt in; a prior case must not pause this bot. + $env:PLAYERBOT_DEPOT_RESTART_PHASE = "" Invoke-TimedStep -Name $Name -Body $Body Add-ScenarioResult -Name $Name -Status "pass" } diff --git a/scripts/playerbot-gameplay/scenarios-combat-loot.ps1 b/scripts/playerbot-gameplay/scenarios-combat-loot.ps1 index e515ef68..eab8fd64 100644 --- a/scripts/playerbot-gameplay/scenarios-combat-loot.ps1 +++ b/scripts/playerbot-gameplay/scenarios-combat-loot.ps1 @@ -17,7 +17,8 @@ } Assert-CorpseDetourEvents -Logs $corpseLogs } - Invoke-Scenario -Name "corpse_inaccessible" -DefaultTimeoutSeconds 75 -Body { + # Allow server startup in addition to the asserted 70-second loot bound. + Invoke-Scenario -Name "corpse_inaccessible" -DefaultTimeoutSeconds 90 -Body { Invoke-Compose down --volumes --remove-orphans $env:PLAYERBOT_GAMEPLAY_MODE = "corpse_inaccessible" $env:PLAYERBOT_HUNT_DURATION_SECONDS = "900" diff --git a/scripts/playerbot-gameplay/scenarios-service.ps1 b/scripts/playerbot-gameplay/scenarios-service.ps1 index d427a6d3..51462db6 100644 --- a/scripts/playerbot-gameplay/scenarios-service.ps1 +++ b/scripts/playerbot-gameplay/scenarios-service.ps1 @@ -138,15 +138,23 @@ Invoke-Scenario -Name "real_depot" -DefaultTimeoutSeconds 240 -Body { Invoke-Compose down --volumes --remove-orphans $env:PLAYERBOT_GAMEPLAY_MODE = "depot" - $env:PLAYERBOT_DEPOT_RESTART_PHASE = "" + # Stop before optional liquidation can withdraw freshly deposited cargo. + $env:PLAYERBOT_DEPOT_RESTART_PHASE = "depart" $env:PLAYERBOT_DEPOT_MOVE_CASE = "normal" Invoke-Compose up --detach Invoke-DatabaseCommand -Query "INSERT INTO player_depotitems (player_id, sid, pid, itemtype, count, attributes) SELECT id, 9001, 2, 2684, 7, X'' FROM players WHERE name = 'Rook Tester'" - $firstCycleLogs = Wait-ForLog -Pattern '"action":"deposit","result":"complete"' + $firstCycleLogs = Wait-ForLog -Pattern '"action":"depot_restart_checkpoint","result":"paused","phase":"depart"' Assert-DepotEvents -Logs $firstCycleLogs -ExpectedDepositedCount 2 -ExpectedEquipmentDeposits 2 -ExpectedToolDeposits 4 Invoke-Compose stop server - Invoke-Compose up --detach server + # The checkpoint suppresses restart cargo in checkpoint-only cases. Re-arm + # normal second-cycle seeding only after this cycle is safely persisted. + Invoke-DatabaseCommand -Query "UPDATE player_storage JOIN players ON players.id = player_storage.player_id SET player_storage.value = 0 WHERE players.name = 'Bot One' AND player_storage.key = 50095 AND player_storage.value = 2" + $fixtureState = Invoke-DatabaseScalar -Query "SELECT value FROM player_storage JOIN players ON players.id = player_storage.player_id WHERE players.name = 'Bot One' AND player_storage.key = 50095" + if ($fixtureState -ne 0) { throw "Could not re-arm real_depot second-cycle cargo." } + $env:PLAYERBOT_DEPOT_RESTART_PHASE = "" + # Changed fixture environment creates a new container with fresh logs. + Invoke-Compose up --detach --force-recreate server $secondCycleLogs = Wait-ForLatestServerGenerationLog -Pattern '"action":"deposit","result":"complete"' Assert-DepotEvents -Logs $secondCycleLogs -ExpectedDepositedCount 1 -ExpectedEquipmentDeposits 0 $sentinelCount = Invoke-DatabaseScalar -Query "SELECT COALESCE(SUM(count), 0) FROM player_depotitems JOIN players ON players.id = player_depotitems.player_id WHERE players.name = 'Rook Tester' AND pid = 2 AND itemtype = 2684" diff --git a/scripts/playerbot-gameplay/scenarios-spells.ps1 b/scripts/playerbot-gameplay/scenarios-spells.ps1 index 5e68ad4b..c54799d8 100644 --- a/scripts/playerbot-gameplay/scenarios-spells.ps1 +++ b/scripts/playerbot-gameplay/scenarios-spells.ps1 @@ -162,7 +162,8 @@ Invoke-DatabaseCommand -Query "UPDATE players SET mana = $($cast.mana_after) WHERE name = 'Bot One'" Invoke-Compose up --detach server Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST MAGIC_TRAINING_RESTART_START' | Out-Null - $restartLogs = Wait-ForLatestServerGenerationLog -Pattern '"event":"goal_candidate".*"goal":"magic_training".*"reason":"next_tick_not_overflow"' + # Complete the startup decision before checking its guard and selected goal. + $restartLogs = Wait-ForLatestServerGenerationLog -Pattern '"event":"goal_selection".*"decision_id":1,"decision_reason":"startup"' Assert-MagicTrainingEvents -Logs $restartLogs -Mode "restart" } } diff --git a/scripts/test-playerbot-depot-fixture.lua b/scripts/test-playerbot-depot-fixture.lua new file mode 100644 index 00000000..af6fd61a --- /dev/null +++ b/scripts/test-playerbot-depot-fixture.lua @@ -0,0 +1,48 @@ +-- Run from the repository root: lua scripts/test-playerbot-depot-fixture.lua +Position = function(x, y, z) return {x = x, y = y, z = z} end +PlayerbotGameplayFixture = {} +dofile("server/tests/playerbot-gameplay/includes/constants.inc") +dofile("server/tests/playerbot-gameplay/includes/verifiers.inc") +local F = PlayerbotGameplayFixture +CONST_SLOT_ARMOR, CONST_SLOT_LEFT, CONST_SLOT_HEAD, CONST_SLOT_FEET = 4, 6, 1, 8 +ITEM_GOLD_COIN = 2148 +F.potionItemId = F.smallHealthPotionItemId +F.PlayerbotThaisLockerDepotId = function() return 2 end +local phase, state, stored, carried +os.getenv = function(key) + assert(key == "PLAYERBOT_DEPOT_RESTART_PHASE") + return phase +end +local equipment = {[4] = F.starterArmorId, [6] = F.seedWeaponId, [1] = 2461, [8] = 2643} +Player = function() + return { + isRemoved = function() return false end, + getDepotChest = function() return {getItemCountById = function(_, id) return stored[id] or 0 end} end, + getStorageValue = function(_, key) assert(key == F.depotFixtureStorage); return state end, + getItemCount = function(_, id) return carried[id] or 0 end, + getSlotItem = function(_, slot) return {getId = function() return equipment[slot] end} end, + } +end +local function setup(checkpoint, secondCycle) + phase, state = checkpoint, secondCycle and 2 or 0 + stored = {[F.depotLootItemId] = secondCycle and 3 or 2, [2148] = 1, + [2380] = 1, [F.starterWeaponId] = 1, [2120] = 2, [2554] = 2} + carried = {[F.depotLootItemId] = 2, [F.potionItemId] = 10, [2120] = 1, [2554] = 1} +end +setup("depart", false) +state = 2 -- checkpoint-only restart does not seed another cheese +F.verifyDepot(3, 0) +setup("", true) +F.verifyDepot(3, 0) -- two persisted cheeses plus one new cargo; no meat was seeded +stored[F.depotLootItemId] = 2 +assert(not pcall(F.verifyDepot, 3, 0), "second cycle accepted missing new cargo") +setup("", true) +stored[F.potionItemId] = 1 +assert(not pcall(F.verifyDepot, 3, 0), "accepted deposited potion reserve") +setup("", true) +carried[F.potionItemId] = 4 +assert(not pcall(F.verifyDepot, 3, 0), "accepted depleted potion reserve") +setup("", true) +carried[F.depotLootItemId] = 1 +assert(not pcall(F.verifyDepot, 3, 0), "accepted missing retained cheese") +print("PASS depot fixture: checkpoint/two-cycle counts, unseeded meat, potion and cheese protection") diff --git a/scripts/test-playerbot-fixture-isolation.lua b/scripts/test-playerbot-fixture-isolation.lua new file mode 100644 index 00000000..a24e290f --- /dev/null +++ b/scripts/test-playerbot-fixture-isolation.lua @@ -0,0 +1,179 @@ +-- Run from the repository root: lua scripts/test-playerbot-fixture-isolation.lua +-- Check mode boundaries first, then complete the two intended-rapier login paths. +local boundary = {} +PlayerbotGameplayFixture = { + botName = "Bot One", + economicRewardStorage = 50082, + pickupRewardStorage = 64120, + mainlandRewardStorage = 50076, + nestedRewardStorage = 50083, + selectHealthPotion = function() error(boundary) end, +} +CreatureEvent = function() return {register = function() end} end +local loginPath = "server/tests/playerbot-gameplay/includes/login.inc" +dofile(loginPath) +local selectedMode +os.getenv = function() return selectedMode end +local isolated = { + progression = true, readiness_low_wealth = true, arbitration = true, + magic_training_haste = true, magic_training_great_light = true, magic_training_light = true, + magic_training_refresh = true, magic_training_failed = true, magic_training_restart = true, + magic_training_hunt = true, magic_training_post_hunt = true, magic_training_post_hunt_no_overflow = true, +} +local function run(mode, storage, name, rejectWrite) + selectedMode = mode + local writes = 0 + local player = { + getName = function() return name or "Bot One" end, + getStorageValue = function(_, key) return storage[key] or -1 end, + setStorageValue = function(_, key, value) + local rapier = mode == "progression" or mode == "arbitration" + assert((key == 50082 or rapier and (key == 50076 or key == 50083)) and value == 1, + "isolation changed another reward") + writes = writes + 1 + if rejectWrite then return false end + storage[key] = value + return true + end, + } + local ok, result = pcall(PlayerbotGameplayFixture.login.onLogin, player) + return ok, result, writes +end + +local file = assert(io.open(loginPath)) +local source = file:read("*a") +file:close() +local modes = {cycle = true} +for mode in source:gmatch('mode == "([%w_]+)"') do modes[mode] = true end +local count = 0 +for mode in pairs(modes) do + local storage = {} + local ok, result, writes = run(mode, storage) + assert(not ok and result == boundary, mode .. " did not reach gameplay setup") + local rapier = mode == "progression" or mode == "arbitration" + assert(writes == (rapier and 3 or isolated[mode] and 1 or 0), mode .. " has incorrect isolation") + assert(storage[50076] == (rapier and 1 or nil) and storage[50083] == (rapier and 1 or nil), + mode .. " has incorrect competing reward storage") + assert(storage[64120] == nil, "intended pickup reward was claimed") + local humanOk, _, humanWrites = run(mode, {}, "Rook Tester") + assert(humanOk and humanWrites == 0, "isolation affected another player") + count = count + 1 +end +for mode, marker in pairs({progression = 64120, magic_training_restart = 50098}) do + local ok, result, writes = run(mode, {[marker] = 1, [50082] = 1, [50076] = 1, [50083] = 1}) + assert(not ok and result == boundary and writes == 0, "restart rewrote persisted isolation") + ok, result, writes = run(mode, {[marker] = 1}) + assert(not ok and tostring(result):find("did not persist", 1, true) and writes == 0, + "restart silently repaired missing isolation") +end +local ok, result = run("magic_training_light", {}, nil, true) +assert(not ok and tostring(result):find("could not suppress", 1, true), "failed storage write was ignored") +-- Use real constants and baseline/potion helpers; mock only player/world APIs. +Position = function(x, y, z) return {x = x, y = y, z = z} end +CONST_SLOT_ARMOR, CONST_SLOT_LEFT, CONST_SLOT_RIGHT, CONST_SLOT_FEET, CONST_SLOT_BACKPACK = 4, 6, 5, 8, 3 +ITEM_GOLD_COIN = 2148 +dofile("server/tests/playerbot-gameplay/includes/constants.inc") +dofile("server/tests/playerbot-gameplay/includes/helpers.inc") +local F = PlayerbotGameplayFixture +F.suppressNearbyMonsters = function() end +F.verifyPickupProgression = function() end +local function completeLogin(mode, storage, restarting) + selectedMode = mode + local inventory, writes, scheduled = {}, {}, 0 + local armor = {getId = function() return F.starterArmorId end} + local weapon = {getId = function() return restarting and F.pickupRewardId or F.starterWeaponId end} + local backpack = {addItem = function(_, id, quantity) + inventory[id] = (inventory[id] or 0) + quantity + return true + end} + local slots = {[CONST_SLOT_ARMOR] = armor, [CONST_SLOT_LEFT] = weapon, [CONST_SLOT_BACKPACK] = backpack} + local player = { + getName = function() return F.botName end, + getId = function() return 3 end, + getLevel = function() return 1 end, + getVocation = function() return {getId = function() return 0 end} end, + getPosition = function() return Position(32097, 32219, 7) end, + getSlotItem = function(_, slot) return slots[slot] end, + getItemCount = function(_, id) return inventory[id] or 0 end, + removeItem = function(_, id, quantity) + inventory[id] = (inventory[id] or 0) - quantity + return true + end, + addItem = backpack.addItem, + getStorageValue = function(_, key) return storage[key] or -1 end, + setStorageValue = function(_, key, value) + writes[key], storage[key] = value, value + return true + end, + } + addEvent = function(callback) + assert(callback == F.verifyPickupProgression, "unexpected scheduled verifier") + scheduled = scheduled + 1 + end + assert(F.login.onLogin(player), "login did not finish") + for _, key in ipairs({50076, 50082, 50083}) do + assert(storage[key] == 1, "competing reward remained unclaimed after setup") + assert(writes[key] == (not restarting and 1 or nil), "incorrect isolation write after setup") + end + assert(writes[64120] == nil and storage[64120] == (restarting and 1 or nil), + "setup changed intended reward eligibility") + assert(storage[56002] == nil, "setup disabled the unrelated doublet reward") + local freshProgression = mode == "progression" and not restarting + assert(scheduled == (freshProgression and 1 or 0), "setup changed progression verification") + assert(inventory[8704] == ((freshProgression or mode == "arbitration") and 10 or nil) and + inventory[2148] == (freshProgression and 100 or nil), "setup changed service reserves") +end +completeLogin("progression", {}, false) +completeLogin("arbitration", {}, false) +completeLogin("progression", {[64120] = 1, [50076] = 1, [50082] = 1, [50083] = 1}, true) +for _, missing in ipairs({50076, 50082, 50083}) do + local storage = {[64120] = 1, [50076] = 1, [50082] = 1, [50083] = 1} + storage[missing] = nil + local success, failure = pcall(completeLogin, "progression", storage, true) + assert(not success and tostring(failure):find("did not persist", 1, true), + "restart accepted missing competing reward isolation") +end +-- Exercise the real magic reserve setup, stopping before unrelated level/spell APIs. +Town = function() return {} end +Game = {getExperienceForLevel = function() error(boundary) end} +local function magicReserve(mode, restarting, initialCount) + selectedMode = mode + local vocation, potions, mutations = restarting and 4 or 0, initialCount, 0 + local storage = restarting and {[50098] = 1, [50082] = 1} or {} + local player = { + getName = function() return F.botName end, + getId = function() return 3 end, + getStorageValue = function(_, key) return storage[key] or -1 end, + setStorageValue = function(_, key, value) storage[key] = value; return true end, + getVocation = function() return {getId = function() return vocation end} end, + setVocation = function(_, id) vocation = id; return true end, + setTown = function() return true end, + getItemCount = function(_, id) assert(id == 7618); return potions end, + removeItem = function(_, id, quantity) + assert(id == 7618 and quantity == potions) + potions, mutations = 0, mutations + 1 + return true + end, + addItem = function(_, id, quantity) + assert(id == 7618 and quantity == 2, "wrong selected potion reserve") + potions, mutations = potions + quantity, mutations + 1 + return true + end, + } + local success, result = pcall(F.login.onLogin, player) + assert(restarting and success and result == true or not restarting and not success and result == boundary, + mode .. " did not reach the expected setup boundary") + local seed = not restarting and mode ~= "magic_training_service" and mode ~= "magic_training_progression" + assert(potions == (seed and 2 or initialCount), mode .. " changed the wrong reserve") + assert(seed or mutations == 0, "priority/restart fixture mutated the shared reserve") + assert(F.potionItemId == 7618, "magic fixture retained vocationless potion selection") +end +for mode in pairs(modes) do + if mode:sub(1, 14) == "magic_training" then + magicReserve(mode, false, 0) + magicReserve(mode, false, 7) + end +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 +print("PASS fixture isolation: " .. count .. " modes, rapier setup/restart, magic reserves/restart, other-player guard, failed writes") diff --git a/scripts/test-playerbot-log-parsing.ps1 b/scripts/test-playerbot-log-parsing.ps1 new file mode 100644 index 00000000..844c53ea --- /dev/null +++ b/scripts/test-playerbot-log-parsing.ps1 @@ -0,0 +1,45 @@ +#Requires -Version 7.0 +$ErrorActionPreference = 'Stop' +. $PSScriptRoot/playerbot-gameplay/log-parsing.ps1 + +function Assert-Generation { + param( + [string]$Name, + [string]$Logs, + [string]$Expected, + [int]$MinimumOnlineEvents = 0 + ) + + $script:serverPlayerbotEvents = @(ConvertFrom-PlayerbotLogs -Logs $Logs) + $script:minimumServerOnlineEvents = $MinimumOnlineEvents + $actual = Get-LatestServerGenerationLogs -Logs $Logs + if ($actual -cne $Expected) { + throw "${Name}: unexpected generation logs.`nExpected: [$Expected]`nActual: [$actual]" + } +} + +# Matches printServerVersion() in server/src/otserv.cpp, including Git builds. +$boundary = 'The Forgotten Server - Version 1.5' +$newBoundary = 'The Forgotten Server - Version angelion-base-v1.1-42-gabcdef' +$online = '{"component":"playerbot","bot":"Bot One","event":"lifecycle","status":"online"}' +$hunt = '{"component":"playerbot","bot":"Bot One","event":"goal_selection","goal":"hunt","reason":"pickup_progression"}' +$old = "$boundary`nold generation marker`n$online" +$new = "$newBoundary`n$hunt`n$online`nafter online" + +Assert-Generation 'startup hunt before online' $new $new 1 +Assert-Generation 'latest process only' "$old`n$new" $new 2 +Assert-Generation 'CRLF' ("$old`n$new" -replace "`n", "`r`n") $new 2 +Assert-Generation 'minimum online count not reached' "$old`n$new" '' 3 +Assert-Generation 'recovery online is not a process boundary' "$new`n$online" "$new`n$online" 2 +Assert-Generation 'partial newest startup despite earlier online count' "$old`n$online`n$newBoundary`n$hunt" '' 2 +Assert-Generation 'newest boundary only' "$old`n$newBoundary" '' 1 +Assert-Generation 'no online' "$newBoundary`n$hunt" '' +Assert-Generation 'missing boundary' "$hunt`n$online" '' 1 +Assert-Generation 'empty logs' '' '' +Assert-Generation 'non-playerbot online does not qualify' "$old`n$newBoundary`n$($online.Replace('playerbot', 'other'))" '' 1 +Assert-Generation 'offline does not qualify' "$old`n$newBoundary`n$($online.Replace('online', 'offline'))" '' 1 +Assert-Generation 'malformed online does not qualify' "$old`n$newBoundary`n$online garbage" '' 1 +$spacedOnline = '{ "status": "online", "event": "lifecycle", "component": "playerbot", "bot": "Bot One" }' +Assert-Generation 'JSON property order and whitespace' "$newBoundary`n$hunt`n$spacedOnline" "$newBoundary`n$hunt`n$spacedOnline" 1 + +Write-Host 'Playerbot log-parsing regressions passed.' diff --git a/scripts/test-playerbot-magic-training-assertions.ps1 b/scripts/test-playerbot-magic-training-assertions.ps1 new file mode 100644 index 00000000..e0522c91 --- /dev/null +++ b/scripts/test-playerbot-magic-training-assertions.ps1 @@ -0,0 +1,65 @@ +#Requires -Version 7.0 +$ErrorActionPreference = 'Stop' +. $PSScriptRoot/playerbot-gameplay/log-parsing.ps1 +. $PSScriptRoot/playerbot-gameplay/assertions-magic-training.ps1 + +function New-RestartFixture([string]$Reason = 'spell_cooldown') { + @( + @{ event = 'magic_training_fixture'; source = 'authoritative_forecast'; case = 'active_default'; active = $true; gain = 5; interval = 6000; remaining = 6000 } + @{ event = 'goal_candidate'; goal = 'magic_training'; decision_id = 1; decision_reason = 'startup'; feasible = $false; reason = $Reason; mana = 980; mana_gain = 5; mana_max = 1000; mana_tick_remaining = 6000; mana_tick_interval = 6000 } + @{ event = 'goal_selection'; decision_id = 1; decision_reason = 'startup'; to_goal = 'hunt' } + @{ event = 'lifecycle'; status = 'online' } + ) +} +function Test-RestartFixture([string]$Name, [array]$Events, [bool]$Reject = $false) { + $logs = ($Events | ForEach-Object { + $_.component = 'playerbot'; $_.bot = 'Bot One' + $_ | ConvertTo-Json -Compress + }) -join "`n" + try { Assert-MagicTrainingEvents -Logs $logs -Mode restart } + catch { if ($Reject) { return }; throw "${Name}: $_" } + if ($Reject) { throw "${Name}: invalid restart unexpectedly passed." } +} +foreach ($reason in @('spell_cooldown', 'next_tick_not_overflow')) { + Test-RestartFixture $reason (New-RestartFixture $reason) +} +$cast = @{ event = 'action_result'; action = 'magic_training'; result = 'requested' } +$events = @(New-RestartFixture) +Test-RestartFixture 'cast before initial decision' (@($events[0], $cast) + $events[1..3]) $true +Test-RestartFixture 'cast between candidate and selection' ($events[0..1] + $cast + $events[2..3]) $true +$later = @{ event = 'goal_selection'; decision_id = 2; decision_reason = 'hunt_deadline'; to_goal = 'magic_training' } +Test-RestartFixture 'later cast outside startup window' ($events + $later + $cast) +Test-RestartFixture 'duplicate startup candidate' ($events[0..1] + $events[1] + $events[2..3]) $true +Test-RestartFixture 'missing forecast fixture' $events[1..3] $true +Test-RestartFixture 'forecast after startup selection' @($events[1], $events[2], $events[0], $events[3]) $true +Test-RestartFixture 'missing online' $events[0..2] $true +Test-RestartFixture 'missing selection' ($events[0..1] + $events[3]) $true +Test-RestartFixture 'selection before candidate' @($events[0], $events[2], $events[1], $events[3]) $true +Test-RestartFixture 'duplicate startup selection' ($events + $events[2]) $true +foreach ($change in @(@{ to_goal = 'magic_training' }, @{ to_goal = '' }, @{ decision_id = 2 }, @{ decision_reason = 'hunt_deadline' })) { + $events = @(New-RestartFixture) + foreach ($key in $change.Keys) { $events[2][$key] = $change[$key] } + Test-RestartFixture "invalid selection $($change.Keys)" $events $true +} +foreach ($change in @( + @{ mana = 1000 }, @{ feasible = $true }, @{ reason = 'cooldown' }, + @{ decision_reason = 'magic_training_complete' }, @{ decision_id = 2 }, + @{ mana_tick_remaining = 0 }, @{ mana_tick_remaining = 6001 } +)) { + $events = @(New-RestartFixture) + foreach ($key in $change.Keys) { $events[1][$key] = $change[$key] } + Test-RestartFixture "invalid candidate $($change.Keys)" $events $true +} +foreach ($field in @('mana', 'mana_gain', 'mana_max', 'mana_tick_remaining', 'mana_tick_interval', 'feasible')) { + $events = @(New-RestartFixture); $events[1].Remove($field) + Test-RestartFixture "missing $field" $events $true +} +foreach ($change in @(@{ active = $false }, @{ remaining = 0 }, @{ remaining = 5999 }, @{ source = 'other' })) { + $events = @(New-RestartFixture) + foreach ($key in $change.Keys) { $events[0][$key] = $change[$key] } + Test-RestartFixture "invalid forecast $($change.Keys)" $events $true +} +$events = @(New-RestartFixture) +$earlier = (New-RestartFixture)[1]; $earlier.decision_reason = 'hunt_deadline' +Test-RestartFixture 'startup must be first candidate' (@($events[0], $earlier) + $events[1..3]) $true +Write-Host 'Magic-training restart assertion regressions passed.' diff --git a/scripts/test-playerbot-navigation-assertions.ps1 b/scripts/test-playerbot-navigation-assertions.ps1 new file mode 100644 index 00000000..128dd5ef --- /dev/null +++ b/scripts/test-playerbot-navigation-assertions.ps1 @@ -0,0 +1,131 @@ +#Requires -Version 7.0 + +param() + +$ErrorActionPreference = "Stop" + +. $PSScriptRoot/playerbot-gameplay/log-parsing.ps1 +. $PSScriptRoot/playerbot-gameplay/assertions-navigation.ps1 + +function ConvertTo-FixtureLogs { + param([hashtable[]]$Events) + + return ($Events | ForEach-Object { + $_.component = "playerbot" + $_.bot = "Bot One" + $_ | ConvertTo-Json -Compress -Depth 5 + }) -join "`n" +} + +function Assert-Rejected { + param([string]$Name, [scriptblock]$Body, [string]$ExpectedMessage) + + try { + & $Body + } + catch { + if ($_.Exception.Message -notlike "$ExpectedMessage*") { + throw "$Name failed for an unexpected reason: $($_.Exception.Message)" + } + return + } + throw "$Name unexpectedly passed." +} + +function New-MainlandFixture { + return @( + @{ event = "action_result"; action = "hunt_cycle"; result = "started"; cycle = 1 } + @{ event = "action_result"; action = "hunt_cycle"; result = "started"; cycle = 2 } + @{ event = "action_result"; action = "hunt_cycle"; result = "started"; cycle = 3 } + @{ event = "action_result"; action = "deposit"; result = "complete"; depot_id = 2 } + @{ event = "action_result"; action = "deposit"; result = "complete"; depot_id = 2 } + @{ event = "action_result"; action = "depot_discover"; result = "success"; depot_id = 2 + locker_item_id = 2591; locker = @{ x = 32352; y = 32225; z = 7 } + approach = @{ x = 32352; y = 32226; z = 7 } } + @{ event = "hunt_region_selection"; result = "selected"; atlas_site_id = 1; atlas_variant_id = 1; atlas_spawns = 2 } + @{ event = "action_result"; action = "deposit"; result = "success"; item_id = 2826; verified = 1 } + ) +} + +foreach ($lockerId in @(2589, 2590, 2591, 2592)) { + $events = New-MainlandFixture + $discovery = $events | Where-Object action -eq "depot_discover" + $discovery.locker_item_id = $lockerId + Assert-MainlandLoopEvents -Logs (ConvertTo-FixtureLogs $events) +} +foreach ($lockerId in @(2588, 2593)) { + $events = New-MainlandFixture + $discovery = $events | Where-Object action -eq "depot_discover" + $discovery.locker_item_id = $lockerId + Assert-Rejected "Invalid locker $lockerId" { + Assert-MainlandLoopEvents -Logs (ConvertTo-FixtureLogs $events) + } "Mainland loop failed." +} +foreach ($axis in @("x", "y", "z")) { + $events = New-MainlandFixture + $discovery = $events | Where-Object action -eq "depot_discover" + $discovery.approach[$axis] = $discovery.locker[$axis] + 2 + Assert-Rejected "Invalid approach $axis" { + Assert-MainlandLoopEvents -Logs (ConvertTo-FixtureLogs $events) + } "Mainland loop failed." +} + +function New-SlottedLootFixture { + return @( + @{ event = "action_result"; action = "deposit"; result = "success"; item_id = 2398 + verified = 1; source_slot = 10; disposition = "deposit"; provider_available = $false } + @{ event = "sell_loot_plan"; action = "sell_loot_plan"; result = "candidate" + reason = "profitable_trip_validated"; source_depot_id = 2; manifest_batches = 1 + expected_revenue = 30; round_trip_time_cost = 5; utility = 25 } + @{ event = "action_result"; action = "sell"; result = "success"; item_id = 2398 + count = 1; carried_before = 0; bank_before = 100; carried_after = 30; bank_after = 100 } + ) +} + +Assert-SlottedLootEvents -Logs (ConvertTo-FixtureLogs (New-SlottedLootFixture)) -SellerAvailable +foreach ($change in @( + @{ Field = "source_depot_id"; Value = 1 } + @{ Field = "manifest_batches"; Value = 0 } + @{ Field = "utility"; Value = 0 } + @{ Field = "utility"; Value = -1 } + @{ Field = "result"; Value = "deferred" } +)) { + $events = New-SlottedLootFixture + $plan = $events | Where-Object event -eq "sell_loot_plan" + $plan[$change.Field] = $change.Value + Assert-Rejected "Invalid plan $($change.Field)=$($change.Value)" { + Assert-SlottedLootEvents -Logs (ConvertTo-FixtureLogs $events) -SellerAvailable + } "The slotted loot sale lacked a candidate plan" +} +foreach ($field in @("source_depot_id", "manifest_batches", "utility", "plan")) { + $events = New-SlottedLootFixture + if ($field -eq "plan") { + $events = @($events | Where-Object event -ne "sell_loot_plan") + } else { + $plan = $events | Where-Object event -eq "sell_loot_plan" + $plan.Remove($field) + } + Assert-Rejected "Missing $field" { + Assert-SlottedLootEvents -Logs (ConvertTo-FixtureLogs $events) -SellerAvailable + } "The slotted loot sale lacked a candidate plan" +} +foreach ($case in @("missing_sale", "wrong_item", "no_money_gain", "unverified_deposit", "protected_item")) { + $events = New-SlottedLootFixture + $sale = $events | Where-Object action -eq "sell" + $expectedMessage = "Slotted loot did not use the expected local disposition." + switch ($case) { + "missing_sale" { $events = @($events | Where-Object action -ne "sell") } + "wrong_item" { $sale.item_id = 2399 } + "no_money_gain" { $sale.carried_after = 0 } + "unverified_deposit" { ($events | Where-Object action -eq "deposit").verified = 0 } + "protected_item" { + $events += @{ event = "action_result"; action = "item_disposition"; item_id = 2463 } + $expectedMessage = "Slotted disposition did not preserve protected state or bounded service." + } + } + Assert-Rejected $case { + Assert-SlottedLootEvents -Logs (ConvertTo-FixtureLogs $events) -SellerAvailable + } $expectedMessage +} + +"Playerbot navigation assertion regression PASS" diff --git a/scripts/test-playerbot-readiness-assertions.ps1 b/scripts/test-playerbot-readiness-assertions.ps1 new file mode 100644 index 00000000..4f3f4501 --- /dev/null +++ b/scripts/test-playerbot-readiness-assertions.ps1 @@ -0,0 +1,45 @@ +#Requires -Version 7.0 +$ErrorActionPreference = 'Stop' +. $PSScriptRoot/playerbot-gameplay/log-parsing.ps1 +. $PSScriptRoot/playerbot-gameplay/assertions-readiness.ps1 + +function New-FoodFixture { + @( + @{ event = 'combat_readiness'; vocation_id = 4; result = 'ready'; requirements = @( + @{ name = 'health_potions'; item_id = 7618; ready = $true } + @{ name = 'free_capacity'; ready = $true; current = 0; minimum = 2000; reclaimable_food = 3200; effective = 3200 } + @{ name = 'food'; required = $false; count = 8; reclaimable_weight = 3200 } + @{ name = 'weapon'; ready = $true } + @{ name = 'armor'; ready = $true } + ) } + @{ event = 'action_result'; action = 'hunt_cycle'; result = 'started' } + foreach ($remaining in 7..0) { + @{ event = 'action_result'; action = 'eat'; result = 'success'; item_id = 2696; count = 1; inventory_count = $remaining; food_ticks = (8 - $remaining) * 108000 } + } + ) +} +function Test-Fixture([array]$Events, [bool]$Reject) { + $logs = ($Events | ForEach-Object { + $_.component = 'playerbot'; $_.bot = 'Bot One' + $_ | ConvertTo-Json -Depth 8 -Compress + }) -join "`n" + try { Assert-CombatReadinessEvents -Logs $logs -Mode food_capacity } + catch { if ($Reject) { return }; throw } + if ($Reject) { throw 'Invalid food fixture unexpectedly passed.' } +} +Test-Fixture (New-FoodFixture) $false +# Reject the old six-use/two-retained contract, missing and extra uses, and unverified deltas. +Test-Fixture (New-FoodFixture | Select-Object -First 8) $true +Test-Fixture (New-FoodFixture | Select-Object -First 9) $true +$events = @(New-FoodFixture); Test-Fixture ($events + $events[-1]) $true +foreach ($field in @('inventory_count', 'count', 'food_ticks', 'item_id', 'result')) { + $events = @(New-FoodFixture); $events[5][$field] = 0 + Test-Fixture $events $true +} +$events = @(New-FoodFixture); $events[0].requirements[1].current = 1; Test-Fixture $events $true +$events = @(New-FoodFixture); $events[0].requirements[1].reclaimable_food = 3199; Test-Fixture $events $true +$events = @(New-FoodFixture); $events[0].requirements[2].count = 2; Test-Fixture $events $true +$events = @(New-FoodFixture); $events[1].result = 'failed'; Test-Fixture $events $true +$events = @(New-FoodFixture); Test-Fixture ($events + @{ action = 'buy_meat'; result = 'success' }) $true +$events = @(New-FoodFixture); Test-Fixture ($events + @{ event = 'combat_readiness'; selected_recovery = 'service' }) $true +Write-Host 'Food-capacity assertion regressions passed.' diff --git a/scripts/test-playerbot-scenario-isolation.ps1 b/scripts/test-playerbot-scenario-isolation.ps1 new file mode 100644 index 00000000..4057b028 --- /dev/null +++ b/scripts/test-playerbot-scenario-isolation.ps1 @@ -0,0 +1,25 @@ +#Requires -Version 7.0 +$ErrorActionPreference = "Stop" +. (Join-Path $PSScriptRoot "playerbot-gameplay/runtime.ps1") + +function Invoke-TimedStep { param($Name, [scriptblock]$Body) & $Body } +function Add-ScenarioResult { param($Name, $Status, $ErrorMessage, $ArtifactPath) } + +$exactScenarioSelection = $false +$timeoutOverridden = $false +$ContinueOnFailure = $false +$previousPhase = $env:PLAYERBOT_DEPOT_RESTART_PHASE +try { + Invoke-Scenario -Name "checkpoint" -DefaultTimeoutSeconds 30 -Body { + $env:PLAYERBOT_DEPOT_RESTART_PHASE = "depart" + } + Invoke-Scenario -Name "ordinary" -DefaultTimeoutSeconds 30 -Body { + if ($env:PLAYERBOT_DEPOT_RESTART_PHASE) { throw "Checkpoint leaked into the next scenario." } + $env:PLAYERBOT_DEPOT_RESTART_PHASE = "deposit" + if ($env:PLAYERBOT_DEPOT_RESTART_PHASE -ne "deposit") { throw "Scenario cannot opt into its checkpoint." } + } + Write-Host "Scenario checkpoint isolation passed." +} +finally { + $env:PLAYERBOT_DEPOT_RESTART_PHASE = $previousPhase +} diff --git a/server/tests/playerbot-gameplay/includes/login.inc b/server/tests/playerbot-gameplay/includes/login.inc index 41586c24..dfd07375 100644 --- a/server/tests/playerbot-gameplay/includes/login.inc +++ b/server/tests/playerbot-gameplay/includes/login.inc @@ -8,6 +8,34 @@ function F.login.onLogin(player) end local mode = os.getenv("PLAYERBOT_GAMEPLAY_MODE") or "cycle" + -- These two cases target pickupRewardStorage, not the other named reward + -- fixtures. Keep the finite fixture set isolated without filtering map discovery. + if mode == "progression" or mode == "arbitration" then + local restarting = mode == "progression" and player:getStorageValue(F.pickupRewardStorage) == 1 + for _, storage in ipairs({F.mainlandRewardStorage, F.economicRewardStorage, F.nestedRewardStorage}) do + if restarting then + assert(player:getStorageValue(storage) == 1, + "fixture did not persist competing reward isolation: " .. storage) + else + assert(player:setStorageValue(storage, 1), + "fixture could not suppress competing reward: " .. storage) + end + end + -- The nearby currency reward otherwise consumes the magic-training window + -- or preempts the intended readiness work. Leave these cases' other rewards alone. + elseif mode == "readiness_low_wealth" or + mode == "magic_training_haste" or mode == "magic_training_great_light" or mode == "magic_training_light" or + mode == "magic_training_refresh" or mode == "magic_training_failed" or mode == "magic_training_restart" or + mode == "magic_training_hunt" or mode == "magic_training_post_hunt" or mode == "magic_training_post_hunt_no_overflow" then + local restarting = mode == "magic_training_restart" and player:getStorageValue(50098) == 1 + if restarting then + assert(player:getStorageValue(F.economicRewardStorage) == 1, + "fixture did not persist the claimed nearby currency reward") + else + assert(player:setStorageValue(F.economicRewardStorage, 1), + "fixture could not suppress the nearby currency reward") + end + end F.selectHealthPotion(player) assert(mode == "mainland" or mode == "carlin_local_service" or mode == "cycle" or mode == "depot" or mode == "depot_risk_fallback" or mode == "slotted_loot_seller" or mode == "slotted_loot_no_seller" or mode == "sell_loot" or mode == "sell_loot_remote_depot" or mode == "navigation" or mode == "navigation_recovery" or mode == "carlin_service_route" or mode == "mutable_portal_route" or mode == "patrol_recovery" or mode == "target_approach" or mode == "target_approach_unreachable" or mode == "target_attacker_priority" or mode == "hunt_area_arrival" or mode == "corpse" or mode == "corpse_detour" or mode == "corpse_inaccessible" or mode == "death" or mode == "healing" or mode == "spell_use" or mode == "healing_resupply" or mode == "value" or mode == "progression" or mode == "progression_bundle" or @@ -259,6 +287,13 @@ function F.login.onLogin(player) end local thais = Town(F.thaisTownId) assert(thais and player:setTown(thais) and player:setVocation(4), "magic training fixture could not select Thais Knight") + F.selectHealthPotion(player) + if mode ~= "magic_training_service" and mode ~= "magic_training_progression" then + -- Exclude incidental healing service; priority fixtures own their reserves. + -- Restart returns above so it uses persisted inventory instead of reseeding. + F.removeAll(player, F.potionItemId) + assert(player:addItem(F.potionItemId, 2), "magic training fixture could not seed the selected potion reserve") + end local requiredExperience = Game.getExperienceForLevel(20) - player:getExperience() if requiredExperience > 0 then player:addExperience(requiredExperience) end assert(player:getLevel() == 20, "magic training fixture could not select the required level") @@ -593,6 +628,10 @@ function F.login.onLogin(player) addEvent(F.triggerArbitrationInterrupt, 500, player:getId()) print("PLAYERBOT_GAMEPLAY_TEST GOAL_ARBITRATION_INTERRUPT_START") else + -- Keep ordinary cash service below the intended reward's utility. + -- Missing potions instead make reserve restocking the higher-priority goal. + F.removeAll(player, F.potionItemId) + assert(player:addItem(F.potionItemId, 10), "goal arbitration fixture could not prepare its potion reserve") print("PLAYERBOT_GAMEPLAY_TEST GOAL_ARBITRATION_START") end return true diff --git a/server/tests/playerbot-gameplay/includes/verifiers.inc b/server/tests/playerbot-gameplay/includes/verifiers.inc index bd88ee39..7870e2c1 100644 --- a/server/tests/playerbot-gameplay/includes/verifiers.inc +++ b/server/tests/playerbot-gameplay/includes/verifiers.inc @@ -10,7 +10,7 @@ function F.verifyReadiness(playerId, mode, attempts) if mode == "retention" then ready = ready and player:getItemCount(2050) >= 1 and player:getItemCount(F.starterWeaponId) >= 1 end if mode == "no_food" then ready = ready and player:getItemCount(F.meatItemId) == 0 end if mode == "food_capacity" then - ready = ready and player:getFreeCapacity() == ItemType(2696):getWeight() * 6 and player:getItemCount(2696) == 2 + ready = ready and player:getFreeCapacity() == ItemType(2696):getWeight() * 8 and player:getItemCount(2696) == 0 end if not ready and attempts > 0 then addEvent(F.verifyReadiness, 500, playerId, mode, attempts - 1) @@ -422,7 +422,9 @@ function F.verifyDepot(playerId, attempts) assert(player:getItemCount(2120) == 1 and player:getItemCount(2554) == 1, "navigation tool reserves were not retained") assert(chest:getItemCountById(2120) == 2 and chest:getItemCountById(2554) == 2, "surplus navigation tools were not deposited") - assert(player:getItemCount(F.potionItemId) >= 5 and player:getItemCount(F.meatItemId) >= 1, "supply reserves were deposited") + -- This fixture seeds cheese (checked above), not meat. Potions must stay carried. + assert(player:getItemCount(F.potionItemId) >= 5 and chest:getItemCountById(F.potionItemId) == 0, + "supply reserves were deposited") print("PLAYERBOT_GAMEPLAY_TEST DEPOT_PASS") end From 13548d0f3434aacb5e1ac527101dfed7ac7cf49e Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:27:07 +0200 Subject: [PATCH 4/8] testing: isolate scenario-owned environment settings --- scripts/playerbot-gameplay/runtime.ps1 | 23 ++++- scripts/test-playerbot-scenario-isolation.ps1 | 86 ++++++++++++++++--- 2 files changed, 95 insertions(+), 14 deletions(-) diff --git a/scripts/playerbot-gameplay/runtime.ps1 b/scripts/playerbot-gameplay/runtime.ps1 index 8a2081f4..c6d73082 100644 --- a/scripts/playerbot-gameplay/runtime.ps1 +++ b/scripts/playerbot-gameplay/runtime.ps1 @@ -444,9 +444,23 @@ function Invoke-Scenario { $script:currentWaitTimeoutSeconds = if ($timeoutOverridden) { $TimeoutSeconds } else { $DefaultTimeoutSeconds } $script:currentScenarioDeadline = [DateTime]::UtcNow.AddSeconds($currentWaitTimeoutSeconds) $startedAt = [DateTime]::UtcNow + # Scenario-owned settings only; defaults match the gameplay Compose stack. + $scenarioDefaults = @{ + PLAYERBOT_GAMEPLAY_MODE = "cycle" + PLAYERBOT_HUNT_DURATION_SECONDS = "1500" + PLAYERBOT_RELOG_DELAY_SECONDS = "5" + PLAYERBOT_MAX_CONSECUTIVE_DEATHS = "3" + PLAYERBOT_DEPOT_RESTART_PHASE = "" + PLAYERBOT_DEPOT_MOVE_CASE = "normal" + } + $incomingEnvironment = @{} + foreach ($key in $scenarioDefaults.Keys) { + $incomingEnvironment[$key] = [Environment]::GetEnvironmentVariable($key) + } try { - # Checkpoint scenarios must opt in; a prior case must not pause this bot. - $env:PLAYERBOT_DEPOT_RESTART_PHASE = "" + foreach ($key in $scenarioDefaults.Keys) { + [Environment]::SetEnvironmentVariable($key, $scenarioDefaults[$key]) + } Invoke-TimedStep -Name $Name -Body $Body Add-ScenarioResult -Name $Name -Status "pass" } @@ -458,4 +472,9 @@ function Invoke-Scenario { throw } } + finally { + foreach ($key in $incomingEnvironment.Keys) { + [Environment]::SetEnvironmentVariable($key, $incomingEnvironment[$key]) + } + } } diff --git a/scripts/test-playerbot-scenario-isolation.ps1 b/scripts/test-playerbot-scenario-isolation.ps1 index 4057b028..0f98cb66 100644 --- a/scripts/test-playerbot-scenario-isolation.ps1 +++ b/scripts/test-playerbot-scenario-isolation.ps1 @@ -3,23 +3,85 @@ $ErrorActionPreference = "Stop" . (Join-Path $PSScriptRoot "playerbot-gameplay/runtime.ps1") function Invoke-TimedStep { param($Name, [scriptblock]$Body) & $Body } -function Add-ScenarioResult { param($Name, $Status, $ErrorMessage, $ArtifactPath) } +function Add-ScenarioResult { param($Name, $Status, $ErrorMessage, $ArtifactPath) $script:lastStatus = $Status } +function Save-ScenarioFailureArtifacts { param($Name, $Status, $Exception, $StartedAt) return "stub-artifacts" } +function Assert-Environment($Expected) { + foreach ($key in $Expected.Keys) { + # Before PowerShell 7.5, setting an empty environment value removes it. + if ([string][Environment]::GetEnvironmentVariable($key) -cne [string]$Expected[$key]) { + throw "Unexpected environment value for $key." + } + } +} +$defaults = @{ + PLAYERBOT_GAMEPLAY_MODE = "cycle" + PLAYERBOT_HUNT_DURATION_SECONDS = "1500" + PLAYERBOT_RELOG_DELAY_SECONDS = "5" + PLAYERBOT_MAX_CONSECUTIVE_DEATHS = "3" + PLAYERBOT_DEPOT_RESTART_PHASE = "" + PLAYERBOT_DEPOT_MOVE_CASE = "normal" +} +$unrelated = @{ PLAYERBOT_SPEED_BONUS = "123"; PLAYERBOT_REGRESSION_MODE = "untouched" } +$previous = @{} +foreach ($key in @($defaults.Keys) + @($unrelated.Keys)) { + $previous[$key] = [Environment]::GetEnvironmentVariable($key) +} $exactScenarioSelection = $false -$timeoutOverridden = $false +$timeoutOverridden = $true +$TimeoutSeconds = 77 $ContinueOnFailure = $false -$previousPhase = $env:PLAYERBOT_DEPOT_RESTART_PHASE try { - Invoke-Scenario -Name "checkpoint" -DefaultTimeoutSeconds 30 -Body { - $env:PLAYERBOT_DEPOT_RESTART_PHASE = "depart" - } - Invoke-Scenario -Name "ordinary" -DefaultTimeoutSeconds 30 -Body { - if ($env:PLAYERBOT_DEPOT_RESTART_PHASE) { throw "Checkpoint leaked into the next scenario." } - $env:PLAYERBOT_DEPOT_RESTART_PHASE = "deposit" - if ($env:PLAYERBOT_DEPOT_RESTART_PHASE -ne "deposit") { throw "Scenario cannot opt into its checkpoint." } + foreach ($key in $unrelated.Keys) { [Environment]::SetEnvironmentVariable($key, $unrelated[$key]) } + # Exercise incoming populated, absent, and explicitly empty values. + foreach ($incomingValue in @("incoming", $null, "")) { + $incoming = @{} + foreach ($key in $defaults.Keys) { + [Environment]::SetEnvironmentVariable($key, $incomingValue) + $incoming[$key] = [Environment]::GetEnvironmentVariable($key) + } + Invoke-Scenario -Name "override" -DefaultTimeoutSeconds 30 -Body { + Assert-Environment $defaults + Assert-Environment $unrelated + if ($currentWaitTimeoutSeconds -ne 77) { throw "CLI timeout was lost." } + foreach ($key in $defaults.Keys) { [Environment]::SetEnvironmentVariable($key, "case-override") } + $override = @{} + foreach ($key in $defaults.Keys) { $override[$key] = "case-override" } + Assert-Environment $override + } + if ($lastStatus -ne "pass") { throw "Override scenario did not pass." } + Assert-Environment $incoming + Invoke-Scenario -Name "ordinary" -DefaultTimeoutSeconds 30 -Body { Assert-Environment $defaults } + Assert-Environment $incoming + + foreach ($continue in @($false, $true)) { + $ContinueOnFailure = $continue + $caught = $false + try { + Invoke-Scenario -Name "throwing" -DefaultTimeoutSeconds 30 -Body { + Assert-Environment $defaults + foreach ($key in $defaults.Keys) { [Environment]::SetEnvironmentVariable($key, "failed-case") } + throw "deliberate failure" + } + } + catch { + if ($_.Exception.Message -ne "deliberate failure") { throw } + $caught = $true + } + if ($caught -eq $continue -or $lastStatus -ne "fail") { throw "Failure handling changed." } + Assert-Environment $incoming + Invoke-Scenario -Name "after-failure" -DefaultTimeoutSeconds 30 -Body { Assert-Environment $defaults } + } + $exactScenarioSelection = $true + $selectedScenarios = [System.Collections.Generic.HashSet[string]]::new() + Invoke-Scenario -Name "skipped" -DefaultTimeoutSeconds 30 -Body { throw "Skipped body ran." } + if ($lastStatus -ne "skipped") { throw "Scenario was not skipped." } + Assert-Environment $incoming + Assert-Environment $unrelated + $exactScenarioSelection = $false } - Write-Host "Scenario checkpoint isolation passed." + Write-Host "Scenario environment isolation passed." } finally { - $env:PLAYERBOT_DEPOT_RESTART_PHASE = $previousPhase + foreach ($key in $previous.Keys) { [Environment]::SetEnvironmentVariable($key, $previous[$key]) } } From 0f0b806d859b0aadfeb8f823fbb04d04ef80b41d Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:27:07 +0200 Subject: [PATCH 5/8] testing: gate final death on recovered service discovery --- docs/linux-validation.md | 8 +- docs/testing.md | 17 +++ .../scenarios-combat-loot.ps1 | 11 ++ scripts/test-playerbot-death-fixture.lua | 114 ++++++++++++++++++ scripts/test-playerbot-death-scenario.ps1 | 48 ++++++++ .../playerbot-gameplay/includes/combat.inc | 32 ++++- .../playerbot-gameplay/includes/constants.inc | 1 + .../playerbot-gameplay/includes/login.inc | 4 +- 8 files changed, 232 insertions(+), 3 deletions(-) create mode 100644 scripts/test-playerbot-death-fixture.lua create mode 100644 scripts/test-playerbot-death-scenario.ps1 diff --git a/docs/linux-validation.md b/docs/linux-validation.md index 9145d3d3..6366e2a5 100644 --- a/docs/linux-validation.md +++ b/docs/linux-validation.md @@ -36,9 +36,15 @@ migration green. Reproduce them independently before changing gameplay policy. | `combat_readiness_low_wealth` | Sale cargo goes to the depot before service; the fixture expects 56 gp including proceeds but observes a 50 gp withdrawal. Isolate sale/service inputs. | | `magic_training_progression` | Equipment/service work consumes the funds intended for spell learning. Isolate the learning/affordability contract. | | `magic_training_post_hunt`, `magic_training_post_hunt_no_overflow` | The controller visits the depot before arbitration. Tests expect immediate post-hunt `Idle` arbitration; the detour also changes mana overflow. Decide intended hunt-end behavior first. | -| `death` | The third fixture kill can precede service discovery after recovery. Replace the fixed delay with a bounded milestone wait. | | `real_depot` Lua verifier | After deposit completion, optional liquidation can withdraw a tool before the asynchronous inventory verifier runs. Separate deposit-boundary inventory checks from post-departure activity. A passing PowerShell scenario alone does not resolve this verifier race. | +The first isolation follow-up validated `mainland_loop`, +`real_depot_rejected_move`, `pickup_progression_bundle`, `goal_arbitration`, +`magic_training_restart`, and `healing_resupply` with per-scenario environment +reset/restore. `death` initially timed out because its prior killer survived at +the depot. After fixture-owned killer cleanup and milestone-gated third death, +the isolated death rerun passed in 34.6 seconds with its 45-second limit unchanged. + The ordinary `corpse` scenario has both passing and failing traces: a defensive attacker classification previously prevented normal looting. The latest targeted run passed all three corpse cases; this does not establish timing stability. diff --git a/docs/testing.md b/docs/testing.md index 8274dbb4..d4ed4e0a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -29,6 +29,7 @@ pwsh -File scripts/test-playerbot-navigation-assertions.ps1 pwsh -File scripts/test-playerbot-readiness-assertions.ps1 pwsh -File scripts/test-playerbot-log-parsing.ps1 pwsh -File scripts/test-playerbot-scenario-isolation.ps1 +pwsh -File scripts/test-playerbot-death-scenario.ps1 pwsh -File scripts/test-playerbot-magic-training-assertions.ps1 ``` @@ -39,6 +40,7 @@ require Lua or LuaJIT. Run these from the repository root: sh server/tests/playerbot_contracts.sh lua scripts/test-playerbot-fixture-isolation.lua lua scripts/test-playerbot-depot-fixture.lua +lua scripts/test-playerbot-death-fixture.lua ``` `server/tests/playerbotdepotworkflow_test.cpp` includes its compile command and @@ -143,6 +145,21 @@ pwsh -File scripts/test-playerbot-gameplay.ps1 -Healing -Focused -SkipBuild The driver includes Compose status and the last 80 server-log lines in timeout failures. Docker builds reuse a persistent BuildKit `ccache` mount. +Each selected scenario owns six environment settings: `PLAYERBOT_GAMEPLAY_MODE` +(default `cycle`), `PLAYERBOT_HUNT_DURATION_SECONDS` (`1500`), +`PLAYERBOT_RELOG_DELAY_SECONDS` (`5`), `PLAYERBOT_MAX_CONSECUTIVE_DEATHS` (`3`), +`PLAYERBOT_DEPOT_RESTART_PHASE` (empty), and `PLAYERBOT_DEPOT_MOVE_CASE` (`normal`). +`Invoke-Scenario` applies these defaults before the body; individual cases may +then override them. It restores incoming values after success or failure. +Skipped scenarios do not touch the environment. Suite CLI options (including +`-TimeoutSeconds`) and unrelated environment settings remain unchanged; this +is test-harness ownership, not a change to production configuration. + +The death fixture's third kill waits for `service_discovered` after the second +recovered controller reports online. The driver releases a DB-only fixture marker; +Lua polls it for at most 30 seconds. The scenario's overall 45-second deadline and +existing recovery/terminal assertions remain unchanged. + Independent scenarios still receive a fresh database and server process. The database container remains healthy between them to avoid repeated MariaDB and volume initialization. Scenarios that verify clean-shutdown persistence stop and diff --git a/scripts/playerbot-gameplay/scenarios-combat-loot.ps1 b/scripts/playerbot-gameplay/scenarios-combat-loot.ps1 index eab8fd64..65aa4b68 100644 --- a/scripts/playerbot-gameplay/scenarios-combat-loot.ps1 +++ b/scripts/playerbot-gameplay/scenarios-combat-loot.ps1 @@ -37,6 +37,17 @@ $env:PLAYERBOT_MAX_CONSECUTIVE_DEATHS = "2" Invoke-Compose up --detach Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST DEATH_RECOVERY_STATE_PASS' | Out-Null + $deathMilestone = @{ Recovered = $false } + Wait-ForPlayerbotEvent -Predicate { + if ($_.bot -ne "Bot One") { return $false } + if ($_.event -eq "lifecycle" -and $_.status -eq "online" -and + $_.recovered -eq $true -and $_.recovery_count -eq 2) { + $deathMilestone.Recovered = $true + } + $deathMilestone.Recovered -and $_.event -eq "service_discovered" + } | Out-Null + # DB-only fixture signal: Lua polls SQL rather than cached player storage. + Invoke-DatabaseCommand -Query "INSERT INTO player_storage (player_id, ``key``, value) SELECT id, 50099, 1 FROM players WHERE name = 'Bot One' ON DUPLICATE KEY UPDATE value = 1" $deathLogs = Wait-ForPlayerbotEvent -Predicate { $_.event -eq "lifecycle" -and $_.status -eq "recovery_abandoned" -and $_.reason -eq "death_loop_limit" } diff --git a/scripts/test-playerbot-death-fixture.lua b/scripts/test-playerbot-death-fixture.lua new file mode 100644 index 00000000..83bf32b6 --- /dev/null +++ b/scripts/test-playerbot-death-fixture.lua @@ -0,0 +1,114 @@ +-- Run from the repository root: lua scripts/test-playerbot-death-fixture.lua +PlayerbotGameplayFixture = {fixtureReadyStorage = 50099} +dofile("server/tests/playerbot-gameplay/includes/combat.inc") +local F = PlayerbotGameplayFixture +local marker, queued, kills, freed, queries +local present = true +Player = function(id) + assert(id == 7) + if not present then return nil end + return {isRemoved = function() return false end, getGuid = function() return 3 end} +end +db = {storeQuery = function(sql) + assert(sql == "SELECT `value` FROM `player_storage` WHERE `player_id` = 3 AND `key` = 50099") + queries = queries + 1 + return marker ~= nil and 42 or false +end} +result = { + getNumber = function(id, key) assert(id == 42 and key == "value"); return marker end, + free = function(id) assert(id == 42); freed = freed + 1 end, +} +addEvent = function(fn, delay, id, attempts) + assert(fn == F.waitForDeathService and delay == 250 and id == 7) + queued = function() queued = nil; fn(id, attempts) end +end +F.prepareDeath = function(id) assert(id == 7); kills = kills + 1 end +local function reset(value) + marker, queued, kills, freed, queries = value, nil, 0, 0, 0 +end +reset(nil) +F.waitForDeathService(7, 120) +assert(kills == 0 and queued) +marker = 0 +queued() +assert(kills == 0 and freed == 1 and queued) +marker = 1 +queued() +assert(kills == 1 and freed == 2 and not queued) +reset(nil) +F.waitForDeathService(7, 120) +local ok, err = pcall(function() while queued do queued() end end) +assert(not ok and err:find("timed out waiting for recovered service evidence", 1, true)) +assert(queries == 120 and kills == 0 and not queued) +reset(1) +present = false +assert(not pcall(F.waitForDeathService, 7, 120)) +assert(queries == 0 and kills == 0 and not queued) +print("PASS death fixture: milestone gate, marker cleanup, 30-second polling bound, missing player") + +-- Exercise the real login handler: the killer is at the old depot, not within +-- the recovered player's temple cleanup. Only the recorded creature may go. +Position = function(x, y, z) return {x = x, y = y, z = z} end +dofile("server/tests/playerbot-gameplay/includes/constants.inc") +CreatureEvent = function() return {register = function() end} end +dofile("server/tests/playerbot-gameplay/includes/login.inc") +os.getenv = function(key) assert(key == "PLAYERBOT_GAMEPLAY_MODE"); return "death" end +F.selectHealthPotion = function() end +F.restoreRookgaardBaseline = function() end +local player = { + getId = function() return 7 end, + getName = function() return F.botName end, + isRemoved = function() return false end, + getPosition = function() return Position(32097, 32219, 7) end, +} +Player = function(id) assert(id == 7); return player end +Tile = function() return {isWalkable = function() return true end} end +local monsters, removed, nextId = {}, {}, 100 +monsters[999] = {remove = function() error("removed unrelated world monster") end} +Monster = function(id) assert(id ~= 0); return monsters[id] end +Game = {createMonster = function(name) + assert(name == F.deathMonsterName) + nextId = nextId + 1 + local id = nextId + local monster = { + getId = function() return id end, + selectTarget = function(_, target) assert(target == player); return true end, + isRemoved = function() return false end, + remove = function() removed[id] = true; monsters[id] = nil; return true end, + } + monsters[id] = monster + return monster +end} +F.removeNearbyMonsters = function() + assert(F.deathMonsterId == 0, "owned killer not cleared before temple cleanup") +end +local scheduled = 0 +addEvent = function(fn, delay, id, attempts) + assert(F.deathMonsterId == 0, "owned killer survived until next death/service timer") + assert(id == 7) + scheduled = scheduled + 1 + if scheduled <= 2 then + assert(fn == F.prepareDeath and delay == 100) + else + assert(fn == F.waitForDeathService and delay == 250 and attempts == 120) + end +end +assert(F.login.onLogin(player)) +for recovery = 1, 2 do + F.spawnDeathMonster(7) + local id = F.deathMonsterId + assert(id == 100 + recovery and monsters[id]) + assert(F.login.onLogin(player)) + assert(removed[id] and not monsters[id] and F.deathMonsterId == 0) +end +assert(scheduled == 3 and monsters[999]) +F.cleanupDeathMonster() -- repeated cleanup is harmless +F.deathMonsterId = 12345 -- engine already removed the owned creature +F.cleanupDeathMonster() +assert(F.deathMonsterId == 0 and monsters[999]) +F.deathMonsterId = 12346 +monsters[12346] = {isRemoved = function() return true end, + remove = function() error("removed creature was removed twice") end} +F.cleanupDeathMonster() +assert(F.deathMonsterId == 0) +print("PASS death killer lifecycle: spawn ID, both relog cleanups, cleared/missing/removed IDs, unrelated monster retained") diff --git a/scripts/test-playerbot-death-scenario.ps1 b/scripts/test-playerbot-death-scenario.ps1 new file mode 100644 index 00000000..9e9e32ea --- /dev/null +++ b/scripts/test-playerbot-death-scenario.ps1 @@ -0,0 +1,48 @@ +# Run in a separate process: pwsh -File scripts/test-playerbot-death-scenario.ps1 +$ErrorActionPreference = 'Stop' +$DeathTelemetry = $true +$script:released = $false +$script:observed = $false +$script:validated = $false +function Invoke-Scenario { + param($Name, $DefaultTimeoutSeconds, $Body) + if ($Name -ne 'death' -or $DefaultTimeoutSeconds -ne 45) { throw 'Scenario bound changed' } + & $Body +} +function Invoke-Compose {} +function Wait-ForLog {} +function Start-Sleep {} +function Get-OnlineBotCount { 0 } +function Get-ServerLogs { 'fixture logs' } +function Assert-DeathEvents { param($Logs); $script:validated = $true } +function Invoke-DatabaseCommand { + param($Query) + if (-not $script:observed -or $Query -notmatch 'SELECT id, 50099, 1') { throw 'Premature or wrong release' } + $script:released = $true +} +function Wait-ForPlayerbotEvent { + param($Predicate) + if ($script:released) { + $event = [pscustomobject]@{event='lifecycle'; status='recovery_abandoned'; reason='death_loop_limit'} + if (-not @($event | Where-Object $Predicate).Count) { throw 'Terminal wait changed' } + return + } + $events = @( + @{bot='Bot One'; event='service_discovered'}, + @{bot='Bot One'; event='lifecycle'; status='online'; recovered=$true; recovery_count=1}, + @{bot='Bot One'; event='service_discovered'}, + @{bot='Other'; event='lifecycle'; status='online'; recovered=$true; recovery_count=2}, + @{bot='Bot One'; event='service_discovered'}, + @{bot='Bot One'; event='lifecycle'; status='online'; recovered=$true; recovery_count=2}, + @{bot='Other'; event='service_discovered'}, + @{bot='Bot One'; event='service_discovered'} + ) + for ($i = 0; $i -lt $events.Count; $i++) { + $matched = @([pscustomobject]$events[$i] | Where-Object $Predicate).Count -gt 0 + if ($matched -ne ($i -eq $events.Count - 1)) { throw "Incorrect milestone match at event $i" } + } + $script:observed = $true +} +. "$PSScriptRoot/playerbot-gameplay/scenarios-combat-loot.ps1" +if (-not $script:released -or -not $script:validated) { throw 'Death scenario did not complete its contract' } +'PASS death scenario: ordered second-recovery milestone, DB release, terminal wait, assertions retained' diff --git a/server/tests/playerbot-gameplay/includes/combat.inc b/server/tests/playerbot-gameplay/includes/combat.inc index 1f4cd5ef..cabbc098 100644 --- a/server/tests/playerbot-gameplay/includes/combat.inc +++ b/server/tests/playerbot-gameplay/includes/combat.inc @@ -61,6 +61,14 @@ function F.spawnTargetPriorityAttacker(playerId, targetId) print("PLAYERBOT_GAMEPLAY_TEST TARGET_PRIORITY_ATTACKERS " .. targetId .. " " .. attackerIds[1] .. " " .. attackerIds[2]) end +function F.cleanupDeathMonster() + local monster = F.deathMonsterId ~= 0 and Monster(F.deathMonsterId) or nil + if monster and not monster:isRemoved() then + assert(monster:remove(), "death fixture could not remove its previous killer") + end + F.deathMonsterId = 0 +end + function F.spawnDeathMonster(playerId) local player = Player(playerId) assert(player and not player:isRemoved(), "Bot One disappeared before death telemetry test") @@ -74,7 +82,9 @@ function F.spawnDeathMonster(playerId) local tile = Tile(position) if tile and tile:isWalkable() then local monster = Game.createMonster(F.deathMonsterName, position, true, true) - assert(monster and monster:selectTarget(player), "death test monster could not target Bot One") + assert(monster, "death test monster could not spawn") + F.deathMonsterId = monster:getId() + assert(monster:selectTarget(player), "death test monster could not target Bot One") print("PLAYERBOT_GAMEPLAY_TEST DEATH_THREAT_SPAWNED") return end @@ -198,6 +208,26 @@ function F.cleanupSpellTargets() F.spellSecondaryTargetId = 0 end +-- The harness releases this DB-only marker after observing service_discovered on +-- the second recovered controller. Read SQL, not the player's cached storage. +function F.waitForDeathService(playerId, attempts) + local player = Player(playerId) + assert(player and not player:isRemoved(), "Bot One disappeared while waiting for recovered service") + local marker = db.storeQuery("SELECT `value` FROM `player_storage` WHERE `player_id` = " .. + player:getGuid() .. " AND `key` = " .. F.fixtureReadyStorage) + local ready = false + if marker ~= false then + ready = result.getNumber(marker, "value") == 1 + result.free(marker) + end + if ready then + F.prepareDeath(playerId) + return + end + assert(attempts > 1, "timed out waiting for recovered service evidence before final death") + addEvent(F.waitForDeathService, 250, playerId, attempts - 1) +end + function F.prepareDeath(playerId) local player = Player(playerId) assert(player and not player:isRemoved(), "Bot One disappeared before death recovery setup") diff --git a/server/tests/playerbot-gameplay/includes/constants.inc b/server/tests/playerbot-gameplay/includes/constants.inc index 29f3a1ad..fdc609b6 100644 --- a/server/tests/playerbot-gameplay/includes/constants.inc +++ b/server/tests/playerbot-gameplay/includes/constants.inc @@ -55,6 +55,7 @@ F.depotFixtureStorage = 50095 F.spellTrainingStorage = 50097 F.fixtureReadyStorage = 50099 F.deathLoginCount = 0 +F.deathMonsterId = 0 F.spellTargetId = 0 F.spellSecondaryTargetId = 0 F.spellSupportObserved = false diff --git a/server/tests/playerbot-gameplay/includes/login.inc b/server/tests/playerbot-gameplay/includes/login.inc index dfd07375..5dc84a7f 100644 --- a/server/tests/playerbot-gameplay/includes/login.inc +++ b/server/tests/playerbot-gameplay/includes/login.inc @@ -570,6 +570,8 @@ function F.login.onLogin(player) return true end if mode == "death" then + -- The previous killer remains at the depot, outside the temple cleanup. + F.cleanupDeathMonster() F.deathLoginCount = F.deathLoginCount + 1 F.removeNearbyMonsters(player) local position = player:getPosition() @@ -578,7 +580,7 @@ function F.login.onLogin(player) addEvent(F.prepareDeath, 100, player:getId()) print("PLAYERBOT_GAMEPLAY_TEST DEATH_START " .. F.deathLoginCount) else - addEvent(F.prepareDeath, 1500, player:getId()) + addEvent(F.waitForDeathService, 250, player:getId(), 120) print("PLAYERBOT_GAMEPLAY_TEST DEATH_RECOVERY_STATE_PASS") end return true From 138cfa05da76d485be254497faa94feb1979a14b Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Sun, 6 Sep 2026 03:31:48 +0200 Subject: [PATCH 6/8] testing: verify depot inventory before optional liquidation --- docs/linux-validation.md | 10 +- docs/testing.md | 21 +++- scripts/playerbot-gameplay/runtime.ps1 | 1 + .../playerbot-gameplay/scenarios-service.ps1 | 25 ++-- scripts/test-playerbot-depot-fixture.lua | 118 ++++++++++++++---- scripts/test-playerbot-depot-scenario.ps1 | 91 ++++++++++++++ scripts/test-playerbot-gameplay.ps1 | 2 + scripts/test-playerbot-scenario-isolation.ps1 | 1 + server/compose.playerbot-gameplay.yaml | 1 + .../playerbot-gameplay/includes/login.inc | 10 +- .../playerbot-gameplay/includes/verifiers.inc | 17 +-- 11 files changed, 250 insertions(+), 47 deletions(-) create mode 100644 scripts/test-playerbot-depot-scenario.ps1 diff --git a/docs/linux-validation.md b/docs/linux-validation.md index 6366e2a5..a524310c 100644 --- a/docs/linux-validation.md +++ b/docs/linux-validation.md @@ -36,7 +36,6 @@ migration green. Reproduce them independently before changing gameplay policy. | `combat_readiness_low_wealth` | Sale cargo goes to the depot before service; the fixture expects 56 gp including proceeds but observes a 50 gp withdrawal. Isolate sale/service inputs. | | `magic_training_progression` | Equipment/service work consumes the funds intended for spell learning. Isolate the learning/affordability contract. | | `magic_training_post_hunt`, `magic_training_post_hunt_no_overflow` | The controller visits the depot before arbitration. Tests expect immediate post-hunt `Idle` arbitration; the detour also changes mana overflow. Decide intended hunt-end behavior first. | -| `real_depot` Lua verifier | After deposit completion, optional liquidation can withdraw a tool before the asynchronous inventory verifier runs. Separate deposit-boundary inventory checks from post-departure activity. A passing PowerShell scenario alone does not resolve this verifier race. | The first isolation follow-up validated `mainland_loop`, `real_depot_rejected_move`, `pickup_progression_bundle`, `goal_arbitration`, @@ -45,6 +44,15 @@ reset/restore. `death` initially timed out because its prior killer survived at the depot. After fixture-owned killer cleanup and milestone-gated third death, the isolated death rerun passed in 34.6 seconds with its 45-second limit unchanged. +The depot verifier now checks exact inventory while the existing Depart fixture +checkpoint holds the controller paused, before optional liquidation. Both normal +cycles and all five restart recoveries require the Lua pass marker. Recovery +recreates only the server (`--no-deps`), so provisioning cannot refill equipment +slots. The paused Lua success marker is explicitly flushed to the log pipe. +All six normal/restart scenarios passed the final targeted run; partial and +rejected moves passed the preceding run. Production depot/selling policy is +unchanged. + The ordinary `corpse` scenario has both passing and failing traces: a defensive attacker classification previously prevented normal looting. The latest targeted run passed all three corpse cases; this does not establish timing stability. diff --git a/docs/testing.md b/docs/testing.md index d4ed4e0a..49780901 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -29,6 +29,7 @@ pwsh -File scripts/test-playerbot-navigation-assertions.ps1 pwsh -File scripts/test-playerbot-readiness-assertions.ps1 pwsh -File scripts/test-playerbot-log-parsing.ps1 pwsh -File scripts/test-playerbot-scenario-isolation.ps1 +pwsh -File scripts/test-playerbot-depot-scenario.ps1 pwsh -File scripts/test-playerbot-death-scenario.ps1 pwsh -File scripts/test-playerbot-magic-training-assertions.ps1 ``` @@ -43,6 +44,21 @@ lua scripts/test-playerbot-depot-fixture.lua lua scripts/test-playerbot-death-fixture.lua ``` +The depot Lua regression checks login-time inventory expectations, bounded waiting +for the Depart pause, and lost inventory/reserve failures. The PowerShell depot +regression checks pause, verification, and stopped-database rearm ordering. +Recovery uses `--no-deps` to keep provisioning from changing saved inventory; +the Lua success marker is flushed explicitly because the paused bot emits no +later telemetry. +`PLAYERBOT_DEPOT_VERIFIER_PHASE` is Lua-fixture-only: it preserves the original +restart phase's prior-gold expectation while recovery pauses at Depart. + +Run the affected live depot scenarios without the longer risk-fallback fixture: + +```powershell +pwsh -File scripts/test-playerbot-gameplay.ps1 -Scenario real_depot,real_depot_restart_approach,real_depot_restart_locker,real_depot_restart_chest,real_depot_restart_deposit,real_depot_restart_depart,real_depot_partial_move,real_depot_rejected_move +``` + `server/tests/playerbotdepotworkflow_test.cpp` includes its compile command and requires the server development headers. These checks supplement, not replace, live gameplay validation. @@ -99,7 +115,7 @@ the changed behavior: | `-CombatReadiness` | Equipment, the one-potion return threshold and 10-potion restock target, optional-food hunting, generic food consumption and reclaimable capacity, low-wealth banking, carried-upgrade retention through service, and restart reconstruction. It does not cover the terminal case where total funds cannot buy enough potions to exceed the return threshold. | | `-EquipmentPurchases` | Justified purchase and equip verification, clean restart persistence, carried-upgrade recovery, displaced-item-space rejection, and rejected transactions. | | `-MainlandRewards` | Real Thais reward object from a teleported, high-capacity fixture; scale-armor claim and equip, displaced-item and bundle preservation, restart reconstruction, and non-null battle-axe rejection evidence. It does not prove normal traversal, realistic capacity limits, or a specific rejection reason. | -| `-Depot` | Real Thais locker/chest discovery from Naji, including exact nearest-locker selection, carried-upgrade equipment, displaced and inferior equipment deposits, one carried rope and shovel, surplus tool deposits, nested loot, move verification, retries, and restart checkpoints. | +| `-Depot` | Real Thais locker/chest discovery from Naji, including exact nearest-locker selection, carried-upgrade equipment, displaced and inferior equipment deposits, one carried rope and shovel, surplus tool deposits, nested loot, move verification, retries, and restart checkpoints. Both normal cycles, all five checkpoint recoveries, and partial moves verify completed inventory while paused at Depart, before optional selling. Rejected moves retain separate exact delta/retry/discard assertions. | | `-SlottedLoot` | Invalid-slot loot sale through a live seller, direct depot fallback without an eligible seller, protected-equipment retention, move verification, and interrupted-deposit restart recovery. | | `-SellLoot` | Local and remote-depot liquidation, capacity-bounded manifests, verified withdrawal, seller travel, sale ordering, and proceeds-funded resupply. The workflow excludes fluid containers and splashes; current fixtures do not seed those item types. | | `-MainlandLoop` | Two real Thais hunt/depot cycles, local services, depot fallback for remote-buyer loot, restart recovery, and teleport exclusion. | @@ -148,7 +164,8 @@ failures. Docker builds reuse a persistent BuildKit `ccache` mount. Each selected scenario owns six environment settings: `PLAYERBOT_GAMEPLAY_MODE` (default `cycle`), `PLAYERBOT_HUNT_DURATION_SECONDS` (`1500`), `PLAYERBOT_RELOG_DELAY_SECONDS` (`5`), `PLAYERBOT_MAX_CONSECUTIVE_DEATHS` (`3`), -`PLAYERBOT_DEPOT_RESTART_PHASE` (empty), and `PLAYERBOT_DEPOT_MOVE_CASE` (`normal`). +`PLAYERBOT_DEPOT_RESTART_PHASE` (empty), `PLAYERBOT_DEPOT_VERIFIER_PHASE` (empty), +and `PLAYERBOT_DEPOT_MOVE_CASE` (`normal`). `Invoke-Scenario` applies these defaults before the body; individual cases may then override them. It restores incoming values after success or failure. Skipped scenarios do not touch the environment. Suite CLI options (including diff --git a/scripts/playerbot-gameplay/runtime.ps1 b/scripts/playerbot-gameplay/runtime.ps1 index c6d73082..52c210a8 100644 --- a/scripts/playerbot-gameplay/runtime.ps1 +++ b/scripts/playerbot-gameplay/runtime.ps1 @@ -451,6 +451,7 @@ function Invoke-Scenario { PLAYERBOT_RELOG_DELAY_SECONDS = "5" PLAYERBOT_MAX_CONSECUTIVE_DEATHS = "3" PLAYERBOT_DEPOT_RESTART_PHASE = "" + PLAYERBOT_DEPOT_VERIFIER_PHASE = "" PLAYERBOT_DEPOT_MOVE_CASE = "normal" } $incomingEnvironment = @{} diff --git a/scripts/playerbot-gameplay/scenarios-service.ps1 b/scripts/playerbot-gameplay/scenarios-service.ps1 index 51462db6..1e25664a 100644 --- a/scripts/playerbot-gameplay/scenarios-service.ps1 +++ b/scripts/playerbot-gameplay/scenarios-service.ps1 @@ -143,7 +143,8 @@ $env:PLAYERBOT_DEPOT_MOVE_CASE = "normal" Invoke-Compose up --detach Invoke-DatabaseCommand -Query "INSERT INTO player_depotitems (player_id, sid, pid, itemtype, count, attributes) SELECT id, 9001, 2, 2684, 7, X'' FROM players WHERE name = 'Rook Tester'" - $firstCycleLogs = Wait-ForLog -Pattern '"action":"depot_restart_checkpoint","result":"paused","phase":"depart"' + Wait-ForLog -Pattern '"action":"depot_restart_checkpoint","result":"paused","phase":"depart"' | Out-Null + $firstCycleLogs = Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST DEPOT_PASS' Assert-DepotEvents -Logs $firstCycleLogs -ExpectedDepositedCount 2 -ExpectedEquipmentDeposits 2 -ExpectedToolDeposits 4 Invoke-Compose stop server @@ -152,10 +153,11 @@ Invoke-DatabaseCommand -Query "UPDATE player_storage JOIN players ON players.id = player_storage.player_id SET player_storage.value = 0 WHERE players.name = 'Bot One' AND player_storage.key = 50095 AND player_storage.value = 2" $fixtureState = Invoke-DatabaseScalar -Query "SELECT value FROM player_storage JOIN players ON players.id = player_storage.player_id WHERE players.name = 'Bot One' AND player_storage.key = 50095" if ($fixtureState -ne 0) { throw "Could not re-arm real_depot second-cycle cargo." } - $env:PLAYERBOT_DEPOT_RESTART_PHASE = "" - # Changed fixture environment creates a new container with fresh logs. - Invoke-Compose up --detach --force-recreate server - $secondCycleLogs = Wait-ForLatestServerGenerationLog -Pattern '"action":"deposit","result":"complete"' + Invoke-DatabaseCommand -Query "UPDATE player_storage JOIN players ON players.id = player_storage.player_id SET player_storage.value = 0 WHERE players.name = 'Bot One' AND player_storage.key = 50096" + # Fresh logs, no provisioning: recovery must load only the saved inventory. + Invoke-Compose up --no-deps --detach --force-recreate server + Wait-ForLatestServerGenerationLog -Pattern '"action":"depot_restart_checkpoint","result":"paused","phase":"depart"' | Out-Null + $secondCycleLogs = Wait-ForLatestServerGenerationLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST DEPOT_PASS' Assert-DepotEvents -Logs $secondCycleLogs -ExpectedDepositedCount 1 -ExpectedEquipmentDeposits 0 $sentinelCount = Invoke-DatabaseScalar -Query "SELECT COALESCE(SUM(count), 0) FROM player_depotitems JOIN players ON players.id = player_depotitems.player_id WHERE players.name = 'Rook Tester' AND pid = 2 AND itemtype = 2684" if ($sentinelCount -ne 7) { @@ -183,8 +185,12 @@ throw "Depot $phase restart checkpoint did not pause exactly once. checkpoint=$($checkpointEvents.Count), transition=$($pausedTransitions.Count)." } Invoke-Compose stop server - Invoke-Compose up --detach server - $recoveryLogs = Wait-ForLatestServerGenerationLog -Pattern '"action":"deposit","result":"complete"' + Invoke-DatabaseCommand -Query "UPDATE player_storage JOIN players ON players.id = player_storage.player_id SET player_storage.value = 0 WHERE players.name = 'Bot One' AND player_storage.key = 50096" + $env:PLAYERBOT_DEPOT_VERIFIER_PHASE = $phase + $env:PLAYERBOT_DEPOT_RESTART_PHASE = "depart" + Invoke-Compose up --no-deps --detach --force-recreate server + Wait-ForLatestServerGenerationLog -Pattern '"action":"depot_restart_checkpoint","result":"paused","phase":"depart"' | Out-Null + $recoveryLogs = Wait-ForLatestServerGenerationLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST DEPOT_PASS' Assert-DepotRecoveryEvents -Logs $recoveryLogs -Phase $phase } } @@ -192,10 +198,11 @@ Invoke-Scenario -Name "real_depot_partial_move" -DefaultTimeoutSeconds 120 -Body { Invoke-Compose down --volumes --remove-orphans $env:PLAYERBOT_GAMEPLAY_MODE = "depot" - $env:PLAYERBOT_DEPOT_RESTART_PHASE = "" + $env:PLAYERBOT_DEPOT_RESTART_PHASE = "depart" $env:PLAYERBOT_DEPOT_MOVE_CASE = "partial" Invoke-Compose up --detach - $partialLogs = Wait-ForLog -Pattern '"action":"deposit","result":"complete","depot_id":2' + Wait-ForLog -Pattern '"action":"depot_restart_checkpoint","result":"paused","phase":"depart"' | Out-Null + $partialLogs = Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST DEPOT_PASS' $events = @(ConvertFrom-PlayerbotLogs -Logs $partialLogs) $partial = @($events | Where-Object { $_.action -eq "deposit" -and $_.result -eq "partial" -and $_.item_id -eq 2684 -and diff --git a/scripts/test-playerbot-depot-fixture.lua b/scripts/test-playerbot-depot-fixture.lua index af6fd61a..bf82e335 100644 --- a/scripts/test-playerbot-depot-fixture.lua +++ b/scripts/test-playerbot-depot-fixture.lua @@ -8,41 +8,109 @@ CONST_SLOT_ARMOR, CONST_SLOT_LEFT, CONST_SLOT_HEAD, CONST_SLOT_FEET = 4, 6, 1, 8 ITEM_GOLD_COIN = 2148 F.potionItemId = F.smallHealthPotionItemId F.PlayerbotThaisLockerDepotId = function() return 2 end -local phase, state, stored, carried -os.getenv = function(key) - assert(key == "PLAYERBOT_DEPOT_RESTART_PHASE") - return phase +local paused, stored, carried, equipment, pending +local originalPrint, originalFlush = print, io.flush +local buffered, visible +print = function(message) buffered = message end +io.flush = function() visible, buffered = buffered, nil end +os.getenv = function() error("delayed verifier must not read mutable environment") end +addEvent = function(callback, delay, ...) + assert(delay == 500) + local args = {...} + pending = function() callback((table.unpack or unpack)(args)) end end -local equipment = {[4] = F.starterArmorId, [6] = F.seedWeaponId, [1] = 2461, [8] = 2643} Player = function() return { isRemoved = function() return false end, getDepotChest = function() return {getItemCountById = function(_, id) return stored[id] or 0 end} end, - getStorageValue = function(_, key) assert(key == F.depotFixtureStorage); return state end, + getStorageValue = function(_, key) assert(key == 50096); return paused and 1 or 0 end, getItemCount = function(_, id) return carried[id] or 0 end, getSlotItem = function(_, slot) return {getId = function() return equipment[slot] end} end, } end -local function setup(checkpoint, secondCycle) - phase, state = checkpoint, secondCycle and 2 or 0 - stored = {[F.depotLootItemId] = secondCycle and 3 or 2, [2148] = 1, +local function setup(loot, gold) + paused, pending, visible, buffered = true, nil, nil, nil + equipment = {[4] = F.starterArmorId, [6] = F.seedWeaponId, [1] = 2461, [8] = 2643} + stored = {[F.depotLootItemId] = loot, [2148] = gold, [2380] = 1, [F.starterWeaponId] = 1, [2120] = 2, [2554] = 2} carried = {[F.depotLootItemId] = 2, [F.potionItemId] = 10, [2120] = 1, [2554] = 1} end -setup("depart", false) -state = 2 -- checkpoint-only restart does not seed another cheese -F.verifyDepot(3, 0) -setup("", true) -F.verifyDepot(3, 0) -- two persisted cheeses plus one new cargo; no meat was seeded -stored[F.depotLootItemId] = 2 -assert(not pcall(F.verifyDepot, 3, 0), "second cycle accepted missing new cargo") -setup("", true) +for _, gold in ipairs({0, 1}) do + setup(2, gold) + F.verifyDepot(3, 0, 2, gold) + assert(visible == "PLAYERBOT_GAMEPLAY_TEST DEPOT_PASS" and buffered == nil, + "paused verifier left its success marker buffered") +end +setup(3, 1) +F.verifyDepot(3, 0, 3, 1) +-- Matching food/gold is not proof that all deposits have finished. +paused = false +stored[2120] = 0 +F.verifyDepot(3, 1, 3, 1) +assert(pending, "verifier did not wait for Depart") +assert(not pcall(F.verifyDepot, 3, 0, 3, 1), "accepted inventory before pause") +paused, stored[2120] = true, 2 +pending() +for _, id in ipairs({F.depotLootItemId, 2148, 2380, F.starterWeaponId, 2120, 2554}) do + setup(3, 1) + stored[id] = stored[id] - 1 + assert(not pcall(F.verifyDepot, 3, 0, 3, 1), "accepted lost depot inventory: " .. id) +end +for _, id in ipairs({F.depotLootItemId, F.potionItemId, 2120, 2554}) do + setup(3, 1) + carried[id] = 0 + assert(not pcall(F.verifyDepot, 3, 0, 3, 1), "accepted lost reserve: " .. id) +end +for slot in pairs(equipment) do + setup(3, 1) + equipment[slot] = 0 + assert(not pcall(F.verifyDepot, 3, 0, 3, 1), "accepted lost equipment: " .. slot) +end +setup(3, 1) stored[F.potionItemId] = 1 -assert(not pcall(F.verifyDepot, 3, 0), "accepted deposited potion reserve") -setup("", true) -carried[F.potionItemId] = 4 -assert(not pcall(F.verifyDepot, 3, 0), "accepted depleted potion reserve") -setup("", true) -carried[F.depotLootItemId] = 1 -assert(not pcall(F.verifyDepot, 3, 0), "accepted missing retained cheese") -print("PASS depot fixture: checkpoint/two-cycle counts, unseeded meat, potion and cheese protection") +assert(not pcall(F.verifyDepot, 3, 0, 3, 1), "accepted deposited potion reserve") +print, io.flush = originalPrint, originalFlush +-- Exercise the actual login branch: snapshot before second-cycle storage mutation. +local file = assert(io.open("server/tests/playerbot-gameplay/includes/login.inc")) +local source = file:read("*a") +file:close() +local branch = assert(source:match('( if mode == "depot" then.-) if mode == "slotted_loot_seller"')) +local login = assert((loadstring or load)("return function(F, player, mode)\n" .. branch .. "end"))() +Condition = function() return {setParameter = function() end} end +local state, seeded, scheduled +local player = { + removeCondition = function() end, + addCondition = function() return true end, + getStorageValue = function() return state end, + setStorageValue = function(_, key, value) assert(key == F.depotFixtureStorage); state = value; return true end, + getSlotItem = function() return {addItem = function() seeded = true; return true end} end, + getId = function() return 3 end, +} +local env = {} +os.getenv = function(key) return env[key] end +addEvent = function(callback, delay, id, attempts, loot, gold) + assert(callback == F.verifyDepot and delay == 500 and id == 3 and attempts == 360) + scheduled = {loot, gold} +end +for _, phase in ipairs({"approach", "locker", "chest", "deposit", "depart"}) do + state, seeded, scheduled = 2, false, nil + env = {PLAYERBOT_DEPOT_RESTART_PHASE = "depart", PLAYERBOT_DEPOT_VERIFIER_PHASE = phase} + login(F, player, "depot") + assert(not seeded and scheduled[1] == 2) + assert(scheduled[2] == ((phase == "deposit" or phase == "depart") and 1 or 0)) +end +state, seeded, scheduled = 0, false, nil +env = {PLAYERBOT_DEPOT_RESTART_PHASE = "depart"} +login(F, player, "depot") +assert(state == 2 and seeded and scheduled[1] == 3 and scheduled[2] == 1) +for _, phase in ipairs({"approach", "locker", "chest", "deposit", ""}) do + state, scheduled = 2, nil + env = {PLAYERBOT_DEPOT_RESTART_PHASE = phase} + login(F, player, "depot") + assert(not scheduled, "successful verifier scheduled outside Depart") +end +state, scheduled = 2, nil +env = {PLAYERBOT_DEPOT_RESTART_PHASE = "depart", PLAYERBOT_DEPOT_MOVE_CASE = "rejected"} +login(F, player, "depot") +assert(not scheduled, "rejected moves invoked successful-depot verifier") +print("PASS depot fixture: login snapshots, bounded pause wait, explicit expectations, lost inventory and reserve protection") diff --git a/scripts/test-playerbot-depot-scenario.ps1 b/scripts/test-playerbot-depot-scenario.ps1 new file mode 100644 index 00000000..5375a027 --- /dev/null +++ b/scripts/test-playerbot-depot-scenario.ps1 @@ -0,0 +1,91 @@ +#Requires -Version 7.0 +$ErrorActionPreference = "Stop" +$Depot = $true +$MainlandLoop = $SlottedLoot = $SellLoot = $false +$previous = @{} +foreach ($key in @('PLAYERBOT_GAMEPLAY_MODE', 'PLAYERBOT_DEPOT_RESTART_PHASE', 'PLAYERBOT_DEPOT_VERIFIER_PHASE', 'PLAYERBOT_DEPOT_MOVE_CASE')) { + $previous[$key] = [Environment]::GetEnvironmentVariable($key) +} +function Invoke-Scenario { + param($Name, $DefaultTimeoutSeconds, [scriptblock]$Body) + if ($Name -notmatch '^real_depot($|_restart_|_partial_move$)') { return } + $script:scenario = $Name + $script:generation = 0 + $script:stopped = $false + $script:rearmed = $false + $script:seeded = $false + $script:checkpoint = $false + $script:verified = $false + $env:PLAYERBOT_DEPOT_VERIFIER_PHASE = '' + & $Body + $expectedGenerations = if ($Name -eq 'real_depot_partial_move') { 1 } else { 2 } + if (-not $script:verified -or $script:generation -ne $expectedGenerations) { throw "Missing completed recovery verification: $Name" } +} +function Invoke-Compose { + $command = $args -join ' ' + if ($command -eq 'stop server') { + if (-not $script:checkpoint) { throw 'Stopped before checkpoint' } + if ($script:scenario -eq 'real_depot' -and -not $script:verified) { throw 'Stopped before inventory verification' } + $script:stopped = $true + } + if ($args[0] -eq 'up') { + if ($script:generation -eq 1) { + if ($args -notcontains '--no-deps') { throw 'Recovery must not rerun provisioning' } + if (-not $script:stopped -or -not $script:rearmed) { throw 'Restart before persisted checkpoint rearm' } + if ($env:PLAYERBOT_DEPOT_RESTART_PHASE -ne 'depart') { throw 'Recovery must pause before selling' } + if ($script:scenario -eq 'real_depot') { + if (-not $script:seeded) { throw 'Missing second-cycle cargo rearm' } + } elseif ($env:PLAYERBOT_DEPOT_VERIFIER_PHASE -ne ($script:scenario -replace '^real_depot_restart_', '')) { + throw 'Original recovery phase lost' + } + } + $script:generation++ + $script:checkpoint = $script:verified = $false + } +} +function Invoke-DatabaseCommand { + param($Query) + if ($Query -match 'UPDATE player_storage') { + if (-not $script:stopped) { throw 'Storage changed while server running' } + if ($Query -match '50096') { $script:rearmed = $true } + if ($Query -match '50095') { $script:seeded = $true } + } +} +function Invoke-DatabaseScalar { param($Query) if ($Query -match '50095') { return 0 }; return 7 } +function Wait-ForLog { + param($Pattern) + if ($Pattern -match 'depot_restart_checkpoint') { + if ($Pattern -notmatch ('"phase":"' + $env:PLAYERBOT_DEPOT_RESTART_PHASE + '"')) { throw 'Wrong checkpoint' } + $script:checkpoint = $true + } elseif ($Pattern -match 'DEPOT_PASS') { + if (-not $script:checkpoint) { throw 'Inventory verification before checkpoint wait' } + $script:verified = $true + } else { throw "Unexpected wait: $Pattern" } + return 'stub logs' +} +function Wait-ForLatestServerGenerationLog { param($Pattern) Wait-ForLog -Pattern $Pattern } +function ConvertFrom-PlayerbotLogs { + param($Logs) + if ($script:scenario -eq 'real_depot_partial_move') { + [pscustomobject]@{event='action_result'; action='deposit'; result='partial'; item_id=2684; requested=2; verified=1; inventory_before=4; inventory_after=3; depot_before=0; depot_after=1} + [pscustomobject]@{event='action_result'; action='deposit'; result='requested'; requested=2; submitted=1} + return + } + [pscustomobject]@{event='action_result'; action='depot_restart_checkpoint'; result='paused'; phase=$env:PLAYERBOT_DEPOT_RESTART_PHASE} + [pscustomobject]@{event='state_transition'; to='paused'} +} +function Assert-DepotEvents { + param($Logs, $ExpectedDepositedCount, $ExpectedEquipmentDeposits, $ExpectedToolDeposits) + if (-not $script:verified) { throw 'Deposit assertions before Lua verification' } + if ($ExpectedDepositedCount -ne (3 - $script:generation)) { throw 'Changed deposit counts' } +} +function Assert-DepotRecoveryEvents { + param($Logs, $Phase) + if (-not $script:verified -or $Phase -ne $env:PLAYERBOT_DEPOT_VERIFIER_PHASE) { throw 'Recovery assertions lost original phase or inventory verification' } +} +try { + . (Join-Path $PSScriptRoot 'playerbot-gameplay/scenarios-service.ps1') + Write-Host 'Depot scenario pause/wait/rearm ordering passed for both cycles, all five restarts, and partial moves.' +} finally { + foreach ($key in $previous.Keys) { [Environment]::SetEnvironmentVariable($key, $previous[$key]) } +} diff --git a/scripts/test-playerbot-gameplay.ps1 b/scripts/test-playerbot-gameplay.ps1 index 18ed819f..03a5429e 100644 --- a/scripts/test-playerbot-gameplay.ps1 +++ b/scripts/test-playerbot-gameplay.ps1 @@ -119,6 +119,7 @@ $previousMode = $env:PLAYERBOT_GAMEPLAY_MODE $previousRelogDelay = $env:PLAYERBOT_RELOG_DELAY_SECONDS $previousMaximumDeaths = $env:PLAYERBOT_MAX_CONSECUTIVE_DEATHS $previousDepotRestartPhase = $env:PLAYERBOT_DEPOT_RESTART_PHASE +$previousDepotVerifierPhase = $env:PLAYERBOT_DEPOT_VERIFIER_PHASE $previousDepotMoveCase = $env:PLAYERBOT_DEPOT_MOVE_CASE $timeoutOverridden = $PSBoundParameters.ContainsKey("TimeoutSeconds") $timings = [ordered]@{} @@ -228,6 +229,7 @@ finally { $env:PLAYERBOT_RELOG_DELAY_SECONDS = $previousRelogDelay $env:PLAYERBOT_MAX_CONSECUTIVE_DEATHS = $previousMaximumDeaths $env:PLAYERBOT_DEPOT_RESTART_PHASE = $previousDepotRestartPhase + $env:PLAYERBOT_DEPOT_VERIFIER_PHASE = $previousDepotVerifierPhase $env:PLAYERBOT_DEPOT_MOVE_CASE = $previousDepotMoveCase foreach ($timing in $timings.GetEnumerator()) { "PLAYERBOT_GAMEPLAY_TIMING $($timing.Key)=$([Math]::Round($timing.Value.TotalSeconds, 2))s" diff --git a/scripts/test-playerbot-scenario-isolation.ps1 b/scripts/test-playerbot-scenario-isolation.ps1 index 0f98cb66..d13bcd82 100644 --- a/scripts/test-playerbot-scenario-isolation.ps1 +++ b/scripts/test-playerbot-scenario-isolation.ps1 @@ -20,6 +20,7 @@ $defaults = @{ PLAYERBOT_RELOG_DELAY_SECONDS = "5" PLAYERBOT_MAX_CONSECUTIVE_DEATHS = "3" PLAYERBOT_DEPOT_RESTART_PHASE = "" + PLAYERBOT_DEPOT_VERIFIER_PHASE = "" PLAYERBOT_DEPOT_MOVE_CASE = "normal" } $unrelated = @{ PLAYERBOT_SPEED_BONUS = "123"; PLAYERBOT_REGRESSION_MODE = "untouched" } diff --git a/server/compose.playerbot-gameplay.yaml b/server/compose.playerbot-gameplay.yaml index 2edd839a..a167a78a 100644 --- a/server/compose.playerbot-gameplay.yaml +++ b/server/compose.playerbot-gameplay.yaml @@ -7,6 +7,7 @@ services: environment: PLAYERBOT_GAMEPLAY_MODE: "${PLAYERBOT_GAMEPLAY_MODE:-cycle}" PLAYERBOT_DEPOT_RESTART_PHASE: "${PLAYERBOT_DEPOT_RESTART_PHASE:-}" + PLAYERBOT_DEPOT_VERIFIER_PHASE: "${PLAYERBOT_DEPOT_VERIFIER_PHASE:-}" PLAYERBOT_DEPOT_MOVE_CASE: "${PLAYERBOT_DEPOT_MOVE_CASE:-normal}" volumes: - ./tests/playerbot-gameplay:/app/data/scripts/playerbot-gameplay:ro diff --git a/server/tests/playerbot-gameplay/includes/login.inc b/server/tests/playerbot-gameplay/includes/login.inc index 5dc84a7f..2276db24 100644 --- a/server/tests/playerbot-gameplay/includes/login.inc +++ b/server/tests/playerbot-gameplay/includes/login.inc @@ -481,6 +481,11 @@ function F.login.onLogin(player) food:setParameter(CONDITION_PARAM_TICKS, 1200000) assert(player:addCondition(food), "depot fixture could not fill the food timer") local fixtureState = player:getStorageValue(F.depotFixtureStorage) + local restartPhase = os.getenv("PLAYERBOT_DEPOT_RESTART_PHASE") or "" + local verifierPhase = os.getenv("PLAYERBOT_DEPOT_VERIFIER_PHASE") or "" + local expectedLoot = fixtureState == 0 and 3 or 2 + local expectedPriorGold = (verifierPhase == "approach" or verifierPhase == "locker" or + verifierPhase == "chest") and 0 or 1 if fixtureState == -1 then for _, slot in ipairs({CONST_SLOT_HEAD, CONST_SLOT_ARMOR, CONST_SLOT_RIGHT, CONST_SLOT_LEFT, CONST_SLOT_LEGS, CONST_SLOT_FEET}) do local item = player:getSlotItem(slot) @@ -511,14 +516,15 @@ function F.login.onLogin(player) assert(backpack:addItem(2120, 1) and backpack:addItem(2120, 1) and backpack:addItem(2554, 1) and backpack:addItem(2554, 1), "depot fixture could not seed surplus navigation tools") - local restartPhase = os.getenv("PLAYERBOT_DEPOT_RESTART_PHASE") or "" assert(player:setStorageValue(F.depotFixtureStorage, restartPhase == "" and 0 or 2), "depot fixture state could not persist") elseif fixtureState == 0 then local backpack = player:getSlotItem(CONST_SLOT_BACKPACK) assert(backpack and backpack:addItem(F.depotLootItemId, 1), "depot fixture could not seed second-cycle loot") assert(player:setStorageValue(F.depotFixtureStorage, 2), "depot second-cycle state could not persist") end - addEvent(F.verifyDepot, 500, player:getId(), 360) + if restartPhase == "depart" and os.getenv("PLAYERBOT_DEPOT_MOVE_CASE") ~= "rejected" then + addEvent(F.verifyDepot, 500, player:getId(), 360, expectedLoot, expectedPriorGold) + end print("PLAYERBOT_GAMEPLAY_TEST DEPOT_START") return true end diff --git a/server/tests/playerbot-gameplay/includes/verifiers.inc b/server/tests/playerbot-gameplay/includes/verifiers.inc index 7870e2c1..965dc6f9 100644 --- a/server/tests/playerbot-gameplay/includes/verifiers.inc +++ b/server/tests/playerbot-gameplay/includes/verifiers.inc @@ -390,23 +390,22 @@ function F.verifyService(playerId, initialDepotBagCount, initialDepotSaleItemCou print("PLAYERBOT_GAMEPLAY_TEST SERVICE_PASS") end -function F.verifyDepot(playerId, attempts) +function F.verifyDepot(playerId, attempts, expectedLoot, expectedPriorGold) local player = Player(playerId) if not player or player:isRemoved() then if attempts > 0 then - addEvent(F.verifyDepot, 500, playerId, attempts - 1) + addEvent(F.verifyDepot, 500, playerId, attempts - 1, expectedLoot, expectedPriorGold) end return end local chest = player:getDepotChest(F.PlayerbotThaisLockerDepotId(), false) - local restartPhase = os.getenv("PLAYERBOT_DEPOT_RESTART_PHASE") or "" - local expectedLoot = restartPhase ~= "" and 2 or player:getStorageValue(F.depotFixtureStorage) == 2 and 3 or 2 - local priorGoldPersists = restartPhase == "" or restartPhase == "deposit" or restartPhase == "depart" - local expectedPriorGold = priorGoldPersists and 1 or 0 - local complete = chest and chest:getItemCountById(F.depotLootItemId) == expectedLoot and + -- Depart sets this one-shot marker and pauses in the same dispatcher turn. + -- Counts alone can match before equipment/tool deposits finish on restart. + local complete = player:getStorageValue(50096) == 1 and chest and + chest:getItemCountById(F.depotLootItemId) == expectedLoot and chest:getItemCountById(ITEM_GOLD_COIN) == expectedPriorGold if not complete and attempts > 0 then - addEvent(F.verifyDepot, 500, playerId, attempts - 1) + addEvent(F.verifyDepot, 500, playerId, attempts - 1, expectedLoot, expectedPriorGold) return end assert(complete, "real Thais depot did not retain prior contents and nested loot") @@ -426,6 +425,8 @@ function F.verifyDepot(playerId, attempts) assert(player:getItemCount(F.potionItemId) >= 5 and chest:getItemCountById(F.potionItemId) == 0, "supply reserves were deposited") print("PLAYERBOT_GAMEPLAY_TEST DEPOT_PASS") + -- A paused controller emits no later telemetry to flush this pipe-backed stream. + io.flush() end function F.verifyDepotRiskFallback(playerId, attempts) From e4bffb40f8599d128b365ec9511ab53540c06bd6 Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:29:07 +0200 Subject: [PATCH 7/8] testing: isolate banking and spell-learning fixture inputs --- docs/linux-validation.md | 47 +++++--- docs/playerbots.md | 8 +- docs/testing.md | 27 +++++ .../assertions-readiness.ps1 | 19 ++++ .../scenarios-progression.ps1 | 14 +-- scripts/test-playerbot-fixture-isolation.lua | 102 +++++++++++++++++- .../test-playerbot-readiness-assertions.ps1 | 38 ++++++- .../playerbot-gameplay/includes/login.inc | 16 +-- .../playerbot-gameplay/includes/verifiers.inc | 10 +- 9 files changed, 238 insertions(+), 43 deletions(-) diff --git a/docs/linux-validation.md b/docs/linux-validation.md index a524310c..f2bbaa52 100644 --- a/docs/linux-validation.md +++ b/docs/linux-validation.md @@ -16,14 +16,13 @@ scenario passes. Commands and prerequisites are in [testing.md](testing.md) and coverage; in-game testing remains necessary. Client fixes remain separate from the tooling work. -The last full 87-scenario run reported **75 passed, 8 failed assertions, and -4 timeouts**. Subsequent targeted runs validate corrections; they do not replace -a fresh full-suite result. The 22-case follow-up passed 20 cases initially. -After fixture corrections, all five depot restart checkpoints and arbitration -passed a seven-case rerun; `real_depot` then passed its final one-case rerun. -Thus all 22 selected cases have passing follow-up evidence, across separate runs. -The selection includes all five full-navigation cases, all three corpse cases, -restart log consumers, and the affected depot/service/progression scenarios. +The final full 87-scenario run reported **85 passed, 2 failed assertions, +0 timeouts, and 0 skipped**. It rebuilt the server and ran with +`-ContinueOnFailure`. Only `magic_training_post_hunt` and +`magic_training_post_hunt_no_overflow` failed. These known gameplay-policy +mismatches are deferred from the Linux migration; the suite is not fully green. +Hunt-area arrival, all navigation and corpse cases, and the corrected economic, +restart, and depot fixtures passed this run. ## Remaining work outside portability @@ -32,9 +31,6 @@ migration green. Reproduce them independently before changing gameplay policy. | Scenario | Observed problem / next decision | | --- | --- | -| `hunt_area_arrival` | The selected destination is reachable, but patrol safety rejects the later route. Define bounded safe-region fallback without relaxing danger limits. | -| `combat_readiness_low_wealth` | Sale cargo goes to the depot before service; the fixture expects 56 gp including proceeds but observes a 50 gp withdrawal. Isolate sale/service inputs. | -| `magic_training_progression` | Equipment/service work consumes the funds intended for spell learning. Isolate the learning/affordability contract. | | `magic_training_post_hunt`, `magic_training_post_hunt_no_overflow` | The controller visits the depot before arbitration. Tests expect immediate post-hunt `Idle` arbitration; the detour also changes mana overflow. Decide intended hunt-end behavior first. | The first isolation follow-up validated `mainland_loop`, @@ -53,9 +49,32 @@ All six normal/restart scenarios passed the final targeted run; partial and rejected moves passed the preceding run. Production depot/selling policy is unchanged. -The ordinary `corpse` scenario has both passing and failing traces: a defensive -attacker classification previously prevented normal looting. The latest targeted -run passed all three corpse cases; this does not establish timing stability. +The economic-fixture follow-up passed `magic_training_progression`, +`magic_training_reserve`, and `magic_training_service`. Progression now starts +with 600 total gp, ten selected potions, and two meat; the nearby currency reward +is suppressed. This proves learning-goal priority, not completed spell payment. + +The low-wealth contract deliberately separates banking from liquidation: it seeds +56 bank gp rather than requiring 50 gp plus rabbit-sale proceeds. Its initial +zero-capacity setup could not recover the 30 oz threshold after depositing only +the displaced 25 oz club. Removing capacity pressure instead allowed unrelated +reward/equipment work to run first. The final fixture starts with 10 oz free, +forcing readiness service while allowing the club deposit to restore 35 oz. +Local regressions and the final live rerun passed. The live scenario completed +in 22.3 seconds, verifying the exact 56 gp withdrawal, retained upgrade, hunt +resumption, and absence of sales or terminal events. Reproduce with: + +```sh +pwsh -File scripts/test-playerbot-gameplay.ps1 -Scenario combat_readiness_low_wealth -SkipBuild +``` + +Docker approval is required on the current machine; no permission workaround +is part of this work. + +Earlier runs exposed hunt-area patrol safety and ordinary-corpse attacker +classification failures. Both scenarios passed the final full run. Retain these +observations if either recurs; a single passing run does not establish timing +stability. ## Test isolation follow-up diff --git a/docs/playerbots.md b/docs/playerbots.md index 6ee03d1c..d6e39e8a 100644 --- a/docs/playerbots.md +++ b/docs/playerbots.md @@ -403,7 +403,13 @@ carried-gold reserve. If total gold cannot raise stock above the return threshold, service stops with `insufficient_potion_funds` without buying an unusable partial reserve. The cycle deposits carried money and withdraws up to 100 gp without exceeding the bot's total available gold. It does not buy food -merely because none is carried. +merely because none is carried. The low-wealth regression isolates this banking +contract with 56 bank gp, zero carried gp, ten selected health potions, and no +sale cargo. Initial free capacity is 10 oz (1000 native units), below the 30 oz +readiness threshold. Depositing the displaced 25 oz club restores 35 oz, above +the 30 oz return threshold, without sale cargo; currency weight is reclaimable. +It verifies a normal 56 gp withdrawal and retention of equipped +upgrade 2384 before hunting; dedicated liquidation scenarios cover selling. Hunting ends after the configured duration or below 30 oz effective free capacity. Effective capacity is physical free capacity plus the weight of carried standard food and currency, because food can be consumed or diff --git a/docs/testing.md b/docs/testing.md index 49780901..3ee22049 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -149,6 +149,33 @@ not prove that the image matches the worktree. `-KeepStack` preserves the final stack for debugging. `-TimeoutSeconds` accepts `30` through `3600` and replaces each scenario's fail-fast deadline. +`combat_readiness_low_wealth` isolates banking from selling: zero carried gold, +56 gp in the bank, ten selected health potions, a carried upgrade (2384), and +no rabbit sale cargo. Setup leaves 10 oz free capacity (`usedCapacity + 1000` +in native units), below the 30 oz readiness threshold. Depositing the displaced +25 oz club (2382) restores 35 oz, above the 30 oz return threshold. This forces +readiness → equip → depot → bank without sale proceeds or a zero-capacity dead +end; withdrawn currency weight remains reclaimable. +It requires exactly one normal 56 gp withdrawal (bank +56 → 0), the Lua-verified 56 gp carried balance and equipped upgrade, a hunt +start, and no sale or terminal event. Liquidation remains in `sell_loot` and +`sell_loot_remote_depot`: their assertions check manifest withdrawal and sales; +the local case also checks proceeds-funded potion resupply. + +`magic_training_progression` seeds ten selected health potions, two meat, +100 carried gp and 500 bank gp. This leaves the 100 gp reserve plus the +500 gp Great Light price. Only nearby currency reward 50082 is marked claimed. +The scenario ends at `learn_spell` selection over feasible magic training +(utilities 550 and 350); it does not wait for NPC dialogue or spell payment. +`magic_training_reserve` and `magic_training_service` retain their intentional +low-mana and capacity/service setups. + +Local regressions: `lua scripts/test-playerbot-fixture-isolation.lua` and +`pwsh -File scripts/test-playerbot-readiness-assertions.ps1`. Live coverage uses +`-Focused -CombatReadiness` and `-Focused -MagicTraining -MagicTrainingCase` +with each of `magic_training_progression`, `magic_training_reserve`, and +`magic_training_service` on `scripts/test-playerbot-gameplay.ps1`. + Use `-MagicTrainingCase ` with `-Focused` to run one case from the 16-scenario magic-training matrix without paying for the other server recreations. PowerShell validates the case name from the supported mode list. diff --git a/scripts/playerbot-gameplay/assertions-readiness.ps1 b/scripts/playerbot-gameplay/assertions-readiness.ps1 index bb6552e6..8caf7106 100644 --- a/scripts/playerbot-gameplay/assertions-readiness.ps1 +++ b/scripts/playerbot-gameplay/assertions-readiness.ps1 @@ -1,3 +1,22 @@ +function Assert-LowWealthEvents { + param([string]$Logs) + + $events = @(ConvertFrom-PlayerbotLogs -Logs $Logs) + $withdrawals = @($events | Where-Object { $_.action -eq 'bank_withdraw' }) + $upgrades = @($events | Where-Object { $_.action -eq 'equip_readiness' -and $_.result -eq 'success' }) + $hunts = @($events | Where-Object { $_.event -eq 'action_result' -and $_.action -eq 'hunt_cycle' -and $_.result -eq 'started' }) + $forbidden = @($events | Where-Object { $_.action -eq 'sell' -or $_.event -eq 'terminal' }) + if ($withdrawals.Count -ne 1 -or $withdrawals[0].event -ne 'action_result' -or + $withdrawals[0].result -ne 'success' -or $withdrawals[0].count -ne 56 -or + $withdrawals[0].bank_before -ne 56 -or $withdrawals[0].bank_after -ne 0 -or + $upgrades.Count -ne 1 -or $upgrades[0].event -ne 'action_result' -or + $upgrades[0].result -ne 'success' -or $upgrades[0].item_id -ne 2384 -or + $hunts.Count -lt 1 -or $forbidden.Count -ne 0 -or + $Logs -notmatch '(?m)^PLAYERBOT_GAMEPLAY_TEST READINESS_LOW_WEALTH_PASS\r?$') { + throw 'Low-wealth banking did not retain the upgrade and carry exactly 56 gp into hunting without sales.' + } +} + function Assert-CombatReadinessEvents { param([string]$Logs, [string]$Mode) diff --git a/scripts/playerbot-gameplay/scenarios-progression.ps1 b/scripts/playerbot-gameplay/scenarios-progression.ps1 index c3e511a4..a723990b 100644 --- a/scripts/playerbot-gameplay/scenarios-progression.ps1 +++ b/scripts/playerbot-gameplay/scenarios-progression.ps1 @@ -193,19 +193,7 @@ Invoke-Compose up --detach Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST READINESS_LOW_WEALTH_PASS' | Out-Null $lowWealthLogs = Wait-ForLog -Pattern '"action":"hunt_cycle".*"result":"started"' - $events = @(ConvertFrom-PlayerbotLogs -Logs $lowWealthLogs) - $withdrawal = @($events | Where-Object { - $_.action -eq "bank_withdraw" -and $_.result -eq "success" -and $_.count -eq 56 -and - $_.bank_before -eq 56 -and $_.bank_after -eq 0 - }) - $upgrade = @($events | Where-Object { - $_.action -eq "equip_readiness" -and $_.result -eq "success" -and $_.item_id -eq 2384 - }) - $soldUpgrade = @($events | Where-Object { $_.action -eq "sell" -and $_.item_id -eq 2384 }) - if ($withdrawal.Count -lt 1 -or $upgrade.Count -ne 1 -or $soldUpgrade.Count -ne 0 -or - @($events | Where-Object { $_.event -eq "terminal" }).Count -ne 0) { - throw "Low-wealth service did not withdraw the available balance and resume hunting." - } + Assert-LowWealthEvents -Logs $lowWealthLogs } Invoke-Scenario -Name "combat_readiness_food_capacity" -DefaultTimeoutSeconds 60 -Body { Invoke-Compose down --volumes --remove-orphans diff --git a/scripts/test-playerbot-fixture-isolation.lua b/scripts/test-playerbot-fixture-isolation.lua index a24e290f..df90f711 100644 --- a/scripts/test-playerbot-fixture-isolation.lua +++ b/scripts/test-playerbot-fixture-isolation.lua @@ -15,7 +15,7 @@ dofile(loginPath) local selectedMode os.getenv = function() return selectedMode end local isolated = { - progression = true, readiness_low_wealth = true, arbitration = true, + progression = true, readiness_low_wealth = true, arbitration = true, magic_training_progression = true, magic_training_haste = true, magic_training_great_light = true, magic_training_light = true, magic_training_refresh = true, magic_training_failed = true, magic_training_restart = true, magic_training_hunt = true, magic_training_post_hunt = true, magic_training_post_hunt_no_overflow = true, @@ -133,6 +133,86 @@ for _, missing in ipairs({50076, 50082, 50083}) do assert(not success and tostring(failure):find("did not persist", 1, true), "restart accepted missing competing reward isolation") end +-- Complete the economic/priority login paths with player/world boundary doubles. +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 +local function economicLogin(mode) + selectedMode = mode + local inventory = {[7618] = 7, [F.meatItemId] = 3, [F.saleItemId] = 3} + local storage, spells = {}, {} + 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 + return true + end} + 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, + getExperience = function() return 0 end, + getVocation = function() return {getId = function() return vocation end} end, + setVocation = function(_, id) vocation = id; return true end, + getStorageValue = function(_, key) return storage[key] or -1 end, + setStorageValue = function(_, key, value) storage[key] = value; return true end, + getSlotItem = function(_, slot) if slot == CONST_SLOT_BACKPACK then return backpack end end, + getItemCount = function(_, id) return inventory[id] or 0 end, + addItem = backpack.addItem, + 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, + addMoney = function(_, amount) money = money + amount; return true end, + getBankBalance = function() return bank end, + setBankBalance = function(_, amount) bank = amount; return true end, + getCapacity = function() return capacity end, + getFreeCapacity = function() return 2000 end, + setCapacity = function(_, amount) + if mode == 'readiness_low_wealth' then + assert(amount == 9000, 'low-wealth setup must leave 1000 units (10 oz) free') + end + capacity = amount + return true + end, + setTown = function() return true end, + teleportTo = function() return true end, + forgetSpell = function(_, name) spells[name] = nil end, + learnSpell = function(_, name) spells[name] = true; return true end, + setMaxMana = function() return true end, + getMaxMana = function() return 1000 end, + getMana = function() return mana end, + addMana = function(_, amount) mana = mana + amount; return true end, + removeCondition = function() end, + addCondition = function() return true end, + } + addEvent = function(callback) + assert(callback == 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 + 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. + assert(freeCapacity == 1000 and freeCapacity < 3000 and freeCapacity + 2500 == 3500) + elseif mode == 'magic_training_progression' then + assert(money == 100 and bank == 500 and inventory[7618] == 10 and inventory[F.meatItemId] == 2) + assert(spells.Light and not spells['Great Light'] and not spells['Find Person']) + assert(capacity == 10000 and mana == 1000 and scheduled == 0) + else + assert(bank == 23 and inventory[F.meatItemId] == 3 and scheduled == 0) + assert(inventory[7618] == (mode == 'magic_training_service' and 7 or 2)) + assert(capacity == (mode == 'magic_training_service' and 1 or 10000)) + 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 + economicLogin(mode) +end -- Exercise the real magic reserve setup, stopping before unrelated level/spell APIs. Town = function() return {} end Game = {getExperienceForLevel = function() error(boundary) end} @@ -176,4 +256,22 @@ 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 -print("PASS fixture isolation: " .. count .. " modes, rapier setup/restart, magic reserves/restart, other-player guard, failed writes") +dofile('server/tests/playerbot-gameplay/includes/verifiers.inc') +local function verifyWealth(money, bank, weapon, cargo) + Player = function() return { + isRemoved = function() return false end, + getMoney = function() return money end, + getBankBalance = function() return bank end, + getSlotItem = function() return weapon and {getId = function() return weapon end} end, + getItemCount = function(_, id) assert(id == F.saleItemId); return cargo end, + } end + return pcall(F.verifyLowWealth, 3, 0) +end +assert(verifyWealth(56, 0, 2384, 0)) +assert(not verifyWealth(50, 0, 2384, 0)) +assert(not verifyWealth(57, 0, 2384, 0)) +assert(not verifyWealth(56, 1, 2384, 0)) +assert(not verifyWealth(56, 0, 2382, 0)) +assert(not verifyWealth(56, 0, nil, 0)) +assert(not verifyWealth(56, 0, 2384, 1)) +print("PASS fixture isolation: " .. count .. " modes, rapier setup/restart, economic login/verification, magic reserves/service/restart, other-player guard, failed writes") diff --git a/scripts/test-playerbot-readiness-assertions.ps1 b/scripts/test-playerbot-readiness-assertions.ps1 index 4f3f4501..7246845d 100644 --- a/scripts/test-playerbot-readiness-assertions.ps1 +++ b/scripts/test-playerbot-readiness-assertions.ps1 @@ -42,4 +42,40 @@ $events = @(New-FoodFixture); $events[0].requirements[2].count = 2; Test-Fixture $events = @(New-FoodFixture); $events[1].result = 'failed'; Test-Fixture $events $true $events = @(New-FoodFixture); Test-Fixture ($events + @{ action = 'buy_meat'; result = 'success' }) $true $events = @(New-FoodFixture); Test-Fixture ($events + @{ event = 'combat_readiness'; selected_recovery = 'service' }) $true -Write-Host 'Food-capacity assertion regressions passed.' +function New-LowWealthFixture { + @( + @{ event = 'action_result'; action = 'bank_withdraw'; result = 'success'; count = 56; bank_before = 56; bank_after = 0 } + @{ event = 'action_result'; action = 'equip_readiness'; result = 'success'; item_id = 2384 } + @{ event = 'action_result'; action = 'hunt_cycle'; result = 'started' } + ) +} +function Test-LowWealthFixture([array]$Events, [bool]$Reject, [bool]$VerifiedInventory = $true) { + $logs = ($Events | ForEach-Object { + $_.component = 'playerbot'; $_.bot = 'Bot One' + $_ | ConvertTo-Json -Depth 8 -Compress + }) -join "`n" + if ($VerifiedInventory) { $logs += "`nPLAYERBOT_GAMEPLAY_TEST READINESS_LOW_WEALTH_PASS" } + try { Assert-LowWealthEvents -Logs $logs } + catch { if ($Reject) { return }; throw } + if ($Reject) { throw 'Invalid low-wealth fixture unexpectedly passed.' } +} +Test-LowWealthFixture (New-LowWealthFixture) $false +Test-LowWealthFixture (@(@{ event = 'action_result'; action = 'equip_readiness'; result = 'requested'; item_id = 2384 }) + @(New-LowWealthFixture)) $false +foreach ($field in @('count', 'bank_before', 'bank_after', 'event', 'result')) { + $events = @(New-LowWealthFixture); $events[0][$field] = 50 + Test-LowWealthFixture $events $true + $events = @(New-LowWealthFixture); $events[0].Remove($field) + Test-LowWealthFixture $events $true +} +foreach ($index in 0..2) { + $events = @(New-LowWealthFixture) + Test-LowWealthFixture @($events | Select-Object -SkipIndex $index) $true +} +$events = @(New-LowWealthFixture); Test-LowWealthFixture ($events + $events[0]) $true +$events = @(New-LowWealthFixture); $events[1].item_id = 2382; Test-LowWealthFixture $events $true +foreach ($item in @(2384, 2699)) { + Test-LowWealthFixture (@(New-LowWealthFixture) + @{ event = 'action_result'; action = 'sell'; item_id = $item; result = 'success' }) $true +} +Test-LowWealthFixture (@(New-LowWealthFixture) + @{ event = 'terminal' }) $true +Test-LowWealthFixture (New-LowWealthFixture) $true $false +Write-Host 'Food-capacity and low-wealth assertion regressions passed.' diff --git a/server/tests/playerbot-gameplay/includes/login.inc b/server/tests/playerbot-gameplay/includes/login.inc index 2276db24..495545e0 100644 --- a/server/tests/playerbot-gameplay/includes/login.inc +++ b/server/tests/playerbot-gameplay/includes/login.inc @@ -23,7 +23,7 @@ function F.login.onLogin(player) end -- The nearby currency reward otherwise consumes the magic-training window -- or preempts the intended readiness work. Leave these cases' other rewards alone. - elseif mode == "readiness_low_wealth" or + elseif mode == "readiness_low_wealth" or mode == "magic_training_progression" or mode == "magic_training_haste" or mode == "magic_training_great_light" or mode == "magic_training_light" or mode == "magic_training_refresh" or mode == "magic_training_failed" or mode == "magic_training_restart" or mode == "magic_training_hunt" or mode == "magic_training_post_hunt" or mode == "magic_training_post_hunt_no_overflow" then @@ -339,9 +339,9 @@ function F.login.onLogin(player) player:forgetSpell("Find Person") F.removeAll(player, F.potionItemId) F.removeAll(player, F.meatItemId) - assert(player:addItem(F.potionItemId, 5) and player:addItem(F.meatItemId, 1), "magic training progression fixture could not prepare service reserves") - assert(player:removeMoney(player:getMoney()) and player:addMoney(100) and player:setBankBalance(305), "magic training progression fixture could not normalize spell training funds") - assert(player:getMoney() == 100 and player:getBankBalance() == 305, "magic training progression fixture has the wrong service and spell-training reserves") + assert(player:addItem(F.potionItemId, 10) and player:addItem(F.meatItemId, 2), "magic training progression fixture could not prepare service reserves") + assert(player:removeMoney(player:getMoney()) and player:addMoney(100) and player:setBankBalance(500), "magic training progression fixture could not normalize spell training funds") + assert(player:getMoney() == 100 and player:getBankBalance() == 500, "magic training progression fixture has the wrong service and spell-training reserves") local trainer = Npc("Gregor") assert(trainer, "magic training progression fixture could not find Gregor") local trainerPosition = trainer:getPosition() @@ -391,12 +391,14 @@ function F.login.onLogin(player) elseif mode == "readiness_low_wealth" then local money = player:getMoney() if money > 0 then assert(player:removeMoney(money), "low-wealth fixture could not clear carried money") end - assert(player:setBankBalance(50), "low-wealth fixture could not set the bank balance") + assert(player:setBankBalance(56), "low-wealth fixture could not set the bank balance") local backpack = player:getSlotItem(CONST_SLOT_BACKPACK) assert(backpack and backpack:addItem(F.pickupRewardId, 1), "low-wealth fixture could not add a carried upgrade") - assert(backpack:addItem(F.saleItemId, 1) and backpack:addItem(F.saleItemId, 1) and backpack:addItem(F.saleItemId, 1), "low-wealth fixture could not add saleable capacity cargo") + F.removeAll(player, F.saleItemId) -- Banking must not depend on liquidation proceeds. + -- Start below readiness; depositing the displaced 25 oz club restores 35 oz, + -- above the 30 oz return threshold, without depending on sale cargo. local usedCapacity = player:getCapacity() - player:getFreeCapacity() - assert(player:setCapacity(usedCapacity), "low-wealth fixture could not exhaust physical capacity") + assert(player:setCapacity(usedCapacity + 1000), "low-wealth fixture could not set 10 oz free capacity") addEvent(F.verifyLowWealth, 500, player:getId(), 240) elseif mode == "readiness_food_capacity" then F.removeAll(player, F.meatItemId) diff --git a/server/tests/playerbot-gameplay/includes/verifiers.inc b/server/tests/playerbot-gameplay/includes/verifiers.inc index 965dc6f9..ae213f8f 100644 --- a/server/tests/playerbot-gameplay/includes/verifiers.inc +++ b/server/tests/playerbot-gameplay/includes/verifiers.inc @@ -32,14 +32,14 @@ end function F.verifyLowWealth(playerId, attempts) local player = Player(playerId) assert(player and not player:isRemoved(), "Bot One disappeared during low-wealth fixture") - if (player:getMoney() ~= 56 or player:getBankBalance() ~= 0 or - player:getSlotItem(CONST_SLOT_LEFT):getId() ~= F.pickupRewardId) and attempts > 0 then + local weapon = player:getSlotItem(CONST_SLOT_LEFT) + local ready = player:getMoney() == 56 and player:getBankBalance() == 0 and + weapon and weapon:getId() == F.pickupRewardId and player:getItemCount(F.saleItemId) == 0 + if not ready and attempts > 0 then addEvent(F.verifyLowWealth, 500, playerId, attempts - 1) return end - assert(player:getMoney() == 56 and player:getBankBalance() == 0 and - player:getSlotItem(CONST_SLOT_LEFT):getId() == F.pickupRewardId, - "service did not carry the available gold below the normal reserve") + assert(ready, "service did not retain the upgrade and carry exactly 56 gold without sale cargo") print("PLAYERBOT_GAMEPLAY_TEST READINESS_LOW_WEALTH_PASS") end From 893ac5571b05c0ac1fa5a1a8556bf96de4228868 Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:37:53 +0200 Subject: [PATCH 8/8] docs: update Linux guidance and remove validation history --- AGENTS.md | 11 ++++- docs/linux-validation.md | 94 ---------------------------------------- docs/testing.md | 3 -- 3 files changed, 9 insertions(+), 99 deletions(-) delete mode 100644 docs/linux-validation.md diff --git a/AGENTS.md b/AGENTS.md index 1b640ad7..e3f76e4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,7 +99,9 @@ git subtree pull --prefix=client redemption-upstream main --squash - Never commit `Tibia.dat`, `Tibia.spr`, OTClient executables, logs, screenshots, minimap caches, or database volumes. -- Restore the pinned client runtime and assets with: +- Use PowerShell 7+ (`pwsh`) on Windows or Linux. On Windows, bootstrap restores + the pinned client runtime and assets. On Linux, first build `client/otclient`; + bootstrap installs verified assets and leaves that executable untouched: ```powershell pwsh -File scripts/bootstrap-client.ps1 @@ -114,7 +116,8 @@ pwsh -File scripts/bootstrap-client.ps1 ## Server And Database -- Docker Compose is the supported local server environment. +- Docker Compose is the supported local server environment: Docker Desktop on + Windows, or Docker Engine with Compose v2 on Linux. - Keep the Compose project name `angelion` so resources do not depend on the checkout directory name. - Keep ports `7171` and `7172` bound to `127.0.0.1` unless external exposure is @@ -179,6 +182,10 @@ pwsh -File scripts/bootstrap-client.ps1 ## Verification +See `docs/testing.md` for focused scenarios and non-Docker regression checks. +Persistence tests must restart or recreate only the server with `--no-deps`; +rerunning provisioning can refill equipment slots and invalidate saved-state checks. + For server, infrastructure, or cross-stack changes, run at minimum: ```powershell diff --git a/docs/linux-validation.md b/docs/linux-validation.md deleted file mode 100644 index f2bbaa52..00000000 --- a/docs/linux-validation.md +++ /dev/null @@ -1,94 +0,0 @@ -# Linux validation baseline - -This records the Linux development migration, not a claim that every gameplay -scenario passes. Commands and prerequisites are in [testing.md](testing.md) and -[client-runtime.md](client-runtime.md). - -## Verified scope - -- PowerShell 7 runs the shared bootstrap and gameplay driver on Linux. -- Bootstrap preserves the locally built client and checks the pinned asset hashes. -- The server builds in Docker. Fresh database health, bot provisioning, online - lifecycle telemetry, and local ports 7171/7172 passed the startup check. -- Pure C++ contracts and the depot workflow regression passed with real server - headers in the Docker builder. -- The client was built and launched locally. This is not full client compatibility - coverage; in-game testing remains necessary. Client fixes remain separate from - the tooling work. - -The final full 87-scenario run reported **85 passed, 2 failed assertions, -0 timeouts, and 0 skipped**. It rebuilt the server and ran with -`-ContinueOnFailure`. Only `magic_training_post_hunt` and -`magic_training_post_hunt_no_overflow` failed. These known gameplay-policy -mismatches are deferred from the Linux migration; the suite is not fully green. -Hunt-area arrival, all navigation and corpse cases, and the corrected economic, -restart, and depot fixtures passed this run. - -## Remaining work outside portability - -Do not skip these scenarios silently or loosen their assertions to make the -migration green. Reproduce them independently before changing gameplay policy. - -| Scenario | Observed problem / next decision | -| --- | --- | -| `magic_training_post_hunt`, `magic_training_post_hunt_no_overflow` | The controller visits the depot before arbitration. Tests expect immediate post-hunt `Idle` arbitration; the detour also changes mana overflow. Decide intended hunt-end behavior first. | - -The first isolation follow-up validated `mainland_loop`, -`real_depot_rejected_move`, `pickup_progression_bundle`, `goal_arbitration`, -`magic_training_restart`, and `healing_resupply` with per-scenario environment -reset/restore. `death` initially timed out because its prior killer survived at -the depot. After fixture-owned killer cleanup and milestone-gated third death, -the isolated death rerun passed in 34.6 seconds with its 45-second limit unchanged. - -The depot verifier now checks exact inventory while the existing Depart fixture -checkpoint holds the controller paused, before optional liquidation. Both normal -cycles and all five restart recoveries require the Lua pass marker. Recovery -recreates only the server (`--no-deps`), so provisioning cannot refill equipment -slots. The paused Lua success marker is explicitly flushed to the log pipe. -All six normal/restart scenarios passed the final targeted run; partial and -rejected moves passed the preceding run. Production depot/selling policy is -unchanged. - -The economic-fixture follow-up passed `magic_training_progression`, -`magic_training_reserve`, and `magic_training_service`. Progression now starts -with 600 total gp, ten selected potions, and two meat; the nearby currency reward -is suppressed. This proves learning-goal priority, not completed spell payment. - -The low-wealth contract deliberately separates banking from liquidation: it seeds -56 bank gp rather than requiring 50 gp plus rabbit-sale proceeds. Its initial -zero-capacity setup could not recover the 30 oz threshold after depositing only -the displaced 25 oz club. Removing capacity pressure instead allowed unrelated -reward/equipment work to run first. The final fixture starts with 10 oz free, -forcing readiness service while allowing the club deposit to restore 35 oz. -Local regressions and the final live rerun passed. The live scenario completed -in 22.3 seconds, verifying the exact 56 gp withdrawal, retained upgrade, hunt -resumption, and absence of sales or terminal events. Reproduce with: - -```sh -pwsh -File scripts/test-playerbot-gameplay.ps1 -Scenario combat_readiness_low_wealth -SkipBuild -``` - -Docker approval is required on the current machine; no permission workaround -is part of this work. - -Earlier runs exposed hunt-area patrol safety and ordinary-corpse attacker -classification failures. Both scenarios passed the final full run. Retain these -observations if either recurs; a single passing run does not establish timing -stability. - -## Test isolation follow-up - -Keep PowerShell as the cross-platform runner. Refactor by behavior, not language: - -1. Put priority, scoring, reserve arithmetic, and state-transition rules into - small controlled contract tests where practical. -2. Give engine-path fixtures explicit inventory, rewards, services, and cleanup. - Checkpoint flags must not leak into the next scenario. -3. Wait for observable milestones with deadlines, not wall-clock guesses. -4. Retain a small real-map smoke suite and selected integration routes. Exact - coordinates belong in those map-specific tests, not generic planner contracts. -5. Assert outcomes and safety guarantees; require an internal phase sequence only - when that sequence is an agreed behavior contract. - -Preserve exact ownership, persistence, protocol, safety, and timeout guarantees. -Use failures to distinguish broken fixtures from actual behavior regressions. diff --git a/docs/testing.md b/docs/testing.md index 3ee22049..7befaedc 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -1,8 +1,5 @@ # Testing -See [Linux validation baseline](linux-validation.md) for migration evidence, -known gameplay failures, and the test-isolation follow-up. - Use PowerShell 7+ (`pwsh`) on Windows or Linux. The same scripts, scenario catalog, and assertions apply on both platforms. Linux needs Docker Engine and Compose v2; bootstrap also requires a locally built `client/otclient`.