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
20 changes: 13 additions & 7 deletions docs/playerbots.md

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ Useful subsystem switches include `-Healing`, `-ValueLoot` (value replacement pl

`-Scenario door_passages` also checks the shared stable-approach admission used by NPC boarding, hunt depot/supply exits, sell-loot source/seller selection, service providers, and depot scans/revalidation. A lower-cost teleport is rejected while an ordinary tile is selected; adding a teleport to the selected tile invalidates it. Floor changes and solid blockers are rejected, occupied ordinary tiles remain eligible, and the movement resolver still permits intentional teleport traversal. The same scenario checks coarse portal danger-sample reuse, unchanged cost/peak results, peak rejection, and tile-dependency invalidation. The same scenario checks first-stage troll, spider, rat, and poison-spider corpse transforms against loaded item definitions: both stages remain walkable and preserve a watched route, while closing a door still invalidates it. This tests loaded engine tiles, not every controller workflow independently.

The Knight scenarios `spell_training_low_supplies` and `spell_training_low_supplies_unaffordable` seed two health potions, meeting the mandatory safety floor. First-heal bootstrap releases only the 100-gold cash buffer, so their computed spell reserve is 0. The affordable case must prioritize Light Healing, pay exactly 170 gold (270→100), and retain both potions. The underfunded case starts at 169 gold (`170 price + 0 mandatory reserve - 1`) and must reject learning with `unaffordable_after_reserves` without a purchase. `pwsh -File scripts/test-playerbot-supply-assertions.ps1` checks these telemetry contracts; `lua scripts/test-playerbot-fixture-isolation.lua` checks the native setup and payment/stock verifier without Docker. These checks do not replace a loaded-world scenario run.

Paladin healing has exact scenarios `paladin_spell_training` (paid learning, casting, and restart), `paladin_healing_economy` (spell choice and reserve), `paladin_mana_healing`, `paladin_healing_fallback`, and `paladin_supply_restock`. They seed Bot One as a Paladin, omit the other three bots, and do not establish sustained progression. `-SpellTraining`, `-SpellUse`, and `-Healing` include the relevant Paladin cases alongside Knight checks.

`-Scenario paladin_spear_restock` checks paid restocking and hunt resumption in fresh level-8 and level-16 runs; `paladin_spear_break` checks equipping a backpack spare, ending the hunt at the reserve, and restocking. `equipment_purchase_spear` checks supply recovery from zero spears at startup in both hand orientations with only 39 gold, plus a zero-funds case that must stop after one unsuccessful service rather than loop or hunt unarmed. `paladin_spear_last_break` checks losing the final spear after hunting starts. Funded cases must buy three spears, equip them, and resume hunting without a terminal event. `paladin_spear_recovery_income` starts at 128/185 health with one spear and six gold. It requires a verified departure-healing potion use, a planner-selected stock-fitting recovery hunt, area arrival, combat, and coin looting without a reserve-one interruption. It controls corpse gold, not weapon breakage or sustainable economics. These scenarios use normalized gear and capacity, not the normal seed's capacity budget. `lua scripts/test-playerbot-spear-fixture.lua` checks their setup and verifiers without Docker.
Expand Down Expand Up @@ -78,6 +80,7 @@ sh server/tests/playerbotrangedposition_contracts.sh
sh server/tests/playerbothunttiming_contracts.sh
sh server/tests/playerbotdamage_contracts.sh
sh server/tests/playerbotrouting_contracts.sh
sh server/tests/playerbottrainer_contracts.sh
sh server/tests/playerbotselllootapproach_contracts.sh
sh server/tests/playerbotselllootbasket_contracts.sh
sh server/tests/playerbotselllootfilter_contracts.sh
Expand All @@ -99,8 +102,13 @@ lua scripts/test-playerbot-death-fixture.lua
lua scripts/test-playerbot-transit-fixture.lua
lua scripts/test-playerbot-supply-fixture.lua
lua scripts/test-playerbot-coin-estimation.lua
luajit scripts/test-maealil-healing-keywords.lua
```

The trainer contracts exercise paid-fare plus spell/supply reserve boundaries, sliced transport continuation, independent trainer/hunt/service work slots, world/provider/fund invalidation, turn priority, and the real paid-learning session after a verified travel receipt. They also link the production trainer-arbitration phase with the shared planning budget: after another controller denies preparation admission, both positive and negative trainer decisions are reused and the next admission advances preparation. A real navigation-runtime/path-search contract blocks the direct post-landing step, resumes a four-step sliced detour with the request's exclusions, and verifies that no second fare is authorized. On Linux, use `CPPFLAGS='-I/tmp/angelion-test-includes -I/usr/include/luajit-2.1'` when those development headers are outside the default include paths. These checks do not run NPC dialogue or the loaded world. Existing `-SpellTraining` scenarios still cover local trainer learning; they do not establish Edron-to-mainland trainer travel.

The Maealil keyword check loads the actual NPC script and keyword handler. It verifies that healing spell names reach their paid offers while the word `heal` still provides free healing and condition removal. Use LuaJIT or Lua 5.4: the existing NPC modules do not load under Lua 5.5.

A zero exit status and each script's explicit pass marker are the pass signal. These checks prove policy, arithmetic, parsing, and fixture contracts; they do not execute an ordinary live world.

### Depot liquidation timing
Expand Down
9 changes: 5 additions & 4 deletions scripts/playerbot-gameplay/assertions-spells.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -114,23 +114,24 @@ function Assert-LowSupplySpellTrainingEvents {
param([string]$Logs, [switch]$Unaffordable)
$events = @(ConvertFrom-PlayerbotLogs -Logs $Logs)
$candidates = @($events | Where-Object {
$_.event -eq 'spell_candidate' -and $_.spell -eq 'Light Healing' -and $_.price -eq 170 -and $_.reserve -eq 100
# Stock meets the mandatory floor; first-heal bootstrap releases the cash buffer.
$_.event -eq 'spell_candidate' -and $_.spell -eq 'Light Healing' -and $_.price -eq 170 -and $_.reserve -eq 0
})
$purchases = @($events | Where-Object { $_.event -eq 'action_result' -and $_.action -eq 'learn_spell' })
if ($Unaffordable) {
if (@($candidates | Where-Object { $_.result -eq 'rejected' -and $_.reason -eq 'unaffordable_after_reserves' }).Count -lt 1 -or $purchases.Count -ne 0) {
throw 'Exura did not respect the one-gold-short emergency reserve boundary.'
throw 'Exura did not respect the 169-gold boundary (170 price + 0 mandatory reserve - 1).'
}
return
}
$selection = @($events | Where-Object { $_.event -eq 'goal_selection' -and $_.to_goal -eq 'learn_spell' })
$priority = @($events | Where-Object { $_.event -eq 'goal_candidate' -and $_.goal -eq 'learn_spell' -and $_.feasible -and $_.reason -eq 'priority_recovery_spell' })
if (@($candidates | Where-Object { $_.result -eq 'feasible' }).Count -lt 1 -or
$selection.Count -ne 1 -or $priority.Count -lt 1 -or $purchases.Count -ne 1 -or
$purchases[0].result -ne 'success' -or $purchases[0].spell -ne 'Light Healing' -or
$purchases[0].result -ne 'success' -or $purchases[0].spell -ne 'Light Healing' -or $purchases[0].price -ne 170 -or
$purchases[0].money_before -ne 270 -or $purchases[0].money_after -ne 100 -or
$Logs -notmatch 'PLAYERBOT_GAMEPLAY_TEST SPELL_TRAINING_LOW_SUPPLIES_PASS') {
throw 'Low-supply Exura learning did not preserve exact payment, priority, and potion reserves.'
throw 'Low-supply Exura learning did not preserve exact payment, priority, and the potion safety floor with bootstrap reserve 0.'
}
$firstSelection = @($events | Where-Object { $_.event -eq 'goal_selection' } | Select-Object -First 1)
if ($firstSelection.Count -ne 1 -or $firstSelection[0].to_goal -ne 'learn_spell') {
Expand Down
2 changes: 1 addition & 1 deletion scripts/playerbot-gameplay/scenarios-spells.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
$trisha = @($events | Where-Object {
$_.event -eq "spell_candidate" -and $_.npc_name -eq "Trisha" -and $_.spell -eq "Light Healing" -and
$_.result -eq "rejected" -and $_.reason -eq "unaffordable_after_reserves" -and
$_.price -eq 170 -and $_.reserve -eq 100 -and $_.travel_steps -eq 0
$_.price -eq 170 -and $_.reserve -eq 0 -and $_.travel_steps -eq 0
})
$nearbyWrongVocation = @($events | Where-Object {
$_.event -eq "spell_candidate" -and $_.npc_name -in @("Faluae", "Eroth", "Maealil", "Elathriel")
Expand Down
146 changes: 146 additions & 0 deletions scripts/test-maealil-healing-keywords.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
-- Run from the repository root: luajit scripts/test-maealil-healing-keywords.lua
-- Lua 5.1/5.4 also work; existing modules.lua is not compatible with Lua 5.5.
-- Load production routing/callbacks; stub only server/player/NPC boundaries.
CONDITION_FIRE, CONDITION_POISON, CONDITION_ENERGY = 1, 2, 3
CONST_ME_MAGIC_GREEN, CONST_ME_MAGIC_RED = 1, 2
MESSAGE_GREET, MESSAGE_WALKAWAY, MESSAGE_FAREWELL = 1, 2, 3
TAG_PLAYERNAME = '|PLAYERNAME|'

function table.contains(values, expected)
for _, value in ipairs(values) do
if value == expected then return true end
end
return false
end

local player = { health = 100, conditions = {}, effects = {}, money = 2000, learned = {} }
function Player(cid)
assert(cid == 1)
return player
end
function player:getName() return 'Test Player' end
function player:getCondition(condition) return self.conditions[condition] end
function player:removeCondition(condition) self.conditions[condition] = nil end
function player:getHealth() return self.health end
function player:addHealth(amount) self.health = self.health + amount end
function player:getPosition()
return { sendMagicEffect = function(_, effect)
self.effects[#self.effects + 1] = effect
end }
end
function player:getVocation()
return { getBase = function() return { getId = function() return 3 end } end }
end
function player:hasLearnedSpell(name) return self.learned[name] == true end
function player:canLearnSpell() return true end
function player:isPremium() return true end
function player:removeTotalMoney(amount)
assert(self.money >= amount)
self.money = self.money - amount
return true
end
function player:learnSpell(name) self.learned[name] = true end

local npc = { addSpellOffer = function() end }
function Npc() return npc end
local handler
NpcHandler = {}
function NpcHandler:new(keywordHandler)
handler = setmetatable({ keywordHandler = keywordHandler }, { __index = self })
return handler
end
function NpcHandler:isFocused(cid) return cid == 1 end
function NpcHandler:parseMessage(text) return text end
function NpcHandler:say(text, cid)
assert(cid == 1)
self.response = text
end
function NpcHandler:resetNpc(cid) self.keywordHandler:reset(cid) end
function NpcHandler:setMessage() end
function NpcHandler:addModule(module) module:init(self) end
NpcSystem = { parseParameters = function() end }

dofile('server/data/npc/lib/npcsystem/keywordhandler.lua')
dofile('server/data/npc/lib/npcsystem/modules.lua')
dofile('server/data/npc/lib/npcsystem/customModules.lua')
dofile('server/data/npc/scripts/Maealil.lua')

local function reset(health, conditions)
handler:resetNpc(1)
handler.response = nil
player.health, player.conditions = health, conditions or {}
player.effects, player.learned, player.money = {}, {}, 2000
end
local function request(message)
assert(handler.keywordHandler:processMessage(1, message), 'No keyword matched: ' .. message)
return handler.keywordHandler:getLastNode(1)
end

-- A free-heal condition must not steal a spell request, even before the fallback.
local states = {
{ health = 100 },
{ health = 20 },
{ health = 100, condition = CONDITION_FIRE },
{ health = 100, condition = CONDITION_POISON },
{ health = 100, condition = CONDITION_ENERGY },
}
local spells = { { 'Light Healing', 170 }, { 'Intense Healing', 350 }, { 'Ultimate Healing', 1000 } }
for _, state in ipairs(states) do
for _, spell in ipairs(spells) do
reset(state.health, state.condition and { [state.condition] = true })
local node = request(spell[1])
assert(node.parameters.spellName == spell[1], 'Free healing intercepted ' .. spell[1])
assert(node.callback == StdModule.say)
assert(handler.response == 'Do you want to learn the spell ' .. spell[1] .. ' for ' .. spell[2] .. ' gold?')
assert(player.health == state.health and #player.effects == 0)
if state.condition then assert(player.conditions[state.condition]) end
request('yes')
assert(player.learned[spell[1]] and player.money == 2000 - spell[2])
assert(handler.response == 'You have learned ' .. spell[1] .. '.')
end
end

for _, phrase in ipairs({ 'heal', 'please heal me', 'HEAL!', 'please (heal) me' }) do
reset(100)
local node = request(phrase)
assert(not node.parameters.spellName)
assert(handler.response:find("You aren't looking that bad.", 1, true) == 1)
assert(player.health == 100 and #player.effects == 0 and player.money == 2000)

reset(20)
request(phrase)
assert(handler.response == 'You are hurt, my child. I will heal your wounds.')
assert(player.health == 40 and #player.effects == 1 and player.effects[1] == CONST_ME_MAGIC_GREEN)

for _, condition in ipairs({ CONDITION_FIRE, CONDITION_POISON, CONDITION_ENERGY }) do
reset(20, { [condition] = true })
request(phrase)
assert(player.conditions[condition] == nil and player.health == 20)
assert(#player.effects == 1)
assert(player.effects[1] == (condition == CONDITION_POISON and CONST_ME_MAGIC_RED or CONST_ME_MAGIC_GREEN))
local expected = {
[CONDITION_FIRE] = 'You are burning. Let me quench those flames.',
[CONDITION_POISON] = 'You are poisoned. Let me soothe your pain.',
[CONDITION_ENERGY] = 'You are electrified, my child. Let me help you to stop trembling.',
}
assert(handler.response == expected[condition])
end
end

-- Preserve condition priority and the strict health threshold.
reset(20, { [CONDITION_FIRE] = true, [CONDITION_POISON] = true })
request('heal')
assert(not player.conditions[CONDITION_FIRE] and player.conditions[CONDITION_POISON])
request('heal')
assert(not player.conditions[CONDITION_POISON] and player.health == 20)
request('heal')
assert(player.health == 40)
request('heal')
assert(handler.response:find("You aren't looking that bad.", 1, true) == 1)

for _, phrase in ipairs({ 'healing', 'unheal', 'healer', 'prehealpost' }) do
reset(20, { [CONDITION_FIRE] = true })
assert(not handler.keywordHandler:processMessage(1, phrase), 'Substring matched: ' .. phrase)
assert(handler.response == nil and player.health == 20 and player.conditions[CONDITION_FIRE])
end
print('PASS Maealil: loaded spell routing/payment, whole-word free healing, fire/poison/energy priority and low-HP threshold')
Loading