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
11 changes: 9 additions & 2 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,15 @@ Remove the environment override and recreate the server afterward.

The optional regression overlay exercises representative Lua UI/network calls,
temporary inventory and container mutation, death, explicit removal, rejected
login, and clean-shutdown persistence. Supported modes are `interactions`,
`death`, `remove`, and `reject`.
login, learned-spell state, and clean-shutdown persistence. Supported modes are
`interactions`, `death`, `remove`, `reject`, `spellTrainer`, `spellReset`,
`spellLearning`, `spellPersistence`, and `spellFailures`. `spellTrainer` buys
Light Healing and Light from Gregor through normal dialogue and verifies keyword
selection and both prices. `spellFailures` covers level, vocation, promotion,
premium, and money rejection without changing learned state or money. Run
`spellReset`, `spellLearning`, and `spellPersistence` in that order against the
same database volume, stopping the server cleanly between each mode; this also
verifies the learned casting gate, duplicate rejection, and persistence.

```powershell
$env:PLAYERBOT_REGRESSION_MODE = "interactions"
Expand Down
116 changes: 116 additions & 0 deletions scripts/test-knight-spell-contract.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
param()

$ErrorActionPreference = "Stop"

$projectRoot = Split-Path -Parent $PSScriptRoot
$spellFile = Join-Path $projectRoot "server\data\spells\spells.xml"
$npcScriptRoot = Join-Path $projectRoot "server\data\npc\scripts"
[xml]$registry = Get-Content -LiteralPath $spellFile -Raw

# Values come from CipSoft's spell library captured before the December 2010
# spell overhaul: https://web.archive.org/web/20100530041734/http://www.tibia.com/library/?subtopic=spells
$knightSpells = @(
@{ Name = "Find Person"; Words = "exiva"; Level = 8; Mana = 20; Premium = 0; Vocations = @("Knight", "Elite Knight") }
@{ Name = "Light"; Words = "utevo lux"; Level = 8; Mana = 20; Premium = 0; Vocations = @("Knight", "Elite Knight") }
@{ Name = "Light Healing"; Words = "exura"; Level = 9; Mana = 20; Premium = 0; Vocations = @("Knight", "Elite Knight") }
# The 8.60 public name was Antidote; Cure Poison remains the current registry identity until #94 reconciles shared spell names.
@{ Name = "Cure Poison"; Words = "exana pox"; Level = 10; Mana = 30; Premium = 0; Vocations = @("Knight", "Elite Knight") }
@{ Name = "Magic Rope"; Words = "exani tera"; Level = 9; Mana = 20; Premium = 1; Vocations = @("Knight", "Elite Knight") }
@{ Name = "Levitate"; Words = "exani hur"; Level = 12; Mana = 50; Premium = 1; Vocations = @("Knight", "Elite Knight") }
@{ Name = "Great Light"; Words = "utevo gran lux"; Level = 13; Mana = 60; Premium = 0; Vocations = @("Knight", "Elite Knight") }
@{ Name = "Haste"; Words = "utani hur"; Level = 14; Mana = 60; Premium = 1; Vocations = @("Knight", "Elite Knight") }
@{ Name = "Whirlwind Throw"; Words = "exori hur"; Level = 15; Mana = 40; Premium = 1; Vocations = @("Knight", "Elite Knight") }
@{ Name = "Challenge"; Words = "exeta res"; Level = 20; Mana = 30; Premium = 1; Vocations = @("Elite Knight") }
@{ Name = "Charge"; Words = "utani tempo hur"; Level = 25; Mana = 100; Premium = 1; Vocations = @("Knight", "Elite Knight") }
@{ Name = "Wound Cleansing"; Words = "exana mort"; Level = 30; Mana = 65; Premium = 1; Vocations = @("Knight", "Elite Knight") }
@{ Name = "Train Party"; Words = "utito mas sio"; Level = 32; Mana = 0; Premium = 1; Vocations = @("Knight", "Elite Knight") }
@{ Name = "Groundshaker"; Words = "exori mas"; Level = 33; Mana = 160; Premium = 1; Vocations = @("Knight", "Elite Knight") }
@{ Name = "Berserk"; Words = "exori"; Level = 35; Mana = 115; Premium = 1; Vocations = @("Knight", "Elite Knight") }
@{ Name = "Protector"; Words = "utamo tempo"; Level = 55; Mana = 200; Premium = 1; Vocations = @("Knight", "Elite Knight") }
@{ Name = "Blood Rage"; Words = "utito tempo"; Level = 60; Mana = 290; Premium = 1; Vocations = @("Knight", "Elite Knight") }
@{ Name = "Fierce Berserk"; Words = "exori gran"; Level = 70; Mana = 340; Premium = 1; Vocations = @("Knight", "Elite Knight") }
)

foreach ($expected in $knightSpells) {
$spell = @($registry.spells.instant | Where-Object { $_.name -eq $expected.Name })
if ($spell.Count -ne 1) {
throw "Expected one registered '$($expected.Name)' spell, found $($spell.Count)."
}

$spell = $spell[0]
foreach ($attribute in @("Words", "Level", "Mana")) {
$xmlName = $attribute.ToLowerInvariant()
if ([string]$spell.$xmlName -ne [string]$expected.$attribute) {
throw "$($expected.Name) has $xmlName='$($spell.$xmlName)', expected '$($expected.$attribute)'."
}
}

$premium = if ($spell.premium) { [int]$spell.premium } else { 0 }
if ($premium -ne $expected.Premium -or [int]$spell.needlearn -ne 1) {
throw "$($expected.Name) has premium=$premium needlearn=$($spell.needlearn); expected premium=$($expected.Premium) needlearn=1."
}

$vocations = @($spell.vocation | ForEach-Object { [string]$_.name })
$knightVocations = @($vocations | Where-Object { $_ -in @("Knight", "Elite Knight") })
$vocationDifference = @($knightVocations | Where-Object { $_ -notin $expected.Vocations }) +
@($expected.Vocations | Where-Object { $_ -notin $knightVocations })
if ($vocationDifference.Count -gt 0) {
throw "$($expected.Name) has Knight vocations '$($knightVocations -join ', ')', expected '$($expected.Vocations -join ', ')'."
}

$scriptPath = Join-Path (Split-Path $spellFile) "scripts\$($spell.script)"
if (-not (Test-Path -LiteralPath $scriptPath)) {
throw "$($expected.Name) references missing script '$($spell.script)'."
}
}

function Assert-Offer {
param(
[string]$Npc,
[string]$Spell,
[int]$Price,
[int]$Level,
[switch]$Premium
)

$content = Get-Content -LiteralPath (Join-Path $npcScriptRoot "$Npc.lua") -Raw
$premiumPattern = if ($Premium) { ", premium = true" } else { "" }
$pattern = "spellName = '$([regex]::Escape($Spell))', price = $Price, level = $Level$premiumPattern, vocation =\{[^}]*4[^}]*\}"
if ($content -notmatch $pattern) {
throw "$Npc does not offer $Spell for $Price gp at level $Level with the expected premium rule."
}
}

function Assert-NoOffer {
param(
[string]$Npc,
[string]$Spell
)

$content = Get-Content -LiteralPath (Join-Path $npcScriptRoot "$Npc.lua") -Raw
if ($content -match "spellName = '$([regex]::Escape($Spell))'") {
throw "$Npc unexpectedly offers $Spell."
}
}

Assert-Offer -Npc Gregor -Spell "Light Healing" -Price 170 -Level 9
Assert-Offer -Npc Gregor -Spell "Light" -Price 100 -Level 8
Assert-Offer -Npc Puffels -Spell "Whirlwind Throw" -Price 800 -Level 15 -Premium
Assert-Offer -Npc Puffels -Spell "Wound Cleansing" -Price 300 -Level 30 -Premium
Assert-Offer -Npc Eremo -Spell "Challenge" -Price 2000 -Level 20 -Premium
Assert-Offer -Npc Eliza -Spell "Train Party" -Price 4000 -Level 32 -Premium
Assert-Offer -Npc Ursula -Spell "Groundshaker" -Price 1500 -Level 33 -Premium
Assert-Offer -Npc Zoltan -Spell "Fierce Berserk" -Price 5000 -Level 70 -Premium
Assert-NoOffer -Npc Gregor -Spell "Whirlwind Throw"

$loadedData = Get-ChildItem -LiteralPath (Join-Path $projectRoot "server\data") -Recurse -File |
Where-Object { $_.Extension -in @(".lua", ".xml") } |
ForEach-Object { Get-Content -LiteralPath $_.FullName -Raw }
$loadedText = $loadedData -join "`n"
foreach ($unsupported in @("Brutal Strike", "Summon Skullfrost")) {
if ($loadedText.Contains($unsupported)) {
throw "Unsupported Knight spell offer remains: $unsupported."
}
}

"Knight spell contract PASS"
3 changes: 2 additions & 1 deletion server/compose.playerbot-regression.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
services:
server:
environment:
FREE_PREMIUM: "${FREE_PREMIUM:-false}"
PLAYERBOT_REGRESSION_MODE: "${PLAYERBOT_REGRESSION_MODE:-interactions}"
volumes:
- ./tests:/app/data/scripts/playerbot-regression:ro
- ./tests/playerbot_connectionless.lua:/app/data/scripts/creaturescripts/player_login_logout_message.lua:ro
2 changes: 1 addition & 1 deletion server/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ mysqlSock = ""
-- You can disable it to save some memory if you don't see any errors at startup.
-- checkDuplicateStorageKeys checks the values stored in the variables for duplicates.
allowChangeOutfit = true
freePremium = true
freePremium = os.getenv("FREE_PREMIUM") ~= "false"
kickIdlePlayerAfterMinutes = 15
maxMessageBuffer = 4
emoteSpells = false
Expand Down
16 changes: 12 additions & 4 deletions server/data/npc/lib/npcsystem/keywordhandler.lua
Original file line number Diff line number Diff line change
Expand Up @@ -244,20 +244,28 @@ if not KeywordHandler then
keys.callback = FocusModule.messageMatcherDefault

local npcHandler, spellName, price, vocationId = parameters.npcHandler, parameters.spellName, parameters.price, parameters.vocation
local spellKeyword = self:addKeyword(keys, StdModule.say, {npcHandler = npcHandler, text = string.format("Do you want to learn the spell %s for %s?", spellName, price > 0 and price .. " gold" or "free")},
local spellKeyword = self:addKeyword(keys, StdModule.say, {npcHandler = npcHandler, spellName = spellName, text = string.format("Do you want to learn the spell %s for %s?", spellName, price > 0 and price .. " gold" or "free")},
function(player)
local baseVocationId = player:getVocation():getBase():getId()
if type(vocationId) == 'table' then
-- Using a more efficient way to check if the player meets the vocation requirements
return table.find(vocationId, baseVocationId) ~= nil
return table.contains(vocationId, baseVocationId)
else
return vocationId == baseVocationId
end
end
)
local children = self:getRoot().children
for i = 1, #children - 1 do
local other = children[i]
if other.parameters and other.parameters.spellName and #keys > #other.keywords then
table.remove(children)
table.insert(children, i, spellKeyword)
break
end
end

-- It is not necessary to check if the player already has the spell, the check is done in modules.lua
spellKeyword:addChildKeyword({"yes"}, StdModule.learnSpell, {npcHandler = npcHandler, spellName = spellName, level = parameters.level, price = price})
spellKeyword:addChildKeyword({"yes"}, StdModule.learnSpell, {npcHandler = npcHandler, spellName = spellName, level = parameters.level, price = price, premium = parameters.premium})
spellKeyword:addChildKeyword({"no"}, StdModule.say, {npcHandler = npcHandler, text = "Maybe next time.", reset = true})
end
end
6 changes: 3 additions & 3 deletions server/data/npc/scripts/Asrak.lua
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ keywordHandler:addSpellKeyword({'conjure','arrow'}, {npcHandler = npcHandler, sp
keywordHandler:addSpellKeyword({'find','person'}, {npcHandler = npcHandler, spellName = 'Find Person', price = 80, level = 8, vocation ={3,4}})
keywordHandler:addSpellKeyword({'great','light'}, {npcHandler = npcHandler, spellName = 'Great Light', price = 500, level = 13, vocation ={3,4}})
keywordHandler:addSpellKeyword({'intense','healing'}, {npcHandler = npcHandler, spellName = 'Intense Healing', price = 350, level = 20, vocation ={3}})
keywordHandler:addSpellKeyword({'light'}, {npcHandler = npcHandler, spellName = 'Light', price = 0, level = 8, vocation ={3,4}})
keywordHandler:addSpellKeyword({'light','healing'}, {npcHandler = npcHandler, spellName = 'Light Healing', price = 0, level = 8, vocation ={3}})
keywordHandler:addSpellKeyword({'light'}, {npcHandler = npcHandler, spellName = 'Light', price = 100, level = 8, vocation ={3,4}})
keywordHandler:addSpellKeyword({'light','healing'}, {npcHandler = npcHandler, spellName = 'Light Healing', price = 170, level = 9, vocation ={3,4}})
keywordHandler:addKeyword({'healing', 'spells'}, StdModule.say, {npcHandler = npcHandler, text = "In this category I have '{Cure Poison}', '{Divine Healing}', '{Intense Healing}' and '{Light Healing}'."})
keywordHandler:addKeyword({'support', 'spells'}, StdModule.say, {npcHandler = npcHandler, text = "In this category I have '{Conjure Arrow}', '{Conjure Bolt}', '{Conjure Explosive Arrow}', '{Conjure Piercing Bolt}', '{Conjure Poisoned Arrow}', '{Conjure Sniper Arrow}', '{Destroy Field}', '{Find Person}', '{Great Light}' and '{Light}'."})
keywordHandler:addKeyword({'spells'}, StdModule.say, {npcHandler = npcHandler, text = 'I can teach you {Healing spells} and {Support spells}.'})
keywordHandler:addKeyword({'spells'}, StdModule.say, {npcHandler = npcHandler, text = 'I can teach you {Healing spells} and {Support spells}.'})
12 changes: 6 additions & 6 deletions server/data/npc/scripts/Charlotta.lua
Original file line number Diff line number Diff line change
Expand Up @@ -64,19 +64,19 @@ keywordHandler:addSpellKeyword({'animate','dead'}, {npcHandler = npcHandler, spe
keywordHandler:addSpellKeyword({'flame','strike'}, {npcHandler = npcHandler, spellName = 'Flame Strike', price = 800, level = 14, vocation ={2}})
keywordHandler:addSpellKeyword({'food'}, {npcHandler = npcHandler, spellName = 'Food', price = 300, level = 14, vocation ={2}})
keywordHandler:addSpellKeyword({'great','light'}, {npcHandler = npcHandler, spellName = 'Great Light', price = 500, level = 13, vocation ={2}})
keywordHandler:addSpellKeyword({'haste'}, {npcHandler = npcHandler, spellName = 'Haste', price = 600, level = 14, vocation ={2}})
keywordHandler:addSpellKeyword({'haste'}, {npcHandler = npcHandler, spellName = 'Haste', price = 600, level = 14, premium = true, vocation ={2}})
keywordHandler:addSpellKeyword({'heal','friend'}, {npcHandler = npcHandler, spellName = 'Heal Friend', price = 800, level = 18, vocation ={2}})
keywordHandler:addSpellKeyword({'heavy','magic','missile'}, {npcHandler = npcHandler, spellName = 'Heavy Magic Missile', price = 1500, level = 25, vocation ={2}})
keywordHandler:addSpellKeyword({'ice','strike'}, {npcHandler = npcHandler, spellName = 'Ice Strike', price = 800, level = 15, vocation ={2}})
keywordHandler:addSpellKeyword({'ice','wave'}, {npcHandler = npcHandler, spellName = 'Ice Wave', price = 850, level = 18, vocation ={2}})
keywordHandler:addSpellKeyword({'icicle'}, {npcHandler = npcHandler, spellName = 'Icicle', price = 1700, level = 28, vocation ={2}})
keywordHandler:addSpellKeyword({'intense','healing','rune'}, {npcHandler = npcHandler, spellName = 'Intense Healing Rune', price = 600, level = 15, vocation ={2}})
keywordHandler:addSpellKeyword({'invisible'}, {npcHandler = npcHandler, spellName = 'Invisible', price = 2000, level = 35, vocation ={2}})
keywordHandler:addSpellKeyword({'levitate'}, {npcHandler = npcHandler, spellName = 'Levitate', price = 500, level = 12, vocation ={2}})
keywordHandler:addSpellKeyword({'light'}, {npcHandler = npcHandler, spellName = 'Light', price = 0, level = 8, vocation ={2}})
keywordHandler:addSpellKeyword({'light','healing'}, {npcHandler = npcHandler, spellName = 'Light Healing', price = 0, level = 8, vocation ={2}})
keywordHandler:addSpellKeyword({'levitate'}, {npcHandler = npcHandler, spellName = 'Levitate', price = 500, level = 12, premium = true, vocation ={2}})
keywordHandler:addSpellKeyword({'light'}, {npcHandler = npcHandler, spellName = 'Light', price = 100, level = 8, vocation ={2}})
keywordHandler:addSpellKeyword({'light','healing'}, {npcHandler = npcHandler, spellName = 'Light Healing', price = 170, level = 9, vocation ={2}})
keywordHandler:addSpellKeyword({'light','magic','missile'}, {npcHandler = npcHandler, spellName = 'Light Magic Missile', price = 500, level = 15, vocation ={2}})
keywordHandler:addSpellKeyword({'magic','rope'}, {npcHandler = npcHandler, spellName = 'Magic Rope', price = 200, level = 9, vocation ={2}})
keywordHandler:addSpellKeyword({'magic','rope'}, {npcHandler = npcHandler, spellName = 'Magic Rope', price = 200, level = 9, premium = true, vocation ={2}})
keywordHandler:addSpellKeyword({'magic','shield'}, {npcHandler = npcHandler, spellName = 'Magic Shield', price = 450, level = 14, vocation ={2}})
keywordHandler:addSpellKeyword({'mass','healing'}, {npcHandler = npcHandler, spellName = 'Mass Healing', price = 2200, level = 36, vocation ={2}})
keywordHandler:addSpellKeyword({'poison','bomb'}, {npcHandler = npcHandler, spellName = 'Poison Bomb', price = 1000, level = 25, vocation ={2}})
Expand All @@ -95,4 +95,4 @@ keywordHandler:addSpellKeyword({'animate','dead'}, {npcHandler = npcHandler, spe
keywordHandler:addKeyword({'attack', 'spells'}, StdModule.say, {npcHandler = npcHandler, text = "In this category I have '{Energy Strike}', '{Flame Strike}', '{Ice Strike}', '{Ice Wave}', '{Terra Strike}' and '{Terra Wave}'."})
keywordHandler:addKeyword({'healing', 'spells'}, StdModule.say, {npcHandler = npcHandler, text = "In this category I have '{Cure Poison}', '{Heal Friend}', '{Light Healing}', '{Mass Healing}' and '{Ultimate Healing}'."})
keywordHandler:addKeyword({'support', 'spells'}, StdModule.say, {npcHandler = npcHandler, text = "In this category I have '{Animate Dead}', '{Avalanche}', '{Chameleon}', '{Convince Creature}', '{Creature Illusion}', '{Cure Poison Rune}', '{Destroy Field}', '{Disintegrate}', '{Energy Field}', '{Energy Wall}', '{Explosion}', '{Find Person}', '{Fire Bomb}', '{Fire Field}', '{Fire Wall}', '{Food}', '{Great Light}', '{Haste}', '{Heavy Magic Missile}', '{Icicle}', '{Intense Healing Rune}', '{Invisible}', '{Levitate}', '{Light}', '{Light Magic Missile}', '{Magic Rope}', '{Magic Shield}', '{Poison Bomb}', '{Poison Field}', '{Poison Wall}', '{Soulfire}', '{Stalagmite}', '{Stone Shower}', '{Strong Haste}', '{Summon Creature}', '{Summon Grovebeast}' and '{Ultimate Light}'."})
keywordHandler:addKeyword({'spells'}, StdModule.say, {npcHandler = npcHandler, text = 'I can teach you {Attack spells}, {Healing spells} and {Support spells}.'})
keywordHandler:addKeyword({'spells'}, StdModule.say, {npcHandler = npcHandler, text = 'I can teach you {Attack spells}, {Healing spells} and {Support spells}.'})
Loading