From 58de64610fd2c5a6d5c99c23737609510d1bed1c Mon Sep 17 00:00:00 2001 From: troytt <47798984@qq.com> Date: Thu, 1 Oct 2026 01:41:13 +0000 Subject: [PATCH] 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_ token), unknown player class ids throw. - derived-stats.ts: AR/defense/resists from CharStats ToHitFactor and DifficultyLevels ResistPenalty (pure helper + tests). --- src/client/ui-model/derived-stats.ts | 85 ++++++++++++++++++++++ src/client/view/unit-drawables.ts | 62 ++++++---------- src/common/skills/missiles-data.ts | 20 ++++- tests/client/derived-stats.test.ts | 29 ++++++++ tests/client/missile-id-map.test.ts | 57 +++++++++++++++ tests/client/settings-toolbar-view.test.ts | 4 +- 6 files changed, 212 insertions(+), 45 deletions(-) create mode 100644 src/client/ui-model/derived-stats.ts create mode 100644 tests/client/derived-stats.test.ts create mode 100644 tests/client/missile-id-map.test.ts diff --git a/src/client/ui-model/derived-stats.ts b/src/client/ui-model/derived-stats.ts new file mode 100644 index 0000000..c659977 --- /dev/null +++ b/src/client/ui-model/derived-stats.ts @@ -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), + } +} diff --git a/src/client/view/unit-drawables.ts b/src/client/view/unit-drawables.ts index dd6ba03..4c6b344 100644 --- a/src/client/view/unit-drawables.ts +++ b/src/client/view/unit-drawables.ts @@ -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 { PointLightInput } from '../scene/lighting-presets.ts' import { subTileToWorldOrtho } from './viewport-profile.ts' @@ -112,39 +112,17 @@ export type SceneEntityDrawable = | 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> = { - 1: 'arrow', - 2: 'javelin', - 6: 'magicarrow', - 9: 'firearrow', - 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 const MISSILE_ID_TO_KEY: Readonly> = Object.freeze( + Object.fromEntries( + Object.entries(CANONICAL_113C_MISSILES) + .filter(([, m]) => m.clientAlias !== true) + .map(([key, m]) => [m.id, key] as const), + ), +) export interface OverlayMeta { readonly overlayId: number @@ -186,11 +164,14 @@ export function resolveUnitToken(unit: RenderableUnitInput): string { return unit.token.trim() } 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') { - const key = MISSILE_ID_TO_KEY[unit.classId] ?? 'firebolt' - return getMissileTxtData(key).celFile + const key = MISSILE_ID_TO_KEY[unit.classId] + // 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') { return `OBJ_${unit.classId}` @@ -204,11 +185,12 @@ export function resolveUnitToken(unit: RenderableUnitInput): string { export function resolveMissileMetadata( classId: number, explicitToken?: string, -): { readonly key: string; readonly entry: MissileTxtEntry } { +): { readonly key: string; readonly entry: MissileTxtEntry } | null { const key = explicitToken && explicitToken.trim() !== '' ? explicitToken.trim().toLowerCase() - : (MISSILE_ID_TO_KEY[classId] ?? 'firebolt') + : MISSILE_ID_TO_KEY[classId] + if (!key) return null return { key, entry: getMissileTxtData(key), @@ -271,7 +253,9 @@ export function buildSceneDrawables( const { x: worldX, y: worldY } = subTileToWorldOrtho(unit.x, unit.y, originX, originY) 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 dir = computeDirection8Or16( unit.x, diff --git a/src/common/skills/missiles-data.ts b/src/common/skills/missiles-data.ts index b452d2f..0a2eb69 100644 --- a/src/common/skills/missiles-data.ts +++ b/src/common/skills/missiles-data.ts @@ -188,6 +188,11 @@ export const FACING_VECTORS: readonly (readonly [number, number])[] = [ export interface MissileTxtData { readonly name: string 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 maxVel: number readonly range: number @@ -261,6 +266,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = }, lightning: { name: 'lightning', + clientAlias: true, id: 98, vel: 30, maxVel: 30, @@ -326,6 +332,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = }, thunderstorm: { name: 'thunderstorm', + clientAlias: true, id: 166, vel: 2, maxVel: 2, @@ -370,6 +377,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = }, electricnova: { name: 'electricnova', + clientAlias: true, id: 90, vel: 24, maxVel: 24, @@ -584,6 +592,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = }, freezeexplode: { name: 'freezeexplode', + clientAlias: true, id: 88, vel: 0, maxVel: 0, @@ -626,6 +635,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = }, glacialspikeejecta: { name: 'glacialspikeejecta', + clientAlias: true, id: 89, vel: 0, maxVel: 0, @@ -920,6 +930,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = }, hydrafire: { name: 'hydrafire', + clientAlias: true, id: 247, vel: 16, maxVel: 16, @@ -941,7 +952,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = }, firewallmaker: { name: 'firewallmaker', - id: 82, + id: 68, vel: 0, maxVel: 0, range: 1, @@ -983,6 +994,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = }, groundfirebig: { name: 'groundfirebig', + clientAlias: true, id: 83, vel: 0, maxVel: 0, @@ -1004,7 +1016,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = }, blaze: { name: 'blaze', - id: 80, + id: 67, vel: 0, maxVel: 0, range: 90, @@ -1025,7 +1037,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = }, infernoflame1: { name: 'infernoflame1', - id: 72, + id: 60, vel: 12, maxVel: 12, range: 16, @@ -1046,7 +1058,7 @@ export const CANONICAL_113C_MISSILES: Readonly> = }, infernoflame2: { name: 'infernoflame2', - id: 73, + id: 61, vel: 12, maxVel: 12, range: 16, diff --git a/tests/client/derived-stats.test.ts b/tests/client/derived-stats.test.ts new file mode 100644 index 0000000..707f0a6 --- /dev/null +++ b/tests/client/derived-stats.test.ts @@ -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) { + 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) + }) +}) diff --git a/tests/client/missile-id-map.test.ts b/tests/client/missile-id-map.test.ts new file mode 100644 index 0000000..4e6f505 --- /dev/null +++ b/tests/client/missile-id-map.test.ts @@ -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 { + 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() + 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') + }) +}) diff --git a/tests/client/settings-toolbar-view.test.ts b/tests/client/settings-toolbar-view.test.ts index c4a3b91..68df896 100644 --- a/tests/client/settings-toolbar-view.test.ts +++ b/tests/client/settings-toolbar-view.test.ts @@ -396,7 +396,7 @@ describe('SceneSource, UnitDrawables, Missiles/Overlays & LightingInputs (F15, F { kind: 'missile', id: 200, - classId: 29, // FireBall (Trans: 1 additive) + classId: 62, // Missiles.txt row 62 = fireball (Trans: 1 additive) x: 102, y: 100, targetX: 110, @@ -405,7 +405,7 @@ describe('SceneSource, UnitDrawables, Missiles/Overlays & LightingInputs (F15, F { kind: 'missile', id: 201, - classId: 1, // Arrow (Trans: 0 normal) + classId: 0, // Missiles.txt row 0 = arrow (Trans: 0 normal) x: 101, y: 101, },