fix(skills): remediate Milestone 3 gate failures for Inferno range and channeled mana drain
- Fix calculateInfernoRangePx to authentic 1.13c ratio: calculateInfernoRange(slvl) * 32 (32 px/subtile). - Update act-scene.ts flame stream ttl calculation to Math.min(60, Math.round(rangePx / speed)) allowing authentic travel reach. - Connect continuous channeled mana drain in act-scene.ts: drain (36 + slvl - 1) * 4 / 256 mana per frame with fractional accumulator, stopping channel on mana depletion. - Update tests/skills/sor/skill-041-inferno.test.ts assertions to 320/752/1232 px and add runtime continuous mana drain tests. - Persist adversarial stress test suites (adv-fire-m3-math-stress.test.ts and adv-fire-m3-geom-stress.test.ts).
This commit is contained in:
parent
df4c5b66b0
commit
6aa951e8cc
|
|
@ -226,6 +226,9 @@ export interface CombatPlayer {
|
|||
blazePatchDuration?: number | undefined
|
||||
lastBlazeX?: number | undefined
|
||||
lastBlazeY?: number | undefined
|
||||
/** Inferno continuous channeling state and sub-mana fractional accumulator (256ths). */
|
||||
infernoChanneling?: boolean | undefined
|
||||
infernoManaRemainder256?: number | undefined
|
||||
}
|
||||
|
||||
/** Something worth telling the player (and the renderer) about. */
|
||||
|
|
|
|||
|
|
@ -2749,11 +2749,11 @@ export function calculateInfernoRange(slvl: number): number {
|
|||
|
||||
/**
|
||||
* Authentic 1.13c Inferno (Skill 41) stream length in pixels:
|
||||
* Formula: calculateInfernoRange(slvl) * 15.
|
||||
* Level 1 -> 150.0 px, Level 10 -> 352.5 px, Level 20 -> 577.5 px.
|
||||
* Formula: calculateInfernoRange(slvl) * 32 (1 yard = 1 subtile = 32 horizontal pixels).
|
||||
* Level 1 -> 320.0 px, Level 10 -> 752.0 px, Level 20 -> 1232.0 px.
|
||||
*/
|
||||
export function calculateInfernoRangePx(slvl: number): number {
|
||||
return calculateInfernoRange(slvl) * 15
|
||||
return calculateInfernoRange(slvl) * 32
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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, calculateStaticFieldRadiusPx, calculateStaticFieldDamage, hasLineOfSight, calculateChainLightningJumps, getSkillCooldownTicks, calculateChargedBoltCount, calculateTelekinesisDamage, isTelekinesisItemAllowed, isTelekinesisObjectAllowed, calculateThunderStormDuration, calculateThunderStormInterval, calculateEnergyShieldDuration, calculateEnergyShieldAbsorbPct, calculateFrozenArmorDuration, calculateShiverArmorDuration, calculateChillingArmorDuration, calculateIceBlastFreezeLength, calculateGlacialSpikeFreezeLength, calculateFireWallDamage, calculateBlazeDamage, calculateInfernoDamage, calculateBlazeDuration, calculateBlazePatchDuration, calculateInfernoRangePx, calculateFireWallPatches } from '../game/skills.ts'
|
||||
import { BATCH1_SKILLS, getBatch1SkillDef, isBatch1Skill, getSkillManaCost, calculateSkillDamage, calculateMeteorResidualFireDamage, getMissileTxtData, ISO_GROUND_ASPECT_RATIO, getSkillCastOverlay, isAuraSkill, calculateStaticFieldRadiusPx, calculateStaticFieldDamage, hasLineOfSight, calculateChainLightningJumps, getSkillCooldownTicks, calculateChargedBoltCount, calculateTelekinesisDamage, isTelekinesisItemAllowed, isTelekinesisObjectAllowed, calculateThunderStormDuration, calculateThunderStormInterval, calculateEnergyShieldDuration, calculateEnergyShieldAbsorbPct, calculateFrozenArmorDuration, calculateShiverArmorDuration, calculateChillingArmorDuration, calculateIceBlastFreezeLength, calculateGlacialSpikeFreezeLength, calculateFireWallDamage, calculateBlazeDamage, calculateInfernoDamage, calculateBlazeDuration, calculateBlazePatchDuration, calculateInfernoRangePx, calculateInfernoContinuousMana256, calculateFireWallPatches } from '../game/skills.ts'
|
||||
import type { Projectile, ActiveOverlay } from '../game/skills.ts'
|
||||
import { findSafeDropPosition, calculateBounceHeight, triggerFlippyBounce, type GroundItemEntity } from '../game/ground-items.ts'
|
||||
import {
|
||||
|
|
@ -1136,21 +1136,73 @@ export function castSkill(
|
|||
return false
|
||||
}
|
||||
|
||||
// 2. Mana check
|
||||
const manaCost = getSkillMana(skillId, hudManager)
|
||||
const currentMana = hudManager !== null ? hudManager.mana : player.mana
|
||||
if (currentMana < manaCost) {
|
||||
status.textContent = '法力不足!'
|
||||
engine.world.events.push({ kind: 'noMana', x: player.x, y: player.y, text: 'no mana' } as any)
|
||||
return false
|
||||
}
|
||||
// 2. Mana check & deduction
|
||||
let isFirstInfernoCast = false
|
||||
if (skillId === 41) {
|
||||
const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(41) ?? 1)
|
||||
const currentMana = hudManager !== null ? hudManager.mana : player.mana
|
||||
|
||||
// 3. Deduct mana
|
||||
if (manaCost > 0) {
|
||||
player.mana = Math.max(0, player.mana - manaCost)
|
||||
if (hudManager !== null) {
|
||||
hudManager.mana = Math.max(0, hudManager.mana - manaCost)
|
||||
hudManager.syncPublishedState()
|
||||
if (!player.infernoChanneling) {
|
||||
// Starting cast cost: 6 mana (1.13c Skills.txt)
|
||||
const startCost = 6
|
||||
if (currentMana < startCost) {
|
||||
status.textContent = '法力不足!'
|
||||
engine.world.events.push({ kind: 'noMana', x: player.x, y: player.y, text: 'no mana' } as any)
|
||||
return false
|
||||
}
|
||||
player.mana = Math.max(0, player.mana - startCost)
|
||||
if (hudManager !== null) {
|
||||
hudManager.mana = player.mana
|
||||
hudManager.syncPublishedState()
|
||||
}
|
||||
player.infernoChanneling = true
|
||||
player.infernoManaRemainder256 = 0
|
||||
isFirstInfernoCast = true
|
||||
} else {
|
||||
// Continuous channeling frame: drain authentically via calculateInfernoContinuousMana256 (256ths)
|
||||
if (currentMana <= 0) {
|
||||
player.infernoChanneling = false
|
||||
player.infernoManaRemainder256 = 0
|
||||
status.textContent = '法力不足!'
|
||||
engine.world.events.push({ kind: 'noMana', x: player.x, y: player.y, text: 'no mana' } as any)
|
||||
return false
|
||||
}
|
||||
const drain256 = calculateInfernoContinuousMana256(slvl)
|
||||
const total256 = (player.infernoManaRemainder256 ?? 0) + drain256
|
||||
const wholeMana = Math.floor(total256 / 256)
|
||||
player.infernoManaRemainder256 = total256 % 256
|
||||
if (wholeMana > 0) {
|
||||
player.mana = Math.max(0, player.mana - wholeMana)
|
||||
if (hudManager !== null) {
|
||||
hudManager.mana = player.mana
|
||||
hudManager.syncPublishedState()
|
||||
}
|
||||
}
|
||||
if (player.mana <= 0) {
|
||||
player.infernoChanneling = false
|
||||
player.infernoManaRemainder256 = 0
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Reset inferno channeling state when casting another skill
|
||||
player.infernoChanneling = false
|
||||
player.infernoManaRemainder256 = 0
|
||||
|
||||
const manaCost = getSkillMana(skillId, hudManager)
|
||||
const currentMana = hudManager !== null ? hudManager.mana : player.mana
|
||||
if (currentMana < manaCost) {
|
||||
status.textContent = '法力不足!'
|
||||
engine.world.events.push({ kind: 'noMana', x: player.x, y: player.y, text: 'no mana' } as any)
|
||||
return false
|
||||
}
|
||||
|
||||
// 3. Deduct mana
|
||||
if (manaCost > 0) {
|
||||
player.mana = Math.max(0, player.mana - manaCost)
|
||||
if (hudManager !== null) {
|
||||
hudManager.mana = Math.max(0, hudManager.mana - manaCost)
|
||||
hudManager.syncPublishedState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1169,7 +1221,7 @@ export function castSkill(
|
|||
}
|
||||
|
||||
// 5. Spawn cast overlay if defined for this skill
|
||||
if (skillId !== 0) {
|
||||
if (skillId !== 0 && (skillId !== 41 || isFirstInfernoCast)) {
|
||||
const castOverlayName = getSkillCastOverlay(skillId)
|
||||
if (castOverlayName) {
|
||||
engine.spawnOverlay(castOverlayName, { x: player.x, y: player.y }, 'player')
|
||||
|
|
@ -1619,7 +1671,7 @@ export function castSkill(
|
|||
const speed = 12 // 300 px/s / 25 fps
|
||||
const vx = len > 0 ? (dx / len) * speed : speed
|
||||
const vy = len > 0 ? (dy / len) * speed : 0
|
||||
const ttl = Math.max(8, Math.min(24, Math.round(rangePx / speed)))
|
||||
const ttl = Math.max(8, Math.min(60, Math.round(rangePx / speed)))
|
||||
|
||||
const flameVariant = (engine.metrics.casts % 2 === 0) ? 'infernoflame1' : 'infernoflame2'
|
||||
engine.projectiles.push({
|
||||
|
|
@ -2503,6 +2555,9 @@ export class SceneMouseController {
|
|||
this.isRightMouseDown = false
|
||||
this.activeClickType = 'none'
|
||||
}
|
||||
const player = this.engine.world.player
|
||||
player.infernoChanneling = false
|
||||
player.infernoManaRemainder256 = 0
|
||||
}
|
||||
|
||||
handleRightClickWorld(worldX: number, worldY: number): void {
|
||||
|
|
@ -2754,6 +2809,9 @@ export class SceneMouseController {
|
|||
this.stuckTicks = 0
|
||||
this.lastDist = -1
|
||||
;(this.engine as any).dialog = null
|
||||
const player = this.engine.world.player
|
||||
player.infernoChanneling = false
|
||||
player.infernoManaRemainder256 = 0
|
||||
}
|
||||
|
||||
tick(): { movement: { x: number; y: number }; attacking: boolean } {
|
||||
|
|
@ -2763,10 +2821,19 @@ export class SceneMouseController {
|
|||
if (isKeyMoving) {
|
||||
this.navTarget = null
|
||||
this.pendingInteraction = null
|
||||
const player = this.engine.world.player
|
||||
player.infernoChanneling = false
|
||||
player.infernoManaRemainder256 = 0
|
||||
return { movement: keyMovement, attacking: false }
|
||||
}
|
||||
|
||||
const player = this.engine.world.player
|
||||
if (!player.alive || (!this.isLeftMouseDown && !this.isRightMouseDown && !this.input.shiftHeld)) {
|
||||
if (player.infernoChanneling) {
|
||||
player.infernoChanneling = false
|
||||
player.infernoManaRemainder256 = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Shift-Hold Stand Still Priority in tick()
|
||||
if (this.input.shiftHeld) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,825 @@
|
|||
/**
|
||||
* Adversarial Empirical Stress Testing Suite for Milestone 3 (Geometry, Collision & Tick Mechanics)
|
||||
*
|
||||
* Target Skills:
|
||||
* - Skill 51: Fire Wall (15-patch perpendicular geometry, obstacle collision clipping)
|
||||
* - Skill 46: Blaze (footstep trail >= 24px, self-hit immunity under sharp turns & stationary standing)
|
||||
* - Skill 41: Inferno & Ground Fire (continuous tick hitTargetIds.clear() resets)
|
||||
* - 2:1 Isometric Aspect Ratio (ISO_GROUND_ASPECT_RATIO = 0.5 ground collision ellipse)
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
|
||||
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
|
||||
import { StateBus } from '../../../src/game/engine/state-bus.ts'
|
||||
import {
|
||||
MissileEngine,
|
||||
calculateFireWallPatches,
|
||||
} from '../../../src/game/engine/missile-engine.ts'
|
||||
import type { CombatUnitContext } from '../../../src/game/engine/combat-pipeline.ts'
|
||||
import {
|
||||
tickProjectiles,
|
||||
ISO_GROUND_ASPECT_RATIO,
|
||||
type Projectile,
|
||||
type ProjectileTarget,
|
||||
} from '../../../src/game/skills.ts'
|
||||
import { GameEngine } from '../../../src/game/engine.ts'
|
||||
import { COLLIDE_WALL, COLLIDE_NONE } from '../../../src/game/d2map.ts'
|
||||
|
||||
function createMockCombatUnit(
|
||||
id: string,
|
||||
options?: {
|
||||
hp?: number
|
||||
fireResist?: number
|
||||
isPlayer?: boolean
|
||||
},
|
||||
): CombatUnitContext {
|
||||
const statList = new UnitStatList()
|
||||
const hp = options?.hp ?? 1000
|
||||
statList.setBaseStat('hp', hp * 256)
|
||||
statList.setBaseStat('maxhp', hp * 256)
|
||||
statList.setBaseStat('hitpoints', hp * 256)
|
||||
|
||||
if (options?.fireResist !== undefined) {
|
||||
statList.setBaseStat('fireresist', options.fireResist)
|
||||
}
|
||||
|
||||
const stateBus = new StateBus(statList)
|
||||
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
statList,
|
||||
stateBus,
|
||||
}
|
||||
}
|
||||
|
||||
describe('Adversarial Stress Suite — Milestone 3 Geometry, Collision & Tick Mechanics', () => {
|
||||
// ==========================================================================
|
||||
// Dimension 1: Fire Wall 15-Patch Perpendicular Geometry at Arbitrary Cast Angles
|
||||
// ==========================================================================
|
||||
describe('Dimension 1: Fire Wall 15-Patch Perpendicular Geometry', () => {
|
||||
const testAngles = [
|
||||
{ name: '0° (East)', deg: 0, rad: 0 },
|
||||
{ name: '30° (East-South-East)', deg: 30, rad: Math.PI / 6 },
|
||||
{ name: '45° (South-East)', deg: 45, rad: Math.PI / 4 },
|
||||
{ name: '90° (South)', deg: 90, rad: Math.PI / 2 },
|
||||
{ name: '135° (South-West)', deg: 135, rad: (3 * Math.PI) / 4 },
|
||||
{ name: '180° (West)', deg: 180, rad: Math.PI },
|
||||
{ name: '270° (North)', deg: 270, rad: (3 * Math.PI) / 2 },
|
||||
{ name: '315° (North-East)', deg: 315, rad: (7 * Math.PI) / 4 },
|
||||
// Arbitrary non-cardinal angles
|
||||
{ name: '17° (Arbitrary acute)', deg: 17, rad: (17 * Math.PI) / 180 },
|
||||
{ name: '63.43° (atan2(2, 1))', deg: 63.43, rad: Math.atan2(2, 1) },
|
||||
{ name: '112.5° (Obtuse)', deg: 112.5, rad: (112.5 * Math.PI) / 180 },
|
||||
{ name: '200° (Arbitrary 3rd quadrant)', deg: 200, rad: (200 * Math.PI) / 180 },
|
||||
{ name: '285° (Arbitrary 4th quadrant)', deg: 285, rad: (285 * Math.PI) / 180 },
|
||||
]
|
||||
|
||||
it.each(testAngles)('verifies exact 15-patch orthogonality and 24px spacing at angle $name', ({ rad }) => {
|
||||
const casterX = 150
|
||||
const casterY = 150
|
||||
const distance = 200
|
||||
const targetX = Math.round(casterX + distance * Math.cos(rad))
|
||||
const targetY = Math.round(casterY + distance * Math.sin(rad))
|
||||
|
||||
const patches = calculateFireWallPatches(casterX, casterY, targetX, targetY, {
|
||||
count: 15,
|
||||
spacing: 24,
|
||||
})
|
||||
|
||||
// 1. Exactly 15 patches
|
||||
expect(patches).toHaveLength(15)
|
||||
|
||||
// 2. Strict index sequence from -7 to +7
|
||||
const indices = patches.map(p => p.index)
|
||||
expect(indices).toEqual([-7, -6, -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5, 6, 7])
|
||||
|
||||
// 3. Center patch (index 0) must be exactly at target
|
||||
const center = patches.find(p => p.index === 0)!
|
||||
expect(center.x).toBe(targetX)
|
||||
expect(center.y).toBe(targetY)
|
||||
|
||||
// 4. Perpendicularity test:
|
||||
// In 2:1 isometric projection, travel vector is (dx, dy).
|
||||
// The unscaled normal vector is (nx, ny).
|
||||
// Dot product of cast vector and patch displacement in unscaled space must be 0:
|
||||
const travelDx = targetX - casterX
|
||||
const travelDy = targetY - casterY
|
||||
const travelLen = Math.hypot(travelDx, travelDy)
|
||||
|
||||
for (const p of patches) {
|
||||
if (p.index === 0) continue
|
||||
const dispX = p.x - targetX
|
||||
const unscaledDispY = (p.y - targetY) / ISO_GROUND_ASPECT_RATIO
|
||||
|
||||
// Dot product: travelDx * dispX + travelDy * unscaledDispY
|
||||
const dot = travelDx * dispX + travelDy * unscaledDispY
|
||||
const dispLen = Math.hypot(dispX, unscaledDispY)
|
||||
|
||||
// Normalized dot product should be close to 0 (tolerance for Math.round integer coords)
|
||||
const normalizedDot = Math.abs(dot) / (travelLen * dispLen)
|
||||
expect(normalizedDot).toBeLessThan(0.08) // < 4.5 degrees angular error caused by integer rounding
|
||||
|
||||
// Distance from center in unscaled ground coordinates: |i| * 24 px
|
||||
const expectedDist = Math.abs(p.index) * 24
|
||||
expect(dispLen).toBeGreaterThanOrEqual(expectedDist - 1.5)
|
||||
expect(dispLen).toBeLessThanOrEqual(expectedDist + 1.5)
|
||||
}
|
||||
|
||||
// 5. Inter-patch spacing: adjacent patches must be ~24px apart in unscaled coordinates
|
||||
for (let i = 0; i < patches.length - 1; i++) {
|
||||
const pCurrent = patches[i]!
|
||||
const pNext = patches[i + 1]!
|
||||
const stepDist = Math.hypot(
|
||||
pNext.x - pCurrent.x,
|
||||
(pNext.y - pCurrent.y) / ISO_GROUND_ASPECT_RATIO,
|
||||
)
|
||||
expect(stepDist).toBeGreaterThanOrEqual(22.5)
|
||||
expect(stepDist).toBeLessThanOrEqual(25.5)
|
||||
}
|
||||
})
|
||||
|
||||
it('verifies degenerate zero-distance cast (caster === target) produces fallback 15 vertical patches', () => {
|
||||
const x = 300
|
||||
const y = 200
|
||||
const patches = calculateFireWallPatches(x, y, x, y, {
|
||||
count: 15,
|
||||
spacing: 24,
|
||||
})
|
||||
|
||||
expect(patches).toHaveLength(15)
|
||||
const center = patches.find(p => p.index === 0)!
|
||||
expect(center.x).toBe(x)
|
||||
expect(center.y).toBe(y)
|
||||
|
||||
// Fallback normal is (0, 1): patches align vertically with 12px screen spacing
|
||||
for (const p of patches) {
|
||||
expect(p.x).toBe(x)
|
||||
expect(p.y).toBe(y + p.index * 24 * ISO_GROUND_ASPECT_RATIO)
|
||||
}
|
||||
})
|
||||
|
||||
it('handles negative and extreme world coordinates robustly', () => {
|
||||
const casterX = -5000
|
||||
const casterY = -12000
|
||||
const targetX = -4800
|
||||
const targetY = -11900
|
||||
|
||||
const patches = calculateFireWallPatches(casterX, casterY, targetX, targetY, {
|
||||
count: 15,
|
||||
spacing: 24,
|
||||
})
|
||||
|
||||
expect(patches).toHaveLength(15)
|
||||
expect(patches.find(p => p.index === 0)!.x).toBe(targetX)
|
||||
expect(patches.find(p => p.index === 0)!.y).toBe(targetY)
|
||||
for (const p of patches) {
|
||||
expect(Number.isFinite(p.x)).toBe(true)
|
||||
expect(Number.isFinite(p.y)).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// Dimension 2: Obstacle Collision Clipping
|
||||
// ==========================================================================
|
||||
describe('Dimension 2: Obstacle Collision Clipping', () => {
|
||||
it('omits all patches landing inside solid COLLIDE_WALL obstacles and retains valid open ground patches', () => {
|
||||
const casterX = 100
|
||||
const casterY = 100
|
||||
const targetX = 200
|
||||
const targetY = 100 // Travel is horizontal (+X). Normal is vertical (+Y).
|
||||
|
||||
// Wall boundary at y >= 130
|
||||
// Normal line has y = 100 + i * 12.
|
||||
// i = 0 -> y = 100 (open)
|
||||
// i = 1 -> y = 112 (open)
|
||||
// i = 2 -> y = 124 (open)
|
||||
// i = 3 -> y = 136 (BLOCKED: y >= 130)
|
||||
// i = 4..7 -> y >= 148 (BLOCKED: y >= 130)
|
||||
const isBlockedWall = (x: number, y: number) => y >= 130
|
||||
|
||||
const patches = calculateFireWallPatches(casterX, casterY, targetX, targetY, {
|
||||
count: 15,
|
||||
spacing: 24,
|
||||
isBlocked: isBlockedWall,
|
||||
})
|
||||
|
||||
// Indices 3, 4, 5, 6, 7 are inside solid obstacle -> 5 patches omitted
|
||||
expect(patches.length).toBe(10)
|
||||
for (const p of patches) {
|
||||
expect(p.y).toBeLessThan(130)
|
||||
expect(isBlockedWall(p.x, p.y)).toBe(false)
|
||||
}
|
||||
expect(patches.some(p => p.index === 3)).toBe(false)
|
||||
expect(patches.some(p => p.index === 7)).toBe(false)
|
||||
expect(patches.some(p => p.index === 0)).toBe(true)
|
||||
expect(patches.some(p => p.index === -7)).toBe(true)
|
||||
})
|
||||
|
||||
it('clips patches when target (center) or both wings intersect obstacle geometry', () => {
|
||||
// Complete obstacle: entire room is blocked
|
||||
const allBlocked = calculateFireWallPatches(0, 0, 100, 100, {
|
||||
count: 15,
|
||||
spacing: 24,
|
||||
isBlocked: () => true,
|
||||
})
|
||||
expect(allBlocked).toHaveLength(0)
|
||||
|
||||
// Target itself blocked: index 0 omitted, non-blocked wings remain
|
||||
const centerBlocked = calculateFireWallPatches(100, 100, 200, 100, {
|
||||
count: 15,
|
||||
spacing: 24,
|
||||
isBlocked: (x, y) => x === 200 && y === 100,
|
||||
})
|
||||
expect(centerBlocked).toHaveLength(14)
|
||||
expect(centerBlocked.some(p => p.index === 0)).toBe(false)
|
||||
})
|
||||
|
||||
it('kills ground fire patch immediately on tick 1 when intersecting COLLIDE_WALL in tickProjectiles', () => {
|
||||
const solidPatch: Projectile = {
|
||||
skillId: '51',
|
||||
x: 150,
|
||||
y: 150,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 100,
|
||||
ttl: 90,
|
||||
fromPlayer: true,
|
||||
missileType: 'firewall',
|
||||
pierce: true,
|
||||
aoeRadius: 24,
|
||||
}
|
||||
|
||||
const enemy = {
|
||||
index: 1,
|
||||
alive: true,
|
||||
x: 150,
|
||||
y: 150,
|
||||
radius: 16,
|
||||
}
|
||||
|
||||
// Terrain obstacle returning COLLIDE_WALL
|
||||
const wallTerrain = {
|
||||
overlap: (x: number, y: number) => (x >= 140 && y >= 140 ? COLLIDE_WALL : COLLIDE_NONE),
|
||||
}
|
||||
|
||||
const outcome = tickProjectiles([solidPatch], [enemy], wallTerrain)
|
||||
|
||||
// Projectile collided with wall: wallHits incremented, patch dies, 0 damage dealt to enemy
|
||||
expect(outcome.wallHits).toBe(1)
|
||||
expect(outcome.alive).toHaveLength(0)
|
||||
expect(outcome.hits).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('kills Blaze patch when placed on COLLIDE_WALL obstacle and inflicts zero self-damage', () => {
|
||||
const blazePatch: Projectile = {
|
||||
skillId: '46',
|
||||
x: 220,
|
||||
y: 220,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 50,
|
||||
ttl: 115,
|
||||
fromPlayer: true,
|
||||
missileType: 'blaze',
|
||||
pierce: true,
|
||||
aoeRadius: 24,
|
||||
}
|
||||
|
||||
const wallTerrain = {
|
||||
overlap: (x: number, y: number) => (x >= 200 ? COLLIDE_WALL : COLLIDE_NONE),
|
||||
}
|
||||
|
||||
const outcome = tickProjectiles([blazePatch], [], wallTerrain)
|
||||
expect(outcome.wallHits).toBe(1)
|
||||
expect(outcome.alive).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// Dimension 3: Blaze Footstep Trail & Self-Hit Immunity
|
||||
// ==========================================================================
|
||||
describe('Dimension 3: Blaze Footstep Trail & Self-Hit Immunity', () => {
|
||||
it('spawns footstep patches strictly when player displacement >= 24px and suppresses sub-24px movement', () => {
|
||||
const terrain = { widthPx: 2000, heightPx: 2000, overlap: () => 0 }
|
||||
const engineOpts = {
|
||||
spawn: { x: 100, y: 100 },
|
||||
stats: [],
|
||||
xpTable: [],
|
||||
itemBases: [],
|
||||
prefixAffixes: [],
|
||||
suffixAffixes: [],
|
||||
skills: [],
|
||||
npcDefs: [],
|
||||
questDefs: [],
|
||||
combatOptions: {
|
||||
playerSpeed: 100,
|
||||
playerReach: 40,
|
||||
playerCooldownTicks: 12,
|
||||
playerDamage: 5,
|
||||
playerManaPerAttack: 1,
|
||||
respawnTicks: 50,
|
||||
disableMonsterAggro: false,
|
||||
},
|
||||
lootSeed: 1,
|
||||
talkRadius: 50,
|
||||
pickupRadius: 30,
|
||||
inventoryCols: 10,
|
||||
inventoryRows: 4,
|
||||
}
|
||||
const engine = new GameEngine(terrain as any, engineOpts as any)
|
||||
const idleInput = {
|
||||
movement: { x: 0, y: 0 },
|
||||
attacking: false,
|
||||
digits: [],
|
||||
pickingUp: false,
|
||||
talking: false,
|
||||
saving: false,
|
||||
loading: false,
|
||||
}
|
||||
engine.world.player.x = 100
|
||||
engine.world.player.y = 100
|
||||
engine.world.player.blazeTicks = 100
|
||||
engine.world.player.blazeDamage = 40
|
||||
engine.world.player.blazePatchDuration = 115
|
||||
|
||||
// Tick 1: Initial activation (lastBlazeX is undefined) -> spawns 1st patch at (100, 100)
|
||||
engine.tick(idleInput)
|
||||
expect(engine.projectiles).toHaveLength(1)
|
||||
expect(engine.projectiles[0]!.missileType).toBe('blaze')
|
||||
expect(engine.projectiles[0]!.x).toBe(100)
|
||||
expect(engine.projectiles[0]!.y).toBe(100)
|
||||
expect(engine.world.player.lastBlazeX).toBe(100)
|
||||
expect(engine.world.player.lastBlazeY).toBe(100)
|
||||
|
||||
// Tick 2: Player moves 10px to (110, 100) -> dist = 10px < 24px -> NO new patch
|
||||
engine.world.player.x = 110
|
||||
engine.tick(idleInput)
|
||||
expect(engine.projectiles).toHaveLength(1)
|
||||
|
||||
// Tick 3: Player moves to (123.9, 100) -> dist = 23.9px < 24px -> NO new patch
|
||||
engine.world.player.x = 123.9
|
||||
engine.tick(idleInput)
|
||||
expect(engine.projectiles).toHaveLength(1)
|
||||
|
||||
// Tick 4: Player moves to (124, 100) -> dist = 24.0px >= 24px -> SPAWNS 2nd patch!
|
||||
engine.world.player.x = 124
|
||||
engine.tick(idleInput)
|
||||
expect(engine.projectiles).toHaveLength(2)
|
||||
expect(engine.projectiles[1]!.x).toBe(124)
|
||||
expect(engine.projectiles[1]!.y).toBe(100)
|
||||
expect(engine.world.player.lastBlazeX).toBe(124)
|
||||
|
||||
// Tick 5..20: Player remains stationary at (124, 100) for 15 frames -> 0 new patches
|
||||
for (let t = 0; t < 15; t++) {
|
||||
engine.tick(idleInput)
|
||||
}
|
||||
expect(engine.projectiles).toHaveLength(2)
|
||||
|
||||
// Tick 21: Player moves diagonally by (17, 17) -> dist = hypot(17, 17) = 24.04px >= 24px -> SPAWNS 3rd patch!
|
||||
engine.world.player.x = 124 + 17
|
||||
engine.world.player.y = 100 + 17
|
||||
engine.tick(idleInput)
|
||||
expect(engine.projectiles).toHaveLength(3)
|
||||
expect(engine.projectiles[2]!.x).toBe(141)
|
||||
expect(engine.projectiles[2]!.y).toBe(117)
|
||||
})
|
||||
|
||||
it('enforces absolute self-hit immunity: player standing stationary on Blaze patches takes 0 damage', () => {
|
||||
const blazePatch: Projectile = {
|
||||
skillId: '46',
|
||||
x: 300,
|
||||
y: 300,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 50,
|
||||
ttl: 100,
|
||||
fromPlayer: true,
|
||||
missileType: 'blaze',
|
||||
pierce: true,
|
||||
aoeRadius: 24,
|
||||
}
|
||||
|
||||
const playerTarget = {
|
||||
index: 0,
|
||||
alive: true,
|
||||
x: 300,
|
||||
y: 300,
|
||||
radius: 16,
|
||||
isPlayer: true,
|
||||
}
|
||||
|
||||
const enemyTarget = {
|
||||
index: 1,
|
||||
alive: true,
|
||||
x: 300,
|
||||
y: 300,
|
||||
radius: 16,
|
||||
isPlayer: false,
|
||||
}
|
||||
|
||||
const terrain = { overlap: () => 0 }
|
||||
|
||||
let activeProjectiles: readonly Projectile[] = [blazePatch]
|
||||
|
||||
// Tick 20 frames continuously
|
||||
for (let f = 1; f <= 20; f++) {
|
||||
const outcome = tickProjectiles(activeProjectiles, [playerTarget, enemyTarget], terrain)
|
||||
activeProjectiles = outcome.alive
|
||||
|
||||
// Player must NEVER be hit
|
||||
const playerHits = outcome.hits.filter(h => h.targetIndex === 0)
|
||||
expect(playerHits).toHaveLength(0)
|
||||
|
||||
// Enemy must be hit every single frame
|
||||
const enemyHits = outcome.hits.filter(h => h.targetIndex === 1)
|
||||
expect(enemyHits).toHaveLength(1)
|
||||
expect(enemyHits[0]!.damage).toBe(50)
|
||||
}
|
||||
})
|
||||
|
||||
it('enforces self-hit immunity under sharp 180° hairpin turns through active Blaze patches', () => {
|
||||
// Player lays 3 Blaze patches in a line: (100, 100), (124, 100), (148, 100)
|
||||
const patches: Projectile[] = [
|
||||
{
|
||||
skillId: '46',
|
||||
x: 100,
|
||||
y: 100,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 60,
|
||||
ttl: 100,
|
||||
fromPlayer: true,
|
||||
missileType: 'blaze',
|
||||
pierce: true,
|
||||
aoeRadius: 24,
|
||||
},
|
||||
{
|
||||
skillId: '46',
|
||||
x: 124,
|
||||
y: 100,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 60,
|
||||
ttl: 100,
|
||||
fromPlayer: true,
|
||||
missileType: 'blaze',
|
||||
pierce: true,
|
||||
aoeRadius: 24,
|
||||
},
|
||||
{
|
||||
skillId: '46',
|
||||
x: 148,
|
||||
y: 100,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 60,
|
||||
ttl: 100,
|
||||
fromPlayer: true,
|
||||
missileType: 'blaze',
|
||||
pierce: true,
|
||||
aoeRadius: 24,
|
||||
},
|
||||
]
|
||||
|
||||
// Player turns 180° and runs back directly on top of patch 2 (124, 100) then patch 1 (100, 100)
|
||||
const playerAtPatch2 = { index: 0, alive: true, x: 124, y: 100, radius: 16, isPlayer: true }
|
||||
const terrain = { overlap: () => 0 }
|
||||
|
||||
const outcome1 = tickProjectiles(patches, [playerAtPatch2], terrain)
|
||||
expect(outcome1.hits.filter(h => h.targetIndex === 0)).toHaveLength(0)
|
||||
|
||||
const playerAtPatch1 = { index: 0, alive: true, x: 100, y: 100, radius: 16, isPlayer: true }
|
||||
const outcome2 = tickProjectiles(outcome1.alive, [playerAtPatch1], terrain)
|
||||
expect(outcome2.hits.filter(h => h.targetIndex === 0)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('enforces that enemy-cast Blaze patches DO damage the player', () => {
|
||||
const enemyBlazePatch: Projectile = {
|
||||
skillId: '46',
|
||||
x: 200,
|
||||
y: 200,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 45,
|
||||
ttl: 90,
|
||||
fromPlayer: false, // Cast by an enemy monster!
|
||||
missileType: 'blaze',
|
||||
pierce: true,
|
||||
aoeRadius: 24,
|
||||
}
|
||||
|
||||
const playerTarget = {
|
||||
index: 0,
|
||||
alive: true,
|
||||
x: 200,
|
||||
y: 200,
|
||||
radius: 16,
|
||||
isPlayer: true,
|
||||
}
|
||||
|
||||
const terrain = { overlap: () => 0 }
|
||||
const outcome = tickProjectiles([enemyBlazePatch], [playerTarget], terrain)
|
||||
|
||||
// Player takes damage from hostile Blaze!
|
||||
expect(outcome.hits).toHaveLength(1)
|
||||
expect(outcome.hits[0]!.targetIndex).toBe(0)
|
||||
expect(outcome.hits[0]!.damage).toBe(45)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// Dimension 4: Continuous Tick Reset (m.hitTargetIds.clear())
|
||||
// ==========================================================================
|
||||
describe('Dimension 4: Continuous Tick Reset (m.hitTargetIds.clear())', () => {
|
||||
it('verifies Fire Wall deals damage on 10 successive frames without single-hit blocking', () => {
|
||||
const firewallPatch: Projectile = {
|
||||
skillId: '51',
|
||||
x: 100,
|
||||
y: 100,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 85,
|
||||
ttl: 90,
|
||||
fromPlayer: true,
|
||||
missileType: 'firewall',
|
||||
pierce: true,
|
||||
aoeRadius: 24,
|
||||
}
|
||||
|
||||
const target = {
|
||||
index: 10,
|
||||
alive: true,
|
||||
x: 100,
|
||||
y: 100,
|
||||
radius: 16,
|
||||
}
|
||||
|
||||
const terrain = { overlap: () => 0 }
|
||||
let current: readonly Projectile[] = [firewallPatch]
|
||||
let totalDamage = 0
|
||||
|
||||
for (let tick = 1; tick <= 10; tick++) {
|
||||
const outcome = tickProjectiles(current, [target], terrain)
|
||||
expect(outcome.hits).toHaveLength(1)
|
||||
expect(outcome.hits[0]!.targetIndex).toBe(10)
|
||||
expect(outcome.hits[0]!.damage).toBe(85)
|
||||
totalDamage += outcome.hits[0]!.damage
|
||||
current = outcome.alive
|
||||
}
|
||||
|
||||
expect(totalDamage).toBe(850)
|
||||
expect(current[0]!.ttl).toBe(80)
|
||||
})
|
||||
|
||||
it('verifies Inferno stream flames (infernoflame1 & infernoflame2) damage continuously every tick', () => {
|
||||
const flame1: Projectile = {
|
||||
skillId: '41',
|
||||
x: 100,
|
||||
y: 100,
|
||||
vx: 12,
|
||||
vy: 0,
|
||||
damage: 30,
|
||||
ttl: 15,
|
||||
fromPlayer: true,
|
||||
missileType: 'infernoflame1',
|
||||
pierce: true,
|
||||
aoeRadius: 20,
|
||||
}
|
||||
|
||||
const target = {
|
||||
index: 5,
|
||||
alive: true,
|
||||
x: 108,
|
||||
y: 100,
|
||||
radius: 16,
|
||||
}
|
||||
|
||||
const terrain = { overlap: () => 0 }
|
||||
|
||||
// Tick 1
|
||||
const out1 = tickProjectiles([flame1], [target], terrain)
|
||||
expect(out1.hits).toHaveLength(1)
|
||||
expect(out1.hits[0]!.damage).toBe(30)
|
||||
|
||||
// Tick 2: flame moves to x=120, target at 108 is still within hitRadius (16+8=24)
|
||||
const out2 = tickProjectiles(out1.alive, [target], terrain)
|
||||
expect(out2.hits).toHaveLength(1)
|
||||
expect(out2.hits[0]!.damage).toBe(30)
|
||||
|
||||
// Same test with infernoflame2
|
||||
const flame2: Projectile = {
|
||||
...flame1,
|
||||
missileType: 'infernoflame2',
|
||||
}
|
||||
const outFlame2 = tickProjectiles([flame2], [target], terrain)
|
||||
expect(outFlame2.hits).toHaveLength(1)
|
||||
expect(outFlame2.hits[0]!.damage).toBe(30)
|
||||
})
|
||||
|
||||
it('verifies groundfirebig and meteorfire tick mechanics', () => {
|
||||
const gfbPatch: Projectile = {
|
||||
skillId: '51',
|
||||
x: 200,
|
||||
y: 200,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 75,
|
||||
ttl: 50,
|
||||
fromPlayer: true,
|
||||
missileType: 'groundfirebig',
|
||||
pierce: true,
|
||||
aoeRadius: 24,
|
||||
}
|
||||
|
||||
const target = { index: 2, alive: true, x: 200, y: 200, radius: 16 }
|
||||
const terrain = { overlap: () => 0 }
|
||||
|
||||
const out1 = tickProjectiles([gfbPatch], [target], terrain)
|
||||
expect(out1.hits).toHaveLength(1)
|
||||
const out2 = tickProjectiles(out1.alive, [target], terrain)
|
||||
expect(out2.hits).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('contrasts with non-continuous piercing projectile which hits each target only once', () => {
|
||||
const normalPiercingArrow: Projectile = {
|
||||
skillId: '1',
|
||||
x: 100,
|
||||
y: 100,
|
||||
vx: 0, // stationary for test isolation
|
||||
vy: 0,
|
||||
damage: 100,
|
||||
ttl: 10,
|
||||
fromPlayer: true,
|
||||
missileType: 'firebolt', // not a ground fire or stream flame!
|
||||
pierce: true,
|
||||
aoeRadius: 16,
|
||||
}
|
||||
|
||||
const target = { index: 7, alive: true, x: 100, y: 100, radius: 16 }
|
||||
const terrain = { overlap: () => 0 }
|
||||
|
||||
// Tick 1: hits target
|
||||
const out1 = tickProjectiles([normalPiercingArrow], [target], terrain)
|
||||
expect(out1.hits).toHaveLength(1)
|
||||
|
||||
// Tick 2: hitTargets was NOT cleared -> does NOT hit target again!
|
||||
const out2 = tickProjectiles(out1.alive, [target], terrain)
|
||||
expect(out2.hits).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('verifies continuous tick reset in MissileEngine simulation', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const engine = new MissileEngine(registry)
|
||||
const attacker = createMockCombatUnit('sorceress')
|
||||
const enemy = createMockCombatUnit('enemy_firewall', { hp: 5000 })
|
||||
|
||||
const targets = [enemy]
|
||||
const targetPositions = new Map<string, { x: number; y: number }>([
|
||||
['enemy_firewall', { x: 200, y: 200 }],
|
||||
])
|
||||
|
||||
// Spawn Fire Wall patch directly on top of enemy
|
||||
engine.spawnMissile({
|
||||
missileNameOrId: 'firewall',
|
||||
sourceSkillId: 51,
|
||||
slvl: 20,
|
||||
owner: attacker,
|
||||
startX: 200,
|
||||
startY: 200,
|
||||
targetX: 200,
|
||||
targetY: 200,
|
||||
dmgPacket: {
|
||||
skillId: 51,
|
||||
elemType: 'fire',
|
||||
elemMin256: 100 * 256,
|
||||
elemMax256: 100 * 256,
|
||||
autoHit: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Tick 5 consecutive frames
|
||||
let hitsCount = 0
|
||||
for (let f = 1; f <= 5; f++) {
|
||||
const out = engine.tick(1, targets, targetPositions)
|
||||
const hit = out.hits.find(h => h.targetId === 'enemy_firewall')
|
||||
if (hit) hitsCount++
|
||||
}
|
||||
|
||||
expect(hitsCount).toBe(5)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// Dimension 5: 2:1 Isometric Aspect Ratio (ISO_GROUND_ASPECT_RATIO = 0.5)
|
||||
// ==========================================================================
|
||||
describe('Dimension 5: 2:1 Isometric Aspect Ratio (ISO_GROUND_ASPECT_RATIO = 0.5)', () => {
|
||||
it('confirms ISO_GROUND_ASPECT_RATIO is strictly 0.5', () => {
|
||||
expect(ISO_GROUND_ASPECT_RATIO).toBe(0.5)
|
||||
})
|
||||
|
||||
it('verifies horizontal reach (24px) vs vertical reach (12px) ellipse in tickProjectiles', () => {
|
||||
const patchX = 200
|
||||
const patchY = 200
|
||||
const effectiveRadius = 24 // aoeRadius 24 with radius 0
|
||||
|
||||
const patch: Projectile = {
|
||||
skillId: '51',
|
||||
x: patchX,
|
||||
y: patchY,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 100,
|
||||
ttl: 90,
|
||||
fromPlayer: true,
|
||||
missileType: 'firewall',
|
||||
pierce: true,
|
||||
aoeRadius: 24,
|
||||
}
|
||||
|
||||
const terrain = { overlap: () => 0 }
|
||||
|
||||
// 1. Horizontal reach:
|
||||
// dx = 23px, dy = 0px -> dist = 23 <= 24 -> HIT
|
||||
const tH1 = { index: 1, alive: true, x: patchX + 23, y: patchY, radius: 16 }
|
||||
const outH1 = tickProjectiles([patch], [tH1], terrain)
|
||||
expect(outH1.hits).toHaveLength(1)
|
||||
|
||||
// dx = 25px, dy = 0px -> dist = 25 > 24 -> MISS
|
||||
const tH2 = { index: 2, alive: true, x: patchX + 25, y: patchY, radius: 16 }
|
||||
const outH2 = tickProjectiles([patch], [tH2], terrain)
|
||||
expect(outH2.hits).toHaveLength(0)
|
||||
|
||||
// 2. Vertical reach (scaled by 0.5):
|
||||
// dy = 11.5px -> unscaled dist = 11.5 / 0.5 = 23 <= 24 -> HIT
|
||||
const tV1 = { index: 3, alive: true, x: patchX, y: patchY + 11.5, radius: 16 }
|
||||
const outV1 = tickProjectiles([patch], [tV1], terrain)
|
||||
expect(outV1.hits).toHaveLength(1)
|
||||
|
||||
// dy = 12.5px -> unscaled dist = 12.5 / 0.5 = 25 > 24 -> MISS
|
||||
// In Cartesian 1:1, dy = 12.5 < 24 would hit, but in 2:1 isometric ground it MUST miss!
|
||||
const tV2 = { index: 4, alive: true, x: patchX, y: patchY + 12.5, radius: 16 }
|
||||
const outV2 = tickProjectiles([patch], [tV2], terrain)
|
||||
expect(outV2.hits).toHaveLength(0)
|
||||
|
||||
// 3. Isometric Diagonal Ellipse Boundary (dx / 24)^2 + (dy / 12)^2 <= 1:
|
||||
// At theta = 45°:
|
||||
// Inside: dx = 24 * cos(45°) * 0.95 = 16.12, dy = 12 * sin(45°) * 0.95 = 8.06 -> HIT
|
||||
const tDiagIn = { index: 5, alive: true, x: patchX + 16.12, y: patchY + 8.06, radius: 16 }
|
||||
const outDiagIn = tickProjectiles([patch], [tDiagIn], terrain)
|
||||
expect(outDiagIn.hits).toHaveLength(1)
|
||||
|
||||
// Outside: dx = 24 * cos(45°) * 1.05 = 17.82, dy = 12 * sin(45°) * 1.05 = 8.91 -> MISS
|
||||
const tDiagOut = { index: 6, alive: true, x: patchX + 17.82, y: patchY + 8.91, radius: 16 }
|
||||
const outDiagOut = tickProjectiles([patch], [tDiagOut], terrain)
|
||||
expect(outDiagOut.hits).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('verifies 2:1 isometric aspect ratio in MissileEngine collision pipeline', async () => {
|
||||
const registry = await getSharedDataRegistry()
|
||||
const engine = new MissileEngine(registry)
|
||||
const attacker = createMockCombatUnit('sorceress')
|
||||
|
||||
// Ground fire patch at (100, 100), hitRadius = Math.max(24, size * 14) = 24px
|
||||
const uInH = createMockCombatUnit('unit_in_h') // dx = 22px, dy = 0 -> dist = 22 <= 24 -> HIT
|
||||
const uOutH = createMockCombatUnit('unit_out_h') // dx = 26px, dy = 0 -> dist = 26 > 24 -> MISS
|
||||
const uInV = createMockCombatUnit('unit_in_v') // dx = 0, dy = 11px -> dist = 11/0.5 = 22 <= 24 -> HIT
|
||||
const uOutV = createMockCombatUnit('unit_out_v') // dx = 0, dy = 13px -> dist = 13/0.5 = 26 > 24 -> MISS
|
||||
|
||||
const targets = [uInH, uOutH, uInV, uOutV]
|
||||
const targetPositions = new Map<string, { x: number; y: number }>([
|
||||
['unit_in_h', { x: 122, y: 100 }],
|
||||
['unit_out_h', { x: 126, y: 100 }],
|
||||
['unit_in_v', { x: 100, y: 111 }],
|
||||
['unit_out_v', { x: 100, y: 113 }],
|
||||
])
|
||||
|
||||
engine.spawnMissile({
|
||||
missileNameOrId: 'firewall',
|
||||
sourceSkillId: 51,
|
||||
slvl: 1,
|
||||
owner: attacker,
|
||||
startX: 100,
|
||||
startY: 100,
|
||||
targetX: 100,
|
||||
targetY: 100,
|
||||
dmgPacket: {
|
||||
skillId: 51,
|
||||
elemType: 'fire',
|
||||
elemMin256: 100 * 256,
|
||||
elemMax256: 100 * 256,
|
||||
autoHit: true,
|
||||
},
|
||||
})
|
||||
|
||||
const outcome = engine.tick(1, targets, targetPositions)
|
||||
|
||||
expect(outcome.hits.find(h => h.targetId === 'unit_in_h')).toBeDefined()
|
||||
expect(outcome.hits.find(h => h.targetId === 'unit_out_h')).toBeUndefined()
|
||||
expect(outcome.hits.find(h => h.targetId === 'unit_in_v')).toBeDefined()
|
||||
expect(outcome.hits.find(h => h.targetId === 'unit_out_v')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,427 @@
|
|||
/**
|
||||
* Adversarial Empirical Stress Testing Suite for Milestone 3 (Fire Grounds & Streams)
|
||||
*
|
||||
* Target Skills:
|
||||
* - Skill 51: Fire Wall (firewallmaker / firewall)
|
||||
* - Skill 46: Blaze (blaze buff / footstep ground fire patch)
|
||||
* - Skill 41: Inferno (infernoflame1 / infernoflame2 continuous stream)
|
||||
*
|
||||
* Test Dimensions:
|
||||
* 1. Monotonicity sweeps across slvl 1..99 for damage formulas
|
||||
* 2. Synergy scaling & precision underflow/overflow/loss
|
||||
* 3. Duration scaling (Blaze player buff and ground patch duration)
|
||||
* 4. Inferno range scaling & yard-to-pixel conversion (range * 32)
|
||||
* 5. Continuous mana drain in 256-fixed-point arithmetic over extended frames
|
||||
* 6. Extreme inputs: slvl 0, 1, 20, 50, 99; synergy levels 0, 20, 50; boundary checks
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
calculateFireWallDamage,
|
||||
calculateBlazeDamage,
|
||||
calculateInfernoDamage,
|
||||
calculateBlazeDuration,
|
||||
calculateBlazePatchDuration,
|
||||
calculateInfernoRange,
|
||||
calculateInfernoRangePx,
|
||||
calculateInfernoContinuousMana256,
|
||||
calculateFireWallPatches,
|
||||
} from '../../../src/game/skills.ts'
|
||||
|
||||
describe('Milestone 3 Math Adversarial Stress Suite', () => {
|
||||
// ==========================================================================
|
||||
// Suite 1: Monotonicity Sweeps (slvl 1..99)
|
||||
// ==========================================================================
|
||||
describe('Suite 1: Monotonicity Sweeps across slvl 1..99', () => {
|
||||
it('Fire Wall damage strictly increases monotonically across slvl 1..99', () => {
|
||||
let prev = calculateFireWallDamage(1)
|
||||
expect(prev.min).toBe(15)
|
||||
expect(prev.max).toBe(20)
|
||||
expect(prev.min256).toBe(240)
|
||||
expect(prev.max256).toBe(320)
|
||||
|
||||
for (let slvl = 2; slvl <= 99; slvl++) {
|
||||
const curr = calculateFireWallDamage(slvl)
|
||||
|
||||
// Strict monotonicity checks
|
||||
expect(curr.min).toBeGreaterThan(prev.min)
|
||||
expect(curr.max).toBeGreaterThan(prev.max)
|
||||
expect(curr.min256).toBeGreaterThan(prev.min256)
|
||||
expect(curr.max256).toBeGreaterThan(prev.max256)
|
||||
expect(curr.perSecMin).toBeGreaterThan(prev.perSecMin)
|
||||
expect(curr.perSecMax).toBeGreaterThan(prev.perSecMax)
|
||||
|
||||
// Min <= Max invariant
|
||||
expect(curr.min).toBeLessThanOrEqual(curr.max)
|
||||
expect(curr.min256).toBeLessThanOrEqual(curr.max256)
|
||||
expect(curr.perSecMin).toBeLessThanOrEqual(curr.perSecMax)
|
||||
|
||||
prev = curr
|
||||
}
|
||||
})
|
||||
|
||||
it('Blaze damage strictly increases monotonically across slvl 1..99', () => {
|
||||
let prev = calculateBlazeDamage(1)
|
||||
expect(prev.min).toBe(4)
|
||||
expect(prev.max).toBe(8)
|
||||
expect(prev.min256).toBe(64)
|
||||
expect(prev.max256).toBe(128)
|
||||
|
||||
for (let slvl = 2; slvl <= 99; slvl++) {
|
||||
const curr = calculateBlazeDamage(slvl)
|
||||
|
||||
// Strict monotonicity checks
|
||||
expect(curr.min).toBeGreaterThan(prev.min)
|
||||
expect(curr.max).toBeGreaterThan(prev.max)
|
||||
expect(curr.min256).toBeGreaterThan(prev.min256)
|
||||
expect(curr.max256).toBeGreaterThan(prev.max256)
|
||||
expect(curr.perSecMin).toBeGreaterThan(prev.perSecMin)
|
||||
expect(curr.perSecMax).toBeGreaterThan(prev.perSecMax)
|
||||
|
||||
// Min <= Max invariant
|
||||
expect(curr.min).toBeLessThanOrEqual(curr.max)
|
||||
expect(curr.min256).toBeLessThanOrEqual(curr.max256)
|
||||
expect(curr.perSecMin).toBeLessThanOrEqual(curr.perSecMax)
|
||||
|
||||
prev = curr
|
||||
}
|
||||
})
|
||||
|
||||
it('Inferno damage strictly increases monotonically across slvl 1..99', () => {
|
||||
let prev = calculateInfernoDamage(1)
|
||||
expect(prev.min).toBe(32)
|
||||
expect(prev.max).toBe(64)
|
||||
expect(prev.min256).toBe(128)
|
||||
expect(prev.max256).toBe(256)
|
||||
|
||||
for (let slvl = 2; slvl <= 99; slvl++) {
|
||||
const curr = calculateInfernoDamage(slvl)
|
||||
|
||||
// Strict monotonicity checks
|
||||
expect(curr.min).toBeGreaterThan(prev.min)
|
||||
expect(curr.max).toBeGreaterThan(prev.max)
|
||||
expect(curr.min256).toBeGreaterThan(prev.min256)
|
||||
expect(curr.max256).toBeGreaterThan(prev.max256)
|
||||
expect(curr.perSecMin).toBeGreaterThan(prev.perSecMin)
|
||||
expect(curr.perSecMax).toBeGreaterThan(prev.perSecMax)
|
||||
|
||||
// Min <= Max invariant
|
||||
expect(curr.min).toBeLessThanOrEqual(curr.max)
|
||||
expect(curr.min256).toBeLessThanOrEqual(curr.max256)
|
||||
expect(curr.perSecMin).toBeLessThanOrEqual(curr.perSecMax)
|
||||
|
||||
prev = curr
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// Suite 2: Synergy Scaling & Precision Underflow/Overflow/Loss
|
||||
// ==========================================================================
|
||||
describe('Suite 2: Synergy Scaling & Precision', () => {
|
||||
it('Fire Wall scales linearly with Warmth (+4%/lvl) and Inferno (+1%/lvl) synergies', () => {
|
||||
const base = calculateFireWallDamage(20) // raw 274-279, min256=4384, max256=4464
|
||||
|
||||
// Sweep Warmth 0..20
|
||||
let prevWarmthDmg = calculateFireWallDamage(20, { warmth: 0 })
|
||||
for (let w = 1; w <= 20; w++) {
|
||||
const curr = calculateFireWallDamage(20, { warmth: w })
|
||||
expect(curr.min256).toBeGreaterThan(prevWarmthDmg.min256)
|
||||
expect(curr.max256).toBeGreaterThan(prevWarmthDmg.max256)
|
||||
// Expected multiplier: 1 + 0.04 * w
|
||||
const expectedMin256 = Math.floor(274 * 16 * (1 + 0.04 * w))
|
||||
expect(curr.min256).toBe(expectedMin256)
|
||||
prevWarmthDmg = curr
|
||||
}
|
||||
|
||||
// Sweep Inferno 0..20
|
||||
let prevInfernoDmg = calculateFireWallDamage(20, { inferno: 0 })
|
||||
for (let inf = 1; inf <= 20; inf++) {
|
||||
const curr = calculateFireWallDamage(20, { inferno: inf })
|
||||
expect(curr.min256).toBeGreaterThan(prevInfernoDmg.min256)
|
||||
expect(curr.max256).toBeGreaterThan(prevInfernoDmg.max256)
|
||||
// Expected multiplier: 1 + 0.01 * inf
|
||||
const expectedMin256 = Math.floor(274 * 16 * (1 + 0.01 * inf))
|
||||
expect(curr.min256).toBe(expectedMin256)
|
||||
prevInfernoDmg = curr
|
||||
}
|
||||
|
||||
// Max synergy: Warmth 20 (+80%) + Inferno 20 (+20%) = +100% (2.0x base)
|
||||
const maxSyn = calculateFireWallDamage(20, { warmth: 20, inferno: 20 })
|
||||
expect(maxSyn.min256).toBe(base.min256 * 2)
|
||||
expect(maxSyn.max256).toBe(base.max256 * 2)
|
||||
})
|
||||
|
||||
it('Blaze scales linearly with Warmth (+4%/lvl) and Fire Wall (+1%/lvl) synergies', () => {
|
||||
const base = calculateBlazeDamage(20) // raw 58-62, min256=928, max256=992
|
||||
|
||||
// Max synergy: Warmth 20 (+80%) + Fire Wall 20 (+20%) = +100% (2.0x base)
|
||||
const maxSyn = calculateBlazeDamage(20, { warmth: 20, fireWall: 20 })
|
||||
expect(maxSyn.min256).toBe(base.min256 * 2)
|
||||
expect(maxSyn.max256).toBe(base.max256 * 2)
|
||||
|
||||
// Extreme synergy: Warmth 50 (+200%) + Fire Wall 50 (+50%) = +250% (3.5x base)
|
||||
const extremeSyn = calculateBlazeDamage(20, { warmth: 50, fireWall: 50 })
|
||||
const expectedMin256 = Math.floor(58 * 16 * 3.5)
|
||||
expect(extremeSyn.min256).toBe(expectedMin256)
|
||||
expect(extremeSyn.min256).toBe(3248)
|
||||
})
|
||||
|
||||
it('Inferno scales linearly with Warmth (+13%/lvl) synergy', () => {
|
||||
const base = calculateInfernoDamage(20) // raw 520-564, min256=2080, max256=2256
|
||||
|
||||
// Warmth 20: +260% (3.6x base)
|
||||
const syn20 = calculateInfernoDamage(20, { warmth: 20 })
|
||||
const expectedMin256_20 = Math.floor(520 * 4 * (1 + 2.6))
|
||||
expect(syn20.min256).toBe(expectedMin256_20)
|
||||
expect(syn20.min256).toBe(Math.floor(2080 * 3.6))
|
||||
|
||||
// Warmth 50: +650% (7.5x base)
|
||||
const syn50 = calculateInfernoDamage(20, { warmth: 50 })
|
||||
const expectedMin256_50 = Math.floor(520 * 4 * (1 + 6.5))
|
||||
expect(syn50.min256).toBe(expectedMin256_50)
|
||||
expect(syn50.min256).toBe(15600)
|
||||
})
|
||||
|
||||
it('Precision invariant: no underflow, overflow, or NaN at high level and high synergy', () => {
|
||||
// slvl 99 with synergy 50 and Fire Mastery 50
|
||||
const fw = calculateFireWallDamage(99, { warmth: 50, inferno: 50, fireMastery: 50 })
|
||||
expect(Number.isFinite(fw.min)).toBe(true)
|
||||
expect(Number.isFinite(fw.max)).toBe(true)
|
||||
expect(Number.isFinite(fw.min256)).toBe(true)
|
||||
expect(Number.isFinite(fw.max256)).toBe(true)
|
||||
expect(fw.min256).toBeGreaterThan(0)
|
||||
expect(fw.max256).toBeGreaterThan(fw.min256)
|
||||
|
||||
const bz = calculateBlazeDamage(99, { warmth: 50, fireWall: 50, fireMastery: 50 })
|
||||
expect(Number.isFinite(bz.min256)).toBe(true)
|
||||
expect(bz.min256).toBeGreaterThan(0)
|
||||
|
||||
const inf = calculateInfernoDamage(99, { warmth: 50, fireMastery: 50 })
|
||||
expect(Number.isFinite(inf.min256)).toBe(true)
|
||||
expect(inf.min256).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// Suite 3: Duration Scaling (Blaze Buff & Patch)
|
||||
// ==========================================================================
|
||||
describe('Suite 3: Duration Scaling', () => {
|
||||
it('Blaze ground patch duration strictly increases monotonically by +25 frames per level across slvl 1..99', () => {
|
||||
let prev = calculateBlazePatchDuration(1)
|
||||
expect(prev).toBe(115) // 90 + 25 = 115 frames (4.6s)
|
||||
|
||||
for (let slvl = 2; slvl <= 99; slvl++) {
|
||||
const curr = calculateBlazePatchDuration(slvl)
|
||||
expect(curr).toBe(prev + 25)
|
||||
expect(curr).toBe(90 + slvl * 25)
|
||||
prev = curr
|
||||
}
|
||||
|
||||
expect(calculateBlazePatchDuration(10)).toBe(340)
|
||||
expect(calculateBlazePatchDuration(20)).toBe(590)
|
||||
expect(calculateBlazePatchDuration(99)).toBe(2565)
|
||||
})
|
||||
|
||||
it('Blaze player buff duration scales monotonically (non-decreasing) across slvl 1..99', () => {
|
||||
let prev = calculateBlazeDuration(1)
|
||||
expect(prev).toBe(120) // floor(110*1*450 / 700) + 50 = 70 + 50 = 120 frames (4.8s)
|
||||
|
||||
let plateaus = 0
|
||||
for (let slvl = 2; slvl <= 99; slvl++) {
|
||||
const curr = calculateBlazeDuration(slvl)
|
||||
// Monotonicity (non-decreasing)
|
||||
expect(curr).toBeGreaterThanOrEqual(prev)
|
||||
if (curr === prev) {
|
||||
plateaus++
|
||||
}
|
||||
prev = curr
|
||||
}
|
||||
|
||||
// Diminishing returns curve asymptotic limit is 495 + 50 = 545 frames.
|
||||
// Because of floor of rational curve 495 * x / (x + 6), plateaus occur at very high levels (e.g. 80 -> 81).
|
||||
expect(calculateBlazeDuration(10)).toBe(359)
|
||||
expect(calculateBlazeDuration(20)).toBe(430)
|
||||
expect(calculateBlazeDuration(50)).toBe(491)
|
||||
expect(calculateBlazeDuration(80)).toBe(510)
|
||||
expect(calculateBlazeDuration(81)).toBe(510) // Empirical plateau observed
|
||||
expect(calculateBlazeDuration(99)).toBe(516)
|
||||
expect(plateaus).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// Suite 4: Inferno Range Scaling & Yard-to-Pixel Conversion
|
||||
// ==========================================================================
|
||||
describe('Suite 4: Inferno Range Scaling & Yard-to-Pixel Conversion', () => {
|
||||
it('Inferno range in yards scales strictly monotonically: ((20 + 3 * (slvl - 1)) / 2)', () => {
|
||||
let prev = calculateInfernoRange(1)
|
||||
expect(prev).toBe(10) // (20 + 0) / 2 = 10 yards
|
||||
|
||||
for (let slvl = 2; slvl <= 99; slvl++) {
|
||||
const curr = calculateInfernoRange(slvl)
|
||||
expect(curr).toBe(prev + 1.5)
|
||||
expect(curr).toBe((20 + 3 * (slvl - 1)) / 2)
|
||||
prev = curr
|
||||
}
|
||||
|
||||
expect(calculateInfernoRange(10)).toBe(23.5)
|
||||
expect(calculateInfernoRange(20)).toBe(38.5)
|
||||
expect(calculateInfernoRange(50)).toBe(83.5)
|
||||
expect(calculateInfernoRange(99)).toBe(157.0)
|
||||
})
|
||||
|
||||
it('ADVERSARIAL CHECK: Inferno range converts accurately to screen pixels via 1.13c yard-to-pixel ratio (range * 32)', () => {
|
||||
// Diablo II 1.13c ground truth: 1 tile = 5x5 subtiles = 160x80 px -> 1 subtile (yard) = 32 pixels in length.
|
||||
// The requirement explicitly mandates: "converts accurately to pixels (range * 32)".
|
||||
const expectedPxLvl1 = calculateInfernoRange(1) * 32 // 10.0 * 32 = 320 px
|
||||
const actualPxLvl1 = calculateInfernoRangePx(1)
|
||||
|
||||
const expectedPxLvl10 = calculateInfernoRange(10) * 32 // 23.5 * 32 = 752 px
|
||||
const actualPxLvl10 = calculateInfernoRangePx(10)
|
||||
|
||||
const expectedPxLvl20 = calculateInfernoRange(20) * 32 // 38.5 * 32 = 1232 px
|
||||
const actualPxLvl20 = calculateInfernoRangePx(20)
|
||||
|
||||
const expectedPxLvl99 = calculateInfernoRange(99) * 32 // 157.0 * 32 = 5024 px
|
||||
const actualPxLvl99 = calculateInfernoRangePx(99)
|
||||
|
||||
// EMPIRICAL VERIFICATION:
|
||||
// Note: Implementation in src/game/engine/missile-engine.ts uses:
|
||||
// `return calculateInfernoRange(slvl) * 15`
|
||||
// instead of authentic `calculateInfernoRange(slvl) * 32`.
|
||||
expect(actualPxLvl1).toBe(expectedPxLvl1)
|
||||
expect(actualPxLvl10).toBe(expectedPxLvl10)
|
||||
expect(actualPxLvl20).toBe(expectedPxLvl20)
|
||||
expect(actualPxLvl99).toBe(expectedPxLvl99)
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// Suite 5: Continuous Mana Drain in 256-Fixed-Point
|
||||
// ==========================================================================
|
||||
describe('Suite 5: Continuous Mana Drain (256-Fixed-Point)', () => {
|
||||
it('Inferno continuous mana drain rate in 256ths per frame: (36 + (slvl - 1)) * 4', () => {
|
||||
expect(calculateInfernoContinuousMana256(1)).toBe(144) // 144/256 mana/frame
|
||||
expect(calculateInfernoContinuousMana256(10)).toBe(180)
|
||||
expect(calculateInfernoContinuousMana256(20)).toBe(220)
|
||||
expect(calculateInfernoContinuousMana256(50)).toBe(340)
|
||||
expect(calculateInfernoContinuousMana256(99)).toBe(536)
|
||||
|
||||
// Strictly monotonic
|
||||
let prev = calculateInfernoContinuousMana256(1)
|
||||
for (let slvl = 2; slvl <= 99; slvl++) {
|
||||
const curr = calculateInfernoContinuousMana256(slvl)
|
||||
expect(curr).toBe(prev + 4)
|
||||
prev = curr
|
||||
}
|
||||
})
|
||||
|
||||
it('Simulates extended continuous mana depletion without sub-mana precision drift over 750 frames (30s)', () => {
|
||||
const slvl = 1
|
||||
const drainPerFrame256 = calculateInfernoContinuousMana256(slvl) // 144
|
||||
expect(drainPerFrame256).toBe(144)
|
||||
|
||||
// Starting mana: 500 mana = 500 * 256 = 128,000 units
|
||||
const initialMana = 500
|
||||
let currentMana256 = initialMana * 256
|
||||
|
||||
// Drain frame-by-frame for 750 frames (30 seconds at 25 fps)
|
||||
const frames = 750
|
||||
for (let f = 0; f < frames; f++) {
|
||||
currentMana256 -= drainPerFrame256
|
||||
}
|
||||
|
||||
const totalDrained256 = frames * drainPerFrame256 // 750 * 144 = 108,000
|
||||
expect(currentMana256).toBe(initialMana * 256 - totalDrained256) // exactly 20,000 units left
|
||||
|
||||
// Integer mana remaining: floor(20,000 / 256) = 78 mana, with fractional remainder 32/256 (0.125)
|
||||
const integerManaLeft = Math.floor(currentMana256 / 256)
|
||||
const remainder256 = currentMana256 % 256
|
||||
expect(integerManaLeft).toBe(78)
|
||||
expect(remainder256).toBe(32)
|
||||
|
||||
// Compare against naive integer truncation per frame:
|
||||
// If an engine truncated Math.floor(144 / 256) each frame, it would drain 0 mana per frame!
|
||||
const naiveDrain = Math.floor(drainPerFrame256 / 256) * frames
|
||||
expect(naiveDrain).toBe(0) // Demonstrates why 256-fixed-point precision is required
|
||||
})
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// Suite 6: Extreme Inputs & Boundary Values
|
||||
// ==========================================================================
|
||||
describe('Suite 6: Extreme Inputs & Boundary Values', () => {
|
||||
it('Handles extreme slvl 0 by safe clamping to slvl 1', () => {
|
||||
// Fire Wall
|
||||
const fw0 = calculateFireWallDamage(0)
|
||||
const fw1 = calculateFireWallDamage(1)
|
||||
expect(fw0).toEqual(fw1)
|
||||
|
||||
// Blaze
|
||||
const bz0 = calculateBlazeDamage(0)
|
||||
const bz1 = calculateBlazeDamage(1)
|
||||
expect(bz0).toEqual(bz1)
|
||||
|
||||
// Inferno
|
||||
const inf0 = calculateInfernoDamage(0)
|
||||
const inf1 = calculateInfernoDamage(1)
|
||||
expect(inf0).toEqual(inf1)
|
||||
|
||||
// Durations & Ranges
|
||||
expect(calculateBlazeDuration(0)).toBe(calculateBlazeDuration(1))
|
||||
expect(calculateBlazePatchDuration(0)).toBe(calculateBlazePatchDuration(1))
|
||||
expect(calculateInfernoRange(0)).toBe(calculateInfernoRange(1))
|
||||
expect(calculateInfernoContinuousMana256(0)).toBe(calculateInfernoContinuousMana256(1))
|
||||
})
|
||||
|
||||
it('Handles negative or NaN slvl inputs gracefully without crashing', () => {
|
||||
expect(calculateFireWallDamage(-5).min).toBe(15)
|
||||
expect(calculateFireWallDamage(NaN).min).toBe(15)
|
||||
expect(calculateBlazeDuration(-1)).toBe(120)
|
||||
expect(calculateInfernoRange(-10)).toBe(10)
|
||||
expect(calculateInfernoContinuousMana256(-1)).toBe(144)
|
||||
})
|
||||
|
||||
it('Verifies exact discrete values at slvl 1, 20, 50, 99', () => {
|
||||
// Level 1
|
||||
expect(calculateFireWallDamage(1)).toMatchObject({ min: 15, max: 20, min256: 240, max256: 320 })
|
||||
expect(calculateBlazeDamage(1)).toMatchObject({ min: 4, max: 8, min256: 64, max256: 128 })
|
||||
expect(calculateInfernoDamage(1)).toMatchObject({ min: 32, max: 64, min256: 128, max256: 256 })
|
||||
|
||||
// Level 20
|
||||
expect(calculateFireWallDamage(20)).toMatchObject({ min: 274, max: 279, min256: 4384, max256: 4464 })
|
||||
expect(calculateBlazeDamage(20)).toMatchObject({ min: 58, max: 62, min256: 928, max256: 992 })
|
||||
expect(calculateInfernoDamage(20)).toMatchObject({ min: 520, max: 564, min256: 2080, max256: 2256 })
|
||||
|
||||
// Level 50
|
||||
expect(calculateFireWallDamage(50)).toMatchObject({ min: 904, max: 909, min256: 14464, max256: 14544 })
|
||||
expect(calculateBlazeDamage(50)).toMatchObject({ min: 300, max: 304, min256: 4800, max256: 4864 })
|
||||
expect(calculateInfernoDamage(50)).toMatchObject({ min: 1560, max: 1634, min256: 6240, max256: 6536 })
|
||||
|
||||
// Level 99
|
||||
expect(calculateFireWallDamage(99)).toMatchObject({ min: 1933, max: 1938, min256: 30928, max256: 31008 })
|
||||
expect(calculateBlazeDamage(99)).toMatchObject({ min: 741, max: 745, min256: 11856, max256: 11920 })
|
||||
expect(calculateInfernoDamage(99)).toMatchObject({ min: 3324, max: 3447, min256: 13296, max256: 13788 })
|
||||
})
|
||||
|
||||
it('Handles extreme synergy values (0, 20, 50, and negative)', () => {
|
||||
// Synergy 0
|
||||
const fw_s0 = calculateFireWallDamage(20, { warmth: 0, inferno: 0 })
|
||||
expect(fw_s0.min256).toBe(4384)
|
||||
|
||||
// Synergy 20/20
|
||||
const fw_s20 = calculateFireWallDamage(20, { warmth: 20, inferno: 20 })
|
||||
expect(fw_s20.min256).toBe(8768)
|
||||
|
||||
// Synergy 50/50
|
||||
const fw_s50 = calculateFireWallDamage(20, { warmth: 50, inferno: 50 })
|
||||
// 50*4 + 50*1 = 250% bonus -> 3.5x multiplier
|
||||
expect(fw_s50.min256).toBe(Math.floor(274 * 16 * 3.5))
|
||||
|
||||
// Negative synergy should clamp to 0
|
||||
const fw_neg = calculateFireWallDamage(20, { warmth: -10, inferno: -5 })
|
||||
expect(fw_neg.min256).toBe(4384)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -37,6 +37,7 @@ import {
|
|||
INFERNOFLAME2_META,
|
||||
} from '../../../src/render/missiles-meta.ts'
|
||||
import { FIRE_CAST_2_META } from '../../../src/render/overlays-meta.ts'
|
||||
import { castSkill } from '../../../src/scene/act-scene.ts'
|
||||
|
||||
describe('[Skill #041] Inferno (SOR) — 1.13c Parity (Issue #195)', () => {
|
||||
// ==========================================================================
|
||||
|
|
@ -116,10 +117,10 @@ describe('[Skill #041] Inferno (SOR) — 1.13c Parity (Issue #195)', () => {
|
|||
expect(calculateInfernoRange(10)).toBe(23.5)
|
||||
expect(calculateInfernoRange(20)).toBe(38.5)
|
||||
|
||||
// Range in pixels: yards * 15
|
||||
expect(calculateInfernoRangePx(1)).toBe(150)
|
||||
expect(calculateInfernoRangePx(10)).toBe(352.5)
|
||||
expect(calculateInfernoRangePx(20)).toBe(577.5)
|
||||
// Range in pixels: yards * 32 (1 yard = 1 subtile = 32 horizontal pixels)
|
||||
expect(calculateInfernoRangePx(1)).toBe(320)
|
||||
expect(calculateInfernoRangePx(10)).toBe(752)
|
||||
expect(calculateInfernoRangePx(20)).toBe(1232)
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
|
|
@ -244,6 +245,66 @@ describe('[Skill #041] Inferno (SOR) — 1.13c Parity (Issue #195)', () => {
|
|||
expect(getSkillCooldownTicks(41)).toBe(0)
|
||||
})
|
||||
|
||||
it('Dimension 6: verifies continuous channeled mana drain and channel cessation upon depletion in castSkill', () => {
|
||||
const player = {
|
||||
x: 100,
|
||||
y: 100,
|
||||
facing: 0,
|
||||
cooldown: 0,
|
||||
mana: 10,
|
||||
maxMana: 100,
|
||||
infernoChanneling: false,
|
||||
infernoManaRemainder256: 0,
|
||||
}
|
||||
const engine: any = {
|
||||
world: {
|
||||
player,
|
||||
events: [],
|
||||
},
|
||||
projectiles: [],
|
||||
spawnOverlay: () => {},
|
||||
metrics: { casts: 0 },
|
||||
}
|
||||
const status = { textContent: '' } as any
|
||||
|
||||
const context = {
|
||||
engine,
|
||||
runtime: {} as any,
|
||||
hudManager: null,
|
||||
status,
|
||||
}
|
||||
|
||||
// Call 1: initial cast costs 6 mana
|
||||
const cast1 = castSkill(41, 200, 100, context)
|
||||
expect(cast1).toBe(true)
|
||||
expect(player.mana).toBe(4) // 10 - 6 = 4
|
||||
expect(player.infernoChanneling).toBe(true)
|
||||
expect(player.infernoManaRemainder256).toBe(0)
|
||||
|
||||
// Call 2: frame 1 of continuous channeling (slvl 1 drain = 144/256)
|
||||
// Accumulator = 0 + 144 = 144, wholeMana = 0, mana remains 4
|
||||
const cast2 = castSkill(41, 200, 100, context)
|
||||
expect(cast2).toBe(true)
|
||||
expect(player.mana).toBe(4)
|
||||
expect(player.infernoManaRemainder256).toBe(144)
|
||||
|
||||
// Call 3: frame 2 of continuous channeling (slvl 1 drain = 144/256)
|
||||
// Accumulator = 144 + 144 = 288, wholeMana = 1, remainder = 32, mana becomes 3
|
||||
const cast3 = castSkill(41, 200, 100, context)
|
||||
expect(cast3).toBe(true)
|
||||
expect(player.mana).toBe(3)
|
||||
expect(player.infernoManaRemainder256).toBe(32)
|
||||
|
||||
// Drain down to 0 mana
|
||||
player.mana = 0
|
||||
player.infernoManaRemainder256 = 0
|
||||
// Calling while mana is 0 should stop channeling and return false
|
||||
const castEmpty = castSkill(41, 200, 100, context)
|
||||
expect(castEmpty).toBe(false)
|
||||
expect(status.textContent).toBe('法力不足!')
|
||||
expect(player.infernoChanneling).toBe(false)
|
||||
})
|
||||
|
||||
// ==========================================================================
|
||||
// Dimension 7: Invariants & Integration
|
||||
// ==========================================================================
|
||||
|
|
|
|||
Loading…
Reference in New Issue