Compare commits

...

2 Commits

Author SHA1 Message Date
troytt 10ef14b5b2 test(skills): persist M2 cold adversarial stress test suites on main 2026-09-24 02:44:18 +00:00
troytt 2ac10a38e3 feat(ui): organize left and right speedbar skill selection by skill tree rows (closes #402)
- Group speedbar skills into dedicated horizontal rows by 1.13c ListRow/SkillPage categories:
  - Row 0: Universal actions (Attack 0, Throw 2, Unsummon 3, Left Hand Throw 4)
  - Rows 1..3: Class skill tree tabs (Tab 1, Tab 2, Tab 3)
  - Row 4: Item and charged skills (Townportal, Identify, charged items)
- Compact unlearned or empty tree rows contiguously without blank spacing
- Enforce strict 1.13c left-click filtering (83 active leftskill=1 skills; 26 passives and all auras excluded)
- Orient left popup upward-rightward and right popup upward-leftward with 800x600 canvas clamping
- Sort intra-row skills by reqlevel ascending then skillId ascending
- Add comprehensive 7-class parity test suite covering grouping, compaction, filtering, and geometry
2026-09-24 02:44:09 +00:00
5 changed files with 2413 additions and 29 deletions

View File

@ -27,6 +27,10 @@ export interface HotkeySkillEntry {
readonly charges?: number
readonly maxCharges?: number
readonly cooldownMs?: number
readonly listRow?: number
readonly reqlevel?: number
readonly isItemSkill?: boolean
readonly tabIndex?: number
}
/**
@ -78,17 +82,26 @@ export function isPassiveSkill(skillId: number): boolean {
* Diablo II v1.13c Skills usable on the Left-Click action button (Skills.txt `leftskill = 1`).
*/
export const LEFT_USABLE_SKILL_IDS: ReadonlySet<number> = new Set<number>([
0, 2, 6, 7, 10, 11, 12, 14, 15, 16, 19, 20, 21, 22, 24, 25, 26, 27, 30, 31, 34, 35,
36, 38, 39, 41, 45, 47, 49, 53, 55, 56, 59, 64,
// Universal actions (Attack 0, Throw 2, Left Hand Throw 4, Left Hand Swing 5)
0, 2, 4, 5,
// Amazon (20 active skills with leftskill = 1)
6, 7, 10, 11, 12, 14, 15, 16, 19, 20, 21, 22, 24, 25, 26, 27, 30, 31, 34, 35,
// Sorceress (10 active skills with leftskill = 1)
36, 38, 39, 41, 45, 47, 49, 53, 55, 64,
// Necromancer (4 active skills with leftskill = 1)
67, 73, 84, 93,
// Paladin (9 active combat skills with leftskill = 1)
96, 97, 101, 106, 107, 111, 112, 116, 121,
// Barbarian (10 active combat skills with leftskill = 1)
126, 132, 133, 139, 140, 143, 144, 147, 151, 152,
225, 229, 230, 232, 238, 239, 240, 242, 243, 245, 248,
// Druid (12 active combat/elemental skills with leftskill = 1)
225, 229, 230, 232, 233, 238, 239, 240, 242, 243, 245, 248,
// Assassin (14 active martial/trap skills with leftskill = 1)
251, 254, 255, 256, 257, 259, 260, 265, 266, 269, 270, 274, 275, 280,
])
export function isLeftUsableSkill(skillId: number): boolean {
if (isPassiveSkill(skillId)) return false
if (isPassiveSkill(skillId) || isAuraSkill(skillId)) return false
return LEFT_USABLE_SKILL_IDS.has(skillId)
}
@ -182,6 +195,42 @@ export function resolveSkillIcon(skillId: number, hasWeapon = true): SkillIconMa
atlasRect: { x: 192, y: 0, width: 48, height: 48, w: 48, h: 48 },
}
}
if (skillId === 4) {
return {
skillId: 4,
name: 'Left Hand Throw',
iconPath: '/skills/icon_4.png',
isGeneric: true,
dc6Path: 'data/global/ui/SPELLS/Skillicon.DC6',
dc6Source: 'data/global/ui/SPELLS/Skillicon.DC6',
frameIndex: 12,
atlasRect: { x: 576, y: 0, width: 48, height: 48, w: 48, h: 48 },
}
}
if (skillId === 217 || skillId === 218) {
return {
skillId,
name: skillId === 217 ? 'Scroll of Identify' : 'Book of Identify',
iconPath: `/skills/icon_${skillId}.png`,
isGeneric: true,
dc6Path: 'data/global/ui/SPELLS/Skillicon.DC6',
dc6Source: 'data/global/ui/SPELLS/Skillicon.DC6',
frameIndex: 14,
atlasRect: { x: 672, y: 0, width: 48, height: 48, w: 48, h: 48 },
}
}
if (skillId === 219 || skillId === 220) {
return {
skillId,
name: skillId === 219 ? 'Scroll of Townportal' : 'Book of Townportal',
iconPath: `/skills/icon_${skillId}.png`,
isGeneric: true,
dc6Path: 'data/global/ui/SPELLS/Skillicon.DC6',
dc6Source: 'data/global/ui/SPELLS/Skillicon.DC6',
frameIndex: 18,
atlasRect: { x: 864, y: 0, width: 48, height: 48, w: 48, h: 48 },
}
}
return {
skillId,
name: `Skill ${skillId}`,
@ -194,24 +243,78 @@ export function resolveSkillIcon(skillId: number, hasWeapon = true): SkillIconMa
}
}
/**
* Resolves authentic 1.13c ListRow for Speedbar popup (0..4).
* Row 0 (bottom): Basic universal actions (Attack 0, Throw 2, Unsummon 3, etc.)
* Rows 1..3: Character skill tree tabs (Tab 1 -> ListRow 1, Tab 2 -> ListRow 2, Tab 3 -> ListRow 3)
* Row 4 (top): Item skills (Scrolls/Books of TP & ID, or items marked as item skills)
*/
export function getSkillListRow(skill: HotkeySkillEntry): number {
if (typeof (skill as any).listRow === 'number') {
return (skill as any).listRow
}
const id = skill.skillId
// 1. Item skills (Scrolls/Books of TP & ID, charged items, or marked as item skills)
if (id === 217 || id === 218 || id === 219 || id === 220 || (skill as any).isItemSkill || skill.charges !== undefined) {
return 4
}
// 2. Universal / basic actions
if (id === 0 || id === 1 || id === 2 || id === 3 || id === 4 || id === 5 || id === 350 || (skill as any).isGeneric) {
return 0
}
// 3. Class skill tree tabs (Tab 1 -> ListRow 1, Tab 2 -> ListRow 2, Tab 3 -> ListRow 3)
const catalogEntry = SKILLS_BY_ID[id]
if (catalogEntry) {
return catalogEntry.tabIndex + 1
}
if (typeof (skill as any).tabIndex === 'number') {
return (skill as any).tabIndex + 1
}
return 0
}
/**
* Resolves requirement level (reqlevel) for intra-row sorting in Speedbar popup.
*/
export function getSkillReqLevel(skill: HotkeySkillEntry): number {
if (typeof (skill as any).reqlevel === 'number') {
return (skill as any).reqlevel
}
const catalogEntry = SKILLS_BY_ID[skill.skillId]
if (catalogEntry && typeof catalogEntry.reqlevel === 'number') {
return catalogEntry.reqlevel
}
return 1
}
export function getAvailableSkillsForSide(
side: 'left' | 'right',
skills: readonly HotkeySkillEntry[] = DEFAULT_SORCERESS_SKILLS,
): readonly HotkeySkillEntry[] {
return skills.filter(s => {
if (isPassiveSkill(s.skillId) || s.isPassive) return false
return side === 'left' ? s.leftUsable : s.rightUsable
if (side === 'left') {
if (isAuraSkill(s.skillId) || s.isAura) return false
if (!isLeftUsableSkill(s.skillId)) return false
return s.leftUsable !== false
} else {
return s.rightUsable !== false
}
})
}
export const LEFT_SKILL_BOUNDS = { x: 117, y: 551, width: 48, height: 48 } as const
export const RIGHT_SKILL_BOUNDS = { x: 635, y: 551, width: 48, height: 48 } as const
export const DEFAULT_SORCERESS_SKILLS: readonly HotkeySkillEntry[] = [
// Universal Skills
export const DEFAULT_UNIVERSAL_SKILLS: readonly HotkeySkillEntry[] = [
{ skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
{ skillId: 2, name: 'Throw', nameZh: '投掷', level: 1, manaCost: 0, leftUsable: true, rightUsable: true },
{ skillId: 3, name: 'Unsummon', nameZh: '取消召唤', level: 1, manaCost: 0, leftUsable: false, rightUsable: true },
]
export const DEFAULT_SORCERESS_SKILLS: readonly HotkeySkillEntry[] = [
// Universal Skills
...DEFAULT_UNIVERSAL_SKILLS,
// Fire Spells (8 active)
{ skillId: 36, name: 'Fire Bolt', nameZh: '火弹', level: 5, manaCost: 2.5, leftUsable: true, rightUsable: true },
{ skillId: 41, name: 'Inferno', nameZh: '地狱之火', level: 1, manaCost: 0.6, leftUsable: true, rightUsable: true },
@ -219,7 +322,7 @@ export const DEFAULT_SORCERESS_SKILLS: readonly HotkeySkillEntry[] = [
{ skillId: 47, name: 'Fire Ball', nameZh: '火球', level: 8, manaCost: 8.5, leftUsable: true, rightUsable: true },
{ skillId: 51, name: 'Fire Wall', nameZh: '火墙', level: 1, manaCost: 22, leftUsable: false, rightUsable: true },
{ skillId: 52, name: 'Enchant', nameZh: '强化', level: 1, manaCost: 25, leftUsable: false, rightUsable: true },
{ skillId: 56, name: 'Meteor', nameZh: '陨石', level: 6, manaCost: 22, leftUsable: true, rightUsable: true },
{ skillId: 56, name: 'Meteor', nameZh: '陨石', level: 6, manaCost: 22, leftUsable: false, rightUsable: true },
{ skillId: 62, name: 'Hydra', nameZh: '九头海蛇', level: 1, manaCost: 20, leftUsable: false, rightUsable: true },
// Lightning Spells (9 active)
{ skillId: 38, name: 'Charged Bolt', nameZh: '充能弹', level: 4, manaCost: 4, leftUsable: true, rightUsable: true },
@ -238,7 +341,7 @@ export const DEFAULT_SORCERESS_SKILLS: readonly HotkeySkillEntry[] = [
{ skillId: 45, name: 'Ice Blast', nameZh: '冰风暴', level: 5, manaCost: 8, leftUsable: true, rightUsable: true },
{ skillId: 50, name: 'Shiver Armor', nameZh: '碎冰甲', level: 1, manaCost: 11, leftUsable: false, rightUsable: true },
{ skillId: 55, name: 'Glacial Spike', nameZh: '冰尖柱', level: 10, manaCost: 14.5, leftUsable: true, rightUsable: true },
{ skillId: 59, name: 'Blizzard', nameZh: '暴风雪', level: 10, manaCost: 27, leftUsable: true, rightUsable: true },
{ skillId: 59, name: 'Blizzard', nameZh: '暴风雪', level: 10, manaCost: 27, leftUsable: false, rightUsable: true },
{ skillId: 60, name: 'Chilling Armor', nameZh: '寒冰装甲', level: 1, manaCost: 17, leftUsable: false, rightUsable: true },
{ skillId: 64, name: 'Frozen Orb', nameZh: '冰封球', level: 12, manaCost: 30, leftUsable: true, rightUsable: true },
]
@ -377,8 +480,12 @@ export class SkillHotkeysHud {
): void {
// Passive skills strictly cannot enter the quickbar
this.availableSkills = skills.filter(s => !isPassiveSkill(s.skillId) && !s.isPassive)
if (defaultLeft !== undefined && !isPassiveSkill(defaultLeft)) this.leftSkillId = defaultLeft
if (defaultRight !== undefined && !isPassiveSkill(defaultRight)) this.rightSkillId = defaultRight
if (defaultLeft !== undefined && isLeftUsableSkill(defaultLeft) && !isAuraSkill(defaultLeft)) {
this.leftSkillId = defaultLeft
}
if (defaultRight !== undefined && !isPassiveSkill(defaultRight)) {
this.rightSkillId = defaultRight
}
this.preloadSkillIcons()
}
@ -428,23 +535,24 @@ export class SkillHotkeysHud {
assignSkill(side: 'left' | 'right', skillId: number): boolean {
if (isPassiveSkill(skillId)) return false
if (side === 'left') this.leftSkillId = skillId
else this.rightSkillId = skillId
if (side === 'left') {
if (isAuraSkill(skillId) || !isLeftUsableSkill(skillId)) return false
this.leftSkillId = skillId
} else {
this.rightSkillId = skillId
}
return true
}
setDualSkill(skillId: number): boolean {
if (isPassiveSkill(skillId)) return false
if (isPassiveSkill(skillId) || isAuraSkill(skillId) || !isLeftUsableSkill(skillId)) return false
this.leftSkillId = skillId
this.rightSkillId = skillId
return true
}
getAvailableSkillsForSide(side: 'left' | 'right'): readonly HotkeySkillEntry[] {
return this.availableSkills.filter(s => {
if (isPassiveSkill(s.skillId) || s.isPassive) return false
return side === 'left' ? s.leftUsable : s.rightUsable
})
return getAvailableSkillsForSide(side, this.availableSkills)
}
triggerFunctionKey(fKey: string): { side: 'left' | 'right'; skillId: number } | null {
@ -455,8 +563,15 @@ export class SkillHotkeysHud {
}
const bound = this.bindings.get(key)
if (!bound) return null
if (bound.side === 'left') this.leftSkillId = bound.skillId
else this.rightSkillId = bound.skillId
if (bound.side === 'left') {
if (isLeftUsableSkill(bound.skillId) && !isAuraSkill(bound.skillId)) {
this.leftSkillId = bound.skillId
}
} else {
if (!isPassiveSkill(bound.skillId)) {
this.rightSkillId = bound.skillId
}
}
return bound
}
@ -466,19 +581,74 @@ export class SkillHotkeysHud {
y: number
w: number
h: number
listRow: number
visualRow: number
col: number
}[] {
const list = this.getAvailableSkillsForSide(side)
const cols = 5
const cellW = 48
const cellH = 48
const base = side === 'left' ? LEFT_SKILL_BOUNDS : RIGHT_SKILL_BOUNDS
return list.map((skill, idx) => {
const col = idx % cols
const row = Math.floor(idx / cols)
const x = side === 'left' ? base.x + col * cellW : base.x - col * cellW
const y = base.y - (row + 1) * cellH - 6
return { skill, x, y, w: cellW, h: cellH }
// 1. Group available skills by authentic ListRow (0..4)
const rowMap = new Map<number, HotkeySkillEntry[]>()
for (const skill of list) {
const explicitCharges = this.getSkillCharges(skill.skillId)
const effectiveSkill = (explicitCharges !== undefined && skill.charges === undefined)
? { ...skill, charges: explicitCharges }
: skill
const row = getSkillListRow(effectiveSkill)
let group = rowMap.get(row)
if (!group) {
group = []
rowMap.set(row, group)
}
group.push(effectiveSkill)
}
// 2. Intra-row ordering: sort each row by reqlevel ascending, then skillId ascending
for (const group of rowMap.values()) {
group.sort((a, b) => {
const reqA = getSkillReqLevel(a)
const reqB = getSkillReqLevel(b)
if (reqA !== reqB) return reqA - reqB
return a.skillId - b.skillId
})
}
// 3. Compaction: only consider rows with >= 1 skill, ordered from bottom (Row 0) to top (Row 4)
// Row 0: Basic actions (bottom, closest to slot)
// Rows 1..3: Class skill tree tabs
// Row 4: Item skills (top)
// Unlearned or inactive rows are compacted without blank gaps
const populatedRows = Array.from(rowMap.keys())
.filter(r => (rowMap.get(r)?.length ?? 0) > 0)
.sort((a, b) => a - b)
const cells: {
skill: HotkeySkillEntry
x: number
y: number
w: number
h: number
listRow: number
visualRow: number
col: number
}[] = []
populatedRows.forEach((listRow, visualRow) => {
const skillsInRow = rowMap.get(listRow)!
skillsInRow.forEach((skill, col) => {
const rawX = side === 'left' ? base.x + col * cellW : base.x - col * cellW
const rawY = base.y - (visualRow + 1) * cellH - 6
// Clamp strictly within 800x600 viewport canvas bounds
const x = Math.max(0, Math.min(800 - cellW, rawX))
const y = Math.max(0, Math.min(600 - cellH, rawY))
cells.push({ skill, x, y, w: cellW, h: cellH, listRow, visualRow, col })
})
})
return cells
}
getSpeedbarSkills(side: 'left' | 'right'): {
@ -487,6 +657,9 @@ export class SkillHotkeysHud {
y: number
w: number
h: number
listRow: number
visualRow: number
col: number
}[] {
return this.getPopupCells(side)
}
@ -536,8 +709,15 @@ export class SkillHotkeysHud {
const cells = this.getPopupCells(this.openPopup)
for (const c of cells) {
if (logicalX >= c.x && logicalX <= c.x + c.w && logicalY >= c.y && logicalY <= c.y + c.h) {
if (this.openPopup === 'left') this.leftSkillId = c.skill.skillId
else this.rightSkillId = c.skill.skillId
if (this.openPopup === 'left') {
if (isLeftUsableSkill(c.skill.skillId) && !isAuraSkill(c.skill.skillId)) {
this.leftSkillId = c.skill.skillId
}
} else {
if (!isPassiveSkill(c.skill.skillId)) {
this.rightSkillId = c.skill.skillId
}
}
this.openPopup = null
return true
}
@ -664,6 +844,36 @@ export class SkillHotkeysHud {
ctx.fillStyle = side === 'left' ? '#5c2818' : '#182c5c'
ctx.fillRect(x + 2, y + 2, 44, 44)
}
} else if (skillId === 4) {
const icon = this.iconImages.get(4)
if (icon && icon.complete && icon.naturalWidth > 0) {
ctx.drawImage(icon, x, y, 48, 48)
} else if (genericSkillsImg && genericSkillsImg.complete && genericSkillsImg.naturalWidth > 0) {
ctx.drawImage(genericSkillsImg, 12 * 48, 0, 48, 48, x, y, 48, 48)
} else {
ctx.fillStyle = side === 'left' ? '#5c2818' : '#182c5c'
ctx.fillRect(x + 2, y + 2, 44, 44)
}
} else if (skillId === 217 || skillId === 218) {
const icon = this.iconImages.get(skillId)
if (icon && icon.complete && icon.naturalWidth > 0) {
ctx.drawImage(icon, x, y, 48, 48)
} else if (genericSkillsImg && genericSkillsImg.complete && genericSkillsImg.naturalWidth > 0) {
ctx.drawImage(genericSkillsImg, 14 * 48, 0, 48, 48, x, y, 48, 48)
} else {
ctx.fillStyle = side === 'left' ? '#5c2818' : '#182c5c'
ctx.fillRect(x + 2, y + 2, 44, 44)
}
} else if (skillId === 219 || skillId === 220) {
const icon = this.iconImages.get(skillId)
if (icon && icon.complete && icon.naturalWidth > 0) {
ctx.drawImage(icon, x, y, 48, 48)
} else if (genericSkillsImg && genericSkillsImg.complete && genericSkillsImg.naturalWidth > 0) {
ctx.drawImage(genericSkillsImg, 18 * 48, 0, 48, 48, x, y, 48, 48)
} else {
ctx.fillStyle = side === 'left' ? '#5c2818' : '#182c5c'
ctx.fillRect(x + 2, y + 2, 44, 44)
}
} else {
const icon = this.iconImages.get(skillId)
if (icon && icon.complete && icon.naturalWidth > 0) {
@ -841,6 +1051,128 @@ export class SkillHotkeysHud {
return lines
}
if (skillId === 4) {
lines.push({
text: '左手投掷 (Left Hand Throw)',
color: 'gold',
font: 'fontexocet10',
align: 'center',
})
const boundKey = this.getHotkeyLabelForSkill(side, skillId)
if (isSpeedbar) {
lines.push({
text: boundKey ? `当前快捷键: ${boundKey} (按 F1-F8 键重新绑定)` : '按 F1-F8 键设置快捷键',
color: 'tan',
font: 'font8',
align: 'center',
})
} else if (boundKey) {
lines.push({
text: `快捷键: ${boundKey}`,
color: 'tan',
font: 'font8',
align: 'center',
})
}
lines.push({
text: '使用副手投掷当前装备的投掷类武器。',
color: 'white',
font: 'font8',
align: 'center',
})
lines.push({
text: '法力消耗: 0',
color: 'white',
font: 'font8',
align: 'center',
})
return lines
}
if (skillId === 217 || skillId === 218) {
const isBook = skillId === 218
lines.push({
text: isBook ? '辨识之书 (Tome of Identify)' : '辨识卷轴 (Scroll of Identify)',
color: 'gold',
font: 'fontexocet10',
align: 'center',
})
const boundKey = this.getHotkeyLabelForSkill(side, skillId)
if (isSpeedbar) {
lines.push({
text: boundKey ? `当前快捷键: ${boundKey} (按 F1-F8 键重新绑定)` : '按 F1-F8 键设置快捷键',
color: 'tan',
font: 'font8',
align: 'center',
})
} else if (boundKey) {
lines.push({
text: `快捷键: ${boundKey}`,
color: 'tan',
font: 'font8',
align: 'center',
})
}
lines.push({
text: '使用后辨识未鉴定的魔法、稀有或暗金物品。',
color: 'white',
font: 'font8',
align: 'center',
})
const charges = this.getSkillCharges(skillId)
if (charges !== undefined) {
lines.push({
text: `数量: ${charges}`,
color: charges > 0 ? 'white' : 'red',
font: 'font8',
align: 'center',
})
}
return lines
}
if (skillId === 219 || skillId === 220) {
const isBook = skillId === 220
lines.push({
text: isBook ? '城镇传送之书 (Tome of Town Portal)' : '城镇传送卷轴 (Scroll of Town Portal)',
color: 'gold',
font: 'fontexocet10',
align: 'center',
})
const boundKey = this.getHotkeyLabelForSkill(side, skillId)
if (isSpeedbar) {
lines.push({
text: boundKey ? `当前快捷键: ${boundKey} (按 F1-F8 键重新绑定)` : '按 F1-F8 键设置快捷键',
color: 'tan',
font: 'font8',
align: 'center',
})
} else if (boundKey) {
lines.push({
text: `快捷键: ${boundKey}`,
color: 'tan',
font: 'font8',
align: 'center',
})
}
lines.push({
text: '打开一个通往最近城镇的安全蓝色传送门。',
color: 'white',
font: 'font8',
align: 'center',
})
const charges = this.getSkillCharges(skillId)
if (charges !== undefined) {
lines.push({
text: `数量: ${charges}`,
color: charges > 0 ? 'white' : 'red',
font: 'font8',
align: 'center',
})
}
return lines
}
// 2. Class Skills
const vm = this.tooltipResolver ? this.tooltipResolver(skillId) : null
const entry = this.availableSkills.find(s => s.skillId === skillId)

View File

@ -233,6 +233,13 @@ describe('SkillHotkeysHud & Dual Slot Mechanics', () => {
hud.draw(ctx, mockAtlas, mockFont, true)
const unsummonDraw = drawImageCalls.find((c) => c[0] === mockAtlas && c[1] === 192)
expect(unsummonDraw).toBeDefined()
// 5. Left Hand Throw (skill 4): should draw left hand throw (sx = 576, frame 12)
hud.leftSkillId = 4
drawImageCalls.length = 0
hud.draw(ctx, mockAtlas, mockFont, true)
const leftHandThrowDraw = drawImageCalls.find((c) => c[0] === mockAtlas && c[1] === 576)
expect(leftHandThrowDraw).toBeDefined()
})
it('allows binding Throw to left/right slot and Unsummon to right slot only (Issue #376)', () => {

View File

@ -0,0 +1,819 @@
/**
* Adversarial Stress & Boundary Testing Suite — Milestone 2: Cold Projectiles & Cold Mastery
* Commit: 375f329
*
* Focus Areas:
* 1. Glacial Spike 64px (4 subtiles) Radial Burst Boundary Verification:
* - Distance boundary sweeps: 0px, 30px, 63px, 64px, 64.1px, 65px, 80px, 128px.
* - Strict inequality validation: units at <= 64px receive full cold damage & solid freeze;
* units at > 64px receive 0 splash damage and 0 freeze.
* - Multi-directional radial boundary test across 8 compass axes (0°, 45°, 90°, ..., 315°).
* 2. Obstacle & Wall Collision Detonation:
* - Collision against terrain obstacle (COLLIDE_WALL).
* - Immediate detonation at impact coordinates (hitWallX, hitWallY).
* - Submissile ejection verification: 'glacialspikeejecta' and explosion missile spawned.
* - 64px AoE burst applied to adjacent units from the wall impact point.
* 3. Freeze Application & Immunity:
* - Cannot Be Frozen (cannot_be_frozen > 0): receives cold damage, but freeze state is skipped.
* - Cold Immunity (coldresist >= 100): 0 damage, freeze state is skipped, immune flag set.
* - Standard targets: receive cold damage and 'freeze' state.
* 4. Cold Mastery Resistance Sweeps (-100% to +200% against slvl 1 to 50):
* - Systematic sweep of base cold resistance across [-100%, +200%] vs slvl 1..50.
* - Natural immunity rule: Cold Mastery alone NEVER pierces cold immune targets (res >= 100%).
* - Broken immunity rule: when Conviction/Lower Resist reduces res < 100%, Cold Mastery applies at full power.
* - Strict -100% floor clamp: effective resistance never drops below -100% (max 2.0x multiplier).
* - Differential fuzzing against brute-force mathematical oracle (1000 randomized iterations).
*/
import { describe, expect, it } from 'vitest'
import { getSharedDataRegistry } from '../../../src/game/engine/data-registry.ts'
import { UnitStatList } from '../../../src/game/engine/stat-list.ts'
import { StateBus } from '../../../src/game/engine/state-bus.ts'
import {
MissileEngine,
calculateGlacialSpikeDamage,
calculateGlacialSpikeFreezeLength,
calculateColdMasteryPierce,
} from '../../../src/game/engine/missile-engine.ts'
import {
computeEffectiveResistance,
executeSUnitDmg,
type CombatUnitContext,
} from '../../../src/game/engine/combat-pipeline.ts'
import { COLLIDE_WALL } from '../../../src/game/d2map.ts'
function createMockCombatUnit(
id: string,
options?: {
hp?: number
coldResist?: number
cannotBeFrozen?: boolean
coldMasterySlvl?: number
allSkills?: number
},
): CombatUnitContext {
const statList = new UnitStatList()
const hp = options?.hp ?? 500
statList.setBaseStat('hp', hp * 256)
statList.setBaseStat('maxhp', hp * 256)
statList.setBaseStat('hitpoints', hp * 256)
if (options?.coldResist !== undefined) {
statList.setBaseStat('coldresist', options.coldResist)
}
if (options?.cannotBeFrozen) {
statList.setBaseStat('cannot_be_frozen', 1)
}
if (options?.coldMasterySlvl !== undefined && options.coldMasterySlvl > 0) {
const pierce = calculateColdMasteryPierce(options.coldMasterySlvl)
statList.setBaseStat('passive_cold_pierce', pierce)
}
if (options?.allSkills) {
statList.setAllSkillsBonus(options.allSkills)
}
const stateBus = new StateBus(statList)
return {
id,
name: id,
statList,
stateBus,
}
}
describe('Adversarial Stress Suite — Glacial Spike AoE & Cold Mastery (Commit 375f329)', () => {
// =========================================================================
// Requirement 1: Glacial Spike 64px Radial Burst Verification
// =========================================================================
describe('Requirement 1: Glacial Spike 64px Radial Burst Boundary Verification', () => {
it('verifies exact distance boundaries: 0px, 30px, 63px, 64px receive hit/freeze; 64.1px, 65px, 80px, 128px receive 0 hit/freeze', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const attacker = createMockCombatUnit('sorceress')
// Direct target at (0, 0)
const directTarget = createMockCombatUnit('target_0px')
// Distances to test
const testDistances = [
{ label: 'target_30px', dist: 30, shouldHit: true },
{ label: 'target_63px', dist: 63, shouldHit: true },
{ label: 'target_64px', dist: 64, shouldHit: true },
{ label: 'target_64_1px', dist: 64.1, shouldHit: false },
{ label: 'target_65px', dist: 65, shouldHit: false },
{ label: 'target_80px', dist: 80, shouldHit: false },
{ label: 'target_128px', dist: 128, shouldHit: false },
]
const splashTargets = testDistances.map(td => ({
...td,
unit: createMockCombatUnit(td.label),
}))
const allTargets = [directTarget, ...splashTargets.map(st => st.unit)]
// Target positions relative to impact point (0, 0)
const targetPositions = new Map<string, { x: number; y: number }>()
targetPositions.set('target_0px', { x: 0, y: 0 })
for (const st of splashTargets) {
targetPositions.set(st.label, { x: st.dist, y: 0 })
}
// Spawn missile flying from (-24, 0) straight towards (0, 0)
engine.spawnMissile({
missileNameOrId: 'glacialspike',
sourceSkillId: 55,
slvl: 10,
owner: attacker,
startX: -24,
startY: 0,
targetX: 0,
targetY: 0,
dmgPacket: {
skillId: 55,
elemType: 'cold',
elemMin256: 100 * 256,
elemMax256: 100 * 256,
elemDurationFrames: 77,
autoHit: true,
},
vx: 24,
vy: 0,
maxRangeTicks: 40,
})
// Tick engine to resolve collision with direct target at (0, 0)
const outcome = engine.tick(1, allTargets, targetPositions)
// Direct hit on target_0px
const directHit = outcome.hits.find(h => h.targetId === 'target_0px')
expect(directHit).toBeDefined()
expect(directHit!.damage).toBeGreaterThan(0)
expect(directTarget.stateBus.hasState('freeze')).toBe(true)
// Verify each splash target
for (const st of splashTargets) {
const hit = outcome.hits.find(h => h.targetId === st.label)
if (st.shouldHit) {
expect(hit, `Target at ${st.dist}px should receive splash hit`).toBeDefined()
expect(hit!.missileName).toContain('splash')
expect(hit!.damage, `Target at ${st.dist}px should receive cold damage`).toBeGreaterThan(0)
expect(
st.unit.stateBus.hasState('freeze'),
`Target at ${st.dist}px should have solid freeze state`,
).toBe(true)
} else {
expect(hit, `Target at ${st.dist}px should NOT receive splash hit`).toBeUndefined()
expect(
st.unit.stateBus.hasState('freeze'),
`Target at ${st.dist}px should NOT have solid freeze state`,
).toBe(false)
}
}
})
it('verifies 360-degree radial symmetry across 8 directional axes at r=63.9px (hit) vs r=64.1px (miss)', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const attacker = createMockCombatUnit('sorceress')
// Direct target at (100, 100)
const center = { x: 100, y: 100 }
const directTarget = createMockCombatUnit('center_target')
const angles = [
{ name: 'E', rad: 0 },
{ name: 'NE', rad: Math.PI / 4 },
{ name: 'N', rad: Math.PI / 2 },
{ name: 'NW', rad: (3 * Math.PI) / 4 },
{ name: 'W', rad: Math.PI },
{ name: 'SW', rad: (5 * Math.PI) / 4 },
{ name: 'S', rad: (3 * Math.PI) / 2 },
{ name: 'SE', rad: (7 * Math.PI) / 4 },
]
const insideUnits: CombatUnitContext[] = []
const outsideUnits: CombatUnitContext[] = []
const targetPositions = new Map<string, { x: number; y: number }>()
targetPositions.set('center_target', center)
for (const dir of angles) {
// Inside boundary: r = 63.9 px
const inId = `inside_${dir.name}`
const inUnit = createMockCombatUnit(inId)
insideUnits.push(inUnit)
targetPositions.set(inId, {
x: center.x + 63.9 * Math.cos(dir.rad),
y: center.y + 63.9 * Math.sin(dir.rad),
})
// Outside boundary: r = 64.1 px
const outId = `outside_${dir.name}`
const outUnit = createMockCombatUnit(outId)
outsideUnits.push(outUnit)
targetPositions.set(outId, {
x: center.x + 64.1 * Math.cos(dir.rad),
y: center.y + 64.1 * Math.sin(dir.rad),
})
}
const allTargets = [directTarget, ...insideUnits, ...outsideUnits]
engine.spawnMissile({
missileNameOrId: 'glacialspike',
sourceSkillId: 55,
slvl: 10,
owner: attacker,
startX: center.x - 24,
startY: center.y,
targetX: center.x,
targetY: center.y,
dmgPacket: {
skillId: 55,
elemType: 'cold',
elemMin256: 100 * 256,
elemMax256: 100 * 256,
elemDurationFrames: 77,
autoHit: true,
},
vx: 24,
vy: 0,
maxRangeTicks: 40,
})
const outcome = engine.tick(1, allTargets, targetPositions)
// Direct hit passes
expect(outcome.hits.find(h => h.targetId === 'center_target')).toBeDefined()
// All 8 inside units receive splash hit and freeze
for (const inUnit of insideUnits) {
const hit = outcome.hits.find(h => h.targetId === inUnit.id)
expect(hit, `${inUnit.id} (r=63.9px) must be hit`).toBeDefined()
expect(hit!.damage).toBeGreaterThan(0)
expect(inUnit.stateBus.hasState('freeze'), `${inUnit.id} must be frozen`).toBe(true)
}
// All 8 outside units receive 0 hit and no freeze
for (const outUnit of outsideUnits) {
const hit = outcome.hits.find(h => h.targetId === outUnit.id)
expect(hit, `${outUnit.id} (r=64.1px) must NOT be hit`).toBeUndefined()
expect(outUnit.stateBus.hasState('freeze'), `${outUnit.id} must NOT be frozen`).toBe(false)
}
})
it('handles high-density multi-target clustering inside and outside 64px radius', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const attacker = createMockCombatUnit('sorceress')
const center = { x: 50, y: 50 }
const directTarget = createMockCombatUnit('cluster_direct')
const targetPositions = new Map<string, { x: number; y: number }>()
targetPositions.set('cluster_direct', center)
// Generate 20 units inside (radius 10..60px) and 20 units outside (radius 70..150px)
const units: CombatUnitContext[] = [directTarget]
const insideIds = new Set<string>()
const outsideIds = new Set<string>()
for (let i = 0; i < 20; i++) {
const id = `dense_in_${i}`
const radius = 10 + (i / 19) * 53 // 10 to 63 px (strictly inside 64px boundary)
const theta = (i * 2.4)
targetPositions.set(id, {
x: center.x + radius * Math.cos(theta),
y: center.y + radius * Math.sin(theta),
})
units.push(createMockCombatUnit(id))
insideIds.add(id)
}
for (let i = 0; i < 20; i++) {
const id = `dense_out_${i}`
const radius = 64.5 + i * 4 // 64.5 to 140.5 px
const theta = (i * 1.7)
targetPositions.set(id, {
x: center.x + radius * Math.cos(theta),
y: center.y + radius * Math.sin(theta),
})
units.push(createMockCombatUnit(id))
outsideIds.add(id)
}
engine.spawnMissile({
missileNameOrId: 'glacialspike',
sourceSkillId: 55,
slvl: 20,
owner: attacker,
startX: center.x - 20,
startY: center.y,
targetX: center.x,
targetY: center.y,
dmgPacket: {
skillId: 55,
elemType: 'cold',
elemMin256: 200 * 256,
elemMax256: 200 * 256,
elemDurationFrames: 107,
autoHit: true,
},
vx: 20,
vy: 0,
maxRangeTicks: 40,
})
const outcome = engine.tick(1, units, targetPositions)
// Direct target hit
expect(outcome.hits.find(h => h.targetId === 'cluster_direct')).toBeDefined()
// Exactly all 20 inside units hit
for (const id of insideIds) {
const hit = outcome.hits.find(h => h.targetId === id)
expect(hit, `Inside cluster unit ${id} should be hit`).toBeDefined()
const unit = units.find(u => u.id === id)!
expect(unit.stateBus.hasState('freeze')).toBe(true)
}
// Exactly all 20 outside units NOT hit
for (const id of outsideIds) {
const hit = outcome.hits.find(h => h.targetId === id)
expect(hit, `Outside cluster unit ${id} should NOT be hit`).toBeUndefined()
const unit = units.find(u => u.id === id)!
expect(unit.stateBus.hasState('freeze')).toBe(false)
}
})
})
// =========================================================================
// Requirement 2: Obstacle & Wall Collision Detonation
// =========================================================================
describe('Requirement 2: Obstacle & Wall Collision Detonation', () => {
it('detonates immediately upon hitting a COLLIDE_WALL tile, spawning submissiles and bursting 64px', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const attacker = createMockCombatUnit('sorceress')
// Wall at x >= 50
// Collision map using overlap returning COLLIDE_WALL (0x0001)
const wallTerrain = {
overlap: (x: number, _y: number) => (x >= 50 ? COLLIDE_WALL : 0),
}
// Adjacent monster placed along the wall at (50, 35):
// - Far enough from flight path y=0 that it avoids direct missile body collision (dist > hitRadius 28px)
// - Distance from wall impact point (50, 0) is 35px <= 64px (hit by splash explosion)
const nearMonster = createMockCombatUnit('near_wall_monster')
// Distant monster at (50, 100): distance from wall impact point (50, 0) is 100px > 64px
const farMonster = createMockCombatUnit('far_wall_monster')
const targets = [nearMonster, farMonster]
const targetPositions = new Map<string, { x: number; y: number }>([
['near_wall_monster', { x: 50, y: 35 }],
['far_wall_monster', { x: 50, y: 100 }],
])
// Spawn projectile starting at (0, 0) moving with vx = 24 towards wall
engine.spawnMissile({
missileNameOrId: 'glacialspike',
sourceSkillId: 55,
slvl: 10,
owner: attacker,
startX: 0,
startY: 0,
targetX: 100,
targetY: 0,
dmgPacket: {
skillId: 55,
elemType: 'cold',
elemMin256: 91 * 256,
elemMax256: 103 * 256,
elemDurationFrames: 77,
autoHit: true,
},
vx: 24,
vy: 0,
maxRangeTicks: 40,
})
// Tick 1: moves from 0 to 24 (not blocked)
const tick1 = engine.tick(1, targets, targetPositions, wallTerrain)
expect(tick1.hits.length).toBe(0)
expect(tick1.subMissilesSpawned.length).toBe(0)
// Tick 2: moves from 24 to 48 (not blocked)
const tick2 = engine.tick(1, targets, targetPositions, wallTerrain)
expect(tick2.hits.length).toBe(0)
expect(tick2.subMissilesSpawned.length).toBe(0)
// Tick 3: moves from 48 to 72. Crosses wall at x=50.
// Continuous collision stepping stops at wall impact point (50, 0)!
const tick3 = engine.tick(1, targets, targetPositions, wallTerrain)
// 1. Immediate AoE burst hit on nearMonster
expect(tick3.hits.length).toBe(1)
expect(tick3.hits[0]!.targetId).toBe('near_wall_monster')
expect(tick3.hits[0]!.missileName).toContain('splash')
expect(tick3.hits[0]!.damage).toBeGreaterThan(0)
expect(nearMonster.stateBus.hasState('freeze')).toBe(true)
// Far monster unaffected
expect(farMonster.stateBus.hasState('freeze')).toBe(false)
// 2. Submissile ejection: 'glacialspikeejecta' and explosion missile
expect(tick3.subMissilesSpawned).toContain('glacialspikeejecta')
expect(tick3.subMissilesSpawned).toContain('freezeexplode')
// 3. Projectile expires upon impact
const activeSpikes = engine.getActiveMissiles().filter(m => m.name === 'glacialspike')
expect(activeSpikes.length).toBe(0)
})
it('verifies continuous stepping prevents tunneling through thin (<24px) walls and detonates correctly', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const attacker = createMockCombatUnit('sorceress')
// Thin wall only 8px wide between x=32 and x=40
const thinWallTerrain = {
overlap: (x: number, _y: number) => (x >= 32 && x <= 40 ? COLLIDE_WALL : 0),
}
// Unit stationed right next to the thin wall at (35, 15)
const adjacentUnit = createMockCombatUnit('thin_wall_unit')
const targetPositions = new Map<string, { x: number; y: number }>([
['thin_wall_unit', { x: 35, y: 15 }],
])
// Projectile moves 24px per tick, starting at x=20.
// Next step would land at x=44 (beyond the thin wall), but stepping catches x=32!
engine.spawnMissile({
missileNameOrId: 'glacialspike',
sourceSkillId: 55,
slvl: 10,
owner: attacker,
startX: 20,
startY: 0,
targetX: 100,
targetY: 0,
dmgPacket: {
skillId: 55,
elemType: 'cold',
elemMin256: 100 * 256,
elemMax256: 100 * 256,
elemDurationFrames: 77,
autoHit: true,
},
vx: 24,
vy: 0,
maxRangeTicks: 40,
})
const outcome = engine.tick(1, [adjacentUnit], targetPositions, thinWallTerrain)
expect(outcome.hits.length).toBe(1)
expect(outcome.hits[0]!.targetId).toBe('thin_wall_unit')
expect(adjacentUnit.stateBus.hasState('freeze')).toBe(true)
expect(outcome.subMissilesSpawned).toContain('glacialspikeejecta')
})
})
// =========================================================================
// Requirement 3: Freeze Application & Immunity
// =========================================================================
describe('Requirement 3: Freeze Application & Immunity Verification', () => {
it('damages targets with cannot_be_frozen but strictly skips solid freeze state on direct and splash hits', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const attacker = createMockCombatUnit('sorceress')
// Direct target with cannot_be_frozen = 1
const cbfDirect = createMockCombatUnit('cbf_direct', { cannotBeFrozen: true })
// Splash target with cannot_be_frozen = 1
const cbfSplash = createMockCombatUnit('cbf_splash', { cannotBeFrozen: true })
// Normal splash target
const normalSplash = createMockCombatUnit('normal_splash', { cannotBeFrozen: false })
const targets = [cbfDirect, cbfSplash, normalSplash]
const targetPositions = new Map<string, { x: number; y: number }>([
['cbf_direct', { x: 0, y: 0 }],
['cbf_splash', { x: 25, y: 0 }],
['normal_splash', { x: 0, y: 25 }],
])
engine.spawnMissile({
missileNameOrId: 'glacialspike',
sourceSkillId: 55,
slvl: 20,
owner: attacker,
startX: -20,
startY: 0,
targetX: 0,
targetY: 0,
dmgPacket: {
skillId: 55,
elemType: 'cold',
elemMin256: 225 * 256,
elemMax256: 242 * 256,
elemDurationFrames: 107,
autoHit: true,
},
vx: 20,
vy: 0,
maxRangeTicks: 40,
})
const outcome = engine.tick(1, targets, targetPositions)
// Direct target receives damage, but NO freeze state
const directHit = outcome.hits.find(h => h.targetId === 'cbf_direct')
expect(directHit).toBeDefined()
expect(directHit!.damage).toBeGreaterThan(0)
expect(cbfDirect.stateBus.hasState('freeze')).toBe(false)
// Splash target with CBF receives splash damage, but NO freeze state
const splashHit = outcome.hits.find(h => h.targetId === 'cbf_splash')
expect(splashHit).toBeDefined()
expect(splashHit!.damage).toBeGreaterThan(0)
expect(cbfSplash.stateBus.hasState('freeze')).toBe(false)
// Normal splash target receives splash damage AND solid freeze state
const normalHit = outcome.hits.find(h => h.targetId === 'normal_splash')
expect(normalHit).toBeDefined()
expect(normalHit!.damage).toBeGreaterThan(0)
expect(normalSplash.stateBus.hasState('freeze')).toBe(true)
})
it('verifies cold immune target (res >= 100%) receives 0 damage, immune flag true, and NO freeze state', async () => {
const registry = await getSharedDataRegistry()
const engine = new MissileEngine(registry)
const attacker = createMockCombatUnit('sorceress')
// Direct target with 120% cold resistance (cold immune)
const immuneDirect = createMockCombatUnit('immune_direct', { coldResist: 120 })
// Splash target with 150% cold resistance (cold immune)
const immuneSplash = createMockCombatUnit('immune_splash', { coldResist: 150 })
// Splash target with 0% cold resistance (normal)
const normalSplash = createMockCombatUnit('normal_splash', { coldResist: 0 })
const targets = [immuneDirect, immuneSplash, normalSplash]
const targetPositions = new Map<string, { x: number; y: number }>([
['immune_direct', { x: 0, y: 0 }],
['immune_splash', { x: 20, y: 0 }],
['normal_splash', { x: 0, y: 20 }],
])
engine.spawnMissile({
missileNameOrId: 'glacialspike',
sourceSkillId: 55,
slvl: 20,
owner: attacker,
startX: -20,
startY: 0,
targetX: 0,
targetY: 0,
dmgPacket: {
skillId: 55,
elemType: 'cold',
elemMin256: 225 * 256,
elemMax256: 242 * 256,
elemDurationFrames: 107,
autoHit: true,
},
vx: 20,
vy: 0,
maxRangeTicks: 40,
})
const outcome = engine.tick(1, targets, targetPositions)
// Direct immune target
const directHit = outcome.hits.find(h => h.targetId === 'immune_direct')
expect(directHit).toBeDefined()
expect(directHit!.immune).toBe(true)
expect(directHit!.damage).toBe(0)
expect(immuneDirect.stateBus.hasState('freeze')).toBe(false)
// Splash immune target
const splashHit = outcome.hits.find(h => h.targetId === 'immune_splash')
expect(splashHit).toBeDefined()
expect(splashHit!.immune).toBe(true)
expect(splashHit!.damage).toBe(0)
expect(immuneSplash.stateBus.hasState('freeze')).toBe(false)
// Normal splash target receives full damage and solid freeze
const normalHit = outcome.hits.find(h => h.targetId === 'normal_splash')
expect(normalHit).toBeDefined()
expect(normalHit!.immune).toBe(false)
expect(normalHit!.damage).toBeGreaterThan(0)
expect(normalSplash.stateBus.hasState('freeze')).toBe(true)
})
})
// =========================================================================
// Requirement 4: Cold Mastery Resistance Sweeps (-100% to +200% vs slvl 1..50)
// =========================================================================
describe('Requirement 4: Cold Mastery Resistance Sweeps & Combat Calculations', () => {
it('verifies Cold Mastery linear formula across slvl 1 to 50: pierce = 20 + (slvl - 1) * 5', () => {
for (let slvl = 1; slvl <= 50; slvl++) {
const expected = 20 + (slvl - 1) * 5
expect(calculateColdMasteryPierce(slvl)).toBe(expected)
}
expect(calculateColdMasteryPierce(0)).toBe(0)
expect(calculateColdMasteryPierce(-1)).toBe(0)
})
it('adversarial sweep: verifies natural cold immune (res >= 100%) is NEVER pierced or reduced by Cold Mastery alone', () => {
// Test cold resistance from 100% to 200% across slvl 1 to 50
const testLevels = [1, 5, 10, 15, 20, 25, 30, 40, 50]
for (let baseRes = 100; baseRes <= 200; baseRes += 5) {
for (const slvl of testLevels) {
const pierce = calculateColdMasteryPierce(slvl)
const result = computeEffectiveResistance({
baseRes,
passiveEnemyPierce: pierce,
})
// Invariant: Cold Mastery alone cannot break immunity
expect(result.isImmune, `baseRes ${baseRes}% with slvl ${slvl} CM must remain immune`).toBe(true)
expect(result.effectiveRes, `baseRes ${baseRes}% with slvl ${slvl} CM must have effectiveRes 100`).toBe(100)
expect(result.convictionAndLRApplied).toBe(0)
}
}
})
it('adversarial sweep: verifies strict -100% effective resistance floor clamp for all non-immune resistances', () => {
// Sweep base resistance from -100% to 99% against slvl 1 to 50
const testLevels = [1, 5, 10, 15, 20, 25, 30, 40, 50]
for (let baseRes = -100; baseRes < 100; baseRes += 10) {
for (const slvl of testLevels) {
const pierce = calculateColdMasteryPierce(slvl)
const result = computeEffectiveResistance({
baseRes,
passiveEnemyPierce: pierce,
})
expect(result.isImmune).toBe(false)
const expected = Math.max(-100, Math.min(99, baseRes - pierce))
expect(result.effectiveRes, `baseRes ${baseRes} with pierce ${pierce}`).toBe(expected)
expect(result.effectiveRes, 'Effective resistance must never drop below -100%').toBeGreaterThanOrEqual(-100)
}
}
})
it('adversarial sweep: verifies broken immunity allows Cold Mastery to apply at full 100% effect without 1/5 penalty', () => {
// Test cases where Conviction breaks immunity (baseRes in 100..116, Conviction 85 -> 85/5 = 17 -> res becomes 83..99)
const convictionPierce = 85 // standard slvl 12 Conviction
const testLevels = [1, 5, 10, 20, 30, 50]
for (let baseRes = 100; baseRes <= 116; baseRes++) {
for (const slvl of testLevels) {
const cmPierce = calculateColdMasteryPierce(slvl)
const result = computeEffectiveResistance({
baseRes,
convictionPierce,
passiveEnemyPierce: cmPierce,
})
// 1. Conviction applied at 1/5th to immune: 85 / 5 = 17
expect(result.convictionAndLRApplied).toBe(17)
const resAfterConv = baseRes - 17
expect(result.resAfterConvictionLR).toBe(resAfterConv)
// 2. Since resAfterConv < 100, target is NO LONGER immune
expect(result.isImmune).toBe(false)
// 3. Cold Mastery applies at full effect without 1/5 penalty, clamped to -100%
const expectedEffective = Math.max(-100, Math.min(99, resAfterConv - cmPierce))
expect(result.effectiveRes).toBe(expectedEffective)
}
}
// Test case where Conviction is NOT enough to break immunity (e.g. baseRes = 120, 120 - 17 = 103 >= 100)
for (const slvl of testLevels) {
const cmPierce = calculateColdMasteryPierce(slvl)
const result = computeEffectiveResistance({
baseRes: 120,
convictionPierce: 85,
passiveEnemyPierce: cmPierce,
})
// Unbroken immunity: target stays immune, effectiveRes stays 100, CM pierce is 0
expect(result.isImmune).toBe(true)
expect(result.effectiveRes).toBe(100)
}
})
it('differential fuzzing: 1000 randomized iterations comparing engine against mathematical oracle', () => {
// Independent Mathematical Oracle
function oracleEffectiveResistance(
baseRes: number,
conviction: number,
lowerResist: number,
passivePierce: number,
): { isImmune: boolean; effectiveRes: number } {
const rawConvLR = Math.max(0, Math.trunc(conviction + lowerResist))
let convLRApplied = 0
if (baseRes >= 100) {
convLRApplied = Math.trunc(rawConvLR / 5)
} else {
convLRApplied = rawConvLR
}
const resAfter = baseRes - convLRApplied
if (resAfter >= 100) {
return { isImmune: true, effectiveRes: 100 }
}
const eff = Math.max(-100, Math.min(99, resAfter - Math.max(0, Math.trunc(passivePierce))))
return { isImmune: false, effectiveRes: eff }
}
// Seeded deterministic pseudo-random number generator for differential fuzzing
let seed = 0x1337c0de
function nextRand(): number {
seed = (seed * 1664525 + 1013904223) >>> 0
return seed / 4294967296
}
for (let iter = 1; iter <= 1000; iter++) {
// Random base resistance in [-100, 200]
const baseRes = Math.floor(nextRand() * 301) - 100
// Random Cold Mastery slvl in [0, 50]
const slvl = Math.floor(nextRand() * 51)
const cmPierce = calculateColdMasteryPierce(slvl)
// Random Conviction in [0, 150]
const conviction = Math.floor(nextRand() * 151)
// Random Lower Resist in [0, 75]
const lowerResist = Math.floor(nextRand() * 76)
const actual = computeEffectiveResistance({
baseRes,
convictionPierce: conviction,
lowerResistPierce: lowerResist,
passiveEnemyPierce: cmPierce,
})
const expected = oracleEffectiveResistance(baseRes, conviction, lowerResist, cmPierce)
expect(actual.isImmune, `Iter ${iter}: isImmune mismatch for baseRes=${baseRes}, slvl=${slvl}`).toBe(
expected.isImmune,
)
expect(actual.effectiveRes, `Iter ${iter}: effectiveRes mismatch for baseRes=${baseRes}, slvl=${slvl}`).toBe(
expected.effectiveRes,
)
}
})
it('SUnitDmg pipeline: validates exact damage multiplier clamping at 2.0x (effectiveRes = -100%)', () => {
const attacker = createMockCombatUnit('sorceress_cm', { coldMasterySlvl: 20 }) // 115% pierce
// Target A: 0% cold res -> with 115% pierce -> effectiveRes = -100% (takes 2.0x damage)
const targetA = createMockCombatUnit('target_0res', { coldResist: 0 })
const outA = executeSUnitDmg(attacker, targetA, {
skillId: 55,
attackKind: 'spell',
elemType: 'cold',
elemMin256: 100 * 256,
elemMax256: 100 * 256,
autoHit: true,
})
expect(outA.totalDamage).toBe(200) // 100 * (100 - (-100)) / 100 = 200
// Target B: -50% cold res with slvl 50 CM (265% pierce) -> clamped to -100% -> STILL takes 2.0x damage
const superAttacker = createMockCombatUnit('sorceress_super_cm', { coldMasterySlvl: 50 })
const targetB = createMockCombatUnit('target_neg_res', { coldResist: -50 })
const outB = executeSUnitDmg(superAttacker, targetB, {
skillId: 55,
attackKind: 'spell',
elemType: 'cold',
elemMin256: 100 * 256,
elemMax256: 100 * 256,
autoHit: true,
})
expect(outB.totalDamage, 'Damage multiplier must strictly clamp at 2.0x (200 damage)').toBe(200)
// Target C: 50% cold res -> with 115% pierce -> effectiveRes = -65% -> takes 1.65x damage
const targetC = createMockCombatUnit('target_50res', { coldResist: 50 })
const outC = executeSUnitDmg(attacker, targetC, {
skillId: 55,
attackKind: 'spell',
elemType: 'cold',
elemMin256: 100 * 256,
elemMax256: 100 * 256,
autoHit: true,
})
expect(outC.totalDamage).toBe(165) // 100 * (100 - (-65)) / 100 = 165
})
})
})

View File

@ -0,0 +1,646 @@
/**
* Adversarial Stress-Testing & Boundary Verification Suite
* Milestone 2: Cold Projectiles & Cold Mastery (Ice Blast #199, Glacial Spike #209, Cold Mastery #219)
*
* Verification Categories:
* 1. Monotonicity & Continuity Sweeps (slvl 1 to 99 across all 5 level progression bands)
* 2. Strict Freeze Duration Monotonicity (slvl 1 to 99)
* 3. Linear Synergy Stress Testing (0 to 60 synergy points, overrides & object structures)
* 4. Extreme Boundaries & Robustness (slvl <= 0, fractional, NaN, negative, huge values > 1000)
* 5. Cold Mastery Resistance Pipeline & Floor Clamping (-100% cap, immunity bypass, broken immunities)
* 6. Differential Oracle Testing (Naive band summation vs closed-form implementations)
* 7. System Integration & Invariant Enforcement (BATCH1_SKILLS length, mana cost curves, 0 cooldown)
*/
import { describe, expect, it } from 'vitest'
import {
calculateIceBlastDamage,
calculateIceBlastFreezeLength,
calculateGlacialSpikeDamage,
calculateGlacialSpikeFreezeLength,
calculateColdMasteryPierce,
} from '../../../src/game/engine/missile-engine.ts'
import {
calculateSkillDamage,
getSkillManaCost,
getSkillCooldownTicks,
BATCH1_SKILLS,
} from '../../../src/game/skills.ts'
import { computeEffectiveResistance } from '../../../src/game/engine/combat-pipeline.ts'
describe('Adversarial Stress Test: Cold Projectiles & Mastery Milestone 2', () => {
// =========================================================================
// 1. Monotonicity & Continuity Sweeps (slvl 1 to 99 across 5 Progression Bands)
// =========================================================================
describe('1. Monotonicity & Continuity Sweeps (slvl 1 to 99)', () => {
it('Ice Blast: min <= max and strict positive monotonicity across all 99 levels', () => {
let prevDamage = calculateIceBlastDamage(1)
expect(prevDamage.min).toBe(8)
expect(prevDamage.max).toBe(12)
expect(prevDamage.min).toBeLessThanOrEqual(prevDamage.max)
for (let slvl = 2; slvl <= 99; slvl++) {
const current = calculateIceBlastDamage(slvl)
// 1. Min damage must never exceed max damage
expect(current.min).toBeLessThanOrEqual(current.max)
// 2. Strict monotonicity: every single level up MUST increase damage
expect(current.min).toBeGreaterThan(prevDamage.min)
expect(current.max).toBeGreaterThan(prevDamage.max)
prevDamage = current
}
})
it('Ice Blast: progression band slope verification across all 5 bands', () => {
// Band 1: slvl 1-8 -> +7.0 min, +7.5 max per level
for (let slvl = 1; slvl <= 7; slvl++) {
const dCurrent = calculateIceBlastDamage(slvl)
const dNext = calculateIceBlastDamage(slvl + 1)
expect(dNext.min - dCurrent.min).toBeCloseTo(7.0, 5)
expect(dNext.max - dCurrent.max).toBeCloseTo(7.5, 5)
}
// Band 2: slvl 8-16 -> +14.0 min, +14.5 max per level
for (let slvl = 8; slvl <= 15; slvl++) {
const dCurrent = calculateIceBlastDamage(slvl)
const dNext = calculateIceBlastDamage(slvl + 1)
expect(dNext.min - dCurrent.min).toBeCloseTo(14.0, 5)
expect(dNext.max - dCurrent.max).toBeCloseTo(14.5, 5)
}
// Band 3: slvl 16-22 -> +21.0 min, +21.5 max per level
for (let slvl = 16; slvl <= 21; slvl++) {
const dCurrent = calculateIceBlastDamage(slvl)
const dNext = calculateIceBlastDamage(slvl + 1)
expect(dNext.min - dCurrent.min).toBeCloseTo(21.0, 5)
expect(dNext.max - dCurrent.max).toBeCloseTo(21.5, 5)
}
// Band 4: slvl 22-28 -> +28.0 min, +28.5 max per level
for (let slvl = 22; slvl <= 27; slvl++) {
const dCurrent = calculateIceBlastDamage(slvl)
const dNext = calculateIceBlastDamage(slvl + 1)
expect(dNext.min - dCurrent.min).toBeCloseTo(28.0, 5)
expect(dNext.max - dCurrent.max).toBeCloseTo(28.5, 5)
}
// Band 5: slvl 28-99 -> +35.0 min, +35.5 max per level
for (let slvl = 28; slvl <= 98; slvl++) {
const dCurrent = calculateIceBlastDamage(slvl)
const dNext = calculateIceBlastDamage(slvl + 1)
expect(dNext.min - dCurrent.min).toBeCloseTo(35.0, 5)
expect(dNext.max - dCurrent.max).toBeCloseTo(35.5, 5)
}
})
it('Glacial Spike: min <= max and strict positive monotonicity across all 99 levels', () => {
let prevDamage = calculateGlacialSpikeDamage(1)
expect(prevDamage.min).toBe(16)
expect(prevDamage.max).toBe(24)
expect(prevDamage.min).toBeLessThanOrEqual(prevDamage.max)
for (let slvl = 2; slvl <= 99; slvl++) {
const current = calculateGlacialSpikeDamage(slvl)
// 1. Min damage must never exceed max damage
expect(current.min).toBeLessThanOrEqual(current.max)
// 2. Strict monotonicity
expect(current.min).toBeGreaterThan(prevDamage.min)
expect(current.max).toBeGreaterThan(prevDamage.max)
prevDamage = current
}
})
it('Glacial Spike: progression band slope verification across all 5 bands', () => {
// Band 1: slvl 1-8 -> +7.0 min, +7.5 max per level
for (let slvl = 1; slvl <= 7; slvl++) {
const dCurrent = calculateGlacialSpikeDamage(slvl)
const dNext = calculateGlacialSpikeDamage(slvl + 1)
expect(dNext.min - dCurrent.min).toBeCloseTo(7.0, 5)
expect(dNext.max - dCurrent.max).toBeCloseTo(7.5, 5)
}
// Band 2: slvl 8-16 -> +13.0 min, +13.5 max per level
for (let slvl = 8; slvl <= 15; slvl++) {
const dCurrent = calculateGlacialSpikeDamage(slvl)
const dNext = calculateGlacialSpikeDamage(slvl + 1)
expect(dNext.min - dCurrent.min).toBeCloseTo(13.0, 5)
expect(dNext.max - dCurrent.max).toBeCloseTo(13.5, 5)
}
// Band 3: slvl 16-22 -> +14.0 min, +14.5 max per level
for (let slvl = 16; slvl <= 21; slvl++) {
const dCurrent = calculateGlacialSpikeDamage(slvl)
const dNext = calculateGlacialSpikeDamage(slvl + 1)
expect(dNext.min - dCurrent.min).toBeCloseTo(14.0, 5)
expect(dNext.max - dCurrent.max).toBeCloseTo(14.5, 5)
}
// Band 4: slvl 22-28 -> +15.0 min, +15.5 max per level
for (let slvl = 22; slvl <= 27; slvl++) {
const dCurrent = calculateGlacialSpikeDamage(slvl)
const dNext = calculateGlacialSpikeDamage(slvl + 1)
expect(dNext.min - dCurrent.min).toBeCloseTo(15.0, 5)
expect(dNext.max - dCurrent.max).toBeCloseTo(15.5, 5)
}
// Band 5: slvl 28-99 -> +16.0 min, +16.5 max per level
for (let slvl = 28; slvl <= 98; slvl++) {
const dCurrent = calculateGlacialSpikeDamage(slvl)
const dNext = calculateGlacialSpikeDamage(slvl + 1)
expect(dNext.min - dCurrent.min).toBeCloseTo(16.0, 5)
expect(dNext.max - dCurrent.max).toBeCloseTo(16.5, 5)
}
})
it('Cold Mastery: strict positive monotonicity across all 99 levels (+5% per level)', () => {
let prevPierce = calculateColdMasteryPierce(1)
expect(prevPierce).toBe(20)
for (let slvl = 2; slvl <= 99; slvl++) {
const currentPierce = calculateColdMasteryPierce(slvl)
expect(currentPierce).toBeGreaterThan(prevPierce)
expect(currentPierce - prevPierce).toBe(5)
prevPierce = currentPierce
}
expect(calculateColdMasteryPierce(1)).toBe(20)
expect(calculateColdMasteryPierce(10)).toBe(65)
expect(calculateColdMasteryPierce(20)).toBe(115)
expect(calculateColdMasteryPierce(30)).toBe(165)
expect(calculateColdMasteryPierce(99)).toBe(510)
})
})
// =========================================================================
// 2. Strict Freeze Duration Monotonicity (slvl 1 to 99)
// =========================================================================
describe('2. Strict Freeze Duration Monotonicity', () => {
it('Ice Blast: freeze length increases strictly by +5 frames per level across slvl 1 to 99', () => {
let prevLen = calculateIceBlastFreezeLength(1)
expect(prevLen).toBe(75) // 3.0s
for (let slvl = 2; slvl <= 99; slvl++) {
const currentLen = calculateIceBlastFreezeLength(slvl)
expect(currentLen).toBeGreaterThan(prevLen)
expect(currentLen - prevLen).toBe(5)
prevLen = currentLen
}
expect(calculateIceBlastFreezeLength(1)).toBe(75)
expect(calculateIceBlastFreezeLength(10)).toBe(120)
expect(calculateIceBlastFreezeLength(20)).toBe(170)
expect(calculateIceBlastFreezeLength(99)).toBe(75 + 98 * 5)
})
it('Ice Blast: freeze length strictly monotonic with non-zero synergy across slvl 1 to 99', () => {
for (const syn of [1, 10, 20, 60]) {
let prevLen = calculateIceBlastFreezeLength(1, syn)
for (let slvl = 2; slvl <= 99; slvl++) {
const currentLen = calculateIceBlastFreezeLength(slvl, syn)
expect(currentLen).toBeGreaterThan(prevLen)
prevLen = currentLen
}
}
})
it('Glacial Spike: freeze length increases strictly by +3 frames per level across slvl 1 to 99', () => {
let prevLen = calculateGlacialSpikeFreezeLength(1)
expect(prevLen).toBe(50) // 2.0s
for (let slvl = 2; slvl <= 99; slvl++) {
const currentLen = calculateGlacialSpikeFreezeLength(slvl)
expect(currentLen).toBeGreaterThan(prevLen)
expect(currentLen - prevLen).toBe(3)
prevLen = currentLen
}
expect(calculateGlacialSpikeFreezeLength(1)).toBe(50)
expect(calculateGlacialSpikeFreezeLength(10)).toBe(77)
expect(calculateGlacialSpikeFreezeLength(20)).toBe(107)
expect(calculateGlacialSpikeFreezeLength(99)).toBe(50 + 98 * 3)
})
it('Glacial Spike: freeze length strictly monotonic with non-zero synergy across slvl 1 to 99', () => {
for (const syn of [1, 10, 20, 60]) {
let prevLen = calculateGlacialSpikeFreezeLength(1, syn)
for (let slvl = 2; slvl <= 99; slvl++) {
const currentLen = calculateGlacialSpikeFreezeLength(slvl, syn)
expect(currentLen).toBeGreaterThan(prevLen)
prevLen = currentLen
}
}
})
})
// =========================================================================
// 3. Synergy Stress Testing (0 to 60 Points, Linearity & Objects)
// =========================================================================
describe('3. Synergy Stress Testing', () => {
it('Ice Blast: 0 to 60 synergy points scaling (+8% per point) is strictly linear and positive', () => {
const baseDmg = calculateIceBlastDamage(20, 0)
expect(baseDmg).toEqual({ min: 253, max: 266.5 })
for (let syn = 0; syn <= 60; syn++) {
const current = calculateIceBlastDamage(20, syn)
const expectedMultiplier = 1.0 + syn * 0.08
const expectedMin = Math.floor((253 * expectedMultiplier + 1e-9) * 10) / 10
const expectedMax = Math.floor((266.5 * expectedMultiplier + 1e-9) * 10) / 10
expect(current.min).toBeCloseTo(expectedMin, 1)
expect(current.max).toBeCloseTo(expectedMax, 1)
expect(current.min).toBeLessThanOrEqual(current.max)
}
// Check key milestones
expect(calculateIceBlastDamage(20, 0)).toEqual({ min: 253, max: 266.5 })
expect(calculateIceBlastDamage(20, 20)).toEqual({ min: 657.8, max: 692.9 }) // (1 + 1.6 = 2.6x)
expect(calculateIceBlastDamage(20, 60)).toEqual({ min: 1467.4, max: 1545.7 }) // (1 + 4.8 = 5.8x)
})
it('Ice Blast: multi-skill synergy object decomposition parity', () => {
// 4 synergy skills: Ice Bolt, Blizzard, Frozen Orb, Glacial Spike
const combined = calculateIceBlastDamage(20, {
iceBolt: 15,
blizzard: 15,
frozenOrb: 15,
glacialSpike: 15,
})
// 15 * 4 = 60 total points -> exactly identical to passing numeric 60
const numeric = calculateIceBlastDamage(20, 60)
expect(combined).toEqual(numeric)
// Verify each individual synergy contributes identically (symmetric +8%)
const viaIceBolt = calculateIceBlastDamage(20, { iceBolt: 20 })
const viaBlizzard = calculateIceBlastDamage(20, { blizzard: 20 })
const viaFrozenOrb = calculateIceBlastDamage(20, { frozenOrb: 20 })
const viaGlacialSpike = calculateIceBlastDamage(20, { glacialSpike: 20 })
expect(viaIceBolt).toEqual(viaBlizzard)
expect(viaBlizzard).toEqual(viaFrozenOrb)
expect(viaFrozenOrb).toEqual(viaGlacialSpike)
})
it('Ice Blast: synergy override options (synPctPerLvl and synMultiplier)', () => {
// Custom % per level override
const customPct = calculateIceBlastDamage(20, 10, { synPctPerLvl: 12 })
// 10 * 12% = +120% -> 2.2x
expect(customPct.min).toBeCloseTo(253 * 2.2, 1)
expect(customPct.max).toBeCloseTo(266.5 * 2.2, 1)
// Direct multiplier override
const directMult = calculateIceBlastDamage(20, undefined, { synMultiplier: 3.5 })
expect(directMult.min).toBeCloseTo(253 * 3.5, 1)
expect(directMult.max).toBeCloseTo(266.5 * 3.5, 1)
})
it('Glacial Spike: 0 to 60 synergy points scaling (+5% per point) is strictly linear and positive', () => {
const baseDmg = calculateGlacialSpikeDamage(20, 0)
expect(baseDmg).toEqual({ min: 225, max: 242.5 })
for (let syn = 0; syn <= 60; syn++) {
const current = calculateGlacialSpikeDamage(20, syn)
const expectedMultiplier = 1.0 + syn * 0.05
const expectedMin = Math.floor((225 * expectedMultiplier + 1e-9) * 10) / 10
const expectedMax = Math.floor((242.5 * expectedMultiplier + 1e-9) * 10) / 10
expect(current.min).toBeCloseTo(expectedMin, 1)
expect(current.max).toBeCloseTo(expectedMax, 1)
expect(current.min).toBeLessThanOrEqual(current.max)
}
// Check key milestones
expect(calculateGlacialSpikeDamage(20, 0)).toEqual({ min: 225, max: 242.5 })
expect(calculateGlacialSpikeDamage(20, 20)).toEqual({ min: 450, max: 485 }) // (1 + 1.0 = 2.0x)
expect(calculateGlacialSpikeDamage(20, 60)).toEqual({ min: 900, max: 970 }) // (1 + 3.0 = 4.0x)
})
it('Glacial Spike: multi-skill synergy object decomposition parity', () => {
// 4 synergy skills: Ice Bolt, Ice Blast, Blizzard, Frozen Orb
const combined = calculateGlacialSpikeDamage(20, {
iceBolt: 15,
iceBlast: 15,
blizzard: 15,
frozenOrb: 15,
})
// 15 * 4 = 60 total points -> exactly identical to passing numeric 60
const numeric = calculateGlacialSpikeDamage(20, 60)
expect(combined).toEqual(numeric)
// Verify each individual synergy contributes identically (symmetric +5%)
const viaIceBolt = calculateGlacialSpikeDamage(20, { iceBolt: 20 })
const viaIceBlast = calculateGlacialSpikeDamage(20, { iceBlast: 20 })
const viaBlizzard = calculateGlacialSpikeDamage(20, { blizzard: 20 })
const viaFrozenOrb = calculateGlacialSpikeDamage(20, { frozenOrb: 20 })
expect(viaIceBolt).toEqual(viaIceBlast)
expect(viaIceBlast).toEqual(viaBlizzard)
expect(viaBlizzard).toEqual(viaFrozenOrb)
})
it('Glacial Spike: freeze length synergy scaling with Blizzard (+3% per point)', () => {
const baseLen = calculateGlacialSpikeFreezeLength(20, 0)
expect(baseLen).toBe(107)
for (let syn = 0; syn <= 60; syn++) {
const len = calculateGlacialSpikeFreezeLength(20, syn)
const expected = Math.floor((107 * (100 + syn * 3)) / 100)
expect(len).toBe(expected)
}
// Object format parity
expect(calculateGlacialSpikeFreezeLength(20, { blizzard: 20 })).toBe(
calculateGlacialSpikeFreezeLength(20, 20),
)
})
})
// =========================================================================
// 4. Extreme Boundaries & Robustness Testing
// =========================================================================
describe('4. Extreme Boundaries & Robustness', () => {
it('handles slvl <= 0 gracefully without exceptions or negative damage', () => {
for (const invalidLvl of [0, -1, -50, -9999]) {
const ibDmg = calculateIceBlastDamage(invalidLvl)
expect(ibDmg).toEqual({ min: 8, max: 12 }) // Clamps to lvl 1 base
const ibFreeze = calculateIceBlastFreezeLength(invalidLvl)
expect(ibFreeze).toBe(75) // Clamps to lvl 1 base
const gsDmg = calculateGlacialSpikeDamage(invalidLvl)
expect(gsDmg).toEqual({ min: 16, max: 24 }) // Clamps to lvl 1 base
const gsFreeze = calculateGlacialSpikeFreezeLength(invalidLvl)
expect(gsFreeze).toBe(50) // Clamps to lvl 1 base
const cmPierce = calculateColdMasteryPierce(invalidLvl)
expect(cmPierce).toBe(0) // Passive returns 0 when inactive/unlearned
// Mana costs for invalid levels clamp to level 1
expect(getSkillManaCost(45, invalidLvl)).toBe(6.0)
expect(getSkillManaCost(55, invalidLvl)).toBe(10.0)
expect(getSkillManaCost(65, invalidLvl)).toBe(0)
}
})
it('handles NaN, Infinity, -Infinity, null, and undefined safely', () => {
for (const nonFinite of [NaN, Infinity, -Infinity, undefined as any, null as any]) {
const ibDmg = calculateIceBlastDamage(nonFinite)
expect(Number.isFinite(ibDmg.min)).toBe(true)
expect(Number.isFinite(ibDmg.max)).toBe(true)
expect(ibDmg).toEqual({ min: 8, max: 12 })
const ibFreeze = calculateIceBlastFreezeLength(nonFinite)
expect(Number.isFinite(ibFreeze)).toBe(true)
expect(ibFreeze).toBe(75)
const gsDmg = calculateGlacialSpikeDamage(nonFinite)
expect(Number.isFinite(gsDmg.min)).toBe(true)
expect(Number.isFinite(gsDmg.max)).toBe(true)
expect(gsDmg).toEqual({ min: 16, max: 24 })
const gsFreeze = calculateGlacialSpikeFreezeLength(nonFinite)
expect(Number.isFinite(gsFreeze)).toBe(true)
expect(gsFreeze).toBe(50)
const cmPierce = calculateColdMasteryPierce(nonFinite)
expect(cmPierce).toBe(0)
expect(getSkillManaCost(45, nonFinite)).toBe(6.0)
expect(getSkillManaCost(55, nonFinite)).toBe(10.0)
expect(getSkillManaCost(65, nonFinite)).toBe(0)
}
})
it('handles fractional slvl values by rounding down consistently (Math.floor)', () => {
// 5.2 and 5.9 both evaluate to slvl 5
expect(calculateIceBlastDamage(5.2)).toEqual(calculateIceBlastDamage(5))
expect(calculateIceBlastDamage(5.9)).toEqual(calculateIceBlastDamage(5))
expect(calculateIceBlastFreezeLength(5.2)).toBe(calculateIceBlastFreezeLength(5))
expect(calculateIceBlastFreezeLength(5.9)).toBe(calculateIceBlastFreezeLength(5))
expect(calculateGlacialSpikeDamage(5.2)).toEqual(calculateGlacialSpikeDamage(5))
expect(calculateGlacialSpikeDamage(5.9)).toEqual(calculateGlacialSpikeDamage(5))
expect(calculateGlacialSpikeFreezeLength(5.2)).toBe(calculateGlacialSpikeFreezeLength(5))
expect(calculateGlacialSpikeFreezeLength(5.9)).toBe(calculateGlacialSpikeFreezeLength(5))
expect(calculateColdMasteryPierce(5.2)).toBe(calculateColdMasteryPierce(5))
expect(calculateColdMasteryPierce(5.9)).toBe(calculateColdMasteryPierce(5))
})
it('handles very large values (> 1000) stably without overflow or precision collapse', () => {
const largeLvl = 1000
const ibDmg = calculateIceBlastDamage(largeLvl)
expect(Number.isFinite(ibDmg.min)).toBe(true)
expect(Number.isFinite(ibDmg.max)).toBe(true)
expect(ibDmg.min).toBeLessThanOrEqual(ibDmg.max)
expect(ibDmg.min).toBeGreaterThan(10000)
const gsDmg = calculateGlacialSpikeDamage(largeLvl)
expect(Number.isFinite(gsDmg.min)).toBe(true)
expect(Number.isFinite(gsDmg.max)).toBe(true)
expect(gsDmg.min).toBeLessThanOrEqual(gsDmg.max)
expect(gsDmg.min).toBeGreaterThan(10000)
const cmPierce = calculateColdMasteryPierce(largeLvl)
expect(cmPierce).toBe(20 + 999 * 5) // 5015% pierce
})
it('handles adversarial negative synergy values and clamped multiplier overrides', () => {
// Negative synergy numbers clamped to 0
expect(calculateIceBlastDamage(20, -50)).toEqual(calculateIceBlastDamage(20, 0))
expect(calculateGlacialSpikeDamage(20, -50)).toEqual(calculateGlacialSpikeDamage(20, 0))
expect(calculateIceBlastFreezeLength(20, -50)).toBe(calculateIceBlastFreezeLength(20, 0))
expect(calculateGlacialSpikeFreezeLength(20, -50)).toBe(calculateGlacialSpikeFreezeLength(20, 0))
// Negative synergy object members clamped to 0
expect(
calculateIceBlastDamage(20, { iceBolt: -10, blizzard: -20, frozenOrb: -5 } as any),
).toEqual(calculateIceBlastDamage(20, 0))
expect(
calculateGlacialSpikeDamage(20, { iceBolt: -10, blizzard: -20, frozenOrb: -5 } as any),
).toEqual(calculateGlacialSpikeDamage(20, 0))
// Multiplier overrides: negative or zero multiplier clamped to >= 0
expect(calculateIceBlastDamage(20, undefined, { synMultiplier: -2 })).toEqual({ min: 0, max: 0 })
expect(calculateIceBlastDamage(20, undefined, { synMultiplier: 0 })).toEqual({ min: 0, max: 0 })
expect(calculateGlacialSpikeDamage(20, undefined, { synMultiplier: -2 })).toEqual({ min: 0, max: 0 })
expect(calculateGlacialSpikeDamage(20, undefined, { synMultiplier: 0 })).toEqual({ min: 0, max: 0 })
})
})
// =========================================================================
// 5. Cold Mastery Combat Pipeline Adversarial Scenarios
// =========================================================================
describe('5. Cold Mastery Combat Pipeline Adversarial Scenarios', () => {
it('authentically clamps effective resistance at -100% floor for vulnerable enemies', () => {
// Slvl 20 Cold Mastery: -115% pierce
const cm20 = calculateColdMasteryPierce(20)
expect(cm20).toBe(115)
// Case 1: Enemy has 0% Cold Res -> 0 - 115 = -115%, but clamped at -100%
const res0 = computeEffectiveResistance({ baseRes: 0, passiveEnemyPierce: cm20 })
expect(res0.effectiveRes).toBe(-100)
expect(res0.isImmune).toBe(false)
// Case 2: Enemy has -50% Cold Res -> -50 - 115 = -165%, clamped at -100%
const resNeg50 = computeEffectiveResistance({ baseRes: -50, passiveEnemyPierce: cm20 })
expect(resNeg50.effectiveRes).toBe(-100)
// Case 3: Enemy has 50% Cold Res -> 50 - 115 = -65% (within [-100, 99])
const res50 = computeEffectiveResistance({ baseRes: 50, passiveEnemyPierce: cm20 })
expect(res50.effectiveRes).toBe(-65)
expect(res50.isImmune).toBe(false)
})
it('Cold Mastery alone CANNOT pierce Cold Immune monsters (res >= 100%)', () => {
const cm20 = calculateColdMasteryPierce(20) // 115% pierce
const cm99 = calculateColdMasteryPierce(99) // 510% pierce
// Target with 100% Cold Res (immune)
const res100 = computeEffectiveResistance({ baseRes: 100, passiveEnemyPierce: cm20 })
expect(res100.isImmune).toBe(true)
expect(res100.effectiveRes).toBe(100)
// Target with 140% Cold Res (immune) with slvl 99 Cold Mastery
const res140 = computeEffectiveResistance({ baseRes: 140, passiveEnemyPierce: cm99 })
expect(res140.isImmune).toBe(true)
expect(res140.effectiveRes).toBe(100)
})
it('Broken Cold Immunities receive 100% Cold Mastery pierce without 1/5 penalty', () => {
// Target with 110% Cold Res
// Conviction Level 25: -150% pierce
// When baseRes >= 100%, Conviction applies at 1/5th effectiveness -> -30%
// New resistance = 110 - 30 = 80% (< 100% -> immunity broken!)
// Once broken, Cold Mastery (slvl 20 = -115%) applies at full 100% effectiveness:
// Final effective resistance = 80 - 115 = -35%
const cm20 = calculateColdMasteryPierce(20)
const brokenResult = computeEffectiveResistance({
baseRes: 110,
convictionPierce: 150,
passiveEnemyPierce: cm20,
})
expect(brokenResult.isImmune).toBe(false)
expect(brokenResult.convictionAndLRApplied).toBe(30)
expect(brokenResult.resAfterConvictionLR).toBe(80)
expect(brokenResult.effectiveRes).toBe(-35)
})
it('Unbreakable Cold Immunities stay immune and bypass Cold Mastery entirely', () => {
// Target with 140% Cold Res
// Conviction Level 25 (-150% -> 1/5 = -30%)
// Res after Conviction = 140 - 30 = 110% (still >= 100% -> immune remains unbroken!)
// Cold Mastery must NOT apply!
const cm20 = calculateColdMasteryPierce(20)
const unbrokenResult = computeEffectiveResistance({
baseRes: 140,
convictionPierce: 150,
passiveEnemyPierce: cm20,
})
expect(unbrokenResult.isImmune).toBe(true)
expect(unbrokenResult.convictionAndLRApplied).toBe(30)
expect(unbrokenResult.resAfterConvictionLR).toBe(110)
expect(unbrokenResult.effectiveRes).toBe(100)
})
})
// =========================================================================
// 6. Differential Oracle Testing (Naive Band Summation vs Implementation)
// =========================================================================
describe('6. Differential Oracle Testing', () => {
// Naive brute-force simulator for 1.13c 5-band table evaluation
function naiveBandEvaluator(
baseMin: number,
baseMax: number,
minSteps: number[],
maxSteps: number[],
slvl: number,
): { min: number; max: number } {
const lvl = Math.max(1, Math.floor(slvl))
let curMin = baseMin
let curMax = baseMax
for (let l = 2; l <= lvl; l++) {
let stepIdx = 0
if (l >= 2 && l <= 8) stepIdx = 0
else if (l >= 9 && l <= 16) stepIdx = 1
else if (l >= 17 && l <= 22) stepIdx = 2
else if (l >= 23 && l <= 28) stepIdx = 3
else stepIdx = 4
curMin += minSteps[stepIdx]
curMax += maxSteps[stepIdx]
}
return { min: curMin / 2, max: curMax / 2 }
}
it('Ice Blast: matches naive brute-force 5-band oracle across all 99 levels', () => {
const minSteps = [14, 28, 42, 56, 70]
const maxSteps = [15, 29, 43, 57, 71]
for (let slvl = 1; slvl <= 99; slvl++) {
const oracle = naiveBandEvaluator(16, 24, minSteps, maxSteps, slvl)
const actual = calculateIceBlastDamage(slvl)
expect(actual.min).toBe(oracle.min)
expect(actual.max).toBe(oracle.max)
}
})
it('Glacial Spike: matches naive brute-force 5-band oracle across all 99 levels', () => {
const minSteps = [14, 26, 28, 30, 32]
const maxSteps = [15, 27, 29, 31, 33]
for (let slvl = 1; slvl <= 99; slvl++) {
const oracle = naiveBandEvaluator(32, 48, minSteps, maxSteps, slvl)
const actual = calculateGlacialSpikeDamage(slvl)
expect(actual.min).toBe(oracle.min)
expect(actual.max).toBe(oracle.max)
}
})
})
// =========================================================================
// 7. System Integration & Invariant Enforcement
// =========================================================================
describe('7. System Integration & Invariant Enforcement', () => {
it('BATCH1_SKILLS length remains strictly 38', () => {
expect(Object.keys(BATCH1_SKILLS).length).toBe(38)
})
it('calculateSkillDamage dispatcher cleanly routes skills 45, 55, and 65', () => {
expect(calculateSkillDamage(45, 10, { iceBolt: 5 })).toEqual(
calculateIceBlastDamage(10, { iceBolt: 5 }),
)
expect(calculateSkillDamage('iceblast', 10, 5)).toEqual(
calculateIceBlastDamage(10, 5),
)
expect(calculateSkillDamage(55, 10, { blizzard: 5 })).toEqual(
calculateGlacialSpikeDamage(10, { blizzard: 5 }),
)
expect(calculateSkillDamage('glacialspike', 10, 5)).toEqual(
calculateGlacialSpikeDamage(10, 5),
)
// Cold Mastery is passive, returns { min: 0, max: 0 }
expect(calculateSkillDamage(65, 20)).toEqual({ min: 0, max: 0 })
expect(calculateSkillDamage('coldmastery', 20)).toEqual({ min: 0, max: 0 })
})
it('mana costs and cooldown ticks adhere strictly to 1.13c specification', () => {
// Ice Blast: (12 + slvl - 1) * 0.5
expect(getSkillManaCost(45, 1)).toBe(6.0)
expect(getSkillManaCost(45, 10)).toBe(10.5)
expect(getSkillManaCost(45, 20)).toBe(15.5)
// Glacial Spike: (20 + slvl - 1) * 0.5
expect(getSkillManaCost(55, 1)).toBe(10.0)
expect(getSkillManaCost(55, 10)).toBe(14.5)
expect(getSkillManaCost(55, 20)).toBe(19.5)
// Cold Mastery: passive 0 mana
expect(getSkillManaCost(65, 1)).toBe(0)
expect(getSkillManaCost(65, 20)).toBe(0)
// 0 cooldown ticks
expect(getSkillCooldownTicks(45)).toBe(0)
expect(getSkillCooldownTicks(55)).toBe(0)
expect(getSkillCooldownTicks(65)).toBe(0)
})
})
})

View File

@ -0,0 +1,580 @@
import { describe, it, expect, beforeEach } from 'vitest'
import {
SkillHotkeysHud,
DEFAULT_UNIVERSAL_SKILLS,
DEFAULT_SORCERESS_SKILLS,
isLeftUsableSkill,
isPassiveSkill,
isAuraSkill,
getSkillListRow,
getSkillReqLevel,
LEFT_SKILL_BOUNDS,
RIGHT_SKILL_BOUNDS,
} from '../src/ui/hotkeys.ts'
import type { HotkeySkillEntry } from '../src/ui/hotkeys.ts'
import { SKILLS_CATALOG, SKILLS_BY_CLASS } from '../src/data/skills-catalog.ts'
import type { SkillCatalogEntry } from '../src/data/skills-catalog.ts'
import { CHARACTER_CLASS_CODES } from '../src/game/classes.ts'
describe('Diablo II v1.13c Speedbar Authentic Parity (All 7 Classes)', () => {
let hud: SkillHotkeysHud
beforeEach(() => {
hud = new SkillHotkeysHud()
})
// Helper to convert catalog entries into complete HotkeySkillEntry objects
function createClassSkillEntries(entries: readonly SkillCatalogEntry[]): HotkeySkillEntry[] {
return entries.map(s => ({
skillId: s.id,
name: s.name,
nameZh: s.nameZh,
level: 1,
manaCost: s.mana,
leftUsable: isLeftUsableSkill(s.id),
rightUsable: !isPassiveSkill(s.id),
isAura: isAuraSkill(s.id),
isPassive: isPassiveSkill(s.id),
tabIndex: s.tabIndex,
reqlevel: s.reqlevel,
}))
}
// =========================================================================
// 1. Ground Truth Catalog Mapping: ListRow & ReqLevel
// =========================================================================
describe('1. Ground Truth Catalog Mapping', () => {
it('maps all 210 class skills in SKILLS_CATALOG to ListRow = tabIndex + 1 with 100% parity', () => {
expect(SKILLS_CATALOG.length).toBe(210)
for (const s of SKILLS_CATALOG) {
const entry: HotkeySkillEntry = {
skillId: s.id,
name: s.name,
nameZh: s.nameZh,
level: 1,
manaCost: s.mana,
leftUsable: isLeftUsableSkill(s.id),
rightUsable: !isPassiveSkill(s.id),
tabIndex: s.tabIndex,
}
const row = getSkillListRow(entry)
expect(row).toBe(s.tabIndex + 1)
expect(row).toBeGreaterThanOrEqual(1)
expect(row).toBeLessThanOrEqual(3)
}
})
it('maps universal / basic actions to ListRow = 0', () => {
const basicActions = [
{ skillId: 0, name: 'Attack', leftUsable: true, rightUsable: true },
{ skillId: 2, name: 'Throw', leftUsable: true, rightUsable: true },
{ skillId: 3, name: 'Unsummon', leftUsable: false, rightUsable: true },
{ skillId: 4, name: 'Left Hand Throw', leftUsable: true, rightUsable: true },
{ skillId: 5, name: 'Left Hand Swing', leftUsable: true, rightUsable: true },
{ skillId: 350, name: 'Delerium Change', leftUsable: false, rightUsable: true },
]
for (const action of basicActions) {
const row = getSkillListRow({
skillId: action.skillId,
name: action.name,
nameZh: action.name,
level: 1,
manaCost: 0,
leftUsable: action.leftUsable,
rightUsable: action.rightUsable,
})
expect(row).toBe(0)
}
})
it('maps item skills (Town Portal, Identify, Charges) to ListRow = 4', () => {
const itemSkills = [
{ skillId: 217, name: 'Scroll of Identify' },
{ skillId: 218, name: 'Tome of Identify' },
{ skillId: 219, name: 'Scroll of Town Portal' },
{ skillId: 220, name: 'Tome of Town Portal' },
{ skillId: 999, name: 'Teleport Charge Item', isItemSkill: true },
]
for (const item of itemSkills) {
const row = getSkillListRow({
skillId: item.skillId,
name: item.name,
nameZh: item.name,
level: 1,
manaCost: 0,
leftUsable: false,
rightUsable: true,
isItemSkill: (item as any).isItemSkill,
})
expect(row).toBe(4)
}
})
it('maps charged class skills (e.g. Teleport 54, Life Tap 82, Lower Resist 91) to ListRow = 4', () => {
const chargedSkills: HotkeySkillEntry[] = [
{ skillId: 54, name: 'Teleport', nameZh: '传送', level: 1, manaCost: 24, charges: 20, maxCharges: 20, leftUsable: false, rightUsable: true },
{ skillId: 82, name: 'Life Tap', nameZh: '偷取生命', level: 1, manaCost: 9, charges: 15, maxCharges: 15, leftUsable: false, rightUsable: true },
{ skillId: 91, name: 'Lower Resist', nameZh: '降低抵抗', level: 1, manaCost: 22, charges: 10, maxCharges: 10, leftUsable: false, rightUsable: true },
]
for (const skill of chargedSkills) {
expect(getSkillListRow(skill)).toBe(4)
}
})
it('retrieves accurate reqlevel from catalog for all skills', () => {
for (const s of SKILLS_CATALOG) {
const entry: HotkeySkillEntry = {
skillId: s.id,
name: s.name,
nameZh: s.nameZh,
level: 1,
manaCost: s.mana,
leftUsable: true,
rightUsable: true,
}
expect(getSkillReqLevel(entry)).toBe(s.reqlevel)
}
// Basic actions default to reqlevel 1
expect(getSkillReqLevel({ skillId: 0, name: 'Attack', nameZh: '攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true })).toBe(1)
expect(getSkillReqLevel({ skillId: 2, name: 'Throw', nameZh: '投掷', level: 1, manaCost: 0, leftUsable: true, rightUsable: true })).toBe(1)
expect(getSkillReqLevel({ skillId: 3, name: 'Unsummon', nameZh: '取消召唤', level: 1, manaCost: 0, leftUsable: false, rightUsable: true })).toBe(1)
})
})
// =========================================================================
// 2. Usability Filters (Left vs Right) Across All 7 Classes
// =========================================================================
describe('2. Usability Filters (Left vs Right) Across All 7 Classes', () => {
CHARACTER_CLASS_CODES.forEach(cls => {
it(`enforces authentic 1.13c left and right filters for ${cls.toUpperCase()}`, () => {
const classSkills = createClassSkillEntries(SKILLS_BY_CLASS[cls])
const allSkills: HotkeySkillEntry[] = [...DEFAULT_UNIVERSAL_SKILLS, ...classSkills]
hud.setAvailableSkills(allSkills)
// --- Left Click Speedbar ---
const leftCells = hud.getPopupCells('left')
for (const cell of leftCells) {
const id = cell.skill.skillId
// 1. Passive skills strictly filtered out
expect(isPassiveSkill(id)).toBe(false)
expect(cell.skill.isPassive).toBeFalsy()
// 2. Aura skills strictly filtered out
expect(isAuraSkill(id)).toBe(false)
expect(cell.skill.isAura).toBeFalsy()
// 3. Must be an official 1.13c left skill
expect(isLeftUsableSkill(id)).toBe(true)
// 4. Unsummon (3) is right-only
expect(id).not.toBe(3)
}
// --- Right Click Speedbar ---
const rightCells = hud.getPopupCells('right')
for (const cell of rightCells) {
const id = cell.skill.skillId
// 1. Passive skills strictly filtered out
expect(isPassiveSkill(id)).toBe(false)
expect(cell.skill.isPassive).toBeFalsy()
// 2. Right click allows both active skills and auras
expect(cell.skill.rightUsable !== false).toBe(true)
}
})
})
it('strictly excludes Meteor (56) and Blizzard (59) from Sorceress left speedbar', () => {
hud.setAvailableSkills([...DEFAULT_SORCERESS_SKILLS])
const leftCells = hud.getPopupCells('left')
const leftIds = leftCells.map(c => c.skill.skillId)
expect(leftIds).not.toContain(56) // Meteor
expect(leftIds).not.toContain(59) // Blizzard
expect(leftIds).toContain(47) // Fire Ball
expect(leftIds).toContain(55) // Glacial Spike
expect(leftIds).toContain(38) // Charged Bolt
expect(leftIds).toContain(0) // Normal Attack
const rightCells = hud.getPopupCells('right')
const rightIds = rightCells.map(c => c.skill.skillId)
expect(rightIds).toContain(56) // Meteor present on right
expect(rightIds).toContain(59) // Blizzard present on right
})
it('strictly includes Maul (233) on Druid left speedbar', () => {
const druidSkills = createClassSkillEntries(SKILLS_BY_CLASS.dru)
hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...druidSkills])
const leftIds = hud.getPopupCells('left').map(c => c.skill.skillId)
expect(leftIds).toContain(233) // Maul
expect(leftIds).not.toContain(228) // Cyclone Armor (buff, leftskill=0)
expect(leftIds).not.toContain(226) // Oak Sage (summon, leftskill=0)
})
it('strictly excludes all Paladin auras and Holy Shield from left speedbar', () => {
const paladinSkills = createClassSkillEntries(SKILLS_BY_CLASS.pal)
hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...paladinSkills])
const leftCells = hud.getPopupCells('left')
const leftIds = leftCells.map(c => c.skill.skillId)
// No aura allowed
expect(leftIds).not.toContain(98) // Might
expect(leftIds).not.toContain(102) // Holy Fire
expect(leftIds).not.toContain(113) // Holy Freeze
expect(leftIds).not.toContain(118) // Holy Shock
expect(leftIds).not.toContain(122) // Fanaticism
expect(leftIds).not.toContain(125) // Salvation
expect(leftIds).not.toContain(99) // Prayer
expect(leftIds).not.toContain(109) // Cleansing
expect(leftIds).not.toContain(115) // Vigor
expect(leftIds).not.toContain(124) // Redemption
expect(leftIds).not.toContain(117) // Holy Shield (self-buff, leftskill=0)
// Combat skills allowed
expect(leftIds).toContain(96) // Sacrifice
expect(leftIds).toContain(97) // Smite
expect(leftIds).toContain(101) // Holy Bolt
expect(leftIds).toContain(106) // Zeal
expect(leftIds).toContain(107) // Charge
expect(leftIds).toContain(111) // Vengeance
expect(leftIds).toContain(112) // Blessed Hammer
expect(leftIds).toContain(116) // Conversion
expect(leftIds).toContain(121) // Fist of the Heavens
// Right speedbar includes auras with yellow border marker
const rightCells = hud.getPopupCells('right')
const rightAuras = rightCells.filter(c => isAuraSkill(c.skill.skillId))
expect(rightAuras.length).toBe(20) // All 20 Paladin auras
})
it('strictly excludes Barbarian combat mastery passives from both speedbars', () => {
const barbarianSkills = createClassSkillEntries(SKILLS_BY_CLASS.bar)
hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...barbarianSkills])
const leftIds = hud.getPopupCells('left').map(c => c.skill.skillId)
const rightIds = hud.getPopupCells('right').map(c => c.skill.skillId)
const passives = [127, 128, 129, 134, 135, 136, 141, 145, 148, 153]
for (const p of passives) {
expect(leftIds).not.toContain(p)
expect(rightIds).not.toContain(p)
}
})
})
// =========================================================================
// 3. Multi-Row Grouping & Row Compaction
// =========================================================================
describe('3. Multi-Row Grouping & Row Compaction', () => {
it('compacts Barbarian right-click speedbar from [0, 1, 3] into contiguous visualRows [0, 1, 2]', () => {
const barbarianSkills = createClassSkillEntries(SKILLS_BY_CLASS.bar)
hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...barbarianSkills])
const rightCells = hud.getPopupCells('right')
// Barbarian has:
// ListRow 0: Attack (0), Throw (2), Unsummon (3)
// ListRow 1: Combat Skills (10 active)
// ListRow 2: Combat Masteries (10 PASSIVES -> 0 active)
// ListRow 3: Warcries (10 active)
const listRowsPresent = Array.from(new Set(rightCells.map(c => c.listRow))).sort((a, b) => a - b)
expect(listRowsPresent).toEqual([0, 1, 3])
const visualRowsPresent = Array.from(new Set(rightCells.map(c => c.visualRow))).sort((a, b) => a - b)
expect(visualRowsPresent).toEqual([0, 1, 2])
// Verify that every cell in ListRow 3 is compacted into visualRow 2
const warcriesCells = rightCells.filter(c => c.listRow === 3)
expect(warcriesCells.length).toBe(10)
for (const cell of warcriesCells) {
expect(cell.visualRow).toBe(2)
}
})
it('compacts Paladin left-click speedbar to contiguous visualRows [0, 1]', () => {
const paladinSkills = createClassSkillEntries(SKILLS_BY_CLASS.pal)
hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...paladinSkills])
const leftCells = hud.getPopupCells('left')
// ListRow 0 (Universal), ListRow 1 (Combat skills). ListRow 2 and 3 are auras (filtered out).
const listRowsPresent = Array.from(new Set(leftCells.map(c => c.listRow))).sort((a, b) => a - b)
expect(listRowsPresent).toEqual([0, 1])
const visualRowsPresent = Array.from(new Set(leftCells.map(c => c.visualRow))).sort((a, b) => a - b)
expect(visualRowsPresent).toEqual([0, 1])
})
it('compacts single-tree character without gap rows', () => {
// Character that only learned Lightning Spells (Tab 2 => ListRow 2)
const lightSpells = createClassSkillEntries(SKILLS_BY_CLASS.sor.filter(s => s.tabIndex === 1 && !isPassiveSkill(s.id)))
hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...lightSpells])
const rightCells = hud.getPopupCells('right')
// ListRow 0 (Universal) and ListRow 2 (Lightning)
const listRowsPresent = Array.from(new Set(rightCells.map(c => c.listRow))).sort((a, b) => a - b)
expect(listRowsPresent).toEqual([0, 2])
// Compacted to visualRow 0 and 1
const visualRowsPresent = Array.from(new Set(rightCells.map(c => c.visualRow))).sort((a, b) => a - b)
expect(visualRowsPresent).toEqual([0, 1])
const lightCells = rightCells.filter(c => c.listRow === 2)
for (const cell of lightCells) {
expect(cell.visualRow).toBe(1)
}
})
it('maintains contiguous visualRow compaction with item skills across all 7 classes', () => {
const itemSkills: HotkeySkillEntry[] = [
{ skillId: 219, name: 'Scroll of Town Portal', nameZh: '城镇传送卷轴', level: 1, manaCost: 0, isItemSkill: true, leftUsable: false, rightUsable: true },
{ skillId: 217, name: 'Scroll of Identify', nameZh: '辨识卷轴', level: 1, manaCost: 0, isItemSkill: true, leftUsable: false, rightUsable: true },
]
for (const cls of CHARACTER_CLASS_CODES) {
const classSkills = createClassSkillEntries(SKILLS_BY_CLASS[cls])
hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...classSkills, ...itemSkills])
const rightCells = hud.getPopupCells('right')
const visualRows = Array.from(new Set(rightCells.map(c => c.visualRow))).sort((a, b) => a - b)
for (let i = 0; i < visualRows.length; i++) {
expect(visualRows[i]).toBe(i)
}
const itemCells = rightCells.filter(c => c.listRow === 4)
const maxRow = Math.max(...visualRows)
for (const cell of itemCells) {
expect(cell.visualRow).toBe(maxRow)
}
}
})
it('correctly places charged skills in ListRow 4 on a non-native class (Barbarian with Teleport charges)', () => {
const barbarianSkills = createClassSkillEntries(SKILLS_BY_CLASS.bar)
const tpChargedItem: HotkeySkillEntry = {
skillId: 54,
name: 'Teleport',
nameZh: '传送',
level: 1,
manaCost: 24,
charges: 20,
maxCharges: 20,
leftUsable: false,
rightUsable: true,
}
hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...barbarianSkills, tpChargedItem])
const rightCells = hud.getPopupCells('right')
const tpCell = rightCells.find(c => c.skill.skillId === 54)
expect(tpCell).toBeDefined()
expect(tpCell!.listRow).toBe(4)
// Visual rows: 0 (Universal), 1 (Combat skills), 2 (Warcries), 3 (Item row with Teleport)
expect(tpCell!.visualRow).toBe(3)
})
})
// =========================================================================
// 4. Intra-Row Ordering
// =========================================================================
describe('4. Intra-Row Ordering', () => {
it('strictly sorts skills within each row by reqlevel ascending, then skillId ascending', () => {
for (const cls of CHARACTER_CLASS_CODES) {
const classSkills = createClassSkillEntries(SKILLS_BY_CLASS[cls])
hud.setAvailableSkills([...DEFAULT_UNIVERSAL_SKILLS, ...classSkills])
for (const side of ['left', 'right'] as const) {
const cells = hud.getPopupCells(side)
// Group cells by visualRow
const rows = new Map<number, (typeof cells)[number][]>()
for (const c of cells) {
let list = rows.get(c.visualRow)
if (!list) {
list = []
rows.set(c.visualRow, list)
}
list.push(c)
}
for (const rowCells of rows.values()) {
for (let i = 0; i < rowCells.length - 1; i++) {
const a = rowCells[i]
const b = rowCells[i + 1]
const reqA = getSkillReqLevel(a.skill)
const reqB = getSkillReqLevel(b.skill)
if (reqA === reqB) {
expect(a.skill.skillId).toBeLessThan(b.skill.skillId)
} else {
expect(reqA).toBeLessThan(reqB)
}
// Verify column index is sequential 0, 1, 2...
expect(a.col).toBe(i)
expect(b.col).toBe(i + 1)
}
}
}
}
})
})
// =========================================================================
// 5. Adaptive Alignment & Canvas Bounds Clamping
// =========================================================================
describe('5. Adaptive Alignment & Canvas Bounds Clamping', () => {
it('expands left speedbar rightward and right speedbar leftward', () => {
hud.setAvailableSkills([...DEFAULT_SORCERESS_SKILLS])
const leftCells = hud.getPopupCells('left')
const rightCells = hud.getPopupCells('right')
// In left popup, col 0 is at base.x (117), col 1 is at 117 + 48 = 165
const leftRow0 = leftCells.filter(c => c.visualRow === 0)
if (leftRow0.length >= 2) {
expect(leftRow0[0].x).toBe(LEFT_SKILL_BOUNDS.x)
expect(leftRow0[1].x).toBe(LEFT_SKILL_BOUNDS.x + 48)
}
// In right popup, col 0 is at base.x (635), col 1 is at 635 - 48 = 587
const rightRow0 = rightCells.filter(c => c.visualRow === 0)
if (rightRow0.length >= 2) {
expect(rightRow0[0].x).toBe(RIGHT_SKILL_BOUNDS.x)
expect(rightRow0[1].x).toBe(RIGHT_SKILL_BOUNDS.x - 48)
}
})
it('strictly clamps all cells within [0, 800 - 48] and [0, 600 - 48]', () => {
// Simulate extreme row with 25 skills
const megaSkills: HotkeySkillEntry[] = []
for (let i = 1; i <= 25; i++) {
megaSkills.push({
skillId: 1000 + i,
name: `Custom Skill ${i}`,
nameZh: `自定义技能 ${i}`,
level: 1,
manaCost: 10,
leftUsable: true,
rightUsable: true,
tabIndex: 0,
reqlevel: i,
})
}
hud.setAvailableSkills(megaSkills)
for (const side of ['left', 'right'] as const) {
const cells = hud.getPopupCells(side)
for (const cell of cells) {
expect(cell.x).toBeGreaterThanOrEqual(0)
expect(cell.x).toBeLessThanOrEqual(800 - 48)
expect(cell.y).toBeGreaterThanOrEqual(0)
expect(cell.y).toBeLessThanOrEqual(600 - 48)
}
}
})
})
// =========================================================================
// 6. Item Skills & Tooltips
// =========================================================================
describe('6. Item Skills & Tooltips', () => {
it('positions item skills at the top row (ListRow = 4)', () => {
const skillsWithItems: HotkeySkillEntry[] = [
...DEFAULT_SORCERESS_SKILLS,
{ skillId: 219, name: 'Scroll of Town Portal', nameZh: '城镇传送卷轴', level: 1, manaCost: 0, isItemSkill: true, leftUsable: false, rightUsable: true },
{ skillId: 217, name: 'Scroll of Identify', nameZh: '辨识卷轴', level: 1, manaCost: 0, isItemSkill: true, leftUsable: false, rightUsable: true },
]
hud.setAvailableSkills(skillsWithItems)
const rightCells = hud.getPopupCells('right')
const itemCells = rightCells.filter(c => c.listRow === 4)
expect(itemCells.length).toBe(2)
const maxVisualRow = Math.max(...rightCells.map(c => c.visualRow))
for (const cell of itemCells) {
expect(cell.visualRow).toBe(maxVisualRow)
}
})
it('formats tooltips correctly for Town Portal, Identify, and Left Hand Throw', () => {
hud.setSkillCharges(219, 12, 20)
const tpLines = hud.formatTooltipLines(219, 'right', true)
expect(tpLines[0].text).toContain('城镇传送卷轴')
expect(tpLines.some(l => l.text.includes('12'))).toBe(true)
hud.setSkillCharges(218, 20, 20)
const idLines = hud.formatTooltipLines(218, 'right', true)
expect(idLines[0].text).toContain('辨识之书')
expect(idLines.some(l => l.text.includes('20'))).toBe(true)
const lhtLines = hud.formatTooltipLines(4, 'left', true)
expect(lhtLines[0].text).toContain('左手投掷')
})
})
// =========================================================================
// 7. Interactive Assignments & Guardrails
// =========================================================================
describe('7. Interactive Assignments & Guardrails', () => {
beforeEach(() => {
hud.setAvailableSkills([...DEFAULT_SORCERESS_SKILLS])
})
it('rejects assigning Meteor (56) and Blizzard (59) to left slot', () => {
expect(hud.assignSkill('left', 56)).toBe(false)
expect(hud.assignSkill('left', 59)).toBe(false)
expect(hud.leftSkillId).not.toBe(56)
expect(hud.leftSkillId).not.toBe(59)
// Right slot succeeds
expect(hud.assignSkill('right', 56)).toBe(true)
expect(hud.rightSkillId).toBe(56)
expect(hud.assignSkill('right', 59)).toBe(true)
expect(hud.rightSkillId).toBe(59)
})
it('rejects assigning auras to left slot', () => {
expect(hud.assignSkill('left', 113)).toBe(false) // Holy Freeze
expect(hud.leftSkillId).not.toBe(113)
expect(hud.assignSkill('right', 113)).toBe(true)
expect(hud.rightSkillId).toBe(113)
})
it('rejects assigning passive skills to any slot', () => {
expect(hud.assignSkill('left', 61)).toBe(false) // Fire Mastery
expect(hud.assignSkill('right', 61)).toBe(false) // Fire Mastery
expect(hud.assignSkill('left', 127)).toBe(false) // Sword Mastery
expect(hud.assignSkill('right', 127)).toBe(false)
})
it('enforces left-usability on setDualSkill', () => {
expect(hud.setDualSkill(0)).toBe(true) // Attack
expect(hud.leftSkillId).toBe(0)
expect(hud.rightSkillId).toBe(0)
expect(hud.setDualSkill(56)).toBe(false) // Meteor
expect(hud.setDualSkill(113)).toBe(false) // Holy Freeze
})
it('handles speedbar popup opening, cell click selection, and slot closing', () => {
expect(hud.openPopup).toBeNull()
// 1. Click Left Slot opens left popup
const leftBounds = LEFT_SKILL_BOUNDS
hud.handleClick(leftBounds.x + 10, leftBounds.y + 10)
expect(hud.openPopup).toBe('left')
// 2. Select a cell in left popup (e.g. Fire Ball 47)
const leftCells = hud.getPopupCells('left')
const fbCell = leftCells.find(c => c.skill.skillId === 47)!
expect(fbCell).toBeDefined()
hud.handleClick(fbCell.x + 5, fbCell.y + 5)
expect(hud.leftSkillId).toBe(47)
expect(hud.openPopup).toBeNull()
// 3. Click Right Slot opens right popup
const rightBounds = RIGHT_SKILL_BOUNDS
hud.handleClick(rightBounds.x + 10, rightBounds.y + 10)
expect(hud.openPopup).toBe('right')
// 4. Select Blizzard (59) in right popup
const rightCells = hud.getPopupCells('right')
const blizCell = rightCells.find(c => c.skill.skillId === 59)!
expect(blizCell).toBeDefined()
hud.handleClick(blizCell.x + 5, blizCell.y + 5)
expect(hud.rightSkillId).toBe(59)
expect(hud.openPopup).toBeNull()
})
})
})