/** * 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)