diablo2-web/scripts/verify-m4.ts

186 lines
10 KiB
TypeScript

/**
* M4 checks: skills, projectiles, quests, NPC dialogue, headless.
*
* Skills and quests are state machines with resource gates, and both fail in ways
* that are annoying to notice by playing: a skill that fires while on cooldown, a
* projectile that passes through a wall, a quest that counts the wrong monster or
* rewards twice, an NPC that offers a quest already in progress. Each is an
* assertion here.
*
* Name resolution is checked too: tables address strings by index into a `.tbl`,
* and the fallback (a literal cell, or no `.tbl` at all) has to keep working —
* that is the path a real archive takes while the TBL decoder is still unproven
* against shipped files.
*
* Usage: node scripts/verify-m4.ts
*/
import { parseTable, resolveText, textSourceOf } from '../src/game/tables.ts'
import { castSkill, skillDamageAt, skillsFromTable, tickProjectiles } from '../src/game/skills.ts'
import type { Projectile, ProjectileTarget, SkillDef } from '../src/game/skills.ts'
import { QuestLog, npcDialog, npcsFromTable, questsFromTable } from '../src/game/quests.ts'
import { Rng } from '../src/game/rng.ts'
const problems: string[] = []
let checks = 0
/**
* Assert one condition.
*
* @param condition - the condition to hold.
* @param description - what it means.
*/
function expect(condition: boolean, description: string): void {
checks += 1
if (!condition) problems.push(description)
}
// A tiny string table, as if decoded from a real `.tbl`.
const strings = textSourceOf(['', 'Fire Bolt', 'Ice Blast', 'Deckard Cain', 'Kill the fallen', 'Slay the fallen'])
expect(resolveText('1', strings) === 'Fire Bolt', 'a numeric cell resolves through the string table')
expect(resolveText('Fire Bolt', strings) === 'Fire Bolt', 'a literal cell passes through unchanged')
expect(resolveText('99', strings) === '99', 'an index the table does not have stays as written')
const noTable = textSourceOf(null)
expect(resolveText('7', noTable) === '7', 'with no table loaded, indices stay literal')
// --- skills -----------------------------------------------------------------
const skills = skillsFromTable(parseTable([
'Id\tName\tManaCost\tCooldownTicks\tRange\tSpeed\tMinDam\tMaxDam\tPerLevel\tRadius',
'firebolt\t1\t5\t8\t240\t300\t6\t9\t3\t20',
'frostnova\t2\t12\t40\t60\t0\t10\t14\t4\t90',
'basic\tBasic Attack\t0\t0\t48\t0\t2\t3\t1\t16',
].join('\r\n')), strings)
expect(skills.length === 3, 'every skill row becomes a definition')
expect(skills[0]?.name === 'Fire Bolt', 'skill names resolve through the string table')
expect(skills[0]?.projectile === true, 'a skill with missile speed is a projectile skill')
expect(skills[1]?.projectile === false, 'a skill with no speed strikes instantly')
expect(skills[2]?.name === 'Basic Attack', 'a literal name needs no table')
const firebolt = skills[0]!
const frostnova = skills[1]!
// damage scaling: base at level 1, plus the slope per level, inside the range
const level1 = Array.from({ length: 40 }, (_, index) => skillDamageAt(firebolt, 1, new Rng(index)))
expect(level1.every(damage => damage >= 6 && damage <= 9), 'level 1 damage stays inside the base range')
const level5 = Array.from({ length: 40 }, (_, index) => skillDamageAt(firebolt, 5, new Rng(index)))
expect(level5.every(damage => damage >= 18 && damage <= 21), 'each level adds the table slope')
expect(Math.min(...level5) > Math.max(...level1), 'a higher skill level always out-damages a lower one')
// --- casting gates ----------------------------------------------------------
const caster = { x: 100, y: 100, facing: 6 }
const ready = castSkill(firebolt, caster, 1, new Rng(1), 0, 50)
expect(ready.kind === 'projectile', 'a ready cast with mana produces a projectile')
expect(castSkill(firebolt, caster, 1, new Rng(1), 3, 50).kind === 'cooldown', 'a skill on cooldown refuses to fire')
expect(castSkill(firebolt, caster, 1, new Rng(1), 0, 1).kind === 'mana', 'a cast without mana refuses to fire')
expect(castSkill(firebolt, caster, 1, new Rng(1), 0, 5).kind === 'projectile', 'exactly enough mana is enough')
expect(castSkill(frostnova, caster, 1, new Rng(1), 0, 50).kind === 'instant', 'an instant skill reports as instant')
if (ready.kind === 'projectile') {
const shot = ready.projectile
// Facing 6 is east: the velocity must run along +x and nowhere else.
expect(shot.vx > 0 && Math.abs(shot.vy) < 1e-9, 'a projectile flies along the caster\'s facing')
expect(Math.abs(Math.hypot(shot.vx, shot.vy) - firebolt.speed / 25) < 1e-9, 'projectile speed is the table speed per tick')
expect(shot.ttl === Math.round(firebolt.range / (firebolt.speed / 25)), 'range divided by step gives the lifetime')
expect(shot.fromPlayer === true, 'a cast projectile is marked as the player\'s')
}
const aimed = castSkill(firebolt, { x: 0, y: 0, facing: 0 }, 1, new Rng(1), 0, 50, { x: 0, y: 100 })
if (aimed.kind === 'projectile') {
expect(aimed.projectile.vy > 0 && Math.abs(aimed.projectile.vx) < 1e-9, 'an aim point overrides the facing')
} else {
problems.push('an aimed cast did not produce a projectile')
}
// --- projectiles ------------------------------------------------------------
const flying: Projectile = { skillId: 'firebolt', x: 0, y: 0, vx: 12, vy: 0, damage: 7, ttl: 5, fromPlayer: true }
const targetAt = (x: number, alive = true): ProjectileTarget => ({ index: 1, x, y: 0, radius: 16, alive })
const openTerrain = { overlap: () => 0 }
const stepOne = tickProjectiles([flying], [targetAt(500)], openTerrain)
expect(stepOne.alive.length === 1 && stepOne.hits.length === 0, 'a projectile with nothing in the way keeps flying')
expect(stepOne.alive[0]?.x === 12, 'a projectile advances by its velocity each tick')
const hit = tickProjectiles([{ ...flying, x: 100 }], [targetAt(110)], openTerrain)
expect(hit.hits.length === 1 && hit.hits[0]?.targetIndex === 1, 'a projectile hits a target in its path')
expect(hit.alive.length === 0, 'a projectile that hits is consumed')
expect(hit.hits[0]?.damage === 7, 'the hit carries the rolled damage')
const wall = tickProjectiles([{ ...flying, x: 100 }], [targetAt(104)], { overlap: x => (x >= 105 ? 1 : 0) })
expect(wall.wallHits === 1 && wall.hits.length === 0, 'a wall stops a projectile before a target behind it')
expect(wall.alive.length === 0, 'a projectile that hits a wall is consumed')
const expired = tickProjectiles([{ ...flying, ttl: 0 }], [targetAt(500)], openTerrain)
expect(expired.expired === 1 && expired.alive.length === 0, 'a projectile dies when its lifetime runs out')
const dead = tickProjectiles([{ ...flying, x: 100 }], [targetAt(105, false)], openTerrain)
expect(dead.hits.length === 0 && dead.alive.length === 1, 'a dead target is not hit')
// --- quests and NPCs --------------------------------------------------------
const quests = questsFromTable(parseTable([
'Id\tName\tDescription\tMonsterId\tKillCount\tRewardXP\tRewardGold',
'den\t4\t5\tfallen\t3\t120\t50',
'any\tDen of Evil\tKill anything\t*\t2\t40\t10',
].join('\n')), strings)
expect(quests[0]?.name === 'Kill the fallen', 'quest names resolve through the string table')
expect(quests[0]?.description === 'Slay the fallen', 'descriptions resolve too')
expect(quests[1]?.name === 'Den of Evil', 'a literal quest name passes through')
const npcs = npcsFromTable(parseTable([
'Id\tName\tQuest\tOffer\tProgress\tDone',
'cain\t3\tden\tAccept|Go now\t1|Still alive?\tWell done',
].join('\n')), strings)
expect(npcs[0]?.name === 'Deckard Cain', 'NPC names resolve through the string table')
expect(npcs[0]?.offerLines.length === 2, 'pipe-separated dialogue becomes several lines')
expect(npcs[0]?.progressLines[0] === 'Fire Bolt', 'a numeric dialogue line resolves to its string')
const log = new QuestLog(quests)
const cain = npcs[0]!
expect(npcDialog(cain, log).join('|') === 'Accept|Go now', 'before accepting, the NPC offers the quest')
expect(log.accept('den'), 'accepting an inactive quest works')
expect(!log.accept('den'), 'accepting an active quest is refused')
expect(npcDialog(cain, log).join('|') === 'Fire Bolt|Still alive?', 'while active, the NPC reports progress')
expect(log.get('den')?.status === 'active', 'the quest is active')
expect(log.recordKill('zombie').length === 0, 'a kill of the wrong monster does not complete the quest')
expect(log.recordKill('fallen').length === 0, 'the first wanted kill does not complete a three-kill quest')
const rewards = log.recordKill('fallen')
expect(rewards.length === 0, 'the second kills still does not complete it')
const finalRewards = log.recordKill('fallen')
expect(finalRewards.length === 1 && finalRewards[0]?.questId === 'den', 'the third kill completes the quest')
expect(finalRewards[0]?.xp === 120 && finalRewards[0]?.gold === 50, 'the reward comes from the quest row')
expect(log.get('den')?.status === 'complete', 'the quest is complete')
expect(log.recordKill('fallen').length === 0, 'a completed quest does not reward again')
expect(npcDialog(cain, log).join('|') === 'Well done', 'after completion, the NPC changes to the done lines')
// A wildcard quest counts any monster, and both quests can be active at once.
const wildcardLog = new QuestLog(quests)
wildcardLog.accept('any')
expect(wildcardLog.recordKill('zombie').length === 0, 'a wildcard quest counts the first kill')
expect(wildcardLog.recordKill('skeleton').length === 1, 'a wildcard quest completes on any monsters')
expect(wildcardLog.active.length === 0, 'no quest is left active once complete')
expect(wildcardLog.all.length === 2, 'the log tracks every quest')
// --- determinism ------------------------------------------------------------
const castRun = (seed: number): string => {
const rng = new Rng(seed)
const shots: string[] = []
for (let index = 0; index < 6; index += 1) {
const result = castSkill(firebolt, { x: index, y: 0, facing: 6 }, 3, rng, 0, 100)
shots.push(result.kind === 'projectile' ? String(result.projectile.damage) : result.kind)
}
return shots.join(',')
}
expect(castRun(9) === castRun(9), 'the same seed casts for the same damage')
expect(new Set([1, 2, 3, 4].map(castRun)).size > 1, 'different seeds roll different damage')
console.log(`checks ${String(checks)}`)
console.log(`problems ${String(problems.length)}`)
for (const problem of problems.slice(0, 12)) console.log(` - ${problem}`)
console.log(problems.length === 0 ? 'RESULT skill, projectile and quest behaviours hold' : 'RESULT FAILED')
process.exit(problems.length === 0 ? 0 : 1)