feat(skills): implement Sorceress Lightning (49) & Chain Lightning (53) with 1.13c parity (closes #203, closes #207)
- Implement 1.13c piecewise 5-band damage and +8%/+4% synergies for Lightning and Chain Lightning - Implement authentic missile kinematics, NextDelay 4, ping-pong retargeting excluding immediate victim, and infinite beam piercing - Wire casting overlays, 0 frame cooldown, and 1.13c mana formulas in missile-engine.ts, skills.ts, and act-scene.ts - Add comprehensive 7-dimensional verification test suites in tests/skills/sor/ (40/40 passing) - Full repo test suite passing: 4,140 passed, 0 failed, 0 typecheck errors
This commit is contained in:
parent
0e0cd3adee
commit
6fefc684ef
|
|
@ -113,6 +113,157 @@ export function calculateNovaDamage(slvl: number): { min: number; max: number }
|
||||||
return { min, max }
|
return { min, max }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentic 1.13c Lightning (Skill 49) synergy multiplier:
|
||||||
|
* +8% lightning damage per hard point in Charged Bolt (38), Nova (48), and Chain Lightning (53).
|
||||||
|
*/
|
||||||
|
export function calculateLightningSynergyMultiplier(synergies: {
|
||||||
|
readonly chargedBolt?: number | undefined
|
||||||
|
readonly chargedBoltPoints?: number | undefined
|
||||||
|
readonly nova?: number | undefined
|
||||||
|
readonly novaPoints?: number | undefined
|
||||||
|
readonly chainLightning?: number | undefined
|
||||||
|
readonly chainLightningPoints?: number | undefined
|
||||||
|
}): number {
|
||||||
|
const points =
|
||||||
|
(synergies.chargedBolt ?? synergies.chargedBoltPoints ?? 0) +
|
||||||
|
(synergies.nova ?? synergies.novaPoints ?? 0) +
|
||||||
|
(synergies.chainLightning ?? synergies.chainLightningPoints ?? 0)
|
||||||
|
return 1.0 + points * 0.08
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentic 1.13c Lightning (Skill 49) 5-band damage scaling (Skills.txt row 51):
|
||||||
|
* EMin: 1, EMinLev1..5: 0 (Min damage is always 1)
|
||||||
|
* EMax: 40, EMaxLev1..5: 8, 12, 20, 28, 36
|
||||||
|
* Synergy: +8% per hard point in Charged Bolt, Nova, Chain Lightning.
|
||||||
|
*/
|
||||||
|
export function calculateLightningDamage(
|
||||||
|
slvl: number,
|
||||||
|
synergies?: {
|
||||||
|
readonly chargedBolt?: number | undefined
|
||||||
|
readonly chargedBoltPoints?: number | undefined
|
||||||
|
readonly nova?: number | undefined
|
||||||
|
readonly novaPoints?: number | undefined
|
||||||
|
readonly chainLightning?: number | undefined
|
||||||
|
readonly chainLightningPoints?: number | undefined
|
||||||
|
} | number,
|
||||||
|
): { min: number; max: number } {
|
||||||
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
||||||
|
let min = 1
|
||||||
|
let max = 40
|
||||||
|
if (lvl > 1) {
|
||||||
|
const b1 = Math.min(lvl - 1, 7)
|
||||||
|
max += b1 * 8
|
||||||
|
if (lvl > 8) {
|
||||||
|
const b2 = Math.min(lvl - 8, 8)
|
||||||
|
max += b2 * 12
|
||||||
|
}
|
||||||
|
if (lvl > 16) {
|
||||||
|
const b3 = Math.min(lvl - 16, 6)
|
||||||
|
max += b3 * 20
|
||||||
|
}
|
||||||
|
if (lvl > 22) {
|
||||||
|
const b4 = Math.min(lvl - 22, 6)
|
||||||
|
max += b4 * 28
|
||||||
|
}
|
||||||
|
if (lvl > 28) {
|
||||||
|
const b5 = lvl - 28
|
||||||
|
max += b5 * 36
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const synMult =
|
||||||
|
typeof synergies === 'number'
|
||||||
|
? (Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0)
|
||||||
|
: synergies
|
||||||
|
? calculateLightningSynergyMultiplier(synergies)
|
||||||
|
: 1.0
|
||||||
|
|
||||||
|
min = Math.floor(min * synMult)
|
||||||
|
max = Math.floor(max * synMult)
|
||||||
|
return { min, max }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentic 1.13c Chain Lightning (Skill 53) synergy multiplier:
|
||||||
|
* +4% lightning damage per hard point in Charged Bolt (38), Nova (48), and Lightning (49).
|
||||||
|
*/
|
||||||
|
export function calculateChainLightningSynergyMultiplier(synergies: {
|
||||||
|
readonly chargedBolt?: number | undefined
|
||||||
|
readonly chargedBoltPoints?: number | undefined
|
||||||
|
readonly nova?: number | undefined
|
||||||
|
readonly novaPoints?: number | undefined
|
||||||
|
readonly lightning?: number | undefined
|
||||||
|
readonly lightningPoints?: number | undefined
|
||||||
|
}): number {
|
||||||
|
const points =
|
||||||
|
(synergies.chargedBolt ?? synergies.chargedBoltPoints ?? 0) +
|
||||||
|
(synergies.nova ?? synergies.novaPoints ?? 0) +
|
||||||
|
(synergies.lightning ?? synergies.lightningPoints ?? 0)
|
||||||
|
return 1.0 + points * 0.04
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentic 1.13c Chain Lightning (Skill 53) jumps calculation:
|
||||||
|
* 1.13c Skills.txt: Param5 = 5 (base jumps), calc1 = ln34 / 5 -> 5 + Math.floor(slvl / 5).
|
||||||
|
*/
|
||||||
|
export function calculateChainLightningJumps(slvl: number): number {
|
||||||
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
||||||
|
return 5 + Math.floor(lvl / 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authentic 1.13c Chain Lightning (Skill 53) 5-band damage scaling (Skills.txt row 55):
|
||||||
|
* EMin: 1, EMinLev1..5: 0 (Min damage is always 1)
|
||||||
|
* EMax: 40, EMaxLev1..5: 11, 13, 15, 15, 15
|
||||||
|
* Synergy: +4% per hard point in Charged Bolt, Nova, Lightning.
|
||||||
|
*/
|
||||||
|
export function calculateChainLightningDamage(
|
||||||
|
slvl: number,
|
||||||
|
synergies?: {
|
||||||
|
readonly chargedBolt?: number | undefined
|
||||||
|
readonly nova?: number | undefined
|
||||||
|
readonly lightning?: number | undefined
|
||||||
|
} | number,
|
||||||
|
): { min: number; max: number } {
|
||||||
|
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
||||||
|
let min = 1
|
||||||
|
let max = 40
|
||||||
|
if (lvl > 1) {
|
||||||
|
const b1 = Math.min(lvl - 1, 7)
|
||||||
|
max += b1 * 11
|
||||||
|
if (lvl > 8) {
|
||||||
|
const b2 = Math.min(lvl - 8, 8)
|
||||||
|
max += b2 * 13
|
||||||
|
}
|
||||||
|
if (lvl > 16) {
|
||||||
|
const b3 = Math.min(lvl - 16, 6)
|
||||||
|
max += b3 * 15
|
||||||
|
}
|
||||||
|
if (lvl > 22) {
|
||||||
|
const b4 = Math.min(lvl - 22, 6)
|
||||||
|
max += b4 * 15
|
||||||
|
}
|
||||||
|
if (lvl > 28) {
|
||||||
|
const b5 = lvl - 28
|
||||||
|
max += b5 * 15
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const synMult =
|
||||||
|
typeof synergies === 'number'
|
||||||
|
? (Number.isFinite(synergies) && synergies > 0 ? synergies : 1.0)
|
||||||
|
: synergies
|
||||||
|
? calculateChainLightningSynergyMultiplier(synergies)
|
||||||
|
: 1.0
|
||||||
|
|
||||||
|
min = Math.floor(min * synMult)
|
||||||
|
max = Math.floor(max * synMult)
|
||||||
|
return { min, max }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export function hasLineOfSight(
|
export function hasLineOfSight(
|
||||||
x0: number,
|
x0: number,
|
||||||
y0: number,
|
y0: number,
|
||||||
|
|
@ -192,6 +343,7 @@ export interface ActiveMissile {
|
||||||
readonly hitTargetIds: Set<string>
|
readonly hitTargetIds: Set<string>
|
||||||
readonly dmgPacket: SUnitDmgPacket
|
readonly dmgPacket: SUnitDmgPacket
|
||||||
chainRemaining: number
|
chainRemaining: number
|
||||||
|
lastHitTargetId?: string | undefined
|
||||||
expired: boolean
|
expired: boolean
|
||||||
subMissileIdx?: number
|
subMissileIdx?: number
|
||||||
currentHitFrame?: number
|
currentHitFrame?: number
|
||||||
|
|
@ -244,6 +396,8 @@ export class MissileEngine {
|
||||||
dmgPacket: SUnitDmgPacket
|
dmgPacket: SUnitDmgPacket
|
||||||
pierceChancePct?: number
|
pierceChancePct?: number
|
||||||
chainCount?: number
|
chainCount?: number
|
||||||
|
chainRemaining?: number
|
||||||
|
lastHitTargetId?: string
|
||||||
isTurret?: boolean
|
isTurret?: boolean
|
||||||
fireCadenceTicks?: number
|
fireCadenceTicks?: number
|
||||||
spawnMissileType?: string
|
spawnMissileType?: string
|
||||||
|
|
@ -433,7 +587,8 @@ export class MissileEngine {
|
||||||
pierceCount: 0,
|
pierceCount: 0,
|
||||||
hitTargetIds: new Set<string>(),
|
hitTargetIds: new Set<string>(),
|
||||||
dmgPacket: params.dmgPacket,
|
dmgPacket: params.dmgPacket,
|
||||||
chainRemaining: params.chainCount ?? (effectiveRec.pSrvHitFunc === 12 ? 5 + Math.trunc(params.slvl / 5) : 0),
|
chainRemaining: params.chainRemaining ?? params.chainCount ?? (effectiveRec.pSrvHitFunc === 12 ? 5 + Math.trunc(params.slvl / 5) : 0),
|
||||||
|
lastHitTargetId: params.lastHitTargetId,
|
||||||
expired: false,
|
expired: false,
|
||||||
isTurret,
|
isTurret,
|
||||||
...(params.altitude !== undefined ? { altitude: params.altitude } : {}),
|
...(params.altitude !== undefined ? { altitude: params.altitude } : {}),
|
||||||
|
|
@ -723,9 +878,21 @@ export class MissileEngine {
|
||||||
}
|
}
|
||||||
|
|
||||||
// `pSrvHitFunc = 12` (`Chain Lightning` 53, `Lightning Strike` 34, `royalstrikechainlightning` 568):
|
// `pSrvHitFunc = 12` (`Chain Lightning` 53, `Lightning Strike` 34, `royalstrikechainlightning` 568):
|
||||||
// Jump to next unhit target within range!
|
// Authentic 1.13c ping-pong chaining: excludes the target just hit (target.id),
|
||||||
|
// searches up to 20 subtiles (10 yards = 400 px), and leaps to the closest valid alive enemy.
|
||||||
if (m.record.pSrvHitFunc === 12 && m.chainRemaining > 0) {
|
if (m.record.pSrvHitFunc === 12 && m.chainRemaining > 0) {
|
||||||
const nextTarget = targets.find(t => t.id !== target.id && t.statList.getHp256() > 0 && !m.hitTargetIds.has(t.id))
|
const searchRadiusPx = 400
|
||||||
|
let nextTarget: CombatUnitContext | undefined
|
||||||
|
let minDistance = Infinity
|
||||||
|
for (const cand of targets) {
|
||||||
|
if (cand.id === target.id || cand.statList.getHp256() <= 0) continue
|
||||||
|
const candPos = targetPositions.get(cand.id) ?? { x: 0, y: 120 }
|
||||||
|
const dist = Math.hypot(candPos.x - pos.x, candPos.y - pos.y)
|
||||||
|
if (dist <= searchRadiusPx && dist < minDistance) {
|
||||||
|
minDistance = dist
|
||||||
|
nextTarget = cand
|
||||||
|
}
|
||||||
|
}
|
||||||
if (nextTarget) {
|
if (nextTarget) {
|
||||||
const nPos = targetPositions.get(nextTarget.id) ?? { x: 0, y: 120 }
|
const nPos = targetPositions.get(nextTarget.id) ?? { x: 0, y: 120 }
|
||||||
this.spawnMissile({
|
this.spawnMissile({
|
||||||
|
|
@ -739,6 +906,7 @@ export class MissileEngine {
|
||||||
targetY: nPos.y,
|
targetY: nPos.y,
|
||||||
dmgPacket: m.dmgPacket,
|
dmgPacket: m.dmgPacket,
|
||||||
chainCount: m.chainRemaining - 1,
|
chainCount: m.chainRemaining - 1,
|
||||||
|
lastHitTargetId: target.id,
|
||||||
})
|
})
|
||||||
subMissilesSpawned.push(m.name)
|
subMissilesSpawned.push(m.name)
|
||||||
}
|
}
|
||||||
|
|
@ -786,9 +954,15 @@ export class MissileEngine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pierce evaluation (1.13c cap: max 4 pierces = 5 total targets hit)
|
// Pierce evaluation (1.13c cap: max 4 pierces = 5 total targets hit, EXCEPT infinite piercing missiles like Lightning)
|
||||||
|
const isInfinitePierce =
|
||||||
|
m.name === 'lightningbolt' ||
|
||||||
|
m.name === 'lightning' ||
|
||||||
|
(!m.record.collideKill && m.canPierce)
|
||||||
if (m.name === 'meteorfire') {
|
if (m.name === 'meteorfire') {
|
||||||
// Ground fire persists for its full duration and does not die on hit
|
// Ground fire persists for its full duration and does not die on hit
|
||||||
|
} else if (isInfinitePierce) {
|
||||||
|
m.pierceCount += 1
|
||||||
} else if (m.canPierce && m.pierceCount < 4 && m.pierceChancePct > 0) {
|
} else if (m.canPierce && m.pierceCount < 4 && m.pierceChancePct > 0) {
|
||||||
m.pierceCount += 1
|
m.pierceCount += 1
|
||||||
} else if (m.record.collideKill || !m.canPierce) {
|
} else if (m.record.collideKill || !m.canPierce) {
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,11 @@ import {
|
||||||
hasLineOfSight,
|
hasLineOfSight,
|
||||||
spawnNovaRing,
|
spawnNovaRing,
|
||||||
executeStaticField,
|
executeStaticField,
|
||||||
|
calculateLightningDamage,
|
||||||
|
calculateLightningSynergyMultiplier,
|
||||||
|
calculateChainLightningDamage,
|
||||||
|
calculateChainLightningSynergyMultiplier,
|
||||||
|
calculateChainLightningJumps,
|
||||||
type StaticFieldResult,
|
type StaticFieldResult,
|
||||||
type StaticFieldTarget,
|
type StaticFieldTarget,
|
||||||
} from './engine/missile-engine.ts'
|
} from './engine/missile-engine.ts'
|
||||||
|
|
@ -46,6 +51,11 @@ export {
|
||||||
hasLineOfSight,
|
hasLineOfSight,
|
||||||
spawnNovaRing,
|
spawnNovaRing,
|
||||||
executeStaticField,
|
executeStaticField,
|
||||||
|
calculateLightningDamage,
|
||||||
|
calculateLightningSynergyMultiplier,
|
||||||
|
calculateChainLightningDamage,
|
||||||
|
calculateChainLightningSynergyMultiplier,
|
||||||
|
calculateChainLightningJumps,
|
||||||
type StaticFieldResult,
|
type StaticFieldResult,
|
||||||
type StaticFieldTarget,
|
type StaticFieldTarget,
|
||||||
} from './engine/missile-engine.ts'
|
} from './engine/missile-engine.ts'
|
||||||
|
|
@ -163,6 +173,10 @@ export interface Projectile {
|
||||||
/** Authentic 1.13c NextHit flag and NextDelay immunity window in frames. */
|
/** Authentic 1.13c NextHit flag and NextDelay immunity window in frames. */
|
||||||
readonly nextHit?: boolean | number | undefined
|
readonly nextHit?: boolean | number | undefined
|
||||||
readonly nextDelay?: number | undefined
|
readonly nextDelay?: number | undefined
|
||||||
|
/** Number of jumps remaining for chaining projectiles (e.g. Chain Lightning). */
|
||||||
|
readonly chainRemaining?: number | undefined
|
||||||
|
/** Previous target index hit by this chain projectile to avoid immediately retargeting it. */
|
||||||
|
readonly lastHitTargetIndex?: number | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -230,6 +244,71 @@ export interface MissileTxtData {
|
||||||
|
|
||||||
/** Canonical 1.13c Ground Truth Missiles.txt defaults. */
|
/** Canonical 1.13c Ground Truth Missiles.txt defaults. */
|
||||||
export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> = Object.freeze({
|
export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> = Object.freeze({
|
||||||
|
lightningbolt: {
|
||||||
|
name: 'lightningbolt',
|
||||||
|
id: 98,
|
||||||
|
vel: 30,
|
||||||
|
maxVel: 30,
|
||||||
|
range: 25,
|
||||||
|
levRange: 0,
|
||||||
|
celFile: 'LightningStrike',
|
||||||
|
animLen: 16,
|
||||||
|
animSpeed: 16,
|
||||||
|
loopAnim: 2,
|
||||||
|
numDirections: 1,
|
||||||
|
explosionMissile: '',
|
||||||
|
light: 0,
|
||||||
|
red: 255,
|
||||||
|
green: 255,
|
||||||
|
blue: 255,
|
||||||
|
trans: 1,
|
||||||
|
speedPxPerSec: 750,
|
||||||
|
distancePx: 750,
|
||||||
|
},
|
||||||
|
lightning: {
|
||||||
|
name: 'lightning',
|
||||||
|
id: 98,
|
||||||
|
vel: 30,
|
||||||
|
maxVel: 30,
|
||||||
|
range: 25,
|
||||||
|
levRange: 0,
|
||||||
|
celFile: 'LightningStrike',
|
||||||
|
animLen: 16,
|
||||||
|
animSpeed: 16,
|
||||||
|
loopAnim: 2,
|
||||||
|
numDirections: 1,
|
||||||
|
explosionMissile: '',
|
||||||
|
light: 0,
|
||||||
|
red: 255,
|
||||||
|
green: 255,
|
||||||
|
blue: 255,
|
||||||
|
trans: 1,
|
||||||
|
speedPxPerSec: 750,
|
||||||
|
distancePx: 750,
|
||||||
|
},
|
||||||
|
chainlightning: {
|
||||||
|
name: 'chainlightning',
|
||||||
|
id: 93,
|
||||||
|
vel: 30,
|
||||||
|
maxVel: 30,
|
||||||
|
range: 25,
|
||||||
|
levRange: 0,
|
||||||
|
celFile: 'LightningStrike',
|
||||||
|
animLen: 16,
|
||||||
|
animSpeed: 16,
|
||||||
|
loopAnim: 1,
|
||||||
|
numDirections: 32,
|
||||||
|
explosionMissile: '',
|
||||||
|
light: 0,
|
||||||
|
red: 255,
|
||||||
|
green: 255,
|
||||||
|
blue: 255,
|
||||||
|
nextHit: 1,
|
||||||
|
nextDelay: 4,
|
||||||
|
trans: 1,
|
||||||
|
speedPxPerSec: 750,
|
||||||
|
distancePx: 750,
|
||||||
|
},
|
||||||
nova: {
|
nova: {
|
||||||
name: 'nova',
|
name: 'nova',
|
||||||
id: 90,
|
id: 90,
|
||||||
|
|
@ -4180,6 +4259,55 @@ export function tickProjectiles(
|
||||||
}
|
}
|
||||||
|
|
||||||
hitTargets.add(target.index)
|
hitTargets.add(target.index)
|
||||||
|
|
||||||
|
// Authentic 1.13c Chain Lightning retargeting / jump
|
||||||
|
const isChainLightning =
|
||||||
|
projectile.missileType === 'chainlightning' ||
|
||||||
|
(projectile.chainRemaining !== undefined && projectile.chainRemaining > 0)
|
||||||
|
if (isChainLightning) {
|
||||||
|
const jumpsLeft = projectile.chainRemaining !== undefined ? projectile.chainRemaining : 0
|
||||||
|
if (jumpsLeft > 0) {
|
||||||
|
// Search radius: 20 subtiles (10 yards = 400 px)
|
||||||
|
const searchRadiusPx = 400
|
||||||
|
let closestCand: ProjectileTarget | null = null
|
||||||
|
let closestDist = Infinity
|
||||||
|
for (const cand of targets) {
|
||||||
|
if (!cand.alive || cand.index === target.index) continue
|
||||||
|
// Authentic ping-pong: only exclude immediate prior victim (target.index)
|
||||||
|
const d = Math.hypot(cand.x - target.x, cand.y - target.y)
|
||||||
|
if (d <= searchRadiusPx && d < closestDist) {
|
||||||
|
closestDist = d
|
||||||
|
closestCand = cand
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (closestCand) {
|
||||||
|
const dx = closestCand.x - target.x
|
||||||
|
const dy = closestCand.y - target.y
|
||||||
|
const len = Math.hypot(dx, dy)
|
||||||
|
const speed = 30 // 1.13c Missiles.txt vel = 30 (750 px/s)
|
||||||
|
const pVx = len > 0 ? (dx / len) * speed : speed
|
||||||
|
const pVy = len > 0 ? (dy / len) * speed : 0
|
||||||
|
spawned.push({
|
||||||
|
skillId: projectile.skillId,
|
||||||
|
x: target.x,
|
||||||
|
y: target.y,
|
||||||
|
vx: pVx,
|
||||||
|
vy: pVy,
|
||||||
|
damage: projectile.damage,
|
||||||
|
ttl: 25,
|
||||||
|
fromPlayer: projectile.fromPlayer,
|
||||||
|
missileType: 'chainlightning',
|
||||||
|
chainRemaining: jumpsLeft - 1,
|
||||||
|
lastHitTargetIndex: target.index,
|
||||||
|
nextHit: 1,
|
||||||
|
nextDelay: 4,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
struck = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
if (!projectile.pierce) {
|
if (!projectile.pierce) {
|
||||||
struck = true
|
struck = true
|
||||||
if (isBlizzardShard(projectile.missileType)) {
|
if (isBlizzardShard(projectile.missileType)) {
|
||||||
|
|
@ -4355,6 +4483,16 @@ export function getSkillManaCost(skillId: number, effectiveLevel: number): numbe
|
||||||
// 1.13c Skills.txt: mana = 9, lvlmana = 0, manashift = 8
|
// 1.13c Skills.txt: mana = 9, lvlmana = 0, manashift = 8
|
||||||
return 9
|
return 9
|
||||||
}
|
}
|
||||||
|
if (skillId === 49) {
|
||||||
|
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
||||||
|
// 1.13c Skills.txt: mana = 16, lvlmana = 1, manashift = 7 (factor = 0.5)
|
||||||
|
return (16 + (lvl - 1)) * 0.5
|
||||||
|
}
|
||||||
|
if (skillId === 53) {
|
||||||
|
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
||||||
|
// 1.13c Skills.txt: mana = 9, lvlmana = 1, manashift = 8 (factor = 1.0)
|
||||||
|
return 9 + (lvl - 1)
|
||||||
|
}
|
||||||
if (skillId === 56) {
|
if (skillId === 56) {
|
||||||
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
||||||
// 1.13c Skills.txt: mana = 34, lvlmana = 1, manashift = 7 (factor = 0.5)
|
// 1.13c Skills.txt: mana = 34, lvlmana = 1, manashift = 7 (factor = 0.5)
|
||||||
|
|
@ -4396,7 +4534,7 @@ export function getSkillManaCost(skillId: number, effectiveLevel: number): numbe
|
||||||
* - All other Batch 1 skills: 0 frames
|
* - All other Batch 1 skills: 0 frames
|
||||||
*/
|
*/
|
||||||
export function getSkillCooldownTicks(skillId: number): number {
|
export function getSkillCooldownTicks(skillId: number): number {
|
||||||
if (skillId === 48 || skillId === 42) {
|
if (skillId === 48 || skillId === 42 || skillId === 49 || skillId === 53) {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
if (skillId === 56) {
|
if (skillId === 56) {
|
||||||
|
|
@ -4444,6 +4582,12 @@ export function calculateSkillDamage(
|
||||||
if (skillId === 42 || skillId === '42' || skillId === 'staticfield' || skillId === 'static field') {
|
if (skillId === 42 || skillId === '42' || skillId === 'staticfield' || skillId === 'static field') {
|
||||||
return { min: 0, max: 0 }
|
return { min: 0, max: 0 }
|
||||||
}
|
}
|
||||||
|
if (skillId === 49 || skillId === '49' || skillId === 'lightning') {
|
||||||
|
return calculateLightningDamage(slvl, synergies as any)
|
||||||
|
}
|
||||||
|
if (skillId === 53 || skillId === '53' || skillId === 'chainlightning' || skillId === 'chain lightning') {
|
||||||
|
return calculateChainLightningDamage(slvl, synergies as any)
|
||||||
|
}
|
||||||
if (skillId === 59 || skillId === '59' || skillId === 'blizzard') {
|
if (skillId === 59 || skillId === '59' || skillId === 'blizzard') {
|
||||||
return calculateBlizzardDamage(slvl, synergies)
|
return calculateBlizzardDamage(slvl, synergies)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ import { buildNpcDef, hasPackedSprite } from '../game/npc.ts'
|
||||||
import { ADDITIVE_OBJECT_TOKENS } from '../game/objects.ts'
|
import { ADDITIVE_OBJECT_TOKENS } from '../game/objects.ts'
|
||||||
import { npcDialog } from '../game/quests.ts'
|
import { npcDialog } from '../game/quests.ts'
|
||||||
import type { NpcDef } from '../game/quests.ts'
|
import type { NpcDef } from '../game/quests.ts'
|
||||||
import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, calculateSkillDamage, calculateMeteorResidualFireDamage, getMissileTxtData, ISO_GROUND_ASPECT_RATIO, getSkillCastOverlay, isAuraSkill, calculateStaticFieldRadiusPx, calculateStaticFieldDamage, hasLineOfSight } from '../game/skills.ts'
|
import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, calculateSkillDamage, calculateMeteorResidualFireDamage, getMissileTxtData, ISO_GROUND_ASPECT_RATIO, getSkillCastOverlay, isAuraSkill, calculateStaticFieldRadiusPx, calculateStaticFieldDamage, hasLineOfSight, calculateChainLightningJumps, getSkillCooldownTicks } from '../game/skills.ts'
|
||||||
import type { Projectile, ActiveOverlay } from '../game/skills.ts'
|
import type { Projectile, ActiveOverlay } from '../game/skills.ts'
|
||||||
import { findSafeDropPosition, calculateBounceHeight, triggerFlippyBounce, type GroundItemEntity } from '../game/ground-items.ts'
|
import { findSafeDropPosition, calculateBounceHeight, triggerFlippyBounce, type GroundItemEntity } from '../game/ground-items.ts'
|
||||||
import {
|
import {
|
||||||
|
|
@ -1454,6 +1454,9 @@ export function castSkill(
|
||||||
let multiCount = 1
|
let multiCount = 1
|
||||||
let spreadAngle = 0
|
let spreadAngle = 0
|
||||||
let isNova = false
|
let isNova = false
|
||||||
|
let chainRemaining: number | undefined = undefined
|
||||||
|
let nextHit: boolean | number | undefined = undefined
|
||||||
|
let nextDelay: number | undefined = undefined
|
||||||
|
|
||||||
if (skillId === 36) {
|
if (skillId === 36) {
|
||||||
const missile = getMissileTxtData('firebolt')
|
const missile = getMissileTxtData('firebolt')
|
||||||
|
|
@ -1662,11 +1665,29 @@ export function castSkill(
|
||||||
missileType = 'frozenorb'
|
missileType = 'frozenorb'
|
||||||
skillName = '冰封球'
|
skillName = '冰封球'
|
||||||
} else if (skillId === 49) {
|
} else if (skillId === 49) {
|
||||||
speed = 600
|
const missile = getMissileTxtData('lightningbolt')
|
||||||
range = 500
|
speed = missile.speedPxPerSec || 750
|
||||||
damage = 30
|
range = missile.distancePx || 750
|
||||||
|
const slvl = (hudManager as any)?.getSkillLevel?.(49) ?? 1
|
||||||
|
const dmgRange = calculateSkillDamage(49, slvl)
|
||||||
|
damage = Math.round((dmgRange.min + dmgRange.max) / 2)
|
||||||
delay = 0
|
delay = 0
|
||||||
|
pierce = true
|
||||||
|
missileType = 'lightningbolt'
|
||||||
skillName = '闪电'
|
skillName = '闪电'
|
||||||
|
} else if (skillId === 53) {
|
||||||
|
const missile = getMissileTxtData('chainlightning')
|
||||||
|
speed = missile.speedPxPerSec || 750
|
||||||
|
range = missile.distancePx || 750
|
||||||
|
const slvl = (hudManager as any)?.getSkillLevel?.(53) ?? 1
|
||||||
|
const dmgRange = calculateSkillDamage(53, slvl)
|
||||||
|
damage = Math.round((dmgRange.min + dmgRange.max) / 2)
|
||||||
|
delay = 0
|
||||||
|
missileType = 'chainlightning'
|
||||||
|
chainRemaining = calculateChainLightningJumps(slvl)
|
||||||
|
nextHit = 1
|
||||||
|
nextDelay = 4
|
||||||
|
skillName = '连锁闪电'
|
||||||
} else if (skillId === 56) {
|
} else if (skillId === 56) {
|
||||||
speed = 300
|
speed = 300
|
||||||
range = 450
|
range = 450
|
||||||
|
|
@ -1767,10 +1788,14 @@ export function castSkill(
|
||||||
spiral,
|
spiral,
|
||||||
jitter,
|
jitter,
|
||||||
isUndeadOnly,
|
isUndeadOnly,
|
||||||
|
chainRemaining,
|
||||||
|
nextHit,
|
||||||
|
nextDelay,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
player.cooldown = delay > 0 ? delay : 10
|
const cdTicks = getSkillCooldownTicks(skillId)
|
||||||
|
player.cooldown = cdTicks > 0 ? cdTicks : (delay > 0 ? delay : (skillId === 48 || skillId === 49 || skillId === 53 || skillId === 42 ? 0 : 10))
|
||||||
engine.metrics.casts += 1
|
engine.metrics.casts += 1
|
||||||
status.textContent = `施展技能:${skillName}`
|
status.textContent = `施展技能:${skillName}`
|
||||||
return true
|
return true
|
||||||
|
|
@ -3803,6 +3828,11 @@ export async function loadMissileArtMap(
|
||||||
if (map.has('blizzardexplode2') && !map.has('blizzardexplode3')) {
|
if (map.has('blizzardexplode2') && !map.has('blizzardexplode3')) {
|
||||||
map.set('blizzardexplode3', map.get('blizzardexplode2')!)
|
map.set('blizzardexplode3', map.get('blizzardexplode2')!)
|
||||||
}
|
}
|
||||||
|
if (map.has('lightningstrike')) {
|
||||||
|
const ltArt = map.get('lightningstrike')!
|
||||||
|
if (!map.has('lightningbolt')) map.set('lightningbolt', ltArt)
|
||||||
|
if (!map.has('lightning')) map.set('lightning', ltArt)
|
||||||
|
}
|
||||||
return map
|
return map
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,109 +1,543 @@
|
||||||
/**
|
/**
|
||||||
* 1.13c Numerical & Runtime Verification Suite — Skill #049: Lightning (SOR)
|
* 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #049: Lightning (SOR)
|
||||||
* Gitea Tracking Issue: #203
|
* Gitea Tracking Issue: #203
|
||||||
|
*
|
||||||
|
* 7-Dimensional Verification Coverage:
|
||||||
|
* D1: 1.13c Data Table Parsing & Canonical Missile Definition (Skills.txt row 49, Missiles.txt 'lightningbolt' / ID 98)
|
||||||
|
* D2: Visual Assets & Metadata Integrity (lightningstrike.png/json, light_cast_1.png/json, Additive Blending)
|
||||||
|
* D3: Beam Kinematics & Infinite Piercing (Speed 30 / 750 px/s, pierces all monsters along trajectory)
|
||||||
|
* D4: Obstacle & Terrain Collision Clipping (8px continuous sub-stepping wall collision terminates beam)
|
||||||
|
* D5: 1.13c 5-Band Piecewise Damage Scaling & +8% Synergies (EMin: 1, EMax: 40, +8/+12/+20/+28/+36, synergies 38/48/53)
|
||||||
|
* D6: Lifecycle, Range 25 Frames, Mana Cost Formula (8 + (slvl - 1) * 0.5, 0 cooldown)
|
||||||
|
* D7: WorldArena & Runtime Cast Integration (Cast action frame, mana cost, 0 cooldown, overlay attachment)
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
|
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
|
||||||
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
||||||
|
import { StateBus } from '../../../src/game/engine/state-bus.ts'
|
||||||
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
||||||
import { skillModule } from '../../../src/game/skills/impl/sor/skill-049-lightning.ts'
|
import { skillModule } from '../../../src/game/skills/impl/sor/skill-049-lightning.ts'
|
||||||
|
import {
|
||||||
|
parseMissilesTxt,
|
||||||
|
getMissileTxtData,
|
||||||
|
CANONICAL_113C_MISSILES,
|
||||||
|
getSkillManaCost,
|
||||||
|
getSkillCooldownTicks,
|
||||||
|
getSkillCastOverlay,
|
||||||
|
SORCERESS_SKILL_CAST_OVERLAYS,
|
||||||
|
calculateLightningDamage,
|
||||||
|
calculateLightningSynergyMultiplier,
|
||||||
|
calculateSkillDamage,
|
||||||
|
tickProjectiles,
|
||||||
|
type Projectile,
|
||||||
|
} from '../../../src/game/skills.ts'
|
||||||
|
import {
|
||||||
|
MissileEngine,
|
||||||
|
hasLineOfSight,
|
||||||
|
} from '../../../src/game/engine/missile-engine.ts'
|
||||||
|
import {
|
||||||
|
LIGHTNINGSTRIKE_META,
|
||||||
|
MISSILE_METAS,
|
||||||
|
} from '../../../src/render/missiles-meta.ts'
|
||||||
|
import {
|
||||||
|
LIGHT_CAST_1_META,
|
||||||
|
OVERLAY_METAS,
|
||||||
|
} from '../../../src/render/overlays-meta.ts'
|
||||||
|
|
||||||
describe('[Skill #049] Lightning (SOR) — 1.13c Parity (Issue #203)', () => {
|
describe('[Skill #049] Lightning (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #203)', () => {
|
||||||
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
|
const missilesTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Missiles.txt')
|
||||||
const registry = await getSharedDataRegistry()
|
const missilesTxtContent = fs.readFileSync(missilesTxtPath, 'latin1')
|
||||||
const skillRec = registry.getSkillById(49)!
|
const skillsTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Skills.txt')
|
||||||
expect(skillRec).toBeDefined()
|
const skillsTxtContent = fs.readFileSync(skillsTxtPath, 'latin1')
|
||||||
expect(skillModule.skillId).toBe(49)
|
|
||||||
expect(skillModule.name).toBe("Lightning")
|
|
||||||
expect(skillModule.charClass).toBe("sor")
|
|
||||||
|
|
||||||
const stats1 = new UnitStatList()
|
// =========================================================================
|
||||||
stats1.setBaseSkillLevel(49, 1)
|
// Dimension 1: 1.13c Data Table Parsing & Canonical Missile Definition
|
||||||
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
|
// =========================================================================
|
||||||
expect(r1.manaCost).toBe(8)
|
describe('Dimension 1: 1.13c Data Table Parsing & Canonical Missile Definition', () => {
|
||||||
expect(r1.manaCost256).toBe(2048)
|
it('parses authentic Skills.txt row 49 (Lightning)', () => {
|
||||||
expect(r1.minPhysDmg).toBe(0)
|
const lines = skillsTxtContent.split('\n')
|
||||||
expect(r1.maxPhysDmg).toBe(0)
|
const headers = lines[0]!.split('\t')
|
||||||
expect(r1.minElemDmg256).toBe(256)
|
const idIdx = headers.indexOf('Id')
|
||||||
expect(r1.maxElemDmg256).toBe(10240)
|
const skillIdx = headers.indexOf('skill')
|
||||||
|
const charClassIdx = headers.indexOf('charclass')
|
||||||
|
const manaIdx = headers.indexOf('mana')
|
||||||
|
const lvlManaIdx = headers.indexOf('lvlmana')
|
||||||
|
const manaShiftIdx = headers.indexOf('manashift')
|
||||||
|
const castOverlayIdx = headers.indexOf('castoverlay')
|
||||||
|
const srvMissileIdx = headers.indexOf('srvmissile')
|
||||||
|
const eMinIdx = headers.indexOf('EMin')
|
||||||
|
const eMaxIdx = headers.indexOf('EMax')
|
||||||
|
const eMaxLev1Idx = headers.indexOf('EMaxLev1')
|
||||||
|
const eMaxLev2Idx = headers.indexOf('EMaxLev2')
|
||||||
|
const eMaxLev3Idx = headers.indexOf('EMaxLev3')
|
||||||
|
const eMaxLev4Idx = headers.indexOf('EMaxLev4')
|
||||||
|
const eMaxLev5Idx = headers.indexOf('EMaxLev5')
|
||||||
|
const param8Idx = headers.indexOf('Param8') !== -1 ? headers.indexOf('Param8') : headers.indexOf('param8')
|
||||||
|
|
||||||
const stats10 = new UnitStatList()
|
const row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx]?.trim() === '49')
|
||||||
stats10.setBaseSkillLevel(49, 10)
|
expect(row).toBeDefined()
|
||||||
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
|
expect(row![skillIdx]?.trim()).toBe('Lightning')
|
||||||
expect(r10.manaCost).toBe(12)
|
expect(row![charClassIdx]?.trim()).toBe('sor')
|
||||||
expect(r10.manaCost256).toBe(3200)
|
expect(Number(row![manaIdx])).toBe(16)
|
||||||
expect(r10.minPhysDmg).toBe(0)
|
expect(Number(row![lvlManaIdx])).toBe(1)
|
||||||
expect(r10.maxPhysDmg).toBe(0)
|
expect(Number(row![manaShiftIdx])).toBe(7)
|
||||||
expect(r10.minElemDmg256).toBe(256)
|
expect(row![castOverlayIdx]?.trim()).toBe('light_cast_1')
|
||||||
expect(r10.maxElemDmg256).toBe(30720)
|
expect(row![srvMissileIdx]?.trim()).toBe('lightningbolt')
|
||||||
|
expect(Number(row![eMinIdx])).toBe(1)
|
||||||
|
expect(Number(row![eMaxIdx])).toBe(40)
|
||||||
|
expect(Number(row![eMaxLev1Idx])).toBe(8)
|
||||||
|
expect(Number(row![eMaxLev2Idx])).toBe(12)
|
||||||
|
expect(Number(row![eMaxLev3Idx])).toBe(20)
|
||||||
|
expect(Number(row![eMaxLev4Idx])).toBe(28)
|
||||||
|
expect(Number(row![eMaxLev5Idx])).toBe(36)
|
||||||
|
expect(Number(row![param8Idx])).toBe(8)
|
||||||
|
})
|
||||||
|
|
||||||
const stats20 = new UnitStatList()
|
it('parses authentic Missiles.txt row for lightningbolt (ID 98)', () => {
|
||||||
stats20.setBaseSkillLevel(49, 20)
|
const map = parseMissilesTxt(missilesTxtContent)
|
||||||
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
|
const bolt = map.get('lightningbolt')
|
||||||
expect(r20.manaCost).toBe(17)
|
expect(bolt).toBeDefined()
|
||||||
expect(r20.manaCost256).toBe(4480)
|
expect(bolt!.id).toBe(98)
|
||||||
expect(r20.minPhysDmg).toBe(0)
|
expect(bolt!.vel).toBe(30)
|
||||||
expect(r20.maxPhysDmg).toBe(0)
|
expect(bolt!.range).toBe(25)
|
||||||
expect(r20.minElemDmg256).toBe(256)
|
expect(bolt!.speedPxPerSec).toBe(750) // 30 * 25
|
||||||
expect(r20.maxElemDmg256).toBe(69632)
|
expect(bolt!.distancePx).toBe(750) // 30 * 25
|
||||||
expect(r20.durationFrames).toBe(0)
|
expect(bolt!.trans).toBe(1)
|
||||||
expect(r20.radiusSubtiles).toBe(390)
|
})
|
||||||
expect(r20.petMax).toBe(0)
|
|
||||||
expect(r20.cooldownFrames).toBe(0)
|
it('CANONICAL_113C_MISSILES contains exact lightningbolt and lightning definitions', () => {
|
||||||
|
const bolt = getMissileTxtData('lightningbolt')
|
||||||
|
expect(bolt.id).toBe(98)
|
||||||
|
expect(bolt.vel).toBe(30)
|
||||||
|
expect(bolt.range).toBe(25)
|
||||||
|
expect(bolt.speedPxPerSec).toBe(750)
|
||||||
|
expect(bolt.distancePx).toBe(750)
|
||||||
|
expect(bolt.trans).toBe(1)
|
||||||
|
|
||||||
|
const lt = getMissileTxtData('lightning')
|
||||||
|
expect(lt.id).toBe(98)
|
||||||
|
expect(lt.vel).toBe(30)
|
||||||
|
expect(lt.range).toBe(25)
|
||||||
|
expect(lt.speedPxPerSec).toBe(750)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('enforces 1.13c blvl synergy rule (hard points boost synergy; +skills and item charges do NOT)', async () => {
|
// =========================================================================
|
||||||
const registry = await getSharedDataRegistry()
|
// Dimension 2: Visual Assets & Metadata Integrity
|
||||||
const skillRec = registry.getSkillById(49)!
|
// =========================================================================
|
||||||
const synId = 38
|
describe('Dimension 2: Visual Assets & Metadata Integrity', () => {
|
||||||
|
it('verifies baked DCC atlas sprite sheet files exist with non-zero size', () => {
|
||||||
|
const missilePng = path.resolve(__dirname, '../../../public/missiles/lightningstrike.png')
|
||||||
|
const missileJson = path.resolve(__dirname, '../../../public/missiles/lightningstrike.json')
|
||||||
|
const overlayPng = path.resolve(__dirname, '../../../public/overlays/light_cast_1.png')
|
||||||
|
const overlayJson = path.resolve(__dirname, '../../../public/overlays/light_cast_1.json')
|
||||||
|
|
||||||
const statsBase = new UnitStatList()
|
expect(fs.existsSync(missilePng)).toBe(true)
|
||||||
statsBase.setBaseSkillLevel(49, 20)
|
expect(fs.statSync(missilePng).size).toBeGreaterThan(10000)
|
||||||
if (synId !== null) {
|
expect(fs.existsSync(missileJson)).toBe(true)
|
||||||
statsBase.setBaseSkillLevel(synId, 20)
|
expect(fs.existsSync(overlayPng)).toBe(true)
|
||||||
}
|
expect(fs.statSync(overlayPng).size).toBeGreaterThan(10000)
|
||||||
const withHardPoints = skillModule.evaluate!({
|
expect(fs.existsSync(overlayJson)).toBe(true)
|
||||||
registry,
|
|
||||||
skill: skillRec,
|
|
||||||
slvl: 20,
|
|
||||||
blvl: 20,
|
|
||||||
statList: statsBase,
|
|
||||||
})
|
})
|
||||||
expect(withHardPoints.synergyBonusPct).toBe(160)
|
|
||||||
|
|
||||||
// +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy
|
it('verifies LIGHTNINGSTRIKE_META metadata and registry registration', () => {
|
||||||
const statsSoftOnly = new UnitStatList()
|
expect(LIGHTNINGSTRIKE_META.name).toBe('lightningstrike')
|
||||||
statsSoftOnly.setBaseSkillLevel(49, 20)
|
expect(LIGHTNINGSTRIKE_META.directions).toBe(32)
|
||||||
statsSoftOnly.addStat('item_allskills', 20)
|
expect(MISSILE_METAS['lightningstrike']).toBe(LIGHTNINGSTRIKE_META)
|
||||||
if (synId !== null) {
|
})
|
||||||
statsSoftOnly.setChargedSkillLevel(synId, 33)
|
|
||||||
}
|
it('verifies cast overlay mapping and metadata', () => {
|
||||||
const withSoftOnly = skillModule.evaluate!({
|
expect(SORCERESS_SKILL_CAST_OVERLAYS[49]).toBe('light_cast_1')
|
||||||
registry,
|
expect(getSkillCastOverlay(49)).toBe('light_cast_1')
|
||||||
skill: skillRec,
|
expect(LIGHT_CAST_1_META.name).toBe('light_cast_1')
|
||||||
slvl: 20,
|
expect(OVERLAY_METAS['light_cast_1']).toBe(LIGHT_CAST_1_META)
|
||||||
blvl: 20,
|
|
||||||
statList: statsSoftOnly,
|
|
||||||
})
|
})
|
||||||
expect(withSoftOnly.synergyBonusPct).toBe(0)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('executes runtime skill effect in WorldArena (castCount, action frame, and combat/state/missile/summon outcome)', async () => {
|
// =========================================================================
|
||||||
const registry = await getSharedDataRegistry()
|
// Dimension 3: Beam Kinematics & Infinite Piercing
|
||||||
const arena = new WorldArena({
|
// =========================================================================
|
||||||
registry,
|
describe('Dimension 3: Beam Kinematics & Infinite Piercing', () => {
|
||||||
classCode: "sor",
|
it('missile advances at speed 30 px/tick (750 px/s) over 25 ticks', () => {
|
||||||
skillId: 49,
|
const missile = getMissileTxtData('lightningbolt')
|
||||||
slvl: 20,
|
const perTick = missile.speedPxPerSec / 25
|
||||||
|
expect(perTick).toBe(30)
|
||||||
|
expect(missile.range).toBe(25)
|
||||||
|
expect(missile.distancePx).toBe(750)
|
||||||
})
|
})
|
||||||
const outcome = arena.triggerCast()
|
|
||||||
arena.stepTicks(15)
|
|
||||||
const dbg = arena.getDebugState()
|
|
||||||
|
|
||||||
expect(dbg.ready).toBe(true)
|
it('infinite piercing: strikes 6 lined-up enemies without dying or being stopped by the 4-pierce cap', () => {
|
||||||
expect(dbg.skillId).toBe(49)
|
// 6 enemies lined up horizontally along the beam's trajectory (y = 0)
|
||||||
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
|
const enemies = [
|
||||||
expect(dbg.actionFrameTriggered).toBe(true)
|
{ index: 1, x: 50, y: 0, radius: 15, alive: true },
|
||||||
expect(outcome?.executed).toBe(true)
|
{ index: 2, x: 100, y: 0, radius: 15, alive: true },
|
||||||
|
{ index: 3, x: 150, y: 0, radius: 15, alive: true },
|
||||||
|
{ index: 4, x: 200, y: 0, radius: 15, alive: true },
|
||||||
|
{ index: 5, x: 250, y: 0, radius: 15, alive: true },
|
||||||
|
{ index: 6, x: 300, y: 0, radius: 15, alive: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
let projectiles: readonly Projectile[] = [
|
||||||
|
{
|
||||||
|
skillId: '49',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
vx: 30,
|
||||||
|
vy: 0,
|
||||||
|
damage: 100,
|
||||||
|
ttl: 25,
|
||||||
|
fromPlayer: true,
|
||||||
|
missileType: 'lightningbolt',
|
||||||
|
pierce: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const openTerrain = { overlap: () => 0 }
|
||||||
|
const allHits: number[] = []
|
||||||
|
|
||||||
|
// Step 12 ticks: missile covers 12 * 30 = 360 px, passing through all 6 enemies
|
||||||
|
for (let t = 1; t <= 12; t++) {
|
||||||
|
const outcome = tickProjectiles(projectiles, enemies, openTerrain)
|
||||||
|
projectiles = outcome.alive
|
||||||
|
for (const h of outcome.hits) {
|
||||||
|
allHits.push(h.targetIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All 6 enemies must be struck exactly once!
|
||||||
|
expect(allHits).toEqual([1, 2, 3, 4, 5, 6])
|
||||||
|
// The missile must still be alive in flight!
|
||||||
|
expect(projectiles.length).toBe(1)
|
||||||
|
expect(projectiles[0]!.x).toBe(360)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('MissileEngine simulates infinite piercing across 6 targets', async () => {
|
||||||
|
const registry = await getSharedDataRegistry()
|
||||||
|
const engine = new MissileEngine(registry)
|
||||||
|
const ownerStats = new UnitStatList()
|
||||||
|
const owner = {
|
||||||
|
id: 'player',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
statList: ownerStats,
|
||||||
|
stateBus: new StateBus(ownerStats),
|
||||||
|
} as any
|
||||||
|
|
||||||
|
const m = engine.spawnMissile({
|
||||||
|
missileNameOrId: 'lightningbolt',
|
||||||
|
sourceSkillId: 49,
|
||||||
|
slvl: 20,
|
||||||
|
owner,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
targetX: 600,
|
||||||
|
targetY: 0,
|
||||||
|
dmgPacket: { minDamage: 100, maxDamage: 100, damageType: 'lightning' } as any,
|
||||||
|
vx: 30,
|
||||||
|
vy: 0,
|
||||||
|
maxRangeTicks: 25,
|
||||||
|
})
|
||||||
|
expect(m).not.toBeNull()
|
||||||
|
|
||||||
|
const targets = ['m1', 'm2', 'm3', 'm4', 'm5', 'm6'].map((id) => {
|
||||||
|
const statList = new UnitStatList()
|
||||||
|
statList.setHp256(1000 * 256)
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
statList,
|
||||||
|
stateBus: new StateBus(statList),
|
||||||
|
} as any
|
||||||
|
})
|
||||||
|
|
||||||
|
const targetPositions = new Map<string, { x: number; y: number }>([
|
||||||
|
['m1', { x: 50, y: 0 }],
|
||||||
|
['m2', { x: 100, y: 0 }],
|
||||||
|
['m3', { x: 150, y: 0 }],
|
||||||
|
['m4', { x: 200, y: 0 }],
|
||||||
|
['m5', { x: 250, y: 0 }],
|
||||||
|
['m6', { x: 300, y: 0 }],
|
||||||
|
])
|
||||||
|
|
||||||
|
const allHitIds: string[] = []
|
||||||
|
for (let tick = 1; tick <= 12; tick++) {
|
||||||
|
const step = engine.tick(tick, targets, targetPositions)
|
||||||
|
for (const h of step.hits) {
|
||||||
|
allHitIds.push(h.targetId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(allHitIds).toEqual(['m1', 'm2', 'm3', 'm4', 'm5', 'm6'])
|
||||||
|
const active = engine.getActiveMissiles()
|
||||||
|
expect(active.length).toBe(1)
|
||||||
|
expect(active[0]!.pierceCount).toBe(6)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Dimension 4: Obstacle & Terrain Collision Clipping
|
||||||
|
// =========================================================================
|
||||||
|
describe('Dimension 4: Obstacle & Terrain Collision Clipping', () => {
|
||||||
|
it('expires missile when colliding with a solid wall (x >= 80)', () => {
|
||||||
|
let projectiles: readonly Projectile[] = [
|
||||||
|
{
|
||||||
|
skillId: '49',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
vx: 30,
|
||||||
|
vy: 0,
|
||||||
|
damage: 100,
|
||||||
|
ttl: 25,
|
||||||
|
fromPlayer: true,
|
||||||
|
missileType: 'lightningbolt',
|
||||||
|
pierce: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const wallTerrain = { overlap: (x: number) => (x >= 80 ? 1 : 0) }
|
||||||
|
|
||||||
|
// Tick 1: moves to x=30 (open)
|
||||||
|
const step1 = tickProjectiles(projectiles, [], wallTerrain)
|
||||||
|
expect(step1.alive.length).toBe(1)
|
||||||
|
expect(step1.wallHits).toBe(0)
|
||||||
|
|
||||||
|
// Tick 2: moves to x=60 (open)
|
||||||
|
const step2 = tickProjectiles(step1.alive, [], wallTerrain)
|
||||||
|
expect(step2.alive.length).toBe(1)
|
||||||
|
expect(step2.wallHits).toBe(0)
|
||||||
|
|
||||||
|
// Tick 3: moves to x=90 (wall at x>=80 hit!)
|
||||||
|
const step3 = tickProjectiles(step2.alive, [], wallTerrain)
|
||||||
|
expect(step3.alive.length).toBe(0)
|
||||||
|
expect(step3.wallHits).toBe(1)
|
||||||
|
expect(step3.wallHitEvents![0]!.x).toBe(90)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sub-stepping collision detects thin obstacle between steps', () => {
|
||||||
|
// Sub-stepping checks 8px intervals. Thin wall from x=35 to x=45.
|
||||||
|
// Notice endpoints x=30 and x=60 are both outside the wall; only sub-step x=37.5 strikes it!
|
||||||
|
const thinWallTerrain = {
|
||||||
|
isMissileBlocked: (x: number) => x >= 35 && x <= 45,
|
||||||
|
}
|
||||||
|
let projectiles: readonly Projectile[] = [
|
||||||
|
{
|
||||||
|
skillId: '49',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
vx: 30,
|
||||||
|
vy: 0,
|
||||||
|
damage: 100,
|
||||||
|
ttl: 25,
|
||||||
|
fromPlayer: true,
|
||||||
|
missileType: 'lightningbolt',
|
||||||
|
pierce: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
// Step 1: x from 0 to 30 (clear)
|
||||||
|
const step1 = tickProjectiles(projectiles, [], {
|
||||||
|
overlap: () => 0,
|
||||||
|
isMissileBlocked: thinWallTerrain.isMissileBlocked,
|
||||||
|
})
|
||||||
|
expect(step1.alive.length).toBe(1)
|
||||||
|
|
||||||
|
// Step 2: x from 30 to 60 (crosses 38..42 -> blocked!)
|
||||||
|
const step2 = tickProjectiles(step1.alive, [], {
|
||||||
|
overlap: () => 0,
|
||||||
|
isMissileBlocked: thinWallTerrain.isMissileBlocked,
|
||||||
|
})
|
||||||
|
expect(step2.alive.length).toBe(0)
|
||||||
|
expect(step2.wallHits).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hasLineOfSight returns true in open space and false across walls', () => {
|
||||||
|
const terrain = { overlap: (x: number) => (x >= 50 && x <= 60 ? 1 : 0) }
|
||||||
|
expect(hasLineOfSight(0, 0, 40, 0, terrain)).toBe(true)
|
||||||
|
expect(hasLineOfSight(0, 0, 100, 0, terrain)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Dimension 5: Piecewise 5-Band Damage Scaling & Synergy Verification
|
||||||
|
// =========================================================================
|
||||||
|
describe('Dimension 5: Piecewise 5-Band Damage Scaling & Synergy Verification', () => {
|
||||||
|
it('calculates exact 1.13c 5-band damage without synergies at slvl 1, 2, 8, 9, 10, 16, 20, 28, 30', () => {
|
||||||
|
// Level 1: 1-40
|
||||||
|
const d1 = calculateLightningDamage(1)
|
||||||
|
expect(d1.min).toBe(1)
|
||||||
|
expect(d1.max).toBe(40)
|
||||||
|
|
||||||
|
// Level 2: 1 to 40 + 8 = 48
|
||||||
|
const d2 = calculateLightningDamage(2)
|
||||||
|
expect(d2.min).toBe(1)
|
||||||
|
expect(d2.max).toBe(48)
|
||||||
|
|
||||||
|
// Level 8: 1 to 40 + 7 * 8 = 96
|
||||||
|
const d8 = calculateLightningDamage(8)
|
||||||
|
expect(d8.min).toBe(1)
|
||||||
|
expect(d8.max).toBe(96)
|
||||||
|
|
||||||
|
// Level 9: 1 to 96 + 12 = 108
|
||||||
|
const d9 = calculateLightningDamage(9)
|
||||||
|
expect(d9.min).toBe(1)
|
||||||
|
expect(d9.max).toBe(108)
|
||||||
|
|
||||||
|
// Level 10: 1 to 96 + 2 * 12 = 120
|
||||||
|
const d10 = calculateLightningDamage(10)
|
||||||
|
expect(d10.min).toBe(1)
|
||||||
|
expect(d10.max).toBe(120)
|
||||||
|
|
||||||
|
// Level 16: 1 to 96 + 8 * 12 = 192
|
||||||
|
const d16 = calculateLightningDamage(16)
|
||||||
|
expect(d16.min).toBe(1)
|
||||||
|
expect(d16.max).toBe(192)
|
||||||
|
|
||||||
|
// Level 20: 1 to 192 + 4 * 20 = 272
|
||||||
|
const d20 = calculateLightningDamage(20)
|
||||||
|
expect(d20.min).toBe(1)
|
||||||
|
expect(d20.max).toBe(272)
|
||||||
|
|
||||||
|
// Level 28: 1 to 192 + 6 * 20 + 6 * 28 = 480
|
||||||
|
const d28 = calculateLightningDamage(28)
|
||||||
|
expect(d28.min).toBe(1)
|
||||||
|
expect(d28.max).toBe(480)
|
||||||
|
|
||||||
|
// Level 30: 1 to 480 + 2 * 36 = 552
|
||||||
|
const d30 = calculateLightningDamage(30)
|
||||||
|
expect(d30.min).toBe(1)
|
||||||
|
expect(d30.max).toBe(552)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calculates exact +8% synergies per hard point in Charged Bolt, Nova, Chain Lightning', () => {
|
||||||
|
// 10 pts Charged Bolt (+80%), 5 pts Nova (+40%), 5 pts Chain Lightning (+40%) = +160% (mult = 2.6)
|
||||||
|
const mult = calculateLightningSynergyMultiplier({ chargedBolt: 10, nova: 5, chainLightning: 5 })
|
||||||
|
expect(mult).toBeCloseTo(2.6, 4)
|
||||||
|
|
||||||
|
// slvl 20 base = 1-272. With +160% synergy: min = floor(1 * 2.6) = 2, max = floor(272 * 2.6) = 707
|
||||||
|
const d20Syn = calculateLightningDamage(20, { chargedBolt: 10, nova: 5, chainLightning: 5 })
|
||||||
|
expect(d20Syn.min).toBe(2)
|
||||||
|
expect(d20Syn.max).toBe(707)
|
||||||
|
|
||||||
|
// calculateSkillDamage routes 49 and 'lightning' identically
|
||||||
|
const viaCalc1 = calculateSkillDamage(49, 20, { chargedBolt: 10, nova: 5, chainLightning: 5 } as any)
|
||||||
|
expect(viaCalc1.min).toBe(2)
|
||||||
|
expect(viaCalc1.max).toBe(707)
|
||||||
|
|
||||||
|
const viaCalc2 = calculateSkillDamage('lightning', 20, { chargedBolt: 10, nova: 5, chainLightning: 5 } as any)
|
||||||
|
expect(viaCalc2.min).toBe(2)
|
||||||
|
expect(viaCalc2.max).toBe(707)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('evaluates exact 1.13c Skills.txt formulas in skillModule.evaluate', async () => {
|
||||||
|
const registry = await getSharedDataRegistry()
|
||||||
|
const skillRec = registry.getSkillById(49)!
|
||||||
|
|
||||||
|
const stats1 = new UnitStatList()
|
||||||
|
stats1.setBaseSkillLevel(49, 1)
|
||||||
|
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
|
||||||
|
expect(r1.manaCost).toBe(8)
|
||||||
|
expect(r1.manaCost256).toBe(2048)
|
||||||
|
expect(r1.minElemDmg256).toBe(256) // 1 * 256
|
||||||
|
expect(r1.maxElemDmg256).toBe(10240) // 40 * 256
|
||||||
|
|
||||||
|
const stats10 = new UnitStatList()
|
||||||
|
stats10.setBaseSkillLevel(49, 10)
|
||||||
|
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
|
||||||
|
expect(r10.manaCost).toBe(12)
|
||||||
|
expect(r10.minElemDmg256).toBe(256) // 1 * 256
|
||||||
|
expect(r10.maxElemDmg256).toBe(30720) // 120 * 256
|
||||||
|
|
||||||
|
const stats20 = new UnitStatList()
|
||||||
|
stats20.setBaseSkillLevel(49, 20)
|
||||||
|
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
|
||||||
|
expect(r20.manaCost).toBe(17)
|
||||||
|
expect(r20.minElemDmg256).toBe(256)
|
||||||
|
expect(r20.maxElemDmg256).toBe(69632) // 272 * 256
|
||||||
|
expect(r20.cooldownFrames).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Dimension 6: Lifecycle, Range 25 Frames, Mana Cost Formula
|
||||||
|
// =========================================================================
|
||||||
|
describe('Dimension 6: Lifecycle, Range 25 Frames, Mana Cost Formula', () => {
|
||||||
|
it('projectile lives exactly 25 ticks in open space and expires on tick 25', () => {
|
||||||
|
let projectiles: readonly Projectile[] = [
|
||||||
|
{
|
||||||
|
skillId: '49',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
vx: 30,
|
||||||
|
vy: 0,
|
||||||
|
damage: 100,
|
||||||
|
ttl: 25,
|
||||||
|
fromPlayer: true,
|
||||||
|
missileType: 'lightningbolt',
|
||||||
|
pierce: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const openTerrain = { overlap: () => 0 }
|
||||||
|
|
||||||
|
for (let t = 1; t <= 25; t++) {
|
||||||
|
const outcome = tickProjectiles(projectiles, [], openTerrain)
|
||||||
|
expect(outcome.alive.length).toBe(1)
|
||||||
|
expect(outcome.expired).toBe(0)
|
||||||
|
projectiles = outcome.alive
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tick 26: expires after completing full 25 ticks of flight
|
||||||
|
const outcome26 = tickProjectiles(projectiles, [], openTerrain)
|
||||||
|
expect(outcome26.alive.length).toBe(0)
|
||||||
|
expect(outcome26.expired).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verifies getSkillManaCost formula: 8 + (slvl - 1) * 0.5', () => {
|
||||||
|
expect(getSkillManaCost(49, 1)).toBe(8)
|
||||||
|
expect(getSkillManaCost(49, 2)).toBe(8.5)
|
||||||
|
expect(getSkillManaCost(49, 3)).toBe(9)
|
||||||
|
expect(getSkillManaCost(49, 10)).toBe(12.5)
|
||||||
|
expect(getSkillManaCost(49, 20)).toBe(17.5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verifies getSkillCooldownTicks returns 0 frames', () => {
|
||||||
|
expect(getSkillCooldownTicks(49)).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Dimension 7: WorldArena & Runtime Cast Integration
|
||||||
|
// =========================================================================
|
||||||
|
describe('Dimension 7: WorldArena & Runtime Cast Integration', () => {
|
||||||
|
it('executes runtime skill effect in WorldArena (castCount, action frame, and combat outcome)', async () => {
|
||||||
|
const registry = await getSharedDataRegistry()
|
||||||
|
const arena = new WorldArena({
|
||||||
|
registry,
|
||||||
|
classCode: 'sor',
|
||||||
|
skillId: 49,
|
||||||
|
slvl: 20,
|
||||||
|
})
|
||||||
|
const outcome = arena.triggerCast()
|
||||||
|
arena.stepTicks(15)
|
||||||
|
const dbg = arena.getDebugState()
|
||||||
|
|
||||||
|
expect(dbg.ready).toBe(true)
|
||||||
|
expect(dbg.skillId).toBe(49)
|
||||||
|
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(dbg.actionFrameTriggered).toBe(true)
|
||||||
|
expect(outcome?.executed).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verifies skillModule definition and metadata for Lightning (49)', () => {
|
||||||
|
expect(skillModule.skillId).toBe(49)
|
||||||
|
expect(skillModule.name).toBe('Lightning')
|
||||||
|
expect(skillModule.charClass).toBe('sor')
|
||||||
|
expect(skillModule.srvStFunc).toBe(0)
|
||||||
|
expect(skillModule.srvDoFunc).toBe(0)
|
||||||
|
expect(getSkillCastOverlay(49)).toBe('light_cast_1')
|
||||||
|
expect(SORCERESS_SKILL_CAST_OVERLAYS[49]).toBe('light_cast_1')
|
||||||
|
expect(getSkillCooldownTicks(49)).toBe(0)
|
||||||
|
expect(getSkillManaCost(49, 1)).toBe(8)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,109 +1,651 @@
|
||||||
/**
|
/**
|
||||||
* 1.13c Numerical & Runtime Verification Suite — Skill #053: Chain Lightning (SOR)
|
* Diablo II: Lord of Destruction v1.13c — Comprehensive 7-Dimensional Verification Suite
|
||||||
|
* Skill #053: Chain Lightning (SOR)
|
||||||
|
*
|
||||||
* Gitea Tracking Issue: #207
|
* Gitea Tracking Issue: #207
|
||||||
|
*
|
||||||
|
* 7 Verification Dimensions:
|
||||||
|
* - Dimension 1: 1.13c Data Table Parsing & Canonical Missile Definition
|
||||||
|
* - Dimension 2: Visual Assets & Metadata Integrity
|
||||||
|
* - Dimension 3: Seeking Kinematics & Jump Formula
|
||||||
|
* - Dimension 4: Ping-Pong Retargeting, Target Exclusion & NextDelay = 4
|
||||||
|
* - Dimension 5: Piecewise 5-Band Damage Scaling & Synergy Verification
|
||||||
|
* - Dimension 6: Lifecycle, Range 25 Frames, Mana Cost Formula
|
||||||
|
* - Dimension 7: WorldArena & Runtime Cast Integration
|
||||||
*/
|
*/
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
|
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
|
||||||
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
||||||
|
import { StateBus } from '../../../src/game/engine/state-bus.ts'
|
||||||
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
||||||
import { skillModule } from '../../../src/game/skills/impl/sor/skill-053-chain-lightning.ts'
|
import { skillModule } from '../../../src/game/skills/impl/sor/skill-053-chain-lightning.ts'
|
||||||
|
import {
|
||||||
|
parseMissilesTxt,
|
||||||
|
getMissileTxtData,
|
||||||
|
CANONICAL_113C_MISSILES,
|
||||||
|
getSkillManaCost,
|
||||||
|
getSkillCooldownTicks,
|
||||||
|
getSkillCastOverlay,
|
||||||
|
SORCERESS_SKILL_CAST_OVERLAYS,
|
||||||
|
calculateChainLightningDamage,
|
||||||
|
calculateChainLightningSynergyMultiplier,
|
||||||
|
calculateChainLightningJumps,
|
||||||
|
calculateSkillDamage,
|
||||||
|
tickProjectiles,
|
||||||
|
type Projectile,
|
||||||
|
type ProjectileTarget,
|
||||||
|
} from '../../../src/game/skills.ts'
|
||||||
|
import {
|
||||||
|
MissileEngine,
|
||||||
|
hasLineOfSight,
|
||||||
|
} from '../../../src/game/engine/missile-engine.ts'
|
||||||
|
import {
|
||||||
|
CHAINLIGHTNING_META,
|
||||||
|
MISSILE_METAS,
|
||||||
|
} from '../../../src/render/missiles-meta.ts'
|
||||||
|
import {
|
||||||
|
LIGHT_CAST_1_META,
|
||||||
|
OVERLAY_METAS,
|
||||||
|
} from '../../../src/render/overlays-meta.ts'
|
||||||
|
|
||||||
describe('[Skill #053] Chain Lightning (SOR) — 1.13c Parity (Issue #207)', () => {
|
describe('[Skill #053] Chain Lightning (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #207)', () => {
|
||||||
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
|
const missilesTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Missiles.txt')
|
||||||
const registry = await getSharedDataRegistry()
|
const missilesTxtContent = fs.readFileSync(missilesTxtPath, 'latin1')
|
||||||
const skillRec = registry.getSkillById(53)!
|
const skillsTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Skills.txt')
|
||||||
expect(skillRec).toBeDefined()
|
const skillsTxtContent = fs.readFileSync(skillsTxtPath, 'latin1')
|
||||||
expect(skillModule.skillId).toBe(53)
|
|
||||||
expect(skillModule.name).toBe("Chain Lightning")
|
|
||||||
expect(skillModule.charClass).toBe("sor")
|
|
||||||
|
|
||||||
const stats1 = new UnitStatList()
|
// =========================================================================
|
||||||
stats1.setBaseSkillLevel(53, 1)
|
// Dimension 1: 1.13c Data Table Parsing & Canonical Missile Definition
|
||||||
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
|
// =========================================================================
|
||||||
expect(r1.manaCost).toBe(9)
|
describe('Dimension 1: 1.13c Data Table Parsing & Canonical Missile Definition', () => {
|
||||||
expect(r1.manaCost256).toBe(2304)
|
it('parses authentic Skills.txt row 53 (Chain Lightning)', () => {
|
||||||
expect(r1.minPhysDmg).toBe(0)
|
const lines = skillsTxtContent.split('\n')
|
||||||
expect(r1.maxPhysDmg).toBe(0)
|
const headers = lines[0]!.split('\t')
|
||||||
expect(r1.minElemDmg256).toBe(256)
|
const idIdx = headers.indexOf('Id')
|
||||||
expect(r1.maxElemDmg256).toBe(10240)
|
const skillIdx = headers.indexOf('skill')
|
||||||
|
const charClassIdx = headers.indexOf('charclass')
|
||||||
|
const manaIdx = headers.indexOf('mana')
|
||||||
|
const lvlManaIdx = headers.indexOf('lvlmana')
|
||||||
|
const manaShiftIdx = headers.indexOf('manashift')
|
||||||
|
const castOverlayIdx = headers.indexOf('castoverlay')
|
||||||
|
const srvMissileAIdx = headers.indexOf('srvmissilea')
|
||||||
|
const eMinIdx = headers.indexOf('EMin')
|
||||||
|
const eMaxIdx = headers.indexOf('EMax')
|
||||||
|
const eMaxLev1Idx = headers.indexOf('EMaxLev1')
|
||||||
|
const eMaxLev2Idx = headers.indexOf('EMaxLev2')
|
||||||
|
const eMaxLev3Idx = headers.indexOf('EMaxLev3')
|
||||||
|
const eMaxLev4Idx = headers.indexOf('EMaxLev4')
|
||||||
|
const eMaxLev5Idx = headers.indexOf('EMaxLev5')
|
||||||
|
const param8Idx = headers.indexOf('Param8') !== -1 ? headers.indexOf('Param8') : headers.indexOf('param8')
|
||||||
|
|
||||||
const stats10 = new UnitStatList()
|
const row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx]?.trim() === '53')
|
||||||
stats10.setBaseSkillLevel(53, 10)
|
expect(row).toBeDefined()
|
||||||
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
|
expect(row![skillIdx]?.trim()).toBe('Chain Lightning')
|
||||||
expect(r10.manaCost).toBe(18)
|
expect(row![charClassIdx]?.trim()).toBe('sor')
|
||||||
expect(r10.manaCost256).toBe(4608)
|
expect(Number(row![manaIdx])).toBe(9)
|
||||||
expect(r10.minPhysDmg).toBe(0)
|
expect(Number(row![lvlManaIdx])).toBe(1)
|
||||||
expect(r10.maxPhysDmg).toBe(0)
|
expect(Number(row![manaShiftIdx])).toBe(8)
|
||||||
expect(r10.minElemDmg256).toBe(256)
|
expect(row![castOverlayIdx]?.trim()).toBe('light_cast_1')
|
||||||
expect(r10.maxElemDmg256).toBe(36608)
|
expect(row![srvMissileAIdx]?.trim()).toBe('chainlightning')
|
||||||
|
expect(Number(row![eMinIdx])).toBe(1)
|
||||||
|
expect(Number(row![eMaxIdx])).toBe(40)
|
||||||
|
expect(Number(row![eMaxLev1Idx])).toBe(11)
|
||||||
|
expect(Number(row![eMaxLev2Idx])).toBe(13)
|
||||||
|
expect(Number(row![eMaxLev3Idx])).toBe(15)
|
||||||
|
expect(Number(row![eMaxLev4Idx])).toBe(15)
|
||||||
|
expect(Number(row![eMaxLev5Idx])).toBe(15)
|
||||||
|
expect(Number(row![param8Idx])).toBe(4)
|
||||||
|
})
|
||||||
|
|
||||||
const stats20 = new UnitStatList()
|
it('parses authentic Missiles.txt row for chainlightning (ID 93)', () => {
|
||||||
stats20.setBaseSkillLevel(53, 20)
|
const map = parseMissilesTxt(missilesTxtContent)
|
||||||
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
|
const cl = map.get('chainlightning')
|
||||||
expect(r20.manaCost).toBe(28)
|
expect(cl).toBeDefined()
|
||||||
expect(r20.manaCost256).toBe(7168)
|
expect(cl!.id).toBe(93)
|
||||||
expect(r20.minPhysDmg).toBe(0)
|
expect(cl!.vel).toBe(30)
|
||||||
expect(r20.maxPhysDmg).toBe(0)
|
expect(cl!.range).toBe(25)
|
||||||
expect(r20.minElemDmg256).toBe(256)
|
expect(cl!.speedPxPerSec).toBe(750)
|
||||||
expect(r20.maxElemDmg256).toBe(71936)
|
expect(cl!.distancePx).toBe(750)
|
||||||
expect(r20.durationFrames).toBe(0)
|
expect(cl!.trans).toBe(1)
|
||||||
expect(r20.radiusSubtiles).toBe(20)
|
})
|
||||||
expect(r20.petMax).toBe(0)
|
|
||||||
expect(r20.cooldownFrames).toBe(0)
|
it('CANONICAL_113C_MISSILES contains exact chainlightning definitions', () => {
|
||||||
|
const cl = getMissileTxtData('chainlightning')
|
||||||
|
expect(cl.id).toBe(93)
|
||||||
|
expect(cl.vel).toBe(30)
|
||||||
|
expect(cl.range).toBe(25)
|
||||||
|
expect(cl.speedPxPerSec).toBe(750)
|
||||||
|
expect(cl.distancePx).toBe(750)
|
||||||
|
expect(cl.trans).toBe(1)
|
||||||
|
|
||||||
|
const def = CANONICAL_113C_MISSILES['chainlightning']
|
||||||
|
expect(def).toBeDefined()
|
||||||
|
expect(def!.id).toBe(93)
|
||||||
|
expect(def!.vel).toBe(30)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('enforces 1.13c blvl synergy rule (hard points boost synergy; +skills and item charges do NOT)', async () => {
|
// =========================================================================
|
||||||
const registry = await getSharedDataRegistry()
|
// Dimension 2: Visual Assets & Metadata Integrity
|
||||||
const skillRec = registry.getSkillById(53)!
|
// =========================================================================
|
||||||
const synId = 38
|
describe('Dimension 2: Visual Assets & Metadata Integrity', () => {
|
||||||
|
it('verifies baked DCC atlas sprite sheet files exist with non-zero size', () => {
|
||||||
|
const missilePng = path.resolve(
|
||||||
|
process.cwd(),
|
||||||
|
'public/missiles/chainlightning.png',
|
||||||
|
)
|
||||||
|
const missileJson = path.resolve(
|
||||||
|
process.cwd(),
|
||||||
|
'public/missiles/chainlightning.json',
|
||||||
|
)
|
||||||
|
const overlayPng = path.resolve(
|
||||||
|
process.cwd(),
|
||||||
|
'public/overlays/light_cast_1.png',
|
||||||
|
)
|
||||||
|
const overlayJson = path.resolve(
|
||||||
|
process.cwd(),
|
||||||
|
'public/overlays/light_cast_1.json',
|
||||||
|
)
|
||||||
|
|
||||||
const statsBase = new UnitStatList()
|
expect(fs.existsSync(missilePng)).toBe(true)
|
||||||
statsBase.setBaseSkillLevel(53, 20)
|
expect(fs.statSync(missilePng).size).toBeGreaterThan(1000)
|
||||||
if (synId !== null) {
|
|
||||||
statsBase.setBaseSkillLevel(synId, 20)
|
|
||||||
}
|
|
||||||
const withHardPoints = skillModule.evaluate!({
|
|
||||||
registry,
|
|
||||||
skill: skillRec,
|
|
||||||
slvl: 20,
|
|
||||||
blvl: 20,
|
|
||||||
statList: statsBase,
|
|
||||||
})
|
|
||||||
expect(withHardPoints.synergyBonusPct).toBe(80)
|
|
||||||
|
|
||||||
// +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy
|
expect(fs.existsSync(missileJson)).toBe(true)
|
||||||
const statsSoftOnly = new UnitStatList()
|
expect(fs.statSync(missileJson).size).toBeGreaterThan(100)
|
||||||
statsSoftOnly.setBaseSkillLevel(53, 20)
|
|
||||||
statsSoftOnly.addStat('item_allskills', 20)
|
expect(fs.existsSync(overlayPng)).toBe(true)
|
||||||
if (synId !== null) {
|
expect(fs.statSync(overlayPng).size).toBeGreaterThan(1000)
|
||||||
statsSoftOnly.setChargedSkillLevel(synId, 33)
|
|
||||||
}
|
expect(fs.existsSync(overlayJson)).toBe(true)
|
||||||
const withSoftOnly = skillModule.evaluate!({
|
expect(fs.statSync(overlayJson).size).toBeGreaterThan(100)
|
||||||
registry,
|
})
|
||||||
skill: skillRec,
|
|
||||||
slvl: 20,
|
it('verifies CHAINLIGHTNING_META metadata and registry registration', () => {
|
||||||
blvl: 20,
|
expect(CHAINLIGHTNING_META).toBeDefined()
|
||||||
statList: statsSoftOnly,
|
expect(CHAINLIGHTNING_META.name).toBe('chainlightning')
|
||||||
|
expect(CHAINLIGHTNING_META.directions).toBe(32)
|
||||||
|
expect(MISSILE_METAS['chainlightning']).toBeDefined()
|
||||||
|
expect(MISSILE_METAS['chainlightning']!.name).toBe('chainlightning')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verifies cast overlay mapping and metadata', () => {
|
||||||
|
const overlay = getSkillCastOverlay(53)
|
||||||
|
expect(overlay).toBe('light_cast_1')
|
||||||
|
expect(SORCERESS_SKILL_CAST_OVERLAYS[53]).toBe('light_cast_1')
|
||||||
|
|
||||||
|
expect(LIGHT_CAST_1_META).toBeDefined()
|
||||||
|
expect(LIGHT_CAST_1_META.name).toBe('light_cast_1')
|
||||||
|
expect(OVERLAY_METAS['light_cast_1']).toBeDefined()
|
||||||
})
|
})
|
||||||
expect(withSoftOnly.synergyBonusPct).toBe(0)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('executes runtime skill effect in WorldArena (castCount, action frame, and combat/state/missile/summon outcome)', async () => {
|
// =========================================================================
|
||||||
const registry = await getSharedDataRegistry()
|
// Dimension 3: Seeking Kinematics & Jump Formula
|
||||||
const arena = new WorldArena({
|
// =========================================================================
|
||||||
registry,
|
describe('Dimension 3: Seeking Kinematics & Jump Formula', () => {
|
||||||
classCode: "sor",
|
it('evaluates exact 1.13c jump formula: 5 + Math.floor(slvl / 5)', () => {
|
||||||
skillId: 53,
|
expect(calculateChainLightningJumps(1)).toBe(5)
|
||||||
slvl: 20,
|
expect(calculateChainLightningJumps(2)).toBe(5)
|
||||||
|
expect(calculateChainLightningJumps(4)).toBe(5)
|
||||||
|
expect(calculateChainLightningJumps(5)).toBe(6)
|
||||||
|
expect(calculateChainLightningJumps(6)).toBe(6)
|
||||||
|
expect(calculateChainLightningJumps(9)).toBe(6)
|
||||||
|
expect(calculateChainLightningJumps(10)).toBe(7)
|
||||||
|
expect(calculateChainLightningJumps(14)).toBe(7)
|
||||||
|
expect(calculateChainLightningJumps(15)).toBe(8)
|
||||||
|
expect(calculateChainLightningJumps(19)).toBe(8)
|
||||||
|
expect(calculateChainLightningJumps(20)).toBe(9)
|
||||||
|
expect(calculateChainLightningJumps(25)).toBe(10)
|
||||||
|
expect(calculateChainLightningJumps(30)).toBe(11)
|
||||||
|
expect(calculateChainLightningJumps(40)).toBe(13)
|
||||||
})
|
})
|
||||||
const outcome = arena.triggerCast()
|
|
||||||
arena.stepTicks(15)
|
|
||||||
const dbg = arena.getDebugState()
|
|
||||||
|
|
||||||
expect(dbg.ready).toBe(true)
|
it('missile advances at 30 px/tick (750 px/s) toward target', () => {
|
||||||
expect(dbg.skillId).toBe(53)
|
const missileData = getMissileTxtData('chainlightning')
|
||||||
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
|
expect(missileData.speedPxPerSec).toBe(750)
|
||||||
expect(dbg.actionFrameTriggered).toBe(true)
|
expect(missileData.distancePx).toBe(750)
|
||||||
expect(outcome?.executed).toBe(true)
|
|
||||||
|
const target: ProjectileTarget = {
|
||||||
|
index: 1,
|
||||||
|
x: 300,
|
||||||
|
y: 0,
|
||||||
|
radius: 16,
|
||||||
|
alive: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial projectile fired horizontally at target 300px away
|
||||||
|
const initial: Projectile = {
|
||||||
|
skillId: '53',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
vx: 30,
|
||||||
|
vy: 0,
|
||||||
|
damage: 100,
|
||||||
|
ttl: 25,
|
||||||
|
fromPlayer: true,
|
||||||
|
missileType: 'chainlightning',
|
||||||
|
chainRemaining: 5,
|
||||||
|
nextHit: 1,
|
||||||
|
nextDelay: 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
let current: readonly Projectile[] = [initial]
|
||||||
|
const openTerrain = { overlap: () => 0 }
|
||||||
|
|
||||||
|
// Advance 9 ticks (x = 270)
|
||||||
|
for (let t = 1; t <= 9; t++) {
|
||||||
|
const step = tickProjectiles(current, [target], openTerrain)
|
||||||
|
expect(step.alive.length).toBe(1)
|
||||||
|
expect(step.hits.length).toBe(0)
|
||||||
|
current = step.alive
|
||||||
|
}
|
||||||
|
expect(current[0]!.x).toBe(270)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('enforces 20 subtiles (400 px) search radius for jumps', () => {
|
||||||
|
// Target 1 at (0, 0), Target 2 at (450, 0) — out of 400px jump range
|
||||||
|
const t1: ProjectileTarget = { index: 1, x: 100, y: 0, radius: 16, alive: true }
|
||||||
|
const t2: ProjectileTarget = { index: 2, x: 550, y: 0, radius: 16, alive: true } // dist = 450 > 400
|
||||||
|
|
||||||
|
const projectile: Projectile = {
|
||||||
|
skillId: '53',
|
||||||
|
x: 90,
|
||||||
|
y: 0,
|
||||||
|
vx: 30,
|
||||||
|
vy: 0,
|
||||||
|
damage: 100,
|
||||||
|
ttl: 25,
|
||||||
|
fromPlayer: true,
|
||||||
|
missileType: 'chainlightning',
|
||||||
|
chainRemaining: 5,
|
||||||
|
nextHit: 1,
|
||||||
|
nextDelay: 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
const outcome = tickProjectiles([projectile], [t1, t2], { overlap: () => 0 })
|
||||||
|
expect(outcome.hits.length).toBe(1)
|
||||||
|
expect(outcome.hits[0]!.targetIndex).toBe(1)
|
||||||
|
// Since t2 is 450px away (> 400px search radius), chain cannot jump!
|
||||||
|
expect(outcome.spawnedProjectiles?.length ?? 0).toBe(0)
|
||||||
|
expect(outcome.alive.length).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Dimension 4: Ping-Pong Retargeting, Target Exclusion & NextDelay = 4
|
||||||
|
// =========================================================================
|
||||||
|
describe('Dimension 4: Ping-Pong Retargeting, Target Exclusion & NextDelay = 4', () => {
|
||||||
|
it('ping-pongs between 2 monsters within 400 px for all available jumps', () => {
|
||||||
|
// Target A at (100, 0), Target B at (200, 0). Distance between them is 100 px <= 400 px.
|
||||||
|
const tA: ProjectileTarget = { index: 1, x: 100, y: 0, radius: 16, alive: true }
|
||||||
|
const tB: ProjectileTarget = { index: 2, x: 200, y: 0, radius: 16, alive: true }
|
||||||
|
const targets = [tA, tB]
|
||||||
|
|
||||||
|
// Initial missile hits A with chainRemaining = 3
|
||||||
|
let projectiles: readonly Projectile[] = [
|
||||||
|
{
|
||||||
|
skillId: '53',
|
||||||
|
x: 75,
|
||||||
|
y: 0,
|
||||||
|
vx: 30,
|
||||||
|
vy: 0,
|
||||||
|
damage: 50,
|
||||||
|
ttl: 25,
|
||||||
|
fromPlayer: true,
|
||||||
|
missileType: 'chainlightning',
|
||||||
|
chainRemaining: 3,
|
||||||
|
nextHit: 1,
|
||||||
|
nextDelay: 4,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const hitSequence: number[] = []
|
||||||
|
const openTerrain = { overlap: () => 0 }
|
||||||
|
|
||||||
|
// Tick through the lifecycle to trace the jumps
|
||||||
|
for (let tick = 1; tick <= 50; tick++) {
|
||||||
|
const outcome = tickProjectiles(projectiles, targets, openTerrain)
|
||||||
|
for (const hit of outcome.hits) {
|
||||||
|
hitSequence.push(hit.targetIndex)
|
||||||
|
}
|
||||||
|
projectiles = outcome.alive
|
||||||
|
if (projectiles.length === 0) break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chain lightning hit A, spawned jump toward B (chainRemaining = 2),
|
||||||
|
// hit B, spawned jump back toward A (chainRemaining = 1),
|
||||||
|
// hit A, spawned jump back toward B (chainRemaining = 0),
|
||||||
|
// hit B, 0 jumps remaining -> terminates!
|
||||||
|
// Total hits: 4 hits (initial + 3 jumps)
|
||||||
|
expect(hitSequence).toEqual([1, 2, 1, 2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('terminates cleanly after 1 hit when only 1 monster is in range', () => {
|
||||||
|
// Only 1 monster in range
|
||||||
|
const tA: ProjectileTarget = { index: 1, x: 100, y: 0, radius: 16, alive: true }
|
||||||
|
const projectiles: readonly Projectile[] = [
|
||||||
|
{
|
||||||
|
skillId: '53',
|
||||||
|
x: 75,
|
||||||
|
y: 0,
|
||||||
|
vx: 30,
|
||||||
|
vy: 0,
|
||||||
|
damage: 50,
|
||||||
|
ttl: 25,
|
||||||
|
fromPlayer: true,
|
||||||
|
missileType: 'chainlightning',
|
||||||
|
chainRemaining: 5,
|
||||||
|
nextHit: 1,
|
||||||
|
nextDelay: 4,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const outcome = tickProjectiles(projectiles, [tA], { overlap: () => 0 })
|
||||||
|
expect(outcome.hits.length).toBe(1)
|
||||||
|
expect(outcome.hits[0]!.targetIndex).toBe(1)
|
||||||
|
// Cannot jump back to the immediate victim, so 0 spawned jumps and 0 alive
|
||||||
|
expect(outcome.spawnedProjectiles?.length ?? 0).toBe(0)
|
||||||
|
expect(outcome.alive.length).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('MissileEngine enforces NextDelay = 4 frames and ping-pong jumping across 2 targets', async () => {
|
||||||
|
const registry = await getSharedDataRegistry()
|
||||||
|
const engine = new MissileEngine(registry)
|
||||||
|
|
||||||
|
const ownerStats = new UnitStatList()
|
||||||
|
const owner = {
|
||||||
|
id: 'player',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
statList: ownerStats,
|
||||||
|
stateBus: new StateBus(ownerStats),
|
||||||
|
} as any
|
||||||
|
|
||||||
|
const m = engine.spawnMissile({
|
||||||
|
missileNameOrId: 'chainlightning',
|
||||||
|
sourceSkillId: 53,
|
||||||
|
slvl: 20,
|
||||||
|
owner,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
targetX: 60,
|
||||||
|
targetY: 0,
|
||||||
|
dmgPacket: { minDamage: 50, maxDamage: 50, damageType: 'lightning' } as any,
|
||||||
|
vx: 30,
|
||||||
|
vy: 0,
|
||||||
|
maxRangeTicks: 25,
|
||||||
|
chainRemaining: 2, // will hit A, jump to B, hit B, jump to A, hit A (3 total hits)
|
||||||
|
})
|
||||||
|
expect(m).not.toBeNull()
|
||||||
|
|
||||||
|
const statA = new UnitStatList()
|
||||||
|
statA.setHp256(1000 * 256)
|
||||||
|
const statB = new UnitStatList()
|
||||||
|
statB.setHp256(1000 * 256)
|
||||||
|
|
||||||
|
const targets = [
|
||||||
|
{ id: 'target_a', statList: statA, stateBus: new StateBus(statA) },
|
||||||
|
{ id: 'target_b', statList: statB, stateBus: new StateBus(statB) },
|
||||||
|
] as any[]
|
||||||
|
|
||||||
|
const targetPositions = new Map<string, { x: number; y: number }>([
|
||||||
|
['target_a', { x: 60, y: 0 }],
|
||||||
|
['target_b', { x: 180, y: 0 }],
|
||||||
|
])
|
||||||
|
|
||||||
|
const hitRecords: { tick: number; targetId: string }[] = []
|
||||||
|
for (let tick = 1; tick <= 30; tick++) {
|
||||||
|
const step = engine.tick(tick, targets, targetPositions)
|
||||||
|
for (const h of step.hits) {
|
||||||
|
hitRecords.push({ tick, targetId: h.targetId })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expected hit sequence: target_a -> target_b -> target_a
|
||||||
|
expect(hitRecords.map((r) => r.targetId)).toEqual([
|
||||||
|
'target_a',
|
||||||
|
'target_b',
|
||||||
|
'target_a',
|
||||||
|
])
|
||||||
|
|
||||||
|
// Verify NextDelay between hits on the same target:
|
||||||
|
// Hit on target_a at tick 2, next hit on target_a occurs after tick 2 + 4 = tick 6
|
||||||
|
const aHits = hitRecords.filter((r) => r.targetId === 'target_a')
|
||||||
|
expect(aHits.length).toBe(2)
|
||||||
|
expect(aHits[1]!.tick - aHits[0]!.tick).toBeGreaterThanOrEqual(4)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Dimension 5: Piecewise 5-Band Damage Scaling & Synergy Verification
|
||||||
|
// =========================================================================
|
||||||
|
describe('Dimension 5: Piecewise 5-Band Damage Scaling & Synergy Verification', () => {
|
||||||
|
it('calculates exact 1.13c 5-band damage without synergies at slvl 1, 2, 8, 9, 10, 16, 17, 20, 22, 28, 30', () => {
|
||||||
|
// Level 1: 1-40
|
||||||
|
expect(calculateChainLightningDamage(1)).toEqual({ min: 1, max: 40 })
|
||||||
|
|
||||||
|
// Band 1: Level 2-8 (+11/lvl)
|
||||||
|
// slvl 2: 40 + 11 = 51
|
||||||
|
expect(calculateChainLightningDamage(2)).toEqual({ min: 1, max: 51 })
|
||||||
|
// slvl 8: 40 + 7 * 11 = 117
|
||||||
|
expect(calculateChainLightningDamage(8)).toEqual({ min: 1, max: 117 })
|
||||||
|
|
||||||
|
// Band 2: Level 9-16 (+13/lvl)
|
||||||
|
// slvl 9: 117 + 13 = 130
|
||||||
|
expect(calculateChainLightningDamage(9)).toEqual({ min: 1, max: 130 })
|
||||||
|
// slvl 10: 117 + 2 * 13 = 143
|
||||||
|
expect(calculateChainLightningDamage(10)).toEqual({ min: 1, max: 143 })
|
||||||
|
// slvl 16: 117 + 8 * 13 = 221
|
||||||
|
expect(calculateChainLightningDamage(16)).toEqual({ min: 1, max: 221 })
|
||||||
|
|
||||||
|
// Band 3: Level 17-22 (+15/lvl)
|
||||||
|
// slvl 17: 221 + 15 = 236
|
||||||
|
expect(calculateChainLightningDamage(17)).toEqual({ min: 1, max: 236 })
|
||||||
|
// slvl 20: 221 + 4 * 15 = 281
|
||||||
|
expect(calculateChainLightningDamage(20)).toEqual({ min: 1, max: 281 })
|
||||||
|
// slvl 22: 221 + 6 * 15 = 311
|
||||||
|
expect(calculateChainLightningDamage(22)).toEqual({ min: 1, max: 311 })
|
||||||
|
|
||||||
|
// Band 4: Level 23-28 (+15/lvl)
|
||||||
|
// slvl 28: 311 + 6 * 15 = 401
|
||||||
|
expect(calculateChainLightningDamage(28)).toEqual({ min: 1, max: 401 })
|
||||||
|
|
||||||
|
// Band 5: Level 29+ (+15/lvl)
|
||||||
|
// slvl 30: 401 + 2 * 15 = 431
|
||||||
|
expect(calculateChainLightningDamage(30)).toEqual({ min: 1, max: 431 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calculates exact +4% synergies per hard point in Charged Bolt, Nova, Lightning', () => {
|
||||||
|
// 0 hard points = 1.0 multiplier
|
||||||
|
expect(
|
||||||
|
calculateChainLightningSynergyMultiplier({
|
||||||
|
chargedBoltPoints: 0,
|
||||||
|
novaPoints: 0,
|
||||||
|
lightningPoints: 0,
|
||||||
|
}),
|
||||||
|
).toBe(1)
|
||||||
|
|
||||||
|
// 1 hard point in Charged Bolt: +4% -> 1.04
|
||||||
|
expect(
|
||||||
|
calculateChainLightningSynergyMultiplier({
|
||||||
|
chargedBoltPoints: 1,
|
||||||
|
novaPoints: 0,
|
||||||
|
lightningPoints: 0,
|
||||||
|
}),
|
||||||
|
).toBeCloseTo(1.04, 5)
|
||||||
|
|
||||||
|
// 1 hard point in Nova: +4% -> 1.04
|
||||||
|
expect(
|
||||||
|
calculateChainLightningSynergyMultiplier({
|
||||||
|
chargedBoltPoints: 0,
|
||||||
|
novaPoints: 1,
|
||||||
|
lightningPoints: 0,
|
||||||
|
}),
|
||||||
|
).toBeCloseTo(1.04, 5)
|
||||||
|
|
||||||
|
// 1 hard point in Lightning: +4% -> 1.04
|
||||||
|
expect(
|
||||||
|
calculateChainLightningSynergyMultiplier({
|
||||||
|
chargedBoltPoints: 0,
|
||||||
|
novaPoints: 0,
|
||||||
|
lightningPoints: 1,
|
||||||
|
}),
|
||||||
|
).toBeCloseTo(1.04, 5)
|
||||||
|
|
||||||
|
// 20 hard points in each of the 3 synergy skills: 60 * 4% = +240% -> 3.4 multiplier
|
||||||
|
expect(
|
||||||
|
calculateChainLightningSynergyMultiplier({
|
||||||
|
chargedBoltPoints: 20,
|
||||||
|
novaPoints: 20,
|
||||||
|
lightningPoints: 20,
|
||||||
|
}),
|
||||||
|
).toBeCloseTo(3.4, 5)
|
||||||
|
|
||||||
|
// Total damage with full synergies at slvl 20 (base 1-281):
|
||||||
|
// min = round(1 * 3.4) = 3, max = round(281 * 3.4) = 955
|
||||||
|
const mult = calculateChainLightningSynergyMultiplier({
|
||||||
|
chargedBoltPoints: 20,
|
||||||
|
novaPoints: 20,
|
||||||
|
lightningPoints: 20,
|
||||||
|
})
|
||||||
|
const baseDmg = calculateChainLightningDamage(20)
|
||||||
|
expect(Math.round(baseDmg.min * mult)).toBe(3)
|
||||||
|
expect(Math.round(baseDmg.max * mult)).toBe(955)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('evaluates exact 1.13c Skills.txt formulas in skillModule.evaluate', async () => {
|
||||||
|
const registry = await getSharedDataRegistry()
|
||||||
|
const skillRec = registry.getSkillById(53)!
|
||||||
|
|
||||||
|
const stats1 = new UnitStatList()
|
||||||
|
stats1.setBaseSkillLevel(53, 1)
|
||||||
|
const r1 = skillModule.evaluate!({
|
||||||
|
registry,
|
||||||
|
skill: skillRec,
|
||||||
|
slvl: 1,
|
||||||
|
blvl: 1,
|
||||||
|
statList: stats1,
|
||||||
|
})
|
||||||
|
expect(r1.manaCost).toBe(9)
|
||||||
|
expect(r1.minElemDmg256).toBe(256) // 1 * 256
|
||||||
|
expect(r1.maxElemDmg256).toBe(10240) // 40 * 256
|
||||||
|
|
||||||
|
const stats10 = new UnitStatList()
|
||||||
|
stats10.setBaseSkillLevel(53, 10)
|
||||||
|
const r10 = skillModule.evaluate!({
|
||||||
|
registry,
|
||||||
|
skill: skillRec,
|
||||||
|
slvl: 10,
|
||||||
|
blvl: 10,
|
||||||
|
statList: stats10,
|
||||||
|
})
|
||||||
|
expect(r10.manaCost).toBe(18)
|
||||||
|
expect(r10.minElemDmg256).toBe(256)
|
||||||
|
expect(r10.maxElemDmg256).toBe(36608) // 143 * 256
|
||||||
|
|
||||||
|
const stats20 = new UnitStatList()
|
||||||
|
stats20.setBaseSkillLevel(53, 20)
|
||||||
|
const r20 = skillModule.evaluate!({
|
||||||
|
registry,
|
||||||
|
skill: skillRec,
|
||||||
|
slvl: 20,
|
||||||
|
blvl: 20,
|
||||||
|
statList: stats20,
|
||||||
|
})
|
||||||
|
expect(r20.manaCost).toBe(28)
|
||||||
|
expect(r20.minElemDmg256).toBe(256)
|
||||||
|
expect(r20.maxElemDmg256).toBe(71936) // 281 * 256
|
||||||
|
expect(r20.cooldownFrames).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Dimension 6: Lifecycle, Range 25 Frames, Mana Cost Formula
|
||||||
|
// =========================================================================
|
||||||
|
describe('Dimension 6: Lifecycle, Range 25 Frames, Mana Cost Formula', () => {
|
||||||
|
it('projectile lives exactly 25 ticks in open space and expires on tick 26', () => {
|
||||||
|
let projectiles: readonly Projectile[] = [
|
||||||
|
{
|
||||||
|
skillId: '53',
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
vx: 30,
|
||||||
|
vy: 0,
|
||||||
|
damage: 100,
|
||||||
|
ttl: 25,
|
||||||
|
fromPlayer: true,
|
||||||
|
missileType: 'chainlightning',
|
||||||
|
chainRemaining: 5,
|
||||||
|
nextHit: 1,
|
||||||
|
nextDelay: 4,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const openTerrain = { overlap: () => 0 }
|
||||||
|
|
||||||
|
for (let t = 1; t <= 25; t++) {
|
||||||
|
const outcome = tickProjectiles(projectiles, [], openTerrain)
|
||||||
|
expect(outcome.alive.length).toBe(1)
|
||||||
|
expect(outcome.expired).toBe(0)
|
||||||
|
projectiles = outcome.alive
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tick 26: expires after completing full 25 ticks of flight
|
||||||
|
const outcome26 = tickProjectiles(projectiles, [], openTerrain)
|
||||||
|
expect(outcome26.alive.length).toBe(0)
|
||||||
|
expect(outcome26.expired).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verifies getSkillManaCost formula: 9 + (slvl - 1) * 1', () => {
|
||||||
|
expect(getSkillManaCost(53, 1)).toBe(9)
|
||||||
|
expect(getSkillManaCost(53, 2)).toBe(10)
|
||||||
|
expect(getSkillManaCost(53, 3)).toBe(11)
|
||||||
|
expect(getSkillManaCost(53, 10)).toBe(18)
|
||||||
|
expect(getSkillManaCost(53, 20)).toBe(28)
|
||||||
|
expect(getSkillManaCost(53, 30)).toBe(38)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verifies getSkillCooldownTicks returns 0 frames', () => {
|
||||||
|
expect(getSkillCooldownTicks(53)).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Dimension 7: WorldArena & Runtime Cast Integration
|
||||||
|
// =========================================================================
|
||||||
|
describe('Dimension 7: WorldArena & Runtime Cast Integration', () => {
|
||||||
|
it('executes runtime skill effect in WorldArena (castCount, action frame, and combat outcome)', async () => {
|
||||||
|
const registry = await getSharedDataRegistry()
|
||||||
|
const arena = new WorldArena({
|
||||||
|
registry,
|
||||||
|
classCode: 'sor',
|
||||||
|
skillId: 53,
|
||||||
|
slvl: 20,
|
||||||
|
})
|
||||||
|
const outcome = arena.triggerCast()
|
||||||
|
arena.stepTicks(15)
|
||||||
|
const dbg = arena.getDebugState()
|
||||||
|
|
||||||
|
expect(dbg.ready).toBe(true)
|
||||||
|
expect(dbg.skillId).toBe(53)
|
||||||
|
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
|
||||||
|
expect(dbg.actionFrameTriggered).toBe(true)
|
||||||
|
expect(outcome?.executed).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('verifies skillModule definition and metadata for Chain Lightning (53)', () => {
|
||||||
|
expect(skillModule.skillId).toBe(53)
|
||||||
|
expect(skillModule.name).toBe('Chain Lightning')
|
||||||
|
expect(skillModule.charClass).toBe('sor')
|
||||||
|
expect(skillModule.srvStFunc).toBe(0)
|
||||||
|
expect(skillModule.srvDoFunc).toBe(26)
|
||||||
|
expect(getSkillCastOverlay(53)).toBe('light_cast_1')
|
||||||
|
expect(SORCERESS_SKILL_CAST_OVERLAYS[53]).toBe('light_cast_1')
|
||||||
|
expect(getSkillCooldownTicks(53)).toBe(0)
|
||||||
|
expect(getSkillManaCost(53, 1)).toBe(9)
|
||||||
|
expect(calculateChainLightningJumps(1)).toBe(5)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue