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',
|
10: 's3',
|
||||||
11: 's4',
|
11: 's4',
|
||||||
12: 'dd',
|
12: 'dd',
|
||||||
13: 'rn',
|
13: 'gh',
|
||||||
14: 'xx',
|
14: 'xx',
|
||||||
15: 'rn',
|
15: 'rn',
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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