fix(client): decouple 25Hz simulation and character animations from display refresh rate

- Decouple discrete simulation stepping (missiles, overlays, frameCount) in ClientWorld to fixed 25Hz ticks (40ms) via accumulator, preventing animation speedup at high refresh rates (120Hz/144Hz/240Hz).
- Continuous movement interpolation for moving units using exact (safeDt / 1000) * 10 without Math.max(1, ...).
- Guard held mouse / repeat clicks from interrupting active cast animation every 100ms via CommandMapper.isPlayerBusyCasting.
- Preserve locally-predicted cast timing and avoid animation restart when server 0x4C SkillCast echo arrives.
- Drive fallback cast overlay in unit-drawables using elapsedTicks and animRate instead of global display tick.
- Add comprehensive vitest coverage in tests/client/char-anim-speed-loop.test.ts.
This commit is contained in:
troytt 2026-10-02 11:13:26 +00:00
parent b839301aae
commit 62663c4840
6 changed files with 317 additions and 46 deletions

View File

@ -346,6 +346,23 @@ export class CommandMapper {
return this.hudManager.isPointInterceptedByHud(screenX, screenY) return this.hudManager.isPointInterceptedByHud(screenX, screenY)
} }
/**
* Returns true if the local player is currently executing a non-expired cast
* or attack animation window.
*/
isPlayerBusyCasting(nowMs = performance.now()): boolean {
const selfUnit =
this.world.selfId !== 0 ? this.world.getUnit('player', this.world.selfId) : undefined
const castState = selfUnit?.castState ?? this.world.self.castState
if (!castState) return false
if (castState.untilMs !== undefined) {
return nowMs < castState.untilMs
}
const elapsed = castState.elapsedMs ?? 0
const duration = castState.durationMs ?? 0
return duration > 0 && elapsed < duration
}
// ─── Mouse / Pointer Input Handlers ────────────────────────── // ─── Mouse / Pointer Input Handlers ──────────────────────────
/** /**
@ -378,8 +395,14 @@ export class CommandMapper {
} }
this.lockedPointerUnit = null this.lockedPointerUnit = null
} }
if (this.isPlayerBusyCasting(nowMs)) {
return
}
this.triggerPrimaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs) this.triggerPrimaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs)
} else if (this.rightMouseDown) { } else if (this.rightMouseDown) {
if (this.isPlayerBusyCasting(nowMs)) {
return
}
this.triggerSecondaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs) this.triggerSecondaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs)
} }
} }
@ -421,12 +444,18 @@ export class CommandMapper {
this.leftMouseDown = true this.leftMouseDown = true
this.lockedPointerUnit = this.lockedPointerUnit =
target.hoveredUnit?.unitType === 'item' ? target.hoveredUnit : null target.hoveredUnit?.unitType === 'item' ? target.hoveredUnit : null
if (this.isPlayerBusyCasting(nowMs)) {
return null
}
return this.triggerPrimaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs) return this.triggerPrimaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs)
} }
if (button === 2) { if (button === 2) {
this.rightMouseDown = true this.rightMouseDown = true
this.lockedPointerUnit = null this.lockedPointerUnit = null
if (this.isPlayerBusyCasting(nowMs)) {
return null
}
return this.triggerSecondaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs) return this.triggerSecondaryWorldAction(target.subtileX, target.subtileY, target.hoveredUnit, nowMs)
} }
@ -461,6 +490,9 @@ export class CommandMapper {
// Stand-still modifier (Shift): cast left skill on location/target instead of moving // Stand-still modifier (Shift): cast left skill on location/target instead of moving
if (this.shiftHeld) { if (this.shiftHeld) {
if (this.isPlayerBusyCasting(nowMs)) {
return null
}
this.pendingItemPickupId = null this.pendingItemPickupId = null
if (hoveredUnit && hoveredUnit.unitType === 'monster') { if (hoveredUnit && hoveredUnit.unitType === 'monster') {
return this.dispatchRateLimited( return this.dispatchRateLimited(
@ -611,6 +643,9 @@ export class CommandMapper {
} }
// Non-zero left skill -> cast/attack on monster; otherwise MoveToUnit / attack // Non-zero left skill -> cast/attack on monster; otherwise MoveToUnit / attack
if (leftSkillId > 0) { if (leftSkillId > 0) {
if (this.isPlayerBusyCasting(nowMs)) {
return null
}
return this.dispatchRateLimited( return this.dispatchRateLimited(
{ {
type: 'SkillOn', type: 'SkillOn',
@ -634,6 +669,9 @@ export class CommandMapper {
} }
// Ground walk / run // Ground walk / run
if (this.isPlayerBusyCasting(nowMs)) {
return null
}
this.pendingItemPickupId = null this.pendingItemPickupId = null
const path = this.startLocalPathPrediction(subtileX, subtileY, isRunning) const path = this.startLocalPathPrediction(subtileX, subtileY, isRunning)
const moveTarget = path.length > 0 ? path[path.length - 1]! : { x: subtileX, y: subtileY } const moveTarget = path.length > 0 ? path[path.length - 1]! : { x: subtileX, y: subtileY }
@ -657,6 +695,9 @@ export class CommandMapper {
hoveredUnit: ClientUnit | null = null, hoveredUnit: ClientUnit | null = null,
nowMs = performance.now(), nowMs = performance.now(),
): DispatchedCommandRecord | null { ): DispatchedCommandRecord | null {
if (this.isPlayerBusyCasting(nowMs)) {
return null
}
this.pendingItemPickupId = null this.pendingItemPickupId = null
if (hoveredUnit) { if (hoveredUnit) {
return this.dispatchRateLimited( return this.dispatchRateLimited(
@ -982,6 +1023,9 @@ export class CommandMapper {
if (this.lockedPointerUnit?.unitType === 'item') { if (this.lockedPointerUnit?.unitType === 'item') {
return null return null
} }
if (this.isPlayerBusyCasting(nowMs)) {
return null
}
const target = this.resolvePointerTarget(this.lastPointerX, this.lastPointerY) const target = this.resolvePointerTarget(this.lastPointerX, this.lastPointerY)
const record = this.triggerPrimaryWorldAction( const record = this.triggerPrimaryWorldAction(
target.subtileX, target.subtileX,
@ -993,6 +1037,9 @@ export class CommandMapper {
} }
if (this.rightMouseDown && !this.isPointIntercepted(this.lastPointerX, this.lastPointerY)) { if (this.rightMouseDown && !this.isPointIntercepted(this.lastPointerX, this.lastPointerY)) {
if (this.isPlayerBusyCasting(nowMs)) {
return null
}
const target = this.resolvePointerTarget(this.lastPointerX, this.lastPointerY) const target = this.resolvePointerTarget(this.lastPointerX, this.lastPointerY)
const record = this.triggerSecondaryWorldAction( const record = this.triggerSecondaryWorldAction(
target.subtileX, target.subtileX,
@ -1078,7 +1125,7 @@ export class CommandMapper {
command.type === 'CastRightSkillOnTarget' || command.type === 'CastRightSkillOnTarget' ||
command.type === 'Resurrect' command.type === 'Resurrect'
) { ) {
this.world.noteOutgoingCommand(command) this.world.noteOutgoingCommand(command, nowMs)
} }
} }
return { return {

View File

@ -939,7 +939,7 @@ async function bootPlayApp(): Promise<PlayAppController> {
flowState.lastFrameTimeMs = null flowState.lastFrameTimeMs = null
} }
session.tick(dtMs || 40, nowMs) session.tick(dtMs, nowMs)
const currentSettings = settingsStore.get() const currentSettings = settingsStore.get()
const profile = getViewportProfile(currentSettings.viewport) const profile = getViewportProfile(currentSettings.viewport)

View File

@ -415,7 +415,7 @@ export class OnlineSession {
const levelView = this.syncActiveAreaFromPlayerPosition({ emitHudSync: true }) const levelView = this.syncActiveAreaFromPlayerPosition({ emitHudSync: true })
const levelId = levelView?.areaId ?? (this.world.areaId > 0 ? this.world.areaId : 0) const levelId = levelView?.areaId ?? (this.world.areaId > 0 ? this.world.areaId : 0)
return { return {
tick: this.tickCounter, tick: this.world.frameCount,
...(levelId > 0 ? { actId: getActForArea(levelId), levelId } : {}), ...(levelId > 0 ? { actId: getActForArea(levelId), levelId } : {}),
playerSubX, playerSubX,
playerSubY, playerSubY,

View File

@ -592,7 +592,17 @@ export function buildSceneDrawables(
const ovTxt = CANONICAL_113C_OVERLAYS[castOverlayKey.toLowerCase()] const ovTxt = CANONICAL_113C_OVERLAYS[castOverlayKey.toLowerCase()]
if (ovTxt) { if (ovTxt) {
const totalFrames = Math.max(1, ovTxt.frames) const totalFrames = Math.max(1, ovTxt.frames)
const frame = Math.min(totalFrames - 1, Math.max(0, Math.floor(tick))) const animRate = Math.max(1, ovTxt.animRate || 16)
const elapsedMs =
activeCastState.elapsedMs ??
(activeCastState.startedAtMs && typeof performance !== 'undefined'
? Math.max(0, performance.now() - activeCastState.startedAtMs)
: 0)
const elapsedTicks = elapsedMs / 40
const frame = Math.min(
totalFrames - 1,
Math.max(0, Math.floor((elapsedTicks * animRate) / 16)),
)
const ovDrawable: SceneOverlayDrawable = { const ovDrawable: SceneOverlayDrawable = {
drawableKind: 'overlay', drawableKind: 'overlay',
ownerUnitId: unit.id, ownerUnitId: unit.id,

View File

@ -326,6 +326,7 @@ export class ClientWorld {
private _lastLocalCastSkillId = -1 private _lastLocalCastSkillId = -1
private _lastLocalCastAtMs = -1000 private _lastLocalCastAtMs = -1000
private _simTimeMs = 0 private _simTimeMs = 0
private _tickAccumulatorMs = 0
private readonly _castSimStartMs = new Map<string, number>() private readonly _castSimStartMs = new Map<string, number>()
private _notedCommands = new WeakSet<object>() private _notedCommands = new WeakSet<object>()
private readonly _blockedSubTiles = new Set<string>() private readonly _blockedSubTiles = new Set<string>()
@ -989,11 +990,21 @@ export class ClientWorld {
const skillId = isRight ? this._self.rightSkill : this._self.leftSkill const skillId = isRight ? this._self.rightSkill : this._self.leftSkill
const targetX = cmd.type === 'SkillAt' ? cmd.at.x : cmd.x const targetX = cmd.type === 'SkillAt' ? cmd.at.x : cmd.x
const targetY = cmd.type === 'SkillAt' ? cmd.at.y : cmd.y const targetY = cmd.type === 'SkillAt' ? cmd.at.y : cmd.y
this._lastLocalCastSkillId = skillId const now = this.resolveEventNowMs(nowMs)
this._lastLocalCastAtMs = Date.now()
if (this.selfId !== 0) { if (this.selfId !== 0) {
const selfUnit = this.ensureUnit('player', this.selfId) const selfUnit = this.ensureUnit('player', this.selfId)
this.applySkillCastToUnit(selfUnit, skillId, targetX, targetY, 1, true, nowMs) const isBusyActiveCast =
selfUnit.castState &&
selfUnit.castState.skillId === skillId &&
now < selfUnit.castState.untilMs
if (!isBusyActiveCast) {
this._lastLocalCastSkillId = skillId
this._lastLocalCastAtMs = now
this.applySkillCastToUnit(selfUnit, skillId, targetX, targetY, 1, true, nowMs)
}
} else {
this._lastLocalCastSkillId = skillId
this._lastLocalCastAtMs = now
} }
break break
} }
@ -1008,12 +1019,22 @@ export class ClientWorld {
const targetUnit = this.getUnit(targetRef.unitType, targetRef.id) const targetUnit = this.getUnit(targetRef.unitType, targetRef.id)
const targetX = targetUnit?.x ?? this._self.x const targetX = targetUnit?.x ?? this._self.x
const targetY = targetUnit?.y ?? this._self.y const targetY = targetUnit?.y ?? this._self.y
this._lastLocalCastSkillId = skillId const now = this.resolveEventNowMs(nowMs)
this._lastLocalCastAtMs = Date.now()
if (this.selfId !== 0) { if (this.selfId !== 0) {
const selfUnit = this.ensureUnit('player', this.selfId) const selfUnit = this.ensureUnit('player', this.selfId)
selfUnit.targetUnit = targetRef selfUnit.targetUnit = targetRef
this.applySkillCastToUnit(selfUnit, skillId, targetX, targetY, 1, true, nowMs) const isBusyActiveCast =
selfUnit.castState &&
selfUnit.castState.skillId === skillId &&
now < selfUnit.castState.untilMs
if (!isBusyActiveCast) {
this._lastLocalCastSkillId = skillId
this._lastLocalCastAtMs = now
this.applySkillCastToUnit(selfUnit, skillId, targetX, targetY, 1, true, nowMs)
}
} else {
this._lastLocalCastSkillId = skillId
this._lastLocalCastAtMs = now
} }
break break
} }
@ -1485,25 +1506,65 @@ export class ClientWorld {
} }
tick(dtMs = 40, nowMs?: number): void { tick(dtMs = 40, nowMs?: number): void {
this.frameCount++ const safeDt = Math.max(0, Number.isFinite(dtMs) ? dtMs : 0)
const safeDt = Math.max(0, dtMs) const cappedDt = Math.min(200, safeDt)
this._tickAccumulatorMs += cappedDt
this._simTimeMs += safeDt this._simTimeMs += safeDt
const step = Math.max(1, (safeDt / 1000) * 10)
const ticksToStep = Math.max(1, Math.round(safeDt / 40))
const inTown = isTownLevel(this.areaId)
this.expireCastStates(nowMs, safeDt) this.expireCastStates(nowMs, safeDt)
const D2_TICK_MS = 40
const EPSILON = 1e-4
while (this._tickAccumulatorMs >= D2_TICK_MS - EPSILON) {
this._tickAccumulatorMs = Math.max(0, this._tickAccumulatorMs - D2_TICK_MS)
this.stepDiscreteSimulationTick(D2_TICK_MS)
}
// Continuous unit movement interpolation across display frames
const step = (safeDt / 1000) * 10
if (step > 0) {
const inTown = isTownLevel(this.areaId)
for (const unit of this._units.values()) {
if (unit.dead || unit.castState || unit.unitType === 'missile') continue
if (unit.targetX === undefined || unit.targetY === undefined) continue
const dx = unit.targetX - unit.x
const dy = unit.targetY - unit.y
const dist = Math.hypot(dx, dy)
if (dist === 0) continue
if (dist <= step) {
unit.x = unit.targetX
unit.y = unit.targetY
if (
unit.unitType === 'player' &&
(unit.mode === 2 || unit.mode === 3 || unit.mode === 6 || unit.mode === 19)
) {
unit.mode = inTown ? 5 : 1
unit.moveMode = 'stop'
} else if (
unit.unitType === 'monster' &&
(unit.mode === 2 || unit.mode === 9 || unit.mode === 15)
) {
unit.mode = 1
unit.moveMode = 'stop'
}
} else {
unit.x += (dx / dist) * step
unit.y += (dy / dist) * step
}
}
}
}
private stepDiscreteSimulationTick(_tickDtMs = 40): void {
this.frameCount++
const unitsSnapshot = Array.from(this._units.values()) const unitsSnapshot = Array.from(this._units.values())
for (const unit of unitsSnapshot) { for (const unit of unitsSnapshot) {
if (!this._units.has(unit.key)) continue if (!this._units.has(unit.key)) continue
// 1. Step missile units // 1. Step missile units (1 simulation tick)
if (unit.unitType === 'missile') { if (unit.unitType === 'missile') {
for (let t = 0; t < ticksToStep; t++) { this.stepMissileUnit(unit)
if (!this._units.has(unit.key)) break
this.stepMissileUnit(unit)
}
continue continue
} }
@ -1511,7 +1572,7 @@ export class ClientWorld {
if (unit.overlays.length > 0) { if (unit.overlays.length > 0) {
const remainingOverlays: ClientUnitOverlay[] = [] const remainingOverlays: ClientUnitOverlay[] = []
for (const ov of unit.overlays) { for (const ov of unit.overlays) {
ov.ageTicks += ticksToStep ov.ageTicks += 1
const rawFrame = Math.floor((ov.ageTicks * ov.animRate) / 16) const rawFrame = Math.floor((ov.ageTicks * ov.animRate) / 16)
if (ov.loop) { if (ov.loop) {
ov.frame = rawFrame % Math.max(1, ov.totalFrames) ov.frame = rawFrame % Math.max(1, ov.totalFrames)
@ -1523,28 +1584,6 @@ export class ClientWorld {
} }
unit.overlays = remainingOverlays unit.overlays = remainingOverlays
} }
// 3. Interpolate moving non-casting units
if (unit.dead || unit.castState) continue
if (unit.targetX === undefined || unit.targetY === undefined) continue
const dx = unit.targetX - unit.x
const dy = unit.targetY - unit.y
const dist = Math.hypot(dx, dy)
if (dist === 0) continue
if (dist <= step) {
unit.x = unit.targetX
unit.y = unit.targetY
if (unit.unitType === 'player' && (unit.mode === 2 || unit.mode === 3 || unit.mode === 6 || unit.mode === 19)) {
unit.mode = inTown ? 5 : 1
unit.moveMode = 'stop'
} else if (unit.unitType === 'monster' && (unit.mode === 2 || unit.mode === 9 || unit.mode === 15)) {
unit.mode = 1
unit.moveMode = 'stop'
}
} else {
unit.x += (dx / dist) * step
unit.y += (dy / dist) * step
}
} }
} }
@ -2027,15 +2066,22 @@ export class ClientWorld {
} }
} }
const now = this.resolveEventNowMs(nowMs)
const timeSinceLocalCast = now - this._lastLocalCastAtMs
const isSelfEcho = const isSelfEcho =
unit.unitType === 'player' && unit.unitType === 'player' &&
(unit.id === this.selfId || this.selfId === 0) && (unit.id === this.selfId || this.selfId === 0) &&
skillId === this._lastLocalCastSkillId && skillId === this._lastLocalCastSkillId &&
Date.now() - this._lastLocalCastAtMs >= 0 && timeSinceLocalCast >= 0 &&
Date.now() - this._lastLocalCastAtMs <= 250 timeSinceLocalCast <= 500
if (isSelfEcho) { if (isSelfEcho) {
this._lastLocalCastSkillId = -1 this._lastLocalCastSkillId = -1
this.applySkillCastToUnit(unit, skillId, targetX, targetY, level, false, nowMs) if (unit.castState && unit.castState.skillId === skillId && now < unit.castState.untilMs) {
unit.targetX = targetX
unit.targetY = targetY
} else {
this.applySkillCastToUnit(unit, skillId, targetX, targetY, level, false, nowMs)
}
} else { } else {
this.applySkillCastToUnit(unit, skillId, targetX, targetY, level, true, nowMs) this.applySkillCastToUnit(unit, skillId, targetX, targetY, level, true, nowMs)
} }
@ -2605,6 +2651,7 @@ export class ClientWorld {
this._lastLocalCastSkillId = -1 this._lastLocalCastSkillId = -1
this._lastLocalCastAtMs = -1000 this._lastLocalCastAtMs = -1000
this._simTimeMs = 0 this._simTimeMs = 0
this._tickAccumulatorMs = 0
this._castSimStartMs.clear() this._castSimStartMs.clear()
this._notedCommands = new WeakSet<object>() this._notedCommands = new WeakSet<object>()
this._blockedSubTiles.clear() this._blockedSubTiles.clear()

View File

@ -642,4 +642,171 @@ describe('1.13c character animation speed & one-shot lifecycle (#552)', () => {
const finalFrame = drawnFrames.at(-1)! const finalFrame = drawnFrames.at(-1)!
expect(Math.floor(finalFrame.x / 100)).toBe(expectedNuGroup * 10) 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)
})
}) })