Fix skill anim modes, monster knockback, warp hover, and rate limiting (Closes #565, #566, #573, #575)

This commit is contained in:
troytt 2026-10-02 15:14:59 +00:00
parent 43d32e6d23
commit cc0756de2e
10 changed files with 3210 additions and 74 deletions

View File

@ -281,7 +281,7 @@ const MONSTER_MODE_TO_CLIP: Readonly<Record<number, string>> = {
10: 's3', 10: 's3',
11: 's4', 11: 's4',
12: 'dd', 12: 'dd',
13: 'rn', 13: 'gh',
14: 'xx', 14: 'xx',
15: 'rn', 15: 'rn',
} }

View File

@ -45,6 +45,7 @@ import {
type UnitType, type UnitType,
} from '../../netproto/index.ts' } from '../../netproto/index.ts'
import { CANONICAL_LEVEL_LIGHTING } from '../../common/world/levels-meta.ts' import { CANONICAL_LEVEL_LIGHTING } from '../../common/world/levels-meta.ts'
import { resolveLevelWarpName, resolveLvlWarpBounds } from '../../common/world/level-warps-meta.ts'
import { resolveGroundItemMetadata } from '../../common/items/ground-items.ts' import { resolveGroundItemMetadata } from '../../common/items/ground-items.ts'
import { requireTbl } from '../i18n/lang.ts' import { requireTbl } from '../i18n/lang.ts'
import { HudManager } from '../ui/hud-manager.ts' import { HudManager } from '../ui/hud-manager.ts'
@ -71,7 +72,7 @@ import type { RenderableUnitInput } from '../view/unit-drawables.ts'
import { getViewportProfile } from '../view/viewport-profile.ts' import { getViewportProfile } from '../view/viewport-profile.ts'
import { ClientWorld, type ChatLogEntry } from '../world/client-world.ts' import { ClientWorld, type ChatLogEntry } from '../world/client-world.ts'
import { getCanonicalItemDataTables } from '../world/item-tables-provider.ts' import { getCanonicalItemDataTables } from '../world/item-tables-provider.ts'
import { OutboundRateLimiter } from './rate-limiter.ts' import { OutboundRateLimiter, classifyClientCommand } from './rate-limiter.ts'
import { computeMonsterMoveSpeedSubTilesPerSec } from '../../common/units/monsters-data.ts' import { computeMonsterMoveSpeedSubTilesPerSec } from '../../common/units/monsters-data.ts'
import { isTownLevel } from '../../common/world/scene-population.ts' import { isTownLevel } from '../../common/world/scene-population.ts'
@ -1072,12 +1073,19 @@ export class OnlineSession {
y: Number(rawCmd.targetY ?? rawCmd.y ?? 0), y: Number(rawCmd.targetY ?? rawCmd.y ?? 0),
} }
: (cmd as ClientCommand) : (cmd as ClientCommand)
const res = this.rateLimiter.tryAcquire(normalized, nowMs) const category = classifyClientCommand(normalized)
if (res.allowed) { // Non-movement commands (interaction, skill, UI) bypass generic rate limiter per 1.13c parity (#575)
this.world.noteOutgoingCommand(normalized, nowMs) // to prevent player clicks and interactions from being silently swallowed.
if (category !== 'movement') {
this.forwardCommandToAdapter(normalized, nowMs) this.forwardCommandToAdapter(normalized, nowMs)
return true return true
} }
const res = this.rateLimiter.tryAcquire(normalized, nowMs)
if (res.allowed) {
this.forwardCommandToAdapter(normalized, nowMs)
return true
}
console.warn(`[OnlineSession] Outbound command throttled: ${normalized.type} (${res.reason ?? 'unknown'})`)
return false return false
} }
@ -1712,6 +1720,7 @@ export class OnlineSession {
throw new RangeError(`nowMs must be a finite number, got ${String(nowMs)}`) throw new RangeError(`nowMs must be a finite number, got ${String(nowMs)}`)
} }
const safeDt = Math.max(0, dtMs) const safeDt = Math.max(0, dtMs)
const clampedDt = Math.min(200, safeDt)
this.tickCounter++ this.tickCounter++
this.commandMapper.tick(nowMs) this.commandMapper.tick(nowMs)
@ -1729,7 +1738,7 @@ export class OnlineSession {
} }
} }
const predSnap = this.predictor.update(safeDt) const predSnap = this.predictor.update(clampedDt)
if (predSnap.x !== 0 || predSnap.y !== 0) { if (predSnap.x !== 0 || predSnap.y !== 0) {
this.camera.update(predSnap.x, predSnap.y) this.camera.update(predSnap.x, predSnap.y)
} }
@ -1748,13 +1757,13 @@ export class OnlineSession {
this.interpolator.setEntityTargetDirection(uTypeId, unit.id, unit.targetDirection, false) this.interpolator.setEntityTargetDirection(uTypeId, unit.id, unit.targetDirection, false)
} }
} else if (!unit.dead) { } else if (!unit.dead) {
unit.advanceTurn(safeDt) unit.advanceTurn(clampedDt)
} }
} }
this.world.tick(dtMs, nowMs) this.world.tick(clampedDt, nowMs)
this.interpolator.update(safeDt) this.interpolator.update(clampedDt)
for (const state of this.interpolator.getAllStates()) { for (const state of this.interpolator.getAllStates()) {
const unit = this.world.getUnit(state.unitType as UnitType, state.unitId) const unit = this.world.getUnit(state.unitType as UnitType, state.unitId)
if (unit) { if (unit) {
@ -1784,10 +1793,10 @@ export class OnlineSession {
} }
} else if (unit.unitType === 'monster' && unit.lifePct > 0) { } else if (unit.unitType === 'monster' && unit.lifePct > 0) {
if (state.moving && !unit.castState && unit.moveMode !== 'stop') { if (state.moving && !unit.castState && unit.moveMode !== 'stop') {
unit.mode = state.running ? 15 : unit.moveMode === 'knockback' ? 9 : 2 unit.mode = state.running ? 15 : unit.moveMode === 'knockback' ? 13 : 2
} else if ( } else if (
!state.moving && !state.moving &&
(unit.mode === 2 || unit.mode === 9 || unit.mode === 15) (unit.mode === 2 || unit.mode === 13 || unit.mode === 15)
) { ) {
unit.mode = 1 unit.mode = 1
unit.moveMode = 'stop' unit.moveMode = 'stop'
@ -1935,33 +1944,17 @@ export class OnlineSession {
if (u.unitType === 'tile') { if (u.unitType === 'tile') {
const warpId = u.warpId ?? u.classId const warpId = u.warpId ?? u.classId
let matchedWarp = activeLevelView?.warps.find((w) => w.lvlWarpId === warpId) const matchedWarp = activeLevelView?.warps.find((w) => w.lvlWarpId === warpId)
if (!matchedWarp && activeLevelView && activeLevelView.warps.length > 0) {
let bestDistSq = 30 * 30
for (const w of activeLevelView.warps) {
const wAny = w as Record<string, any>
const room =
typeof w.room === 'number' ? activeLevelView.rooms[w.room] : undefined
const subX = typeof wAny.subX === 'number' ? wAny.subX : (room?.subX ?? 0)
const subY = typeof wAny.subY === 'number' ? wAny.subY : (room?.subY ?? 0)
const wx = activeLevelView.subOriginX + subX
const wy = activeLevelView.subOriginY + subY
const dSq = (wx - ux) * (wx - ux) + (wy - uy) * (wy - uy)
if (dSq <= bestDistSq) {
bestDistSq = dSq
matchedWarp = w
}
}
}
const toLevelId = matchedWarp?.toLevelId const toLevelId = matchedWarp?.toLevelId
const toAreaName = const warpNames = toLevelId !== undefined ? resolveLevelWarpName(toLevelId) : undefined
toLevelId !== undefined ? CANONICAL_LEVEL_LIGHTING[toLevelId]?.levelName : undefined const bounds = resolveLvlWarpBounds(warpId)
const warpName = const warpName =
u.name && u.name.trim() !== '' u.name && u.name.trim() !== ''
? u.name ? u.name
: toAreaName : warpNames
? `To ${toAreaName}` ? warpNames.name
: `Entrance (${warpId})` : `Entrance (${warpId})`
const warpNameZh = warpNames?.nameZh
out.push({ out.push({
id: u.id, id: u.id,
kind: 'warp', kind: 'warp',
@ -1969,12 +1962,13 @@ export class OnlineSession {
warpId, warpId,
...(toLevelId !== undefined ? { toLevelId } : {}), ...(toLevelId !== undefined ? { toLevelId } : {}),
name: warpName, name: warpName,
...(warpNameZh !== undefined ? { nameZh: warpNameZh } : {}),
x: ux, x: ux,
y: uy, y: uy,
mode: u.mode, mode: u.mode,
direction: interp ? interp.dir64 : u.direction, direction: interp ? interp.dir64 : u.direction,
radiusX: 28, radiusX: bounds.radiusX,
radiusY: 20, radiusY: bounds.radiusY,
isHovered, isHovered,
}) })
continue continue
@ -2018,7 +2012,7 @@ export class OnlineSession {
} else if (castState) { } else if (castState) {
mode = castState.mode || u.mode || 1 mode = castState.mode || u.mode || 1
} else if (interp?.moving && u.moveMode !== 'stop') { } else if (interp?.moving && u.moveMode !== 'stop') {
mode = u.moveMode === 'knockback' ? 9 : interp.running ? 15 : 2 mode = u.moveMode === 'knockback' ? 13 : interp.running ? 15 : 2
} else { } else {
mode = u.mode || 1 mode = u.mode || 1
} }

View File

@ -33,14 +33,21 @@ export interface RateLimitDecision {
export type RateLimitAcquireResult = RateLimitDecision export type RateLimitAcquireResult = RateLimitDecision
export type RateLimiterConfig = OutboundRateLimiterConfig export type RateLimiterConfig = OutboundRateLimiterConfig
export const DEFAULT_RATE_LIMITS: Required<OutboundRateLimiterConfig> = { export const DEFAULT_RATE_LIMITS: Readonly<{
moveMinIntervalMs: number
skillMinIntervalMs: number
chatMinIntervalMs: number
interactionMinIntervalMs: number
burstCapacity: number
refillPerSec: number
}> = Object.freeze({
moveMinIntervalMs: 120, moveMinIntervalMs: 120,
skillMinIntervalMs: 100, skillMinIntervalMs: 0,
chatMinIntervalMs: 500, chatMinIntervalMs: 0,
interactionMinIntervalMs: 80, interactionMinIntervalMs: 0,
burstCapacity: 12, burstCapacity: 1000,
refillPerSec: 20, refillPerSec: 1000,
} })
export function classifyClientCommand(cmd: ClientCommand): CommandRateCategory { export function classifyClientCommand(cmd: ClientCommand): CommandRateCategory {
switch (cmd.type) { switch (cmd.type) {
@ -73,6 +80,9 @@ export function classifyClientCommand(cmd: ClientCommand): CommandRateCategory {
* Outbound C→S packet rate limiter protecting against D2GS flood disconnects. * Outbound C→S packet rate limiter protecting against D2GS flood disconnects.
* Implements per-category minimum intervals, duplicate movement suppression, * Implements per-category minimum intervals, duplicate movement suppression,
* coalesced trailing movement emission, and a global token bucket. * coalesced trailing movement emission, and a global token bucket.
*
* 1.13c Parity (#575): No artificial cooldown or rate-limiting is applied to non-movement
* commands (interaction, skill, UI clicks) by default, preventing silent click swallowing.
*/ */
export class OutboundRateLimiter { export class OutboundRateLimiter {
private readonly moveMinIntervalMs: number private readonly moveMinIntervalMs: number
@ -94,12 +104,12 @@ export class OutboundRateLimiter {
coalescedCount = 0 coalescedCount = 0
constructor(config?: OutboundRateLimiterConfig) { constructor(config?: OutboundRateLimiterConfig) {
this.moveMinIntervalMs = Math.max(0, config?.moveMinIntervalMs ?? 120) this.moveMinIntervalMs = Math.max(0, config?.moveMinIntervalMs ?? DEFAULT_RATE_LIMITS.moveMinIntervalMs)
this.skillMinIntervalMs = Math.max(0, config?.skillMinIntervalMs ?? 100) this.skillMinIntervalMs = Math.max(0, config?.skillMinIntervalMs ?? DEFAULT_RATE_LIMITS.skillMinIntervalMs)
this.chatMinIntervalMs = Math.max(0, config?.chatMinIntervalMs ?? 500) this.chatMinIntervalMs = Math.max(0, config?.chatMinIntervalMs ?? DEFAULT_RATE_LIMITS.chatMinIntervalMs)
this.interactionMinIntervalMs = Math.max(0, config?.interactionMinIntervalMs ?? 80) this.interactionMinIntervalMs = Math.max(0, config?.interactionMinIntervalMs ?? DEFAULT_RATE_LIMITS.interactionMinIntervalMs)
this.burstCapacity = Math.max(1, config?.burstCapacity ?? 12) this.burstCapacity = Math.max(1, config?.burstCapacity ?? DEFAULT_RATE_LIMITS.burstCapacity)
this.refillPerSec = Math.max(1, config?.refillPerSec ?? 20) this.refillPerSec = Math.max(1, config?.refillPerSec ?? DEFAULT_RATE_LIMITS.refillPerSec)
this.tokens = this.burstCapacity this.tokens = this.burstCapacity
} }

View File

@ -403,7 +403,7 @@ export class ClientUnit {
} else if (mode === 'run') { } else if (mode === 'run') {
this.mode = this.unitType === 'monster' ? 15 : 3 this.mode = this.unitType === 'monster' ? 15 : 3
} else if (mode === 'knockback') { } else if (mode === 'knockback') {
this.mode = this.unitType === 'monster' ? 9 : 19 this.mode = this.unitType === 'monster' ? 13 : 19
} else { } else {
this.mode = 2 this.mode = 2
} }

View File

@ -26,6 +26,7 @@ import {
getOverlayTxtData, getOverlayTxtData,
getSkillCastOverlay, getSkillCastOverlay,
} from '../../common/skills/missiles-data.ts' } from '../../common/skills/missiles-data.ts'
import { getSkillAnimModes } from '../../common/skills/skill-anim-modes.ts'
import { import {
CHAR_ANIM_CLIPS_113C, CHAR_ANIM_CLIPS_113C,
computeAnimFrameIndex, computeAnimFrameIndex,
@ -217,12 +218,6 @@ const CROSSBOW_ITEM_CODES = new Set([
'6lx', '6mx', '6hx', '6rx', '6lx', '6mx', '6hx', '6rx',
]) ])
/** 1.13c physical/weapon attack skill IDs that use attack animation mode (`A1`) instead of spellcast (`SC`). */
const PHYSICAL_ATTACK_SKILL_IDS = new Set([
0, 2, 6, 7, 10, 11, 12, 16, 19, 20, 21, 22, 24, 26, 27, 31, 96, 97, 106, 107, 116, 126, 133, 139,
140, 144, 147, 151, 152,
])
/** Single-bolt linear projectile skills (1.13c Skills.txt srvmissile / cltmissilea). */ /** Single-bolt linear projectile skills (1.13c Skills.txt srvmissile / cltmissilea). */
const LINEAR_SKILL_MISSILES: Readonly<Record<number, { key: string; pierce?: boolean }>> = const LINEAR_SKILL_MISSILES: Readonly<Record<number, { key: string; pierce?: boolean }>> =
Object.freeze({ Object.freeze({
@ -890,9 +885,9 @@ export class ClientWorld {
// Turn toward the cast/attack target via D2Common.#10425 (bInstant = 0); 25Hz tick steps // Turn toward the cast/attack target via D2Common.#10425 (bInstant = 0); 25Hz tick steps
// `unit.direction` toward `unit.targetDirection`. // `unit.direction` toward `unit.targetDirection`.
unit.faceToward(targetX, targetY, false) unit.faceToward(targetX, targetY, false)
const isAttack = PHYSICAL_ATTACK_SKILL_IDS.has(skillId) const animModes = getSkillAnimModes(skillId)
const mode = unit.unitType === 'monster' ? animModes.monsterMode : animModes.playerMode
const isSelf = unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0) const isSelf = unit.unitType === 'player' && (unit.id === this.selfId || this.selfId === 0)
const mode = unit.unitType === 'monster' ? (isAttack ? 4 : 7) : isAttack ? 7 : 10
unit.mode = mode unit.mode = mode
const castOverlayToken = getSkillCastOverlay(skillId) const castOverlayToken = getSkillCastOverlay(skillId)
const now = this.resolveEventNowMs(nowMs) const now = this.resolveEventNowMs(nowMs)
@ -1701,7 +1696,7 @@ export class ClientWorld {
unit.moveMode = 'stop' unit.moveMode = 'stop'
} else if ( } else if (
unit.unitType === 'monster' && unit.unitType === 'monster' &&
(unit.mode === 2 || unit.mode === 9 || unit.mode === 15) (unit.mode === 2 || unit.mode === 13 || unit.mode === 15)
) { ) {
unit.mode = 1 unit.mode = 1
unit.moveMode = 'stop' unit.moveMode = 'stop'
@ -2015,17 +2010,17 @@ export class ClientWorld {
this.selfId = unit.id this.selfId = unit.id
this._self.unitId = unit.id this._self.unitId = unit.id
} }
if (ev.unit.name) { if (unit.name) {
this._self.name = ev.unit.name this._self.name = unit.name
} }
if (ev.unit.classId >= 0 && ev.unit.classId <= 6) { if (unit.classId >= 0 && unit.classId <= 6) {
this._self.charClass = ev.unit.classId as CharClassId this._self.charClass = unit.classId as CharClassId
} }
if (ev.unit.x !== 0 || ev.unit.y !== 0) { if (unit.x !== 0 || unit.y !== 0) {
this._self.x = ev.unit.x this._self.x = unit.x
this._self.y = ev.unit.y this._self.y = unit.y
this._self.targetX = ev.unit.x this._self.targetX = unit.x
this._self.targetY = ev.unit.y this._self.targetY = unit.y
} }
} }
break break
@ -2124,7 +2119,7 @@ export class ClientWorld {
} else if (moveMode === 'run') { } else if (moveMode === 'run') {
unit.mode = 15 unit.mode = 15
} else if (moveMode === 'knockback') { } else if (moveMode === 'knockback') {
unit.mode = 9 unit.mode = 13
} else { } else {
unit.mode = 2 unit.mode = 2
} }

File diff suppressed because it is too large Load Diff

View File

@ -199,7 +199,15 @@ const PLAYER_MODE_NUM_TO_CLIP: Readonly<Record<number, CharAnimClipKey>> = {
8: 'a2', 8: 'a2',
9: 'a1', 9: 'a1',
10: 'sc', 10: 'sc',
11: 'a1', // TH (Throw)
12: 'a1', // KK (Kick)
13: 'a1', // S1 (Skill 1)
14: 'sc', // S2 (Skill 2)
15: 'a1', // S3 (Skill 3)
16: 'a1', // S4 (Skill 4)
17: 'dd', 17: 'dd',
18: 'a1', // SQ (Sequence)
19: 'gh', // KB (Knock back, token GH)
} }
/** /**

View File

@ -0,0 +1,694 @@
/**
* Canonical 1.13c Level Warp & Geometry Metadata (Levels.txt & LvlWarp.txt).
*
* Ground truth parsed from:
* - Levels.txt: column LevelWarp (key into string.tbl / expansionstring.tbl / patchstring.tbl)
* - LvlWarp.txt: columns SelectX, SelectY, SelectDX, SelectDY (hitbox bounds)
*/
import { CANONICAL_ENG_TBL_DICT, CANONICAL_CHI_TBL_DICT } from '../data/canonical-tbl-strings.ts'
export interface LvlWarpBounds {
readonly selectX: number
readonly selectY: number
readonly selectDX: number
readonly selectDY: number
readonly radiusX: number
readonly radiusY: number
}
export const CANONICAL_LEVEL_WARP_KEYS: Readonly<Record<number, string>> = Object.freeze({
"1": "To The Rogue Encampment",
"2": "To The Blood Moor",
"3": "To The Cold Plains",
"4": "To The Stony Field",
"5": "To The Dark Wood",
"6": "To The Black Marsh",
"7": "To The Tamoe Highland",
"8": "To The Den of Evil",
"9": "To The Cave Level 1",
"10": "To Underground Passage Level 1",
"11": "To The Hole Level 1",
"12": "To The Pit Level 1",
"13": "To The Cave Level 2",
"14": "To Underground Passage Level 2",
"15": "To The Hole Level 2",
"16": "To The Pit Level 2",
"17": "To The Burial Grounds",
"18": "To The Crypt",
"19": "To The Mausoleum",
"20": "To The Forgotten Tower",
"21": "To The Tower Cellar Level 1",
"22": "To The Tower Cellar Level 2",
"23": "To The Tower Cellar Level 3",
"24": "To The Tower Cellar Level 4",
"25": "To The Tower Cellar Level 5",
"26": "To The Monastery Gate",
"27": "To The Outer Cloister",
"28": "To The Barracks",
"29": "To The Jail Level 1",
"30": "To The Jail Level 2",
"31": "To The Jail Level 3",
"32": "To The Inner Cloister",
"33": "To The Cathedral",
"34": "To The Catacombs Level 1",
"35": "To The Catacombs Level 2",
"36": "To The Catacombs Level 3",
"37": "To The Catacombs Level 4",
"38": "To Tristram",
"39": "To The Moo Moo Farm",
"40": "To Lut Gholein",
"41": "To The Rocky Waste",
"42": "To The Dry Hills",
"43": "To The Far Oasis",
"44": "To The Lost City",
"45": "To The Valley of Snakes",
"46": "To The Canyon of the Magi",
"47": "To The Sewers Level 1",
"48": "To The Sewers Level 2",
"49": "To The Sewers Level 3",
"50": "To The Harem Level 1",
"51": "To The Harem Level 2",
"52": "To The Palace Cellar Level 1",
"53": "To The Palace Cellar Level 2",
"54": "To The Palace Cellar Level 3",
"55": "To The Stony Tomb Level 1",
"56": "To The Halls of the Dead Level 1",
"57": "To The Halls of the Dead Level 2",
"58": "To The Claw Viper Temple Level 1",
"59": "To The Stony Tomb Level 2",
"60": "To The Halls of the Dead Level 3",
"61": "To The Claw Viper Temple Level 2",
"62": "To The Maggot Lair Level 1",
"63": "To The Maggot Lair Level 2",
"64": "To The Maggot Lair Level 3",
"65": "To The Ancient Tunnels",
"66": "To Tal Rasha's Tomb",
"67": "To Tal Rasha's Tomb",
"68": "To Tal Rasha's Tomb",
"69": "To Tal Rasha's Tomb",
"70": "To Tal Rasha's Tomb",
"71": "To Tal Rasha's Tomb",
"72": "To Tal Rasha's Tomb",
"73": "To Duriel's Lair",
"74": "To The Arcane Sanctuary",
"75": "To The Kurast Docktown",
"76": "To The Spider Forest",
"77": "To The Great Marsh",
"78": "To The Flayer Jungle",
"79": "To Lower Kurast",
"80": "To The Kurast Bazaar",
"81": "To Upper Kurast",
"82": "To The Kurast Causeway",
"83": "To Travincal",
"84": "To The Spider Cave",
"85": "To The Spider Cavern",
"86": "To The Swampy Pit Level 1",
"87": "To The Swampy Pit Level 2",
"88": "To The Flayer Dungeon Level 1",
"89": "To The Flayer Dungeon Level 2",
"90": "To The Swampy Pit Level 3",
"91": "To The Flayer Dungeon Level 3",
"92": "To The Sewers Level 1",
"93": "To The Sewers Level 2",
"94": "To The Ruined Temple",
"95": "To The Disused Fane",
"96": "To The Forgotten Reliquary",
"97": "To The Forgotten Temple",
"98": "To The Ruined Fane",
"99": "To The Disused Reliquary",
"100": "To The Durance of Hate Level 1",
"101": "To The Durance of Hate Level 2",
"102": "To The Durance of Hate Level 3",
"103": "To The Pandemonium Fortress",
"104": "To The Outer Steppes",
"105": "To The Plains of Despair",
"106": "To The City of the Damned",
"107": "To The River of Flame",
"108": "To Chaos Sanctum",
"109": "To Harrogath",
"110": "To The Bloody Foothills",
"111": "To The Rigid Highlands",
"112": "To The Arreat Plateau",
"113": "To The Crystalized Cavern Level 1",
"114": "To The Cellar of Pity",
"115": "To The Crystalized Cavern Level 2",
"116": "To The Echo Chamber",
"117": "To The Tundra Wastelands",
"118": "To The Glacier Caves Level 1",
"119": "To The Glacier Caves Level 2",
"120": "To The Rocky Summit",
"121": "To Nihlathaks Temple",
"122": "To The Halls of Anguish",
"123": "To The Halls of Death's Calling",
"124": "To The Halls of Vaught",
"125": "To Hell1",
"126": "To Hell2",
"127": "To Hell3",
"128": "To The Worldstone Keep Level 1",
"129": "To The Worldstone Keep Level 2",
"130": "To The Worldstone Keep Level 3",
"131": "To The Throne of Destruction",
"132": "To The Worldstone Chamber",
"133": "To The Pandemonium Run 1",
"134": "To The Pandemonium Run 2",
"135": "To The Pandemonium Run 3",
"136": "To Tristram"
})
export const CANONICAL_LVLWARP_GEOMETRY: Readonly<Record<number, { readonly selectX: number; readonly selectY: number; readonly selectDX: number; readonly selectDY: number }>> = Object.freeze({
"0": {
"selectX": -90,
"selectY": -100,
"selectDX": 90,
"selectDY": 110
},
"1": {
"selectX": -15,
"selectY": -90,
"selectDX": 90,
"selectDY": 110
},
"2": {
"selectX": -60,
"selectY": -60,
"selectDX": 85,
"selectDY": 100
},
"3": {
"selectX": -25,
"selectY": -60,
"selectDX": 85,
"selectDY": 100
},
"4": {
"selectX": -30,
"selectY": -120,
"selectDX": 120,
"selectDY": 150
},
"5": {
"selectX": -25,
"selectY": -75,
"selectDX": 80,
"selectDY": 100
},
"6": {
"selectX": -90,
"selectY": -110,
"selectDX": 110,
"selectDY": 140
},
"7": {
"selectX": -70,
"selectY": -90,
"selectDX": 90,
"selectDY": 120
},
"8": {
"selectX": -10,
"selectY": -90,
"selectDX": 60,
"selectDY": 110
},
"9": {
"selectX": -30,
"selectY": -90,
"selectDX": 60,
"selectDY": 110
},
"10": {
"selectX": -10,
"selectY": -50,
"selectDX": 150,
"selectDY": 80
},
"11": {
"selectX": -50,
"selectY": -130,
"selectDX": 60,
"selectDY": 140
},
"12": {
"selectX": -50,
"selectY": -90,
"selectDX": 80,
"selectDY": 110
},
"13": {
"selectX": -50,
"selectY": -110,
"selectDX": 110,
"selectDY": 150
},
"14": {
"selectX": -40,
"selectY": -80,
"selectDX": 210,
"selectDY": 110
},
"15": {
"selectX": -30,
"selectY": -110,
"selectDX": 85,
"selectDY": 130
},
"16": {
"selectX": -35,
"selectY": -130,
"selectDX": 90,
"selectDY": 150
},
"17": {
"selectX": -50,
"selectY": -110,
"selectDX": 110,
"selectDY": 150
},
"18": {
"selectX": -60,
"selectY": -80,
"selectDX": 210,
"selectDY": 110
},
"19": {
"selectX": -10,
"selectY": -10,
"selectDX": 0,
"selectDY": 0
},
"20": {
"selectX": -25,
"selectY": -95,
"selectDX": 70,
"selectDY": 130
},
"21": {
"selectX": 20,
"selectY": -80,
"selectDX": 65,
"selectDY": 140
},
"22": {
"selectX": -25,
"selectY": -160,
"selectDX": 100,
"selectDY": 180
},
"23": {
"selectX": -65,
"selectY": -55,
"selectDX": 150,
"selectDY": 110
},
"24": {
"selectX": -120,
"selectY": -20,
"selectDX": 150,
"selectDY": 130
},
"25": {
"selectX": -35,
"selectY": -70,
"selectDX": 95,
"selectDY": 130
},
"26": {
"selectX": -25,
"selectY": -150,
"selectDX": 80,
"selectDY": 180
},
"27": {
"selectX": -40,
"selectY": -160,
"selectDX": 80,
"selectDY": 180
},
"28": {
"selectX": -20,
"selectY": -40,
"selectDX": 170,
"selectDY": 100
},
"29": {
"selectX": -50,
"selectY": -20,
"selectDX": 160,
"selectDY": 85
},
"30": {
"selectX": -25,
"selectY": -100,
"selectDX": 60,
"selectDY": 125
},
"31": {
"selectX": -40,
"selectY": -100,
"selectDX": 60,
"selectDY": 125
},
"32": {
"selectX": -30,
"selectY": -30,
"selectDX": 170,
"selectDY": 90
},
"33": {
"selectX": -70,
"selectY": -140,
"selectDX": 80,
"selectDY": 160
},
"34": {
"selectX": -50,
"selectY": -120,
"selectDX": 90,
"selectDY": 150
},
"35": {
"selectX": -30,
"selectY": -140,
"selectDX": 80,
"selectDY": 160
},
"36": {
"selectX": 0,
"selectY": -140,
"selectDX": 100,
"selectDY": 150
},
"37": {
"selectX": -30,
"selectY": -140,
"selectDX": 80,
"selectDY": 160
},
"38": {
"selectX": -40,
"selectY": -190,
"selectDX": 90,
"selectDY": 210
},
"39": {
"selectX": -40,
"selectY": -210,
"selectDX": 50,
"selectDY": 230
},
"40": {
"selectX": -30,
"selectY": -100,
"selectDX": 60,
"selectDY": 140
},
"41": {
"selectX": -90,
"selectY": -140,
"selectDX": 70,
"selectDY": 160
},
"42": {
"selectX": -10,
"selectY": -110,
"selectDX": 60,
"selectDY": 150
},
"43": {
"selectX": 0,
"selectY": -130,
"selectDX": 90,
"selectDY": 150
},
"44": {
"selectX": -10,
"selectY": -210,
"selectDX": 70,
"selectDY": 240
},
"45": {
"selectX": -30,
"selectY": -115,
"selectDX": 75,
"selectDY": 145
},
"46": {
"selectX": -45,
"selectY": -120,
"selectDX": 85,
"selectDY": 145
},
"47": {
"selectX": -10,
"selectY": -60,
"selectDX": 90,
"selectDY": 90
},
"48": {
"selectX": -20,
"selectY": -70,
"selectDX": 80,
"selectDY": 85
},
"49": {
"selectX": -115,
"selectY": -10,
"selectDX": 160,
"selectDY": 70
},
"50": {
"selectX": -10,
"selectY": -10,
"selectDX": 0,
"selectDY": 0
},
"51": {
"selectX": -60,
"selectY": -70,
"selectDX": 70,
"selectDY": 90
},
"52": {
"selectX": 10,
"selectY": -100,
"selectDX": 80,
"selectDY": 110
},
"53": {
"selectX": -15,
"selectY": -70,
"selectDX": 70,
"selectDY": 110
},
"54": {
"selectX": -130,
"selectY": -10,
"selectDX": 170,
"selectDY": 100
},
"55": {
"selectX": -10,
"selectY": -100,
"selectDX": 90,
"selectDY": 130
},
"56": {
"selectX": -40,
"selectY": -20,
"selectDX": 170,
"selectDY": 100
},
"57": {
"selectX": -60,
"selectY": -40,
"selectDX": 120,
"selectDY": 90
},
"58": {
"selectX": -75,
"selectY": -110,
"selectDX": 100,
"selectDY": 150
},
"59": {
"selectX": -25,
"selectY": -110,
"selectDX": 100,
"selectDY": 150
},
"60": {
"selectX": -10,
"selectY": -10,
"selectDX": 0,
"selectDY": 0
},
"61": {
"selectX": -10,
"selectY": -10,
"selectDX": 0,
"selectDY": 0
},
"62": {
"selectX": -50,
"selectY": -110,
"selectDX": 80,
"selectDY": 140
},
"63": {
"selectX": -30,
"selectY": -110,
"selectDX": 80,
"selectDY": 140
},
"64": {
"selectX": -10,
"selectY": -10,
"selectDX": 0,
"selectDY": 0
},
"65": {
"selectX": -65,
"selectY": -150,
"selectDX": 125,
"selectDY": 180
},
"66": {
"selectX": -60,
"selectY": -150,
"selectDX": 125,
"selectDY": 180
},
"67": {
"selectX": -65,
"selectY": -150,
"selectDX": 125,
"selectDY": 180
},
"68": {
"selectX": -60,
"selectY": -150,
"selectDX": 125,
"selectDY": 180
},
"69": {
"selectX": -60,
"selectY": -40,
"selectDX": 150,
"selectDY": 160
},
"70": {
"selectX": -90,
"selectY": -50,
"selectDX": 160,
"selectDY": 120
},
"71": {
"selectX": -15,
"selectY": -90,
"selectDX": 70,
"selectDY": 100
},
"72": {
"selectX": -20,
"selectY": -120,
"selectDX": 75,
"selectDY": 100
},
"73": {
"selectX": -10,
"selectY": -120,
"selectDX": 100,
"selectDY": 120
},
"74": {
"selectX": -20,
"selectY": -110,
"selectDX": 90,
"selectDY": 115
},
"75": {
"selectX": -5,
"selectY": -110,
"selectDX": 80,
"selectDY": 110
},
"76": {
"selectX": 0,
"selectY": -110,
"selectDX": 95,
"selectDY": 100
},
"77": {
"selectX": 0,
"selectY": -70,
"selectDX": 140,
"selectDY": 80
},
"78": {
"selectX": -20,
"selectY": -110,
"selectDX": 100,
"selectDY": 110
},
"79": {
"selectX": -40,
"selectY": -110,
"selectDX": 0,
"selectDY": 0
},
"80": {
"selectX": -20,
"selectY": -110,
"selectDX": 0,
"selectDY": 0
},
"81": {
"selectX": -20,
"selectY": -110,
"selectDX": 80,
"selectDY": 115
},
"82": {
"selectX": -20,
"selectY": -110,
"selectDX": 80,
"selectDY": 115
}
})
/**
* Resolves the localized display name for a destination level's warp.
*/
export function resolveLevelWarpName(toLevelId: number): { readonly name: string; readonly nameZh: string } | undefined {
const warpKey = CANONICAL_LEVEL_WARP_KEYS[toLevelId]
if (!warpKey) return undefined
const name = CANONICAL_ENG_TBL_DICT[warpKey] ?? warpKey
const nameZh = CANONICAL_CHI_TBL_DICT[warpKey] ?? name
return { name, nameZh }
}
/**
* Resolves the 1.13c LvlWarp.txt hover/selection bounding box and pixel radii for a warp ID.
*/
export function resolveLvlWarpBounds(warpId: number): LvlWarpBounds {
const geom = CANONICAL_LVLWARP_GEOMETRY[warpId]
if (geom && geom.selectDX > 0 && geom.selectDY > 0) {
return {
selectX: geom.selectX,
selectY: geom.selectY,
selectDX: geom.selectDX,
selectDY: geom.selectDY,
radiusX: Math.round(geom.selectDX / 2),
radiusY: Math.round(geom.selectDY / 2),
}
}
return {
selectX: -28,
selectY: -20,
selectDX: 56,
selectDY: 40,
radiusX: 28,
radiusY: 20,
}
}

View File

@ -145,15 +145,15 @@ export const MONSTER_CMD_TO_MODE: readonly number[] = [
12, // 0x09: DD (Dead corpse) 12, // 0x09: DD (Dead corpse)
4, // 0x0A: A1 (Attack 1 with target) 4, // 0x0A: A1 (Attack 1 with target)
4, // 0x0B: A1 (Attack 1 no target) 4, // 0x0B: A1 (Attack 1 no target)
8, // 0x0C: SQ (Sequence no target) 8, // 0x0C: S1 (Skill 1 no target)
8, // 0x0D: SQ (Sequence with target) 8, // 0x0D: S1 (Skill 1 with target)
9, // 0x0E: KB (Knockback no target) 9, // 0x0E: S2 (Skill 2 no target)
9, // 0x0F: KB (Knockback with target) 9, // 0x0F: S2 (Skill 2 with target)
5, // 0x10: A2 (Attack 2 with target) 5, // 0x10: A2 (Attack 2 with target)
5, // 0x11: A2 (Attack 2 no target) 5, // 0x11: A2 (Attack 2 no target)
6, // 0x12: BL (Block) 6, // 0x12: BL (Block)
16, // 0x13: special 16, // 0x13: special
13, // 0x14: S2 (Skill 2) 13, // 0x14: KB (Knockback)
14, // 0x15: S3 (Skill 3) 14, // 0x15: S3 (Skill 3)
14, // 0x16: S3 (Skill 3) 14, // 0x16: S3 (Skill 3)
15, // 0x17: RN (0x67 run) 15, // 0x17: RN (0x67 run)

View File

@ -0,0 +1,261 @@
/**
* Parity Test Suite for Batch 1 Phase 3 Issues: #565, #566, #573, #575.
*
* Ground truth parity verified against:
* - #565: Skills.txt anim / monanim mode mapping (PlrMode / MonMode)
* - #566: Monster knockback MonMode 13 (KB) vs 9 (S2), arrival reset, and clip mapping
* - #573: Warp hover parity (Levels.txt LevelWarp -> tbl string, LvlWarp.txt SelectDX/DY bounds)
* - #575: 1.13c non-movement rate limiter bypass and 200ms tick delta clamping
*/
import { describe, expect, it, vi } from 'vitest'
import { getSkillAnimModes } from '../../src/common/skills/skill-anim-modes.ts'
import { resolveLevelWarpName, resolveLvlWarpBounds } from '../../src/common/world/level-warps-meta.ts'
import { OutboundRateLimiter, DEFAULT_RATE_LIMITS } from '../../src/client/session/rate-limiter.ts'
import { ClientUnit } from '../../src/client/world/client-unit.ts'
import { ClientWorld } from '../../src/client/world/client-world.ts'
import { OnlineSession } from '../../src/client/session/online-session.ts'
import type { ClientCommand } from '../../src/netproto/domain/client-command.ts'
describe('Batch 1 Phase 3 Parity (#565, #566, #573, #575)', () => {
// ─── #565: Skills.txt anim / monanim mode mapping ─────────────────────────
describe('#565: Skills.txt anim / monanim mode mapping', () => {
it('maps skill animations to 1.13c PlrMode and MonMode values without hardcoded physical sets', () => {
// Throw (2) -> anim: TH (PlrMode 11)
const throwModes = getSkillAnimModes(2)
expect(throwModes.playerMode).toBe(11)
// Kick (1) -> anim: KK (PlrMode 12)
const kickModes = getSkillAnimModes(1)
expect(kickModes.playerMode).toBe(12)
// Smite (97) -> anim: S1 (PlrMode 13)
const smiteModes = getSkillAnimModes(97)
expect(smiteModes.playerMode).toBe(13)
// Charge (107) -> anim: SQ (PlrMode 18)
const chargeModes = getSkillAnimModes(107)
expect(chargeModes.playerMode).toBe(18)
// Teleport (54) -> anim: SC (PlrMode 10)
const tpModes = getSkillAnimModes(54)
expect(tpModes.playerMode).toBe(10)
// Standard Attack (0) -> anim: A1 (PlrMode 7)
const attackModes = getSkillAnimModes(0)
expect(attackModes.playerMode).toBe(7)
})
it('sets player unit mode according to Skills.txt anim in ClientWorld SkillCast', () => {
const world = new ClientWorld()
world.apply({
type: 'UnitAssign',
unit: { unitType: 0, id: 1, classId: 0, name: 'Amazon', x: 100, y: 100 },
isSelf: true,
})
world.selfId = 1
const player = world.getUnit('player', 1)!
expect(player).toBeDefined()
// Cast Throw (skillId 2) -> should set mode 11 (TH), not attack (7) or cast (10)
world.apply({
type: 'SkillCast',
caster: { unitType: 0, id: 1 },
skillId: 2,
target: { x: 105, y: 100 },
})
expect(player.mode).toBe(11)
// Cast Smite (skillId 97) -> should set mode 13 (S1)
world.apply({
type: 'SkillCast',
caster: { unitType: 0, id: 1 },
skillId: 97,
target: { x: 105, y: 100 },
})
expect(player.mode).toBe(13)
})
})
// ─── #566: Monster knockback mode 13 vs 9 ─────────────────────────────────
describe('#566: Monster knockback MonMode 13 (KB) parity', () => {
it('sets monster mode to 13 (KB) on knockback in ClientUnit.applyMove', () => {
const monster = new ClientUnit({
id: 10,
unitType: 'monster',
classId: 1,
x: 50,
y: 50,
name: 'Zombie',
})
monster.applyMove('knockback', { x: 55, y: 50 }, { x: 50, y: 50 })
expect(monster.mode).toBe(13)
const player = new ClientUnit({
id: 1,
unitType: 'player',
classId: 0,
x: 10,
y: 10,
name: 'Barbarian',
})
player.applyMove('knockback', { x: 15, y: 10 }, { x: 10, y: 10 })
expect(player.mode).toBe(19) // PlrMode KB is 19
})
it('resets monster mode 13 (KB) to 1 (NU) upon destination arrival, but preserves mode 9 (S2)', () => {
const world = new ClientWorld()
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 101, classId: 1, x: 49.95, y: 50 },
})
const monsterKb = world.getUnit('monster', 101)!
monsterKb.mode = 13
monsterKb.moveMode = 'knockback'
monsterKb.targetX = 50
monsterKb.targetY = 50
// Monster in mode 9 (legitimate skill 2 cast)
world.apply({
type: 'UnitAssign',
unit: { unitType: 1, id: 102, classId: 1, x: 60, y: 60 },
})
const monsterS2 = world.getUnit('monster', 102)!
monsterS2.mode = 9
monsterS2.moveMode = 'stop'
monsterS2.targetX = 60
monsterS2.targetY = 60
// Step tick (simulating arrival at destination)
world.tick(40, 1000)
// Knockback monster arrived: mode 13 resets to 1 (NU)
expect(monsterKb.mode).toBe(1)
expect(monsterKb.moveMode).toBe('stop')
// S2 monster: mode 9 MUST NOT be reset to 1!
expect(monsterS2.mode).toBe(9)
})
})
// ─── #573: Warp hover parity ──────────────────────────────────────────────
describe('#573: Warp hover localized naming & geometry hitbox', () => {
it('resolves localized level warp names from Levels.txt LevelWarp and .tbl', () => {
const denNames = resolveLevelWarpName(8)
expect(denNames).toBeDefined()
expect(denNames?.name).toBe('To The Den of Evil')
expect(denNames?.nameZh).toBeTruthy()
const bloodMoorNames = resolveLevelWarpName(2)
expect(bloodMoorNames).toBeDefined()
expect(bloodMoorNames?.name).toBe('To The Blood Moor')
})
it('resolves accurate selection bounds from LvlWarp.txt SelectDX/SelectDY', () => {
// Warp 0: SelectDX=90, SelectDY=110 -> radiusX=45, radiusY=55
const b0 = resolveLvlWarpBounds(0)
expect(b0.selectDX).toBe(90)
expect(b0.selectDY).toBe(110)
expect(b0.radiusX).toBe(45)
expect(b0.radiusY).toBe(55)
// Warp 10: SelectDX=150, SelectDY=80 -> radiusX=75, radiusY=40
const b10 = resolveLvlWarpBounds(10)
expect(b10.selectDX).toBe(150)
expect(b10.selectDY).toBe(80)
expect(b10.radiusX).toBe(75)
expect(b10.radiusY).toBe(40)
})
it('populates accurate warp name and bounds in OnlineSession.buildRenderableUnits', () => {
const session = new OnlineSession()
session.levelView = {
act: 0,
areaId: 2,
subOriginX: 5000,
subOriginY: 5000,
subWidth: 100,
subHeight: 100,
isoOriginX: 0,
isoOriginY: 0,
cellsX: 20,
cellsY: 20,
gridWidth: 100,
blocked: new Uint8Array(100),
walkable: new Uint8Array(100).fill(1),
collisionMasks: new Uint16Array(100),
rooms: [],
floors: [],
shadows: [],
walls: [],
roofs: [],
warps: [{ lvlWarpId: 4, toLevelId: 8 }],
presets: [],
} as any
session.handleServerEvent({
type: 'UnitAssign',
unit: { unitType: 5, id: 301, classId: 4, warpId: 4, x: 5020, y: 5020 } as any,
})
const renderables = session.buildRenderableUnits()
const warp = renderables.find((u) => u.id === 301)
expect(warp).toBeDefined()
expect(warp?.name).toBe('To The Den of Evil')
expect(warp?.nameZh).toBeTruthy()
expect(warp?.radiusX).toBeGreaterThan(0)
expect(warp?.radiusY).toBeGreaterThan(0)
session.destroy()
})
})
// ─── #575: Non-movement rate limiter bypass & tick clamp ────────────────────
describe('#575: Rate limiter non-movement bypass & tick clamp', () => {
it('does not throttle consecutive non-movement commands with DEFAULT_RATE_LIMITS', () => {
const limiter = new OutboundRateLimiter()
expect(DEFAULT_RATE_LIMITS.skillMinIntervalMs).toBe(0)
expect(DEFAULT_RATE_LIMITS.interactionMinIntervalMs).toBe(0)
const interactCmd: ClientCommand = {
type: 'Interact',
target: { unitType: 2, id: 50 },
}
// Consecutive rapid interaction clicks within 10ms
const first = limiter.tryAcquire(interactCmd, 1000)
const second = limiter.tryAcquire(interactCmd, 1010)
const third = limiter.tryAcquire(interactCmd, 1020)
expect(first.allowed).toBe(true)
expect(second.allowed).toBe(true)
expect(third.allowed).toBe(true)
})
it('always allows non-movement commands in OnlineSession.dispatchCommand without swallowing', () => {
const session = new OnlineSession()
const noteSpy = vi.spyOn(session.world, 'noteOutgoingCommand')
const cmd1: ClientCommand = { type: 'Interact', target: { unitType: 2, id: 10 } }
const cmd2: ClientCommand = { type: 'Interact', target: { unitType: 2, id: 10 } }
expect(session.dispatchCommand(cmd1, 1000)).toBe(true)
expect(session.dispatchCommand(cmd2, 1005)).toBe(true)
expect(noteSpy).toHaveBeenCalledTimes(2)
session.destroy()
})
it('clamps delta-time accumulator to 200ms in OnlineSession.tick to prevent death spiral', () => {
const session = new OnlineSession()
const worldTickSpy = vi.spyOn(session.world, 'tick')
// Tick with huge dtMs = 5000 (e.g. background tab or lag spike)
session.tick(5000, 6000)
// World tick should have received clampedDt = 200
expect(worldTickSpy).toHaveBeenCalledWith(200, 6000)
session.destroy()
})
})
})