diablo2-web/tests/client/char-anim-speed-loop.test.ts

813 lines
24 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import {
CHAR_ANIM_CLIPS_113C,
computeAnimFrameIndex,
computeDiminishingRate113c,
computeEffectiveFcr,
computeEffectiveFhr,
computeEffectiveIas,
computeOneShotDurationMs,
computeOneShotTicks,
computeStepSpeed256,
getCharAnimClip113c,
resolveWeaponWsm,
} from '../../src/common/units/char-anim-speed.ts'
import { dir64FromPoints, dir64ToClipDirection } from '../../src/common/units/direction.ts'
import type { AnimClipMeta } from '../../src/client/render/actor-animator.ts'
import type { LoadedCharacter } from '../../src/client/scene/entity-art-loader.ts'
import {
clearMissingArtEntries,
renderSceneSourceFrame,
} from '../../src/client/scene/world-renderer.ts'
import { OnlineSession } from '../../src/client/session/online-session.ts'
import type { SpriteRenderer } from '../../src/client/render/renderer.ts'
import type { SceneFrameSnapshot, SceneSource } from '../../src/client/view/scene-source.ts'
import { buildSceneDrawables, type RenderableUnitInput } from '../../src/client/view/unit-drawables.ts'
import { getViewportProfile } from '../../src/client/view/viewport-profile.ts'
import type { ClientUnit } from '../../src/client/world/client-unit.ts'
import { ClientWorld } from '../../src/client/world/client-world.ts'
type MockFrame = LoadedCharacter['groups'][number][number]
function toRenderableUnit(unit: ClientUnit): RenderableUnitInput {
return {
id: unit.id,
kind: unit.unitType,
classId: unit.classId,
x: unit.x,
y: unit.y,
...(unit.targetX !== undefined ? { targetX: unit.targetX } : {}),
...(unit.targetY !== undefined ? { targetY: unit.targetY } : {}),
mode: unit.mode,
direction: unit.direction,
lifePct: unit.lifePct,
...(unit.castState !== undefined ? { castState: unit.castState } : {}),
...(unit.modeStartedAtMs !== undefined ? { animStartedAtMs: unit.modeStartedAtMs } : {}),
...(unit.animStepSpeed256 !== undefined ? { animStepSpeed256: unit.animStepSpeed256 } : {}),
...(unit.animEffRate !== undefined ? { animEffRate: unit.animEffRate } : {}),
states: Array.from(unit.states.list()),
}
}
function createMockCharacterArt(classId = 1): LoadedCharacter {
const canonical = CHAR_ANIM_CLIPS_113C[classId] ?? CHAR_ANIM_CLIPS_113C[1]!
const groups: MockFrame[][] = []
const clips: Record<string, AnimClipMeta> = {}
const numDirs = 16
let groupCursor = 0
for (const [clipKey, spec] of Object.entries(canonical)) {
clips[clipKey] = {
group: groupCursor,
frames: spec.frames,
directions: numDirs,
speed: spec.speed,
loop: spec.loop,
events: [],
}
for (let dir = 0; dir < numDirs; dir++) {
const dirFrames: MockFrame[] = []
for (let f = 0; f < spec.frames; f++) {
dirFrames.push({
x: (groupCursor + dir) * 1000 + f,
y: f,
width: 48,
height: 72,
anchorX: -24,
anchorY: -64,
})
}
groups.push(dirFrames)
}
groupCursor += numDirs
}
return {
handle: 1 as unknown as LoadedCharacter['handle'],
stand: clips.nu!.group,
walk: clips.wl!.group,
directions: numDirs,
members: 1,
clips,
groups,
}
}
function createSpyRenderer(): {
readonly renderer: SpriteRenderer
readonly drawnFrames: MockFrame[]
} {
const drawnFrames: MockFrame[] = []
const renderer = {
begin: () => {},
flush: () => {},
setPalette: () => {},
setLighting: () => {},
addIndexedAtlas: () => 1 as any,
draw: (frame: MockFrame) => {
drawnFrames.push(frame)
},
} as unknown as SpriteRenderer
return { renderer, drawnFrames }
}
describe('1.13c character animation speed & one-shot lifecycle (#552)', () => {
it('computes 1.13c AnimData.d2 diminishing returns, EFCR/EIAS/EFHR, stepSpeed256, and durationMs', () => {
expect(computeDiminishingRate113c(0)).toBe(0)
expect(computeDiminishingRate113c(20)).toBe(17)
expect(computeDiminishingRate113c(40)).toBe(30)
expect(computeDiminishingRate113c(63)).toBe(41)
expect(computeDiminishingRate113c(105)).toBe(56)
expect(computeDiminishingRate113c(120)).toBe(60)
expect(computeDiminishingRate113c(200)).toBe(75)
expect(computeDiminishingRate113c(-30)).toBe(-30)
expect(computeEffectiveFcr(0)).toBe(0)
expect(computeEffectiveFcr(105)).toBe(56)
expect(computeEffectiveFcr(500)).toBe(75)
// Phase Blade (WSM -30) + 40% item IAS (diminished = 30) + 20% Fanaticism -> min(75, 30 + 20 - (-30)) = 75
expect(computeEffectiveIas(40, 20, -30)).toBe(75)
// Great Maul (WSM +20) + 0% IAS -> -20
expect(computeEffectiveIas(0, 0, 20)).toBe(-20)
expect(computeEffectiveFhr(0)).toBe(0)
expect(computeEffectiveFhr(86)).toBe(50)
// Sorceress SC: 14 frames, baseSpeed = 256
const sorcSc = getCharAnimClip113c(1, 'sc')!
expect(sorcSc.frames).toBe(14)
expect(sorcSc.speed).toBe(256)
expect(sorcSc.loop).toBe(false)
const step0 = computeStepSpeed256(sorcSc.speed, computeEffectiveFcr(0))
expect(step0).toBe(256)
expect(computeOneShotTicks(sorcSc.frames, step0)).toBe(14)
expect(computeOneShotDurationMs(sorcSc.frames, step0)).toBe(560)
const step105 = computeStepSpeed256(sorcSc.speed, computeEffectiveFcr(105))
expect(step105).toBe(399)
expect(computeOneShotTicks(sorcSc.frames, step105)).toBe(9)
expect(computeOneShotDurationMs(sorcSc.frames, step105)).toBe(360)
})
it('resolves 1.13c weapon WSM from Weapons.txt codes', () => {
expect(resolveWeaponWsm('7cr')).toBe(-30) // Phase Blade
expect(resolveWeaponWsm('hax')).toBe(0) // Hand Axe
expect(resolveWeaponWsm('axe')).toBe(10) // Axe
expect(resolveWeaponWsm('mau')).toBe(10) // Maul
expect(resolveWeaponWsm('gma')).toBe(20) // Great Maul
})
it('drives spell cast speed and castState duration from FCR in StatList and equipped items', () => {
const world = new ClientWorld()
world.apply(
{
type: 'UnitAssign',
isSelf: true,
unit: {
unitType: 'player',
id: 1,
classId: 1, // Sorceress
name: 'Sorc',
x: 5000,
y: 5000,
mode: 1,
lifePct: 128,
},
},
1000,
)
// Cast Fireball (skillId = 47) at 0% FCR
world.apply(
{
type: 'SkillCast',
caster: { unitType: 'player', id: 1 },
skill: 47,
level: 1,
target: { x: 5020, y: 5000 },
},
1000,
)
const unit = world.getUnit('player', 1)!
expect(unit.mode).toBe(10)
expect(unit.castState).toBeDefined()
expect(unit.castState!.stepSpeed256).toBe(256)
expect(unit.castState!.durationMs).toBe(560)
expect(unit.castState!.untilMs).toBe(1560)
// Equip a weapon + shield totaling 105% FCR (statId = 105)
world.apply({
type: 'ItemAction',
action: 0x06,
item: {
id: 101,
code: 'obf',
container: 'equipment',
equipSlot: 4,
x: 0,
y: 0,
stats: [{ id: 105, value: 40 }],
runewordStats: [{ id: 105, value: 35 }],
} as any,
})
world.apply({
type: 'ItemAction',
action: 0x06,
item: {
id: 102,
code: 'uit',
container: 'equipment',
equipSlot: 5,
x: 0,
y: 0,
stats: [{ id: 105, value: 30 }],
} as any,
})
// Cast Fireball at 105% FCR
world.apply(
{
type: 'SkillCast',
caster: { unitType: 'player', id: 1 },
skill: 47,
level: 1,
target: { x: 5020, y: 5000 },
},
2000,
)
expect(unit.castState).toBeDefined()
expect(unit.castState!.effRate).toBe(56)
expect(unit.castState!.stepSpeed256).toBe(399)
expect(unit.castState!.durationMs).toBe(360)
expect(unit.castState!.untilMs).toBe(2360)
})
it('drives melee attack speed and duration from IAS, skill IAS, and equipped weapon WSM', () => {
const world = new ClientWorld()
world.apply(
{
type: 'UnitAssign',
isSelf: true,
unit: {
unitType: 'player',
id: 1,
classId: 3, // Paladin (A1: 14 frames, baseSpeed = 256)
name: 'Pala',
x: 5000,
y: 5000,
mode: 1,
lifePct: 128,
},
},
1000,
)
// 1) Equip slow Great Maul ('gma', WSM = +20) with 0% IAS
world.apply({
type: 'ItemAction',
action: 0x06,
item: {
id: 201,
code: 'gma',
container: 'equipment',
equipSlot: 4,
x: 0,
y: 0,
stats: [],
} as any,
})
world.apply(
{
type: 'SkillCast',
caster: { unitType: 'player', id: 1 },
skill: 0, // Normal Attack -> mode 7 (A1)
level: 1,
target: { x: 5005, y: 5000 },
},
1000,
)
const unit = world.getUnit('player', 1)!
expect(unit.mode).toBe(7)
expect(unit.castState!.effRate).toBe(-20)
expect(unit.castState!.stepSpeed256).toBe(204)
expect(unit.castState!.durationMs).toBe(720)
// 2) Replace with Phase Blade ('7cr', WSM = -30, +40% IAS) + 20% skill IAS (statId 68)
world.apply({
type: 'ItemAction',
action: 0x06,
item: {
id: 202,
code: '7cr',
container: 'equipment',
equipSlot: 4,
x: 0,
y: 0,
stats: [{ id: 93, value: 40 }],
} as any,
})
world.apply({ type: 'StatSet', stat: 68, value: 20, param: 0 }, 2000)
world.apply(
{
type: 'SkillCast',
caster: { unitType: 'player', id: 1 },
skill: 0,
level: 1,
target: { x: 5005, y: 5000 },
},
2000,
)
expect(unit.castState!.effRate).toBe(75)
expect(unit.castState!.stepSpeed256).toBe(448)
expect(unit.castState!.durationMs).toBe(320)
expect(unit.castState!.untilMs).toBe(2320)
})
it('clamps non-looping one-shot clips at frameCount - 1 without modulo wrapping and wraps looping clips', () => {
// Non-looping 14-frame clip at stepSpeed256 = 256 (1 frame per 40ms)
const framesVisited: number[] = []
for (let t = 0; t <= 1200; t += 40) {
framesVisited.push(
computeAnimFrameIndex({
frameCount: 14,
stepSpeed256: 256,
loop: false,
nowMs: 1000 + t,
startedAtMs: 1000,
}),
)
}
expect(framesVisited[0]).toBe(0)
expect(framesVisited[1]).toBe(1)
expect(framesVisited[13]).toBe(13)
// After 13 * 40 = 520ms, all subsequent samples stay clamped at 13 (never wrap to 0)
for (let i = 13; i < framesVisited.length; i++) {
expect(framesVisited[i]).toBe(13)
}
// Looping 8-frame clip wraps around
expect(
computeAnimFrameIndex({
frameCount: 8,
stepSpeed256: 256,
loop: true,
nowMs: 8 * 40,
}),
).toBe(0)
expect(
computeAnimFrameIndex({
frameCount: 8,
stepSpeed256: 256,
loop: true,
nowMs: 9 * 40,
}),
).toBe(1)
})
it('expires castState back to mode 1 (NU) without moving caster position, and interrupts cast on UnitMove', () => {
const world = new ClientWorld()
world.apply(
{
type: 'UnitAssign',
isSelf: true,
unit: {
unitType: 'player',
id: 1,
classId: 1,
name: 'Sorc',
x: 5100,
y: 5100,
mode: 1,
lifePct: 128,
},
},
1000,
)
// Cast ranged spell toward (5200, 5100)
world.apply(
{
type: 'SkillCast',
caster: { unitType: 'player', id: 1 },
skill: 47,
level: 1,
target: { x: 5200, y: 5100 },
},
1000,
)
const unit = world.getUnit('player', 1)!
const castDir = dir64FromPoints(5100, 5100, 5200, 5100, 0)
expect(unit.mode).toBe(10)
expect(unit.direction).toBe(castDir)
// Mid-cast tick at t = 1280ms (280ms elapsed < 560ms duration)
world.tick(280, 1280)
expect(unit.x).toBe(5100)
expect(unit.y).toBe(5100)
expect(unit.castState).toBeDefined()
expect(unit.mode).toBe(10)
const midDrawables = buildSceneDrawables([toRenderableUnit(unit)]).unitDrawables
expect(midDrawables[0]!.mode).toBe(10)
expect(midDrawables[0]!.animMode).toBe('SC')
expect(midDrawables[0]!.direction).toBe(castDir)
// Tick past cast completion at t = 1560ms (560ms elapsed)
world.tick(280, 1560)
expect(unit.castState).toBeUndefined()
expect(world.self.castState).toBeUndefined()
expect(unit.mode).toBe(1)
expect(unit.direction).toBe(castDir)
const afterDrawables = buildSceneDrawables([toRenderableUnit(unit)]).unitDrawables
expect(afterDrawables[0]!.mode).toBe(1)
expect(afterDrawables[0]!.animMode).toBeUndefined()
expect(afterDrawables[0]!.direction).toBe(castDir)
// Start another cast at t = 2000ms and interrupt it with UnitMove at t = 2100ms
world.apply(
{
type: 'SkillCast',
caster: { unitType: 'player', id: 1 },
skill: 47,
level: 1,
target: { x: 5200, y: 5100 },
},
2000,
)
expect(unit.castState).toBeDefined()
expect(unit.mode).toBe(10)
world.apply(
{
type: 'UnitMove',
unitType: 'player',
id: 1,
mode: 'run',
from: { x: 5100, y: 5100 },
to: { x: 5120, y: 5120 },
},
2100,
)
expect(unit.castState).toBeUndefined()
expect(world.self.castState).toBeUndefined()
expect(unit.mode).toBe(3)
})
it('preserves local player cast facing direction on OnlineSession after cast expires into NU and clears cast on movement', () => {
const session = new OnlineSession()
session.handleServerEvent({
type: 'UnitAssign',
isSelf: true,
unit: {
unitType: 'player',
id: 1,
classId: 1,
name: 'Sorc',
x: 5000,
y: 5000,
mode: 1,
lifePct: 128,
},
})
session.handleServerEvent({
type: 'SkillCast',
caster: { unitType: 'player', id: 1 },
skill: 47,
level: 1,
target: { x: 5040, y: 5020 },
})
const expectedDir = dir64FromPoints(5000, 5000, 5040, 5020, 0)
const duringUnits = session.buildRenderableUnits()
const selfDuring = duringUnits.find((u) => u.isLocalPlayer)!
expect(selfDuring.mode).toBe(10)
expect(selfDuring.direction).toBe(expectedDir)
expect(selfDuring.castState).toBeDefined()
// Advance 600ms via session.tick so the 560ms Sorceress cast finishes
for (let i = 1; i <= 15; i++) {
session.tick(40, i * 40)
}
const afterUnits = session.buildRenderableUnits()
const selfAfter = afterUnits.find((u) => u.isLocalPlayer)!
expect(selfAfter.mode).toBe(1) // Returned to NU (idle)
expect(selfAfter.castState).toBeUndefined()
expect(selfAfter.direction).toBe(expectedDir) // Still facing cast target!
session.destroy()
})
it('renders non-looping one-shot clip frames once (clamped at last frame) and switches back to NU clip after castState expires', () => {
clearMissingArtEntries()
const world = new ClientWorld()
world.apply(
{
type: 'UnitAssign',
isSelf: true,
unit: {
unitType: 'player',
id: 1,
classId: 1, // Sorceress
name: 'Sorc',
x: 5000,
y: 5000,
mode: 1,
lifePct: 128,
},
},
1000,
)
world.apply(
{
type: 'SkillCast',
caster: { unitType: 'player', id: 1 },
skill: 47,
level: 1,
target: { x: 5030, y: 5000 },
},
1000,
)
const charArt = createMockCharacterArt(1)
const characterArtByToken = new Map<string, LoadedCharacter>([['so', charArt]])
const { renderer, drawnFrames } = createSpyRenderer()
const vp = getViewportProfile('800x600')
const makeSource = (): SceneSource => ({
isWorldReady: () => true,
getLevelView: () => null,
getViewportProfile: () => vp,
setViewportProfile: () => vp,
buildLightingInputs: () => ({}) as any,
buildFrameSnapshot: (): SceneFrameSnapshot => {
const unit = world.getUnit('player', 1)!
const entities = buildSceneDrawables([
{
id: unit.id,
kind: 'player',
classId: unit.classId,
x: unit.x,
y: unit.y,
mode: unit.mode,
direction: unit.direction,
castState: unit.castState,
animStartedAtMs: unit.castState?.startedAtMs ?? unit.modeStartedAtMs,
animStepSpeed256: unit.castState?.stepSpeed256 ?? unit.animStepSpeed256,
animEffRate: unit.castState?.effRate ?? unit.animEffRate,
isLocalPlayer: true,
},
])
return {
tick: 0,
worldReady: true,
actId: 1,
levelId: 1,
viewportProfile: vp,
playerSubTile: { x: 5000, y: 5000 },
playerLocalSubTile: { x: 5000, y: 5000 },
playerWorld: { x: 0, y: 0 },
cameraTopLeft: { x: -400, y: -300 },
floorQuads: [],
shadowQuads: [],
lowerWallQuads: [],
upperWallQuads: [],
roofQuads: [],
entities,
}
},
}) as unknown as SceneSource
const source = makeSource()
const scClip = charArt.clips!.sc!
const nuClip = charArt.clips!.nu!
const unit = world.getUnit('player', 1)!
const clipDir = dir64ToClipDirection(unit.direction, scClip.directions ?? 16)
const expectedScGroup = scClip.group + clipDir
const expectedNuGroup = nuClip.group + clipDir
// At t = 1000ms (cast start), renders SC frame 0
renderSceneSourceFrame({
source,
lightingPreset: 'day' as any,
renderer,
characterArtByToken,
nowMs: 1000,
})
expect(drawnFrames.at(-1)!.x).toBe(expectedScGroup * 1000 + 0)
expect(drawnFrames.at(-1)!.y).toBe(0)
// At t = 1200ms (5 ticks into cast), renders SC frame 5
renderSceneSourceFrame({
source,
lightingPreset: 'day' as any,
renderer,
characterArtByToken,
nowMs: 1200,
})
expect(drawnFrames.at(-1)!.x).toBe(expectedScGroup * 1000 + 5)
expect(drawnFrames.at(-1)!.y).toBe(5)
// Even if world.tick hasn't been called yet at t = 1800ms, the one-shot SC clip clamps at frame 13 (never wraps to 0)
renderSceneSourceFrame({
source,
lightingPreset: 'day' as any,
renderer,
characterArtByToken,
nowMs: 1800,
})
expect(drawnFrames.at(-1)!.x).toBe(expectedScGroup * 1000 + 13)
expect(drawnFrames.at(-1)!.y).toBe(13)
// Once world.tick(600, 1600) expires castState, unit returns to NU clip while preserving clipDir
world.tick(600, 1600)
renderSceneSourceFrame({
source,
lightingPreset: 'day' as any,
renderer,
characterArtByToken,
nowMs: 1600,
})
const finalFrame = drawnFrames.at(-1)!
expect(Math.floor(finalFrame.x / 100)).toBe(expectedNuGroup * 10)
})
it('decouples discrete 25Hz simulation ticks from high display refresh rates (144Hz, 240Hz, and 0ms frames)', () => {
const world = new ClientWorld()
world.apply({
type: 'UnitAssign',
isSelf: true,
unit: {
unitType: 'player',
id: 1,
classId: 1,
name: 'Sorc',
x: 5000,
y: 5000,
mode: 1,
lifePct: 128,
},
})
// Spawn a missile (e.g. Fireball) and a cast overlay
const missile = world.spawnMissileByKey('fireball', 5000, 5000, 5050, 5000)
const overlay = world.attachUnitOverlay(world.getUnit('player', 1)!, 'fire_cast_2', true)
expect(world.frameCount).toBe(0)
expect(missile.ageTicks).toBe(0)
expect(overlay.ageTicks).toBe(0)
// Simulate 1 second at 144Hz: 144 frames of ~6.944ms each = 1000ms
const dt144 = 1000 / 144
for (let frame = 1; frame <= 144; frame++) {
world.tick(dt144, frame * dt144)
}
// In 1 second (1000ms), 25Hz simulation should advance by exactly 25 ticks, NOT 144 ticks!
expect(world.frameCount).toBe(25)
expect(missile.ageTicks).toBe(25)
expect(overlay.ageTicks).toBe(25)
// Simulate 0ms duplicate RAF callbacks
for (let i = 0; i < 10; i++) {
world.tick(0, 1000)
}
expect(world.frameCount).toBe(25)
expect(missile.ageTicks).toBe(25)
expect(overlay.ageTicks).toBe(25)
// Advance 40ms more (1 tick)
world.tick(40, 1040)
expect(world.frameCount).toBe(26)
expect(missile.ageTicks).toBe(26)
expect(overlay.ageTicks).toBe(26)
})
it('prevents held mouse from interrupting active cast animation every 100ms via isPlayerBusyCasting', () => {
const session = new OnlineSession()
session.handleServerEvent({
type: 'UnitAssign',
isSelf: true,
unit: {
unitType: 'player',
id: 1,
classId: 1, // Sorceress
name: 'Sorc',
x: 5000,
y: 5000,
mode: 1,
lifePct: 128,
},
})
session.world.self.rightSkill = 47 // Fireball
// Player presses and holds right mouse button to cast at t = 1000ms
const rec1 = session.commandMapper.handlePointerDown(400, 300, 2, 1000)
expect(rec1).not.toBeNull()
expect(rec1?.command.type).toBe('SkillAt')
// Client world prediction has set up castState with duration ~520ms (until t = 1520ms)
const selfUnit = session.world.getUnit('player', 1)!
expect(selfUnit.castState).toBeDefined()
expect(selfUnit.castState?.skillId).toBe(47)
const untilMs = selfUnit.castState!.untilMs
expect(untilMs).toBeGreaterThan(1500)
// At t = 1100ms (100ms later, which is the 100ms skillMinIntervalMs rate limit cooldown),
// player is still holding right mouse button.
expect(session.commandMapper.isPlayerBusyCasting(1100)).toBe(true)
const rec2 = session.commandMapper.tick(1100)
expect(rec2).toBeNull() // Must NOT interrupt active cast!
expect(selfUnit.castState?.startedAtMs).toBe(1000) // Cast start time intact!
// Repeat tick at t = 1200, 1300, 1400, 1500ms
for (let t = 1200; t <= 1500; t += 100) {
expect(session.commandMapper.tick(t)).toBeNull()
expect(selfUnit.castState?.startedAtMs).toBe(1000)
}
// Step world past untilMs so the cast finishes
session.world.tick(untilMs - 1000 + 40, untilMs + 40)
expect(selfUnit.castState).toBeUndefined()
expect(session.commandMapper.isPlayerBusyCasting(untilMs + 40)).toBe(false)
// Now that previous cast completed, held mouse initiates the next cast cleanly!
const recNext = session.commandMapper.tick(untilMs + 40)
expect(recNext).not.toBeNull()
expect(recNext?.type).toBe('SkillAt')
expect(selfUnit.castState).toBeDefined()
expect(selfUnit.castState?.startedAtMs).toBe(untilMs + 40)
session.destroy()
})
it('preserves locally-predicted cast timing and avoids restarting animation when server SkillCast echo arrives', () => {
const world = new ClientWorld()
world.apply(
{
type: 'UnitAssign',
isSelf: true,
unit: {
unitType: 'player',
id: 1,
classId: 1,
name: 'Sorc',
x: 5000,
y: 5000,
mode: 1,
lifePct: 128,
},
},
1000,
)
world.self.rightSkill = 47
// Local client predicts right skill cast at t = 1000ms
world.noteOutgoingCommand(
{
type: 'SkillAt',
hand: 'right',
at: { x: 5030, y: 5000 },
},
1000,
)
const unit = world.getUnit('player', 1)!
expect(unit.castState).toBeDefined()
expect(unit.castState?.startedAtMs).toBe(1000)
const origUntilMs = unit.castState!.untilMs
// Advance 80ms of local playback (2 simulation frames)
world.tick(40, 1040)
world.tick(40, 1080)
expect(unit.castState?.elapsedMs).toBe(80)
// At t = 1080ms (80ms RTT), server 0x4C SkillCast echo arrives
world.apply(
{
type: 'SkillCast',
caster: { unitType: 'player', id: 1 },
skill: 47,
level: 1,
target: { x: 5030, y: 5000 },
},
1080,
)
// The cast timing must be preserved without resetting back to frame 0 or startedAtMs = 1080
expect(unit.castState?.startedAtMs).toBe(1000)
expect(unit.castState?.untilMs).toBe(origUntilMs)
expect(unit.castState?.elapsedMs).toBe(80)
})
})