fix(client): table-driven missile ids and derived character stats (P3)
- MISSILE_ID_TO_KEY derived from CANONICAL_113C_MISSILES (Missiles.txt row ids); client visual aliases flagged clientAlias and excluded; 4 wrong ids corrected (firewallmaker 68, blaze 67, infernoflame1/2 60/61). - No 'firebolt' / 'SO' substitution: unknown missile ids are not drawn (MIS_<id> token), unknown player class ids throw. - derived-stats.ts: AR/defense/resists from CharStats ToHitFactor and DifficultyLevels ResistPenalty (pure helper + tests).
This commit is contained in:
parent
f61ff41527
commit
58de64610f
|
|
@ -0,0 +1,85 @@
|
||||||
|
/**
|
||||||
|
* Character sheet derived values (attack rating, defense, resistances) computed from 1.13c tables.
|
||||||
|
*
|
||||||
|
* The 1.13c server does not send these totals: D2Client derives them from the unit StatList and
|
||||||
|
* `CharStats.txt` / `DifficultyLevels.txt`. Inputs here are the server StatList (absent stat = 0),
|
||||||
|
* the class `ToHitFactor` (`CANONICAL_CHARSTATS`) and the difficulty `ResistPenalty`.
|
||||||
|
*
|
||||||
|
* Formulas (1.13c):
|
||||||
|
* - Attack rating = (Dexterity - 7) * 5 + ToHitFactor + stat 19 `tohit`, scaled by stat 119 `item_tohit_percent`.
|
||||||
|
* - Defense = floor(Dexterity / 4) + stat 31 `armorclass`.
|
||||||
|
* - Resistance = min(75 + stat `max*resist`, stat `*resist` + ResistPenalty) for fire/light/cold/poison
|
||||||
|
* (stats 39/41/43/45, max stats 40/42/44/46).
|
||||||
|
*
|
||||||
|
* Equipment item stats are only included once they are aggregated into the self StatList.
|
||||||
|
*/
|
||||||
|
import { RAW_DIFFICULTY_LEVELS } from '../../common/data/canonical-drop-data.ts'
|
||||||
|
import { CANONICAL_CHARSTATS, type CharacterClassCode } from '../../common/units/classes.ts'
|
||||||
|
|
||||||
|
export interface StatReader {
|
||||||
|
get(statId: number): number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterSheetDerived {
|
||||||
|
readonly attackRating: number
|
||||||
|
readonly defense: number
|
||||||
|
readonly fireRes: number
|
||||||
|
readonly coldRes: number
|
||||||
|
readonly lightRes: number
|
||||||
|
readonly poisRes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAT_DEXTERITY = 2
|
||||||
|
const STAT_TOHIT = 19
|
||||||
|
const STAT_ARMORCLASS = 31
|
||||||
|
const STAT_ITEM_TOHIT_PERCENT = 119
|
||||||
|
const STAT_FIRERESIST = 39
|
||||||
|
const STAT_MAXFIRERESIST = 40
|
||||||
|
const STAT_LIGHTRESIST = 41
|
||||||
|
const STAT_MAXLIGHTRESIST = 42
|
||||||
|
const STAT_COLDRESIST = 43
|
||||||
|
const STAT_MAXCOLDRESIST = 44
|
||||||
|
const STAT_POISONRESIST = 45
|
||||||
|
const STAT_MAXPOISONRESIST = 46
|
||||||
|
const BASE_MAX_RESIST = 75
|
||||||
|
|
||||||
|
function parseResistPenalties(raw: string): readonly number[] {
|
||||||
|
const lines = raw.split(/\r?\n/).filter((l) => l.trim() !== '')
|
||||||
|
const header = lines[0]!.split('\t')
|
||||||
|
const idx = header.indexOf('ResistPenalty')
|
||||||
|
if (idx < 0) throw new Error('DifficultyLevels.txt: missing ResistPenalty column')
|
||||||
|
const out = lines.slice(1).map((l) => Number(l.split('\t')[idx]))
|
||||||
|
if (out.length !== 3 || out.some((v) => !Number.isFinite(v))) {
|
||||||
|
throw new Error('DifficultyLevels.txt: expected 3 rows with numeric ResistPenalty')
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `DifficultyLevels.txt` `ResistPenalty` per difficulty (Normal / Nightmare / Hell). */
|
||||||
|
export const RESIST_PENALTY_BY_DIFFICULTY: readonly number[] = parseResistPenalties(RAW_DIFFICULTY_LEVELS)
|
||||||
|
|
||||||
|
function resist(stats: StatReader, statId: number, maxStatId: number, penalty: number): number {
|
||||||
|
return Math.min(BASE_MAX_RESIST + stats.get(maxStatId), stats.get(statId) + penalty)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeCharacterSheetDerived(
|
||||||
|
classCode: CharacterClassCode,
|
||||||
|
stats: StatReader,
|
||||||
|
difficulty: 0 | 1 | 2,
|
||||||
|
): CharacterSheetDerived {
|
||||||
|
const charStats = CANONICAL_CHARSTATS[classCode]
|
||||||
|
if (!charStats) throw new Error(`computeCharacterSheetDerived: no CharStats row for class ${classCode}`)
|
||||||
|
const penalty = RESIST_PENALTY_BY_DIFFICULTY[difficulty]
|
||||||
|
if (penalty === undefined) throw new Error(`computeCharacterSheetDerived: invalid difficulty ${difficulty}`)
|
||||||
|
const dex = stats.get(STAT_DEXTERITY)
|
||||||
|
const baseAr = (dex - 7) * 5 + charStats.toHitFactor + stats.get(STAT_TOHIT)
|
||||||
|
const attackRating = Math.floor((baseAr * (100 + stats.get(STAT_ITEM_TOHIT_PERCENT))) / 100)
|
||||||
|
return {
|
||||||
|
attackRating,
|
||||||
|
defense: Math.floor(dex / 4) + stats.get(STAT_ARMORCLASS),
|
||||||
|
fireRes: resist(stats, STAT_FIRERESIST, STAT_MAXFIRERESIST, penalty),
|
||||||
|
coldRes: resist(stats, STAT_COLDRESIST, STAT_MAXCOLDRESIST, penalty),
|
||||||
|
lightRes: resist(stats, STAT_LIGHTRESIST, STAT_MAXLIGHTRESIST, penalty),
|
||||||
|
poisRes: resist(stats, STAT_POISONRESIST, STAT_MAXPOISONRESIST, penalty),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { getMissileTxtData } from '../../common/skills/missiles-data.ts'
|
import { CANONICAL_113C_MISSILES, getMissileTxtData } from '../../common/skills/missiles-data.ts'
|
||||||
import type { UnitKind, UnitSnapshot } from '../../netproto/index.ts'
|
import type { UnitKind, UnitSnapshot } from '../../netproto/index.ts'
|
||||||
import type { PointLightInput } from '../scene/lighting-presets.ts'
|
import type { PointLightInput } from '../scene/lighting-presets.ts'
|
||||||
import { subTileToWorldOrtho } from './viewport-profile.ts'
|
import { subTileToWorldOrtho } from './viewport-profile.ts'
|
||||||
|
|
@ -112,39 +112,17 @@ export type SceneEntityDrawable =
|
||||||
| SceneOverlayDrawable
|
| SceneOverlayDrawable
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Canonical 1.13c `Missiles.txt` ID -> internal missile key lookup for D2GS missile units.
|
* 1.13c `Missiles.txt` row id (D2GS missile unit classId) -> missile key, derived from
|
||||||
|
* `CANONICAL_113C_MISSILES` (Missiles.txt rows only; client visual aliases excluded).
|
||||||
|
* Ids without art data are absent: callers must treat them as unknown, never substitute another missile.
|
||||||
*/
|
*/
|
||||||
export const MISSILE_ID_TO_KEY: Readonly<Record<number, string>> = {
|
export const MISSILE_ID_TO_KEY: Readonly<Record<number, string>> = Object.freeze(
|
||||||
1: 'arrow',
|
Object.fromEntries(
|
||||||
2: 'javelin',
|
Object.entries(CANONICAL_113C_MISSILES)
|
||||||
6: 'magicarrow',
|
.filter(([, m]) => m.clientAlias !== true)
|
||||||
9: 'firearrow',
|
.map(([key, m]) => [m.id, key] as const),
|
||||||
11: 'coldarrow',
|
),
|
||||||
14: 'guidedarrow',
|
)
|
||||||
28: 'firebolt',
|
|
||||||
29: 'fireball',
|
|
||||||
35: 'chargedbolt',
|
|
||||||
41: 'lightning',
|
|
||||||
44: 'nova',
|
|
||||||
49: 'icebolt',
|
|
||||||
53: 'iceblast',
|
|
||||||
55: 'glacialspike',
|
|
||||||
56: 'meteor',
|
|
||||||
59: 'blizzard',
|
|
||||||
63: 'chainlightning',
|
|
||||||
64: 'frostnova',
|
|
||||||
83: 'poisonnova',
|
|
||||||
92: 'bonespear',
|
|
||||||
93: 'bonespirit',
|
|
||||||
97: 'teeth',
|
|
||||||
101: 'holybolt',
|
|
||||||
102: 'blessedhammer',
|
|
||||||
165: 'frozenorb',
|
|
||||||
232: 'firestorm',
|
|
||||||
235: 'moltenboulder',
|
|
||||||
244: 'twister',
|
|
||||||
245: 'tornado',
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OverlayMeta {
|
export interface OverlayMeta {
|
||||||
readonly overlayId: number
|
readonly overlayId: number
|
||||||
|
|
@ -186,11 +164,14 @@ export function resolveUnitToken(unit: RenderableUnitInput): string {
|
||||||
return unit.token.trim()
|
return unit.token.trim()
|
||||||
}
|
}
|
||||||
if (unit.kind === 'player') {
|
if (unit.kind === 'player') {
|
||||||
return PLAYER_CLASS_TOKENS[unit.classId] ?? 'SO'
|
const token = PLAYER_CLASS_TOKENS[unit.classId]
|
||||||
|
if (!token) throw new Error(`resolveUnitToken: unknown player class id ${unit.classId}`)
|
||||||
|
return token
|
||||||
}
|
}
|
||||||
if (unit.kind === 'missile') {
|
if (unit.kind === 'missile') {
|
||||||
const key = MISSILE_ID_TO_KEY[unit.classId] ?? 'firebolt'
|
const key = MISSILE_ID_TO_KEY[unit.classId]
|
||||||
return getMissileTxtData(key).celFile
|
// Unknown missile ids keep an explicit unresolved token (like OBJ_/MON_) instead of borrowing another missile.
|
||||||
|
return key ? getMissileTxtData(key).celFile : `MIS_${unit.classId}`
|
||||||
}
|
}
|
||||||
if (unit.kind === 'object') {
|
if (unit.kind === 'object') {
|
||||||
return `OBJ_${unit.classId}`
|
return `OBJ_${unit.classId}`
|
||||||
|
|
@ -204,11 +185,12 @@ export function resolveUnitToken(unit: RenderableUnitInput): string {
|
||||||
export function resolveMissileMetadata(
|
export function resolveMissileMetadata(
|
||||||
classId: number,
|
classId: number,
|
||||||
explicitToken?: string,
|
explicitToken?: string,
|
||||||
): { readonly key: string; readonly entry: MissileTxtEntry } {
|
): { readonly key: string; readonly entry: MissileTxtEntry } | null {
|
||||||
const key =
|
const key =
|
||||||
explicitToken && explicitToken.trim() !== ''
|
explicitToken && explicitToken.trim() !== ''
|
||||||
? explicitToken.trim().toLowerCase()
|
? explicitToken.trim().toLowerCase()
|
||||||
: (MISSILE_ID_TO_KEY[classId] ?? 'firebolt')
|
: MISSILE_ID_TO_KEY[classId]
|
||||||
|
if (!key) return null
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
entry: getMissileTxtData(key),
|
entry: getMissileTxtData(key),
|
||||||
|
|
@ -271,7 +253,9 @@ export function buildSceneDrawables(
|
||||||
const { x: worldX, y: worldY } = subTileToWorldOrtho(unit.x, unit.y, originX, originY)
|
const { x: worldX, y: worldY } = subTileToWorldOrtho(unit.x, unit.y, originX, originY)
|
||||||
|
|
||||||
if (unit.kind === 'missile') {
|
if (unit.kind === 'missile') {
|
||||||
const { key, entry } = resolveMissileMetadata(unit.classId, unit.token)
|
const resolved = resolveMissileMetadata(unit.classId, unit.token)
|
||||||
|
if (!resolved) continue // Unknown Missiles.txt id: not drawn (no substitute art).
|
||||||
|
const { key, entry } = resolved
|
||||||
const blendMode: DrawableBlendMode = entry.trans === 1 ? 'additive' : 'normal'
|
const blendMode: DrawableBlendMode = entry.trans === 1 ? 'additive' : 'normal'
|
||||||
const dir = computeDirection8Or16(
|
const dir = computeDirection8Or16(
|
||||||
unit.x,
|
unit.x,
|
||||||
|
|
|
||||||
|
|
@ -188,6 +188,11 @@ export const FACING_VECTORS: readonly (readonly [number, number])[] = [
|
||||||
export interface MissileTxtData {
|
export interface MissileTxtData {
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly id: number
|
readonly id: number
|
||||||
|
/**
|
||||||
|
* `true` for client-side visual aliases that are NOT rows of 1.13c Missiles.txt (their `id` points at the
|
||||||
|
* real row they render for). Aliases are excluded from the wire id -> missile key lookup.
|
||||||
|
*/
|
||||||
|
readonly clientAlias?: boolean | undefined
|
||||||
readonly vel: number
|
readonly vel: number
|
||||||
readonly maxVel: number
|
readonly maxVel: number
|
||||||
readonly range: number
|
readonly range: number
|
||||||
|
|
@ -261,6 +266,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
},
|
},
|
||||||
lightning: {
|
lightning: {
|
||||||
name: 'lightning',
|
name: 'lightning',
|
||||||
|
clientAlias: true,
|
||||||
id: 98,
|
id: 98,
|
||||||
vel: 30,
|
vel: 30,
|
||||||
maxVel: 30,
|
maxVel: 30,
|
||||||
|
|
@ -326,6 +332,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
},
|
},
|
||||||
thunderstorm: {
|
thunderstorm: {
|
||||||
name: 'thunderstorm',
|
name: 'thunderstorm',
|
||||||
|
clientAlias: true,
|
||||||
id: 166,
|
id: 166,
|
||||||
vel: 2,
|
vel: 2,
|
||||||
maxVel: 2,
|
maxVel: 2,
|
||||||
|
|
@ -370,6 +377,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
},
|
},
|
||||||
electricnova: {
|
electricnova: {
|
||||||
name: 'electricnova',
|
name: 'electricnova',
|
||||||
|
clientAlias: true,
|
||||||
id: 90,
|
id: 90,
|
||||||
vel: 24,
|
vel: 24,
|
||||||
maxVel: 24,
|
maxVel: 24,
|
||||||
|
|
@ -584,6 +592,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
},
|
},
|
||||||
freezeexplode: {
|
freezeexplode: {
|
||||||
name: 'freezeexplode',
|
name: 'freezeexplode',
|
||||||
|
clientAlias: true,
|
||||||
id: 88,
|
id: 88,
|
||||||
vel: 0,
|
vel: 0,
|
||||||
maxVel: 0,
|
maxVel: 0,
|
||||||
|
|
@ -626,6 +635,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
},
|
},
|
||||||
glacialspikeejecta: {
|
glacialspikeejecta: {
|
||||||
name: 'glacialspikeejecta',
|
name: 'glacialspikeejecta',
|
||||||
|
clientAlias: true,
|
||||||
id: 89,
|
id: 89,
|
||||||
vel: 0,
|
vel: 0,
|
||||||
maxVel: 0,
|
maxVel: 0,
|
||||||
|
|
@ -920,6 +930,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
},
|
},
|
||||||
hydrafire: {
|
hydrafire: {
|
||||||
name: 'hydrafire',
|
name: 'hydrafire',
|
||||||
|
clientAlias: true,
|
||||||
id: 247,
|
id: 247,
|
||||||
vel: 16,
|
vel: 16,
|
||||||
maxVel: 16,
|
maxVel: 16,
|
||||||
|
|
@ -941,7 +952,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
},
|
},
|
||||||
firewallmaker: {
|
firewallmaker: {
|
||||||
name: 'firewallmaker',
|
name: 'firewallmaker',
|
||||||
id: 82,
|
id: 68,
|
||||||
vel: 0,
|
vel: 0,
|
||||||
maxVel: 0,
|
maxVel: 0,
|
||||||
range: 1,
|
range: 1,
|
||||||
|
|
@ -983,6 +994,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
},
|
},
|
||||||
groundfirebig: {
|
groundfirebig: {
|
||||||
name: 'groundfirebig',
|
name: 'groundfirebig',
|
||||||
|
clientAlias: true,
|
||||||
id: 83,
|
id: 83,
|
||||||
vel: 0,
|
vel: 0,
|
||||||
maxVel: 0,
|
maxVel: 0,
|
||||||
|
|
@ -1004,7 +1016,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
},
|
},
|
||||||
blaze: {
|
blaze: {
|
||||||
name: 'blaze',
|
name: 'blaze',
|
||||||
id: 80,
|
id: 67,
|
||||||
vel: 0,
|
vel: 0,
|
||||||
maxVel: 0,
|
maxVel: 0,
|
||||||
range: 90,
|
range: 90,
|
||||||
|
|
@ -1025,7 +1037,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
},
|
},
|
||||||
infernoflame1: {
|
infernoflame1: {
|
||||||
name: 'infernoflame1',
|
name: 'infernoflame1',
|
||||||
id: 72,
|
id: 60,
|
||||||
vel: 12,
|
vel: 12,
|
||||||
maxVel: 12,
|
maxVel: 12,
|
||||||
range: 16,
|
range: 16,
|
||||||
|
|
@ -1046,7 +1058,7 @@ export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> =
|
||||||
},
|
},
|
||||||
infernoflame2: {
|
infernoflame2: {
|
||||||
name: 'infernoflame2',
|
name: 'infernoflame2',
|
||||||
id: 73,
|
id: 61,
|
||||||
vel: 12,
|
vel: 12,
|
||||||
maxVel: 12,
|
maxVel: 12,
|
||||||
range: 16,
|
range: 16,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
RESIST_PENALTY_BY_DIFFICULTY,
|
||||||
|
computeCharacterSheetDerived,
|
||||||
|
} from '../../src/client/ui-model/derived-stats.ts'
|
||||||
|
|
||||||
|
function statsOf(entries: Record<number, number>) {
|
||||||
|
return { get: (id: number) => entries[id] ?? 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('character sheet derived values (1.13c tables)', () => {
|
||||||
|
it('reads ResistPenalty from DifficultyLevels.txt', () => {
|
||||||
|
expect(RESIST_PENALTY_BY_DIFFICULTY).toEqual([0, -40, -100])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('level-1 Assassin (dex 20, no bonuses)', () => {
|
||||||
|
const d = computeCharacterSheetDerived('ass', statsOf({ 2: 20 }), 0)
|
||||||
|
expect(d.attackRating).toBe((20 - 7) * 5 + 15)
|
||||||
|
expect(d.defense).toBe(5)
|
||||||
|
expect([d.fireRes, d.coldRes, d.lightRes, d.poisRes]).toEqual([0, 0, 0, 0])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies difficulty penalty and the 75 + max-resist cap', () => {
|
||||||
|
const d = computeCharacterSheetDerived('sor', statsOf({ 2: 25, 39: 200, 40: 5, 43: 30 }), 2)
|
||||||
|
expect(d.fireRes).toBe(80)
|
||||||
|
expect(d.coldRes).toBe(-70)
|
||||||
|
expect(d.attackRating).toBe((25 - 7) * 5 - 15)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
/**
|
||||||
|
* Audit regression: `MISSILE_ID_TO_KEY` must match 1.13c Missiles.txt (row id <-> Missile name),
|
||||||
|
* and unknown ids must not borrow another missile's art.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { MISSILE_ID_TO_KEY, resolveMissileMetadata, resolveUnitToken } from '../../src/client/view/unit-drawables.ts'
|
||||||
|
|
||||||
|
function loadMissilesTxtIdToName(): Map<number, string> {
|
||||||
|
const text = readFileSync(resolve(__dirname, '../../samples/fixtures/data/global/excel/Missiles.txt'), 'utf8')
|
||||||
|
const lines = text.split(/\r?\n/)
|
||||||
|
const header = lines[0]!.split('\t')
|
||||||
|
const nameIdx = header.indexOf('Missile')
|
||||||
|
const idIdx = header.indexOf('Id')
|
||||||
|
const out = new Map<number, string>()
|
||||||
|
for (const line of lines.slice(1)) {
|
||||||
|
const cols = line.split('\t')
|
||||||
|
const name = (cols[nameIdx] ?? '').trim()
|
||||||
|
const id = Number(cols[idIdx])
|
||||||
|
if (name && Number.isInteger(id)) out.set(id, name.toLowerCase())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MISSILE_ID_TO_KEY vs Missiles.txt', () => {
|
||||||
|
const txt = loadMissilesTxtIdToName()
|
||||||
|
|
||||||
|
it('every mapped id names the same Missiles.txt row', () => {
|
||||||
|
const entries = Object.entries(MISSILE_ID_TO_KEY)
|
||||||
|
expect(entries.length).toBeGreaterThan(40)
|
||||||
|
for (const [id, key] of entries) {
|
||||||
|
expect(txt.get(Number(id)), `id ${id}`).toBe(key)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('pins well-known 1.13c ids', () => {
|
||||||
|
expect(MISSILE_ID_TO_KEY[0]).toBe('arrow')
|
||||||
|
expect(MISSILE_ID_TO_KEY[56]).toBe('chargedbolt')
|
||||||
|
expect(MISSILE_ID_TO_KEY[58]).toBe('firebolt')
|
||||||
|
expect(MISSILE_ID_TO_KEY[59]).toBe('icebolt')
|
||||||
|
expect(MISSILE_ID_TO_KEY[62]).toBe('fireball')
|
||||||
|
expect(MISSILE_ID_TO_KEY[90]).toBe('nova')
|
||||||
|
expect(MISSILE_ID_TO_KEY[92]).toBe('blessedhammer')
|
||||||
|
expect(MISSILE_ID_TO_KEY[100]).toBe('meteor')
|
||||||
|
expect(MISSILE_ID_TO_KEY[260]).toBe('frozenorb')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('unknown missile ids are explicit (no firebolt / SO substitution)', () => {
|
||||||
|
expect(resolveMissileMetadata(9999)).toBeNull()
|
||||||
|
expect(
|
||||||
|
resolveUnitToken({ id: 1, kind: 'missile', classId: 9999, x: 0, y: 0 } as any),
|
||||||
|
).toBe('MIS_9999')
|
||||||
|
expect(() => resolveUnitToken({ id: 1, kind: 'player', classId: 7, x: 0, y: 0 } as any)).toThrow()
|
||||||
|
expect(resolveUnitToken({ id: 1, kind: 'player', classId: 6, x: 0, y: 0 } as any)).toBe('AI')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -396,7 +396,7 @@ describe('SceneSource, UnitDrawables, Missiles/Overlays & LightingInputs (F15, F
|
||||||
{
|
{
|
||||||
kind: 'missile',
|
kind: 'missile',
|
||||||
id: 200,
|
id: 200,
|
||||||
classId: 29, // FireBall (Trans: 1 additive)
|
classId: 62, // Missiles.txt row 62 = fireball (Trans: 1 additive)
|
||||||
x: 102,
|
x: 102,
|
||||||
y: 100,
|
y: 100,
|
||||||
targetX: 110,
|
targetX: 110,
|
||||||
|
|
@ -405,7 +405,7 @@ describe('SceneSource, UnitDrawables, Missiles/Overlays & LightingInputs (F15, F
|
||||||
{
|
{
|
||||||
kind: 'missile',
|
kind: 'missile',
|
||||||
id: 201,
|
id: 201,
|
||||||
classId: 1, // Arrow (Trans: 0 normal)
|
classId: 0, // Missiles.txt row 0 = arrow (Trans: 0 normal)
|
||||||
x: 101,
|
x: 101,
|
||||||
y: 101,
|
y: 101,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue