/** * M2 combat checks: the simulation, headless. * * The combat model is written to be driven one tick at a time from a small input * record precisely so it can be tested like this — without a canvas, without * input events, and without a map. Each check below states a behaviour the * sandbox must have (aggro, reach, cooldowns, resource costs, experience, * death and respawn) and asserts it against a simulated run, so a regression * shows up as a failing number rather than as something subtly wrong on screen. * * Usage: node scripts/verify-combat.ts */ import { createWorld, experienceTable, monsterStatsFromRow, monsterStatsFromTable, spawnMonsters, tickCombat, } from '../src/game/combat.ts' import type { CombatOptions, CombatWorld, MonsterStats } from '../src/game/combat.ts' import { parseTable, numberCell, findRow } from '../src/game/tables.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 table shaped like the real `MonStats.txt`, including its rough edges. */ const MONSTATS_TEXT = [ 'Id\tName\tHP\tDamage\tCooldownTicks\tReach\tAggroRadius\tSpeed\tXP', 'fallen\tFallen\t12\t3\t24\t36\t200\t80\t8', 'zombie\tZombie\t30\t6\t32\t40\t160\t50\t15\t\t', 'skeleton\tSkeleton\t18\t4\t28\t38\t240\t70\t12', 'empty\tSmall Rat\t\t2\t\t\t\t\t4', // missing cells: defaults must apply 'nulled\tNull Beast\t(null)\t5\t30\t40\t180\t60\t9', ].join('\r\n') const options: CombatOptions = { playerSpeed: 200, playerReach: 48, playerCooldownTicks: 25, playerDamage: 6, playerManaPerAttack: 2, respawnTicks: 50, } /** Flat ground: nothing is ever overlapped. */ const openTerrain = { overlap: () => 0 } // --- tables ----------------------------------------------------------------- const monstats = parseTable(MONSTATS_TEXT) expect(monstats.columns[0] === 'Id', 'header columns are read in order') expect(monstats.rows.length === 5, 'every non-empty line becomes a record') expect(monstats.rows[2]?.Id === 'skeleton', 'records are keyed by column name') expect(monstats.rows[3]?.HP === undefined, 'an empty cell is absent, not empty-string') expect(monstats.rows[4]?.HP === undefined, 'the (null) marker means absent') expect(numberCell(monstats.rows[3] ?? {}, 'HP', 20) === 20, 'a missing numeric cell falls back to the default') expect(findRow(monstats, 'Id', 'ZOMBIE')?.Name === 'Zombie', 'row lookup is case-insensitive') const stats = monsterStatsFromTable(monstats) expect(stats.length === 5, 'every record becomes a definition') expect(stats[3]?.hp === 20, 'a definition with no HP column uses the default') expect(stats[4]?.hp === 20, 'a definition with a null HP uses the default') expect(stats[0]?.name === 'Fallen' && stats[0]?.xp === 8, 'names and experience come from the table') const xpTable = experienceTable(parseTable([ 'Level\tXP', '1\t0', '2\t500', '3\t1500', '4\t400', ].join('\n'))) expect(xpTable[2] === 500 && xpTable[3] === 1500, 'experience thresholds are read per level') expect(xpTable[4] === 1500, 'a non-monotonic table is clamped upward') // --- spawning --------------------------------------------------------------- const world: CombatWorld = createWorld(0, 0) const blockedHalf = { overlap: (x: number) => (x < 0 ? 1 : 0) } const placed = spawnMonsters(world, stats, 6, { x: 0, y: 0 }, 200, blockedHalf) expect(placed === 6 && world.monsters.length === 6, 'spawns fill up to the requested count') expect(world.monsters.every(monster => monster.x >= 0), 'no monster is dropped into blocked ground') const deterministic: CombatWorld = createWorld(0, 0) spawnMonsters(deterministic, stats, 6, { x: 0, y: 0 }, 200, blockedHalf) expect( JSON.stringify(world.monsters.map(monster => [Math.round(monster.x), Math.round(monster.y)])) === JSON.stringify(deterministic.monsters.map(monster => [Math.round(monster.x), Math.round(monster.y)])), 'spawn positions are reproducible', ) // --- aggro, chase, reach ---------------------------------------------------- const idleWorld = createWorld(1000, 1000) const idleStats: MonsterStats = { ...stats[0]!, aggroRadius: 100, speed: 100, reach: 30 } idleWorld.monsters.push({ index: 0, stats: idleStats, x: 0, y: 0, hp: idleStats.hp, cooldown: 0, state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0, }) tickCombat(idleWorld, { movement: { x: 0, y: 0 }, attack: false }, options, openTerrain, xpTable) expect(idleWorld.monsters[0]?.state === 'idle', 'a monster outside its aggro radius stays idle') // Inside the monster's aggro radius: a monster that never notices the player is // tested by the idle case above, so this one must be within range to chase. const chaseWorld = createWorld(80, 0) chaseWorld.monsters.push({ index: 0, stats: idleStats, x: 0, y: 0, hp: idleStats.hp, cooldown: 0, state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0, }) const startDistance = Math.hypot(80, 0) for (let i = 0; i < 10; i += 1) tickCombat(chaseWorld, { movement: { x: 0, y: 0 }, attack: false }, options, openTerrain, xpTable) const chaseDistance = Math.hypot(chaseWorld.monsters[0]!.x - 80, chaseWorld.monsters[0]!.y) expect(chaseWorld.monsters[0]?.state === 'chase' || chaseWorld.monsters[0]?.state === 'attack', 'a monster inside aggro closes in') expect(chaseDistance < startDistance, 'closing in actually reduces the distance') const reachWorld = createWorld(20, 0) reachWorld.monsters.push({ index: 0, stats: idleStats, x: 0, y: 0, hp: 1000, cooldown: 0, state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0, }) const hpBefore = reachWorld.player.hp for (let i = 0; i < 100; i += 1) tickCombat(reachWorld, { movement: { x: 0, y: 0 }, attack: false }, options, openTerrain, xpTable) const taken = hpBefore - reachWorld.player.hp // Attacks land on ticks 1, 25, 49, 73 and 97 for a 24-tick cooldown: five hits // in a hundred ticks, which is what "respects the cooldown" has to mean. expect(taken === 5 * idleStats.damage, `monster damage respects its cooldown (took ${String(taken)}, expected ${String(5 * idleStats.damage)})`) expect(reachWorld.monsters[0]?.state === 'attack', 'a monster in reach switches to attacking') // --- player attack, mana, cooldowns ----------------------------------------- const attackWorld = createWorld(20, 0) attackWorld.monsters.push({ index: 0, stats: { ...idleStats, hp: 100, xp: 40 }, x: 0, y: 0, hp: 100, cooldown: 999, state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0, }) const manaBefore = attackWorld.player.mana for (let i = 0; i < 100; i += 1) tickCombat(attackWorld, { movement: { x: 0, y: 0 }, attack: true }, options, openTerrain, xpTable) const monsterDamage = 100 - (attackWorld.monsters[0]?.hp ?? 0) expect(monsterDamage === 4 * options.playerDamage, `player attacks respect the cooldown (dealt ${String(monsterDamage)})`) expect(manaBefore - attackWorld.player.mana === 4 * options.playerManaPerAttack, 'each attack spends its mana') const noManaWorld = createWorld(20, 0) noManaWorld.player.mana = 1 noManaWorld.monsters.push({ index: 0, stats: idleStats, x: 0, y: 0, hp: 100, cooldown: 999, state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0, }) tickCombat(noManaWorld, { movement: { x: 0, y: 0 }, attack: true }, options, openTerrain, xpTable) expect(noManaWorld.events.some(event => event.kind === 'noMana'), 'an unaffordable attack reports no-mana instead of landing') expect(noManaWorld.monsters[0]?.hp === 100, 'an unaffordable attack deals no damage') const whiffWorld = createWorld(0, 0) for (let i = 0; i < 3; i += 1) tickCombat(whiffWorld, { movement: { x: 0, y: 0 }, attack: true }, options, openTerrain, xpTable) expect(whiffWorld.player.cooldown >= 0, 'attacking with nothing in reach is legal and still costs the cooldown') // --- kill, experience, level up --------------------------------------------- const killWorld = createWorld(20, 0) const killStats: MonsterStats = { ...idleStats, hp: 12, xp: 600, cooldownTicks: 999 } killWorld.monsters.push({ index: 0, stats: killStats, x: 0, y: 0, hp: 12, cooldown: 999, state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0, }) let sawKill = false let sawLevelUp = false for (let i = 0; i < 60; i += 1) { tickCombat(killWorld, { movement: { x: 0, y: 0 }, attack: true }, options, openTerrain, xpTable) if (killWorld.events.some(event => event.kind === 'kill')) sawKill = true if (killWorld.events.some(event => event.kind === 'levelUp')) sawLevelUp = true } expect(sawKill, 'killing a monster emits a kill event') expect(killWorld.kills === 1, 'the kill is counted') expect(killWorld.monsters[0]?.state === 'dead', 'the monster is left dead, not removed mid-frame') expect(killWorld.player.xp === 600, 'experience is awarded from the monster table') expect(sawLevelUp && killWorld.player.level === 2, 'crossing the table threshold levels the player up') expect(killWorld.player.maxHp > 60 && killWorld.player.hp === killWorld.player.maxHp, 'a level up raises and refills resources') // --- death and respawn ------------------------------------------------------ const deathWorld = createWorld(20, 0) deathWorld.player.hp = 5 deathWorld.monsters.push({ index: 0, stats: { ...idleStats, damage: 50, reach: 60 }, x: 0, y: 0, hp: 100, cooldown: 0, state: 'idle', facing: 0, hitFlash: 0, corpseTicks: 0, }) tickCombat(deathWorld, { movement: { x: 0, y: 0 }, attack: false }, options, openTerrain, xpTable) expect(!deathWorld.player.alive, 'lethal damage kills the player') expect(deathWorld.player.respawnIn === options.respawnTicks, 'death starts the respawn timer') for (let i = 0; i < options.respawnTicks + 2; i += 1) { tickCombat(deathWorld, { movement: { x: 0, y: 0 }, attack: false }, options, openTerrain, xpTable) } expect(deathWorld.player.alive, 'the player comes back after the respawn delay') expect(deathWorld.player.hp === deathWorld.player.maxHp, 'respawn restores health') expect(deathWorld.player.mana === deathWorld.player.maxMana, 'respawn restores mana') // --- determinism ------------------------------------------------------------ /** * Digest a world's observable state. * * @param target - the world. * @returns a string digest. */ function digest(target: CombatWorld): string { return JSON.stringify([ target.tick, target.kills, target.player.hp, target.player.xp, target.monsters.map(monster => [Math.round(monster.x * 100), Math.round(monster.y * 100), monster.hp, monster.state]), ]) } /** * Run a fixed scenario for a number of ticks. * * @param ticks - how many ticks to run. * @returns the world digest. */ function runScenario(ticks: number): string { const scenario = createWorld(0, 0) spawnMonsters(scenario, stats, 4, { x: 120, y: 0 }, 120, openTerrain) for (let i = 0; i < ticks; i += 1) { const movement = i % 40 < 20 ? { x: 1, y: 0 } : { x: 0, y: 1 } const attack = i % 7 === 0 tickCombat(scenario, { movement, attack }, options, openTerrain, xpTable) } return digest(scenario) } expect(runScenario(300) === runScenario(300), 'the same inputs and seed produce the same simulation') 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 combat behaviours hold' : 'RESULT FAILED') process.exit(problems.length === 0 ? 0 : 1)