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,6 +1141,12 @@ export class OnlineSession {
if (this.gameAdapter && this.gameAdapter.state !== 'closed') {
try {
this.gameAdapter.send(cmd)
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
this.handleUnexpectedDisconnect(`Failed to send command ${cmd.type}: ${msg}`, 'error', true)
return
}
}
this.world.noteOutgoingCommand(cmd, nowMs)
if (this.world.selfId !== 0) {
const selfUnit = this.world.getUnit('player', this.world.selfId)
@ -1148,14 +1154,6 @@ export class OnlineSession {
this.predictor.setTargetDirection(selfUnit.targetDirection, false)
}
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
this.handleUnexpectedDisconnect(`Failed to send command ${cmd.type}: ${msg}`, 'error', true)
return
}
} else {
this.world.noteOutgoingCommand(cmd, nowMs)
}
if (
cmd.type === 'SkillAt' ||
cmd.type === 'SkillOn' ||

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). */
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`). */
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,
@ -529,7 +536,7 @@ export class ClientWorld {
const defaultMaxTicks =
!loopAnim && data.vel === 0
? 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 unit = new ClientUnit({
@ -577,6 +584,35 @@ export class ClientWorld {
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.
*/
@ -612,12 +648,17 @@ export class ClientWorld {
// 2. Multi-projectile fan spells: Charged Bolt (38), Teeth (67), Multiple Shot (12)
if (skillId === 38 || skillId === 67 || skillId === 12) {
const missileKey =
let missileKey =
skillId === 38 ? 'chargedbolt' : skillId === 67 ? 'teeth' : 'multipleshotarrow'
const count =
skillId === 38
? Math.min(24, 2 + Math.max(1, skillLevel))
: Math.min(24, Math.max(2, 1 + Math.max(1, skillLevel)))
let count = 2
if (skillId === 38) {
count = Math.min(24, 2 + 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))
for (let i = 0; i < count; i++) {
const offset = count === 1 ? 0 : -totalSpread / 2 + (totalSpread * i) / (count - 1)
@ -844,8 +885,6 @@ export class ClientWorld {
spawnVisuals = true,
nowMs?: number,
): void {
unit.targetX = targetX
unit.targetY = targetY
unit.lastSkillId = skillId
unit.moveMode = 'stop'
// Turn toward the cast/attack target via D2Common.#10425 (bInstant = 0); 25Hz tick steps
@ -870,6 +909,18 @@ export class ClientWorld {
effRate = animParams.effRate
stepSpeed256 = animParams.stepSpeed256
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 = {
@ -1217,7 +1268,7 @@ export class ClientWorld {
hitY: number,
isWallHit: boolean,
): void {
const expKey =
let expKey =
missile.explosionMissile ||
(!isWallHit &&
(missile.missileKey === 'lightning' ||
@ -1227,6 +1278,12 @@ export class ClientWorld {
? 'lightninghit'
: '')
if (expKey === 'freezingarrowexp1') {
expKey = 'freezeexplode'
} else if (expKey === 'freezingarrowexp2') {
expKey = 'glacialspikeejecta'
}
if (expKey) {
const exp = this.spawnMissileByKey(expKey, hitX, hitY, hitX, hitY, {
ownerType: missile.ownerType,
@ -1592,9 +1649,8 @@ export class ClientWorld {
if (!Number.isFinite(dtMs)) {
throw new RangeError(`dtMs must be a finite number, got ${String(dtMs)}`)
}
const safeDt = Math.max(0, dtMs)
const cappedDt = Math.min(200, safeDt)
this._tickAccumulatorMs += cappedDt
const safeDt = Math.min(200, Math.max(0, dtMs))
this._tickAccumulatorMs = Math.min(200, this._tickAccumulatorMs + safeDt)
this._simTimeMs += safeDt
this.expireCastStates(nowMs, safeDt)
@ -2143,9 +2199,12 @@ export class ClientWorld {
unit.animStepSpeed256 = speedInfo.stepSpeed256
unit.animEffRate = speedInfo.effRate
} else if (unit.unitType === 'monster' && resolvedMode === 3) {
unit.modeDurationMs = 240
unit.modeUntilMs = now + 240
unit.animStepSpeed256 = undefined
const ghFrames = unit.totalFrames && unit.totalFrames > 0 ? unit.totalFrames : 8
const ghSpeed = unit.animStepSpeed256 && unit.animStepSpeed256 > 0 ? unit.animStepSpeed256 : 256
const ghDuration = computeOneShotDurationMs(ghFrames, ghSpeed)
unit.modeDurationMs = ghDuration
unit.modeUntilMs = now + ghDuration
unit.animStepSpeed256 = ghSpeed
unit.animEffRate = undefined
} else {
unit.modeDurationMs = undefined
@ -2232,7 +2291,10 @@ export class ClientWorld {
// 0x73 CreateMissile: caster.unitType === 3 ('missile')
if (unitTypeFromId(casterType) === 'missile') {
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 ownerId = ev.owner?.id ?? 0
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) &&
skillId === this._lastLocalCastSkillId &&
timeSinceLocalCast >= 0 &&
timeSinceLocalCast <= 500
timeSinceLocalCast <= 1000
if (isSelfEcho) {
this._lastLocalCastSkillId = -1
if (unit.castState && unit.castState.skillId === skillId && now < unit.castState.untilMs) {
unit.targetX = targetX
unit.targetY = targetY
unit.faceToward(targetX, targetY, false)
} else {
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 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))
}

View File

@ -879,7 +879,7 @@ describe('ClientWorld authoritative state mirror', () => {
)
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()
msWorld.apply({
type: 'UnitAssign',
@ -897,7 +897,7 @@ describe('ClientWorld authoritative state mirror', () => {
const multiShotArrows = Array.from(msWorld.units.values()).filter(
u => u.unitType === 'missile' && u.missileKey === 'multipleshotarrow',
)
expect(multiShotArrows).toHaveLength(6)
expect(multiShotArrows).toHaveLength(7)
expect(multiShotArrows.every(m => m.classId === 213)).toBe(true)
// 7. Challenge 3: spawnMissileByKey('arrow') without opts.classId preserves classId === 0