diff --git a/src/game/combat.ts b/src/game/combat.ts index e22ed22..a20e3af 100644 --- a/src/game/combat.ts +++ b/src/game/combat.ts @@ -36,6 +36,11 @@ import { calculateShiverArmorChillLength, calculateChillingArmorDamage, calculateChillingArmorChillLength, + calculateEnchantDamage, + calculateEnchantAttackRating, + calculateEnchantDuration, + calculateFireMasteryBonus, + calculateEnchantAttackDamage, } from './engine/missile-engine.ts' export { @@ -44,6 +49,11 @@ export { spawnMonsterPacksByRooms, spawnPacksInRoom, type MapRoom, + calculateEnchantDamage, + calculateEnchantAttackRating, + calculateEnchantDuration, + calculateFireMasteryBonus, + calculateEnchantAttackDamage, } @@ -229,6 +239,13 @@ export interface CombatPlayer { /** Inferno continuous channeling state and sub-mana fractional accumulator (256ths). */ infernoChanneling?: boolean | undefined infernoManaRemainder256?: number | undefined + /** Enchant active remaining ticks, added fire damage, and attack rating % bonus. */ + enchantTicks?: number | undefined + enchantDamage?: { min: number; max: number } | undefined + enchantAttackRatingPercent?: number | undefined + fireMasterySlvl?: number | undefined + isSorceress?: boolean | undefined + isRanged?: boolean | undefined } /** Something worth telling the player (and the renderer) about. */ @@ -826,6 +843,14 @@ function tickPlayer( if (player.cooldown > 0) player.cooldown -= 1 if (player.swingTicks > 0) player.swingTicks -= 1 + if (player.enchantTicks && player.enchantTicks > 0) { + player.enchantTicks -= 1 + if (player.enchantTicks <= 0) { + player.enchantTicks = 0 + player.enchantDamage = undefined + player.enchantAttackRatingPercent = undefined + } + } // Movement first, so an attack this tick happens from where the player ended up. const frwMultiplier = player.blazeTicks && player.blazeTicks > 0 ? 1.3 : 1.0 @@ -860,9 +885,20 @@ function tickPlayer( world.events.push({ kind: 'playerHit', x: player.x, y: player.y, amount: 0, text: 'whiff' }) } else { player.facing = facingOf(target.x - player.x, target.y - player.y) - target.hp -= options.playerDamage + let totalDamage = options.playerDamage + if (player.enchantTicks && player.enchantTicks > 0 && player.enchantDamage) { + const enchantRes = calculateEnchantAttackDamage({ + enchantDamage: player.enchantDamage, + isRanged: player.isRanged, + isSorceress: player.isSorceress, + fireMasterySlvl: player.fireMasterySlvl, + targetFireResist: (target as any).fireResist ?? (target.stats as any).fireResist, + }) + totalDamage += Math.round(enchantRes.average) + } + target.hp -= totalDamage target.hitFlash = 4 - world.events.push({ kind: 'monsterHit', x: target.x, y: target.y, amount: options.playerDamage }) + world.events.push({ kind: 'monsterHit', x: target.x, y: target.y, amount: totalDamage }) if (target.hp <= 0) { target.state = 'dead' target.corpseTicks = 100 @@ -1107,3 +1143,48 @@ export function hitPlayerWithMissile( damageDealt: dmg, } } + +/** + * Apply Enchant buff to a combat player or ally/pet unit. + * Authentic 1.13c: duration = 3600 + (slvl - 1) * 600 frames, AR bonus = 20 + (slvl - 1) * 9 %, + * damage = calculateEnchantDamage(slvl, synergies, fmSlvl). + */ +export function applyEnchantBuff( + target: CombatPlayer | { + enchantTicks?: number | undefined + enchantDamage?: { min: number; max: number } | undefined + enchantAttackRatingPercent?: number | undefined + }, + slvl: number, + options?: { + casterWarmth?: number | undefined + casterFireMasterySlvl?: number | undefined + isCasterSorceress?: boolean | undefined + } | undefined, +): void { + const duration = calculateEnchantDuration(slvl) + const arBonus = calculateEnchantAttackRating(slvl) + const dmg = calculateEnchantDamage( + slvl, + options?.casterWarmth !== undefined ? { warmth: options.casterWarmth } : undefined, + options?.casterFireMasterySlvl, + ) + + target.enchantTicks = duration + target.enchantAttackRatingPercent = arBonus + target.enchantDamage = { min: dmg.min, max: dmg.max } +} + +/** + * Determine whether a unit can be targeted for Enchant (Skill 52). + * 1.13c Skills.txt: InTown: 1, TargetAlly: 1, TargetPet: 1. + * Enemies cannot be enchanted. Self, party allies, and pets are allowed, even in town. + */ +export function canCastEnchantOn( + targetType: 'self' | 'ally' | 'pet' | 'enemy' | string, + _inTown = true, +): boolean { + if (targetType === 'enemy') return false + return true +} + diff --git a/src/game/engine.ts b/src/game/engine.ts index 68499ed..41743c5 100644 --- a/src/game/engine.ts +++ b/src/game/engine.ts @@ -637,6 +637,21 @@ export class GameEngine { } } + // Enchant buff duration tick & overlay management + if (player.enchantTicks && player.enchantTicks > 0) { + player.enchantTicks -= 1 + if (player.enchantTicks <= 0) { + player.enchantTicks = 0 + player.enchantDamage = undefined + player.enchantAttackRatingPercent = undefined + for (const ov of this.overlays) { + if (ov.overlayName === 'enchant' && ov.casterId === 'player') { + ov.expired = true + } + } + } + } + // Cold Armor duration tick & overlay management if (player.coldArmorTicks && player.coldArmorTicks > 0) { player.coldArmorTicks -= 1 diff --git a/src/game/engine/combat-pipeline.ts b/src/game/engine/combat-pipeline.ts index 3c08d92..5379f04 100644 --- a/src/game/engine/combat-pipeline.ts +++ b/src/game/engine/combat-pipeline.ts @@ -301,6 +301,7 @@ export interface SUnitDmgPacket { readonly isPoison?: boolean readonly isOpenWounds?: boolean readonly returnFire?: boolean + readonly isEnchant?: boolean } export interface SUnitDmgOutcome { @@ -433,14 +434,27 @@ export function executeSUnitDmg( rawElem256 += converted256 } - // 5. Elemental Mastery & 1.13c Blessed Hammer / Venom / Static Field rules + // 5. Elemental Mastery & 1.13c Blessed Hammer / Venom / Static Field / Enchant rules const elemType = (packet.elemType ?? (packet.skillId === 152 || packet.skillId === 112 ? 'mag' : 'fire')).toLowerCase() + const isEnchantFire = Boolean(packet.isEnchant || packet.skillId === 52 || attacker.stateBus.hasState('enchant')) + if (isEnchantFire && attackKind === 'missile' && elemType === 'fire') { + // 1.13c Param3 = 33: Ranged attacks (bow/crossbow/throwing) receive only 33% of Enchant fire damage bonus + rawElem256 = Math.trunc((rawElem256 * 33) / 100) + } + if (rawElem256 > 0) { let masteryPct = 0 - if (elemType === 'fire') masteryPct = attacker.statList.getAccruedStat('passive_fire_mastery') - else if (elemType === 'ltng') masteryPct = attacker.statList.getAccruedStat('passive_ltng_mastery') - else if (elemType === 'cold') masteryPct = attacker.statList.getAccruedStat('passive_cold_mastery') - else if (elemType === 'pois') masteryPct = attacker.statList.getAccruedStat('passive_pois_mastery') + // Ranged Enchant attacks do NOT get the second Fire Mastery multiplier + const skipFireMasterySecondMultiplier = isEnchantFire && attackKind === 'missile' + if (elemType === 'fire' && !skipFireMasterySecondMultiplier) { + masteryPct = attacker.statList.getAccruedStat('passive_fire_mastery') + } else if (elemType === 'ltng') { + masteryPct = attacker.statList.getAccruedStat('passive_ltng_mastery') + } else if (elemType === 'cold') { + masteryPct = attacker.statList.getAccruedStat('passive_cold_mastery') + } else if (elemType === 'pois') { + masteryPct = attacker.statList.getAccruedStat('passive_pois_mastery') + } if (masteryPct > 0) { rawElem256 = Math.trunc((rawElem256 * (100 + masteryPct)) / 100) } diff --git a/src/game/engine/missile-engine.ts b/src/game/engine/missile-engine.ts index f95e404..9fdf754 100644 --- a/src/game/engine/missile-engine.ts +++ b/src/game/engine/missile-engine.ts @@ -2813,6 +2813,159 @@ export function calculateFireWallPatches( return patches } +/** + * Authentic 1.13c Fire Mastery (Skill 61) passive percentage fire damage bonus: + * Param1: 30 (% base at slvl 1), Param2: 7 (% per level thereafter). + * Formula: slvl <= 0 ? 0 : 30 + (slvl - 1) * 7. + * Level 1 -> +30%, Level 10 -> +93%, Level 20 -> +163%, Level 30 -> +233%. + * Clamped: Returns 0 for slvl <= 0. + */ +export function calculateFireMasteryBonus(slvl: number): number { + if (!Number.isFinite(slvl) || slvl <= 0) return 0 + const lvl = Math.floor(slvl) + return 30 + (lvl - 1) * 7 +} +/** + * Authentic 1.13c Enchant (Skill 52) duration in frames at 25 fps: + * Formula (ln12): 3600 + (slvl - 1) * 600 frames (144s base + 24s/lvl). + * Level 1 -> 3600 frames (144.0s), Level 10 -> 9000 frames (360.0s), Level 20 -> 15000 frames (600.0s). + * Clamped: Returns 0 for slvl <= 0. + */ +export function calculateEnchantDuration(slvl: number): number { + if (!Number.isFinite(slvl) || slvl <= 0) return 0 + const lvl = Math.floor(slvl) + return 3600 + (lvl - 1) * 600 +} +/** + * Authentic 1.13c Enchant (Skill 52) Attack Rating percentage bonus: + * ToHit: 20 (% base at slvl 1), LevToHit: 9 (% per level thereafter). + * Formula: 20 + (slvl - 1) * 9. + * Level 1 -> +20%, Level 10 -> +101%, Level 20 -> +191%. + * Clamped: Returns 0 for slvl <= 0. + */ +export function calculateEnchantAttackRating(slvl: number): number { + if (!Number.isFinite(slvl) || slvl <= 0) return 0 + const lvl = Math.floor(slvl) + return 20 + (lvl - 1) * 9 +} +/** + * Authentic 1.13c Enchant (Skill 52) 5-band weapon fire damage progression: + * HitShift = 7 (128/256 precision -> 0.5 fire damage per integer step). + * EMin: 16, EMinLev: [3, 7, 11, 15, 19] + * EMax: 20, EMaxLev: [5, 9, 13, 17, 21] + * Level 1: 16 - 20 raw -> 8.0 - 10.0 fire dmg (2048 - 2560 in 256ths) + * Level 10: 51 - 73 raw -> 25.5 - 36.5 fire dmg (6528 - 9344 in 256ths) + * Level 20: 137 - 179 raw -> 68.5 - 89.5 fire dmg (17536 - 22912 in 256ths) + * Warmth synergy: +9% per hard point (blvl). + * Fire Mastery multiplier upon cast: damage * (1 + calculateFireMasteryBonus(fmSlvl) / 100). + */ +export function calculateEnchantDamage( + slvl: number, + synergies?: { warmth?: number | undefined; fireMastery?: number | undefined } | number | undefined, + fireMasterySlvl?: number | undefined, +): { min: number; max: number; min256: number; max256: number } { + const lvl = Number.isFinite(slvl) ? Math.max(1, Math.floor(slvl)) : 1 + let rawMin = 16 + let rawMax = 20 + + if (lvl > 1) { + const b1 = Math.min(lvl - 1, 7) + rawMin += b1 * 3 + rawMax += b1 * 5 + } + if (lvl > 8) { + const b2 = Math.min(lvl - 8, 8) + rawMin += b2 * 7 + rawMax += b2 * 9 + } + if (lvl > 16) { + const b3 = Math.min(lvl - 16, 6) + rawMin += b3 * 11 + rawMax += b3 * 13 + } + if (lvl > 22) { + const b4 = Math.min(lvl - 22, 6) + rawMin += b4 * 15 + rawMax += b4 * 17 + } + if (lvl > 28) { + const b5 = lvl - 28 + rawMin += b5 * 19 + rawMax += b5 * 21 + } + + let warmthPts = 0 + let fmLvl = 0 + if (typeof synergies === 'number') { + warmthPts = Number.isFinite(synergies) ? Math.max(0, synergies) : 0 + } else if (typeof synergies === 'object' && synergies !== null) { + warmthPts = Math.max(0, synergies.warmth ?? 0) + fmLvl = Math.max(0, synergies.fireMastery ?? 0) + } + if (fireMasterySlvl !== undefined && Number.isFinite(fireMasterySlvl)) { + fmLvl = Math.max(0, fireMasterySlvl) + } + + const warmthMultiplier = 1.0 + (warmthPts * 9) / 100 + const fmMultiplier = 1.0 + calculateFireMasteryBonus(fmLvl) / 100 + const totalMultiplier = warmthMultiplier * fmMultiplier + + const min256 = Math.floor(rawMin * 128 * totalMultiplier) + const max256 = Math.floor(rawMax * 128 * totalMultiplier) + + // Floating point 0.5 step rounded to 1 decimal place + const min = Math.round((rawMin * 0.5 * totalMultiplier) * 10) / 10 + const max = Math.round((rawMax * 0.5 * totalMultiplier) * 10) / 10 + + return { min, max, min256, max256 } +} + +/** + * Authentic 1.13c Enchant attack damage calculation including: + * - Ranged attack penalty: Ranged attacks (bow/crossbow/throwing) receive only 33% of the Enchant fire damage bonus. + * - Sorceress Melee Double-Dip: When a Sorceress with Fire Mastery attacks in melee while buffed with Enchant, + * her outgoing fire damage is multiplied by (1 + calculateFireMasteryBonus(fmSlvl) / 100) again! + * Ranged attacks do NOT get the second melee multiplier. + * - Fire resistance and immunity: Fire Mastery does NOT pierce fire resistance and CANNOT break fire immunities (Res >= 100%). + */ +export function calculateEnchantAttackDamage(params: { + enchantDamage: { min: number; max: number } + isRanged?: boolean | undefined + isSorceress?: boolean | undefined + fireMasterySlvl?: number | undefined + targetFireResist?: number | undefined +}): { min: number; max: number; average: number } { + let min = params.enchantDamage.min + let max = params.enchantDamage.max + + if (params.isRanged) { + // Ranged attack penalty: 33% of Enchant fire damage bonus, no second Fire Mastery + min = Math.round(min * 0.33 * 10) / 10 + max = Math.round(max * 0.33 * 10) / 10 + } else { + // Melee attack: full Enchant fire damage + // Sorceress Melee Double-Dip: + if (params.isSorceress && params.fireMasterySlvl && params.fireMasterySlvl > 0) { + const fmMul = 1.0 + calculateFireMasteryBonus(params.fireMasterySlvl) / 100 + min = Math.round(min * fmMul * 10) / 10 + max = Math.round(max * fmMul * 10) / 10 + } + } + + // Fire resistance / immunity check + if (params.targetFireResist !== undefined) { + if (params.targetFireResist >= 100) { + // Fire Mastery cannot break fire immunity (Res >= 100%) + return { min: 0, max: 0, average: 0 } + } + const resFactor = Math.max(0, 100 - params.targetFireResist) / 100 + min = Math.round(min * resFactor * 10) / 10 + max = Math.round(max * resFactor * 10) / 10 + } + + const average = Math.round(((min + max) / 2) * 10) / 10 + return { min, max, average } +} diff --git a/src/game/engine/stat-list.ts b/src/game/engine/stat-list.ts index bfff5eb..3267fc7 100644 --- a/src/game/engine/stat-list.ts +++ b/src/game/engine/stat-list.ts @@ -10,7 +10,7 @@ * - Dynamic `SkillStatModifier` lists with `expireFrame` (`dwExpireFrame`), `stateId`, and `slvl`. */ import type { D2DataRegistry } from './data-registry.ts' -import { calculateLightningMasteryBonusPct } from './missile-engine.ts' +import { calculateLightningMasteryBonusPct, calculateFireMasteryBonus } from './missile-engine.ts' export const HIT_SHIFT = 8 export const FIXED_ONE = 1 << HIT_SHIFT // 256 @@ -199,9 +199,27 @@ export class UnitStatList { total = Math.trunc((total * (100 + pct)) / 100) } } else if (key === 'passive_ltng_mastery' || key === 'passive_lightning_mastery') { - const effLvl = this.getEffectiveSkillLevel(63) - if (effLvl > 0) { - total += calculateLightningMasteryBonusPct(effLvl) + const base = this.getBaseSkillLevel(63) + const single = this.bonusSkillLevels.get(63) ?? 0 + if (base > 0 || single > 0) { + const effLvl = this.getEffectiveSkillLevel(63) + if (effLvl > 0) { + total += calculateLightningMasteryBonusPct(effLvl) + } + } + } else if (key === 'passive_fire_mastery') { + const base = this.getBaseSkillLevel(61) + const single = this.bonusSkillLevels.get(61) ?? 0 + if (base > 0 || single > 0) { + const effLvl = this.getEffectiveSkillLevel(61) + if (effLvl > 0) { + total += calculateFireMasteryBonus(effLvl) + } + } + } else if (key === 'tohit') { + const pct = this.getModifierBonus('tohit_percent') + this.getModifierBonus('item_tohit_percent') + if (pct !== 0) { + total = Math.trunc((total * (100 + pct)) / 100) } } diff --git a/src/game/skills.ts b/src/game/skills.ts index ce3e2eb..a6d1065 100644 --- a/src/game/skills.ts +++ b/src/game/skills.ts @@ -82,6 +82,11 @@ import { calculateInfernoRangePx, calculateInfernoContinuousMana256, calculateFireWallPatches, + calculateEnchantDamage, + calculateEnchantAttackRating, + calculateEnchantDuration, + calculateFireMasteryBonus, + calculateEnchantAttackDamage, } from './engine/missile-engine.ts' export { calculateNovaDamage, @@ -138,6 +143,11 @@ export { calculateInfernoRangePx, calculateInfernoContinuousMana256, calculateFireWallPatches, + calculateEnchantDamage, + calculateEnchantAttackRating, + calculateEnchantDuration, + calculateFireMasteryBonus, + calculateEnchantAttackDamage, } from './engine/missile-engine.ts' import { BLIZZARD_CIRCLE_X, @@ -3644,6 +3654,7 @@ export const SORCERESS_SKILL_CAST_OVERLAYS: Readonly> = O 49: 'light_cast_1', // Lightning 50: 'ice_cast_2', // Shiver Armor 51: 'fire_cast_2', // Fire Wall + 52: 'fire_cast_2', // Enchant 53: 'light_cast_1', // Chain Lightning 54: 'teleport', // Teleport 55: 'ice_cast_2', // Glacial Spike @@ -3682,6 +3693,7 @@ const SORCERESS_SKILL_NAME_TO_OVERLAY: Readonly> = Object 'shiver armor': 'ice_cast_2', firewall: 'fire_cast_2', 'fire wall': 'fire_cast_2', + enchant: 'fire_cast_2', chainlightning: 'light_cast_1', 'chain lightning': 'light_cast_1', teleport: 'teleport', @@ -4772,6 +4784,11 @@ export function getSkillManaCost(skillId: number, effectiveLevel: number): numbe // 1.13c Skills.txt: mana = 22, lvlmana = 1, manashift = 8 (factor = 1.0) return 22 + (lvl - 1) } + if (skillId === 52) { + const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1 + // 1.13c Skills.txt: mana = 25, lvlmana = 1, manashift = 8 (factor = 1.0) + return 25 + (lvl - 1) + } if (skillId === 43) { // 1.13c Skills.txt: mana = 7, lvlmana = 0, manashift = 8 return 7 @@ -4831,6 +4848,10 @@ export function getSkillManaCost(skillId: number, effectiveLevel: number): numbe // 1.13c Skills.txt: mana = 17, lvlmana = 0, manashift = 8 return 17 } + if (skillId === 61) { + // Fire Mastery: passive + return 0 + } if (skillId === 62) { const lvl = Number.isFinite(effectiveLevel) ? Math.max(1, Math.floor(effectiveLevel)) : 1 // 1.13c Skills.txt: mana = 40, lvlmana = 1, manashift = 7 (factor = 0.5) @@ -4881,11 +4902,13 @@ export function getSkillCooldownTicks(skillId: number): number { skillId === 48 || skillId === 49 || skillId === 50 || + skillId === 52 || skillId === 53 || skillId === 55 || skillId === 57 || skillId === 58 || skillId === 60 || + skillId === 61 || skillId === 63 || skillId === 65 ) { @@ -4948,6 +4971,9 @@ export function calculateSkillDamage( if (skillId === 51 || skillId === '51' || skillId === 'firewall' || skillId === 'fire wall') { return calculateFireWallDamage(slvl, synergies as any) } + if (skillId === 52 || skillId === '52' || skillId === 'enchant') { + return calculateEnchantDamage(slvl, synergies as any) + } if (skillId === 40 || skillId === '40' || skillId === 'frozenarmor' || skillId === 'frozen armor') { return { min: 0, max: 0 } } @@ -4984,6 +5010,9 @@ export function calculateSkillDamage( if (skillId === 58 || skillId === '58' || skillId === 'energyshield' || skillId === 'energy shield') { return { min: 0, max: 0 } } + if (skillId === 61 || skillId === '61' || skillId === 'firemastery' || skillId === 'fire mastery') { + return { min: 0, max: 0 } + } if (skillId === 63 || skillId === '63' || skillId === 'lightningmastery' || skillId === 'lightning mastery') { return { min: 0, max: 0 } } diff --git a/src/scene/act-scene.ts b/src/scene/act-scene.ts index 313bdcb..c982454 100644 --- a/src/scene/act-scene.ts +++ b/src/scene/act-scene.ts @@ -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, calculateInfernoContinuousMana256, 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, calculateEnchantDamage, calculateEnchantAttackRating, calculateEnchantDuration, calculateFireMasteryBonus } from '../game/skills.ts' import type { Projectile, ActiveOverlay } from '../game/skills.ts' import { findSafeDropPosition, calculateBounceHeight, triggerFlippyBounce, type GroundItemEntity } from '../game/ground-items.ts' import { @@ -1787,6 +1787,29 @@ export function castSkill( return true } + if (skillId === 52) { + // Enchant (Skill 52): activate enchant weapon buff on player or target ally/pet + const slvl = Math.max(1, (hudManager as any)?.getSkillLevel?.(52) ?? 1) + const warmthLvl = (hudManager as any)?.getSkillLevel?.(37) ?? 0 + const fireMasterySlvl = (hudManager as any)?.getSkillLevel?.(61) ?? 0 + const duration = calculateEnchantDuration(slvl) + const arBonus = calculateEnchantAttackRating(slvl) + const dmg = calculateEnchantDamage(slvl, { warmth: warmthLvl }, fireMasterySlvl) + + player.enchantTicks = duration + player.enchantAttackRatingPercent = arBonus + player.enchantDamage = { min: dmg.min, max: dmg.max } + player.fireMasterySlvl = fireMasterySlvl + player.isSorceress = true + + engine.spawnOverlay('enchant', { x: player.x, y: player.y }, 'player', { loop: true }) + + player.cooldown = 0 + engine.metrics.casts += 1 + status.textContent = '强化已激活' + return true + } + if (skillId === 62) { // Hydra (Skill 62): 3-head stationary turret emitter at target location // Authentic 1.13c Overlay.txt head offsets: Head 1: (-6, -11), Head 2: (0, -7), Head 3: (+4, -15) diff --git a/tests/skills/sor/skill-052-enchant.test.ts b/tests/skills/sor/skill-052-enchant.test.ts index f798440..7af4096 100644 --- a/tests/skills/sor/skill-052-enchant.test.ts +++ b/tests/skills/sor/skill-052-enchant.test.ts @@ -1,109 +1,398 @@ /** - * 1.13c Numerical & Runtime Verification Suite — Skill #052: Enchant (SOR) + * 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #052: Enchant (SOR) * Gitea Tracking Issue: #206 + * + * 7-Dimensional Verification Coverage: + * D1: 1.13c Data Table Parsing & Schema Integrity (Skills.txt row 52, mana 25, lvlmana 1, manashift 8, castoverlay 'fire_cast_2') + * D2: Visual Assets & Metadata Integrity (Sprite atlas 'enchant.png' / 'enchant.json', ENCHANT_OVERLAY_META, FIRE_CAST_2_META) + * D3: Formulas & Scaling (Duration: 3600 + (slvl-1)*600 frames, AR bonus: 20 + (slvl-1)*9%) + * D4: Ranged Penalty (33%) vs Sorceress Melee Double-Dip (Ranged 33% penalty, Sorc melee double-dip with FM, immunity intact) + * D5: Synergy Math & 5-Band Integer Accuracy (Warmth +9%/blvl, soft points 0%, HitShift 7 0.5 fire dmg band progression) + * D6: Mana Progression & 0 Cooldown Ticks (Mana 25 + slvl - 1, 0 cooldown frames, InTown target ally/pet permitted) + * D7: Invariants (BATCH1_SKILLS length strictly 38, zero runtime MPQ queries, WorldArena cast execution) */ 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-052-enchant.ts' +import { + calculateEnchantDamage, + calculateEnchantAttackRating, + calculateEnchantDuration, + calculateEnchantAttackDamage, + calculateFireMasteryBonus, + getSkillCastOverlay, + getSkillManaCost, + getSkillCooldownTicks, + BATCH1_SKILLS, +} from '../../../src/game/skills.ts' +import { applyEnchantBuff, canCastEnchantOn } from '../../../src/game/combat.ts' +import { + ENCHANT_OVERLAY_META, + FIRE_CAST_2_META, + OVERLAY_METAS, +} from '../../../src/render/overlays-meta.ts' -describe('[Skill #052] Enchant (SOR) — 1.13c Parity (Issue #206)', () => { - 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(52)! - expect(skillRec).toBeDefined() - expect(skillModule.skillId).toBe(52) - expect(skillModule.name).toBe("Enchant") - expect(skillModule.charClass).toBe("sor") +describe('[Skill #052] Enchant (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #206)', () => { + const skillsTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Skills.txt') + const skillsTxtContent = fs.readFileSync(skillsTxtPath, 'latin1') - const stats1 = new UnitStatList() - stats1.setBaseSkillLevel(52, 1) - const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 }) - expect(r1.manaCost).toBe(25) - expect(r1.manaCost256).toBe(6400) - expect(r1.minPhysDmg).toBe(0) - expect(r1.maxPhysDmg).toBe(0) - expect(r1.minElemDmg256).toBe(2048) - expect(r1.maxElemDmg256).toBe(2560) + // ========================================================================= + // Dimension 1: 1.13c Data Table Parsing & Schema Integrity + // ========================================================================= + describe('Dimension 1: 1.13c Data Table Parsing & Schema Integrity', () => { + it('parses authentic Skills.txt row 52 (Enchant)', () => { + 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 manaShiftIdx = headers.indexOf('manashift') + const castOverlayIdx = headers.indexOf('castoverlay') + const aurastateIdx = headers.indexOf('aurastate') + const srvDoFuncIdx = headers.indexOf('srvdofunc') + const srvStFuncIdx = headers.indexOf('srvstfunc') + const eMinIdx = headers.indexOf('EMin') + const eMinLev1Idx = headers.indexOf('EMinLev1') + const eMinLev2Idx = headers.indexOf('EMinLev2') + const eMinLev3Idx = headers.indexOf('EMinLev3') + const eMinLev4Idx = headers.indexOf('EMinLev4') + const eMinLev5Idx = headers.indexOf('EMinLev5') + const eMaxIdx = headers.indexOf('EMax') + const eMaxLev1Idx = headers.indexOf('EMaxLev1') + const eMaxLev2Idx = headers.indexOf('EMaxLev2') + const eMaxLev3Idx = headers.indexOf('EMaxLev3') + const eMaxLev4Idx = headers.indexOf('EMaxLev4') + const eMaxLev5Idx = headers.indexOf('EMaxLev5') + const hitShiftIdx = headers.indexOf('HitShift') + const toHitIdx = headers.indexOf('ToHit') + const toHitLevIdx = headers.indexOf('LevToHit') + const param1Idx = headers.indexOf('Param1') + const param2Idx = headers.indexOf('Param2') + const param3Idx = headers.indexOf('Param3') + const param8Idx = headers.indexOf('Param8') + const auraLenCalcIdx = headers.indexOf('auralencalc') + const inTownIdx = headers.indexOf('InTown') + const targetAllyIdx = headers.indexOf('TargetAlly') + const targetPetIdx = headers.indexOf('TargetPet') - const stats10 = new UnitStatList() - stats10.setBaseSkillLevel(52, 10) - const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 }) - expect(r10.manaCost).toBe(34) - expect(r10.manaCost256).toBe(8704) - expect(r10.minPhysDmg).toBe(0) - expect(r10.maxPhysDmg).toBe(0) - expect(r10.minElemDmg256).toBe(6528) - expect(r10.maxElemDmg256).toBe(9344) + const row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx]?.trim() === '52') + expect(row).toBeDefined() + expect(row![skillIdx]?.trim()).toBe('Enchant') + expect(row![charClassIdx]?.trim()).toBe('sor') + expect(Number(row![manaIdx])).toBe(25) + expect(Number(row![lvlManaIdx])).toBe(1) + expect(Number(row![manaShiftIdx])).toBe(8) + expect(row![castOverlayIdx]?.trim()).toBe('') + expect(row![aurastateIdx]?.trim()).toBe('enchant') + expect(Number(row![srvDoFuncIdx])).toBe(25) + expect(Number(row![srvStFuncIdx])).toBe(0) + expect(Number(row![inTownIdx])).toBe(1) + expect(Number(row![targetAllyIdx])).toBe(1) + expect(Number(row![targetPetIdx])).toBe(1) - const stats20 = new UnitStatList() - stats20.setBaseSkillLevel(52, 20) - const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 }) - expect(r20.manaCost).toBe(44) - expect(r20.manaCost256).toBe(11264) - expect(r20.minPhysDmg).toBe(0) - expect(r20.maxPhysDmg).toBe(0) - expect(r20.minElemDmg256).toBe(17536) - expect(r20.maxElemDmg256).toBe(22912) - expect(r20.durationFrames).toBe(15000) - expect(r20.radiusSubtiles).toBe(15000) - expect(r20.petMax).toBe(0) - expect(r20.cooldownFrames).toBe(0) + // 5-band min & max damage definitions + expect(Number(row![eMinIdx])).toBe(16) + expect(Number(row![eMinLev1Idx])).toBe(3) + expect(Number(row![eMinLev2Idx])).toBe(7) + expect(Number(row![eMinLev3Idx])).toBe(11) + expect(Number(row![eMinLev4Idx])).toBe(15) + expect(Number(row![eMinLev5Idx])).toBe(19) + + expect(Number(row![eMaxIdx])).toBe(20) + expect(Number(row![eMaxLev1Idx])).toBe(5) + expect(Number(row![eMaxLev2Idx])).toBe(9) + expect(Number(row![eMaxLev3Idx])).toBe(13) + expect(Number(row![eMaxLev4Idx])).toBe(17) + expect(Number(row![eMaxLev5Idx])).toBe(21) + + // HitShift = 7 (0.5 damage per raw point) + expect(Number(row![hitShiftIdx])).toBe(7) + + // Attack rating progression: 20 base, +9 per level + expect(Number(row![toHitIdx])).toBe(20) + expect(Number(row![toHitLevIdx])).toBe(9) + + // Duration: 3600 frames + 600 per level + expect(Number(row![param1Idx])).toBe(3600) + expect(Number(row![param2Idx])).toBe(600) + expect(row![auraLenCalcIdx]?.trim()).toBe('ln12') + + // Ranged missile penalty percent (33%) and Warmth synergy (9%) + expect(Number(row![param3Idx])).toBe(33) + expect(Number(row![param8Idx])).toBe(9) + }) }) - 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(52)! - const synId = 37 + // ========================================================================= + // Dimension 2: Visual Assets & Metadata Integrity + // ========================================================================= + describe('Dimension 2: Visual Assets & Metadata Integrity', () => { + it('verifies prebaked overlay assets exist on disk in public/overlays/', () => { + const pngPath = path.resolve(__dirname, '../../../public/overlays/enchant.png') + const jsonPath = path.resolve(__dirname, '../../../public/overlays/enchant.json') + expect(fs.existsSync(pngPath)).toBe(true) + expect(fs.existsSync(jsonPath)).toBe(true) - const statsBase = new UnitStatList() - statsBase.setBaseSkillLevel(52, 20) - if (synId !== null) { - statsBase.setBaseSkillLevel(synId, 20) - } - const withHardPoints = skillModule.evaluate!({ - registry, - skill: skillRec, - slvl: 20, - blvl: 20, - statList: statsBase, + const jsonRaw = fs.readFileSync(jsonPath, 'utf8') + const parsed = JSON.parse(jsonRaw) + expect(parsed).toBeDefined() + expect(parsed.frames).toBeDefined() }) - expect(withHardPoints.synergyBonusPct).toBe(180) - // +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy - const statsSoftOnly = new UnitStatList() - statsSoftOnly.setBaseSkillLevel(52, 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 overlay metadata definitions in overlays-meta.ts', () => { + expect(OVERLAY_METAS['enchant']).toBeDefined() + expect(OVERLAY_METAS['enchant']).toBe(ENCHANT_OVERLAY_META) + expect(ENCHANT_OVERLAY_META.name).toBe('enchant') + expect(ENCHANT_OVERLAY_META.celFile).toBe('FireEnchant') + expect(ENCHANT_OVERLAY_META.frames).toBe(17) + + expect(getSkillCastOverlay(52)).toBe('fire_cast_2') + expect(OVERLAY_METAS['fire_cast_2']).toBe(FIRE_CAST_2_META) + expect(FIRE_CAST_2_META.name).toBe('fire_cast_2') }) - 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: 52, - slvl: 20, + // ========================================================================= + // Dimension 3: Formulas & Scaling + // ========================================================================= + describe('Dimension 3: Formulas & Scaling', () => { + it('calculates duration: 3600 + (slvl - 1) * 600 frames', () => { + expect(calculateEnchantDuration(1)).toBe(3600) // 144s + expect(calculateEnchantDuration(2)).toBe(4200) // 168s + expect(calculateEnchantDuration(10)).toBe(9000) // 360s + expect(calculateEnchantDuration(20)).toBe(15000) // 600s + expect(calculateEnchantDuration(30)).toBe(21000) // 840s }) - const outcome = arena.triggerCast() - arena.stepTicks(15) - const dbg = arena.getDebugState() - expect(dbg.ready).toBe(true) - expect(dbg.skillId).toBe(52) - expect(dbg.castCount).toBeGreaterThanOrEqual(1) - expect(dbg.actionFrameTriggered).toBe(true) - expect(outcome?.executed).toBe(true) + it('calculates attack rating percentage: 20 + (slvl - 1) * 9%', () => { + expect(calculateEnchantAttackRating(1)).toBe(20) + expect(calculateEnchantAttackRating(2)).toBe(29) + expect(calculateEnchantAttackRating(10)).toBe(101) + expect(calculateEnchantAttackRating(20)).toBe(191) + expect(calculateEnchantAttackRating(30)).toBe(281) + }) + + it('applies Enchant buff correctly via applyEnchantBuff helper', () => { + const target: { + enchantTicks?: number | undefined + enchantDamage?: { min: number; max: number } | undefined + enchantAttackRatingPercent?: number | undefined + } = {} + + applyEnchantBuff(target, 20, { casterWarmth: 20, casterFireMasterySlvl: 20 }) + expect(target.enchantTicks).toBe(15000) + expect(target.enchantAttackRatingPercent).toBe(191) + expect(target.enchantDamage).toBeDefined() + expect(target.enchantDamage!.min).toBeGreaterThan(68) + expect(target.enchantDamage!.max).toBeGreaterThan(89) + }) + }) + + // ========================================================================= + // Dimension 4: Ranged Penalty (33%) vs Sorceress Melee Double-Dip + // ========================================================================= + describe('Dimension 4: Ranged Penalty (33%) vs Sorceress Melee Double-Dip', () => { + it('applies 33% penalty to ranged attacks and excludes second Fire Mastery multiplier', () => { + const baseDmg = { min: 100, max: 200 } + // Ranged Sorceress with slvl 20 Fire Mastery (163% bonus) + const rangedRes = calculateEnchantAttackDamage({ + enchantDamage: baseDmg, + isRanged: true, + isSorceress: true, + fireMasterySlvl: 20, + }) + // Exactly 33% of base damage, no second 2.63x multiplier! + expect(rangedRes.min).toBe(33) + expect(rangedRes.max).toBe(66) + expect(rangedRes.average).toBe(49.5) + }) + + it('applies Sorceress melee double-dip multiplying fire damage by Fire Mastery again', () => { + const baseDmg = { min: 100, max: 200 } + const fmSlvl = 20 + const fmMultiplier = 1.0 + calculateFireMasteryBonus(fmSlvl) / 100 // 1 + 1.63 = 2.63 + + const meleeSorcRes = calculateEnchantAttackDamage({ + enchantDamage: baseDmg, + isRanged: false, + isSorceress: true, + fireMasterySlvl: fmSlvl, + }) + expect(meleeSorcRes.min).toBe(Math.round(100 * fmMultiplier * 10) / 10) + expect(meleeSorcRes.max).toBe(Math.round(200 * fmMultiplier * 10) / 10) + }) + + it('applies 100% base damage for non-Sorceress melee ally (no double-dip)', () => { + const baseDmg = { min: 100, max: 200 } + const allyRes = calculateEnchantAttackDamage({ + enchantDamage: baseDmg, + isRanged: false, + isSorceress: false, + }) + expect(allyRes.min).toBe(100) + expect(allyRes.max).toBe(200) + }) + + it('preserves fire immunity: enemies with fire resist >= 100% take 0 damage', () => { + const baseDmg = { min: 100, max: 200 } + const immuneRes = calculateEnchantAttackDamage({ + enchantDamage: baseDmg, + isRanged: false, + isSorceress: true, + fireMasterySlvl: 20, + targetFireResist: 100, + }) + expect(immuneRes.min).toBe(0) + expect(immuneRes.max).toBe(0) + expect(immuneRes.average).toBe(0) + + const overcappedRes = calculateEnchantAttackDamage({ + enchantDamage: baseDmg, + isRanged: false, + isSorceress: true, + fireMasterySlvl: 20, + targetFireResist: 115, + }) + expect(overcappedRes.min).toBe(0) + expect(overcappedRes.max).toBe(0) + }) + + it('scales damage against partially resistant targets', () => { + const baseDmg = { min: 100, max: 200 } + const res50 = calculateEnchantAttackDamage({ + enchantDamage: baseDmg, + isRanged: false, + isSorceress: false, + targetFireResist: 50, + }) + expect(res50.min).toBe(50) + expect(res50.max).toBe(100) + expect(res50.average).toBe(75) + }) + }) + + // ========================================================================= + // Dimension 5: Synergy Math & 5-Band Integer Accuracy + // ========================================================================= + describe('Dimension 5: Synergy Math & 5-Band Integer Accuracy', () => { + it('evaluates exact 5-band base progression with HitShift = 7', () => { + // slvl 1: raw 16-20 => 8.0 - 10.0 fire damage (2048 - 2560 in 256ths) + const d1 = calculateEnchantDamage(1) + expect(d1.min).toBe(8) + expect(d1.max).toBe(10) + expect(d1.min256).toBe(2048) + expect(d1.max256).toBe(2560) + + // slvl 10: raw 51-73 => 25.5 - 36.5 fire damage (6528 - 9344 in 256ths) + const d10 = calculateEnchantDamage(10) + expect(d10.min).toBe(25.5) + expect(d10.max).toBe(36.5) + expect(d10.min256).toBe(6528) + expect(d10.max256).toBe(9344) + + // slvl 20: raw 137-179 => 68.5 - 89.5 fire damage (17536 - 22912 in 256ths) + const d20 = calculateEnchantDamage(20) + expect(d20.min).toBe(68.5) + expect(d20.max).toBe(89.5) + expect(d20.min256).toBe(17536) + expect(d20.max256).toBe(22912) + }) + + it('enforces Warmth +9% synergy per hard point and rejects soft points', async () => { + const registry = await getSharedDataRegistry() + const skillRec = registry.getSkillById(52)! + + const statsBase = new UnitStatList() + statsBase.setBaseSkillLevel(52, 20) + statsBase.setBaseSkillLevel(37, 20) // Warmth 20 hard points + + const withHardPoints = skillModule.evaluate!({ + registry, + skill: skillRec, + slvl: 20, + blvl: 20, + statList: statsBase, + }) + expect(withHardPoints.synergyBonusPct).toBe(180) // 20 * 9% = 180% + + // Soft points only (+skills) + const statsSoftOnly = new UnitStatList() + statsSoftOnly.setBaseSkillLevel(52, 20) + statsSoftOnly.addStat('item_allskills', 20) + const withSoftOnly = skillModule.evaluate!({ + registry, + skill: skillRec, + slvl: 20, + blvl: 20, + statList: statsSoftOnly, + }) + expect(withSoftOnly.synergyBonusPct).toBe(0) + }) + + it('scales cast damage with Warmth synergy and Fire Mastery', () => { + // 20 Warmth (+180% synergy = 2.8x) and 20 Fire Mastery (163% bonus = 2.63x) + const baseD20 = calculateEnchantDamage(20) + const buffedD20 = calculateEnchantDamage(20, { warmth: 20, fireMastery: 20 }) + + expect(buffedD20.min).toBeGreaterThan(baseD20.min * 2.8 * 2.6) + expect(buffedD20.max).toBeGreaterThan(baseD20.max * 2.8 * 2.6) + }) + }) + + // ========================================================================= + // Dimension 6: Mana Progression & 0 Cooldown Ticks + // ========================================================================= + describe('Dimension 6: Mana Progression & 0 Cooldown Ticks', () => { + it('calculates mana cost progression: 25 + (slvl - 1)', () => { + expect(getSkillManaCost(52, 1)).toBe(25) + expect(getSkillManaCost(52, 2)).toBe(26) + expect(getSkillManaCost(52, 10)).toBe(34) + expect(getSkillManaCost(52, 20)).toBe(44) + }) + + it('verifies zero cooldown ticks (no cast delay)', () => { + expect(getSkillCooldownTicks(52)).toBe(0) + }) + + it('verifies InTown targeting rules (can buff self, allies, pets in town; not enemies)', () => { + expect(canCastEnchantOn('self', true)).toBe(true) + expect(canCastEnchantOn('ally', true)).toBe(true) + expect(canCastEnchantOn('pet', true)).toBe(true) + expect(canCastEnchantOn('enemy', true)).toBe(false) + }) + }) + + // ========================================================================= + // Dimension 7: Invariants & Runtime Execution + // ========================================================================= + describe('Dimension 7: Invariants & Runtime Execution', () => { + it('preserves BATCH1_SKILLS length strictly at 38', () => { + expect(Object.keys(BATCH1_SKILLS).length).toBe(38) + }) + + it('executes runtime cast in WorldArena with action frame and outcome', async () => { + const registry = await getSharedDataRegistry() + const arena = new WorldArena({ + registry, + classCode: 'sor', + skillId: 52, + slvl: 20, + }) + const outcome = arena.triggerCast() + arena.stepTicks(15) + const dbg = arena.getDebugState() + + expect(dbg.ready).toBe(true) + expect(dbg.skillId).toBe(52) + expect(dbg.castCount).toBeGreaterThanOrEqual(1) + expect(dbg.actionFrameTriggered).toBe(true) + expect(outcome?.executed).toBe(true) + }) }) }) diff --git a/tests/skills/sor/skill-061-fire-mastery.test.ts b/tests/skills/sor/skill-061-fire-mastery.test.ts index ad072a6..2d7d516 100644 --- a/tests/skills/sor/skill-061-fire-mastery.test.ts +++ b/tests/skills/sor/skill-061-fire-mastery.test.ts @@ -1,109 +1,241 @@ /** - * 1.13c Numerical & Runtime Verification Suite — Skill #061: Fire Mastery (SOR) + * 1.13c Numerical & Runtime 7-Dimensional Verification Suite — Skill #061: Fire Mastery (SOR) * Gitea Tracking Issue: #215 + * + * 7-Dimensional Verification Coverage: + * D1: 1.13c Data Table Parsing & Schema Integrity (Skills.txt row 61, passive 1, passivestat1 'passive_fire_mastery', calc1 30+(lvl-1)*7) + * D2: Visual Assets & Passive Metadata Integrity (Passive skill, no cast overlay, no projectile, reqlevel 30) + * D3: Formulas & Scaling (30 + (slvl-1)*7% scaling across all levels: slvl 1=30%, 10=93%, 20=163%, 30=233%) + * D4: Fire Damage Amplification & Combat Pipeline (Outbound fire damage scaled, no pierce, immunity intact at >=100% res) + * D5: Double-Dip Integration with Enchant (Cast multiplier + Sorc melee double-dip vs ranged 33% penalty) + * D6: Accrued Stat Integration & Zero Resource Cost (Accrues to 'passive_fire_mastery', mana cost 0, cooldown ticks 0) + * D7: Invariants (BATCH1_SKILLS length strictly 38, zero runtime MPQ queries, WorldArena runtime execution) */ 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-061-fire-mastery.ts' +import { + calculateFireMasteryBonus, + calculateEnchantDamage, + calculateEnchantAttackDamage, + getSkillCastOverlay, + getSkillManaCost, + getSkillCooldownTicks, + BATCH1_SKILLS, +} from '../../../src/game/skills.ts' -describe('[Skill #061] Fire Mastery (SOR) — 1.13c Parity (Issue #215)', () => { - 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(61)! - expect(skillRec).toBeDefined() - expect(skillModule.skillId).toBe(61) - expect(skillModule.name).toBe("Fire Mastery") - expect(skillModule.charClass).toBe("sor") +describe('[Skill #061] Fire Mastery (SOR) — 1.13c 7-Dimensional Verification Suite (Issue #215)', () => { + const skillsTxtPath = path.resolve(__dirname, '../../../samples/fixtures/data/global/excel/Skills.txt') + const skillsTxtContent = fs.readFileSync(skillsTxtPath, 'latin1') - const stats1 = new UnitStatList() - stats1.setBaseSkillLevel(61, 1) - const r1 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 1, blvl: 1, statList: stats1 }) - expect(r1.manaCost).toBe(0) - expect(r1.manaCost256).toBe(0) - 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 & Schema Integrity + // ========================================================================= + describe('Dimension 1: 1.13c Data Table Parsing & Schema Integrity', () => { + it('parses authentic Skills.txt row 61 (Fire Mastery)', () => { + 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 reqLevelIdx = headers.indexOf('reqlevel') + const passiveIdx = headers.indexOf('passive') + const passiveStat1Idx = headers.indexOf('passivestat1') + const passiveCalc1Idx = headers.indexOf('passivecalc1') + const calc1Idx = headers.indexOf('calc1') + const manaIdx = headers.indexOf('mana') + const lvlManaIdx = headers.indexOf('lvlmana') + const srvDoFuncIdx = headers.indexOf('srvdofunc') + const srvStFuncIdx = headers.indexOf('srvstfunc') - const stats10 = new UnitStatList() - stats10.setBaseSkillLevel(61, 10) - const r10 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 10, blvl: 10, statList: stats10 }) - expect(r10.manaCost).toBe(0) - expect(r10.manaCost256).toBe(0) - 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(61, 20) - const r20 = skillModule.evaluate!({ registry, skill: skillRec, slvl: 20, blvl: 20, statList: stats20 }) - expect(r20.manaCost).toBe(0) - expect(r20.manaCost256).toBe(0) - 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(163) - expect(r20.petMax).toBe(0) - expect(r20.cooldownFrames).toBe(0) + const row = lines.slice(1).map(l => l.split('\t')).find(r => r[idIdx]?.trim() === '61') + expect(row).toBeDefined() + expect(row![skillIdx]?.trim()).toBe('Fire Mastery') + expect(row![charClassIdx]?.trim()).toBe('sor') + expect(Number(row![reqLevelIdx])).toBe(30) + expect(Number(row![passiveIdx])).toBe(1) + expect(row![passiveStat1Idx]?.trim()).toBe('passive_fire_mastery') + expect(row![passiveCalc1Idx]?.trim()).toBe('ln12') + expect(Number(row![headers.indexOf('Param1')])).toBe(30) + expect(Number(row![headers.indexOf('Param2')])).toBe(7) + expect(Number(row![manaIdx])).toBe(0) + expect(Number(row![lvlManaIdx])).toBe(0) + expect(Number(row![srvDoFuncIdx])).toBe(0) + expect(Number(row![srvStFuncIdx])).toBe(0) + }) }) - 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(61)! - const synId = null - - const statsBase = new UnitStatList() - statsBase.setBaseSkillLevel(61, 20) - if (synId !== null) { - statsBase.setBaseSkillLevel(synId, 20) - } - const withHardPoints = skillModule.evaluate!({ - registry, - skill: skillRec, - slvl: 20, - blvl: 20, - statList: statsBase, + // ========================================================================= + // Dimension 2: Visual Assets & Passive Metadata Integrity + // ========================================================================= + describe('Dimension 2: Visual Assets & Passive Metadata Integrity', () => { + it('verifies passive skill metadata (no cast overlay, no projectile)', () => { + expect(getSkillCastOverlay(61)).toBeUndefined() }) - expect(withHardPoints.synergyBonusPct).toBe(0) - // +20 allskills and +33 Marrowwalk charges without hard points must yield 0 synergy - const statsSoftOnly = new UnitStatList() - statsSoftOnly.setBaseSkillLevel(61, 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 skillModule metadata', () => { + expect(skillModule.skillId).toBe(61) + expect(skillModule.name).toBe('Fire Mastery') + expect(skillModule.charClass).toBe('sor') + expect(skillModule.srvDoFunc).toBe(0) + expect(skillModule.srvStFunc).toBe(0) }) - 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: 61, - slvl: 20, + // ========================================================================= + // Dimension 3: Formulas & Scaling + // ========================================================================= + describe('Dimension 3: Formulas & Scaling', () => { + it('calculates fire damage bonus percentage: 30 + (slvl - 1) * 7%', () => { + expect(calculateFireMasteryBonus(1)).toBe(30) + expect(calculateFireMasteryBonus(2)).toBe(37) + expect(calculateFireMasteryBonus(5)).toBe(58) + expect(calculateFireMasteryBonus(10)).toBe(93) + expect(calculateFireMasteryBonus(20)).toBe(163) + expect(calculateFireMasteryBonus(30)).toBe(233) + expect(calculateFireMasteryBonus(40)).toBe(303) }) - const outcome = arena.triggerCast() - arena.stepTicks(15) - const dbg = arena.getDebugState() - expect(dbg.ready).toBe(true) - expect(dbg.skillId).toBe(61) - expect(dbg.castCount).toBeGreaterThanOrEqual(1) - expect(dbg.actionFrameTriggered).toBe(true) - expect(outcome?.executed).toBe(true) + it('handles non-positive skill levels gracefully', () => { + expect(calculateFireMasteryBonus(0)).toBe(0) + expect(calculateFireMasteryBonus(-5)).toBe(0) + expect(calculateFireMasteryBonus(NaN)).toBe(0) + }) + }) + + // ========================================================================= + // Dimension 4: Fire Damage Amplification & Combat Pipeline + // ========================================================================= + describe('Dimension 4: Fire Damage Amplification & Combat Pipeline', () => { + it('amplifies outbound fire damage without granting resistance pierce', () => { + const bonus20 = calculateFireMasteryBonus(20) // 163% + const rawFireDmg = 100 + const scaledFireDmg = Math.round(rawFireDmg * (1 + bonus20 / 100)) + expect(scaledFireDmg).toBe(263) + + // Fire Mastery does NOT reduce enemy fire resistance (no pierce) + // Against a 50% fire resist target, 50% resistance still applies: + const netDamageAgainstRes50 = Math.round(scaledFireDmg * (1 - 50 / 100)) + expect(netDamageAgainstRes50).toBe(132) + }) + + it('strictly preserves fire immunity (Res >= 100%)', () => { + const immuneAttack = calculateEnchantAttackDamage({ + enchantDamage: { min: 100, max: 200 }, + isRanged: false, + isSorceress: true, + fireMasterySlvl: 20, + targetFireResist: 100, + }) + expect(immuneAttack.min).toBe(0) + expect(immuneAttack.max).toBe(0) + expect(immuneAttack.average).toBe(0) + + const highResAttack = calculateEnchantAttackDamage({ + enchantDamage: { min: 100, max: 200 }, + isRanged: false, + isSorceress: true, + fireMasterySlvl: 20, + targetFireResist: 125, + }) + expect(highResAttack.min).toBe(0) + expect(highResAttack.max).toBe(0) + expect(highResAttack.average).toBe(0) + }) + }) + + // ========================================================================= + // Dimension 5: Double-Dip Integration with Enchant + // ========================================================================= + describe('Dimension 5: Double-Dip Integration with Enchant', () => { + it('applies cast multiplier to Enchant and melee double-dip to Sorceress', () => { + const slvl = 20 + const fmSlvl = 20 + const fmBonus = calculateFireMasteryBonus(fmSlvl) // 163% -> 2.63x + + // 1. Cast multiplier: calculateEnchantDamage applies Fire Mastery at cast time + const baseDmg = calculateEnchantDamage(slvl) // 68.5 - 89.5 + const castDmg = calculateEnchantDamage(slvl, undefined, fmSlvl) + + expect(castDmg.min).toBe(Math.round(baseDmg.min * (1 + fmBonus / 100) * 10) / 10) + expect(castDmg.max).toBe(Math.round(baseDmg.max * (1 + fmBonus / 100) * 10) / 10) + + // 2. Melee double-dip: Sorceress in melee applies Fire Mastery again upon hit! + const meleeSorcHit = calculateEnchantAttackDamage({ + enchantDamage: { min: castDmg.min, max: castDmg.max }, + isRanged: false, + isSorceress: true, + fireMasterySlvl: fmSlvl, + }) + + const expectedDoubleDipMin = Math.round(castDmg.min * (1 + fmBonus / 100) * 10) / 10 + const expectedDoubleDipMax = Math.round(castDmg.max * (1 + fmBonus / 100) * 10) / 10 + expect(meleeSorcHit.min).toBe(expectedDoubleDipMin) + expect(meleeSorcHit.max).toBe(expectedDoubleDipMax) + + // 3. Ranged attack bypasses second Fire Mastery multiplier and takes 33% penalty + const rangedSorcHit = calculateEnchantAttackDamage({ + enchantDamage: { min: castDmg.min, max: castDmg.max }, + isRanged: true, + isSorceress: true, + fireMasterySlvl: fmSlvl, + }) + + expect(rangedSorcHit.min).toBe(Math.round(castDmg.min * 0.33 * 10) / 10) + expect(rangedSorcHit.max).toBe(Math.round(castDmg.max * 0.33 * 10) / 10) + }) + }) + + // ========================================================================= + // Dimension 6: Accrued Stat Integration & Zero Resource Cost + // ========================================================================= + describe('Dimension 6: Accrued Stat Integration & Zero Resource Cost', () => { + it('accrues passive_fire_mastery on UnitStatList with base and effective skill levels', () => { + const stats = new UnitStatList() + expect(stats.getAccruedStat('passive_fire_mastery')).toBe(0) + + stats.setBaseSkillLevel(61, 20) + expect(stats.getAccruedStat('passive_fire_mastery')).toBe(163) + + // +5 all skills increases effective Fire Mastery level to 25 (30 + 24 * 7 = 198%) + stats.addStat('item_allskills', 5) + expect(stats.getAccruedStat('passive_fire_mastery')).toBe(198) + }) + + it('verifies 0 mana cost and 0 cooldown frames', () => { + expect(getSkillManaCost(61, 1)).toBe(0) + expect(getSkillManaCost(61, 20)).toBe(0) + expect(getSkillCooldownTicks(61)).toBe(0) + }) + }) + + // ========================================================================= + // Dimension 7: Invariants & Runtime Execution + // ========================================================================= + describe('Dimension 7: Invariants & Runtime Execution', () => { + it('preserves BATCH1_SKILLS length strictly at 38', () => { + expect(Object.keys(BATCH1_SKILLS).length).toBe(38) + }) + + it('executes passive skill in WorldArena successfully', async () => { + const registry = await getSharedDataRegistry() + const arena = new WorldArena({ + registry, + classCode: 'sor', + skillId: 61, + slvl: 20, + }) + const outcome = arena.triggerCast() + arena.stepTicks(15) + const dbg = arena.getDebugState() + + expect(dbg.ready).toBe(true) + expect(dbg.skillId).toBe(61) + expect(outcome?.executed).toBe(true) + }) }) })