This commit is contained in:
parent
72b9f9e7ec
commit
35b926749e
|
|
@ -22,6 +22,127 @@ import { calculateMeteorResidualFireDamage } from '../skills.ts'
|
|||
|
||||
export const ISO_GROUND_ASPECT_RATIO = 0.5
|
||||
|
||||
export interface StaticFieldResult {
|
||||
readonly damage: number
|
||||
readonly newHp: number
|
||||
readonly immune: boolean
|
||||
readonly blockedByFloor: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentic 1.13c Static Field damage calculation with pre-check invariant and difficulty floors.
|
||||
* Source: D2Game.dll VA 0x6FC62F13 (SKILLS_AuraCallback_StaticField) & DifficultyLevels.txt
|
||||
*/
|
||||
export function calculateStaticFieldDamage(
|
||||
currentHp: number,
|
||||
maxHp: number,
|
||||
difficulty: 'normal' | 'nightmare' | 'hell' = 'normal',
|
||||
lightningRes = 0,
|
||||
): StaticFieldResult {
|
||||
const minPct = difficulty === 'hell' ? 50 : difficulty === 'nightmare' ? 33 : 0
|
||||
const hpFloor = difficulty === 'normal' ? 1 : Math.floor((maxHp * minPct) / 100)
|
||||
|
||||
// 1.13c Assembly Invariant (SKILLS_AuraCallback_StaticField at 0x6FC62F13):
|
||||
// If current HP is already at or below the difficulty floor, return 0 damage immediately!
|
||||
if (currentHp <= hpFloor) {
|
||||
return { damage: 0, newHp: currentHp, immune: false, blockedByFloor: true }
|
||||
}
|
||||
|
||||
if (lightningRes >= 100) {
|
||||
return { damage: 0, newHp: currentHp, immune: true, blockedByFloor: false }
|
||||
}
|
||||
|
||||
let damage = Math.floor(currentHp * 0.25)
|
||||
if (currentHp - damage < 1) {
|
||||
damage = Math.max(0, currentHp - 1)
|
||||
}
|
||||
|
||||
if (lightningRes > 0) {
|
||||
damage = Math.floor((damage * (100 - lightningRes)) / 100)
|
||||
}
|
||||
|
||||
return { damage, newHp: currentHp - damage, immune: false, blockedByFloor: false }
|
||||
}
|
||||
|
||||
export function calculateStaticFieldRadiusYards(slvl: number): number {
|
||||
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
||||
return 4 + lvl
|
||||
}
|
||||
|
||||
export function calculateStaticFieldRadiusPx(slvl: number): number {
|
||||
return calculateStaticFieldRadiusYards(slvl) * 40
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentic 1.13c Nova 5-band damage scaling (Skills.txt row 48):
|
||||
* EMin: 1, EMinLev1..5: 6, 7, 8, 9, 10
|
||||
* EMax: 20, EMaxLev1..5: 8, 9, 10, 11, 12
|
||||
* 0 synergies in 1.13c.
|
||||
*/
|
||||
export function calculateNovaDamage(slvl: number): { min: number; max: number } {
|
||||
const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1
|
||||
let min = 1
|
||||
let max = 20
|
||||
if (lvl > 1) {
|
||||
const b1 = Math.min(lvl - 1, 7)
|
||||
min += b1 * 6
|
||||
max += b1 * 8
|
||||
if (lvl > 8) {
|
||||
const b2 = Math.min(lvl - 8, 8)
|
||||
min += b2 * 7
|
||||
max += b2 * 9
|
||||
}
|
||||
if (lvl > 16) {
|
||||
const b3 = Math.min(lvl - 16, 6)
|
||||
min += b3 * 8
|
||||
max += b3 * 10
|
||||
}
|
||||
if (lvl > 22) {
|
||||
const b4 = Math.min(lvl - 22, 6)
|
||||
min += b4 * 9
|
||||
max += b4 * 11
|
||||
}
|
||||
if (lvl > 28) {
|
||||
const b5 = lvl - 28
|
||||
min += b5 * 10
|
||||
max += b5 * 12
|
||||
}
|
||||
}
|
||||
return { min, max }
|
||||
}
|
||||
|
||||
export function hasLineOfSight(
|
||||
x0: number,
|
||||
y0: number,
|
||||
x1: number,
|
||||
y1: number,
|
||||
terrain?: MissileTerrainCollision | ((x: number, y: number, fromX?: number, fromY?: number) => boolean) | undefined,
|
||||
): boolean {
|
||||
if (!terrain) return true
|
||||
const dist = Math.hypot(x1 - x0, y1 - y0)
|
||||
if (dist === 0) return true
|
||||
const stepSize = 16
|
||||
const steps = Math.ceil(dist / stepSize)
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const t = Math.min(1, i / steps)
|
||||
const curX = x0 + (x1 - x0) * t
|
||||
const curY = y0 + (y1 - y0) * t
|
||||
let blocked = false
|
||||
if (typeof terrain === 'function') {
|
||||
blocked = Boolean(terrain(curX, curY, x0, y0))
|
||||
} else if (terrain.isMissileBlocked !== undefined) {
|
||||
blocked = terrain.isMissileBlocked(curX, curY, x0, y0)
|
||||
} else if (terrain.overlap !== undefined) {
|
||||
blocked = terrain.overlap(curX, curY) > 0
|
||||
}
|
||||
if (blocked) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Authentic D2Game 64-entry discrete circle lookup tables (radius 30).
|
||||
* Replicated byte-for-byte from D2Game.dll 0x6FC581F0 (MISSMODE_SrvDo15_FrozenOrb)
|
||||
|
|
@ -122,6 +243,9 @@ export class MissileEngine {
|
|||
spawnMissileType?: string
|
||||
maxRangeTicks?: number
|
||||
altitude?: number
|
||||
vx?: number
|
||||
vy?: number
|
||||
angleRad?: number
|
||||
}): ActiveMissile | null {
|
||||
const rec =
|
||||
typeof params.missileNameOrId === 'number'
|
||||
|
|
@ -261,7 +385,8 @@ export class MissileEngine {
|
|||
|
||||
const dx = params.targetX - params.startX
|
||||
const dy = params.targetY - params.startY
|
||||
const angleRad = dx === 0 && dy === 0 ? Math.PI / 2 : Math.atan2(dy, dx)
|
||||
const calculatedAngleRad = dx === 0 && dy === 0 ? Math.PI / 2 : Math.atan2(dy, dx)
|
||||
const angleRad = params.angleRad ?? calculatedAngleRad
|
||||
const isTurret = Boolean(params.isTurret || effectiveRec.name.toLowerCase() === 'hydra_turret')
|
||||
const isStationary =
|
||||
isTurret ||
|
||||
|
|
@ -269,8 +394,8 @@ export class MissileEngine {
|
|||
effectiveRec.name.toLowerCase().startsWith('blizzard') ||
|
||||
effectiveRec.name.toLowerCase().startsWith('meteor')
|
||||
const speedPxPerTick = isStationary ? 0 : Math.max(8, effectiveRec.vel || 18)
|
||||
const vx = isStationary ? 0 : Math.cos(angleRad) * speedPxPerTick
|
||||
const vy = isStationary ? 0 : Math.sin(angleRad) * speedPxPerTick
|
||||
const vx = params.vx !== undefined ? params.vx : (isStationary ? 0 : Math.cos(angleRad) * speedPxPerTick)
|
||||
const vy = params.vy !== undefined ? params.vy : (isStationary ? 0 : Math.sin(angleRad) * speedPxPerTick)
|
||||
const baseRange = effectiveRec.range !== undefined && effectiveRec.range > 0 ? effectiveRec.range : 35
|
||||
const calculatedRange = baseRange + Math.max(0, params.slvl - 1) * (effectiveRec.levRange || 0)
|
||||
const maxRangeTicks = params.maxRangeTicks ?? (
|
||||
|
|
@ -476,8 +601,22 @@ export class MissileEngine {
|
|||
m.x += Math.cos(m.angleRad) * (radius * 0.25)
|
||||
m.y += Math.sin(m.angleRad) * (radius * 0.25 * 0.5)
|
||||
} else {
|
||||
const prevX = m.x
|
||||
const prevY = m.y
|
||||
m.x += m.vx
|
||||
m.y += m.vy
|
||||
|
||||
const effectiveTerrain = collision ?? this.terrain
|
||||
if (effectiveTerrain !== undefined && !isTurretEmitter && (m.altitude === undefined || m.altitude <= 0)) {
|
||||
const blocked =
|
||||
effectiveTerrain.isMissileBlocked !== undefined
|
||||
? effectiveTerrain.isMissileBlocked(m.x, m.y, prevX, prevY)
|
||||
: effectiveTerrain.overlap !== undefined && effectiveTerrain.overlap(m.x, m.y) > 0
|
||||
if (blocked) {
|
||||
m.expired = true
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check collision against alive enemy targets
|
||||
|
|
@ -812,4 +951,252 @@ export class MissileEngine {
|
|||
getActiveMissiles(): readonly ActiveMissile[] {
|
||||
return this.missiles
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawns authentic 1.13c 64-bolt circular Nova ring with 2:1 isometric ground foreshortening.
|
||||
* Speed = 24, Range = 13 frames, NextDelay = 4 frames.
|
||||
*/
|
||||
spawnNovaRing(params: {
|
||||
sourceSkillId?: number
|
||||
slvl?: number
|
||||
owner: CombatUnitContext
|
||||
startX: number
|
||||
startY: number
|
||||
dmgPacket: SUnitDmgPacket
|
||||
missileName?: string
|
||||
}): ActiveMissile[] {
|
||||
const spawned: ActiveMissile[] = []
|
||||
const skillId = params.sourceSkillId ?? 48
|
||||
const slvl = params.slvl ?? 1
|
||||
const missileName = params.missileName ?? 'nova'
|
||||
const speed = 24 // 1.13c Missiles.txt: Vel = 24
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const angle = (i / 64) * 2 * Math.PI
|
||||
const vx = Math.cos(angle) * speed
|
||||
const vy = Math.sin(angle) * speed * ISO_GROUND_ASPECT_RATIO
|
||||
const m = this.spawnMissile({
|
||||
missileNameOrId: missileName,
|
||||
sourceSkillId: skillId,
|
||||
slvl,
|
||||
owner: params.owner,
|
||||
startX: params.startX,
|
||||
startY: params.startY,
|
||||
targetX: params.startX + Math.cos(angle) * 100,
|
||||
targetY: params.startY + Math.sin(angle) * 100 * ISO_GROUND_ASPECT_RATIO,
|
||||
dmgPacket: params.dmgPacket,
|
||||
vx,
|
||||
vy,
|
||||
angleRad: angle,
|
||||
})
|
||||
if (m) spawned.push(m)
|
||||
}
|
||||
return spawned
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes authentic 1.13c instantaneous 360° Static Field discharge with difficulty floors and LOS.
|
||||
* Cuts 25% current HP with Normal (1 HP), Nightmare (33% max HP), and Hell (50% max HP) pre-check clamps.
|
||||
*/
|
||||
executeStaticField(params: {
|
||||
caster: CombatUnitContext
|
||||
casterPos: { x: number; y: number }
|
||||
slvl?: number
|
||||
targets: readonly CombatUnitContext[]
|
||||
targetPositions: ReadonlyMap<string, { x: number; y: number }>
|
||||
difficulty?: 'normal' | 'nightmare' | 'hell'
|
||||
terrain?: MissileTerrainCollision
|
||||
}): {
|
||||
hits: {
|
||||
targetId: string
|
||||
damage: number
|
||||
immune: boolean
|
||||
blockedByFloor: boolean
|
||||
blockedByLos: boolean
|
||||
}[]
|
||||
radiusPx: number
|
||||
} {
|
||||
const slvl = params.slvl ?? 1
|
||||
const radiusPx = calculateStaticFieldRadiusPx(slvl)
|
||||
const difficulty = params.difficulty ?? 'normal'
|
||||
const effectiveTerrain = params.terrain ?? this.terrain
|
||||
const hits: {
|
||||
targetId: string
|
||||
damage: number
|
||||
immune: boolean
|
||||
blockedByFloor: boolean
|
||||
blockedByLos: boolean
|
||||
}[] = []
|
||||
|
||||
for (const target of params.targets) {
|
||||
if (target.statList.getHp256() <= 0) continue
|
||||
const pos = params.targetPositions.get(target.id) ?? { x: 0, y: 0 }
|
||||
const dist = Math.hypot(pos.x - params.casterPos.x, pos.y - params.casterPos.y)
|
||||
if (dist > radiusPx) continue
|
||||
|
||||
const los = hasLineOfSight(params.casterPos.x, params.casterPos.y, pos.x, pos.y, effectiveTerrain)
|
||||
if (!los) {
|
||||
hits.push({
|
||||
targetId: target.id,
|
||||
damage: 0,
|
||||
immune: false,
|
||||
blockedByFloor: false,
|
||||
blockedByLos: true,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const currentHp = target.statList.getHp()
|
||||
const maxHp = target.statList.getMaxHp() || currentHp
|
||||
const ltngRes = target.statList.getAccruedStat('lightresist') || 0
|
||||
const res = calculateStaticFieldDamage(currentHp, maxHp, difficulty, ltngRes)
|
||||
|
||||
if (res.damage > 0) {
|
||||
target.statList.setHp256(target.statList.getHp256() - res.damage * 256)
|
||||
}
|
||||
|
||||
hits.push({
|
||||
targetId: target.id,
|
||||
damage: res.damage,
|
||||
immune: res.immune,
|
||||
blockedByFloor: res.blockedByFloor,
|
||||
blockedByLos: false,
|
||||
})
|
||||
}
|
||||
|
||||
return { hits, radiusPx }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone helper to spawn 64 radial Projectile objects for Nova with 2:1 isometric ground foreshortening.
|
||||
*/
|
||||
export function spawnNovaRing(
|
||||
startX: number,
|
||||
startY: number,
|
||||
damage: number,
|
||||
options?: {
|
||||
skillId?: string
|
||||
missileType?: string
|
||||
fromPlayer?: boolean
|
||||
}
|
||||
): {
|
||||
skillId: string
|
||||
missileType: string
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
vy: number
|
||||
damage: number
|
||||
ttl: number
|
||||
fromPlayer: boolean
|
||||
nextDelay: number
|
||||
}[] {
|
||||
const speed = 24 // 1.13c Missiles.txt: vel = 24 (600 px/s)
|
||||
const skillId = options?.skillId ?? '48'
|
||||
const missileType = options?.missileType ?? 'electricnova'
|
||||
const fromPlayer = options?.fromPlayer ?? true
|
||||
const bolts: {
|
||||
skillId: string
|
||||
missileType: string
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
vy: number
|
||||
damage: number
|
||||
ttl: number
|
||||
fromPlayer: boolean
|
||||
nextDelay: number
|
||||
}[] = []
|
||||
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const angle = (i / 64) * 2 * Math.PI
|
||||
const vx = Math.cos(angle) * speed
|
||||
const vy = Math.sin(angle) * speed * ISO_GROUND_ASPECT_RATIO
|
||||
|
||||
bolts.push({
|
||||
skillId,
|
||||
missileType,
|
||||
x: startX,
|
||||
y: startY,
|
||||
vx,
|
||||
vy,
|
||||
damage,
|
||||
ttl: 13,
|
||||
fromPlayer,
|
||||
nextDelay: 4,
|
||||
})
|
||||
}
|
||||
|
||||
return bolts
|
||||
}
|
||||
|
||||
export interface StaticFieldTarget {
|
||||
readonly id: string
|
||||
readonly x: number
|
||||
readonly y: number
|
||||
readonly currentHp: number
|
||||
readonly maxHp: number
|
||||
readonly lightResist?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone helper to execute 1.13c Static Field on target records.
|
||||
*/
|
||||
export function executeStaticField(params: {
|
||||
casterX: number
|
||||
casterY: number
|
||||
slvl: number
|
||||
targets: readonly StaticFieldTarget[]
|
||||
difficulty?: 'normal' | 'nightmare' | 'hell'
|
||||
isBlocked?: ((x: number, y: number, fromX?: number, fromY?: number) => boolean) | MissileTerrainCollision | undefined
|
||||
}): {
|
||||
radiusPx: number
|
||||
hits: {
|
||||
targetId: string
|
||||
damage: number
|
||||
newHp: number
|
||||
immune: boolean
|
||||
blockedByFloor: boolean
|
||||
blockedByLos: boolean
|
||||
}[]
|
||||
} {
|
||||
const radiusPx = calculateStaticFieldRadiusPx(params.slvl)
|
||||
const difficulty = params.difficulty ?? 'normal'
|
||||
const hits: {
|
||||
targetId: string
|
||||
damage: number
|
||||
newHp: number
|
||||
immune: boolean
|
||||
blockedByFloor: boolean
|
||||
blockedByLos: boolean
|
||||
}[] = []
|
||||
|
||||
for (const t of params.targets) {
|
||||
const dist = Math.hypot(t.x - params.casterX, t.y - params.casterY)
|
||||
if (dist > radiusPx) continue
|
||||
|
||||
if (params.isBlocked && !hasLineOfSight(params.casterX, params.casterY, t.x, t.y, params.isBlocked)) {
|
||||
hits.push({
|
||||
targetId: t.id,
|
||||
damage: 0,
|
||||
newHp: t.currentHp,
|
||||
immune: false,
|
||||
blockedByFloor: false,
|
||||
blockedByLos: true,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const res = calculateStaticFieldDamage(t.currentHp, t.maxHp, difficulty, t.lightResist ?? 0)
|
||||
hits.push({
|
||||
targetId: t.id,
|
||||
damage: res.damage,
|
||||
newHp: res.newHp,
|
||||
immune: res.immune,
|
||||
blockedByFloor: res.blockedByFloor,
|
||||
blockedByLos: false,
|
||||
})
|
||||
}
|
||||
|
||||
return { radiusPx, hits }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,7 +25,30 @@ import type { D2Table } from './acts.ts'
|
|||
import type { Rng } from './rng.ts'
|
||||
import type { CharacterClassCode } from './classes.ts'
|
||||
import type { CombatPlayer } from './combat.ts'
|
||||
import { FROZEN_ORB_X_POSITIONS, FROZEN_ORB_Y_POSITIONS } from './engine/missile-engine.ts'
|
||||
import {
|
||||
FROZEN_ORB_X_POSITIONS,
|
||||
FROZEN_ORB_Y_POSITIONS,
|
||||
calculateNovaDamage,
|
||||
calculateStaticFieldDamage,
|
||||
calculateStaticFieldRadiusYards,
|
||||
calculateStaticFieldRadiusPx,
|
||||
hasLineOfSight,
|
||||
spawnNovaRing,
|
||||
executeStaticField,
|
||||
type StaticFieldResult,
|
||||
type StaticFieldTarget,
|
||||
} from './engine/missile-engine.ts'
|
||||
export {
|
||||
calculateNovaDamage,
|
||||
calculateStaticFieldDamage,
|
||||
calculateStaticFieldRadiusYards,
|
||||
calculateStaticFieldRadiusPx,
|
||||
hasLineOfSight,
|
||||
spawnNovaRing,
|
||||
executeStaticField,
|
||||
type StaticFieldResult,
|
||||
type StaticFieldTarget,
|
||||
} from './engine/missile-engine.ts'
|
||||
import {
|
||||
BLIZZARD_CIRCLE_X,
|
||||
BLIZZARD_CIRCLE_Y,
|
||||
|
|
@ -137,6 +160,9 @@ export interface Projectile {
|
|||
readonly fireDuration?: number | undefined
|
||||
/** Residual ground fire damage per tick. */
|
||||
readonly residualDamage?: number | undefined
|
||||
/** Authentic 1.13c NextHit flag and NextDelay immunity window in frames. */
|
||||
readonly nextHit?: boolean | number | undefined
|
||||
readonly nextDelay?: number | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -197,10 +223,59 @@ export interface MissileTxtData {
|
|||
readonly speedPxPerSec: number
|
||||
/** Calculated flight distance in pixels: vel * range */
|
||||
readonly distancePx: number
|
||||
readonly nextHit?: number | boolean | undefined
|
||||
readonly nextDelay?: number | undefined
|
||||
readonly trans?: number | undefined
|
||||
}
|
||||
|
||||
/** Canonical 1.13c Ground Truth Missiles.txt defaults. */
|
||||
export const CANONICAL_113C_MISSILES: Readonly<Record<string, MissileTxtData>> = Object.freeze({
|
||||
nova: {
|
||||
name: 'nova',
|
||||
id: 90,
|
||||
vel: 24,
|
||||
maxVel: 24,
|
||||
range: 13,
|
||||
levRange: 0,
|
||||
celFile: 'ElectricNova',
|
||||
animLen: 13,
|
||||
animSpeed: 16,
|
||||
loopAnim: 1,
|
||||
numDirections: 16,
|
||||
explosionMissile: '',
|
||||
light: 0,
|
||||
red: 255,
|
||||
green: 255,
|
||||
blue: 255,
|
||||
nextHit: 1,
|
||||
nextDelay: 4,
|
||||
trans: 1,
|
||||
speedPxPerSec: 600,
|
||||
distancePx: 312,
|
||||
},
|
||||
electricnova: {
|
||||
name: 'electricnova',
|
||||
id: 90,
|
||||
vel: 24,
|
||||
maxVel: 24,
|
||||
range: 13,
|
||||
levRange: 0,
|
||||
celFile: 'ElectricNova',
|
||||
animLen: 13,
|
||||
animSpeed: 16,
|
||||
loopAnim: 1,
|
||||
numDirections: 16,
|
||||
explosionMissile: '',
|
||||
light: 0,
|
||||
red: 255,
|
||||
green: 255,
|
||||
blue: 255,
|
||||
nextHit: 1,
|
||||
nextDelay: 4,
|
||||
trans: 1,
|
||||
speedPxPerSec: 600,
|
||||
distancePx: 312,
|
||||
},
|
||||
firebolt: {
|
||||
name: 'firebolt',
|
||||
id: 58,
|
||||
|
|
@ -1129,6 +1204,9 @@ export function parseMissilesTxt(content: string): Map<string, MissileTxtData> {
|
|||
const redIdx = col('Red')
|
||||
const greenIdx = col('Green')
|
||||
const blueIdx = col('Blue')
|
||||
const nextHitIdx = col('NextHit')
|
||||
const nextDelayIdx = col('NextDelay')
|
||||
const transIdx = col('Trans')
|
||||
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const cells = lines[i]!.split('\t')
|
||||
|
|
@ -1149,6 +1227,9 @@ export function parseMissilesTxt(content: string): Map<string, MissileTxtData> {
|
|||
const red = Number(cells[redIdx] ?? 255) || 255
|
||||
const green = Number(cells[greenIdx] ?? 255) || 255
|
||||
const blue = Number(cells[blueIdx] ?? 255) || 255
|
||||
const nextHit = Number(cells[nextHitIdx] ?? 0) || 0
|
||||
const nextDelay = Number(cells[nextDelayIdx] ?? 0) || 0
|
||||
const trans = Number(cells[transIdx] ?? 0) || 0
|
||||
|
||||
const rec: MissileTxtData = {
|
||||
name,
|
||||
|
|
@ -1167,10 +1248,16 @@ export function parseMissilesTxt(content: string): Map<string, MissileTxtData> {
|
|||
red,
|
||||
green,
|
||||
blue,
|
||||
nextHit,
|
||||
nextDelay,
|
||||
trans,
|
||||
speedPxPerSec: vel * 25,
|
||||
distancePx: vel * range,
|
||||
}
|
||||
map.set(name, rec)
|
||||
if (name === 'nova') {
|
||||
map.set('electricnova', { ...rec, name: 'electricnova' })
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
|
@ -3578,11 +3665,14 @@ export function tickProjectiles(
|
|||
readonly overlap: (x: number, y: number) => number
|
||||
readonly isMissileBlocked?: ((x: number, y: number, fromX?: number, fromY?: number) => boolean) | undefined
|
||||
readonly radius?: number
|
||||
readonly nextDelayByTarget?: Map<number, number> | undefined
|
||||
readonly currentTick?: number | undefined
|
||||
},
|
||||
): ProjectileOutcome {
|
||||
const radius = options.radius ?? 8
|
||||
const alive: Projectile[] = []
|
||||
const spawned: Projectile[] = []
|
||||
const waveNextDelayHits = new Set<number>()
|
||||
const hits: {
|
||||
targetIndex: number
|
||||
damage: number
|
||||
|
|
@ -4025,6 +4115,20 @@ export function tickProjectiles(
|
|||
: Math.hypot(target.x - x, target.y - y)
|
||||
if (dist > target.radius + radius) continue
|
||||
|
||||
const hasNextDelay =
|
||||
(projectile.nextDelay !== undefined && projectile.nextDelay > 0) ||
|
||||
projectile.missileType === 'nova' ||
|
||||
projectile.missileType === 'electricnova'
|
||||
const nextDelayFrames = projectile.nextDelay ?? 4
|
||||
|
||||
if (hasNextDelay) {
|
||||
if (waveNextDelayHits.has(target.index)) continue
|
||||
if (options.nextDelayByTarget && options.currentTick !== undefined) {
|
||||
const exp = options.nextDelayByTarget.get(target.index)
|
||||
if (exp !== undefined && exp > options.currentTick) continue
|
||||
}
|
||||
}
|
||||
|
||||
const statusEffect =
|
||||
projectile.statusEffect ??
|
||||
(isBlizzardShard(projectile.missileType) || projectile.missileType?.startsWith('blizzardexplode')
|
||||
|
|
@ -4049,6 +4153,13 @@ export function tickProjectiles(
|
|||
isUndeadOnly: projectile.isUndeadOnly,
|
||||
})
|
||||
|
||||
if (hasNextDelay) {
|
||||
waveNextDelayHits.add(target.index)
|
||||
if (options.nextDelayByTarget && options.currentTick !== undefined) {
|
||||
options.nextDelayByTarget.set(target.index, options.currentTick + nextDelayFrames)
|
||||
}
|
||||
}
|
||||
|
||||
hitTargets.add(target.index)
|
||||
if (!projectile.pierce) {
|
||||
struck = true
|
||||
|
|
@ -4216,6 +4327,15 @@ export function isBatch1Skill(skillId: number): boolean {
|
|||
* - Resilience: Invalid / NaN / non-positive levels default to level 1.
|
||||
*/
|
||||
export function getSkillManaCost(skillId: number, effectiveLevel: number): number {
|
||||
if (skillId === 48) {
|
||||
const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1
|
||||
// 1.13c Skills.txt: mana = 15, lvlmana = 1, manashift = 8
|
||||
return 15 + (lvl - 1)
|
||||
}
|
||||
if (skillId === 42) {
|
||||
// 1.13c Skills.txt: mana = 9, lvlmana = 0, manashift = 8
|
||||
return 9
|
||||
}
|
||||
if (skillId === 56) {
|
||||
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)
|
||||
|
|
@ -4257,6 +4377,9 @@ export function getSkillManaCost(skillId: number, effectiveLevel: number): numbe
|
|||
* - All other Batch 1 skills: 0 frames
|
||||
*/
|
||||
export function getSkillCooldownTicks(skillId: number): number {
|
||||
if (skillId === 48 || skillId === 42) {
|
||||
return 0
|
||||
}
|
||||
if (skillId === 56) {
|
||||
return 30
|
||||
}
|
||||
|
|
@ -4296,6 +4419,12 @@ export function calculateSkillDamage(
|
|||
},
|
||||
weaponDamage?: { min: number; max: number }
|
||||
): { min: number; max: number } {
|
||||
if (skillId === 48 || skillId === '48' || skillId === 'nova') {
|
||||
return calculateNovaDamage(slvl)
|
||||
}
|
||||
if (skillId === 42 || skillId === '42' || skillId === 'staticfield' || skillId === 'static field') {
|
||||
return { min: 0, max: 0 }
|
||||
}
|
||||
if (skillId === 59 || skillId === '59' || skillId === 'blizzard') {
|
||||
return calculateBlizzardDamage(slvl, synergies)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ import { buildNpcDef, hasPackedSprite } from '../game/npc.ts'
|
|||
import { ADDITIVE_OBJECT_TOKENS } from '../game/objects.ts'
|
||||
import { npcDialog } 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 } from '../game/skills.ts'
|
||||
import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, calculateSkillDamage, calculateMeteorResidualFireDamage, getMissileTxtData, ISO_GROUND_ASPECT_RATIO, getSkillCastOverlay, isAuraSkill, calculateStaticFieldRadiusPx, calculateStaticFieldDamage, hasLineOfSight } from '../game/skills.ts'
|
||||
import type { Projectile, ActiveOverlay } from '../game/skills.ts'
|
||||
import { findSafeDropPosition, calculateBounceHeight, triggerFlippyBounce, type GroundItemEntity } from '../game/ground-items.ts'
|
||||
import {
|
||||
|
|
@ -1290,18 +1290,33 @@ export function castSkill(
|
|||
}
|
||||
|
||||
if (skillId === 42) {
|
||||
// Static Field: AoE radius 160 around player, 25% current HP
|
||||
// Static Field (Skill 42): Instantaneous 360° area discharge
|
||||
// 1.13c: Radius = (4 + slvl) yards = (4 + slvl) * 40 px in 2D ground plane
|
||||
// Cuts 25% current HP of non-lightning-immune targets, obeys LOS & difficulty floors
|
||||
// Delay = 0 frames (NO cast delay)
|
||||
const slvl = (hudManager as any)?.getSkillLevel?.(42) ?? 1
|
||||
const radiusPx = calculateStaticFieldRadiusPx(slvl)
|
||||
const difficulty = (((engine.world as any).difficulty ?? 'normal') as string).toLowerCase() as 'normal' | 'nightmare' | 'hell'
|
||||
let hitCount = 0
|
||||
for (const monster of engine.world.monsters) {
|
||||
if (monster.state === 'dead' || monster.hp <= 0) continue
|
||||
if (Math.hypot(monster.x - player.x, monster.y - player.y) <= 160) {
|
||||
const dmg = Math.max(1, Math.floor(monster.hp * 0.25))
|
||||
damageMonster(engine.world, monster.index, dmg)
|
||||
engine.metrics.castHits += 1
|
||||
hitCount++
|
||||
const dist = Math.hypot(monster.x - player.x, monster.y - player.y)
|
||||
if (dist <= radiusPx) {
|
||||
if (!hasLineOfSight(player.x, player.y, monster.x, monster.y, engine.terrain)) {
|
||||
continue
|
||||
}
|
||||
const maxHp = monster.stats.hp ?? monster.hp
|
||||
const ltngRes = (monster.stats as any).resistLightning ?? 0
|
||||
const res = calculateStaticFieldDamage(monster.hp, maxHp, difficulty, ltngRes)
|
||||
if (res.damage > 0) {
|
||||
damageMonster(engine.world, monster.index, res.damage)
|
||||
engine.metrics.castHits += 1
|
||||
hitCount++
|
||||
engine.spawnOverlay('lightning', { x: monster.x, y: monster.y })
|
||||
}
|
||||
}
|
||||
}
|
||||
player.cooldown = 10
|
||||
player.cooldown = 0
|
||||
engine.metrics.casts += 1
|
||||
status.textContent = `施展静态力场,削弱 ${hitCount} 个目标`
|
||||
return true
|
||||
|
|
@ -1533,6 +1548,18 @@ export function castSkill(
|
|||
isNova = true
|
||||
multiCount = 32
|
||||
skillName = '剧毒新星'
|
||||
} else if (skillId === 48) {
|
||||
const missile = getMissileTxtData('nova')
|
||||
speed = missile.speedPxPerSec || 600
|
||||
range = missile.distancePx || 312
|
||||
const slvl = (hudManager as any)?.getSkillLevel?.(48) ?? 1
|
||||
const dmgRange = calculateSkillDamage(48, slvl)
|
||||
damage = Math.round((dmgRange.min + dmgRange.max) / 2)
|
||||
delay = 0
|
||||
missileType = 'electricnova'
|
||||
isNova = true
|
||||
multiCount = 64
|
||||
skillName = '新星'
|
||||
} else if (skillId === 101) {
|
||||
const missile = getMissileTxtData('holybolt')
|
||||
speed = missile.speedPxPerSec || 500
|
||||
|
|
@ -1672,6 +1699,8 @@ export function castSkill(
|
|||
missileType,
|
||||
statusEffect,
|
||||
statusDuration,
|
||||
nextHit: 1,
|
||||
nextDelay: 4,
|
||||
})
|
||||
}
|
||||
} else if (multiCount > 1) {
|
||||
|
|
|
|||
|
|
@ -1,109 +1,431 @@
|
|||
/**
|
||||
* 1.13c Numerical & Runtime Verification Suite — Skill #042: Static Field (SOR)
|
||||
* 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #042: Static Field (SOR)
|
||||
* Gitea Tracking Issue: #196
|
||||
*
|
||||
* 7-Dimensional Verification Coverage:
|
||||
* D1: 1.13c Data Table Parsing & Ground Truth (Skills.txt row 42, srvdofunc 20, non-projectile)
|
||||
* D2: Visual Assets & Metadata Integrity (light_cast_1.png/json, lightning.png/json, Additive Blending)
|
||||
* D3: Instantaneous 360° AoE Geometry & Radius Scaling (4 + slvl yards, 40 px/yard, 200px at lvl 1 to 960px at lvl 20)
|
||||
* D4: 25% Current HP Cut, Resistance & Immunity Scaling (Cuts 25% of current HP, lightning resist reduces, immunity ignores)
|
||||
* D5: Line-of-Sight (LOS) Raycast Obstruction (Targets behind solid walls receive 0 damage)
|
||||
* D6: Difficulty Floor Clamps & Assembly Pre-Check Invariant (D2Game.dll 0x6FC62F13: Normal 1 HP, NM 33%, Hell 50%)
|
||||
* D7: WorldArena & Runtime Cast Integration (Cast action frame, 9 flat mana, 0 cooldown, cast & hit overlays)
|
||||
*/
|
||||
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 { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
||||
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
||||
import { skillModule } from '../../../src/game/skills/impl/sor/skill-042-static-field.ts'
|
||||
import {
|
||||
getSkillManaCost,
|
||||
getSkillCooldownTicks,
|
||||
getSkillCastOverlay,
|
||||
SORCERESS_SKILL_CAST_OVERLAYS,
|
||||
calculateStaticFieldDamage,
|
||||
calculateStaticFieldRadiusYards,
|
||||
calculateStaticFieldRadiusPx,
|
||||
hasLineOfSight,
|
||||
executeStaticField,
|
||||
} from '../../../src/game/skills.ts'
|
||||
import {
|
||||
LIGHT_CAST_1_META,
|
||||
LIGHTNING_OVERLAY_META,
|
||||
OVERLAY_METAS,
|
||||
} from '../../../src/render/overlays-meta.ts'
|
||||
|
||||
describe('[Skill #042] Static Field (SOR) — 1.13c Parity (Issue #196)', () => {
|
||||
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const skillRec = registry.getSkillById(42)!
|
||||
expect(skillRec).toBeDefined()
|
||||
expect(skillModule.skillId).toBe(42)
|
||||
expect(skillModule.name).toBe("Static Field")
|
||||
expect(skillModule.charClass).toBe("sor")
|
||||
describe('[Skill #042] Static Field (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #196)', () => {
|
||||
const skillsTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Skills.txt')
|
||||
const skillsTxtContent = fs.readFileSync(skillsTxtPath, 'latin1')
|
||||
|
||||
const stats1 = new UnitStatList()
|
||||
stats1.setBaseSkillLevel(42, 1)
|
||||
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
|
||||
expect(r1.manaCost).toBe(9)
|
||||
expect(r1.manaCost256).toBe(2304)
|
||||
expect(r1.minPhysDmg).toBe(0)
|
||||
expect(r1.maxPhysDmg).toBe(0)
|
||||
expect(r1.minElemDmg256).toBe(0)
|
||||
expect(r1.maxElemDmg256).toBe(0)
|
||||
// =========================================================================
|
||||
// Dimension 1: 1.13c Data Table Parsing & Ground Truth
|
||||
// =========================================================================
|
||||
describe('Dimension 1: 1.13c Data Table Parsing & Ground Truth', () => {
|
||||
it('parses authentic Skills.txt row 42 (Static Field)', () => {
|
||||
const lines = skillsTxtContent.split('\n')
|
||||
const headers = lines[0]!.split('\t')
|
||||
const idIdx = headers.indexOf('Id')
|
||||
const skillIdx = headers.indexOf('skill')
|
||||
const charClassIdx = headers.indexOf('charclass')
|
||||
const manaIdx = headers.indexOf('mana')
|
||||
const lvlManaIdx = headers.indexOf('lvlmana')
|
||||
const srvDoFuncIdx = headers.indexOf('srvdofunc')
|
||||
const losIdx = headers.indexOf('LineOfSight')
|
||||
const p1Idx = headers.indexOf('Param1')
|
||||
const p2Idx = headers.indexOf('Param2')
|
||||
const p4Idx = headers.indexOf('Param4')
|
||||
const rangeCalcIdx = headers.indexOf('aurarangecalc')
|
||||
const castOverlayIdx = headers.indexOf('castoverlay')
|
||||
const srvMissileAIdx = headers.indexOf('srvmissilea')
|
||||
|
||||
const stats10 = new UnitStatList()
|
||||
stats10.setBaseSkillLevel(42, 10)
|
||||
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
|
||||
expect(r10.manaCost).toBe(9)
|
||||
expect(r10.manaCost256).toBe(2304)
|
||||
expect(r10.minPhysDmg).toBe(0)
|
||||
expect(r10.maxPhysDmg).toBe(0)
|
||||
expect(r10.minElemDmg256).toBe(0)
|
||||
expect(r10.maxElemDmg256).toBe(0)
|
||||
const row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx] === '42')
|
||||
expect(row).toBeDefined()
|
||||
expect(row![skillIdx]).toBe('Static Field')
|
||||
expect(row![charClassIdx]).toBe('sor')
|
||||
expect(Number(row![manaIdx])).toBe(9)
|
||||
expect(Number(row![lvlManaIdx])).toBe(0)
|
||||
expect(Number(row![srvDoFuncIdx])).toBe(20) // SKILLS_SrvDo020_StaticField
|
||||
expect(Number(row![losIdx])).toBe(4) // Requires Line Of Sight
|
||||
expect(Number(row![p1Idx])).toBe(5) // Base radius: 5 yards
|
||||
expect(Number(row![p2Idx])).toBe(1) // Radius per level: 1 yard
|
||||
expect(Number(row![p4Idx])).toBe(25) // 25% current HP damage
|
||||
expect(row![rangeCalcIdx]).toBe('ln12')
|
||||
expect(row![castOverlayIdx]).toBe('light_cast_1')
|
||||
// Non-projectile: no missile entity spawned
|
||||
expect(row![srvMissileAIdx] ?? '').toBe('')
|
||||
})
|
||||
|
||||
const stats20 = new UnitStatList()
|
||||
stats20.setBaseSkillLevel(42, 20)
|
||||
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
|
||||
expect(r20.manaCost).toBe(9)
|
||||
expect(r20.manaCost256).toBe(2304)
|
||||
expect(r20.minPhysDmg).toBe(0)
|
||||
expect(r20.maxPhysDmg).toBe(0)
|
||||
expect(r20.minElemDmg256).toBe(0)
|
||||
expect(r20.maxElemDmg256).toBe(0)
|
||||
expect(r20.durationFrames).toBe(0)
|
||||
expect(r20.radiusSubtiles).toBe(24)
|
||||
expect(r20.petMax).toBe(0)
|
||||
expect(r20.cooldownFrames).toBe(0)
|
||||
it('verifies canonical skill metadata registration for Skill 42', () => {
|
||||
expect(getSkillManaCost(42, 1)).toBe(9)
|
||||
expect(getSkillManaCost(42, 10)).toBe(9)
|
||||
expect(getSkillCooldownTicks(42)).toBe(0)
|
||||
expect(getSkillCastOverlay(42)).toBe('light_cast_1')
|
||||
expect(SORCERESS_SKILL_CAST_OVERLAYS[42]).toBe('light_cast_1')
|
||||
})
|
||||
})
|
||||
|
||||
it('enforces 1.13c blvl synergy rule (hard points boost synergy; +skills and item charges do NOT)', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const skillRec = registry.getSkillById(42)!
|
||||
const synId = null
|
||||
// =========================================================================
|
||||
// Dimension 2: Visual Assets & Metadata Integrity
|
||||
// =========================================================================
|
||||
describe('Dimension 2: Visual Assets & Metadata Integrity', () => {
|
||||
it('verifies cast and hit overlay atlas sprite sheets exist with non-zero size', () => {
|
||||
const castPng = path.resolve(__dirname, '../../../public/overlays/light_cast_1.png')
|
||||
const castJson = path.resolve(__dirname, '../../../public/overlays/light_cast_1.json')
|
||||
const hitPng = path.resolve(__dirname, '../../../public/overlays/lightning.png')
|
||||
const hitJson = path.resolve(__dirname, '../../../public/overlays/lightning.json')
|
||||
|
||||
const statsBase = new UnitStatList()
|
||||
statsBase.setBaseSkillLevel(42, 20)
|
||||
if (synId !== null) {
|
||||
statsBase.setBaseSkillLevel(synId, 20)
|
||||
}
|
||||
const withHardPoints = skillModule.evaluate!({
|
||||
registry,
|
||||
skill: skillRec,
|
||||
slvl: 20,
|
||||
blvl: 20,
|
||||
statList: statsBase,
|
||||
expect(fs.existsSync(castPng)).toBe(true)
|
||||
expect(fs.statSync(castPng).size).toBeGreaterThan(10000)
|
||||
expect(fs.existsSync(castJson)).toBe(true)
|
||||
expect(fs.existsSync(hitPng)).toBe(true)
|
||||
expect(fs.statSync(hitPng).size).toBeGreaterThan(10000)
|
||||
expect(fs.existsSync(hitJson)).toBe(true)
|
||||
})
|
||||
expect(withHardPoints.synergyBonusPct).toBe(0)
|
||||
|
||||
// +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy
|
||||
const statsSoftOnly = new UnitStatList()
|
||||
statsSoftOnly.setBaseSkillLevel(42, 20)
|
||||
statsSoftOnly.addStat('item_allskills', 20)
|
||||
if (synId !== null) {
|
||||
statsSoftOnly.setChargedSkillLevel(synId, 33)
|
||||
}
|
||||
const withSoftOnly = skillModule.evaluate!({
|
||||
registry,
|
||||
skill: skillRec,
|
||||
slvl: 20,
|
||||
blvl: 20,
|
||||
statList: statsSoftOnly,
|
||||
it('verifies cast overlay mapping and LIGHT_CAST_1_META metadata', () => {
|
||||
expect(SORCERESS_SKILL_CAST_OVERLAYS[42]).toBe('light_cast_1')
|
||||
expect(getSkillCastOverlay(42)).toBe('light_cast_1')
|
||||
expect(LIGHT_CAST_1_META.name).toBe('light_cast_1')
|
||||
expect(OVERLAY_METAS['light_cast_1']).toBe(LIGHT_CAST_1_META)
|
||||
})
|
||||
|
||||
it('verifies hit overlay LIGHTNING_OVERLAY_META metadata', () => {
|
||||
expect(LIGHTNING_OVERLAY_META.name).toBe('lightning')
|
||||
expect(LIGHTNING_OVERLAY_META.celFile).toBe('ShockHitSm')
|
||||
expect(LIGHTNING_OVERLAY_META.frames).toBe(20)
|
||||
expect(OVERLAY_METAS['lightning']).toBe(LIGHTNING_OVERLAY_META)
|
||||
})
|
||||
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()
|
||||
const arena = new WorldArena({
|
||||
registry,
|
||||
classCode: "sor",
|
||||
skillId: 42,
|
||||
slvl: 20,
|
||||
// =========================================================================
|
||||
// Dimension 3: Instantaneous 360° AoE Geometry & Radius Scaling
|
||||
// =========================================================================
|
||||
describe('Dimension 3: Instantaneous 360° AoE Geometry & Radius Scaling', () => {
|
||||
it('calculates exact 1.13c radius in yards: (4 + slvl)', () => {
|
||||
expect(calculateStaticFieldRadiusYards(1)).toBe(5)
|
||||
expect(calculateStaticFieldRadiusYards(10)).toBe(14)
|
||||
expect(calculateStaticFieldRadiusYards(20)).toBe(24)
|
||||
expect(calculateStaticFieldRadiusYards(30)).toBe(34)
|
||||
})
|
||||
const outcome = arena.triggerCast()
|
||||
arena.stepTicks(15)
|
||||
const dbg = arena.getDebugState()
|
||||
|
||||
expect(dbg.ready).toBe(true)
|
||||
expect(dbg.skillId).toBe(42)
|
||||
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
|
||||
expect(dbg.actionFrameTriggered).toBe(true)
|
||||
expect(outcome?.executed).toBe(true)
|
||||
it('calculates exact radius in pixels: (4 + slvl) * 40 px', () => {
|
||||
expect(calculateStaticFieldRadiusPx(1)).toBe(200) // 5 * 40
|
||||
expect(calculateStaticFieldRadiusPx(10)).toBe(560) // 14 * 40
|
||||
expect(calculateStaticFieldRadiusPx(20)).toBe(960) // 24 * 40 (covers 800x600 screen)
|
||||
expect(calculateStaticFieldRadiusPx(30)).toBe(1360)
|
||||
})
|
||||
|
||||
it('executeStaticField hits targets inside radius and excludes targets outside radius', () => {
|
||||
// slvl 1 radius is 200 px
|
||||
const targetInside = { id: 'm1', x: 150, y: 0, currentHp: 1000, maxHp: 1000 }
|
||||
const targetOutside = { id: 'm2', x: 250, y: 0, currentHp: 1000, maxHp: 1000 }
|
||||
|
||||
const result = executeStaticField({
|
||||
casterX: 0,
|
||||
casterY: 0,
|
||||
slvl: 1,
|
||||
targets: [targetInside, targetOutside],
|
||||
difficulty: 'normal',
|
||||
})
|
||||
|
||||
expect(result.radiusPx).toBe(200)
|
||||
// Only targetInside should be hit
|
||||
expect(result.hits.length).toBe(1)
|
||||
expect(result.hits[0]!.targetId).toBe('m1')
|
||||
expect(result.hits[0]!.damage).toBe(250) // 25% of 1000
|
||||
expect(result.hits[0]!.newHp).toBe(750)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 4: 25% Current HP Cut, Resistance & Immunity Scaling
|
||||
// =========================================================================
|
||||
describe('Dimension 4: 25% Current HP Cut, Resistance & Immunity Scaling', () => {
|
||||
it('cuts 25% of current HP sequentially on repeated casts', () => {
|
||||
// Cast 1: 1000 HP -> cuts 250 -> 750 HP
|
||||
const r1 = calculateStaticFieldDamage(1000, 1000, 'normal', 0)
|
||||
expect(r1.damage).toBe(250)
|
||||
expect(r1.newHp).toBe(750)
|
||||
expect(r1.immune).toBe(false)
|
||||
expect(r1.blockedByFloor).toBe(false)
|
||||
|
||||
// Cast 2: 750 HP -> cuts floor(750 * 0.25) = 187 -> 563 HP
|
||||
const r2 = calculateStaticFieldDamage(750, 1000, 'normal', 0)
|
||||
expect(r2.damage).toBe(187)
|
||||
expect(r2.newHp).toBe(563)
|
||||
|
||||
// Cast 3: 563 HP -> cuts floor(563 * 0.25) = 140 -> 423 HP
|
||||
const r3 = calculateStaticFieldDamage(563, 1000, 'normal', 0)
|
||||
expect(r3.damage).toBe(140)
|
||||
expect(r3.newHp).toBe(423)
|
||||
})
|
||||
|
||||
it('scales damage proportionally with lightning resistance', () => {
|
||||
// 50% lightning resist: 250 * (100 - 50) / 100 = 125 damage
|
||||
const r50 = calculateStaticFieldDamage(1000, 1000, 'normal', 50)
|
||||
expect(r50.damage).toBe(125)
|
||||
expect(r50.newHp).toBe(875)
|
||||
|
||||
// 75% lightning resist: 250 * (100 - 75) / 100 = 62 damage
|
||||
const r75 = calculateStaticFieldDamage(1000, 1000, 'normal', 75)
|
||||
expect(r75.damage).toBe(62)
|
||||
expect(r75.newHp).toBe(938)
|
||||
})
|
||||
|
||||
it('deals 0 damage to lightning-immune enemies (resistance >= 100)', () => {
|
||||
// 100% lightning resist (immune)
|
||||
const r100 = calculateStaticFieldDamage(1000, 1000, 'normal', 100)
|
||||
expect(r100.damage).toBe(0)
|
||||
expect(r100.newHp).toBe(1000)
|
||||
expect(r100.immune).toBe(true)
|
||||
|
||||
// 120% lightning resist (over-capped immune)
|
||||
const r120 = calculateStaticFieldDamage(1000, 1000, 'normal', 120)
|
||||
expect(r120.damage).toBe(0)
|
||||
expect(r120.newHp).toBe(1000)
|
||||
expect(r120.immune).toBe(true)
|
||||
})
|
||||
|
||||
it('enforces 1.13c zero synergy rule (Static Field receives 0% synergy from other skills)', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const skillRec = registry.getSkillById(42)!
|
||||
|
||||
const statsBase = new UnitStatList()
|
||||
statsBase.setBaseSkillLevel(42, 20)
|
||||
statsBase.setBaseSkillLevel(48, 20) // Nova 20
|
||||
statsBase.setBaseSkillLevel(49, 20) // Lightning 20
|
||||
const withHardPoints = skillModule.evaluate!({
|
||||
registry,
|
||||
skill: skillRec,
|
||||
slvl: 20,
|
||||
blvl: 20,
|
||||
statList: statsBase,
|
||||
})
|
||||
expect(withHardPoints.synergyBonusPct).toBe(0)
|
||||
|
||||
const statsSoftOnly = new UnitStatList()
|
||||
statsSoftOnly.setBaseSkillLevel(42, 20)
|
||||
statsSoftOnly.addStat('item_allskills', 20)
|
||||
const withSoftOnly = skillModule.evaluate!({
|
||||
registry,
|
||||
skill: skillRec,
|
||||
slvl: 20,
|
||||
blvl: 20,
|
||||
statList: statsSoftOnly,
|
||||
})
|
||||
expect(withSoftOnly.synergyBonusPct).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 5: Line-of-Sight (LOS) Raycast Obstruction
|
||||
// =========================================================================
|
||||
describe('Dimension 5: Line-of-Sight (LOS) Raycast Obstruction', () => {
|
||||
it('hasLineOfSight correctly distinguishes clear vs obstructed paths', () => {
|
||||
// Wall between x=40 and x=60
|
||||
const isWallBlocked = (x: number) => x >= 40 && x <= 60
|
||||
|
||||
// Path from (0, 0) to (100, 0) intersects wall -> false
|
||||
expect(hasLineOfSight(0, 0, 100, 0, isWallBlocked)).toBe(false)
|
||||
|
||||
// Path from (0, 0) to (30, 0) stays in front of wall -> true
|
||||
expect(hasLineOfSight(0, 0, 30, 0, isWallBlocked)).toBe(true)
|
||||
|
||||
// Path from (70, 0) to (100, 0) stays behind wall -> true
|
||||
expect(hasLineOfSight(70, 0, 100, 0, isWallBlocked)).toBe(true)
|
||||
})
|
||||
|
||||
it('executeStaticField blocks damage to targets obstructed by solid walls', () => {
|
||||
const isWallBlocked = (x: number) => x >= 40 && x <= 60
|
||||
const targetBehindWall = { id: 'wall_target', x: 100, y: 0, currentHp: 1000, maxHp: 1000 }
|
||||
const targetInOpen = { id: 'open_target', x: 0, y: 100, currentHp: 1000, maxHp: 1000 }
|
||||
|
||||
const result = executeStaticField({
|
||||
casterX: 0,
|
||||
casterY: 0,
|
||||
slvl: 5,
|
||||
targets: [targetBehindWall, targetInOpen],
|
||||
difficulty: 'normal',
|
||||
isBlocked: (x: number) => isWallBlocked(x),
|
||||
})
|
||||
|
||||
const behindHit = result.hits.find(h => h.targetId === 'wall_target')
|
||||
expect(behindHit).toBeDefined()
|
||||
expect(behindHit!.blockedByLos).toBe(true)
|
||||
expect(behindHit!.damage).toBe(0)
|
||||
expect(behindHit!.newHp).toBe(1000)
|
||||
|
||||
const openHit = result.hits.find(h => h.targetId === 'open_target')
|
||||
expect(openHit).toBeDefined()
|
||||
expect(openHit!.blockedByLos).toBe(false)
|
||||
expect(openHit!.damage).toBe(250)
|
||||
expect(openHit!.newHp).toBe(750)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 6: Difficulty Floor Clamps & Assembly Pre-Check Invariant
|
||||
// =========================================================================
|
||||
describe('Dimension 6: Difficulty Floor Clamps & Assembly Pre-Check Invariant', () => {
|
||||
it('Normal difficulty: 1 HP floor (cannot reduce HP below 1, never kills)', () => {
|
||||
// Monster with 10 HP takes 2 damage -> 8 HP
|
||||
const r1 = calculateStaticFieldDamage(10, 100, 'normal', 0)
|
||||
expect(r1.damage).toBe(2)
|
||||
expect(r1.newHp).toBe(8)
|
||||
|
||||
// Monster with 5 HP takes 1 damage (floor(5 * 0.25) = 1) -> 4 HP
|
||||
const r2 = calculateStaticFieldDamage(5, 100, 'normal', 0)
|
||||
expect(r2.damage).toBe(1)
|
||||
expect(r2.newHp).toBe(4)
|
||||
|
||||
// Monster with 2 HP: floor(2 * 0.25) = 0 damage in 1.13c integer division
|
||||
const r3 = calculateStaticFieldDamage(2, 100, 'normal', 0)
|
||||
expect(r3.damage).toBe(0)
|
||||
expect(r3.newHp).toBe(2)
|
||||
|
||||
// Monster with 1 HP: pre-check (currentHp <= 1) returns 0 damage immediately!
|
||||
const r4 = calculateStaticFieldDamage(1, 100, 'normal', 0)
|
||||
expect(r4.damage).toBe(0)
|
||||
expect(r4.newHp).toBe(1)
|
||||
expect(r4.blockedByFloor).toBe(true)
|
||||
})
|
||||
|
||||
it('Nightmare difficulty: 33% max HP floor', () => {
|
||||
// Monster at 400 HP / 1000 max HP (40%): cuts 25% (100) -> 300 HP (< 330 floor, clamped)
|
||||
const r400 = calculateStaticFieldDamage(400, 1000, 'nightmare', 0)
|
||||
expect(r400.damage).toBe(100)
|
||||
expect(r400.newHp).toBe(300)
|
||||
|
||||
// Monster at 330 HP / 1000 max HP (33%): exactly at floor -> returns 0 damage immediately!
|
||||
const r330 = calculateStaticFieldDamage(330, 1000, 'nightmare', 0)
|
||||
expect(r330.damage).toBe(0)
|
||||
expect(r330.newHp).toBe(330)
|
||||
expect(r330.blockedByFloor).toBe(true)
|
||||
|
||||
// Monster at 200 HP / 1000 max HP (20%): below floor -> returns 0 damage immediately!
|
||||
const r200 = calculateStaticFieldDamage(200, 1000, 'nightmare', 0)
|
||||
expect(r200.damage).toBe(0)
|
||||
expect(r200.newHp).toBe(200)
|
||||
expect(r200.blockedByFloor).toBe(true)
|
||||
})
|
||||
|
||||
it('Hell difficulty: 50% max HP floor with authentic assembly pre-check invariant', () => {
|
||||
// Reverse-engineered assembly ground truth scenario (Spec Miner analysis):
|
||||
// Target in Hell has 1000 max HP, starts at 520 current HP (52%)
|
||||
// Floor in Hell is 50% = 500 HP
|
||||
|
||||
// Cast 1: 520 > 500. Cuts 25% of 520 = 130 damage. Target reduced to 390 HP (39%)
|
||||
const cast1 = calculateStaticFieldDamage(520, 1000, 'hell', 0)
|
||||
expect(cast1.damage).toBe(130)
|
||||
expect(cast1.newHp).toBe(390)
|
||||
expect(cast1.blockedByFloor).toBe(false)
|
||||
|
||||
// Cast 2: Current HP is 390 <= 500. D2Game.dll 0x6FC62F13 pre-check triggers!
|
||||
// Returns 0 damage immediately without performing damage calculation!
|
||||
const cast2 = calculateStaticFieldDamage(cast1.newHp, 1000, 'hell', 0)
|
||||
expect(cast2.damage).toBe(0)
|
||||
expect(cast2.newHp).toBe(390)
|
||||
expect(cast2.blockedByFloor).toBe(true)
|
||||
|
||||
// Target at exactly 500 HP (50%): returns 0 damage immediately
|
||||
const castAtFloor = calculateStaticFieldDamage(500, 1000, 'hell', 0)
|
||||
expect(castAtFloor.damage).toBe(0)
|
||||
expect(castAtFloor.newHp).toBe(500)
|
||||
expect(castAtFloor.blockedByFloor).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 7: WorldArena & Runtime Cast Integration
|
||||
// =========================================================================
|
||||
describe('Dimension 7: WorldArena & Runtime Cast Integration', () => {
|
||||
it('evaluates exact 1.13c Skills.txt formulas in skillModule.evaluate', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const skillRec = registry.getSkillById(42)!
|
||||
expect(skillRec).toBeDefined()
|
||||
expect(skillModule.skillId).toBe(42)
|
||||
expect(skillModule.name).toBe("Static Field")
|
||||
expect(skillModule.charClass).toBe("sor")
|
||||
|
||||
const stats1 = new UnitStatList()
|
||||
stats1.setBaseSkillLevel(42, 1)
|
||||
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
|
||||
expect(r1.manaCost).toBe(9)
|
||||
expect(r1.manaCost256).toBe(2304)
|
||||
expect(r1.minPhysDmg).toBe(0)
|
||||
expect(r1.maxPhysDmg).toBe(0)
|
||||
expect(r1.minElemDmg256).toBe(0)
|
||||
expect(r1.maxElemDmg256).toBe(0)
|
||||
|
||||
const stats10 = new UnitStatList()
|
||||
stats10.setBaseSkillLevel(42, 10)
|
||||
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
|
||||
expect(r10.manaCost).toBe(9)
|
||||
expect(r10.manaCost256).toBe(2304)
|
||||
expect(r10.minPhysDmg).toBe(0)
|
||||
expect(r10.maxPhysDmg).toBe(0)
|
||||
expect(r10.minElemDmg256).toBe(0)
|
||||
expect(r10.maxElemDmg256).toBe(0)
|
||||
|
||||
const stats20 = new UnitStatList()
|
||||
stats20.setBaseSkillLevel(42, 20)
|
||||
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
|
||||
expect(r20.manaCost).toBe(9)
|
||||
expect(r20.manaCost256).toBe(2304)
|
||||
expect(r20.minPhysDmg).toBe(0)
|
||||
expect(r20.maxPhysDmg).toBe(0)
|
||||
expect(r20.minElemDmg256).toBe(0)
|
||||
expect(r20.maxElemDmg256).toBe(0)
|
||||
expect(r20.durationFrames).toBe(0)
|
||||
expect(r20.cooldownFrames).toBe(0)
|
||||
})
|
||||
|
||||
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: 42,
|
||||
slvl: 20,
|
||||
})
|
||||
const outcome = arena.triggerCast()
|
||||
arena.stepTicks(15)
|
||||
const dbg = arena.getDebugState()
|
||||
|
||||
expect(dbg.ready).toBe(true)
|
||||
expect(dbg.skillId).toBe(42)
|
||||
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
|
||||
expect(dbg.actionFrameTriggered).toBe(true)
|
||||
expect(outcome?.executed).toBe(true)
|
||||
})
|
||||
|
||||
it('verifies getSkillManaCost and getSkillCooldownTicks', () => {
|
||||
expect(getSkillManaCost(42, 1)).toBe(9)
|
||||
expect(getSkillManaCost(42, 10)).toBe(9)
|
||||
expect(getSkillManaCost(42, 20)).toBe(9)
|
||||
expect(getSkillCooldownTicks(42)).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,109 +1,434 @@
|
|||
/**
|
||||
* 1.13c Numerical & Runtime Verification Suite — Skill #048: Nova (SOR)
|
||||
* 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #048: Nova (SOR)
|
||||
* Gitea Tracking Issue: #202
|
||||
*
|
||||
* 7-Dimensional Verification Coverage:
|
||||
* D1: 1.13c Data Table Parsing & Canonical Missile Definition (Skills.txt row 48, Missiles.txt 'nova')
|
||||
* D2: Visual Assets & Metadata Integrity (electricnova.png/json, light_cast_1.png/json, Additive Blending)
|
||||
* D3: Kinematics & 64-Bolt Circular Ring Dispersion (24 vel, 13 range, 2:1 isometric ground foreshortening)
|
||||
* D4: NextDelay = 4 Single-Hit Lockout Protection (Prevents point-blank 64x multi-hit stack)
|
||||
* D5: Obstacle & Terrain Collision Clipping (Wall collisions expire missiles, open space continues)
|
||||
* D6: 1.13c 5-Band Damage Scaling & Synergy Verification (1-20 at lvl 1, 57-94 at lvl 10, 131-188 at lvl 20, 0 synergies)
|
||||
* D7: WorldArena & Runtime Cast Integration (Cast action frame, mana cost, 0 cooldown, overlay attachment)
|
||||
*/
|
||||
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 { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
||||
import { WorldArena } from '../../../src/game/engine/world-arena.ts'
|
||||
import { skillModule } from '../../../src/game/skills/impl/sor/skill-048-nova.ts'
|
||||
import {
|
||||
parseMissilesTxt,
|
||||
getMissileTxtData,
|
||||
CANONICAL_113C_MISSILES,
|
||||
getSkillManaCost,
|
||||
getSkillCooldownTicks,
|
||||
getSkillCastOverlay,
|
||||
SORCERESS_SKILL_CAST_OVERLAYS,
|
||||
calculateNovaDamage,
|
||||
tickProjectiles,
|
||||
ISO_GROUND_ASPECT_RATIO,
|
||||
type Projectile,
|
||||
} from '../../../src/game/skills.ts'
|
||||
import {
|
||||
MissileEngine,
|
||||
spawnNovaRing,
|
||||
} from '../../../src/game/engine/missile-engine.ts'
|
||||
import {
|
||||
ELECTRICNOVA_META,
|
||||
MISSILE_METAS,
|
||||
} from '../../../src/render/missiles-meta.ts'
|
||||
import {
|
||||
LIGHT_CAST_1_META,
|
||||
OVERLAY_METAS,
|
||||
} from '../../../src/render/overlays-meta.ts'
|
||||
|
||||
describe('[Skill #048] Nova (SOR) — 1.13c Parity (Issue #202)', () => {
|
||||
it('evaluates exact 1.13c Skills.txt / Missiles.txt formulas at slvl 1, 10, and 20', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const skillRec = registry.getSkillById(48)!
|
||||
expect(skillRec).toBeDefined()
|
||||
expect(skillModule.skillId).toBe(48)
|
||||
expect(skillModule.name).toBe("Nova")
|
||||
expect(skillModule.charClass).toBe("sor")
|
||||
describe('[Skill #048] Nova (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #202)', () => {
|
||||
const missilesTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Missiles.txt')
|
||||
const missilesTxtContent = fs.readFileSync(missilesTxtPath, 'latin1')
|
||||
const skillsTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Skills.txt')
|
||||
const skillsTxtContent = fs.readFileSync(skillsTxtPath, 'latin1')
|
||||
|
||||
const stats1 = new UnitStatList()
|
||||
stats1.setBaseSkillLevel(48, 1)
|
||||
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
|
||||
expect(r1.manaCost).toBe(15)
|
||||
expect(r1.manaCost256).toBe(3840)
|
||||
expect(r1.minPhysDmg).toBe(0)
|
||||
expect(r1.maxPhysDmg).toBe(0)
|
||||
expect(r1.minElemDmg256).toBe(256)
|
||||
expect(r1.maxElemDmg256).toBe(5120)
|
||||
// =========================================================================
|
||||
// Dimension 1: 1.13c Data Table Parsing & Canonical Missile Definition
|
||||
// =========================================================================
|
||||
describe('Dimension 1: 1.13c Data Table Parsing & Canonical Missile Definition', () => {
|
||||
it('parses authentic Skills.txt row 48 (Nova)', () => {
|
||||
const lines = skillsTxtContent.split('\n')
|
||||
const headers = lines[0]!.split('\t')
|
||||
const idIdx = headers.indexOf('Id')
|
||||
const skillIdx = headers.indexOf('skill')
|
||||
const charClassIdx = headers.indexOf('charclass')
|
||||
const manaIdx = headers.indexOf('mana')
|
||||
const lvlManaIdx = headers.indexOf('lvlmana')
|
||||
const srvDoFuncIdx = headers.indexOf('srvdofunc')
|
||||
const castOverlayIdx = headers.indexOf('castoverlay')
|
||||
const srvMissileAIdx = headers.indexOf('srvmissilea')
|
||||
|
||||
const stats10 = new UnitStatList()
|
||||
stats10.setBaseSkillLevel(48, 10)
|
||||
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
|
||||
expect(r10.manaCost).toBe(24)
|
||||
expect(r10.manaCost256).toBe(6144)
|
||||
expect(r10.minPhysDmg).toBe(0)
|
||||
expect(r10.maxPhysDmg).toBe(0)
|
||||
expect(r10.minElemDmg256).toBe(14592)
|
||||
expect(r10.maxElemDmg256).toBe(24064)
|
||||
const novaRow = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx] === '48')
|
||||
expect(novaRow).toBeDefined()
|
||||
expect(novaRow![skillIdx]).toBe('Nova')
|
||||
expect(novaRow![charClassIdx]).toBe('sor')
|
||||
expect(Number(novaRow![manaIdx])).toBe(15)
|
||||
expect(Number(novaRow![lvlManaIdx])).toBe(1)
|
||||
expect(Number(novaRow![srvDoFuncIdx])).toBe(22)
|
||||
expect(novaRow![castOverlayIdx]).toBe('light_cast_1')
|
||||
expect(novaRow![srvMissileAIdx]).toBe('nova')
|
||||
})
|
||||
|
||||
const stats20 = new UnitStatList()
|
||||
stats20.setBaseSkillLevel(48, 20)
|
||||
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
|
||||
expect(r20.manaCost).toBe(34)
|
||||
expect(r20.manaCost256).toBe(8704)
|
||||
expect(r20.minPhysDmg).toBe(0)
|
||||
expect(r20.maxPhysDmg).toBe(0)
|
||||
expect(r20.minElemDmg256).toBe(33536)
|
||||
expect(r20.maxElemDmg256).toBe(48128)
|
||||
expect(r20.durationFrames).toBe(0)
|
||||
expect(r20.radiusSubtiles).toBe(88)
|
||||
expect(r20.petMax).toBe(0)
|
||||
expect(r20.cooldownFrames).toBe(0)
|
||||
it('parses authentic Missiles.txt row for nova (ID 90)', () => {
|
||||
const map = parseMissilesTxt(missilesTxtContent)
|
||||
const nova = map.get('nova')
|
||||
expect(nova).toBeDefined()
|
||||
expect(nova!.id).toBe(90)
|
||||
expect(nova!.vel).toBe(24)
|
||||
expect(nova!.range).toBe(13)
|
||||
expect(nova!.speedPxPerSec).toBe(600) // 24 * 25
|
||||
expect(nova!.distancePx).toBe(312) // 24 * 13
|
||||
expect(nova!.celFile.toLowerCase()).toBe('electricnova')
|
||||
expect(nova!.animLen).toBe(13)
|
||||
expect(nova!.nextHit).toBe(1)
|
||||
expect(nova!.nextDelay).toBe(4)
|
||||
expect(nova!.trans).toBe(1)
|
||||
})
|
||||
|
||||
it('CANONICAL_113C_MISSILES contains exact nova and electricnova definitions', () => {
|
||||
const nova = getMissileTxtData('nova')
|
||||
expect(nova.id).toBe(90)
|
||||
expect(nova.vel).toBe(24)
|
||||
expect(nova.range).toBe(13)
|
||||
expect(nova.speedPxPerSec).toBe(600)
|
||||
expect(nova.distancePx).toBe(312)
|
||||
expect(nova.nextDelay).toBe(4)
|
||||
expect(nova.celFile.toLowerCase()).toBe('electricnova')
|
||||
|
||||
const electricnova = getMissileTxtData('electricnova')
|
||||
expect(electricnova.id).toBe(90)
|
||||
expect(electricnova.vel).toBe(24)
|
||||
expect(electricnova.range).toBe(13)
|
||||
expect(electricnova.nextDelay).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
it('enforces 1.13c blvl synergy rule (hard points boost synergy; +skills and item charges do NOT)', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const skillRec = registry.getSkillById(48)!
|
||||
const synId = null
|
||||
// =========================================================================
|
||||
// Dimension 2: Visual Assets & Metadata Integrity
|
||||
// =========================================================================
|
||||
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/electricnova.png')
|
||||
const missileJson = path.resolve(__dirname, '../../../public/missiles/electricnova.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()
|
||||
statsBase.setBaseSkillLevel(48, 20)
|
||||
if (synId !== null) {
|
||||
statsBase.setBaseSkillLevel(synId, 20)
|
||||
}
|
||||
const withHardPoints = skillModule.evaluate!({
|
||||
registry,
|
||||
skill: skillRec,
|
||||
slvl: 20,
|
||||
blvl: 20,
|
||||
statList: statsBase,
|
||||
expect(fs.existsSync(missilePng)).toBe(true)
|
||||
expect(fs.statSync(missilePng).size).toBeGreaterThan(10000)
|
||||
expect(fs.existsSync(missileJson)).toBe(true)
|
||||
expect(fs.existsSync(overlayPng)).toBe(true)
|
||||
expect(fs.statSync(overlayPng).size).toBeGreaterThan(10000)
|
||||
expect(fs.existsSync(overlayJson)).toBe(true)
|
||||
})
|
||||
expect(withHardPoints.synergyBonusPct).toBe(0)
|
||||
|
||||
// +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy
|
||||
const statsSoftOnly = new UnitStatList()
|
||||
statsSoftOnly.setBaseSkillLevel(48, 20)
|
||||
statsSoftOnly.addStat('item_allskills', 20)
|
||||
if (synId !== null) {
|
||||
statsSoftOnly.setChargedSkillLevel(synId, 33)
|
||||
}
|
||||
const withSoftOnly = skillModule.evaluate!({
|
||||
registry,
|
||||
skill: skillRec,
|
||||
slvl: 20,
|
||||
blvl: 20,
|
||||
statList: statsSoftOnly,
|
||||
it('verifies ELECTRICNOVA_META metadata and registry registration', () => {
|
||||
expect(ELECTRICNOVA_META.name).toBe('electricnova')
|
||||
expect(ELECTRICNOVA_META.celFile).toBe('ElectricNova')
|
||||
expect(ELECTRICNOVA_META.directions).toBe(16)
|
||||
expect(ELECTRICNOVA_META.framesPerDirection).toBe(13)
|
||||
expect(MISSILE_METAS['electricnova']).toBe(ELECTRICNOVA_META)
|
||||
expect(MISSILE_METAS['ElectricNova']).toBe(ELECTRICNOVA_META)
|
||||
})
|
||||
|
||||
it('verifies cast overlay mapping and metadata', () => {
|
||||
expect(SORCERESS_SKILL_CAST_OVERLAYS[48]).toBe('light_cast_1')
|
||||
expect(getSkillCastOverlay(48)).toBe('light_cast_1')
|
||||
expect(LIGHT_CAST_1_META.name).toBe('light_cast_1')
|
||||
expect(OVERLAY_METAS['light_cast_1']).toBe(LIGHT_CAST_1_META)
|
||||
})
|
||||
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()
|
||||
const arena = new WorldArena({
|
||||
registry,
|
||||
classCode: "sor",
|
||||
skillId: 48,
|
||||
slvl: 20,
|
||||
})
|
||||
const outcome = arena.triggerCast()
|
||||
arena.stepTicks(15)
|
||||
const dbg = arena.getDebugState()
|
||||
// =========================================================================
|
||||
// Dimension 3: Kinematics & 64-Bolt Circular Ring Dispersion
|
||||
// =========================================================================
|
||||
describe('Dimension 3: Kinematics & 64-Bolt Circular Ring Dispersion', () => {
|
||||
it('spawnNovaRing emits exactly 64 bolts with uniform angular distribution', () => {
|
||||
const bolts = spawnNovaRing(200, 300, 45)
|
||||
expect(bolts.length).toBe(64)
|
||||
|
||||
expect(dbg.ready).toBe(true)
|
||||
expect(dbg.skillId).toBe(48)
|
||||
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
|
||||
expect(dbg.actionFrameTriggered).toBe(true)
|
||||
expect(outcome?.executed).toBe(true)
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const b = bolts[i]!
|
||||
expect(b.x).toBe(200)
|
||||
expect(b.y).toBe(300)
|
||||
expect(b.damage).toBe(45)
|
||||
expect(b.ttl).toBe(13)
|
||||
expect(b.missileType).toBe('electricnova')
|
||||
expect(b.fromPlayer).toBe(true)
|
||||
expect(b.nextDelay).toBe(4)
|
||||
|
||||
const expectedAngle = (i / 64) * Math.PI * 2
|
||||
const expectedVx = Math.cos(expectedAngle) * 24
|
||||
const expectedVy = Math.sin(expectedAngle) * 24 * ISO_GROUND_ASPECT_RATIO
|
||||
|
||||
expect(b.vx).toBeCloseTo(expectedVx, 4)
|
||||
expect(b.vy).toBeCloseTo(expectedVy, 4)
|
||||
}
|
||||
})
|
||||
|
||||
it('enforces 2:1 isometric ground foreshortening (ISO_GROUND_ASPECT_RATIO = 0.5)', () => {
|
||||
const bolts = spawnNovaRing(0, 0, 50)
|
||||
const eastBolt = bolts[0]! // angle = 0, cos = 1, sin = 0
|
||||
const southBolt = bolts[16]! // angle = pi/2, cos = 0, sin = 1
|
||||
const westBolt = bolts[32]! // angle = pi, cos = -1, sin = 0
|
||||
const northBolt = bolts[48]! // angle = 3pi/2, cos = 0, sin = -1
|
||||
|
||||
expect(eastBolt.vx).toBeCloseTo(24, 4)
|
||||
expect(eastBolt.vy).toBeCloseTo(0, 4)
|
||||
|
||||
expect(southBolt.vx).toBeCloseTo(0, 4)
|
||||
expect(southBolt.vy).toBeCloseTo(12, 4) // 24 * 0.5 = 12
|
||||
|
||||
expect(westBolt.vx).toBeCloseTo(-24, 4)
|
||||
expect(westBolt.vy).toBeCloseTo(0, 4)
|
||||
|
||||
expect(northBolt.vx).toBeCloseTo(0, 4)
|
||||
expect(northBolt.vy).toBeCloseTo(-12, 4) // -24 * 0.5 = -12
|
||||
})
|
||||
|
||||
it('expanding ring maintains true elliptical shape over 5 ticks in open space', () => {
|
||||
let bolts: readonly Projectile[] = spawnNovaRing(0, 0, 50)
|
||||
const openTerrain = { overlap: () => 0 }
|
||||
|
||||
for (let tick = 1; tick <= 5; tick++) {
|
||||
const outcome = tickProjectiles(bolts, [], openTerrain)
|
||||
bolts = outcome.alive
|
||||
}
|
||||
|
||||
expect(bolts.length).toBe(64)
|
||||
// At tick 5: horizontal displacement = 5 * 24 = 120 px, vertical = 5 * 12 = 60 px
|
||||
for (const b of bolts) {
|
||||
const normalizedX = b.x / 120
|
||||
const normalizedY = b.y / 60
|
||||
const radiusSq = normalizedX * normalizedX + normalizedY * normalizedY
|
||||
expect(radiusSq).toBeCloseTo(1.0, 2)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 4: NextDelay = 4 Single-Hit Lockout Protection
|
||||
// =========================================================================
|
||||
describe('Dimension 4: NextDelay = 4 Single-Hit Lockout Protection', () => {
|
||||
it('prevents point-blank overlapping multi-hit stack (target takes damage from only 1 bolt)', () => {
|
||||
// Spawn 64 radial bolts from (0, 0)
|
||||
const bolts = spawnNovaRing(0, 0, 100)
|
||||
// Target at point-blank (x: 10, y: 0) with radius 15
|
||||
const target = { index: 1, x: 10, y: 0, radius: 15, alive: true }
|
||||
const nextDelayMap = new Map<number, number>()
|
||||
|
||||
const outcome = tickProjectiles(bolts, [target], {
|
||||
overlap: () => 0,
|
||||
nextDelayByTarget: nextDelayMap,
|
||||
currentTick: 1,
|
||||
})
|
||||
|
||||
// Multiple bolts passed through the target, but NextDelay = 4 locks out further hits
|
||||
expect(outcome.hits.length).toBe(1)
|
||||
expect(outcome.hits[0]!.targetIndex).toBe(1)
|
||||
expect(outcome.hits[0]!.damage).toBe(100)
|
||||
|
||||
// Target must have lockout until tick 1 + 4 = 5
|
||||
expect(nextDelayMap.get(1)).toBe(5)
|
||||
|
||||
// Next tick (tick 2), target is still in lockout
|
||||
const outcome2 = tickProjectiles(outcome.alive, [target], {
|
||||
overlap: () => 0,
|
||||
nextDelayByTarget: nextDelayMap,
|
||||
currentTick: 2,
|
||||
})
|
||||
expect(outcome2.hits.length).toBe(0)
|
||||
expect(nextDelayMap.get(1)).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 5: Obstacle & Terrain Collision Clipping
|
||||
// =========================================================================
|
||||
describe('Dimension 5: Obstacle & Terrain Collision Clipping', () => {
|
||||
it('expires bolts moving into solid walls while open-air bolts continue', () => {
|
||||
const bolts = spawnNovaRing(0, 0, 50)
|
||||
// Solid wall at x >= 40
|
||||
const wallTerrain = { overlap: (x: number) => (x >= 40 ? 1 : 0) }
|
||||
|
||||
// Step 3 ticks: x speed is 24 px/tick. By tick 3, all eastern bolts reach x >= 40
|
||||
const step1 = tickProjectiles(bolts, [], wallTerrain)
|
||||
const step2 = tickProjectiles(step1.alive, [], wallTerrain)
|
||||
const step3 = tickProjectiles(step2.alive, [], wallTerrain)
|
||||
|
||||
expect(step3.wallHits).toBeGreaterThan(0)
|
||||
// Eastern bolts (vx > 15) must have hit the wall and died
|
||||
const survivingEastBolts = step3.alive.filter(b => b.vx > 15)
|
||||
expect(survivingEastBolts.length).toBe(0)
|
||||
|
||||
// Western bolts (vx < 0) are in negative x (open space) and survive
|
||||
const survivingWestBolts = step3.alive.filter(b => b.vx < 0)
|
||||
expect(survivingWestBolts.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('MissileEngine.tick detects obstacle collision and sets expired = true', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const engine = new MissileEngine(registry)
|
||||
const dummyOwner = {
|
||||
id: 'player',
|
||||
x: 0,
|
||||
y: 0,
|
||||
statList: new UnitStatList(),
|
||||
} as any
|
||||
engine.spawnMissile({
|
||||
missileNameOrId: 'electricnova',
|
||||
sourceSkillId: 48,
|
||||
slvl: 1,
|
||||
owner: dummyOwner,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
targetX: 100,
|
||||
targetY: 0,
|
||||
dmgPacket: { minDamage: 50, maxDamage: 50, damageType: 'lightning' } as any,
|
||||
vx: 24,
|
||||
vy: 0,
|
||||
maxRangeTicks: 13,
|
||||
})
|
||||
|
||||
// Wall at x >= 20 passed as 4th argument (collision) to engine.tick
|
||||
const collision = { isMissileBlocked: (x: number) => x >= 20 }
|
||||
engine.tick(1, [], new Map(), collision)
|
||||
|
||||
// Missile moved to x=24 >= 20, must be marked expired and purged
|
||||
const active = engine.getActiveMissiles()
|
||||
expect(active.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 6: 1.13c 5-Band Damage Scaling & Synergy Verification
|
||||
// =========================================================================
|
||||
describe('Dimension 6: 1.13c 5-Band Damage Scaling & Synergy Verification', () => {
|
||||
it('calculates exact 1.13c 5-band damage at slvl 1, 10, 20, and 30', () => {
|
||||
// slvl 1: 1-20
|
||||
const d1 = calculateNovaDamage(1)
|
||||
expect(d1.min).toBe(1)
|
||||
expect(d1.max).toBe(20)
|
||||
|
||||
// slvl 10: 1 + 7*6 + 2*7 = 57, 20 + 7*8 + 2*9 = 94
|
||||
const d10 = calculateNovaDamage(10)
|
||||
expect(d10.min).toBe(57)
|
||||
expect(d10.max).toBe(94)
|
||||
|
||||
// slvl 20: 1 + 7*6 + 8*7 + 4*8 = 131, 20 + 7*8 + 8*9 + 4*10 = 188
|
||||
const d20 = calculateNovaDamage(20)
|
||||
expect(d20.min).toBe(131)
|
||||
expect(d20.max).toBe(188)
|
||||
|
||||
// slvl 30: 131 + 2*8 + 6*9 + 2*10 = 221, 188 + 2*10 + 6*11 + 2*12 = 298
|
||||
const d30 = calculateNovaDamage(30)
|
||||
expect(d30.min).toBe(221)
|
||||
expect(d30.max).toBe(298)
|
||||
})
|
||||
|
||||
it('evaluates exact 1.13c Skills.txt formulas in skillModule.evaluate', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const skillRec = registry.getSkillById(48)!
|
||||
expect(skillRec).toBeDefined()
|
||||
expect(skillModule.skillId).toBe(48)
|
||||
expect(skillModule.name).toBe("Nova")
|
||||
expect(skillModule.charClass).toBe("sor")
|
||||
|
||||
const stats1 = new UnitStatList()
|
||||
stats1.setBaseSkillLevel(48, 1)
|
||||
const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 })
|
||||
expect(r1.manaCost).toBe(15)
|
||||
expect(r1.manaCost256).toBe(3840)
|
||||
expect(r1.minElemDmg256).toBe(256)
|
||||
expect(r1.maxElemDmg256).toBe(5120)
|
||||
|
||||
const stats10 = new UnitStatList()
|
||||
stats10.setBaseSkillLevel(48, 10)
|
||||
const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 })
|
||||
expect(r10.manaCost).toBe(24)
|
||||
expect(r10.manaCost256).toBe(6144)
|
||||
expect(r10.minElemDmg256).toBe(14592) // 57 * 256
|
||||
expect(r10.maxElemDmg256).toBe(24064) // 94 * 256
|
||||
|
||||
const stats20 = new UnitStatList()
|
||||
stats20.setBaseSkillLevel(48, 20)
|
||||
const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 })
|
||||
expect(r20.manaCost).toBe(34)
|
||||
expect(r20.manaCost256).toBe(8704)
|
||||
expect(r20.minElemDmg256).toBe(33536) // 131 * 256
|
||||
expect(r20.maxElemDmg256).toBe(48128) // 188 * 256
|
||||
expect(r20.durationFrames).toBe(0)
|
||||
expect(r20.cooldownFrames).toBe(0)
|
||||
})
|
||||
|
||||
it('enforces 1.13c zero synergy rule (Nova receives 0% synergy from other skills)', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const skillRec = registry.getSkillById(48)!
|
||||
|
||||
const statsBase = new UnitStatList()
|
||||
statsBase.setBaseSkillLevel(48, 20)
|
||||
statsBase.setBaseSkillLevel(49, 20) // Lightning 20
|
||||
statsBase.setBaseSkillLevel(53, 20) // Chain Lightning 20
|
||||
statsBase.setBaseSkillLevel(38, 20) // Charged Bolt 20
|
||||
|
||||
const withHardPoints = skillModule.evaluate!({
|
||||
registry,
|
||||
skill: skillRec,
|
||||
slvl: 20,
|
||||
blvl: 20,
|
||||
statList: statsBase,
|
||||
})
|
||||
expect(withHardPoints.synergyBonusPct).toBe(0)
|
||||
|
||||
const statsSoftOnly = new UnitStatList()
|
||||
statsSoftOnly.setBaseSkillLevel(48, 20)
|
||||
statsSoftOnly.addStat('item_allskills', 20)
|
||||
const withSoftOnly = skillModule.evaluate!({
|
||||
registry,
|
||||
skill: skillRec,
|
||||
slvl: 20,
|
||||
blvl: 20,
|
||||
statList: statsSoftOnly,
|
||||
})
|
||||
expect(withSoftOnly.synergyBonusPct).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: 48,
|
||||
slvl: 20,
|
||||
})
|
||||
const outcome = arena.triggerCast()
|
||||
arena.stepTicks(15)
|
||||
const dbg = arena.getDebugState()
|
||||
|
||||
expect(dbg.ready).toBe(true)
|
||||
expect(dbg.skillId).toBe(48)
|
||||
expect(dbg.castCount).toBeGreaterThanOrEqual(1)
|
||||
expect(dbg.actionFrameTriggered).toBe(true)
|
||||
expect(outcome?.executed).toBe(true)
|
||||
})
|
||||
|
||||
it('verifies getSkillManaCost and getSkillCooldownTicks', () => {
|
||||
expect(getSkillManaCost(48, 1)).toBe(15)
|
||||
expect(getSkillManaCost(48, 10)).toBe(24)
|
||||
expect(getSkillManaCost(48, 20)).toBe(34)
|
||||
expect(getSkillCooldownTicks(48)).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in New Issue