186 lines
10 KiB
TypeScript
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)
|