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:
troytt 2026-10-01 01:41:13 +00:00
parent f61ff41527
commit 58de64610f
6 changed files with 212 additions and 45 deletions

View File

@ -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),
}
}

View File

@ -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,

View File

@ -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,

View File

@ -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)
})
})

View File

@ -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')
})
})

View File

@ -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,
}, },