/** * `AnimData.D2` decoder tests, against the real 1.13c archive. * * The structural claims here (570,304 bytes consumed, 3,558 records, every * record's name hash equal to its block index) are what make this decoder * trustworthy at all: the file has no magic number and no version field, so a * decoder that is one byte out of step still produces plausible-looking records. * Only the byte-exact consumption and the 3,558/3,558 hash agreement catch that. * * The 25 golden clips are the user-supplied ground-truth table from * `.agents/ORIGINAL_REQUEST.md` L57-L83, re-verified against the archive. They * are asserted in full rather than sampled, because the interesting failures — * a wrong event frame, a speed read from the wrong offset — show up on * individual clips, not on aggregates. * * The synthetic cases cover what the shipped file cannot: truncation, a tampered * length, a corrupted name, a frame count past the 144-byte flag array, and * duplicate names with conflicting values. */ import { describe, expect, test, beforeAll } from 'vitest' import * as fs from 'node:fs' import { animDataHash, decodeAnimData, decodeAnimDataFile, ticksPerCycle, ANIMDATA_BLOCK_COUNT, ANIMDATA_FLAG_COUNT, ANIMDATA_NAME_SIZE, ANIMDATA_RECORD_SIZE, } from '../src/formats/animdata.ts' import type { AnimDataFile } from '../src/formats/animdata.ts' import { FormatError, InvalidFieldError, TruncatedDataError } from '../src/formats/reader.ts' import { MpqArchive } from '../src/mpq/archive.ts' import { fileSource } from '../src/mpq/file-source.ts' import { MountedArchives } from '../src/mpq/mount.ts' import { cell, parseTable } from '../src/game/acts.ts' import { COMPOSITS, MON_MODES, PLR_MODES, WEAPON_CLASSES, WEAPON_CLASS_CODES, cofName, monModeToken, plrModeToken, } from '../src/game/plr-mode.ts' const ARCHIVE_DIR = 'samples/d2' const MOUNTS = ['d2char.mpq', 'd2data.mpq', 'd2exp.mpq', 'Patch_D2.mpq'] as const const ANIMDATA_MEMBER = 'data\\global\\AnimData.D2' const EANIMDATA_MEMBER = 'data\\global\\EAnimData.D2' /** The real archives are optional in a checkout without game data. */ const hasD2 = MOUNTS.every(name => fs.existsSync(`${ARCHIVE_DIR}/${name}`)) /** Shipped file size, and the arithmetic that proves the layout: 3558*160 + 256*4. */ const ANIMDATA_BYTES = 570_304 /** Records in the shipped table, duplicates included. */ const ANIMDATA_RECORDS = 3558 /** Distinct names; 29 names are stored twice. */ const ANIMDATA_UNIQUE_NAMES = 3529 /** * Ground truth from `.agents/ORIGINAL_REQUEST.md` L57-L83. * * `ticks` is `fpd * 256 / speed` rounded to two decimals — the same arithmetic * the spec table lists, kept here so a wrong speed cannot pass by coincidence. */ const GOLDEN: readonly { readonly name: string readonly fpd: number readonly speed: number readonly ticks: number readonly events: readonly (readonly [number, number])[] }[] = [ { name: 'SONUHTH', fpd: 8, speed: 128, ticks: 16.0, events: [] }, { name: 'SOWLHTH', fpd: 8, speed: 256, ticks: 8.0, events: [] }, { name: 'SORNHTH', fpd: 8, speed: 256, ticks: 8.0, events: [] }, { name: 'SOTNHTH', fpd: 16, speed: 80, ticks: 51.2, events: [] }, { name: 'SOTWHTH', fpd: 8, speed: 256, ticks: 8.0, events: [] }, { name: 'SOA1HTH', fpd: 16, speed: 256, ticks: 16.0, events: [[9, 1]] }, { name: 'SOA11HS', fpd: 20, speed: 256, ticks: 20.0, events: [[12, 1]] }, { name: 'SOSCHTH', fpd: 14, speed: 256, ticks: 14.0, events: [[7, 1]] }, { name: 'SOTHHTH', fpd: 20, speed: 256, ticks: 20.0, events: [[10, 2]] }, { name: 'SOKKHTH', fpd: 12, speed: 256, ticks: 12.0, events: [[6, 1]] }, { name: 'SOGHHTH', fpd: 8, speed: 256, ticks: 8.0, events: [] }, { name: 'SOBLHTH', fpd: 5, speed: 256, ticks: 5.0, events: [] }, { name: 'SODTHTH', fpd: 24, speed: 256, ticks: 24.0, events: [] }, { name: 'SODDHTH', fpd: 1, speed: 256, ticks: 1.0, events: [] }, { name: 'BAWL1HS', fpd: 8, speed: 168, ticks: 12.19, events: [] }, { name: 'BARN1HS', fpd: 8, speed: 216, ticks: 9.48, events: [] }, { name: 'BAA11HS', fpd: 16, speed: 256, ticks: 16.0, events: [[7, 1]] }, { name: 'PAWL1HS', fpd: 10, speed: 288, ticks: 8.89, events: [] }, { name: 'SKA1HTH', fpd: 16, speed: 208, ticks: 19.69, events: [[8, 2]] }, { name: 'SKWLHTH', fpd: 8, speed: 128, ticks: 16.0, events: [] }, { name: 'SKGHHTH', fpd: 4, speed: 184, ticks: 5.57, events: [] }, { name: 'SKDDHTH', fpd: 1, speed: 152, ticks: 1.68, events: [] }, { name: 'FANUHTH', fpd: 20, speed: 256, ticks: 20.0, events: [] }, { name: 'FAWLHTH', fpd: 10, speed: 256, ticks: 10.0, events: [] }, { name: 'FAA1HTH', fpd: 10, speed: 256, ticks: 10.0, events: [[7, 1]] }, ] /** A record to synthesise into a well-formed buffer. */ interface SynthRecord { readonly name: string readonly fpd: number readonly speed: number /** Flag bytes to set, by flag-array index. */ readonly flags?: Readonly> } /** * Build a structurally valid `AnimData.D2` buffer. * * Records are filed into the block their own name hashes to, so the result * satisfies the decoder's integrity checks and can then be damaged one field at * a time. * * @param records - records to store. * @returns the encoded buffer. */ function buildAnimData(records: readonly SynthRecord[]): Uint8Array { const byBlock = new Map() for (const record of records) { const block = animDataHash(record.name) const list = byBlock.get(block) ?? [] list.push(record) byBlock.set(block, list) } const out = new Uint8Array(ANIMDATA_BLOCK_COUNT * 4 + records.length * ANIMDATA_RECORD_SIZE) const view = new DataView(out.buffer) let offset = 0 for (let block = 0; block < ANIMDATA_BLOCK_COUNT; block += 1) { const list = byBlock.get(block) ?? [] view.setUint32(offset, list.length, true) offset += 4 for (const record of list) { if (record.name.length > ANIMDATA_NAME_SIZE) throw new Error(`name too long: ${record.name}`) for (let i = 0; i < record.name.length; i += 1) out[offset + i] = record.name.charCodeAt(i) view.setUint32(offset + ANIMDATA_NAME_SIZE, record.fpd, true) view.setUint32(offset + ANIMDATA_NAME_SIZE + 4, record.speed, true) for (const [frame, code] of Object.entries(record.flags ?? {})) { out[offset + ANIMDATA_NAME_SIZE + 8 + Number(frame)] = code } offset += ANIMDATA_RECORD_SIZE } } return out } /** * Byte offset of the record for `name` in a buffer built with exactly that one * record before it. * * Blocks are interleaved — each block's count is followed by that block's * records — so the record does not start at the end of a 1,024-byte count * table. With no earlier records, it starts right after its own block's count. * * @param name - the record's name. * @returns the offset of the record's first byte. */ function firstRecordOffset(name: string): number { return (animDataHash(name) + 1) * 4 } describe('animDataHash', () => { test('is the sum of the upper-cased character codes modulo 256', () => { // 'SOA1HTH' = 83+79+65+49+72+84+72 = 504; 504 % 256 = 248. expect(animDataHash('SOA1HTH')).toBe(248) expect(animDataHash('soa1hth')).toBe(248) expect(animDataHash('')).toBe(0) }) test('stays inside the block range for every shipped-style name', () => { for (const golden of GOLDEN) { const hash = animDataHash(golden.name) expect(hash).toBeGreaterThanOrEqual(0) expect(hash).toBeLessThan(ANIMDATA_BLOCK_COUNT) } }) }) describe('decodeAnimData on synthetic buffers', () => { test('round-trips names, frames, speed and events', () => { const data = buildAnimData([ { name: 'AAA1HTH', fpd: 12, speed: 256, flags: { 3: 1, 9: 2 } }, { name: 'ZZDDHTH', fpd: 1, speed: 128 }, ]) const file = decodeAnimDataFile(data) expect(file.all).toHaveLength(2) expect(file.bytesConsumed).toBe(data.length) const first = file.records.get('AAA1HTH')! expect(first.framesPerDirection).toBe(12) expect(first.animationSpeed).toBe(256) expect(first.hasSpeed).toBe(true) expect(first.events).toEqual([{ frame: 3, code: 1 }, { frame: 9, code: 2 }]) expect(first.block).toBe(animDataHash('AAA1HTH')) expect(file.records.get('ZZDDHTH')!.framesPerDirection).toBe(1) }) test('an empty table is 1,024 bytes of zero counts', () => { const data = buildAnimData([]) expect(data.length).toBe(ANIMDATA_BLOCK_COUNT * 4) const file = decodeAnimDataFile(data) expect(file.all).toHaveLength(0) expect(file.bytesConsumed).toBe(data.length) }) test('throws when the buffer is truncated mid-record', () => { const data = buildAnimData([{ name: 'AAA1HTH', fpd: 12, speed: 256 }]) expect(() => decodeAnimDataFile(data.subarray(0, data.length - 1))).toThrow(TruncatedDataError) expect(() => decodeAnimDataFile(data.subarray(0, 1030))).toThrow(TruncatedDataError) }) test('throws when the buffer is truncated mid-block-count', () => { const data = buildAnimData([]) expect(() => decodeAnimDataFile(data.subarray(0, 3))).toThrow(TruncatedDataError) expect(() => decodeAnimDataFile(new Uint8Array(0))).toThrow(TruncatedDataError) }) test('throws when trailing bytes are appended, rather than ignoring them', () => { const data = buildAnimData([{ name: 'AAA1HTH', fpd: 12, speed: 256 }]) const tampered = new Uint8Array(data.length + 1) tampered.set(data) let caught: unknown try { decodeAnimDataFile(tampered) } catch (error) { caught = error } expect(caught).toBeInstanceOf(InvalidFieldError) expect((caught as InvalidFieldError).field).toBe('bytes consumed') expect((caught as InvalidFieldError).got).toBe(data.length) expect((caught as InvalidFieldError).expected).toBe(tampered.length) }) test('throws when a block claims more records than the buffer holds', () => { // Running past the end of the buffer entirely. const empty = buildAnimData([]) new DataView(empty.buffer).setUint32((ANIMDATA_BLOCK_COUNT - 1) * 4, 1, true) expect(() => decodeAnimDataFile(empty)).toThrow(TruncatedDataError) // Running into the following blocks' count bytes, which are not a record. const data = buildAnimData([{ name: 'AAA1HTH', fpd: 12, speed: 256 }]) new DataView(data.buffer).setUint32(animDataHash('AAA1HTH') * 4, 4, true) expect(() => decodeAnimDataFile(data)).toThrow(FormatError) }) test('throws when a name is corrupted into the wrong hash block', () => { const data = buildAnimData([{ name: 'AAA1HTH', fpd: 12, speed: 256 }]) data[firstRecordOffset('AAA1HTH')] = 'B'.charCodeAt(0) let caught: unknown try { decodeAnimDataFile(data) } catch (error) { caught = error } expect(caught).toBeInstanceOf(InvalidFieldError) expect((caught as InvalidFieldError).field).toBe('hash of BAA1HTH') }) test('throws on a non-printable or empty name rather than inventing one', () => { const data = buildAnimData([{ name: 'AAA1HTH', fpd: 12, speed: 256 }]) const recordStart = firstRecordOffset('AAA1HTH') const blanked = data.slice() blanked.fill(0, recordStart, recordStart + ANIMDATA_NAME_SIZE) expect(() => decodeAnimDataFile(blanked)).toThrow(FormatError) const control = data.slice() control[recordStart + 1] = 0x01 expect(() => decodeAnimDataFile(control)).toThrow(InvalidFieldError) const resumed = data.slice() resumed[recordStart + 7] = 'X'.charCodeAt(0) expect(() => decodeAnimDataFile(resumed)).toThrow(InvalidFieldError) }) test('scans only the first 144 frames for events and reports the ceiling', () => { const data = buildAnimData([{ name: 'AABLHTH', fpd: 200, speed: 168, flags: { 4: 1, 143: 2 } }]) const file = decodeAnimDataFile(data) const record = file.records.get('AABLHTH')! expect(record.framesPerDirection).toBe(200) expect(record.events).toEqual([{ frame: 4, code: 1 }, { frame: 143, code: 2 }]) expect(file.recordsOverFlagCeiling.map(entry => entry.name)).toEqual(['AABLHTH']) expect(file.strayFlags).toEqual([]) }) test('never emits an event at or past the frame count, and reports the stray byte', () => { const data = buildAnimData([{ name: 'AABLHTH', fpd: 5, speed: 256, flags: { 2: 1, 17: 2 } }]) const file = decodeAnimDataFile(data) const record = file.records.get('AABLHTH')! expect(record.events).toEqual([{ frame: 2, code: 1 }]) expect(record.events.every(event => event.frame < record.framesPerDirection)).toBe(true) expect(file.strayFlags).toEqual([{ name: 'AABLHTH', frame: 17, code: 2 }]) }) test('preserves unknown event codes instead of dropping or normalising them', () => { const data = buildAnimData([{ name: 'AAA1HTH', fpd: 10, speed: 256, flags: { 1: 3, 2: 7, 3: 255 } }]) const record = decodeAnimData(data).get('AAA1HTH')! expect(record.events).toEqual([ { frame: 1, code: 3 }, { frame: 2, code: 7 }, { frame: 3, code: 255 }, ]) }) test('resolves duplicate names first-wins and lists the conflict', () => { const data = buildAnimData([ { name: 'AAA1HTH', fpd: 17, speed: 200, flags: { 10: 2 } }, { name: 'AAA1HTH', fpd: 17, speed: 160, flags: { 10: 2 } }, { name: 'ZZDDHTH', fpd: 1, speed: 256 }, { name: 'ZZDDHTH', fpd: 1, speed: 256 }, ]) const file = decodeAnimDataFile(data) expect(file.all).toHaveLength(4) expect(file.records.size).toBe(2) expect(file.records.get('AAA1HTH')!.animationSpeed).toBe(200) expect(file.duplicates.map(entry => entry.name).sort()).toEqual(['AAA1HTH', 'ZZDDHTH']) expect(file.conflicts.map(entry => entry.name)).toEqual(['AAA1HTH']) expect(file.conflicts[0]!.records.map(record => record.animationSpeed)).toEqual([200, 160]) }) test('preserves speed 0 and refuses to time it', () => { const data = buildAnimData([{ name: 'AADTHTH', fpd: 1, speed: 0 }]) const record = decodeAnimData(data).get('AADTHTH')! expect(record.animationSpeed).toBe(0) expect(record.hasSpeed).toBe(false) expect(() => ticksPerCycle(record)).toThrow(InvalidFieldError) }) test('ticksPerCycle is fpd * 256 / speed, fractional', () => { const data = buildAnimData([ { name: 'AAWL1HS', fpd: 8, speed: 168 }, { name: 'AANUHTH', fpd: 8, speed: 128 }, ]) const records = decodeAnimData(data) expect(ticksPerCycle(records.get('AAWL1HS')!)).toBeCloseTo(12.19, 2) expect(ticksPerCycle(records.get('AANUHTH')!)).toBe(16) }) }) describe.skipIf(!hasD2)('decodeAnimData against the real AnimData.D2', () => { let archives: MountedArchives let raw: Uint8Array let file: AnimDataFile beforeAll(async () => { archives = new MountedArchives() for (const name of MOUNTS) { archives.add(name, await MpqArchive.open(await fileSource(`${ARCHIVE_DIR}/${name}`))) } raw = await archives.read(ANIMDATA_MEMBER) file = decodeAnimDataFile(raw) }) test('is read through has()/read(), never listFiles()', () => { expect(archives.has(ANIMDATA_MEMBER)).toBe(true) expect(archives.has(EANIMDATA_MEMBER)).toBe(true) }) test('consumes exactly 570,304 bytes with no remainder and no overrun', () => { expect(raw.length).toBe(ANIMDATA_BYTES) expect(file.bytesConsumed).toBe(ANIMDATA_BYTES) expect(file.bytesConsumed).toBe(raw.length) }) test('holds 3,558 records whose arithmetic reproduces the file size', () => { expect(file.all).toHaveLength(ANIMDATA_RECORDS) expect(file.all.length * ANIMDATA_RECORD_SIZE + ANIMDATA_BLOCK_COUNT * 4).toBe(ANIMDATA_BYTES) expect(file.blockCounts).toHaveLength(ANIMDATA_BLOCK_COUNT) expect(file.blockCounts.reduce((sum, count) => sum + count, 0)).toBe(ANIMDATA_RECORDS) }) test('every one of the 3,558 records hashes to the block it is stored in', () => { let hits = 0 const misses: string[] = [] for (const record of file.all) { if (animDataHash(record.name) === record.block) hits += 1 else misses.push(`${record.name} in block ${String(record.block)}`) } expect(misses).toEqual([]) expect(hits).toBe(ANIMDATA_RECORDS) }) test('holds 3,529 distinct names with 29 duplicates, 9 of them conflicting', () => { expect(file.records.size).toBe(ANIMDATA_UNIQUE_NAMES) expect(file.duplicates).toHaveLength(29) expect(file.conflicts).toHaveLength(9) expect(file.conflicts.map(entry => entry.name).sort()).toEqual([ '3DNUHTH', '64A1HTH', '64NUHTH', 'MINUHTH', 'VMA1HTH', 'VMGHHTH', 'VMNUHTH', 'VMS1HTH', 'VMWLHTH', ]) }) test('resolves conflicting duplicates first-wins', () => { // VMS1HTH is stored as (17, 200) then (17, 160). The engine scans the hash // block in order and returns the first match, so 200 is the live value. const conflict = file.conflicts.find(entry => entry.name === 'VMS1HTH')! expect(conflict.records.map(record => record.animationSpeed)).toEqual([200, 160]) expect(file.records.get('VMS1HTH')!.animationSpeed).toBe(200) for (const entry of file.conflicts) { expect(file.records.get(entry.name)).toBe(entry.records[0]) } }) test.each(GOLDEN)('golden clip $name: $fpd frames at speed $speed', golden => { const record = file.records.get(golden.name) expect(record, `${golden.name} missing from AnimData.D2`).toBeDefined() expect(record!.framesPerDirection).toBe(golden.fpd) expect(record!.animationSpeed).toBe(golden.speed) expect(record!.events.map(event => [event.frame, event.code])).toEqual( golden.events.map(event => [event[0], event[1]]), ) expect(ticksPerCycle(record!)).toBeCloseTo(golden.ticks, 2) }) test('the golden table covers all 25 rows of the spec', () => { expect(GOLDEN).toHaveLength(25) expect(new Set(GOLDEN.map(golden => golden.name)).size).toBe(25) }) test('four records store speed 0 and are flagged rather than clamped', () => { const speedless = file.all.filter(record => !record.hasSpeed) expect(speedless.map(record => record.name).sort()).toEqual(['CODTHTH', 'R7DDHTH', 'VHNUHTH', 'VJNUHTH']) for (const record of speedless) { expect(record.animationSpeed).toBe(0) expect(() => ticksPerCycle(record)).toThrow(InvalidFieldError) } }) test('42DTHTH declares 200 frames against a 144-byte flag array', () => { expect(file.recordsOverFlagCeiling.map(record => record.name)).toEqual(['42DTHTH']) const record = file.records.get('42DTHTH')! expect(record.framesPerDirection).toBe(200) expect(record.framesPerDirection).toBeGreaterThan(ANIMDATA_FLAG_COUNT) expect(record.events).toEqual([]) expect(Math.max(...file.all.map(entry => entry.framesPerDirection))).toBe(200) }) test('no event in the whole file sits at or past its own frame count', () => { const out: string[] = [] for (const record of file.all) { for (const event of record.events) { if (event.frame >= record.framesPerDirection) out.push(`${record.name}@${String(event.frame)}`) if (event.frame >= ANIMDATA_FLAG_COUNT) out.push(`${record.name} beyond ceiling`) } } expect(out).toEqual([]) expect(file.strayFlags).toEqual([]) }) test('events are ascending by frame within every record', () => { for (const record of file.all) { const frames = record.events.map(event => event.frame) expect(frames).toEqual([...frames].sort((a, b) => a - b)) } }) test('event code 3 ships in four records and is preserved verbatim', () => { const histogram = new Map() for (const record of file.all) { for (const event of record.events) histogram.set(event.code, (histogram.get(event.code) ?? 0) + 1) } expect([...histogram].sort((a, b) => a[0] - b[0])).toEqual([[1, 433], [2, 148], [3, 4]]) const withCode3 = file.all.filter(record => record.events.some(event => event.code === 3)) expect(withCode3.map(record => record.name).sort()).toEqual(['10A1HTH', '10A2HTH', '13A1HTH', 'TES1HTH']) expect(file.records.get('10A1HTH')!.events).toEqual([{ frame: 14, code: 3 }, { frame: 17, code: 1 }]) expect(file.records.get('10A2HTH')!.events).toEqual([{ frame: 16, code: 3 }, { frame: 18, code: 1 }]) expect(file.records.get('13A1HTH')!.events).toEqual([{ frame: 14, code: 3 }, { frame: 17, code: 1 }]) expect(file.records.get('TES1HTH')!.events).toEqual([{ frame: 3, code: 3 }]) }) test('decodeAnimData returns the same first-wins map as decodeAnimDataFile', () => { const map = decodeAnimData(raw) expect(map.size).toBe(file.records.size) expect(map.get('SOA1HTH')).toEqual(file.records.get('SOA1HTH')) expect(map.get('VMS1HTH')!.animationSpeed).toBe(200) }) test('truncating or extending the real file makes the decoder throw', () => { expect(() => decodeAnimDataFile(raw.subarray(0, raw.length - 1))).toThrow(FormatError) expect(() => decodeAnimDataFile(raw.subarray(0, Math.floor(raw.length / 2)))).toThrow(TruncatedDataError) expect(() => decodeAnimDataFile(raw.subarray(0, 1024))).toThrow(TruncatedDataError) const extended = new Uint8Array(raw.length + 4) extended.set(raw) let caught: unknown try { decodeAnimDataFile(extended) } catch (error) { caught = error } expect(caught).toBeInstanceOf(InvalidFieldError) expect((caught as InvalidFieldError).field).toBe('bytes consumed') }) test('corrupting one name byte of the real file is caught by the hash check', () => { const tampered = raw.slice() const view = new DataView(tampered.buffer) // Walk the interleaved blocks to the first record actually stored. let offset = 0 let found = -1 for (let block = 0; block < ANIMDATA_BLOCK_COUNT; block += 1) { const count = view.getUint32(offset, true) offset += 4 if (count > 0) { found = offset break } } expect(found).toBeGreaterThan(0) const original = tampered[found]! // Any change to a name character moves the character-code sum, and the sum // is the block index, so the record no longer belongs where it is stored. tampered[found] = original === 0x41 ? 0x42 : 0x41 let caught: unknown try { decodeAnimDataFile(tampered) } catch (error) { caught = error } expect(caught).toBeInstanceOf(InvalidFieldError) expect((caught as InvalidFieldError).field.startsWith('hash of ')).toBe(true) }) test('EAnimData.D2 is a value-identical subset, so merge precedence is moot', async () => { const expansionRaw = await archives.read(EANIMDATA_MEMBER) expect(expansionRaw.length).toBe(565_984) const expansion = decodeAnimDataFile(expansionRaw) expect(expansion.bytesConsumed).toBe(expansionRaw.length) expect(expansion.all).toHaveLength(3531) const collisions: string[] = [] const disagreeing: string[] = [] for (const [name, record] of expansion.records) { const base = file.records.get(name) if (base === undefined) continue collisions.push(name) if ( base.framesPerDirection !== record.framesPerDirection || base.animationSpeed !== record.animationSpeed || JSON.stringify(base.events) !== JSON.stringify(record.events) ) { disagreeing.push(name) } } const onlyInExpansion = [...expansion.records.keys()].filter(name => !file.records.has(name)) const onlyInBase = [...file.records.keys()].filter(name => !expansion.records.has(name)) expect(collisions).toHaveLength(3520) expect(disagreeing).toEqual([]) expect(onlyInExpansion).toEqual([]) expect(onlyInBase.sort()).toEqual([ '0JDDHTH', '41DDHTH', '44WLHTH', 'K9A2HTH', 'K9BLHTH', 'K9S2HTH', 'K9S3HTH', 'K9S4HTH', 'K9SCHTH', ]) }) }) describe.skipIf(!hasD2)('plr-mode constants match the shipped excel tables', () => { let archives: MountedArchives beforeAll(async () => { archives = new MountedArchives() for (const name of MOUNTS) { archives.add(name, await MpqArchive.open(await fileSource(`${ARCHIVE_DIR}/${name}`))) } }) /** * Read one excel table by explicit name. * * @param name - table stem, e.g. `PlrMode`. * @returns the parsed table. */ async function readExcel(name: string) { const member = `data\\global\\excel\\${name}.txt` expect(archives.has(member), `${member} missing`).toBe(true) return parseTable(await archives.read(member)) } test('PlrMode.txt: 20 rows, code and token per row, SQ and KB borrow GH', async () => { const table = await readExcel('PlrMode') expect(table.rows).toHaveLength(PLR_MODES.length) table.rows.forEach((row, index) => { const baked = PLR_MODES[index]! expect(cell(table, row, 'Code')).toBe(baked.code) expect(cell(table, row, 'Token')).toBe(baked.token) expect(cell(table, row, 'Name')).toBe(baked.name) }) expect(plrModeToken('SQ')).toBe('GH') expect(plrModeToken('KB')).toBe('GH') expect(plrModeToken('a1')).toBe('A1') expect(plrModeToken('ZZ')).toBeUndefined() }) test('MonMode.txt: 16 rows, KB borrows GH and the sequence row is xx', async () => { const table = await readExcel('MonMode') expect(table.rows).toHaveLength(MON_MODES.length) table.rows.forEach((row, index) => { const baked = MON_MODES[index]! expect(cell(table, row, 'code').toUpperCase()).toBe(baked.code) expect(cell(table, row, 'token').toUpperCase()).toBe(baked.token) expect(cell(table, row, 'name')).toBe(baked.name) }) expect(monModeToken('KB')).toBe('GH') expect(monModeToken('XX')).toBe('XX') expect(monModeToken('TN')).toBeUndefined() }) test('Composit.txt: 16 rows in composite-type order', async () => { const table = await readExcel('Composit') expect(table.rows).toHaveLength(COMPOSITS.length) table.rows.forEach((row, index) => { const baked = COMPOSITS[index]! expect(cell(table, row, 'Token')).toBe(baked.token) expect(cell(table, row, 'Name')).toBe(baked.name) }) }) test('WeaponClass.txt: 15 rows, and only 14 carry a COF suffix', async () => { const table = await readExcel('WeaponClass') expect(table.rows).toHaveLength(WEAPON_CLASSES.length) table.rows.forEach((row, index) => { const baked = WEAPON_CLASSES[index]! expect(cell(table, row, 'Code')).toBe(baked.code) expect(cell(table, row, 'Weapon Class')).toBe(baked.name) }) expect(WEAPON_CLASS_CODES).toHaveLength(14) expect(WEAPON_CLASS_CODES).not.toContain('') }) test('mode tokens and weapon classes compose names that exist in AnimData.D2', async () => { const file = decodeAnimDataFile(await archives.read(ANIMDATA_MEMBER)) // Sorceress: every in-scope player mode has an hth clip. for (const mode of ['NU', 'WL', 'RN', 'A1', 'A2', 'SC', 'GH', 'DT', 'DD', 'TN', 'TW']) { expect(file.records.has(cofName('so', mode, 'hth')), `SO${mode}HTH missing`).toBe(true) } // SQ/KB are not animations of their own: they resolve to GH. expect(file.records.has('SOSQHTH')).toBe(false) expect(file.records.has('SOKBHTH')).toBe(false) expect(file.records.has(cofName('so', plrModeToken('SQ')!, 'hth'))).toBe(true) }) })