Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 90 additions & 3 deletions scripts/test-playerbot-gameplay.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ function Assert-CycleEvents {
}

function Assert-OracleDepartureEvents {
param([string]$Logs, [switch]$Restart)
param([string]$Logs, [switch]$Restart, [switch]$InterruptedByRestart)

$events = @(ConvertFrom-PlayerbotLogs -Logs $Logs)
if ($Restart) {
Expand Down Expand Up @@ -273,9 +273,71 @@ function Assert-OracleDepartureEvents {
$stopped = @($events | Where-Object {
$_.event -eq "state_transition" -and $_.to -eq "stopped"
})
$wrongStoppedCount = if ($InterruptedByRestart) { $stopped.Count -lt 1 } else { $stopped.Count -ne 1 }
if ($candidate.Count -lt 1 -or $selection.Count -lt 1 -or $result.Count -ne 1 -or
$goalResult.Count -ne 1 -or $stopped.Count -ne 1) {
throw "The bot did not complete and verify the selected Oracle departure."
$goalResult.Count -ne 1 -or $wrongStoppedCount) {
throw "The bot did not complete and verify the selected Oracle departure: candidate=$($candidate.Count), selection=$($selection.Count), result=$($result.Count), goalResult=$($goalResult.Count), stopped=$($stopped.Count)."
}
}

function Assert-OracleLevelEightInterruptEvents {
param([string]$Logs, [switch]$Recovery)

$events = @(ConvertFrom-PlayerbotLogs -Logs $Logs)
$selection = @($events | Where-Object {
$_.event -eq "goal_selection" -and $_.to_goal -eq "oracle_departure" -and $_.forced -eq $true -and
$_.level -eq 8 -and $_.player_vocation_id -eq 0
})
$selectionIndex = -1
for ($index = 0; $index -lt $events.Count; $index++) {
if ($events[$index].event -eq "goal_selection" -and $events[$index].to_goal -eq "oracle_departure" -and
$events[$index].forced -eq $true) {
$selectionIndex = $index
break
}
}
$postInterrupt = if ($selectionIndex -ge 0 -and $selectionIndex + 1 -lt $events.Count) {
@($events[($selectionIndex + 1)..($events.Count - 1)])
} else {
@()
}
$huntAfterInterrupt = @($postInterrupt | Where-Object {
($_.event -eq "goal_selection" -and $_.to_goal -eq "hunt") -or
($_.event -eq "action_result" -and $_.action -in @("hunt_cycle", "hunt_waypoint", "loot")) -or
($_.event -eq "target_changed" -and $_.reason -eq "visible_monster")
})
if ($Recovery) {
$restored = @($events | Where-Object {
$_.event -eq "lifecycle" -and $_.status -eq "online" -and $_.objective -eq "oracle_departure"
})
$healed = @($events | Where-Object {
$_.event -eq "action_result" -and $_.action -eq "heal" -and $_.result -eq "success"
})
if ($restored.Count -lt 1 -or $selection.Count -lt 1 -or $healed.Count -lt 1 -or
$huntAfterInterrupt.Count -ne 0) {
throw "The restored level-8 player did not remain committed to Oracle departure."
}
return
}

$combat = @($events | Where-Object {
$_.event -eq "target_changed" -and $_.target_name -eq "Playerbot Level Eight Target"
})
$interruptedHunt = @($events | Where-Object {
$_.event -eq "goal_result" -and $_.goal -eq "hunt" -and $_.result -eq "interrupted" -and
$_.reason -eq "level_eight_interrupt"
})
$loot = @($events | Where-Object { $_.event -eq "action_result" -and $_.action -eq "loot" })
$defensiveStart = @($events | Where-Object {
$_.event -eq "action_result" -and $_.action -eq "defensive_combat" -and $_.result -eq "started"
})
$defensiveComplete = @($events | Where-Object {
$_.event -eq "action_result" -and $_.action -eq "defensive_combat" -and $_.result -eq "success"
})
if ($combat.Count -lt 1 -or $interruptedHunt.Count -ne 1 -or $selection.Count -ne 1 -or
$loot.Count -ne 0 -or $defensiveStart.Count -lt 1 -or $defensiveComplete.Count -lt 1 -or
$huntAfterInterrupt.Count -ne 0) {
throw "Level 8 did not interrupt combat, looting, and hunting for Oracle departure."
}
}

Expand Down Expand Up @@ -967,6 +1029,31 @@ try {
$restartLogs = Wait-ForLog -Pattern '"objective":"departure_complete"'
Assert-OracleDepartureEvents -Logs $restartLogs -Restart
}

Invoke-Scenario -Name "oracle_level_eight_interrupt" -DefaultTimeoutSeconds 180 -Body {
Invoke-Compose down --volumes --remove-orphans
$env:PLAYERBOT_GAMEPLAY_MODE = "departure_interrupt"
$env:PLAYERBOT_HUNT_DURATION_SECONDS = "900"
Invoke-Compose up --detach
Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST ORACLE_LEVEL_EIGHT_INTERRUPT_START' | Out-Null
$interruptLogs = Wait-ForLog -Pattern '"action":"oracle_departure","result":"success"'
Assert-OracleDepartureEvents -Logs $interruptLogs
Assert-OracleLevelEightInterruptEvents -Logs $interruptLogs
}

Invoke-Scenario -Name "oracle_level_eight_recovery" -DefaultTimeoutSeconds 180 -Body {
Invoke-Compose down --volumes --remove-orphans
$env:PLAYERBOT_GAMEPLAY_MODE = "departure_recovery"
$env:PLAYERBOT_HUNT_DURATION_SECONDS = "900"
Invoke-Compose up --detach
Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST ORACLE_LEVEL_EIGHT_RECOVERY_PREPARED' | Out-Null
Invoke-Compose stop server
Invoke-Compose up --detach server
Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST ORACLE_LEVEL_EIGHT_RECOVERY_START' | Out-Null
$recoveryLogs = Wait-ForLog -Pattern '"action":"oracle_departure","result":"success"'
Assert-OracleDepartureEvents -Logs $recoveryLogs -InterruptedByRestart
Assert-OracleLevelEightInterruptEvents -Logs $recoveryLogs -Recovery
}
}

if ($FullNavigation) {
Expand Down
2 changes: 1 addition & 1 deletion server/src/playerbotcombat.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -566,7 +566,7 @@ void PlayerBotController::startHunt(Player* player, const Position& position, co

void PlayerBotController::processTraversal(Player* player, const Position& currentPosition)
{
if (cyclePhase != CyclePhase::Hunt) {
if (cyclePhase != CyclePhase::Hunt || progressionObjective == ProgressionObjective::OracleDeparture) {
if (defensiveTargetId != 0) {
processDefensiveCombat(player, currentPosition);
return;
Expand Down
16 changes: 13 additions & 3 deletions server/src/playerbotcontroller.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,12 @@ const PlayerBotTestPolicy& playerbot::testPolicyFromEnvironment()
std::strcmp(gameplayMode, "progression_space") == 0 ||
std::strcmp(gameplayMode, "arbitration") == 0 ||
std::strcmp(gameplayMode, "arbitration_interrupt") == 0 ||
std::strcmp(gameplayMode, "departure") == 0);
std::strcmp(gameplayMode, "departure") == 0 ||
std::strcmp(gameplayMode, "departure_recovery") == 0);
const bool startInHunt = gameplayMode &&
(std::strcmp(gameplayMode, "navigation") == 0 || std::strcmp(gameplayMode, "corpse") == 0 ||
std::strcmp(gameplayMode, "healing") == 0 || std::strcmp(gameplayMode, "healing_resupply") == 0 ||
std::strcmp(gameplayMode, "value") == 0);
std::strcmp(gameplayMode, "value") == 0 || std::strcmp(gameplayMode, "departure_interrupt") == 0);
return PlayerBotTestPolicy{
!regressionMode && (!gameplayMode || progressionMode),
startInHunt,
Expand All @@ -57,7 +58,8 @@ void PlayerBotController::start(const Position& position, bool recovered, uint32
const bool startInHunt = !recovered && testPolicy.startInHunt;
Player* controlledPlayer = g_game.getPlayerByID(playerId);
const bool departureComplete = controlledPlayer && hasCompletedRookgaardDeparture(*controlledPlayer);
const bool useGoalSelector = !recovered && testPolicy.progressionEnabled && controlledPlayer;
const bool departureRequired = controlledPlayer && requiresRookgaardDeparture(*controlledPlayer);
const bool useGoalSelector = controlledPlayer && (departureRequired || (!recovered && testPolicy.progressionEnabled));
if (useGoalSelector && !departureComplete && !selectTopLevelGoal(*controlledPlayer, position, "startup")) {
return;
}
Expand Down Expand Up @@ -649,6 +651,14 @@ void PlayerBotController::navigate()
schedule(blockedRouteRetryInterval);
return;
}
const bool waitingForRecovery = cyclePhase == CyclePhase::Service && needsHealing(*player);
if (!accessingReward && progressionObjective != ProgressionObjective::OracleDeparture &&
requiresRookgaardDeparture(*player) && !waitingForRecovery) {
if (selectTopLevelGoal(*player, currentPosition, "level_eight_interrupt")) {
schedule(SCHEDULER_MINTICKS);
}
return;
}
if (!accessingReward && !verifyingDeparture && handleFood(player, currentPosition)) {
schedule(blockedRouteRetryInterval);
return;
Expand Down
2 changes: 2 additions & 0 deletions server/src/playerbotcontroller.h
Original file line number Diff line number Diff line change
Expand Up @@ -441,9 +441,11 @@ class PlayerBotController : public std::enable_shared_from_this<PlayerBotControl
std::deque<PlayerBotNavigationStep>& rewardSteps);

bool hasCompletedRookgaardDeparture(const Player& player) const;
bool requiresRookgaardDeparture(const Player& player) const;

bool findOracleDeparture(Player& player, const Position& position, DeparturePlan& plan,
std::deque<PlayerBotNavigationStep>& departureSteps);
bool forceOracleDeparture(Player& player, const Position& position, const char* decisionReason);

void beginOracleDeparture(Player& player, const Position& position, DeparturePlan plan,
std::deque<PlayerBotNavigationStep> departureSteps);
Expand Down
64 changes: 64 additions & 0 deletions server/src/playerbotdeparture.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ bool PlayerBotController::hasCompletedRookgaardDeparture(const Player& player) c
return player.getVocation()->getId() != 0 && player.getTown() && player.getTown()->getID() != rookgaardTownId;
}

bool PlayerBotController::requiresRookgaardDeparture(const Player& player) const
{
return player.getVocation()->getId() == 0 && player.getLevel() >= oracleMinimumLevel;
}

bool PlayerBotController::findOracleDeparture(Player& player, const Position& position, DeparturePlan& plan,
std::deque<PlayerBotNavigationStep>& departureSteps)
{
Expand Down Expand Up @@ -78,6 +83,65 @@ bool PlayerBotController::findOracleDeparture(Player& player, const Position& po
return false;
}

bool PlayerBotController::forceOracleDeparture(Player& player, const Position& position, const char* decisionReason)
{
const TopLevelGoal previousGoal = activeGoal;
const bool interruptedHunt = previousGoal == TopLevelGoal::Hunt && cyclePhase == CyclePhase::Hunt;
if (interruptedHunt) {
finishHuntRegion(player, position, "level_eight_interrupt");
emit("goal_result", position,
"\"decision_id\":" + std::to_string(goalDecisionId - 1) +
",\"goal\":\"hunt\",\"result\":\"interrupted\",\"reason\":\"level_eight_interrupt\"");
}

if (defensiveTargetId == 0) {
g_game.playerCancelAttackAndFollow(playerId);
}
clearRatTarget(position, "level_eight_interrupt");
clearNavigation();
pendingLootItemId = 0;
pendingDiscardItemId = 0;
expectedCorpseItemId = 0;
expectedCorpseLootable = false;
player.closeContainer(corpseContainerId);
setStage(ScenarioStage::Traverse, position);
progressionObjective = ProgressionObjective::None;
serviceStage = ServiceStage::Discover;

DeparturePlan plan;
std::deque<PlayerBotNavigationStep> route;
const bool withinOracleLevelRange = player.getLevel() <= oracleMaximumLevel;
const bool found = withinOracleLevelRange && findOracleDeparture(player, position, plan, route);
const GoalCandidate candidate{TopLevelGoal::Departure, found, found ? oracleDepartureUtility : 0,
!withinOracleLevelRange ? "above_maximum_level" :
found ? "oracle_reachable" : "oracle_unreachable"};
emitGoalCandidate(player, candidate, position, decisionReason, nullptr, found ? &plan : nullptr);
if (!found) {
emit("goal_selection", position,
"\"decision_id\":" + std::to_string(goalDecisionId) + ",\"decision_reason\":" +
jsonString(decisionReason) + ",\"from_goal\":" + jsonString(topLevelGoalName(previousGoal)) +
",\"to_goal\":\"oracle_departure\",\"result\":\"failed\",\"reason\":" +
jsonString(candidate.reason) + ",\"forced\":true,\"level\":" + std::to_string(player.getLevel()) +
",\"player_vocation_id\":" + std::to_string(player.getVocation()->getId()) +
",\"vocation_id\":" + std::to_string(oracleVocationId));
stop("oracle_departure_unavailable", position);
return false;
}

activeGoal = TopLevelGoal::Departure;
emit("goal_selection", position,
"\"decision_id\":" + std::to_string(goalDecisionId) + ",\"decision_reason\":" +
jsonString(decisionReason) + ",\"from_goal\":" + jsonString(topLevelGoalName(previousGoal)) +
",\"to_goal\":\"oracle_departure\",\"utility\":" + std::to_string(oracleDepartureUtility) +
",\"reason\":\"forced_level_eight_departure\",\"forced\":true,\"level\":" +
std::to_string(player.getLevel()) + ",\"player_vocation_id\":" +
std::to_string(player.getVocation()->getId()) + ",\"npc_id\":" + std::to_string(plan.npcId) +
",\"town_id\":" + std::to_string(oracleTownId) + ",\"vocation_id\":" +
std::to_string(oracleVocationId));
beginOracleDeparture(player, position, std::move(plan), std::move(route));
return true;
}

void PlayerBotController::beginOracleDeparture(Player& player, const Position& position, DeparturePlan plan,
std::deque<PlayerBotNavigationStep> steps)
{
Expand Down
3 changes: 3 additions & 0 deletions server/src/playerbotprogression.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,9 @@ void PlayerBotController::beginPickupReward(Player& player, const Position& posi
bool PlayerBotController::selectTopLevelGoal(Player& player, const Position& position, const char* decisionReason)
{
++goalDecisionId;
if (requiresRookgaardDeparture(player)) {
return forceOracleDeparture(player, position, decisionReason);
}
const GoalCandidate service = serviceGoalCandidate(player);
DeparturePlan departure;
std::deque<PlayerBotNavigationStep> departureRoute;
Expand Down
66 changes: 65 additions & 1 deletion server/tests/playerbot-gameplay/playerbot_gameplay.lua
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ local lootMonsterName = "Playerbot Loot Corpse"
local nonlootableMonsterName = "Playerbot Nonlootable Corpse"
local containerDeathItemMonsterName = "Playerbot Container Death Item"
local defensiveMonsterName = "Playerbot Defensive Threat"
local levelEightMonsterName = "Playerbot Level Eight Target"
local deathMonsterName = "Playerbot Death Threat"
local valueMonsterName = "Playerbot Value Corpse"
local healingPotionCount = 3
Expand All @@ -22,6 +23,7 @@ local nestedRewardStorage = 50083
local nestedRewardRootId = 1994
local nestedRewardShieldId = 2512
local economicRewardStorage = 50082
local departureRecoveryStorage = 50090
local deathLoginCount = 0

local function verifyOracleDeparture(playerId, attempts)
Expand Down Expand Up @@ -148,6 +150,40 @@ local function spawnCorpseMonster(playerId, monsterName)
error("no adjacent tile was available for corpse test monster")
end

local function spawnDepartureDefensiveThreat(playerId)
local player = Player(playerId)
assert(player and not player:isRemoved(), "Bot One disappeared before departure defensive combat")
local origin = player:getPosition()
for _, position in ipairs({
Position(origin.x + 1, origin.y, origin.z),
Position(origin.x, origin.y + 1, origin.z),
Position(origin.x - 1, origin.y, origin.z),
Position(origin.x, origin.y - 1, origin.z),
}) do
local tile = Tile(position)
if tile and tile:isWalkable() then
local monster = Game.createMonster(defensiveMonsterName, position, true, true)
assert(monster and monster:selectTarget(player), "departure defensive threat could not target Bot One")
print("PLAYERBOT_GAMEPLAY_TEST ORACLE_DEPARTURE_DEFENSIVE_THREAT_SPAWNED")
return
end
end
error("no adjacent tile was available for the departure defensive threat")
end

local function prepareLevelEightCombat(player)
local targetExperience = Game.getExperienceForLevel(8) - 1
assert(player:getExperience() < targetExperience, "level-eight combat fixture expected a low-level player")
player:addExperience(targetExperience - player:getExperience())
assert(player:getLevel() == 7, "level-eight combat fixture could not prepare level 7")
assert(player:teleportTo(depotPosition), "level-eight combat fixture could not leave the temple")
removeNearbyMonsters(player)
spawnCorpseMonster(player:getId(), levelEightMonsterName)
addEvent(spawnDepartureDefensiveThreat, 2500, player:getId())
addEvent(verifyOracleDeparture, 500, player:getId(), 360)
print("PLAYERBOT_GAMEPLAY_TEST ORACLE_LEVEL_EIGHT_INTERRUPT_START")
end

local function removeBlockers(firstId, secondId, thirdId)
for _, blockerId in ipairs({firstId, secondId, thirdId}) do
local blocker = Npc(blockerId)
Expand Down Expand Up @@ -300,7 +336,8 @@ function login.onLogin(player)
mode == "progression_nested" or
mode == "progression_resume" or mode == "progression_nested_resume" or mode == "progression_space" or
mode == "arbitration" or
mode == "arbitration_interrupt" or mode == "departure",
mode == "arbitration_interrupt" or mode == "departure" or mode == "departure_interrupt" or
mode == "departure_recovery",
"unknown PLAYERBOT_GAMEPLAY_MODE: " .. mode)
if mode == "death" then
deathLoginCount = deathLoginCount + 1
Expand Down Expand Up @@ -339,6 +376,33 @@ function login.onLogin(player)
print("PLAYERBOT_GAMEPLAY_TEST ORACLE_DEPARTURE_START")
return true
end
if mode == "departure_interrupt" then
prepareLevelEightCombat(player)
return true
end
if mode == "departure_recovery" then
if player:getStorageValue(departureRecoveryStorage) ~= 1 then
local targetExperience = Game.getExperienceForLevel(9) - 1
assert(player:getExperience() < targetExperience,
"Oracle recovery fixture expected a low-level player")
player:addExperience(targetExperience - player:getExperience())
assert(player:getLevel() == 8, "Oracle recovery fixture could not prepare level 8")
assert(player:setStorageValue(departureRecoveryStorage, 1),
"Oracle recovery fixture could not persist its restart marker")
print("PLAYERBOT_GAMEPLAY_TEST ORACLE_LEVEL_EIGHT_RECOVERY_PREPARED")
return true
end
assert(player:getVocation():getId() == 0 and player:getLevel() == 8,
"Oracle recovery fixture did not restore a vocationless level-8 player")
assert(player:getExperience() == Game.getExperienceForLevel(9) - 1,
"Oracle recovery fixture did not restore the level-9 experience boundary")
assert(player:setHealth(math.floor(player:getMaxHealth() * 0.5)),
"Oracle recovery fixture could not lower Bot One's health")
suppressNearbyMonsters(player:getId())
addEvent(verifyOracleDeparture, 500, player:getId(), 360)
print("PLAYERBOT_GAMEPLAY_TEST ORACLE_LEVEL_EIGHT_RECOVERY_START")
return true
end
if mode == "arbitration" or mode == "arbitration_interrupt" then
local position = player:getPosition()
assert(position.x == 32097 and position.y == 32219 and position.z == 7,
Expand Down
5 changes: 3 additions & 2 deletions server/tests/playerbot-monsters/corpse.lua
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
local function registerCorpseTestMonster(name, loot, corpseId, hostile, health, attacks)
local function registerCorpseTestMonster(name, loot, corpseId, hostile, health, attacks, experience)
local monsterType = Game.createMonsterType(name)
local monster = {
description = name:lower(),
experience = 0,
experience = experience or 0,
outfit = {lookType = 21},
health = health or 1,
maxHealth = health or 1,
Expand Down Expand Up @@ -35,6 +35,7 @@ registerCorpseTestMonster("Playerbot Loot Corpse", {
registerCorpseTestMonster("Playerbot Nonlootable Corpse", {}, ITEM_GOLD_COIN)
registerCorpseTestMonster("Playerbot Container Death Item", {}, ITEM_BAG)
registerCorpseTestMonster("Playerbot Defensive Threat", {}, nil, false, 1)
registerCorpseTestMonster("Playerbot Level Eight Target", {}, nil, false, 1, nil, 1)
registerCorpseTestMonster("Playerbot Value Corpse", {
{id = 2826, chance = 100000, maxCount = 1},
})
Expand Down