Fix skill anim modes, monster knockback, warp hover, and rate limiting (Closes #565, #566, #573, #575)
This commit is contained in:
parent
43d32e6d23
commit
cc0756de2e
|
|
@ -281,7 +281,7 @@ const MONSTER_MODE_TO_CLIP: Readonly<Record<number, string>> = {
|
|||
10: 's3',
|
||||
11: 's4',
|
||||
12: 'dd',
|
||||
13: 'rn',
|
||||
13: 'gh',
|
||||
14: 'xx',
|
||||
15: 'rn',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import {
|
|||
type UnitType,
|
||||
} from '../../netproto/index.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 { requireTbl } from '../i18n/lang.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 { ClientWorld, type ChatLogEntry } from '../world/client-world.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 { isTownLevel } from '../../common/world/scene-population.ts'
|
||||
|
||||
|
|
@ -1072,12 +1073,19 @@ export class OnlineSession {
|
|||
y: Number(rawCmd.targetY ?? rawCmd.y ?? 0),
|
||||
}
|
||||
: (cmd as ClientCommand)
|
||||
const res = this.rateLimiter.tryAcquire(normalized, nowMs)
|
||||
if (res.allowed) {
|
||||
this.world.noteOutgoingCommand(normalized, nowMs)
|
||||
const category = classifyClientCommand(normalized)
|
||||
// Non-movement commands (interaction, skill, UI) bypass generic rate limiter per 1.13c parity (#575)
|
||||
// to prevent player clicks and interactions from being silently swallowed.
|
||||
if (category !== 'movement') {
|
||||
this.forwardCommandToAdapter(normalized, nowMs)
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -1712,6 +1720,7 @@ export class OnlineSession {
|
|||
throw new RangeError(`nowMs must be a finite number, got ${String(nowMs)}`)
|
||||
}
|
||||
const safeDt = Math.max(0, dtMs)
|
||||
const clampedDt = Math.min(200, safeDt)
|
||||
this.tickCounter++
|
||||
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) {
|
||||
this.camera.update(predSnap.x, predSnap.y)
|
||||
}
|
||||
|
|
@ -1748,13 +1757,13 @@ export class OnlineSession {
|
|||
this.interpolator.setEntityTargetDirection(uTypeId, unit.id, unit.targetDirection, false)
|
||||
}
|
||||
} 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()) {
|
||||
const unit = this.world.getUnit(state.unitType as UnitType, state.unitId)
|
||||
if (unit) {
|
||||
|
|
@ -1784,10 +1793,10 @@ export class OnlineSession {
|
|||
}
|
||||
} else if (unit.unitType === 'monster' && unit.lifePct > 0) {
|
||||
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 (
|
||||
!state.moving &&
|
||||
(unit.mode === 2 || unit.mode === 9 || unit.mode === 15)
|
||||
(unit.mode === 2 || unit.mode === 13 || unit.mode === 15)
|
||||
) {
|
||||
unit.mode = 1
|
||||
unit.moveMode = 'stop'
|
||||
|
|
@ -1935,33 +1944,17 @@ export class OnlineSession {
|
|||
|
||||
if (u.unitType === 'tile') {
|
||||
const warpId = u.warpId ?? u.classId
|
||||
let 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 matchedWarp = activeLevelView?.warps.find((w) => w.lvlWarpId === warpId)
|
||||
const toLevelId = matchedWarp?.toLevelId
|
||||
const toAreaName =
|
||||
toLevelId !== undefined ? CANONICAL_LEVEL_LIGHTING[toLevelId]?.levelName : undefined
|
||||
const warpNames = toLevelId !== undefined ? resolveLevelWarpName(toLevelId) : undefined
|
||||
const bounds = resolveLvlWarpBounds(warpId)
|
||||
const warpName =
|
||||
u.name && u.name.trim() !== ''
|
||||
? u.name
|
||||
: toAreaName
|
||||
? `To ${toAreaName}`
|
||||
: warpNames
|
||||
? warpNames.name
|
||||
: `Entrance (${warpId})`
|
||||
const warpNameZh = warpNames?.nameZh
|
||||
out.push({
|
||||
id: u.id,
|
||||
kind: 'warp',
|
||||
|
|
@ -1969,12 +1962,13 @@ export class OnlineSession {
|
|||
warpId,
|
||||
...(toLevelId !== undefined ? { toLevelId } : {}),
|
||||
name: warpName,
|
||||
...(warpNameZh !== undefined ? { nameZh: warpNameZh } : {}),
|
||||
x: ux,
|
||||
y: uy,
|
||||
mode: u.mode,
|
||||
direction: interp ? interp.dir64 : u.direction,
|
||||
radiusX: 28,
|
||||
radiusY: 20,
|
||||
radiusX: bounds.radiusX,
|
||||
radiusY: bounds.radiusY,
|
||||
isHovered,
|
||||
})
|
||||
continue
|
||||
|
|
@ -2018,7 +2012,7 @@ export class OnlineSession {
|
|||
} else if (castState) {
|
||||
mode = castState.mode || u.mode || 1
|
||||
} 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 {
|
||||
mode = u.mode || 1
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,14 +33,21 @@ export interface RateLimitDecision {
|
|||
export type RateLimitAcquireResult = RateLimitDecision
|
||||
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,
|
||||
skillMinIntervalMs: 100,
|
||||
chatMinIntervalMs: 500,
|
||||
interactionMinIntervalMs: 80,
|
||||
burstCapacity: 12,
|
||||
refillPerSec: 20,
|
||||
}
|
||||
skillMinIntervalMs: 0,
|
||||
chatMinIntervalMs: 0,
|
||||
interactionMinIntervalMs: 0,
|
||||
burstCapacity: 1000,
|
||||
refillPerSec: 1000,
|
||||
})
|
||||
|
||||
export function classifyClientCommand(cmd: ClientCommand): CommandRateCategory {
|
||||
switch (cmd.type) {
|
||||
|
|
@ -73,6 +80,9 @@ export function classifyClientCommand(cmd: ClientCommand): CommandRateCategory {
|
|||
* Outbound C→S packet rate limiter protecting against D2GS flood disconnects.
|
||||
* Implements per-category minimum intervals, duplicate movement suppression,
|
||||
* 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 {
|
||||
private readonly moveMinIntervalMs: number
|
||||
|
|
@ -94,12 +104,12 @@ export class OutboundRateLimiter {
|
|||
coalescedCount = 0
|
||||
|
||||
constructor(config?: OutboundRateLimiterConfig) {
|
||||
this.moveMinIntervalMs = Math.max(0, config?.moveMinIntervalMs ?? 120)
|
||||
this.skillMinIntervalMs = Math.max(0, config?.skillMinIntervalMs ?? 100)
|
||||
this.chatMinIntervalMs = Math.max(0, config?.chatMinIntervalMs ?? 500)
|
||||
this.interactionMinIntervalMs = Math.max(0, config?.interactionMinIntervalMs ?? 80)
|
||||
this.burstCapacity = Math.max(1, config?.burstCapacity ?? 12)
|
||||
this.refillPerSec = Math.max(1, config?.refillPerSec ?? 20)
|
||||
this.moveMinIntervalMs = Math.max(0, config?.moveMinIntervalMs ?? DEFAULT_RATE_LIMITS.moveMinIntervalMs)
|
||||
this.skillMinIntervalMs = Math.max(0, config?.skillMinIntervalMs ?? DEFAULT_RATE_LIMITS.skillMinIntervalMs)
|
||||
this.chatMinIntervalMs = Math.max(0, config?.chatMinIntervalMs ?? DEFAULT_RATE_LIMITS.chatMinIntervalMs)
|
||||
this.interactionMinIntervalMs = Math.max(0, config?.interactionMinIntervalMs ?? DEFAULT_RATE_LIMITS.interactionMinIntervalMs)
|
||||
this.burstCapacity = Math.max(1, config?.burstCapacity ?? DEFAULT_RATE_LIMITS.burstCapacity)
|
||||
this.refillPerSec = Math.max(1, config?.refillPerSec ?? DEFAULT_RATE_LIMITS.refillPerSec)
|
||||
this.tokens = this.burstCapacity
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -403,7 +403,7 @@ export class ClientUnit {
|
|||
} else if (mode === 'run') {
|
||||
this.mode = this.unitType === 'monster' ? 15 : 3
|
||||
} else if (mode === 'knockback') {
|
||||
this.mode = this.unitType === 'monster' ? 9 : 19
|
||||
this.mode = this.unitType === 'monster' ? 13 : 19
|
||||
} else {
|
||||
this.mode = 2
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
getOverlayTxtData,
|
||||
getSkillCastOverlay,
|
||||
} from '../../common/skills/missiles-data.ts'
|
||||
import { getSkillAnimModes } from '../../common/skills/skill-anim-modes.ts'
|
||||
import {
|
||||
CHAR_ANIM_CLIPS_113C,
|
||||
computeAnimFrameIndex,
|
||||
|
|
@ -217,12 +218,6 @@ const CROSSBOW_ITEM_CODES = new Set([
|
|||
'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). */
|
||||
const LINEAR_SKILL_MISSILES: Readonly<Record<number, { key: string; pierce?: boolean }>> =
|
||||
Object.freeze({
|
||||
|
|
@ -890,9 +885,9 @@ export class ClientWorld {
|
|||
// Turn toward the cast/attack target via D2Common.#10425 (bInstant = 0); 25Hz tick steps
|
||||
// `unit.direction` toward `unit.targetDirection`.
|
||||
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 mode = unit.unitType === 'monster' ? (isAttack ? 4 : 7) : isAttack ? 7 : 10
|
||||
unit.mode = mode
|
||||
const castOverlayToken = getSkillCastOverlay(skillId)
|
||||
const now = this.resolveEventNowMs(nowMs)
|
||||
|
|
@ -1701,7 +1696,7 @@ export class ClientWorld {
|
|||
unit.moveMode = 'stop'
|
||||
} else if (
|
||||
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.moveMode = 'stop'
|
||||
|
|
@ -2015,17 +2010,17 @@ export class ClientWorld {
|
|||
this.selfId = unit.id
|
||||
this._self.unitId = unit.id
|
||||
}
|
||||
if (ev.unit.name) {
|
||||
this._self.name = ev.unit.name
|
||||
if (unit.name) {
|
||||
this._self.name = unit.name
|
||||
}
|
||||
if (ev.unit.classId >= 0 && ev.unit.classId <= 6) {
|
||||
this._self.charClass = ev.unit.classId as CharClassId
|
||||
if (unit.classId >= 0 && unit.classId <= 6) {
|
||||
this._self.charClass = unit.classId as CharClassId
|
||||
}
|
||||
if (ev.unit.x !== 0 || ev.unit.y !== 0) {
|
||||
this._self.x = ev.unit.x
|
||||
this._self.y = ev.unit.y
|
||||
this._self.targetX = ev.unit.x
|
||||
this._self.targetY = ev.unit.y
|
||||
if (unit.x !== 0 || unit.y !== 0) {
|
||||
this._self.x = unit.x
|
||||
this._self.y = unit.y
|
||||
this._self.targetX = unit.x
|
||||
this._self.targetY = unit.y
|
||||
}
|
||||
}
|
||||
break
|
||||
|
|
@ -2124,7 +2119,7 @@ export class ClientWorld {
|
|||
} else if (moveMode === 'run') {
|
||||
unit.mode = 15
|
||||
} else if (moveMode === 'knockback') {
|
||||
unit.mode = 9
|
||||
unit.mode = 13
|
||||
} else {
|
||||
unit.mode = 2
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -199,7 +199,15 @@ const PLAYER_MODE_NUM_TO_CLIP: Readonly<Record<number, CharAnimClipKey>> = {
|
|||
8: 'a2',
|
||||
9: 'a1',
|
||||
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',
|
||||
18: 'a1', // SQ (Sequence)
|
||||
19: 'gh', // KB (Knock back, token GH)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -145,15 +145,15 @@ export const MONSTER_CMD_TO_MODE: readonly number[] = [
|
|||
12, // 0x09: DD (Dead corpse)
|
||||
4, // 0x0A: A1 (Attack 1 with target)
|
||||
4, // 0x0B: A1 (Attack 1 no target)
|
||||
8, // 0x0C: SQ (Sequence no target)
|
||||
8, // 0x0D: SQ (Sequence with target)
|
||||
9, // 0x0E: KB (Knockback no target)
|
||||
9, // 0x0F: KB (Knockback with target)
|
||||
8, // 0x0C: S1 (Skill 1 no target)
|
||||
8, // 0x0D: S1 (Skill 1 with target)
|
||||
9, // 0x0E: S2 (Skill 2 no target)
|
||||
9, // 0x0F: S2 (Skill 2 with target)
|
||||
5, // 0x10: A2 (Attack 2 with target)
|
||||
5, // 0x11: A2 (Attack 2 no target)
|
||||
6, // 0x12: BL (Block)
|
||||
16, // 0x13: special
|
||||
13, // 0x14: S2 (Skill 2)
|
||||
13, // 0x14: KB (Knockback)
|
||||
14, // 0x15: S3 (Skill 3)
|
||||
14, // 0x16: S3 (Skill 3)
|
||||
15, // 0x17: RN (0x67 run)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue