fix(skill): implement Thunder Storm ambient aura and sky strike (Fixes #433)
This commit is contained in:
parent
a7110143fc
commit
967a545116
|
|
@ -239,6 +239,7 @@ export interface CombatPlayer {
|
|||
thunderstormInterval?: number
|
||||
thunderstormNextStrike?: number
|
||||
thunderstormDamage?: number
|
||||
thunderstormDamageRange?: { readonly min: number; readonly max: number } | undefined
|
||||
thunderstormSlvl?: number
|
||||
/** Energy Shield active remaining ticks. */
|
||||
energyshieldTicks?: number
|
||||
|
|
|
|||
|
|
@ -682,6 +682,7 @@ export class GameEngine {
|
|||
if (player.thunderstormTicks && player.thunderstormTicks > 0) {
|
||||
player.thunderstormTicks -= 1
|
||||
if (player.thunderstormTicks <= 0) {
|
||||
player.thunderstormTicks = 0
|
||||
for (const ov of this.overlays) {
|
||||
if (
|
||||
(ov.overlayName === 'thunderstormback' || ov.overlayName === 'thunderstorm') &&
|
||||
|
|
@ -709,17 +710,49 @@ export class GameEngine {
|
|||
const dmg = player.thunderstormDamage ?? 50
|
||||
damageMonster(this.world, nearestMonster.index, dmg)
|
||||
this.spawnOverlay('light_cast_2', { x: nearestMonster.x, y: nearestMonster.y }, `monster-${nearestMonster.index}`)
|
||||
this.projectiles.push({
|
||||
skillId: '57',
|
||||
x: nearestMonster.x,
|
||||
y: nearestMonster.y,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 0,
|
||||
ttl: 8,
|
||||
fromPlayer: true,
|
||||
missileType: 'thunderstorm1',
|
||||
pierce: true,
|
||||
ageTicks: 0,
|
||||
})
|
||||
this.world.events.push({
|
||||
kind: 'monsterHit',
|
||||
x: nearestMonster.x,
|
||||
y: nearestMonster.y,
|
||||
amount: dmg,
|
||||
})
|
||||
this.world.events.push({
|
||||
kind: 'missileHit',
|
||||
x: nearestMonster.x,
|
||||
y: nearestMonster.y,
|
||||
missileType: 'thunderstorm1',
|
||||
skillId: '57',
|
||||
amount: dmg,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!player.thunderstormTicks || player.thunderstormTicks <= 0) {
|
||||
for (const ov of this.overlays) {
|
||||
if (
|
||||
(ov.overlayName === 'thunderstormback' || ov.overlayName === 'thunderstorm') &&
|
||||
ov.casterId === 'player'
|
||||
) {
|
||||
ov.expired = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Energy Shield duration tick & overlay management
|
||||
if (player.energyshieldTicks && player.energyshieldTicks > 0) {
|
||||
player.energyshieldTicks -= 1
|
||||
|
|
|
|||
|
|
@ -5127,6 +5127,8 @@ export function tickProjectiles(
|
|||
projectile.missileType === 'firewallmaker' ||
|
||||
projectile.missileType === 'freezeexplode' ||
|
||||
projectile.missileType === 'glacialspikeejecta' ||
|
||||
projectile.missileType === 'thunderstorm1' ||
|
||||
projectile.missileType === 'thunderstorm' ||
|
||||
isTurretEmitter
|
||||
if (!isHarmlessEmitter) {
|
||||
for (const target of targets) {
|
||||
|
|
|
|||
|
|
@ -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, calculateFrostNovaDamage, calculateFrostNovaChillLength, calculateFireWallDamage, calculateBlazeDamage, calculateInfernoDamage, calculateBlazeDuration, calculateBlazePatchDuration, calculateInfernoRangePx, calculateInfernoContinuousMana256, calculateFireWallPatches, calculateEnchantDamage, calculateEnchantAttackRating, calculateEnchantDuration, calculateFireMasteryBonus, calculateTeethDamage, calculateTeethCount, calculateBoneArmorCapacity, calculatePoisonDaggerDamage, calculatePoisonDaggerAttackRatingBonus, calculateCorpseExplosionDamage, calculateCorpseExplosionRadiusPx, calculateBoneWallStats, calculatePoisonExplosionDamage, calculateBoneSpearDamage, calculateBonePrisonStats, calculatePoisonNovaDamage, calculateBoneSpiritDamage, calculateCurseEffect, calculateCurseRadiusPx, calculateCurseDurationFrames, calculateTerrorDistance, calculateIronMaidenReturnPct, calculateSkeletonMaxCount, calculateSkeletalMageMaxCount, calculateReviveMaxCount, calculateFistOfTheHeavensDamage } 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, calculateThunderStormDamage, calculateEnergyShieldDuration, calculateEnergyShieldAbsorbPct, calculateFrozenArmorDuration, calculateShiverArmorDuration, calculateChillingArmorDuration, calculateIceBlastFreezeLength, calculateGlacialSpikeFreezeLength, calculateFrostNovaDamage, calculateFrostNovaChillLength, calculateFireWallDamage, calculateBlazeDamage, calculateInfernoDamage, calculateBlazeDuration, calculateBlazePatchDuration, calculateInfernoRangePx, calculateInfernoContinuousMana256, calculateFireWallPatches, calculateEnchantDamage, calculateEnchantAttackRating, calculateEnchantDuration, calculateFireMasteryBonus, calculateTeethDamage, calculateTeethCount, calculateBoneArmorCapacity, calculatePoisonDaggerDamage, calculatePoisonDaggerAttackRatingBonus, calculateCorpseExplosionDamage, calculateCorpseExplosionRadiusPx, calculateBoneWallStats, calculatePoisonExplosionDamage, calculateBoneSpearDamage, calculateBonePrisonStats, calculatePoisonNovaDamage, calculateBoneSpiritDamage, calculateCurseEffect, calculateCurseRadiusPx, calculateCurseDurationFrames, calculateTerrorDistance, calculateIronMaidenReturnPct, calculateSkeletonMaxCount, calculateSkeletalMageMaxCount, calculateReviveMaxCount, calculateFistOfTheHeavensDamage } from '../game/skills.ts'
|
||||
import type { Projectile, ActiveOverlay } from '../game/skills.ts'
|
||||
import { findSafeDropPosition, calculateBounceHeight, triggerFlippyBounce, type GroundItemEntity } from '../game/ground-items.ts'
|
||||
import {
|
||||
|
|
@ -1865,16 +1865,18 @@ export function castSkill(
|
|||
|
||||
if (skillId === 57) {
|
||||
// Thunder Storm (Skill 57): activate thunderstorm buff on player
|
||||
const slvl = 1
|
||||
const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(57) ?? 1)
|
||||
const lmSlvl = (hudManager as any)?.getSkillLevel?.(63) ?? 0
|
||||
const duration = calculateThunderStormDuration(slvl)
|
||||
const interval = calculateThunderStormInterval(slvl)
|
||||
const dmgRange = calculateSkillDamage(57, slvl)
|
||||
const dmgRange = calculateThunderStormDamage(slvl, { lightningMastery: lmSlvl })
|
||||
const tsDmg = Math.round((dmgRange.min + dmgRange.max) / 2)
|
||||
|
||||
player.thunderstormTicks = duration
|
||||
player.thunderstormInterval = interval
|
||||
player.thunderstormNextStrike = interval
|
||||
player.thunderstormDamage = tsDmg
|
||||
player.thunderstormDamageRange = dmgRange
|
||||
player.thunderstormSlvl = slvl
|
||||
|
||||
engine.spawnOverlay('thunderstormback', { x: player.x, y: player.y }, 'player', { loop: true, isStateOverlay: true })
|
||||
|
|
|
|||
|
|
@ -0,0 +1,361 @@
|
|||
/**
|
||||
* Issue #433: Thunder Storm (#57) Feet Ambient Cloud & Lightning Strike
|
||||
*
|
||||
* 1.13c Ground Truth Invariants Verification Suite:
|
||||
* 1. Feet Ambient Cloud: Continuous looping playback of `thunderstormback` state overlay
|
||||
* (ThunderstormCast_operate, PreDraw = 1, trans = 3) at player feet during buff duration.
|
||||
* 2. Player Movement Tracking & Expiration: Overlay follows player coordinates and expires
|
||||
* cleanly when buff duration expires.
|
||||
* 3. Periodic Strike Cadence & Targeting: 17 subtiles (340px) radius targeting closest live enemy.
|
||||
* 4. Sky-Strike Lightning Missile: Spawns `thunderstorm1` (CelFile: LightningboltBig, 8 frames ttl)
|
||||
* at monster location alongside `light_cast_2` ground spark overlay.
|
||||
* 5. Missile Collision Bypass: `thunderstorm1` is in `isHarmlessEmitter`, surviving its full 8-frame
|
||||
* animation without premature collision destruction or duplicate damage.
|
||||
* 6. Dynamic Level & Lightning Mastery Scaling: Accurate duration, cadence, and multiplicative damage.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { GameEngine, type EngineInput } from '../../../src/game/engine.ts'
|
||||
import {
|
||||
CANONICAL_113C_OVERLAYS,
|
||||
CANONICAL_113C_MISSILES,
|
||||
calculateThunderStormDuration,
|
||||
calculateThunderStormInterval,
|
||||
calculateThunderStormDamage,
|
||||
calculateLightningMasteryBonusPct,
|
||||
createActiveOverlay,
|
||||
tickProjectiles,
|
||||
type Projectile,
|
||||
} from '../../../src/game/skills.ts'
|
||||
import {
|
||||
THUNDERSTORM_BACK_OVERLAY_META,
|
||||
OVERLAY_METAS,
|
||||
} from '../../../src/render/overlays-meta.ts'
|
||||
import {
|
||||
THUNDERSTORM1_META,
|
||||
MISSILE_METAS,
|
||||
} from '../../../src/render/missiles-meta.ts'
|
||||
import { DEMO_EXPERIENCE, DEMO_SKILLS, DEMO_QUESTS } from '../../../src/game/demo-data.ts'
|
||||
|
||||
const DEFAULT_INPUT: EngineInput = {
|
||||
movement: { x: 0, y: 0 },
|
||||
attacking: false,
|
||||
pickingUp: false,
|
||||
talking: false,
|
||||
digits: [],
|
||||
saving: false,
|
||||
loading: false,
|
||||
}
|
||||
|
||||
function createTestEngine() {
|
||||
const terrain = { widthPx: 2000, heightPx: 2000, overlap: () => 0 }
|
||||
return new GameEngine(terrain, {
|
||||
spawn: { x: 500, y: 500 },
|
||||
stats: [],
|
||||
xpTable: DEMO_EXPERIENCE,
|
||||
skills: DEMO_SKILLS,
|
||||
npcDefs: [],
|
||||
questDefs: DEMO_QUESTS,
|
||||
combatOptions: {
|
||||
playerSpeed: 4,
|
||||
playerReach: 50,
|
||||
playerCooldownTicks: 0,
|
||||
playerDamage: 5,
|
||||
playerManaPerAttack: 1,
|
||||
respawnTicks: 100,
|
||||
disableMonsterAggro: true,
|
||||
},
|
||||
talkRadius: 50,
|
||||
pickupRadius: 30,
|
||||
inventoryCols: 10,
|
||||
inventoryRows: 4,
|
||||
monsterCount: 0,
|
||||
})
|
||||
}
|
||||
|
||||
describe('Issue #433: Thunder Storm (#57) Feet Ambient Cloud & Lightning Strike', () => {
|
||||
// =========================================================================
|
||||
// Dimension 1: Canonical 1.13c Registries & Asset Metadata
|
||||
// =========================================================================
|
||||
describe('Dimension 1: Canonical 1.13c Registries & Asset Metadata', () => {
|
||||
it('verifies thunderstormback state overlay registration in CANONICAL_113C_OVERLAYS', () => {
|
||||
const ov = CANONICAL_113C_OVERLAYS['thunderstormback']
|
||||
expect(ov).toBeDefined()
|
||||
expect(ov.filename).toBe('ThunderstormCast_operate')
|
||||
expect(ov.frames).toBe(19)
|
||||
expect(ov.preDraw).toBe(true)
|
||||
expect(ov.trans).toBe(3)
|
||||
expect(ov.animRate).toBe(16)
|
||||
})
|
||||
|
||||
it('verifies THUNDERSTORM_BACK_OVERLAY_META in OVERLAY_METAS', () => {
|
||||
expect(THUNDERSTORM_BACK_OVERLAY_META).toBeDefined()
|
||||
expect(THUNDERSTORM_BACK_OVERLAY_META.name).toBe('thunderstormback')
|
||||
expect(THUNDERSTORM_BACK_OVERLAY_META.celFile).toBe('ThunderstormCast_operate')
|
||||
expect(THUNDERSTORM_BACK_OVERLAY_META.frames).toBe(19)
|
||||
expect(THUNDERSTORM_BACK_OVERLAY_META.preDraw).toBe(true)
|
||||
expect(THUNDERSTORM_BACK_OVERLAY_META.trans).toBe(3)
|
||||
expect(OVERLAY_METAS['thunderstormback']).toBe(THUNDERSTORM_BACK_OVERLAY_META)
|
||||
})
|
||||
|
||||
it('verifies thunderstorm1 missile registration in CANONICAL_113C_MISSILES', () => {
|
||||
const missile = CANONICAL_113C_MISSILES['thunderstorm1']
|
||||
expect(missile).toBeDefined()
|
||||
expect(missile.celFile).toBe('LightningboltBig')
|
||||
expect(missile.animLen).toBe(8)
|
||||
expect(missile.trans).toBe(1)
|
||||
})
|
||||
|
||||
it('verifies THUNDERSTORM1_META in MISSILE_METAS', () => {
|
||||
expect(THUNDERSTORM1_META).toBeDefined()
|
||||
expect(THUNDERSTORM1_META.name).toBe('thunderstorm1')
|
||||
expect(THUNDERSTORM1_META.celFile).toBe('LightningboltBig')
|
||||
expect(THUNDERSTORM1_META.framesPerDirection).toBe(8)
|
||||
expect(THUNDERSTORM1_META.animSpeed).toBe(16)
|
||||
expect(MISSILE_METAS['thunderstorm1']).toBe(THUNDERSTORM1_META)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 2: Feet Ambient Cloud (thunderstormback) Lifecycle
|
||||
// =========================================================================
|
||||
describe('Dimension 2: Feet Ambient Cloud Lifecycle & Movement Tracking', () => {
|
||||
it('spawns thunderstormback with preDraw: true, isStateOverlay: true, and loop: true', () => {
|
||||
const engine = createTestEngine()
|
||||
engine.spawnOverlay('thunderstormback', { x: 500, y: 500 }, 'player', {
|
||||
loop: true,
|
||||
isStateOverlay: true,
|
||||
})
|
||||
|
||||
const ov = engine.overlays.find(o => o.overlayName === 'thunderstormback')
|
||||
expect(ov).toBeDefined()
|
||||
expect(ov?.preDraw).toBe(true)
|
||||
expect(ov?.isStateOverlay).toBe(true)
|
||||
expect(ov?.loop).toBe(true)
|
||||
expect(ov?.casterId).toBe('player')
|
||||
expect(ov?.x).toBe(500)
|
||||
expect(ov?.y).toBe(500)
|
||||
})
|
||||
|
||||
it('tracks player coordinates across movement updates', () => {
|
||||
const engine = createTestEngine()
|
||||
engine.world.player.x = 500
|
||||
engine.world.player.y = 500
|
||||
engine.world.player.thunderstormTicks = 50
|
||||
engine.spawnOverlay('thunderstormback', { x: 500, y: 500 }, 'player', {
|
||||
loop: true,
|
||||
isStateOverlay: true,
|
||||
})
|
||||
|
||||
// Move player
|
||||
engine.world.player.x = 560
|
||||
engine.world.player.y = 530
|
||||
engine.tick(DEFAULT_INPUT)
|
||||
|
||||
const ov = engine.overlays.find(o => o.overlayName === 'thunderstormback')
|
||||
expect(ov).toBeDefined()
|
||||
expect(ov?.x).toBe(560)
|
||||
expect(ov?.y).toBe(530)
|
||||
})
|
||||
|
||||
it('loops animation frames continuously during active buff duration', () => {
|
||||
const engine = createTestEngine()
|
||||
engine.world.player.x = 500
|
||||
engine.world.player.y = 500
|
||||
engine.world.player.thunderstormTicks = 100
|
||||
engine.spawnOverlay('thunderstormback', { x: 500, y: 500 }, 'player', {
|
||||
loop: true,
|
||||
isStateOverlay: true,
|
||||
})
|
||||
|
||||
// Tick 35 frames (animRate 16/25 frame per tick -> 35 * 0.64 = 22.4 frames, wraps around 19 maxFrames)
|
||||
for (let i = 0; i < 35; i++) {
|
||||
engine.tick(DEFAULT_INPUT)
|
||||
}
|
||||
|
||||
const ov = engine.overlays.find(o => o.overlayName === 'thunderstormback')
|
||||
expect(ov).toBeDefined()
|
||||
expect(ov?.expired).toBeFalsy()
|
||||
expect(ov?.frame).toBeGreaterThanOrEqual(0)
|
||||
expect(ov?.frame).toBeLessThan(19)
|
||||
})
|
||||
|
||||
it('cleanly removes thunderstormback overlay upon buff expiration', () => {
|
||||
const engine = createTestEngine()
|
||||
engine.world.player.x = 500
|
||||
engine.world.player.y = 500
|
||||
engine.world.player.thunderstormTicks = 3
|
||||
engine.spawnOverlay('thunderstormback', { x: 500, y: 500 }, 'player', {
|
||||
loop: true,
|
||||
isStateOverlay: true,
|
||||
})
|
||||
|
||||
// Tick 2 frames -> still active
|
||||
engine.tick(DEFAULT_INPUT)
|
||||
engine.tick(DEFAULT_INPUT)
|
||||
expect(engine.overlays.some(o => o.overlayName === 'thunderstormback')).toBe(true)
|
||||
|
||||
// Tick 3rd frame -> thunderstormTicks reaches 0, overlay must be removed
|
||||
engine.tick(DEFAULT_INPUT)
|
||||
expect(engine.overlays.some(o => o.overlayName === 'thunderstormback')).toBe(false)
|
||||
expect(engine.world.player.thunderstormTicks).toBe(0)
|
||||
})
|
||||
|
||||
it('immediately purges thunderstormback when thunderstormTicks is uninitialized or 0', () => {
|
||||
const engine = createTestEngine()
|
||||
engine.world.player.thunderstormTicks = 0
|
||||
engine.spawnOverlay('thunderstormback', { x: 500, y: 500 }, 'player', {
|
||||
loop: true,
|
||||
isStateOverlay: true,
|
||||
})
|
||||
expect(engine.overlays.some(o => o.overlayName === 'thunderstormback')).toBe(true)
|
||||
|
||||
engine.tick(DEFAULT_INPUT)
|
||||
expect(engine.overlays.some(o => o.overlayName === 'thunderstormback')).toBe(false)
|
||||
})
|
||||
|
||||
it('refreshes existing thunderstormback on recast without duplicate stacking', () => {
|
||||
const engine = createTestEngine()
|
||||
engine.world.player.thunderstormTicks = 100
|
||||
engine.spawnOverlay('thunderstormback', { x: 500, y: 500 }, 'player', {
|
||||
loop: true,
|
||||
isStateOverlay: true,
|
||||
})
|
||||
expect(engine.overlays.filter(o => o.overlayName === 'thunderstormback')).toHaveLength(1)
|
||||
|
||||
// Recast while active
|
||||
engine.spawnOverlay('thunderstormback', { x: 500, y: 500 }, 'player', {
|
||||
loop: true,
|
||||
isStateOverlay: true,
|
||||
})
|
||||
expect(engine.overlays.filter(o => o.overlayName === 'thunderstormback')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 3: Periodic Sky-Strike Targeting, Projectile & Visuals
|
||||
// =========================================================================
|
||||
describe('Dimension 3: Periodic Sky-Strike Spawning & Collision Bypass', () => {
|
||||
it('spawns thunderstorm1 projectile and light_cast_2 overlay on closest live target within 340px', () => {
|
||||
const engine = createTestEngine()
|
||||
engine.world.player.x = 500
|
||||
engine.world.player.y = 500
|
||||
engine.world.player.thunderstormTicks = 100
|
||||
engine.world.player.thunderstormInterval = 1
|
||||
engine.world.player.thunderstormNextStrike = 1
|
||||
engine.world.player.thunderstormDamage = 250
|
||||
|
||||
engine.world.monsters = [
|
||||
{ index: 0, id: 'm0', x: 510, y: 500, hp: 0, state: 'dead' } as any,
|
||||
{ index: 1, id: 'm1', x: 620, y: 500, hp: 600, maxHp: 600, state: 'idle' } as any, // 120px dist
|
||||
{ index: 2, id: 'm2', x: 750, y: 500, hp: 600, maxHp: 600, state: 'idle' } as any, // 250px dist
|
||||
{ index: 3, id: 'm3', x: 900, y: 500, hp: 600, maxHp: 600, state: 'idle' } as any, // 400px dist (out of range)
|
||||
]
|
||||
|
||||
engine.tick(DEFAULT_INPUT)
|
||||
|
||||
// M1 is struck
|
||||
expect(engine.world.monsters[1]?.hp).toBe(350)
|
||||
expect(engine.world.monsters[2]?.hp).toBe(600)
|
||||
|
||||
// light_cast_2 ground spark overlay is present
|
||||
const spark = engine.overlays.find(o => o.overlayName === 'light_cast_2')
|
||||
expect(spark).toBeDefined()
|
||||
expect(spark?.x).toBe(620)
|
||||
expect(spark?.y).toBe(500)
|
||||
|
||||
// thunderstorm1 sky-strike projectile is spawned
|
||||
const proj = engine.projectiles.find(p => p.missileType === 'thunderstorm1')
|
||||
expect(proj).toBeDefined()
|
||||
expect(proj?.skillId).toBe('57')
|
||||
expect(proj?.x).toBe(620)
|
||||
expect(proj?.y).toBe(500)
|
||||
expect(proj?.ttl).toBe(8)
|
||||
expect(proj?.pierce).toBe(true)
|
||||
expect(proj?.damage).toBe(0)
|
||||
expect(proj?.fromPlayer).toBe(true)
|
||||
|
||||
// Events recorded
|
||||
expect(engine.world.events.some(e => e.kind === 'monsterHit' && e.amount === 250)).toBe(true)
|
||||
expect(engine.world.events.some(e => e.kind === 'missileHit' && e.missileType === 'thunderstorm1' && e.amount === 250)).toBe(true)
|
||||
})
|
||||
|
||||
it('survives all 8 frames without premature collision consumption in tickProjectiles', () => {
|
||||
// Direct simulation of tickProjectiles with a monster sitting on the exact coordinate
|
||||
const target = { index: 1, x: 620, y: 500, radius: 20, alive: true }
|
||||
let current: readonly Projectile[] = [
|
||||
{
|
||||
skillId: '57',
|
||||
x: 620,
|
||||
y: 500,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
damage: 0,
|
||||
ttl: 8,
|
||||
fromPlayer: true,
|
||||
missileType: 'thunderstorm1',
|
||||
pierce: true,
|
||||
ageTicks: 0,
|
||||
},
|
||||
]
|
||||
|
||||
// Across 8 ticks, missile remains alive, advancing ageTicks and decrementing ttl, without any hits
|
||||
for (let tick = 1; tick <= 8; tick++) {
|
||||
const out = tickProjectiles(current, [target], { overlap: () => 0 })
|
||||
expect(out.alive).toHaveLength(1)
|
||||
expect(out.alive[0]?.ageTicks).toBe(tick)
|
||||
expect(out.alive[0]?.ttl).toBe(8 - tick)
|
||||
expect(out.hits).toHaveLength(0)
|
||||
current = out.alive
|
||||
}
|
||||
// On the 9th tick, ttl is 0, so it expires
|
||||
const out9 = tickProjectiles(current, [target], { overlap: () => 0 })
|
||||
expect(out9.alive).toHaveLength(0)
|
||||
expect(out9.expired).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Dimension 4: Dynamic Formulas & Lightning Mastery Multiplicative Scaling
|
||||
// =========================================================================
|
||||
describe('Dimension 4: 1.13c Mathematical Scaling & Synergies', () => {
|
||||
it('evaluates exact 1.13c duration formula: 800 + (slvl - 1) * 200', () => {
|
||||
expect(calculateThunderStormDuration(1)).toBe(800)
|
||||
expect(calculateThunderStormDuration(5)).toBe(1600)
|
||||
expect(calculateThunderStormDuration(10)).toBe(2600)
|
||||
expect(calculateThunderStormDuration(20)).toBe(4600)
|
||||
})
|
||||
|
||||
it('evaluates exact 1.13c hyperbolic cadence formula', () => {
|
||||
expect(calculateThunderStormInterval(1)).toBe(110)
|
||||
expect(calculateThunderStormInterval(5)).toBe(75)
|
||||
expect(calculateThunderStormInterval(10)).toBe(57)
|
||||
expect(calculateThunderStormInterval(20)).toBe(41)
|
||||
expect(calculateThunderStormInterval(40)).toBe(30)
|
||||
expect(calculateThunderStormInterval(60)).toBe(25)
|
||||
expect(calculateThunderStormInterval(99)).toBe(25)
|
||||
})
|
||||
|
||||
it('integrates Lightning Mastery multiplicative scaling accurately', () => {
|
||||
// slvl 1: base 1..100
|
||||
const base = calculateThunderStormDamage(1, { lightningMastery: 0 })
|
||||
expect(base.min).toBe(1)
|
||||
expect(base.max).toBe(100)
|
||||
|
||||
// slvl 1 with LM lvl 1 (+50%)
|
||||
const lm1 = calculateThunderStormDamage(1, { lightningMastery: 1 })
|
||||
expect(lm1.min).toBe(1) // Math.floor(1 * 1.50) = 1
|
||||
expect(lm1.max).toBe(150) // Math.floor(100 * 1.50) = 150
|
||||
|
||||
// slvl 20: base lvl 1=1..100, +7*10=70..70, +8*10=80..80, +4*11=44..44 -> 195..294
|
||||
const lvl20Base = calculateThunderStormDamage(20, { lightningMastery: 0 })
|
||||
expect(lvl20Base.min).toBe(195)
|
||||
expect(lvl20Base.max).toBe(294)
|
||||
|
||||
// slvl 20 with LM lvl 20 (50 + 19 * 12 = 278% -> multiplier 3.78)
|
||||
// 195 * 3.78 = 737.1 -> 737, 294 * 3.78 = 1111.32 -> 1111
|
||||
const lvl20WithLm = calculateThunderStormDamage(20, { lightningMastery: 20 })
|
||||
expect(lvl20WithLm.min).toBe(737)
|
||||
expect(lvl20WithLm.max).toBe(1111)
|
||||
})
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue