Fix Multiple Shot ammunition branching and Missiles.txt 684-row parity (Closes #562, #568, #571, #572, #574, #576)

This commit is contained in:
troytt 2026-10-02 15:00:45 +00:00
parent f8c938f866
commit 43d32e6d23
5 changed files with 787 additions and 1576 deletions

View File

@ -1141,20 +1141,18 @@ export class OnlineSession {
if (this.gameAdapter && this.gameAdapter.state !== 'closed') { if (this.gameAdapter && this.gameAdapter.state !== 'closed') {
try { try {
this.gameAdapter.send(cmd) this.gameAdapter.send(cmd)
this.world.noteOutgoingCommand(cmd, nowMs)
if (this.world.selfId !== 0) {
const selfUnit = this.world.getUnit('player', this.world.selfId)
if (selfUnit?.castState && !this.predictor.getSnapshot().moving) {
this.predictor.setTargetDirection(selfUnit.targetDirection, false)
}
}
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err) const msg = err instanceof Error ? err.message : String(err)
this.handleUnexpectedDisconnect(`Failed to send command ${cmd.type}: ${msg}`, 'error', true) this.handleUnexpectedDisconnect(`Failed to send command ${cmd.type}: ${msg}`, 'error', true)
return return
} }
} else { }
this.world.noteOutgoingCommand(cmd, nowMs) this.world.noteOutgoingCommand(cmd, nowMs)
if (this.world.selfId !== 0) {
const selfUnit = this.world.getUnit('player', this.world.selfId)
if (selfUnit?.castState && !this.predictor.getSnapshot().moving) {
this.predictor.setTargetDirection(selfUnit.targetDirection, false)
}
} }
if ( if (
cmd.type === 'SkillAt' || cmd.type === 'SkillAt' ||

View File

@ -210,6 +210,13 @@ const BUTTON_ACTION_OPEN_STASH = 0x10
/** Largest 1.13c stat id: stat ids are 9 bits wide in the stat bitstream (ItemStatCost.txt). */ /** Largest 1.13c stat id: stat ids are 9 bits wide in the stat bitstream (ItemStatCost.txt). */
const MAX_D2_STAT_ID = 0x1ff const MAX_D2_STAT_ID = 0x1ff
/** 1.13c crossbow item codes (normal, exceptional, elite). */
const CROSSBOW_ITEM_CODES = new Set([
'lxb', 'mxb', 'hxb', 'rxb',
'8lx', '8mx', '8hx', '8rx',
'6lx', '6mx', '6hx', '6rx',
])
/** 1.13c physical/weapon attack skill IDs that use attack animation mode (`A1`) instead of spellcast (`SC`). */ /** 1.13c physical/weapon attack skill IDs that use attack animation mode (`A1`) instead of spellcast (`SC`). */
const PHYSICAL_ATTACK_SKILL_IDS = new Set([ 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, 0, 2, 6, 7, 10, 11, 12, 16, 19, 20, 21, 22, 24, 26, 27, 31, 96, 97, 106, 107, 116, 126, 133, 139,
@ -529,7 +536,7 @@ export class ClientWorld {
const defaultMaxTicks = const defaultMaxTicks =
!loopAnim && data.vel === 0 !loopAnim && data.vel === 0
? Math.max(1, Math.ceil((totalFrames * 16) / animSpeed)) ? Math.max(1, Math.ceil((totalFrames * 16) / animSpeed))
: Math.max(1, (data.range || 20) + Math.max(0, level - 1) * (data.levRange ?? 0)) : Math.max(1, data.range + Math.max(0, level - 1) * (data.levRange ?? 0))
const maxTicks = opts?.maxTicks ?? defaultMaxTicks const maxTicks = opts?.maxTicks ?? defaultMaxTicks
const unit = new ClientUnit({ const unit = new ClientUnit({
@ -577,6 +584,35 @@ export class ClientWorld {
return unit return unit
} }
private isCrossbowEquipped(caster: ClientUnit): boolean {
if (caster.unitType === 'player') {
const isSelf = caster.id === this.selfId || this.selfId === 0
if (isSelf) {
const eqWeapon =
this._containers.equipmentBySlot.get(4) ?? this._containers.equipmentBySlot.get(5)
if (eqWeapon) {
const code = (eqWeapon.code ?? '').trim().toLowerCase()
if (CROSSBOW_ITEM_CODES.has(code)) return true
const rawName = (eqWeapon as any).name
if (typeof rawName === 'string') {
const name = rawName.toLowerCase()
if (
name.includes('crossbow') ||
name.includes('arbalest') ||
name.includes('ballista') ||
name.includes('chu-ko-nu')
) {
return true
}
}
}
}
const weaponType = (caster as any).equippedWeaponType ?? (caster as any).weaponItemType
if (typeof weaponType === 'string' && weaponType.toLowerCase() === 'crossbow') return true
}
return false
}
/** /**
* Spawns canonical 1.13c projectile/ground/radial/spiral/emitter missiles for a skill cast. * Spawns canonical 1.13c projectile/ground/radial/spiral/emitter missiles for a skill cast.
*/ */
@ -612,12 +648,17 @@ export class ClientWorld {
// 2. Multi-projectile fan spells: Charged Bolt (38), Teeth (67), Multiple Shot (12) // 2. Multi-projectile fan spells: Charged Bolt (38), Teeth (67), Multiple Shot (12)
if (skillId === 38 || skillId === 67 || skillId === 12) { if (skillId === 38 || skillId === 67 || skillId === 12) {
const missileKey = let missileKey =
skillId === 38 ? 'chargedbolt' : skillId === 67 ? 'teeth' : 'multipleshotarrow' skillId === 38 ? 'chargedbolt' : skillId === 67 ? 'teeth' : 'multipleshotarrow'
const count = let count = 2
skillId === 38 if (skillId === 38) {
? Math.min(24, 2 + Math.max(1, skillLevel)) count = Math.min(24, 2 + Math.max(1, skillLevel))
: Math.min(24, Math.max(2, 1 + Math.max(1, skillLevel))) } else if (skillId === 67) {
count = Math.min(24, 1 + Math.max(1, skillLevel))
} else if (skillId === 12) {
count = Math.min(24, 2 + Math.max(1, skillLevel))
missileKey = this.isCrossbowEquipped(caster) ? 'multipleshotbolt' : 'multipleshotarrow'
}
const totalSpread = Math.min(Math.PI * 0.65, 0.14 * Math.max(1, count - 1)) const totalSpread = Math.min(Math.PI * 0.65, 0.14 * Math.max(1, count - 1))
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const offset = count === 1 ? 0 : -totalSpread / 2 + (totalSpread * i) / (count - 1) const offset = count === 1 ? 0 : -totalSpread / 2 + (totalSpread * i) / (count - 1)
@ -844,8 +885,6 @@ export class ClientWorld {
spawnVisuals = true, spawnVisuals = true,
nowMs?: number, nowMs?: number,
): void { ): void {
unit.targetX = targetX
unit.targetY = targetY
unit.lastSkillId = skillId unit.lastSkillId = skillId
unit.moveMode = 'stop' unit.moveMode = 'stop'
// 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
@ -870,6 +909,18 @@ export class ClientWorld {
effRate = animParams.effRate effRate = animParams.effRate
stepSpeed256 = animParams.stepSpeed256 stepSpeed256 = animParams.stepSpeed256
durationMs = animParams.durationMs durationMs = animParams.durationMs
} else if (unit.unitType === 'monster') {
if (unit.totalFrames && unit.totalFrames > 0) {
frames = unit.totalFrames
}
if (unit.animStepSpeed256 && unit.animStepSpeed256 > 0) {
stepSpeed256 = unit.animStepSpeed256
baseSpeed = stepSpeed256
} else if (unit.animSpeed && unit.animSpeed > 0) {
baseSpeed = Math.round((unit.animSpeed / 16) * 256)
stepSpeed256 = baseSpeed
}
durationMs = computeOneShotDurationMs(frames, stepSpeed256)
} }
const castState: UnitCastState = { const castState: UnitCastState = {
@ -1217,7 +1268,7 @@ export class ClientWorld {
hitY: number, hitY: number,
isWallHit: boolean, isWallHit: boolean,
): void { ): void {
const expKey = let expKey =
missile.explosionMissile || missile.explosionMissile ||
(!isWallHit && (!isWallHit &&
(missile.missileKey === 'lightning' || (missile.missileKey === 'lightning' ||
@ -1227,6 +1278,12 @@ export class ClientWorld {
? 'lightninghit' ? 'lightninghit'
: '') : '')
if (expKey === 'freezingarrowexp1') {
expKey = 'freezeexplode'
} else if (expKey === 'freezingarrowexp2') {
expKey = 'glacialspikeejecta'
}
if (expKey) { if (expKey) {
const exp = this.spawnMissileByKey(expKey, hitX, hitY, hitX, hitY, { const exp = this.spawnMissileByKey(expKey, hitX, hitY, hitX, hitY, {
ownerType: missile.ownerType, ownerType: missile.ownerType,
@ -1592,9 +1649,8 @@ export class ClientWorld {
if (!Number.isFinite(dtMs)) { if (!Number.isFinite(dtMs)) {
throw new RangeError(`dtMs must be a finite number, got ${String(dtMs)}`) throw new RangeError(`dtMs must be a finite number, got ${String(dtMs)}`)
} }
const safeDt = Math.max(0, dtMs) const safeDt = Math.min(200, Math.max(0, dtMs))
const cappedDt = Math.min(200, safeDt) this._tickAccumulatorMs = Math.min(200, this._tickAccumulatorMs + safeDt)
this._tickAccumulatorMs += cappedDt
this._simTimeMs += safeDt this._simTimeMs += safeDt
this.expireCastStates(nowMs, safeDt) this.expireCastStates(nowMs, safeDt)
@ -2143,9 +2199,12 @@ export class ClientWorld {
unit.animStepSpeed256 = speedInfo.stepSpeed256 unit.animStepSpeed256 = speedInfo.stepSpeed256
unit.animEffRate = speedInfo.effRate unit.animEffRate = speedInfo.effRate
} else if (unit.unitType === 'monster' && resolvedMode === 3) { } else if (unit.unitType === 'monster' && resolvedMode === 3) {
unit.modeDurationMs = 240 const ghFrames = unit.totalFrames && unit.totalFrames > 0 ? unit.totalFrames : 8
unit.modeUntilMs = now + 240 const ghSpeed = unit.animStepSpeed256 && unit.animStepSpeed256 > 0 ? unit.animStepSpeed256 : 256
unit.animStepSpeed256 = undefined const ghDuration = computeOneShotDurationMs(ghFrames, ghSpeed)
unit.modeDurationMs = ghDuration
unit.modeUntilMs = now + ghDuration
unit.animStepSpeed256 = ghSpeed
unit.animEffRate = undefined unit.animEffRate = undefined
} else { } else {
unit.modeDurationMs = undefined unit.modeDurationMs = undefined
@ -2232,7 +2291,10 @@ export class ClientWorld {
// 0x73 CreateMissile: caster.unitType === 3 ('missile') // 0x73 CreateMissile: caster.unitType === 3 ('missile')
if (unitTypeFromId(casterType) === 'missile') { if (unitTypeFromId(casterType) === 'missile') {
const missileClassId = skillId const missileClassId = skillId
const missileKey = MISSILE_CLASS_ID_TO_KEY[missileClassId] ?? 'arrow' const missileKey = MISSILE_CLASS_ID_TO_KEY[missileClassId]
if (!missileKey) {
throw new Error(`Unknown missile classId ${missileClassId} in Missiles.txt`)
}
const ownerType = ev.owner ? unitTypeFromId(ev.owner.unitType) : 'player' const ownerType = ev.owner ? unitTypeFromId(ev.owner.unitType) : 'player'
const ownerId = ev.owner?.id ?? 0 const ownerId = ev.owner?.id ?? 0
const ownerUnit = ev.owner ? this.getUnit(ev.owner.unitType, ev.owner.id) : undefined const ownerUnit = ev.owner ? this.getUnit(ev.owner.unitType, ev.owner.id) : undefined
@ -2292,12 +2354,11 @@ export class ClientWorld {
(unit.id === this.selfId || this.selfId === 0) && (unit.id === this.selfId || this.selfId === 0) &&
skillId === this._lastLocalCastSkillId && skillId === this._lastLocalCastSkillId &&
timeSinceLocalCast >= 0 && timeSinceLocalCast >= 0 &&
timeSinceLocalCast <= 500 timeSinceLocalCast <= 1000
if (isSelfEcho) { if (isSelfEcho) {
this._lastLocalCastSkillId = -1 this._lastLocalCastSkillId = -1
if (unit.castState && unit.castState.skillId === skillId && now < unit.castState.untilMs) { if (unit.castState && unit.castState.skillId === skillId && now < unit.castState.untilMs) {
unit.targetX = targetX unit.faceToward(targetX, targetY, false)
unit.targetY = targetY
} else { } else {
this.applySkillCastToUnit(unit, skillId, targetX, targetY, level, false, nowMs) this.applySkillCastToUnit(unit, skillId, targetX, targetY, level, false, nowMs)
} }

File diff suppressed because it is too large Load Diff

View File

@ -177,7 +177,9 @@ export function computeToHitChance(params: {
const alvl = Math.max(1, params.attackerLvl) const alvl = Math.max(1, params.attackerLvl)
const dlvl = Math.max(1, params.defenderLvl) const dlvl = Math.max(1, params.defenderLvl)
const raw = Math.trunc((2 * effAr * 100 * alvl) / (Math.max(1, effAr + effDef) * (alvl + dlvl))) const divisor = effAr + effDef
const toHitFactor = divisor > 0 ? Math.trunc((100 * effAr) / divisor) : 100
const raw = Math.trunc((2 * alvl * toHitFactor) / (alvl + dlvl))
return Math.max(5, Math.min(95, raw)) return Math.max(5, Math.min(95, raw))
} }

View File

@ -879,7 +879,7 @@ describe('ClientWorld authoritative state mirror', () => {
) )
expect(subBolts.length).toBeGreaterThanOrEqual(4) expect(subBolts.length).toBeGreaterThanOrEqual(4)
// 6. Multiple Shot (skill 12, level 5 -> 6 multipleshotarrow missiles in a fan, classId=213) // 6. Multiple Shot (skill 12, level 5 -> 7 multipleshotarrow missiles in a fan per 1.13c calc1 min(24, 2 + slvl), classId=213)
const msWorld = new ClientWorld() const msWorld = new ClientWorld()
msWorld.apply({ msWorld.apply({
type: 'UnitAssign', type: 'UnitAssign',
@ -897,7 +897,7 @@ describe('ClientWorld authoritative state mirror', () => {
const multiShotArrows = Array.from(msWorld.units.values()).filter( const multiShotArrows = Array.from(msWorld.units.values()).filter(
u => u.unitType === 'missile' && u.missileKey === 'multipleshotarrow', u => u.unitType === 'missile' && u.missileKey === 'multipleshotarrow',
) )
expect(multiShotArrows).toHaveLength(6) expect(multiShotArrows).toHaveLength(7)
expect(multiShotArrows.every(m => m.classId === 213)).toBe(true) expect(multiShotArrows.every(m => m.classId === 213)).toBe(true)
// 7. Challenge 3: spawnMissileByKey('arrow') without opts.classId preserves classId === 0 // 7. Challenge 3: spawnMissileByKey('arrow') without opts.classId preserves classId === 0