diablo2-web/tests/m4.test.ts

544 lines
34 KiB
TypeScript

import { describe, test, expect as vitestExpect } from 'vitest'
import * as fs from 'fs'
/**
* 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 isSkip = false && !fs.existsSync('samples/d2');
const _results: any[] = [];
let suiteCompleted = false;
let problems: string[] = [];
let checks: any[] = [];
function expect(condition: boolean, description: string) { _results.push({cond: condition, desc: description}); if (!condition) problems.push(description); }
function check(nameOrOk: any, okOrMessage: any, detail?: string) {
if (typeof nameOrOk === 'string') {
_results.push({cond: okOrMessage, desc: nameOrOk, detail}); if (!okOrMessage) checks.push({name: nameOrOk, ok: okOrMessage});
} else {
_results.push({cond: nameOrOk, desc: okOrMessage}); if (!nameOrOk) checks.push({name: okOrMessage, ok: nameOrOk});
}
}
if (!isSkip) {
const problems: string[] = []
let checks = 0
/**
* Assert one condition.
*
* @param condition - the condition to hold.
* @param description - what it means.
*/
function __expect_disabled(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 casters 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 players')
}
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')
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')
// disabled exit: problems.length === 0 ? 0 : 1)
suiteCompleted = true;
}
describe('verify-m4.ts', () => {
test.skipIf(isSkip)('evaluates script successfully', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
vitestExpect(problems).toEqual([]);
});
test.skipIf(isSkip)('a numeric cell resolves through the string table', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('a numeric cell resolves through the string table'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('a literal cell passes through unchanged', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('a literal cell passes through unchanged'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('an index the table does not have stays as written', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('an index the table does not have stays as written'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('with no table loaded, indices stay literal', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('with no table loaded, indices stay literal'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('every skill row becomes a definition', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('every skill row becomes a definition'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('skill names resolve through the string table', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('skill names resolve through the string table'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('a skill with missile speed is a projectile skill', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('a skill with missile speed is a projectile skill'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('a skill with no speed strikes instantly', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('a skill with no speed strikes instantly'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('a literal name needs no table', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('a literal name needs no table'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('level 1 damage stays inside the base range', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('level 1 damage stays inside the base range'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('each level adds the table slope', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('each level adds the table slope'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('a higher skill level always out-damages a lower one', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('a higher skill level always out-damages a lower one'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('a ready cast with mana produces a projectile', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('a ready cast with mana produces a projectile'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('a skill on cooldown refuses to fire', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('a skill on cooldown refuses to fire'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('a cast without mana refuses to fire', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('a cast without mana refuses to fire'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('exactly enough mana is enough', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('exactly enough mana is enough'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('an instant skill reports as instant', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('an instant skill reports as instant'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('a projectile flies along the casters facing', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith("a projectile flies along the caster's facing"));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('projectile speed is the table speed per tick', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('projectile speed is the table speed per tick'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('range divided by step gives the lifetime', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('range divided by step gives the lifetime'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('a cast projectile is marked as the players', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith("a cast projectile is marked as the player's"));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('an aim point overrides the facing', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('an aim point overrides the facing'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('a projectile with nothing in the way keeps flying', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('a projectile with nothing in the way keeps flying'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('a projectile advances by its velocity each tick', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('a projectile advances by its velocity each tick'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('a projectile hits a target in its path', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('a projectile hits a target in its path'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('a projectile that hits is consumed', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('a projectile that hits is consumed'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('the hit carries the rolled damage', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('the hit carries the rolled damage'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('a wall stops a projectile before a target behind it', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('a wall stops a projectile before a target behind it'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('a projectile that hits a wall is consumed', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('a projectile that hits a wall is consumed'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('a projectile dies when its lifetime runs out', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('a projectile dies when its lifetime runs out'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('a dead target is not hit', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('a dead target is not hit'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('quest names resolve through the string table', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('quest names resolve through the string table'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('descriptions resolve too', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('descriptions resolve too'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('a literal quest name passes through', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('a literal quest name passes through'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('NPC names resolve through the string table', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('NPC names resolve through the string table'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('pipe-separated dialogue becomes several lines', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('pipe-separated dialogue becomes several lines'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('a numeric dialogue line resolves to its string', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('a numeric dialogue line resolves to its string'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('before accepting, the NPC offers the quest', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('before accepting, the NPC offers the quest'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('accepting an inactive quest works', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('accepting an inactive quest works'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('accepting an active quest is refused', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('accepting an active quest is refused'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('while active, the NPC reports progress', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('while active, the NPC reports progress'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('the quest is active', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('the quest is active'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('a kill of the wrong monster does not complete the quest', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('a kill of the wrong monster does not complete the quest'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('the first wanted kill does not complete a three-kill quest', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('the first wanted kill does not complete a three-kill quest'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('the second kills still does not complete it', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('the second kills still does not complete it'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('the third kill completes the quest', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('the third kill completes the quest'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('the reward comes from the quest row', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('the reward comes from the quest row'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('the quest is complete', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('the quest is complete'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('a completed quest does not reward again', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('a completed quest does not reward again'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('after completion, the NPC changes to the done lines', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('after completion, the NPC changes to the done lines'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('a wildcard quest counts the first kill', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('a wildcard quest counts the first kill'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('a wildcard quest completes on any monsters', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('a wildcard quest completes on any monsters'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('no quest is left active once complete', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('no quest is left active once complete'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('the log tracks every quest', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('the log tracks every quest'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('the same seed casts for the same damage', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('the same seed casts for the same damage'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
test.skipIf(isSkip)('different seeds roll different damage', () => {
vitestExpect(suiteCompleted, 'Suite failed to complete').toBe(true);
let resKey = _results.findIndex(x => x.desc.startsWith('different seeds roll different damage'));
if (resKey !== -1) vitestExpect(_results[resKey].cond, _results[resKey].detail || _results[resKey].desc).toBe(true);
else vitestExpect(true).toBe(true);
});
});