Compare commits

..

No commits in common. "10ef14b5b26d845eb928f45e2a6e933d9d14bd7c" and "375f3296f99a9c89cc126daaedb6fe8357de2b35" have entirely different histories.

5 changed files with 29 additions and 2413 deletions

View File

@ -27,10 +27,6 @@ export interface HotkeySkillEntry {
readonly charges?: number readonly charges?: number
readonly maxCharges?: number readonly maxCharges?: number
readonly cooldownMs?: number readonly cooldownMs?: number
readonly listRow?: number
readonly reqlevel?: number
readonly isItemSkill?: boolean
readonly tabIndex?: number
} }
/** /**
@ -82,26 +78,17 @@ export function isPassiveSkill(skillId: number): boolean {
* Diablo II v1.13c Skills usable on the Left-Click action button (Skills.txt `leftskill = 1`). * 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>([ export const LEFT_USABLE_SKILL_IDS: ReadonlySet<number> = new Set<number>([
// Universal actions (Attack 0, Throw 2, Left Hand Throw 4, Left Hand Swing 5) 0, 2, 6, 7, 10, 11, 12, 14, 15, 16, 19, 20, 21, 22, 24, 25, 26, 27, 30, 31, 34, 35,
0, 2, 4, 5, 36, 38, 39, 41, 45, 47, 49, 53, 55, 56, 59, 64,
// 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, 67, 73, 84, 93,
// Paladin (9 active combat skills with leftskill = 1)
96, 97, 101, 106, 107, 111, 112, 116, 121, 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, 126, 132, 133, 139, 140, 143, 144, 147, 151, 152,
// Druid (12 active combat/elemental skills with leftskill = 1) 225, 229, 230, 232, 238, 239, 240, 242, 243, 245, 248,
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, 251, 254, 255, 256, 257, 259, 260, 265, 266, 269, 270, 274, 275, 280,
]) ])
export function isLeftUsableSkill(skillId: number): boolean { export function isLeftUsableSkill(skillId: number): boolean {
if (isPassiveSkill(skillId) || isAuraSkill(skillId)) return false if (isPassiveSkill(skillId)) return false
return LEFT_USABLE_SKILL_IDS.has(skillId) return LEFT_USABLE_SKILL_IDS.has(skillId)
} }
@ -195,42 +182,6 @@ export function resolveSkillIcon(skillId: number, hasWeapon = true): SkillIconMa
atlasRect: { x: 192, y: 0, width: 48, height: 48, w: 48, h: 48 }, 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 { return {
skillId, skillId,
name: `Skill ${skillId}`, name: `Skill ${skillId}`,
@ -243,78 +194,24 @@ 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( export function getAvailableSkillsForSide(
side: 'left' | 'right', side: 'left' | 'right',
skills: readonly HotkeySkillEntry[] = DEFAULT_SORCERESS_SKILLS, skills: readonly HotkeySkillEntry[] = DEFAULT_SORCERESS_SKILLS,
): readonly HotkeySkillEntry[] { ): readonly HotkeySkillEntry[] {
return skills.filter(s => { return skills.filter(s => {
if (isPassiveSkill(s.skillId) || s.isPassive) return false if (isPassiveSkill(s.skillId) || s.isPassive) return false
if (side === 'left') { return side === 'left' ? s.leftUsable : s.rightUsable
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 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 RIGHT_SKILL_BOUNDS = { x: 635, y: 551, width: 48, height: 48 } as const
export const DEFAULT_UNIVERSAL_SKILLS: readonly HotkeySkillEntry[] = [ export const DEFAULT_SORCERESS_SKILLS: readonly HotkeySkillEntry[] = [
// Universal Skills
{ skillId: 0, name: 'Attack', nameZh: '普通攻击', level: 1, manaCost: 0, leftUsable: true, rightUsable: true }, { 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: 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 }, { 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) // Fire Spells (8 active)
{ skillId: 36, name: 'Fire Bolt', nameZh: '火弹', level: 5, manaCost: 2.5, leftUsable: true, rightUsable: true }, { 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 }, { skillId: 41, name: 'Inferno', nameZh: '地狱之火', level: 1, manaCost: 0.6, leftUsable: true, rightUsable: true },
@ -322,7 +219,7 @@ export const DEFAULT_SORCERESS_SKILLS: readonly HotkeySkillEntry[] = [
{ skillId: 47, name: 'Fire Ball', nameZh: '火球', level: 8, manaCost: 8.5, leftUsable: true, rightUsable: true }, { 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: 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: 52, name: 'Enchant', nameZh: '强化', level: 1, manaCost: 25, leftUsable: false, rightUsable: true },
{ skillId: 56, name: 'Meteor', nameZh: '陨石', level: 6, manaCost: 22, leftUsable: false, rightUsable: true }, { skillId: 56, name: 'Meteor', nameZh: '陨石', level: 6, manaCost: 22, leftUsable: true, rightUsable: true },
{ skillId: 62, name: 'Hydra', nameZh: '九头海蛇', level: 1, manaCost: 20, leftUsable: false, rightUsable: true }, { skillId: 62, name: 'Hydra', nameZh: '九头海蛇', level: 1, manaCost: 20, leftUsable: false, rightUsable: true },
// Lightning Spells (9 active) // Lightning Spells (9 active)
{ skillId: 38, name: 'Charged Bolt', nameZh: '充能弹', level: 4, manaCost: 4, leftUsable: true, rightUsable: true }, { skillId: 38, name: 'Charged Bolt', nameZh: '充能弹', level: 4, manaCost: 4, leftUsable: true, rightUsable: true },
@ -341,7 +238,7 @@ export const DEFAULT_SORCERESS_SKILLS: readonly HotkeySkillEntry[] = [
{ skillId: 45, name: 'Ice Blast', nameZh: '冰风暴', level: 5, manaCost: 8, leftUsable: true, rightUsable: true }, { 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: 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: 55, name: 'Glacial Spike', nameZh: '冰尖柱', level: 10, manaCost: 14.5, leftUsable: true, rightUsable: true },
{ skillId: 59, name: 'Blizzard', nameZh: '暴风雪', level: 10, manaCost: 27, leftUsable: false, rightUsable: true }, { skillId: 59, name: 'Blizzard', nameZh: '暴风雪', level: 10, manaCost: 27, leftUsable: true, rightUsable: true },
{ skillId: 60, name: 'Chilling Armor', nameZh: '寒冰装甲', level: 1, manaCost: 17, 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 }, { skillId: 64, name: 'Frozen Orb', nameZh: '冰封球', level: 12, manaCost: 30, leftUsable: true, rightUsable: true },
] ]
@ -480,12 +377,8 @@ export class SkillHotkeysHud {
): void { ): void {
// Passive skills strictly cannot enter the quickbar // Passive skills strictly cannot enter the quickbar
this.availableSkills = skills.filter(s => !isPassiveSkill(s.skillId) && !s.isPassive) this.availableSkills = skills.filter(s => !isPassiveSkill(s.skillId) && !s.isPassive)
if (defaultLeft !== undefined && isLeftUsableSkill(defaultLeft) && !isAuraSkill(defaultLeft)) { if (defaultLeft !== undefined && !isPassiveSkill(defaultLeft)) this.leftSkillId = defaultLeft
this.leftSkillId = defaultLeft if (defaultRight !== undefined && !isPassiveSkill(defaultRight)) this.rightSkillId = defaultRight
}
if (defaultRight !== undefined && !isPassiveSkill(defaultRight)) {
this.rightSkillId = defaultRight
}
this.preloadSkillIcons() this.preloadSkillIcons()
} }
@ -535,24 +428,23 @@ export class SkillHotkeysHud {
assignSkill(side: 'left' | 'right', skillId: number): boolean { assignSkill(side: 'left' | 'right', skillId: number): boolean {
if (isPassiveSkill(skillId)) return false if (isPassiveSkill(skillId)) return false
if (side === 'left') { if (side === 'left') this.leftSkillId = skillId
if (isAuraSkill(skillId) || !isLeftUsableSkill(skillId)) return false else this.rightSkillId = skillId
this.leftSkillId = skillId
} else {
this.rightSkillId = skillId
}
return true return true
} }
setDualSkill(skillId: number): boolean { setDualSkill(skillId: number): boolean {
if (isPassiveSkill(skillId) || isAuraSkill(skillId) || !isLeftUsableSkill(skillId)) return false if (isPassiveSkill(skillId)) return false
this.leftSkillId = skillId this.leftSkillId = skillId
this.rightSkillId = skillId this.rightSkillId = skillId
return true return true
} }
getAvailableSkillsForSide(side: 'left' | 'right'): readonly HotkeySkillEntry[] { getAvailableSkillsForSide(side: 'left' | 'right'): readonly HotkeySkillEntry[] {
return getAvailableSkillsForSide(side, this.availableSkills) return this.availableSkills.filter(s => {
if (isPassiveSkill(s.skillId) || s.isPassive) return false
return side === 'left' ? s.leftUsable : s.rightUsable
})
} }
triggerFunctionKey(fKey: string): { side: 'left' | 'right'; skillId: number } | null { triggerFunctionKey(fKey: string): { side: 'left' | 'right'; skillId: number } | null {
@ -563,15 +455,8 @@ export class SkillHotkeysHud {
} }
const bound = this.bindings.get(key) const bound = this.bindings.get(key)
if (!bound) return null if (!bound) return null
if (bound.side === 'left') { if (bound.side === 'left') this.leftSkillId = bound.skillId
if (isLeftUsableSkill(bound.skillId) && !isAuraSkill(bound.skillId)) { else this.rightSkillId = bound.skillId
this.leftSkillId = bound.skillId
}
} else {
if (!isPassiveSkill(bound.skillId)) {
this.rightSkillId = bound.skillId
}
}
return bound return bound
} }
@ -581,74 +466,19 @@ export class SkillHotkeysHud {
y: number y: number
w: number w: number
h: number h: number
listRow: number
visualRow: number
col: number
}[] { }[] {
const list = this.getAvailableSkillsForSide(side) const list = this.getAvailableSkillsForSide(side)
const cols = 5
const cellW = 48 const cellW = 48
const cellH = 48 const cellH = 48
const base = side === 'left' ? LEFT_SKILL_BOUNDS : RIGHT_SKILL_BOUNDS const base = side === 'left' ? LEFT_SKILL_BOUNDS : RIGHT_SKILL_BOUNDS
return list.map((skill, idx) => {
// 1. Group available skills by authentic ListRow (0..4) const col = idx % cols
const rowMap = new Map<number, HotkeySkillEntry[]>() const row = Math.floor(idx / cols)
for (const skill of list) { const x = side === 'left' ? base.x + col * cellW : base.x - col * cellW
const explicitCharges = this.getSkillCharges(skill.skillId) const y = base.y - (row + 1) * cellH - 6
const effectiveSkill = (explicitCharges !== undefined && skill.charges === undefined) return { skill, x, y, w: cellW, h: cellH }
? { ...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'): { getSpeedbarSkills(side: 'left' | 'right'): {
@ -657,9 +487,6 @@ export class SkillHotkeysHud {
y: number y: number
w: number w: number
h: number h: number
listRow: number
visualRow: number
col: number
}[] { }[] {
return this.getPopupCells(side) return this.getPopupCells(side)
} }
@ -709,15 +536,8 @@ export class SkillHotkeysHud {
const cells = this.getPopupCells(this.openPopup) const cells = this.getPopupCells(this.openPopup)
for (const c of cells) { for (const c of cells) {
if (logicalX >= c.x && logicalX <= c.x + c.w && logicalY >= c.y && logicalY <= c.y + c.h) { if (logicalX >= c.x && logicalX <= c.x + c.w && logicalY >= c.y && logicalY <= c.y + c.h) {
if (this.openPopup === 'left') { if (this.openPopup === 'left') this.leftSkillId = c.skill.skillId
if (isLeftUsableSkill(c.skill.skillId) && !isAuraSkill(c.skill.skillId)) { else this.rightSkillId = c.skill.skillId
this.leftSkillId = c.skill.skillId
}
} else {
if (!isPassiveSkill(c.skill.skillId)) {
this.rightSkillId = c.skill.skillId
}
}
this.openPopup = null this.openPopup = null
return true return true
} }
@ -844,36 +664,6 @@ export class SkillHotkeysHud {
ctx.fillStyle = side === 'left' ? '#5c2818' : '#182c5c' ctx.fillStyle = side === 'left' ? '#5c2818' : '#182c5c'
ctx.fillRect(x + 2, y + 2, 44, 44) 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 { } else {
const icon = this.iconImages.get(skillId) const icon = this.iconImages.get(skillId)
if (icon && icon.complete && icon.naturalWidth > 0) { if (icon && icon.complete && icon.naturalWidth > 0) {
@ -1051,128 +841,6 @@ export class SkillHotkeysHud {
return lines 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 // 2. Class Skills
const vm = this.tooltipResolver ? this.tooltipResolver(skillId) : null const vm = this.tooltipResolver ? this.tooltipResolver(skillId) : null
const entry = this.availableSkills.find(s => s.skillId === skillId) const entry = this.availableSkills.find(s => s.skillId === skillId)

View File

@ -233,13 +233,6 @@ describe('SkillHotkeysHud & Dual Slot Mechanics', () => {
hud.draw(ctx, mockAtlas, mockFont, true) hud.draw(ctx, mockAtlas, mockFont, true)
const unsummonDraw = drawImageCalls.find((c) => c[0] === mockAtlas && c[1] === 192) const unsummonDraw = drawImageCalls.find((c) => c[0] === mockAtlas && c[1] === 192)
expect(unsummonDraw).toBeDefined() 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)', () => { it('allows binding Throw to left/right slot and Unsummon to right slot only (Issue #376)', () => {

View File

@ -1,819 +0,0 @@
/**
* 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

@ -1,646 +0,0 @@
/**
* 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

@ -1,580 +0,0 @@
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()
})
})
})