diff --git a/tests/skills/ass/adv-ass-e2e-gate.test.ts b/tests/skills/ass/adv-ass-e2e-gate.test.ts new file mode 100644 index 0000000..5bd091d --- /dev/null +++ b/tests/skills/ass/adv-ass-e2e-gate.test.ts @@ -0,0 +1,508 @@ +/** + * Diablo II: Lord of Destruction v1.13c — Assassin Milestone M29 Class Gate & End-to-End Suite + * + * Comprehensive cross-tree integration and architectural verification across all 30 Assassin skills: + * + * Skill Trees Covered: + * 1. Traps (10 skills): + * - Fire Blast (251), Shock Web (256), Blade Sentinel (257), Charged Bolt Sentry (261), + * Wake of Fire (262), Blade Fury (266), Lightning Sentry (271), Wake of Inferno (272), + * Death Sentry (276), Blade Shield (277). + * 2. Martial Arts (10 skills): + * - Tiger Strike (254), Dragon Talon (255), Fists of Fire (259), Dragon Claw (260), + * Cobra Strike (265), Claws of Thunder (269), Dragon Tail (270), Blades of Ice (274), + * Dragon Flight (275), Phoenix Strike (280). + * 3. Shadow Disciplines (10 skills): + * - Claw Mastery (252), Psychic Hammer (253), Burst of Speed (258), Weapon Block (263), + * Cloak of Shadows (264), Fade (267), Shadow Warrior (268), Mind Blast (273), + * Venom (278), Shadow Master (279). + * + * Core Verification Scenarios: + * Scenario 1: Traps Tree Synergy Network & 5-Band Progression (Traps) + * Scenario 2: Martial Arts Charge-Up & Finisher Discharge Pipeline + * Scenario 3: Shadow Disciplines Buff Mechanics & Venom Bitrate Clamp + * Scenario 4: Minion & Summon Caps (5 Traps + 1 Shadow = 6 Max) + * Scenario 5: NextDelay Collision Avoidance Across Trees + * Scenario 6: Cross-Tree Hybrid Build Integration (Trapper / Kicksin / Shadow) + * Scenario 7: Crowd Control & Status Effects (Mind Blast Stun/Convert, Psychic Hammer KB, CoS Blind) + * Scenario 8: Complete 30-Skill Assassin Tree Data Registry & Architectural Invariants + * + * ZERO MOCKS: vi.mock / vi.spyOn strictly forbidden. + * ZERO CIRCULAR ORACLES: Precalculated independently from 1.13c ground truth constants. + */ + +import { describe, expect, it } from 'vitest' +import { BATCH1_SKILLS } from '../../../src/game/skills.ts' +import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts' + +// Traps imports +import { + ASSASSIN_TRAP_SKILL_IDS, + BLADE_SRC_DAM, + MAX_ACTIVE_TRAPS, + NEXT_DELAY_FRAMES, + NextDelayTracker, + TrapManager, + evaluateBladeFuryStats, + evaluateBladeSentinelStats, + evaluateDeathSentryStats, + evaluateFireBlastStats, + evaluateLightningSentryStats, + evaluateWakeOfFireStats, +} from '../../../src/game/skills/assassin-traps.ts' + +// Martial Arts imports +import { + ASSASSIN_MARTIAL_SKILL_IDS, + ChargeOrbManager, + canCastDragonClaw, + evaluateCobraStrikeStats, + evaluateDragonClawStats, + evaluateDragonFlightStats, + evaluateDragonTailStats, + evaluateDragonTalonStats, + evaluatePhoenixStrikeStats, + evaluateTigerStrikeStats, + resolveFinisherDischarge, +} from '../../../src/game/skills/assassin-martial-arts.ts' + +// Shadow Disciplines imports +import { + ASSASSIN_SHADOW_SKILL_IDS, + ShadowBuffTracker, + ShadowMinionManager, + computeBurstOfSpeedDuration, + computeBurstOfSpeedFRW, + computeBurstOfSpeedIAS, + computeClawMasteryAR, + computeClawMasteryCriticalStrike, + computeClawMasteryED, + computeCloakOfShadowsDefenseBonus, + computeCloakOfShadowsDefenseReduction, + computeCloakOfShadowsDuration, + computeFadeCurseReduction, + computeFadeDuration, + computeFadePhysicalDamageReduction, + computeFadeResistances, + computeMindBlastConversionChance, + computeMindBlastDamage, + computeMindBlastStunDuration, + computePsychicHammerDamage, + computePsychicHammerKnockbackChance, + computeShadowMasterStats, + computeShadowWarriorStats, + computeVenomDuration, + computeVenomPoisonDamage, + computeWeaponBlockChance, + resolveMindBlastTarget, + resolveVenomPoisonClamp, + resolveWeaponBlock, +} from '../../../src/game/skills/assassin-shadow.ts' + +describe('Milestone M29 — Assassin Class Gate & Comprehensive E2E Integration Suite', () => { + // ========================================================================= + // Scenario 1: Traps Tree Synergy Network & 5-Band Progression (Traps) + // ========================================================================= + describe('Scenario 1: Traps Tree Synergy Network & 5-Band Progression (Traps)', () => { + it('verifies Lightning Sentry 5-band damage progression and 3-way synergy (+360%)', () => { + // Base Lightning Sentry slvl 20 (0 synergies): + // Min: 1 (constant) + // Max: compute5BandScaling(20, 20, 10, 16, 24, 34, 44) = 20 + 7*10 + 8*16 + 4*24 = 314 + const baseLS = evaluateLightningSentryStats(20, {}) + expect(baseLS.minDamage).toBe(1) + expect(baseLS.maxDamage).toBe(314) + expect(baseLS.shots).toBe(10) + + // Fully synergized Lightning Sentry (20 Shock Web, 20 Charged Bolt Sentry, 20 Death Sentry): + // In 1.13c: +12% per blvl of Shock Web, CBS, Death Sentry (total 3 * 20 * 12% = +720%) + const synLS = evaluateLightningSentryStats(20, { + shockWeb: 20, + chargedBoltSentry: 20, + deathSentry: 20, + }) + expect(synLS.minDamage).toBe(1) + expect(synLS.maxDamage).toBe(Math.floor(314 * (1 + 720 / 100))) // 2574 + }) + + it('verifies Death Sentry corpse explosion radius, shot count, and lightning synergy', () => { + // Base Death Sentry slvl 20, 0 Fire Blast blvl, 0 LS blvl + const baseDS = evaluateDeathSentryStats(20, {}) + // Radius: 10 + (20 - 1) = 29 subtiles (9.66 yards) + expect(baseDS.corpseExplosionRadiusSubtiles).toBe(29) + // Shot count: 5 + floor(0 / 3) = 5 + expect(baseDS.shots).toBe(5) + // Corpse explosion damage range: 40% to 80% of corpse max HP + expect(baseDS.corpseExplosionMinPct).toBe(40) + expect(baseDS.corpseExplosionMaxPct).toBe(80) + + // Death Sentry with 15 Fire Blast blvl -> shot count = 5 + floor(15/3) = 10 shots! + const synDS = evaluateDeathSentryStats(20, { + fireBlast: 15, + lightningSentry: 20, + }) + expect(synDS.shots).toBe(10) + // Lightning damage: base max is 50 + 7*11 + 8*15 + 4*19 = 323, boosted by +240% (20 * 12%) -> floor(323 * 3.4) = 1098 + expect(synDS.maxLtngDamage).toBe(Math.floor(323 * 3.4)) + }) + + it('verifies Blade weapon damage scaling (Blade Sentinel 3/8, Blade Fury 3/4)', () => { + const bSentinel = evaluateBladeSentinelStats(20, 128) + // 128 base weapon damage * 48 / 128 = 48 scaled weapon damage + expect(bSentinel.scaledWeaponPhys).toBe(48) + expect(BLADE_SRC_DAM.BLADE_SENTINEL).toBe(48) // 48/128 = 3/8 + + const bFury = evaluateBladeFuryStats(20, 128) + // 128 base weapon damage * 96 / 128 = 96 scaled weapon damage + expect(bFury.scaledWeaponPhys).toBe(96) + expect(BLADE_SRC_DAM.BLADE_FURY).toBe(96) // 96/128 = 3/4 + }) + }) + + // ========================================================================= + // Scenario 2: Martial Arts Charge-Up & Finisher Discharge Pipeline + // ========================================================================= + describe('Scenario 2: Martial Arts Charge-Up & Finisher Discharge Pipeline', () => { + it('manages multi-skill charge accumulation and resolves full discharge on finisher contact', () => { + const orbMgr = new ChargeOrbManager() + + // Charge up Tiger Strike to tier 3 + orbMgr.addCharge(ASSASSIN_MARTIAL_SKILL_IDS.TIGER_STRIKE, 20, 100) + orbMgr.addCharge(ASSASSIN_MARTIAL_SKILL_IDS.TIGER_STRIKE, 20, 150) + orbMgr.addCharge(ASSASSIN_MARTIAL_SKILL_IDS.TIGER_STRIKE, 20, 200) + + // Charge up Cobra Strike to tier 2 + orbMgr.addCharge(ASSASSIN_MARTIAL_SKILL_IDS.COBRA_STRIKE, 20, 250) + orbMgr.addCharge(ASSASSIN_MARTIAL_SKILL_IDS.COBRA_STRIKE, 20, 300) + + expect(orbMgr.getChargeLevel(ASSASSIN_MARTIAL_SKILL_IDS.TIGER_STRIKE, 300)).toBe(3) + expect(orbMgr.getChargeLevel(ASSASSIN_MARTIAL_SKILL_IDS.COBRA_STRIKE, 300)).toBe(2) + + // Finisher impacts: resolveFinisherDischarge releases all active charges + const activeCharges = orbMgr.dischargeAll(300) + expect(activeCharges.length).toBe(2) + + const payloads = resolveFinisherDischarge(activeCharges) + expect(payloads.length).toBe(2) + + const tigerPayload = payloads.find(p => p.skillId === ASSASSIN_MARTIAL_SKILL_IDS.TIGER_STRIKE) + expect(tigerPayload?.enhancedDamagePct).toBe(1440) // Tier 3 slvl 20: +1440% ED + + const cobraPayload = payloads.find(p => p.skillId === ASSASSIN_MARTIAL_SKILL_IDS.COBRA_STRIKE) + expect(cobraPayload?.lifeStealPct).toBe(135) // Tier 2 slvl 20 + expect(cobraPayload?.manaStealPct).toBe(135) // Tier 2 slvl 20 + + // OrbManager is now completely clear + expect(orbMgr.getActiveCharges(300).length).toBe(0) + }) + + it('verifies Phoenix Strike strictly discharges the targeted single-tier payload', () => { + const pStats = evaluatePhoenixStrikeStats(20) + + // Tier 1: Meteor + expect(pStats.meteorMinDamage).toBeGreaterThan(0) + expect(pStats.meteorMaxDamage).toBeGreaterThan(pStats.meteorMinDamage) + + // Tier 2: Chain Lightning + expect(pStats.chainLightningMaxDamage).toBeGreaterThan(0) + expect(pStats.chainLightningTargets).toBeGreaterThan(0) + + // Tier 3: Chaos Ice Orb + expect(pStats.chaosIceMaxDamage).toBeGreaterThan(0) + expect(pStats.freezeDurationFrames).toBeGreaterThan(0) + + // Discharge resolution for Tier 1 + const p1 = resolveFinisherDischarge([{ + skillId: ASSASSIN_MARTIAL_SKILL_IDS.PHOENIX_STRIKE, + count: 1, + slvl: 20, + expiresAtFrame: 1000, + }])[0] + expect(p1?.phoenixPayload?.tier).toBe(1) + expect(p1?.phoenixPayload?.type).toBe('meteor') + + // Discharge resolution for Tier 2 + const p2 = resolveFinisherDischarge([{ + skillId: ASSASSIN_MARTIAL_SKILL_IDS.PHOENIX_STRIKE, + count: 2, + slvl: 20, + expiresAtFrame: 1000, + }])[0] + expect(p2?.phoenixPayload?.tier).toBe(2) + expect(p2?.phoenixPayload?.type).toBe('chain_lightning') + + // Discharge resolution for Tier 3 + const p3 = resolveFinisherDischarge([{ + skillId: ASSASSIN_MARTIAL_SKILL_IDS.PHOENIX_STRIKE, + count: 3, + slvl: 20, + expiresAtFrame: 1000, + }])[0] + expect(p3?.phoenixPayload?.tier).toBe(3) + expect(p3?.phoenixPayload?.type).toBe('chaos_ice') + }) + + it('verifies Dragon Talon multi-kicks and Dragon Claw dual-claw strike mechanics', () => { + // Dragon Talon kick count: 1 + floor(slvl / 6) + expect(evaluateDragonTalonStats(1).kickCount).toBe(1) + expect(evaluateDragonTalonStats(6).kickCount).toBe(2) + expect(evaluateDragonTalonStats(12).kickCount).toBe(3) + expect(evaluateDragonTalonStats(18).kickCount).toBe(4) + expect(evaluateDragonTalonStats(24).kickCount).toBe(5) + expect(evaluateDragonTalonStats(30).kickCount).toBe(6) + + // Dragon Claw requirement and dual strikes + expect(canCastDragonClaw(true, false)).toBe(false) + expect(canCastDragonClaw(true, true)).toBe(true) + + const dcStats = evaluateDragonClawStats(20, 20) + expect(dcStats.strikesCount).toBe(2) + // Base ED: 50 + 5 * 19 = 145% + Claw Mastery synergy (20 * 4% = +80%) = 225% + expect(dcStats.enhancedDamagePct).toBe(225) + }) + }) + + // ========================================================================= + // Scenario 3: Shadow Disciplines Buff Mechanics & Venom Bitrate Clamp + // ========================================================================= + describe('Scenario 3: Shadow Disciplines Buff Mechanics & Venom Bitrate Clamp', () => { + it('enforces strict mutual exclusivity between Burst of Speed and Fade', () => { + const tracker = new ShadowBuffTracker() + + tracker.applyBurstOfSpeed(20) + expect(tracker.getBurstOfSpeed()?.params['iasPct']).toBe(53) + expect(tracker.getFade()).toBeNull() + + // Activating Fade cancels BoS immediately + const fadeRes = tracker.applyFade(20) + expect(fadeRes.cancelledBoS).toBe(true) + expect(tracker.getBurstOfSpeed()).toBeNull() + expect(tracker.getFade()?.params['allResistPct']).toBe(65) + expect(tracker.getFade()?.params['damageResistPct']).toBe(20) + }) + + it('clamps combined poison damage to 10 frames under active Venom buff', () => { + // Venom slvl 20 deals 170..178 over 10 frames + // Adding weapon poison: 50 damage over 50 frames (1 dmg/frame) + const clamped = resolveVenomPoisonClamp(20, [{ damage: 50, durationFrames: 50 }]) + expect(clamped.finalDurationFrames).toBe(10) + // 170 (venom base min) + 10 (weapon 1 dmg/frame * 10 frames) = 180 total + expect(clamped.totalDamageDealt).toBe(180) + }) + + it('enforces Cloak of Shadows recast lockout and defense modifications', () => { + const tracker = new ShadowBuffTracker() + + const c1 = tracker.applyCloakOfShadows(20) + expect(c1.success).toBe(true) + expect(tracker.getCloakOfShadows()?.params['monsterDefReductionPct']).toBe(72) + expect(tracker.getCloakOfShadows()?.params['assassinDefBonusPct']).toBe(67) + + // Recast while active must fail + const c2 = tracker.applyCloakOfShadows(20) + expect(c2.success).toBe(false) + expect(c2.reason).toContain('recast locked') + }) + }) + + // ========================================================================= + // Scenario 4: Minion & Summon Caps (5 Traps + 1 Shadow = 6 Max) + // ========================================================================= + describe('Scenario 4: Minion & Summon Caps (5 Traps + 1 Shadow = 6 Max)', () => { + it('enforces global 5-trap FIFO eviction cap on PetType assassintrap', () => { + const trapMgr = new TrapManager() + const ownerId = 'assassin_player' + + // Summon 5 traps + for (let i = 1; i <= 5; i++) { + const t = trapMgr.registerTrap({ + ownerId, + skillId: ASSASSIN_TRAP_SKILL_IDS.LIGHTNING_SENTRY, + summonMon: 'lightningsentry', + x: i * 10, + y: i * 10, + slvl: 20, + shots: 10, + currentFrame: i * 100, + }) + expect(t.evictedTrap).toBeUndefined() + } + expect(trapMgr.getActiveTrapCount(ownerId)).toBe(5) + + // 6th trap causes FIFO eviction of trap 1 + const sixth = trapMgr.registerTrap({ + ownerId, + skillId: ASSASSIN_TRAP_SKILL_IDS.DEATH_SENTRY, + summonMon: 'deathsentry', + x: 60, + y: 60, + slvl: 20, + shots: 5, + currentFrame: 600, + }) + expect(sixth.evictedTrap?.id).toBe('trap_1') + expect(trapMgr.getActiveTrapCount(ownerId)).toBe(5) + }) + + it('enforces PetType shadowwarrior cap = 1 and coexists with 5 active traps', () => { + const trapMgr = new TrapManager() + const shadowMgr = new ShadowMinionManager() + const ownerId = 'assassin_player' + + // Summon 5 traps + for (let i = 1; i <= 5; i++) { + trapMgr.registerTrap({ + ownerId, + skillId: ASSASSIN_TRAP_SKILL_IDS.LIGHTNING_SENTRY, + summonMon: 'lightningsentry', + x: i * 10, + y: i * 10, + slvl: 20, + shots: 10, + currentFrame: 100, + }) + } + expect(trapMgr.getActiveTrapCount(ownerId)).toBe(5) + + // Summon Shadow Warrior + const s1 = shadowMgr.summonShadow('warrior', 20, 200) + expect(s1.despawnedPrevious).toBe(false) + expect(shadowMgr.getActiveShadow()?.type).toBe('warrior') + + // Summon Shadow Master -> replaces Shadow Warrior + const s2 = shadowMgr.summonShadow('master', 20, 250) + expect(s2.despawnedPrevious).toBe(true) + expect(shadowMgr.getActiveShadow()?.type).toBe('master') + + // Total pets active: 5 traps + 1 shadow = 6 pets simultaneously + expect(trapMgr.getActiveTrapCount(ownerId)).toBe(5) + expect(shadowMgr.getActiveShadow()).not.toBeNull() + }) + }) + + // ========================================================================= + // Scenario 5: NextDelay Collision Avoidance Across Trees + // ========================================================================= + describe('Scenario 5: NextDelay Collision Avoidance Across Trees', () => { + it('enforces NextDelay hit protection (Wake of Fire 4f, Shock Web 25f, Dragon Flight 4f)', () => { + const nd = new NextDelayTracker() + const targetId = 'target_boss' + const channelWoF = 'wof_channel' + const channelShockWeb = 'shockweb_channel' + + // Wake of Fire (4 frames NextDelay) + expect(nd.canTakeDamage(targetId, channelWoF, 100)).toBe(true) + nd.recordHit(targetId, channelWoF, 100, NEXT_DELAY_FRAMES.WAKE_OF_FIRE) + + // Hits on frames 101, 102, 103 are rejected + expect(nd.canTakeDamage(targetId, channelWoF, 101)).toBe(false) + expect(nd.canTakeDamage(targetId, channelWoF, 102)).toBe(false) + expect(nd.canTakeDamage(targetId, channelWoF, 103)).toBe(false) + // Hit on frame 104 is accepted! + expect(nd.canTakeDamage(targetId, channelWoF, 104)).toBe(true) + + // Shock Web (25 frames NextDelay) + nd.recordHit(targetId, channelShockWeb, 200, NEXT_DELAY_FRAMES.SHOCK_WEB) + expect(nd.canTakeDamage(targetId, channelShockWeb, 224)).toBe(false) + expect(nd.canTakeDamage(targetId, channelShockWeb, 225)).toBe(true) + }) + }) + + // ========================================================================= + // Scenario 6: Cross-Tree Hybrid Build Integration (Trapper / Kicksin / Shadow) + // ========================================================================= + describe('Scenario 6: Cross-Tree Hybrid Build Integration (Trapper / Kicksin / Shadow)', () => { + it('simulates a complete hybrid combat loop without state pollution', () => { + const buffs = new ShadowBuffTracker() + const traps = new TrapManager() + const shadows = new ShadowMinionManager() + const orbs = new ChargeOrbManager() + const ownerId = 'hybrid_assassin' + + // 1. Buff Burst of Speed + buffs.applyBurstOfSpeed(20) + expect(buffs.getBurstOfSpeed()?.params['frwPct']).toBe(61) + + // 2. Summon Shadow Master + shadows.summonShadow('master', 20, 100) + expect(shadows.getActiveShadow()?.stats.allResistPct).toBe(76) + + // 3. Lay 4 Lightning Sentries + 1 Death Sentry + traps.registerTrap({ ownerId, skillId: ASSASSIN_TRAP_SKILL_IDS.LIGHTNING_SENTRY, summonMon: 'ls', x: 0, y: 0, slvl: 20, shots: 10, currentFrame: 150 }) + traps.registerTrap({ ownerId, skillId: ASSASSIN_TRAP_SKILL_IDS.LIGHTNING_SENTRY, summonMon: 'ls', x: 2, y: 0, slvl: 20, shots: 10, currentFrame: 160 }) + traps.registerTrap({ ownerId, skillId: ASSASSIN_TRAP_SKILL_IDS.LIGHTNING_SENTRY, summonMon: 'ls', x: -2, y: 0, slvl: 20, shots: 10, currentFrame: 170 }) + traps.registerTrap({ ownerId, skillId: ASSASSIN_TRAP_SKILL_IDS.LIGHTNING_SENTRY, summonMon: 'ls', x: 0, y: 2, slvl: 20, shots: 10, currentFrame: 180 }) + traps.registerTrap({ ownerId, skillId: ASSASSIN_TRAP_SKILL_IDS.DEATH_SENTRY, summonMon: 'ds', x: 0, y: -2, slvl: 20, shots: 5, currentFrame: 190 }) + expect(traps.getActiveTrapCount(ownerId)).toBe(5) + + // 4. Engage in melee: build Tiger Strike charges + orbs.addCharge(ASSASSIN_MARTIAL_SKILL_IDS.TIGER_STRIKE, 20, 200) + orbs.addCharge(ASSASSIN_MARTIAL_SKILL_IDS.TIGER_STRIKE, 20, 220) + orbs.addCharge(ASSASSIN_MARTIAL_SKILL_IDS.TIGER_STRIKE, 20, 240) + expect(orbs.getChargeLevel(ASSASSIN_MARTIAL_SKILL_IDS.TIGER_STRIKE, 240)).toBe(3) + + // 5. Release via Dragon Talon multi-kick finisher + const talon = evaluateDragonTalonStats(20) + expect(talon.kickCount).toBe(4) // 1 + floor(20/6) = 4 kicks + const discharge = resolveFinisherDischarge(orbs.dischargeAll(250)) + expect(discharge[0]?.enhancedDamagePct).toBe(1440) + expect(orbs.getActiveCharges(250).length).toBe(0) + + // Everything coexists in pristine state + expect(traps.getActiveTrapCount(ownerId)).toBe(5) + expect(shadows.getActiveShadow()).not.toBeNull() + expect(buffs.getBurstOfSpeed()).not.toBeNull() + }) + }) + + // ========================================================================= + // Scenario 7: Crowd Control & Status Effects (Mind Blast, Psychic Hammer, CoS) + // ========================================================================= + describe('Scenario 7: Crowd Control & Status Effects (Mind Blast, Psychic Hammer, CoS)', () => { + it('verifies Mind Blast 4-yard AOE stun and normal monster conversion', () => { + // Normal monster hit by Mind Blast + const resNorm = resolveMindBlastTarget(20, { roll: 10 }) + expect(resNorm.stunApplied).toBe(true) + expect(resNorm.stunDurationFrames).toBe(145) // 50 + 5*(20-1) + expect(resNorm.converted).toBe(true) + + // Unique/Boss immune to conversion + const resBoss = resolveMindBlastTarget(20, { isBoss: true, roll: 0 }) + expect(resBoss.stunApplied).toBe(true) + expect(resBoss.stunDurationFrames).toBe(145) + expect(resBoss.converted).toBe(false) + }) + + it('verifies Psychic Hammer knockback and 50/50 magic/phys damage split', () => { + const dmg = computePsychicHammerDamage(20) + expect(dmg.minPhys).toBe(56) + expect(dmg.minMagic).toBe(56) + expect(dmg.maxPhys).toBe(79) + expect(dmg.maxMagic).toBe(79) + + expect(computePsychicHammerKnockbackChance(20, 'normal')).toBe(100) + expect(computePsychicHammerKnockbackChance(20, 'unique')).toBe(92) + expect(computePsychicHammerKnockbackChance(20, 'boss')).toBe(87) + }) + }) + + // ========================================================================= + // Scenario 8: Complete 30-Skill Assassin Tree Data Registry & Invariants + // ========================================================================= + describe('Scenario 8: Complete 30-Skill Assassin Tree Data Registry & Architectural Invariants', () => { + it('verifies all 30 Assassin skills (IDs 251..280) exist in 1.13c Data Registry', async () => { + const registry = await getSharedDataRegistry() + // Assassin skill IDs are 251..280 inclusive + for (let id = 251; id <= 280; id++) { + const skill = registry.getSkillById(id) + expect(skill, `Skill ${id} must exist in registry`).toBeDefined() + expect(skill?.charClass).toBe('ass') + expect(skill?.name).toBeDefined() + expect(skill?.name.length).toBeGreaterThan(0) + } + }) + + it('enforces BATCH1_SKILLS length invariant = strictly 38', () => { + expect(Object.keys(BATCH1_SKILLS).length).toBe(38) + }) + }) +})