diablo2-web/src/common/skills/skills-meta.ts

672 lines
19 KiB
TypeScript

/**
* Pure skill metadata, canonical 1.13c `skills.txt` / `skilldesc.txt` table columns,
* passive/aura/left-click skill predicates, and display helpers shared between
* `src/common/`, `src/server/`, and `src/client/`.
*/
import { SKILLS_BY_ID, SKILLS_CATALOG } from '../data/skills-catalog.ts'
import { getDefaultTblLang, type TblLang } from '../formats/tbl.ts'
import type { CharacterClassCode } from '../units/classes.ts'
export type D2ColorCode =
| 'white'
| 'red'
| 'green'
| 'blue'
| 'gold'
| 'gray'
| 'black'
| 'tan'
| 'orange'
| 'yellow'
| 'darkgreen'
| 'purple'
| 'brightgreen'
export interface FormattedTooltipLine {
readonly text: string
readonly color: D2ColorCode
readonly font: 'fontexocet10' | 'font8'
readonly align?: 'left' | 'center'
}
export interface HotkeySkillEntry {
readonly skillId: number
readonly name: string
readonly nameZh: string
readonly level: number
readonly manaCost: number
readonly leftUsable: boolean
readonly rightUsable: boolean
readonly isPassive?: boolean
readonly isAura?: boolean
readonly charges?: number
readonly maxCharges?: number
readonly cooldownMs?: number
readonly listRow?: number
readonly reqlevel?: number
readonly isItemSkill?: boolean
readonly tabIndex?: number
}
/**
* Canonical 1.13c `skills.txt` + `skilldesc.txt` row metadata.
*/
export interface SkillTableMeta {
readonly skillId: number
readonly skilldesc: string
readonly passive: 0 | 1
readonly leftskill: 0 | 1
readonly aura: 0 | 1
readonly maxlvl: number
readonly itypea1?: string
readonly weapsel?: number
readonly skillPage: number
readonly listRow: number
readonly listPool: number
readonly iconCel: number
readonly strNameKey: string
readonly strShortKey: string
readonly strLongKey: string
readonly strAltKey: string
readonly descline1?: number
readonly desctexta1?: string
}
/**
* Canonical `.tbl` strings for skill tooltips (`string.tbl`, `expansionstring.tbl`, `patchstring.tbl`).
*/
export const SKILL_TBL_STRINGS = {
ENG: {
StrSkill1: 'Next Level',
StrSkill2: 'Current Skill Level: ',
StrSkill3: 'Mana Cost: ',
StrSkill17: 'First Level',
StrSkill18: 'Required Level:',
Sksyn: '%s Receives Bonuses From:',
skilldesc4: 'Quantity ',
},
CHI: {
StrSkill1: '下一等級',
StrSkill2: '目前技能等級:',
StrSkill3: '法力消耗:',
StrSkill17: '第一級',
StrSkill18: '需要等級:',
Sksyn: '額外的對%s',
skilldesc4: '數量 ',
},
} as const
/**
* Canonical `.tbl` strings (`strName`, `strShort`, `strLong`, `strAlt`) for generic & item skills (`#0..#5`, `#217..#220`, `#350`).
*/
export const GENERIC_SKILL_TBL_STRINGS: Readonly<
Record<
number,
{
readonly nameEn: string
readonly nameZh: string
readonly shortEn: string
readonly shortZh: string
readonly longEn: string
readonly longZh: string
}
>
> = {
0: {
nameEn: 'Attack',
nameZh: '攻擊',
shortEn: 'normal attack',
shortZh: '一般攻擊',
longEn: 'normal attack',
longZh: '一般攻擊',
},
1: {
nameEn: 'Kick',
nameZh: '腳踢',
shortEn: 'kick target',
shortZh: '腳踢目標',
longEn: 'kick target',
longZh: '腳踢目標',
},
2: {
nameEn: 'Throw',
nameZh: '投擲',
shortEn: 'throw equipped item',
shortZh: '投擲裝備的物品',
longEn: 'throw equipped item',
longZh: '投擲裝備的物品',
},
3: {
nameEn: 'Unsummon',
nameZh: '取消召喚',
shortEn: 'releases a summoned creature',
shortZh: '釋放掉你所控制生物中,',
longEn: 'of one of your creatures\nrelinquishes control',
longZh: '其中一位召喚生物\n放棄對它的控制',
},
4: {
nameEn: 'Left Hand Throw',
nameZh: '左手投擲',
shortEn: 'throw left hand item',
shortZh: '丟出左手的物品',
longEn: 'throw left hand item',
longZh: '丟出左手的物品',
},
5: {
nameEn: 'Left Hand Swing',
nameZh: '左手揮動',
shortEn: 'swing left hand weapon',
shortZh: '揮動左手的武器',
longEn: 'swing left hand weapon',
longZh: '揮動左手的武器',
},
217: {
nameEn: 'Scroll of Identify',
nameZh: '辨視卷軸',
shortEn: "identify a magic item's properties",
shortZh: '辨視一個魔法物品的屬性',
longEn: "identify a magic item's properties",
longZh: '辨視一個魔法物品的屬性',
},
218: {
nameEn: 'Tome of Identify',
nameZh: '辨視之書',
shortEn: "identify a magic item's properties",
shortZh: '辨視一個魔法物品的屬性',
longEn: "identify a magic item's properties",
longZh: '辨視一個魔法物品的屬性',
},
219: {
nameEn: 'Scroll of Townportal',
nameZh: '城鎮傳送卷軸',
shortEn: 'create a magic portal to the nearest town',
shortZh: '創造出一個魔法傳送門,通往最近的城市',
longEn: 'create a magic portal to the nearest town',
longZh: '創造出一個魔法傳送門,通往最近的城市',
},
220: {
nameEn: 'Tome of Townportal',
nameZh: '城鎮傳送之書',
shortEn: 'create a magic portal to the nearest town',
shortZh: '創造出一個魔法傳送門,通往最近的城市',
longEn: 'create a magic portal to the nearest town',
longZh: '創造出一個魔法傳送門,通往最近的城市',
},
350: {
nameEn: 'Delerium Change',
nameZh: 'Delerium Change',
shortEn: '',
shortZh: '',
longEn: '',
longZh: '',
},
}
const CANONICAL_PASSIVE_COLUMN_ONE: ReadonlySet<number> = new Set<number>([
9, 13, 18, 23, 29, 33,
37, 61, 63, 65,
69, 79, 89,
127, 128, 129, 134, 135, 136, 141, 145, 148, 153,
224,
252, 263,
])
const CANONICAL_LEFTSKILL_COLUMN_ONE: ReadonlySet<number> = new Set<number>([
0, 2, 4, 5,
6, 7, 9, 10, 11, 12, 13, 14, 15, 16, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 29, 30, 31, 33, 34, 35,
36, 37, 38, 39, 41, 45, 47, 49, 53, 55, 61, 63, 64, 65,
67, 73, 79, 84, 89, 93,
96, 97, 101, 106, 107, 111, 112, 116, 121,
126, 132, 133, 139, 140, 143, 144, 147, 151, 152,
225, 229, 230, 232, 233, 238, 239, 240, 242, 243, 245, 248,
251, 254, 255, 256, 257, 259, 260, 263, 265, 266, 269, 270, 274, 275, 280,
])
const CANONICAL_AURA_COLUMN_ONE: ReadonlySet<number> = new Set<number>([
98, 99, 100, 102, 103, 104, 105, 108, 109, 110,
113, 114, 115, 118, 119, 120, 122, 123, 124, 125,
127, 128, 129,
])
const CANONICAL_CLASS_SKILL_ICON_CEL: Readonly<Record<number, number>> = {
6: 0, 7: 2, 8: 4, 9: 6, 10: 8, 11: 10, 12: 12, 13: 14, 14: 16, 15: 18,
16: 20, 17: 22, 18: 24, 19: 26, 20: 28, 21: 30, 22: 32, 23: 34, 24: 36, 25: 38,
26: 40, 27: 42, 28: 44, 29: 46, 30: 48, 31: 50, 32: 52, 33: 54, 34: 56, 35: 58,
36: 0, 37: 2, 38: 4, 39: 6, 40: 8, 41: 10, 42: 12, 43: 14, 44: 16, 45: 18,
46: 20, 47: 22, 48: 24, 49: 26, 50: 28, 51: 30, 52: 32, 53: 34, 54: 36, 55: 38,
56: 40, 57: 42, 58: 44, 59: 46, 60: 48, 61: 50, 62: 52, 63: 54, 64: 56, 65: 58,
66: 0, 67: 2, 68: 4, 69: 6, 70: 8, 71: 10, 72: 12, 73: 14, 74: 16, 75: 18,
76: 20, 77: 22, 78: 24, 79: 26, 80: 28, 81: 30, 82: 32, 83: 34, 84: 36, 85: 38,
86: 40, 87: 42, 88: 44, 89: 46, 90: 48, 91: 50, 92: 52, 93: 54, 94: 56, 95: 58,
96: 0, 97: 2, 98: 4, 99: 6, 100: 8, 101: 10, 102: 12, 103: 14, 104: 16, 105: 18,
106: 20, 107: 22, 108: 24, 109: 38, 110: 28, 111: 40, 112: 32, 113: 34, 114: 36, 115: 26,
116: 30, 117: 42, 118: 44, 119: 46, 120: 48, 121: 50, 122: 52, 123: 56, 124: 54, 125: 58,
126: 0, 127: 2, 128: 4, 129: 6, 130: 8, 131: 10, 132: 12, 133: 14, 134: 16, 135: 18,
136: 20, 137: 22, 138: 24, 139: 26, 140: 28, 141: 30, 142: 32, 143: 34, 144: 36, 145: 38,
146: 40, 147: 42, 148: 44, 149: 46, 150: 48, 151: 50, 152: 52, 153: 54, 154: 56, 155: 58,
221: 0, 222: 2, 223: 20, 224: 52, 225: 32, 226: 16, 227: 6, 228: 22, 229: 34, 230: 36,
231: 8, 232: 54, 233: 56, 234: 38, 235: 40, 236: 10, 237: 12, 238: 24, 239: 26, 240: 42,
241: 14, 242: 58, 243: 28, 244: 44, 245: 46, 246: 4, 247: 18, 248: 30, 249: 50, 250: 48,
251: 0, 252: 20, 253: 22, 254: 40, 255: 42, 256: 2, 257: 4, 258: 36, 259: 44, 260: 46,
261: 6, 262: 8, 263: 26, 264: 24, 265: 50, 266: 10, 267: 30, 268: 38, 269: 58, 270: 48,
271: 12, 272: 14, 273: 34, 274: 52, 275: 54, 276: 16, 277: 18, 278: 28, 279: 32, 280: 56,
}
function buildSkillsTableMeta(): Readonly<Record<number, SkillTableMeta>> {
const table: Record<number, SkillTableMeta> = {
0: {
skillId: 0,
skilldesc: 'attack',
passive: 0,
leftskill: 1,
aura: 0,
maxlvl: 0,
itypea1: 'weap',
skillPage: 0,
listRow: 0,
listPool: 0,
iconCel: 2,
strNameKey: 'skillname0',
strShortKey: 'skillsd0',
strLongKey: 'skillld0',
strAltKey: 'skillan0',
},
1: {
skillId: 1,
skilldesc: 'kick',
passive: 0,
leftskill: 0,
aura: 0,
maxlvl: 0,
weapsel: 4,
skillPage: 0,
listRow: -1,
listPool: 0,
iconCel: 0,
strNameKey: 'skillname1',
strShortKey: 'skillsd1',
strLongKey: 'skillld1',
strAltKey: 'skillan1',
},
2: {
skillId: 2,
skilldesc: 'throw',
passive: 0,
leftskill: 1,
aura: 0,
maxlvl: 0,
itypea1: 'thro',
skillPage: 0,
listRow: 0,
listPool: 0,
iconCel: 6,
strNameKey: 'skillname2',
strShortKey: 'skillsd2',
strLongKey: 'skillld2',
strAltKey: 'skillan2',
},
3: {
skillId: 3,
skilldesc: 'unsummon',
passive: 0,
leftskill: 0,
aura: 0,
maxlvl: 0,
skillPage: 0,
listRow: 0,
listPool: 0,
iconCel: 4,
strNameKey: 'skillname3',
strShortKey: 'skillsd3',
strLongKey: 'skillld3',
strAltKey: 'skillan3',
},
4: {
skillId: 4,
skilldesc: 'left hand throw',
passive: 0,
leftskill: 1,
aura: 0,
maxlvl: 0,
itypea1: 'thro',
weapsel: 1,
skillPage: 0,
listRow: 0,
listPool: 0,
iconCel: 12,
strNameKey: 'skillname4',
strShortKey: 'skillsd4',
strLongKey: 'skillld4',
strAltKey: 'skillan4',
},
5: {
skillId: 5,
skilldesc: 'left hand swing',
passive: 0,
leftskill: 1,
aura: 0,
maxlvl: 0,
itypea1: 'weap',
weapsel: 1,
skillPage: 0,
listRow: -1,
listPool: 0,
iconCel: 10,
strNameKey: 'skillname5',
strShortKey: 'skillsd5',
strLongKey: 'skillld5',
strAltKey: 'skillan5',
},
217: {
skillId: 217,
skilldesc: 'scroll of identify',
passive: 0,
leftskill: 0,
aura: 0,
maxlvl: 0,
skillPage: 0,
listRow: 4,
listPool: 1,
iconCel: 14,
strNameKey: 'skillname217',
strShortKey: 'skillsd217',
strLongKey: 'skillld217',
strAltKey: 'skillan217',
descline1: 53,
desctexta1: 'isc',
},
218: {
skillId: 218,
skilldesc: 'book of identify',
passive: 0,
leftskill: 0,
aura: 0,
maxlvl: 0,
skillPage: 0,
listRow: 4,
listPool: 0,
iconCel: 14,
strNameKey: 'skillname218',
strShortKey: 'skillsd218',
strLongKey: 'skillld218',
strAltKey: 'skillan218',
descline1: 55,
desctexta1: 'ibk',
},
219: {
skillId: 219,
skilldesc: 'scroll of townportal',
passive: 0,
leftskill: 0,
aura: 0,
maxlvl: 0,
skillPage: 0,
listRow: 4,
listPool: 2,
iconCel: 18,
strNameKey: 'skillname219',
strShortKey: 'skillsd219',
strLongKey: 'skillld219',
strAltKey: 'skillan219',
descline1: 54,
desctexta1: 'tsc',
},
220: {
skillId: 220,
skilldesc: 'book of townportal',
passive: 0,
leftskill: 0,
aura: 0,
maxlvl: 0,
skillPage: 0,
listRow: 4,
listPool: 0,
iconCel: 18,
strNameKey: 'skillname220',
strShortKey: 'skillsd220',
strLongKey: 'skillld220',
strAltKey: 'skillan220',
descline1: 56,
desctexta1: 'tbk',
},
350: {
skillId: 350,
skilldesc: 'delerium change',
passive: 0,
leftskill: 0,
aura: 0,
maxlvl: 0,
skillPage: 0,
listRow: 0,
listPool: 0,
iconCel: 2,
strNameKey: 'Delerium Change',
strShortKey: 'Delerium Change',
strLongKey: 'Delerium Change',
strAltKey: 'Delerium Change',
},
}
for (const s of SKILLS_CATALOG) {
const skillPage = s.tabIndex + 1
const iconCel = CANONICAL_CLASS_SKILL_ICON_CEL[s.id] ?? 0
table[s.id] = {
skillId: s.id,
skilldesc: s.name.toLowerCase(),
passive: CANONICAL_PASSIVE_COLUMN_ONE.has(s.id) ? 1 : 0,
leftskill: CANONICAL_LEFTSKILL_COLUMN_ONE.has(s.id) ? 1 : 0,
aura: CANONICAL_AURA_COLUMN_ONE.has(s.id) ? 1 : 0,
maxlvl: 20,
skillPage,
listRow: skillPage,
listPool: 0,
iconCel,
strNameKey: `skillname${s.id}`,
strShortKey: `skillsd${s.id}`,
strLongKey: `skillld${s.id}`,
strAltKey: `skillan${s.id}`,
}
}
return table
}
/**
* Canonical 1.13c `skills.txt` + `skilldesc.txt` metadata indexed by skillId.
*/
export const SKILLS_TABLE_META: Readonly<Record<number, SkillTableMeta>> = buildSkillsTableMeta()
export function getSkillTableMeta(skillId: number): SkillTableMeta | undefined {
return SKILLS_TABLE_META[skillId]
}
export function getSkillName(skillId: number, lang: TblLang = getDefaultTblLang()): string {
const generic = GENERIC_SKILL_TBL_STRINGS[skillId]
if (generic) {
return lang === 'ENG' ? generic.nameEn : generic.nameZh
}
const entry = SKILLS_BY_ID[skillId] ?? SKILLS_CATALOG.find(s => s.id === skillId)
if (!entry) return `Skill #${skillId}`
return lang === 'ENG' ? entry.name : (entry.nameZh || entry.name)
}
export function getGenericSkillShortDesc(skillId: number, lang: TblLang = getDefaultTblLang()): string | undefined {
const generic = GENERIC_SKILL_TBL_STRINGS[skillId]
if (!generic) return undefined
return lang === 'ENG' ? generic.shortEn : generic.shortZh
}
export function getGenericSkillLongDesc(skillId: number, lang: TblLang = getDefaultTblLang()): string | undefined {
const generic = GENERIC_SKILL_TBL_STRINGS[skillId]
if (!generic) return undefined
return lang === 'ENG' ? generic.longEn : generic.longZh
}
export function formatDamageTypeEn(type: string): string {
switch (type) {
case 'fire':
return 'Fire Damage'
case 'ltng':
return 'Lightning Damage'
case 'cold':
return 'Cold Damage'
case 'pois':
return 'Poison Damage'
case 'mag':
return 'Magic Damage'
case 'physical':
return 'Physical Damage'
default:
return 'Damage'
}
}
/**
* Diablo II v1.13c Canonical Passive Skills (`skills.txt` `passive = 1`).
* Derived directly from `SKILLS_TABLE_META`.
*/
export const PASSIVE_SKILL_IDS: ReadonlySet<number> = new Set<number>(
Object.values(SKILLS_TABLE_META)
.filter(m => m.passive === 1)
.map(m => m.skillId),
)
export function isPassiveSkill(skillId: number): boolean {
return (SKILLS_TABLE_META[skillId]?.passive ?? 0) === 1
}
/**
* Diablo II v1.13c Paladin Aura Skills (`skills.txt` `aura = 1`).
* Derived directly from `SKILLS_TABLE_META`.
*/
export const PALADIN_AURA_SKILL_IDS: ReadonlySet<number> = new Set<number>(
Object.values(SKILLS_TABLE_META)
.filter(m => m.aura === 1 && m.passive === 0)
.map(m => m.skillId),
)
export function isAuraSkill(skillId: number): boolean {
const meta = SKILLS_TABLE_META[skillId]
if (!meta) return false
return meta.aura === 1 && meta.passive === 0
}
/**
* Diablo II v1.13c Skills usable on the Left-Click action button (`skills.txt` `leftskill = 1`).
* Derived directly from `SKILLS_TABLE_META`.
*/
export const LEFT_USABLE_SKILL_IDS: ReadonlySet<number> = new Set<number>(
Object.values(SKILLS_TABLE_META)
.filter(m => m.leftskill === 1 && m.passive === 0 && m.aura === 0)
.map(m => m.skillId),
)
export function isLeftUsableSkill(skillId: number): boolean {
const meta = SKILLS_TABLE_META[skillId]
if (!meta) return false
return meta.leftskill === 1 && meta.passive === 0 && meta.aura === 0
}
/**
* Returns the maximum base skill level from `skills.txt` `maxlvl` column.
* Per D2MOO `PlrMsg.cpp:2969-2973`, if `maxlvl <= 0`, defaults to `20`.
*/
export function getSkillMaxLevel(skillId: number, maxlvlOverride?: number): number {
const raw = maxlvlOverride !== undefined ? maxlvlOverride : (SKILLS_TABLE_META[skillId]?.maxlvl ?? 0)
return raw > 0 ? raw : 20
}
/**
* Returns the canonical `skilldesc.txt` `ListRow` for a skill.
* Throws if the skill has no `skilldesc` entry and is not an item/charge skill.
*/
export function getCanonicalSkillListRow(skillId: number, isItemSkill?: boolean): number {
if (isItemSkill) return 4
const meta = SKILLS_TABLE_META[skillId]
if (!meta) {
throw new Error(`[skills-meta] Missing canonical skilldesc.txt ListRow metadata for skillId=${skillId}`)
}
return meta.listRow
}
/**
* Returns the canonical `skilldesc.txt` `IconCel` for a skill.
* Throws if the skill has no `skilldesc` entry.
*/
export function getCanonicalSkillIconCel(skillId: number): number {
const meta = SKILLS_TABLE_META[skillId]
if (!meta) {
throw new Error(`[skills-meta] Missing canonical skilldesc.txt IconCel metadata for skillId=${skillId}`)
}
return meta.iconCel
}
/**
* Canonical 1.13c item type codes in the `thro` (Throwing Weapon) hierarchy (`ItemTypes.txt`).
*/
export const THROWING_WEAPON_TYPES: ReadonlySet<string> = new Set<string>([
'tpot',
'tkni',
'taxe',
'jave',
'thro',
'ajav',
])
/**
* Canonical 1.13c `Weapons.txt` item codes whose type inherits from `thro`.
*/
export const THROWING_WEAPON_CODES: ReadonlySet<string> = new Set<string>([
'tkf', 'tax', 'bkf', 'bal', 'jav', 'pil', 'ssp', 'glv', 'tsp',
'gps', 'ops', 'gpm', 'opm', 'gpl', 'opl',
'9tk', '9ta', '9bk', '9b8', '9ja', '9pi', '9s9', '9gl', '9ts',
'7tk', '7ta', '7bk', '7b8', '7ja', '7pi', '7s7', '7gl', '7ts',
'am5', 'ama', 'amf',
])
export function isThrowingWeaponCodeOrType(codeOrType?: string | null): boolean {
if (!codeOrType) return false
const norm = codeOrType.trim().toLowerCase()
return THROWING_WEAPON_CODES.has(norm) || THROWING_WEAPON_TYPES.has(norm)
}
export interface WeaponEquipCheckState {
readonly rightHandCode?: string | null
readonly rightHandType?: string | null
readonly leftHandCode?: string | null
readonly leftHandType?: string | null
readonly isRightThrowing?: boolean
readonly isLeftThrowing?: boolean
}
/**
* Evaluates `skills.txt` weapon requirements (`itypea1 = 'thro'`, `weapsel = 1`) for Throw (#2)
* and Left Hand Throw (#4). When `weaponState` is undefined, returns true unless explicitly checked.
*/
export function canUseSkillWithWeapon(skillId: number, weaponState?: WeaponEquipCheckState): boolean {
const meta = SKILLS_TABLE_META[skillId]
if (!meta) return false
if (meta.itypea1 !== 'thro') return true
if (!weaponState) return true
const rightThrow =
weaponState.isRightThrowing ??
(isThrowingWeaponCodeOrType(weaponState.rightHandCode) ||
isThrowingWeaponCodeOrType(weaponState.rightHandType))
const leftThrow =
weaponState.isLeftThrowing ??
(isThrowingWeaponCodeOrType(weaponState.leftHandCode) ||
isThrowingWeaponCodeOrType(weaponState.leftHandType))
if (meta.weapsel === 1) {
return leftThrow
}
return rightThrow || leftThrow
}
export type { CharacterClassCode }