feat(engine): implement R1 universal 1.13c skill engine, 7-class renderer, arena harness, and M2/E2E suites
This commit is contained in:
parent
d51a1adb4f
commit
550787cb0a
|
|
@ -0,0 +1,545 @@
|
||||||
|
/**
|
||||||
|
* Creates (or retrieves) the Gitea Milestone "All Player Skills (v1.13c Parity)"
|
||||||
|
* on https://git.projectdiablo2.cn/troytt/diablo2-web and creates all 221
|
||||||
|
* individual per-skill tracking issues (210 class skills across 7 classes +
|
||||||
|
* 11 universal player skills) directly from 1.13c Patch_D2.mpq Skills.txt and
|
||||||
|
* player_skills_113c.md.
|
||||||
|
*
|
||||||
|
* Writes the authoritative 221-entry mapping to:
|
||||||
|
* - .agents/sub_orch_m2_gitea/gitea-skill-issues.json
|
||||||
|
* - src/data/gitea-skill-issues.json
|
||||||
|
*/
|
||||||
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||||
|
import { dirname, resolve } from 'node:path'
|
||||||
|
import { MpqArchive } from '../src/mpq/archive.ts'
|
||||||
|
import type { MpqSource } from '../src/mpq/source.ts'
|
||||||
|
|
||||||
|
function memoryMpqSource(path: string): MpqSource {
|
||||||
|
const buf = readFileSync(path)
|
||||||
|
return {
|
||||||
|
size: buf.byteLength,
|
||||||
|
label: path,
|
||||||
|
read: async (offset, length) => new Uint8Array(buf.buffer, buf.byteOffset + offset, length),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const GITEA_BASE_API = 'https://git.projectdiablo2.cn/api/v1/repos/troytt/diablo2-web'
|
||||||
|
const GITEA_WEB_BASE = 'https://git.projectdiablo2.cn/troytt/diablo2-web'
|
||||||
|
const GITEA_TOKEN = process.env.GITEA_TOKEN ?? '94c84cc3bd2eab804346cb889650c843d0cda244'
|
||||||
|
const MILESTONE_TITLE = 'All Player Skills (v1.13c Parity)'
|
||||||
|
|
||||||
|
const PATCH_MPQ_PATH = '/usr/local/google/home/taodao/d2-data/Patch_D2.mpq'
|
||||||
|
const SURVEY_SKILLS_MD =
|
||||||
|
'/usr/local/google/home/taodao/diablo2-web/.agents/teamwork_preview_spec_miner_survey_2/player_skills_113c.md'
|
||||||
|
const OUT_AGENTS_JSON =
|
||||||
|
'/usr/local/google/home/taodao/diablo2-web/.agents/sub_orch_m2_gitea/gitea-skill-issues.json'
|
||||||
|
const OUT_SRC_DATA_JSON =
|
||||||
|
'/usr/local/google/home/taodao/diablo2-web/src/data/gitea-skill-issues.json'
|
||||||
|
|
||||||
|
const UNIVERSAL_SKILL_IDS = new Set([0, 1, 2, 3, 4, 5, 217, 218, 219, 220, 350])
|
||||||
|
|
||||||
|
const CLASS_LABELS: Record<string, string> = {
|
||||||
|
uni: 'Universal / Item-Granted',
|
||||||
|
ama: 'Amazon',
|
||||||
|
sor: 'Sorceress',
|
||||||
|
nec: 'Necromancer',
|
||||||
|
pal: 'Paladin',
|
||||||
|
bar: 'Barbarian',
|
||||||
|
dru: 'Druid',
|
||||||
|
ass: 'Assassin',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillSpecRow {
|
||||||
|
skillId: number
|
||||||
|
name: string
|
||||||
|
skillDesc: string
|
||||||
|
charClass: 'uni' | 'ama' | 'sor' | 'nec' | 'pal' | 'bar' | 'dru' | 'ass'
|
||||||
|
slug: string
|
||||||
|
id3: string
|
||||||
|
reqlevel: number
|
||||||
|
reqskill1: string
|
||||||
|
reqskill2: string
|
||||||
|
reqskill3: string
|
||||||
|
srvstfunc: string
|
||||||
|
srvdofunc: string
|
||||||
|
srvprgfunc1: string
|
||||||
|
srvprgfunc2: string
|
||||||
|
srvprgfunc3: string
|
||||||
|
cltstfunc: string
|
||||||
|
cltdofunc: string
|
||||||
|
cltprgfunc1: string
|
||||||
|
cltprgfunc2: string
|
||||||
|
cltprgfunc3: string
|
||||||
|
passive: string
|
||||||
|
passivestate: string
|
||||||
|
passivestat1: string
|
||||||
|
passivestat2: string
|
||||||
|
passivestat3: string
|
||||||
|
checkfunc: string
|
||||||
|
srvmissile: string
|
||||||
|
srvmissilea: string
|
||||||
|
srvmissileb: string
|
||||||
|
srvmissilec: string
|
||||||
|
cltmissile: string
|
||||||
|
cltmissilea: string
|
||||||
|
aurastate: string
|
||||||
|
auratgtstate: string
|
||||||
|
aurastat1: string
|
||||||
|
aurastat2: string
|
||||||
|
aurastat3: string
|
||||||
|
auraevent1: string
|
||||||
|
auraeventfunc1: string
|
||||||
|
cursetype: string
|
||||||
|
summon: string
|
||||||
|
pettype: string
|
||||||
|
petmax: string
|
||||||
|
mana: string
|
||||||
|
lvlmana: string
|
||||||
|
minDam: string
|
||||||
|
maxDam: string
|
||||||
|
eType: string
|
||||||
|
eMin: string
|
||||||
|
eMax: string
|
||||||
|
eLen: string
|
||||||
|
calc1: string
|
||||||
|
calc2: string
|
||||||
|
calc3: string
|
||||||
|
calc4: string
|
||||||
|
edmgSymPerCalc: string
|
||||||
|
dmgSymPerCalc: string
|
||||||
|
elenSymPerCalc: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GiteaSkillIssueRecord {
|
||||||
|
skillId: number
|
||||||
|
id3: string
|
||||||
|
name: string
|
||||||
|
title: string
|
||||||
|
charClass: 'uni' | 'ama' | 'sor' | 'nec' | 'pal' | 'bar' | 'dru' | 'ass'
|
||||||
|
slug: string
|
||||||
|
issueNumber: number
|
||||||
|
issueId: number
|
||||||
|
milestoneId: number
|
||||||
|
url: string
|
||||||
|
implPath: string
|
||||||
|
testPath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSlug(name: string): string {
|
||||||
|
return name
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTargetPlayerSkill(id: number): boolean {
|
||||||
|
if (UNIVERSAL_SKILL_IDS.has(id)) return true
|
||||||
|
if (id >= 6 && id <= 155) return true
|
||||||
|
if (id >= 221 && id <= 280) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCharClass(
|
||||||
|
id: number,
|
||||||
|
rawClass: string,
|
||||||
|
): 'uni' | 'ama' | 'sor' | 'nec' | 'pal' | 'bar' | 'dru' | 'ass' {
|
||||||
|
if (UNIVERSAL_SKILL_IDS.has(id)) return 'uni'
|
||||||
|
const c = rawClass.trim().toLowerCase()
|
||||||
|
if (
|
||||||
|
c === 'ama' ||
|
||||||
|
c === 'sor' ||
|
||||||
|
c === 'nec' ||
|
||||||
|
c === 'pal' ||
|
||||||
|
c === 'bar' ||
|
||||||
|
c === 'dru' ||
|
||||||
|
c === 'ass'
|
||||||
|
) {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
if (id >= 6 && id <= 35) return 'ama'
|
||||||
|
if (id >= 36 && id <= 65) return 'sor'
|
||||||
|
if (id >= 66 && id <= 95) return 'nec'
|
||||||
|
if (id >= 96 && id <= 125) return 'pal'
|
||||||
|
if (id >= 126 && id <= 155) return 'bar'
|
||||||
|
if (id >= 221 && id <= 250) return 'dru'
|
||||||
|
if (id >= 251 && id <= 280) return 'ass'
|
||||||
|
return 'uni'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load113cSkillsFromMpqAndMd(): Promise<SkillSpecRow[]> {
|
||||||
|
// 1. Parse survey markdown table to verify exact 221 skill coverage
|
||||||
|
const mdText = readFileSync(SURVEY_SKILLS_MD, 'utf8')
|
||||||
|
const mdRows = new Map<number, { name: string; rawClass: string }>()
|
||||||
|
for (const line of mdText.split('\n')) {
|
||||||
|
const match = /^\|\s*(\d+)\s*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|/.exec(line)
|
||||||
|
if (match) {
|
||||||
|
const id = Number(match[1])
|
||||||
|
if (isTargetPlayerSkill(id)) {
|
||||||
|
mdRows.set(id, { name: match[2]!.trim(), rawClass: match[3]!.trim() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Parse authoritative 1.13c Skills.txt from Patch_D2.mpq
|
||||||
|
const archive = await MpqArchive.open(memoryMpqSource(PATCH_MPQ_PATH))
|
||||||
|
const skillsEntry = archive.find('data\\global\\excel\\Skills.txt')
|
||||||
|
if (!skillsEntry) {
|
||||||
|
throw new Error(`Could not locate data\\global\\excel\\Skills.txt in ${PATCH_MPQ_PATH}`)
|
||||||
|
}
|
||||||
|
const rawBytes = await archive.read(skillsEntry)
|
||||||
|
const text = new TextDecoder('utf-8').decode(rawBytes)
|
||||||
|
const lines = text.split(/\r?\n/).filter((l) => l.trim().length > 0)
|
||||||
|
const headers = lines[0]!.split('\t').map((h) => h.trim())
|
||||||
|
const colIdx = new Map<string, number>()
|
||||||
|
headers.forEach((h, idx) => {
|
||||||
|
if (!colIdx.has(h.toLowerCase())) {
|
||||||
|
colIdx.set(h.toLowerCase(), idx)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const getCol = (cols: string[], name: string): string => {
|
||||||
|
const idx = colIdx.get(name.toLowerCase())
|
||||||
|
if (idx === undefined) return '-'
|
||||||
|
const val = (cols[idx] ?? '').trim()
|
||||||
|
return val.length > 0 ? val : '-'
|
||||||
|
}
|
||||||
|
|
||||||
|
const specsById = new Map<number, SkillSpecRow>()
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const cols = lines[i]!.split('\t')
|
||||||
|
const idStr = getCol(cols, 'Id')
|
||||||
|
if (idStr === '-' || !/^\d+$/.test(idStr)) continue
|
||||||
|
const skillId = Number(idStr)
|
||||||
|
if (!isTargetPlayerSkill(skillId)) continue
|
||||||
|
|
||||||
|
const name = getCol(cols, 'skill') !== '-' ? getCol(cols, 'skill') : (mdRows.get(skillId)?.name ?? `Skill ${skillId}`)
|
||||||
|
const charClass = resolveCharClass(skillId, getCol(cols, 'charclass'))
|
||||||
|
const slug = toSlug(name)
|
||||||
|
const id3 = String(skillId).padStart(3, '0')
|
||||||
|
const reqlevelRaw = getCol(cols, 'reqlevel')
|
||||||
|
|
||||||
|
specsById.set(skillId, {
|
||||||
|
skillId,
|
||||||
|
name,
|
||||||
|
skillDesc: getCol(cols, 'skilldesc'),
|
||||||
|
charClass,
|
||||||
|
slug,
|
||||||
|
id3,
|
||||||
|
reqlevel: reqlevelRaw !== '-' ? Number(reqlevelRaw) : 1,
|
||||||
|
reqskill1: getCol(cols, 'reqskill1'),
|
||||||
|
reqskill2: getCol(cols, 'reqskill2'),
|
||||||
|
reqskill3: getCol(cols, 'reqskill3'),
|
||||||
|
srvstfunc: getCol(cols, 'srvstfunc'),
|
||||||
|
srvdofunc: getCol(cols, 'srvdofunc'),
|
||||||
|
srvprgfunc1: getCol(cols, 'srvprgfunc1'),
|
||||||
|
srvprgfunc2: getCol(cols, 'srvprgfunc2'),
|
||||||
|
srvprgfunc3: getCol(cols, 'srvprgfunc3'),
|
||||||
|
cltstfunc: getCol(cols, 'cltstfunc'),
|
||||||
|
cltdofunc: getCol(cols, 'cltdofunc'),
|
||||||
|
cltprgfunc1: getCol(cols, 'cltprgfunc1'),
|
||||||
|
cltprgfunc2: getCol(cols, 'cltprgfunc2'),
|
||||||
|
cltprgfunc3: getCol(cols, 'cltprgfunc3'),
|
||||||
|
passive: getCol(cols, 'passive'),
|
||||||
|
passivestate: getCol(cols, 'passivestate'),
|
||||||
|
passivestat1: getCol(cols, 'passivestat1'),
|
||||||
|
passivestat2: getCol(cols, 'passivestat2'),
|
||||||
|
passivestat3: getCol(cols, 'passivestat3'),
|
||||||
|
checkfunc: getCol(cols, 'checkfunc'),
|
||||||
|
srvmissile: getCol(cols, 'srvmissile'),
|
||||||
|
srvmissilea: getCol(cols, 'srvmissilea'),
|
||||||
|
srvmissileb: getCol(cols, 'srvmissileb'),
|
||||||
|
srvmissilec: getCol(cols, 'srvmissilec'),
|
||||||
|
cltmissile: getCol(cols, 'cltmissile'),
|
||||||
|
cltmissilea: getCol(cols, 'cltmissilea'),
|
||||||
|
aurastate: getCol(cols, 'aurastate'),
|
||||||
|
auratgtstate: getCol(cols, 'auratgtstate'),
|
||||||
|
aurastat1: getCol(cols, 'aurastat1'),
|
||||||
|
aurastat2: getCol(cols, 'aurastat2'),
|
||||||
|
aurastat3: getCol(cols, 'aurastat3'),
|
||||||
|
auraevent1: getCol(cols, 'auraevent1'),
|
||||||
|
auraeventfunc1: getCol(cols, 'auraeventfunc1'),
|
||||||
|
cursetype: getCol(cols, 'cursetype'),
|
||||||
|
summon: getCol(cols, 'summon'),
|
||||||
|
pettype: getCol(cols, 'pettype'),
|
||||||
|
petmax: getCol(cols, 'petmax'),
|
||||||
|
mana: getCol(cols, 'mana'),
|
||||||
|
lvlmana: getCol(cols, 'lvlmana'),
|
||||||
|
minDam: getCol(cols, 'MinDam'),
|
||||||
|
maxDam: getCol(cols, 'MaxDam'),
|
||||||
|
eType: getCol(cols, 'EType'),
|
||||||
|
eMin: getCol(cols, 'EMin'),
|
||||||
|
eMax: getCol(cols, 'EMax'),
|
||||||
|
eLen: getCol(cols, 'ELen'),
|
||||||
|
calc1: getCol(cols, 'calc1'),
|
||||||
|
calc2: getCol(cols, 'calc2'),
|
||||||
|
calc3: getCol(cols, 'calc3'),
|
||||||
|
calc4: getCol(cols, 'calc4'),
|
||||||
|
edmgSymPerCalc: getCol(cols, 'EDmgSymPerCalc'),
|
||||||
|
dmgSymPerCalc: getCol(cols, 'DmgSymPerCalc'),
|
||||||
|
elenSymPerCalc: getCol(cols, 'ELenSymPerCalc'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const ordered = [...specsById.values()].sort((a, b) => a.skillId - b.skillId)
|
||||||
|
if (ordered.length !== 221) {
|
||||||
|
throw new Error(`Expected exactly 221 player skills, found ${ordered.length}`)
|
||||||
|
}
|
||||||
|
return ordered
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIssueTitle(spec: SkillSpecRow): string {
|
||||||
|
return `[Skill #${spec.id3}] [${spec.charClass.toUpperCase()}] ${spec.name} (1.13c Parity)`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIssueBody(spec: SkillSpecRow): string {
|
||||||
|
const implPath = `src/game/skills/impl/${spec.charClass}/skill-${spec.id3}-${spec.slug}.ts`
|
||||||
|
const testPath = `tests/skills/${spec.charClass}/skill-${spec.id3}-${spec.slug}.test.ts`
|
||||||
|
const classFull = CLASS_LABELS[spec.charClass] ?? spec.charClass
|
||||||
|
|
||||||
|
return `## Diablo II: Lord of Destruction v1.13c Skill Parity Specification
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|---|---|
|
||||||
|
| **Skill ID** | \`${spec.skillId}\` (\`#${spec.id3}\`) |
|
||||||
|
| **Skill Name** | **${spec.name}** (\`skilldesc: ${spec.skillDesc}\`) |
|
||||||
|
| **Class** | \`${spec.charClass}\` (${classFull}) |
|
||||||
|
| **Required Level (\`reqlevel\`)** | \`${spec.reqlevel}\` |
|
||||||
|
| **Prerequisites (\`reqskill1..3\`)** | \`${spec.reqskill1}\`, \`${spec.reqskill2}\`, \`${spec.reqskill3}\` |
|
||||||
|
| **Isolated Implementation Module** | \`${implPath}\` |
|
||||||
|
| **Isolated Vitest Suite** | \`${testPath}\` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 1. Authoritative 1.13c \`Skills.txt\` Function & Handler Dispatch
|
||||||
|
| Function Slot | 1.13c Value |
|
||||||
|
|---|---|
|
||||||
|
| **\`srvstfunc\`** (Server Start / Pre-Cast) | \`${spec.srvstfunc}\` |
|
||||||
|
| **\`srvdofunc\`** (Server Action Frame \`0x01\`/\`0x02\`) | \`${spec.srvdofunc}\` |
|
||||||
|
| **\`srvprgfunc1..3\`** (Progressive Charge Release) | \`${spec.srvprgfunc1}\` / \`${spec.srvprgfunc2}\` / \`${spec.srvprgfunc3}\` |
|
||||||
|
| **\`cltstfunc\` / \`cltdofunc\`** (Client Start / Action) | \`${spec.cltstfunc}\` / \`${spec.cltdofunc}\` |
|
||||||
|
| **\`cltprgfunc1..3\`** (Client Progressive Release) | \`${spec.cltprgfunc1}\` / \`${spec.cltprgfunc2}\` / \`${spec.cltprgfunc3}\` |
|
||||||
|
| **\`passive\` / \`checkfunc\`** | \`passive=${spec.passive}\` / \`checkfunc=${spec.checkfunc}\` |
|
||||||
|
| **\`auraevent1\` / \`auraeventfunc1\`** | \`${spec.auraevent1}\` / \`${spec.auraeventfunc1}\` |
|
||||||
|
|
||||||
|
### 2. Associated Missiles, States, Auras, Curses & Summons
|
||||||
|
| Subsystem | 1.13c Configuration |
|
||||||
|
|---|---|
|
||||||
|
| **Server Missiles (\`srvmissile\` / \`a..c\`)** | \`${spec.srvmissile}\` / \`a:${spec.srvmissilea}\`, \`b:${spec.srvmissileb}\`, \`c:${spec.srvmissilec}\` |
|
||||||
|
| **Client Missiles (\`cltmissile\` / \`cltmissilea\`)** | \`${spec.cltmissile}\` / \`${spec.cltmissilea}\` |
|
||||||
|
| **Aura / Buff States (\`aurastate\` / \`auratgtstate\`)** | \`self:${spec.aurastate}\` / \`tgt:${spec.auratgtstate}\` |
|
||||||
|
| **Aura / Buff Stats (\`aurastat1..3\`)** | \`${spec.aurastat1}\`, \`${spec.aurastat2}\`, \`${spec.aurastat3}\` |
|
||||||
|
| **Passive State & Stats (\`passivestate\` / \`passivestat1..3\`)** | \`state:${spec.passivestate}\` / \`${spec.passivestat1}\`, \`${spec.passivestat2}\`, \`${spec.passivestat3}\` |
|
||||||
|
| **Curse Priority (\`cursetype\`)** | \`${spec.cursetype}\` |
|
||||||
|
| **Summon / Pet (\`summon\` / \`pettype\` / \`petmax\`)** | \`mon:${spec.summon}\` / \`type:${spec.pettype}\` / \`max:${spec.petmax}\` |
|
||||||
|
|
||||||
|
### 3. 1.13c Base Numerical & Synergy Formulas (\`Patch_D2.mpq\`)
|
||||||
|
- **Mana Cost**: \`mana=${spec.mana}\`, \`lvlmana=${spec.lvlmana}\` (256-fixed-point \`<< HitShift\`)
|
||||||
|
- **Physical / Elemental Damage**: \`MinDam=${spec.minDam}\`, \`MaxDam=${spec.maxDam}\`, \`EType=${spec.eType}\`, \`EMin=${spec.eMin}\`, \`EMax=${spec.eMax}\`, \`ELen=${spec.eLen}\`
|
||||||
|
- **AST \`calc1..4\`**: \`calc1=${spec.calc1}\`, \`calc2=${spec.calc2}\`, \`calc3=${spec.calc3}\`, \`calc4=${spec.calc4}\`
|
||||||
|
- **Synergy Calculations (\`blvl\` strictly base allocated \`STAT_SKILLPTS\` only)**:
|
||||||
|
- \`EDmgSymPerCalc\`: \`${spec.edmgSymPerCalc}\`
|
||||||
|
- \`DmgSymPerCalc\`: \`${spec.dmgSymPerCalc}\`
|
||||||
|
- \`ELenSymPerCalc\`: \`${spec.elenSymPerCalc}\`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. R3 Verification & Merge Checklist
|
||||||
|
- [ ] **Isolated Git Worktree**: Created via \`git worktree add ../wt-skill-${spec.skillId} -b feat/skill-${spec.skillId}\` from \`main\`.
|
||||||
|
- [ ] **1.13c Implementation**: \`${implPath}\` implements exact 1.13c \`srvstfunc\`/\`srvdofunc\`/\`srvprgfunc\` & numerical evaluation.
|
||||||
|
- [ ] **Automated Unit & Numerical Tests (\`slvl\` 1, 10, 20 + \`blvl\` Synergies)**: \`${testPath}\` passes 100% in Vitest verifying \`ManaCost\`, \`MinDmg\`, \`MaxDmg\`, \`ToHit\`, \`Duration\`, \`Radius\`, synergy scaling, and runtime combat/missile/state/summon effect.
|
||||||
|
- [ ] **Real-Browser Casting & Screenshot**: Executed in headless Chromium (\`acts.html?arena=1&class=${spec.charClass}&skill=${spec.skillId}&slvl=20&autocast=1\`) with zero console/page errors and PNG screenshot attached to this Issue.
|
||||||
|
- [ ] **Merge & Cleanup**: Merged to \`main\` with \`fixes #<issue_number>\`, pushed to \`origin/main\`, verified Issue is \`closed\`, and removed worktree \`../wt-skill-${spec.skillId}\` + branch \`feat/skill-${spec.skillId}\`.
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function giteaRequest<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const url = path.startsWith('http') ? path : `${GITEA_BASE_API}${path}`
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Authorization: `token ${GITEA_TOKEN}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
...(init?.headers as Record<string, string> | undefined),
|
||||||
|
}
|
||||||
|
if (init?.body && typeof init.body === 'string' && !headers['Content-Type']) {
|
||||||
|
headers['Content-Type'] = 'application/json'
|
||||||
|
}
|
||||||
|
for (let attempt = 1; attempt <= 4; attempt++) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { ...init, headers })
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text()
|
||||||
|
if (attempt < 4 && (res.status >= 500 || res.status === 429)) {
|
||||||
|
await new Promise((r) => setTimeout(r, 300 * attempt))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
throw new Error(`Gitea API ${init?.method ?? 'GET'} ${url} failed (${res.status}): ${text}`)
|
||||||
|
}
|
||||||
|
return (await res.json()) as T
|
||||||
|
} catch (err) {
|
||||||
|
if (attempt === 4) throw err
|
||||||
|
await new Promise((r) => setTimeout(r, 300 * attempt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Unreachable Gitea API error for ${url}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureMilestone(): Promise<{ id: number; title: string }> {
|
||||||
|
const existing = await giteaRequest<Array<{ id: number; title: string; state: string }>>(
|
||||||
|
'/milestones?state=all&limit=100',
|
||||||
|
)
|
||||||
|
const found = existing.find((m) => m.title === MILESTONE_TITLE)
|
||||||
|
if (found) {
|
||||||
|
if (found.state !== 'open') {
|
||||||
|
await giteaRequest(`/milestones/${found.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ title: MILESTONE_TITLE, state: 'open' }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { id: found.id, title: found.title }
|
||||||
|
}
|
||||||
|
const created = await giteaRequest<{ id: number; title: string }>('/milestones', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: MILESTONE_TITLE,
|
||||||
|
description:
|
||||||
|
'Complete Diablo II: Lord of Destruction v1.13c player skill system (210 class skills across all 7 classes + 11 universal player skills = 221 tracking issues).',
|
||||||
|
state: 'open',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
return { id: created.id, title: created.title }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchExistingSkillIssues(
|
||||||
|
milestoneId: number,
|
||||||
|
): Promise<Map<number, { number: number; id: number; title: string }>> {
|
||||||
|
const map = new Map<number, { number: number; id: number; title: string }>()
|
||||||
|
for (let page = 1; page <= 10; page++) {
|
||||||
|
const issues = await giteaRequest<Array<{ id: number; number: number; title: string }>>(
|
||||||
|
`/issues?state=all&type=issues&milestones=${milestoneId}&limit=50&page=${page}`,
|
||||||
|
)
|
||||||
|
if (!issues || issues.length === 0) break
|
||||||
|
for (const iss of issues) {
|
||||||
|
const m = /^\[Skill #(\d+)\]/.exec(iss.title)
|
||||||
|
if (m) {
|
||||||
|
const skillId = Number(m[1])
|
||||||
|
if (!map.has(skillId)) {
|
||||||
|
map.set(skillId, { number: iss.number, id: iss.id, title: iss.title })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (issues.length < 50) break
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
console.log('1. Loading 221 player skills from 1.13c Patch_D2.mpq (Skills.txt) & survey...')
|
||||||
|
const skills = await load113cSkillsFromMpqAndMd()
|
||||||
|
console.log(` Loaded ${skills.length} player skills (210 class skills + 11 universal skills).`)
|
||||||
|
|
||||||
|
console.log(`2. Ensuring Gitea Milestone "${MILESTONE_TITLE}"...`)
|
||||||
|
const milestone = await ensureMilestone()
|
||||||
|
console.log(` Milestone ID: ${milestone.id} ("${milestone.title}")`)
|
||||||
|
|
||||||
|
console.log('3. Checking existing Gitea issues in milestone...')
|
||||||
|
const existingBySkillId = await fetchExistingSkillIssues(milestone.id)
|
||||||
|
console.log(` Found ${existingBySkillId.size} existing skill issues in Milestone #${milestone.id}.`)
|
||||||
|
|
||||||
|
const recordsBySkillId = new Map<number, GiteaSkillIssueRecord>()
|
||||||
|
|
||||||
|
// Process in sequential order of skillId (or small batches if creating) so issue numbers are clean and ordered!
|
||||||
|
// Let's create sequentially or in ordered batches of 5 so it completes in ~10-15 seconds while maintaining deterministic order.
|
||||||
|
const toCreate: SkillSpecRow[] = []
|
||||||
|
for (const spec of skills) {
|
||||||
|
const existing = existingBySkillId.get(spec.skillId)
|
||||||
|
const title = buildIssueTitle(spec)
|
||||||
|
const implPath = `src/game/skills/impl/${spec.charClass}/skill-${spec.id3}-${spec.slug}.ts`
|
||||||
|
const testPath = `tests/skills/${spec.charClass}/skill-${spec.id3}-${spec.slug}.test.ts`
|
||||||
|
if (existing) {
|
||||||
|
recordsBySkillId.set(spec.skillId, {
|
||||||
|
skillId: spec.skillId,
|
||||||
|
id3: spec.id3,
|
||||||
|
name: spec.name,
|
||||||
|
title: existing.title,
|
||||||
|
charClass: spec.charClass,
|
||||||
|
slug: spec.slug,
|
||||||
|
issueNumber: existing.number,
|
||||||
|
issueId: existing.id,
|
||||||
|
milestoneId: milestone.id,
|
||||||
|
url: `${GITEA_WEB_BASE}/issues/${existing.number}`,
|
||||||
|
implPath,
|
||||||
|
testPath,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
toCreate.push(spec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`4. Creating ${toCreate.length} new Gitea issues for Milestone #${milestone.id}...`)
|
||||||
|
let createdCount = 0
|
||||||
|
for (const spec of toCreate) {
|
||||||
|
const title = buildIssueTitle(spec)
|
||||||
|
const body = buildIssueBody(spec)
|
||||||
|
const created = await giteaRequest<{ id: number; number: number; title: string }>('/issues', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
milestone: milestone.id,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const implPath = `src/game/skills/impl/${spec.charClass}/skill-${spec.id3}-${spec.slug}.ts`
|
||||||
|
const testPath = `tests/skills/${spec.charClass}/skill-${spec.id3}-${spec.slug}.test.ts`
|
||||||
|
recordsBySkillId.set(spec.skillId, {
|
||||||
|
skillId: spec.skillId,
|
||||||
|
id3: spec.id3,
|
||||||
|
name: spec.name,
|
||||||
|
title: created.title,
|
||||||
|
charClass: spec.charClass,
|
||||||
|
slug: spec.slug,
|
||||||
|
issueNumber: created.number,
|
||||||
|
issueId: created.id,
|
||||||
|
milestoneId: milestone.id,
|
||||||
|
url: `${GITEA_WEB_BASE}/issues/${created.number}`,
|
||||||
|
implPath,
|
||||||
|
testPath,
|
||||||
|
})
|
||||||
|
createdCount++
|
||||||
|
if (createdCount % 25 === 0 || createdCount === toCreate.length) {
|
||||||
|
console.log(` Progress: ${createdCount}/${toCreate.length} issues created (latest: #${created.number} -> Skill ${spec.skillId} ${spec.name})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build final sorted mapping object keyed by stringified skillId ("0".."350")
|
||||||
|
const sortedIds = [...recordsBySkillId.keys()].sort((a, b) => a - b)
|
||||||
|
if (sortedIds.length !== 221) {
|
||||||
|
throw new Error(`Expected 221 mapped issues, got ${sortedIds.length}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputMap: Record<string, GiteaSkillIssueRecord> = {}
|
||||||
|
for (const id of sortedIds) {
|
||||||
|
outputMap[String(id)] = recordsBySkillId.get(id)!
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonPayload = JSON.stringify(outputMap, null, 2) + '\n'
|
||||||
|
for (const targetPath of [OUT_AGENTS_JSON, OUT_SRC_DATA_JSON]) {
|
||||||
|
mkdirSync(dirname(resolve(targetPath)), { recursive: true })
|
||||||
|
writeFileSync(targetPath, jsonPayload, 'utf8')
|
||||||
|
console.log(` Saved 221-skill mapping to ${targetPath}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify Milestone status via Gitea API
|
||||||
|
const msStatus = await giteaRequest<{
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
open_issues: number
|
||||||
|
closed_issues: number
|
||||||
|
}>(`/milestones/${milestone.id}`)
|
||||||
|
console.log(
|
||||||
|
`5. Verified Milestone #${msStatus.id} ("${msStatus.title}"): open_issues=${msStatus.open_issues}, closed_issues=${msStatus.closed_issues}, total=${msStatus.open_issues + msStatus.closed_issues}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('FATAL:', err)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
/**
|
||||||
|
* R3 Isolated Worktree & Gitea Evidence Upload Helper (`scripts/gitea-skill-workflow.ts`).
|
||||||
|
*
|
||||||
|
* Provides programmatic helpers for R3 skill workers to upload headless Chromium
|
||||||
|
* screenshots (`POST /api/v1/repos/troytt/diablo2-web/issues/{index}/assets`),
|
||||||
|
* post 1.13c numerical & runtime verification comments, and close Gitea issues.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { basename } from 'node:path'
|
||||||
|
|
||||||
|
const GITEA_BASE_URL = 'https://git.projectdiablo2.cn/api/v1/repos/troytt/diablo2-web'
|
||||||
|
const GITEA_TOKEN = '94c84cc3bd2eab804346cb889650c843d0cda244'
|
||||||
|
|
||||||
|
export interface GiteaAssetUploadResult {
|
||||||
|
readonly id: number
|
||||||
|
readonly name: string
|
||||||
|
readonly uuid: string
|
||||||
|
readonly browser_download_url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadPngToGiteaIssue(
|
||||||
|
issueNumber: number,
|
||||||
|
pngPath: string,
|
||||||
|
): Promise<GiteaAssetUploadResult> {
|
||||||
|
const bytes = readFileSync(pngPath)
|
||||||
|
const fileName = basename(pngPath)
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('attachment', new Blob([bytes], { type: 'image/png' }), fileName)
|
||||||
|
|
||||||
|
const res = await fetch(`${GITEA_BASE_URL}/issues/${String(issueNumber)}/assets?name=${encodeURIComponent(fileName)}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `token ${GITEA_TOKEN}`,
|
||||||
|
},
|
||||||
|
body: form,
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to upload PNG to issue #${String(issueNumber)}: HTTP ${String(res.status)}`)
|
||||||
|
}
|
||||||
|
return (await res.json()) as GiteaAssetUploadResult
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function postGiteaIssueComment(
|
||||||
|
issueNumber: number,
|
||||||
|
markdownBody: string,
|
||||||
|
): Promise<{ id: number; body: string }> {
|
||||||
|
const res = await fetch(`${GITEA_BASE_URL}/issues/${String(issueNumber)}/comments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `token ${GITEA_TOKEN}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ body: markdownBody }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to post comment on issue #${String(issueNumber)}: HTTP ${String(res.status)}`)
|
||||||
|
}
|
||||||
|
return (await res.json()) as { id: number; body: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeGiteaIssue(issueNumber: number): Promise<void> {
|
||||||
|
const res = await fetch(`${GITEA_BASE_URL}/issues/${String(issueNumber)}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
Authorization: `token ${GITEA_TOKEN}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ state: 'closed' }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to close issue #${String(issueNumber)}: HTTP ${String(res.status)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,255 @@
|
||||||
|
/**
|
||||||
|
* Headless Chromium Skill Verification & Screenshot Runner (`scripts/verify-skill-browser.ts`).
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npx tsx scripts/verify-skill-browser.ts <skillId> [--class=sor] [--slvl=20] [--out=/tmp/skill-47.png]
|
||||||
|
*
|
||||||
|
* Launches Vite (if not already running on 127.0.0.1:5173) + headless Chromium
|
||||||
|
* (`/usr/local/google/home/taodao/.cache/ms-playwright/chromium-1228/chrome-linux64/chrome`),
|
||||||
|
* opens `/acts.html?arena=1&class=<cls>&skill=<skillId>&slvl=<slvl>&autocast=1`,
|
||||||
|
* verifies `window.__d2webArena.ready === true && castCount >= 1` and zero runtime errors,
|
||||||
|
* and saves a PNG screenshot to `--out`.
|
||||||
|
*/
|
||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||||
|
import { dirname, resolve } from 'node:path'
|
||||||
|
import { createServer } from 'vite'
|
||||||
|
import { getSharedDataRegistry } from '../src/game/engine/data-registry.ts'
|
||||||
|
|
||||||
|
const CHROME_CANDIDATES = [
|
||||||
|
'/usr/local/google/home/taodao/.cache/ms-playwright/chromium-1228/chrome-linux64/chrome',
|
||||||
|
'/usr/bin/google-chrome',
|
||||||
|
]
|
||||||
|
|
||||||
|
function resolveChromeBinary(): string {
|
||||||
|
for (const bin of CHROME_CANDIDATES) {
|
||||||
|
if (existsSync(bin)) return bin
|
||||||
|
}
|
||||||
|
throw new Error(`No accessible Chromium binary found among: ${CHROME_CANDIDATES.join(', ')}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise(res => setTimeout(res, ms))
|
||||||
|
|
||||||
|
export interface BrowserSkillVerificationResult {
|
||||||
|
readonly ok: boolean
|
||||||
|
readonly url: string
|
||||||
|
readonly screenshotPath: string
|
||||||
|
readonly screenshotBytes: number
|
||||||
|
readonly consoleErrors: string[]
|
||||||
|
readonly arenaState: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifySkillInBrowser(params: {
|
||||||
|
skillId: number
|
||||||
|
classCode?: string
|
||||||
|
slvl?: number
|
||||||
|
screenshotPath: string
|
||||||
|
baseUrl?: string
|
||||||
|
}): Promise<BrowserSkillVerificationResult> {
|
||||||
|
const registry = await getSharedDataRegistry()
|
||||||
|
const skillRec = registry.getSkillById(params.skillId)
|
||||||
|
const classCode = (params.classCode || skillRec?.charClass || 'sor').toLowerCase()
|
||||||
|
const slvl = params.slvl ?? 20
|
||||||
|
|
||||||
|
let viteServer: Awaited<ReturnType<typeof createServer>> | null = null
|
||||||
|
let baseUrl = params.baseUrl ?? ''
|
||||||
|
|
||||||
|
if (!baseUrl) {
|
||||||
|
const chosenPort = 5200 + Math.floor(Math.random() * 600)
|
||||||
|
viteServer = await createServer({
|
||||||
|
root: resolve('.'),
|
||||||
|
logLevel: 'silent',
|
||||||
|
server: {
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: chosenPort,
|
||||||
|
strictPort: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await viteServer.listen()
|
||||||
|
const addr = viteServer.httpServer?.address()
|
||||||
|
const port = typeof addr === 'object' && addr ? addr.port : chosenPort
|
||||||
|
baseUrl = `http://127.0.0.1:${String(port)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetUrl = `${baseUrl}/acts.html?arena=1&class=${encodeURIComponent(classCode)}&skill=${String(params.skillId)}&slvl=${String(slvl)}&autocast=1`
|
||||||
|
const chromeBin = resolveChromeBinary()
|
||||||
|
const debugPort = 9300 + Math.floor(Math.random() * 500)
|
||||||
|
|
||||||
|
const chrome = spawn(
|
||||||
|
chromeBin,
|
||||||
|
[
|
||||||
|
'--headless=new',
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
'--window-size=1024,680',
|
||||||
|
`--remote-debugging-port=${String(debugPort)}`,
|
||||||
|
targetUrl,
|
||||||
|
],
|
||||||
|
{ stdio: ['ignore', 'ignore', 'pipe'] },
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
let pageTarget: { webSocketDebuggerUrl: string; url: string } | null = null
|
||||||
|
for (let i = 0; i < 80; i += 1) {
|
||||||
|
await sleep(150)
|
||||||
|
try {
|
||||||
|
const list = (await (await fetch(`http://127.0.0.1:${String(debugPort)}/json`)).json()) as {
|
||||||
|
type: string
|
||||||
|
url: string
|
||||||
|
webSocketDebuggerUrl: string
|
||||||
|
}[]
|
||||||
|
const pages = list.filter(x => x.type === 'page')
|
||||||
|
const matched = pages.find(x => x.url.includes('acts.html'))
|
||||||
|
if (matched && matched.webSocketDebuggerUrl) {
|
||||||
|
pageTarget = matched
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
if (!pageTarget) {
|
||||||
|
throw new Error(`Headless Chromium did not open ${targetUrl}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ws = new WebSocket(pageTarget.webSocketDebuggerUrl)
|
||||||
|
await new Promise((res, rej) => {
|
||||||
|
ws.onopen = res
|
||||||
|
ws.onerror = rej
|
||||||
|
})
|
||||||
|
|
||||||
|
let msgId = 0
|
||||||
|
const pending = new Map<number, (msg: Record<string, unknown>) => void>()
|
||||||
|
const consoleErrors: string[] = []
|
||||||
|
|
||||||
|
ws.onmessage = event => {
|
||||||
|
const msg = JSON.parse(String(event.data)) as Record<string, unknown>
|
||||||
|
if (msg['method'] === 'Runtime.exceptionThrown') {
|
||||||
|
consoleErrors.push(JSON.stringify(msg['params']))
|
||||||
|
}
|
||||||
|
const id = msg['id'] as number | undefined
|
||||||
|
if (id && pending.has(id)) {
|
||||||
|
pending.get(id)!(msg)
|
||||||
|
pending.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const send = (method: string, cdpParams: Record<string, unknown> = {}) =>
|
||||||
|
new Promise<Record<string, unknown>>(res => {
|
||||||
|
const id = ++msgId
|
||||||
|
pending.set(id, res)
|
||||||
|
ws.send(JSON.stringify({ id, method, params: cdpParams }))
|
||||||
|
})
|
||||||
|
|
||||||
|
await send('Runtime.enable')
|
||||||
|
await send('Page.enable')
|
||||||
|
|
||||||
|
// Poll until `window.__d2webArena.ready === true && window.__d2webArena.castCount >= 1`
|
||||||
|
let arenaState: Record<string, unknown> | null = null
|
||||||
|
for (let i = 0; i < 40; i += 1) {
|
||||||
|
await sleep(250)
|
||||||
|
const evalRes = await send('Runtime.evaluate', {
|
||||||
|
expression: `(async () => {
|
||||||
|
if (!window.__d2webArena && typeof window.__initBrowserArenaMode === 'function') {
|
||||||
|
await window.__initBrowserArenaMode();
|
||||||
|
}
|
||||||
|
if (!window.__d2webArena) {
|
||||||
|
const { initBrowserArenaMode } = await import('/src/scene/act-scene.ts');
|
||||||
|
if (typeof initBrowserArenaMode === 'function') await initBrowserArenaMode();
|
||||||
|
}
|
||||||
|
const a = window.__d2webArena;
|
||||||
|
if (!a || !a.ready) return null;
|
||||||
|
if (a.castCount < 1 && typeof a.triggerCast === 'function') a.triggerCast();
|
||||||
|
return {
|
||||||
|
ready: a.ready,
|
||||||
|
classCode: a.classCode,
|
||||||
|
charToken: a.charToken,
|
||||||
|
skillId: a.skillId,
|
||||||
|
skillName: a.skillName,
|
||||||
|
slvl: a.slvl,
|
||||||
|
tick: a.tick,
|
||||||
|
castCount: a.castCount,
|
||||||
|
actionFrameTriggered: a.actionFrameTriggered,
|
||||||
|
playerAnimMode: a.playerAnimMode,
|
||||||
|
playerAnimFrame: a.playerAnimFrame,
|
||||||
|
activeStates: a.activeStates,
|
||||||
|
activeOverlays: a.activeOverlays,
|
||||||
|
missilesSpawned: a.missilesSpawned,
|
||||||
|
petsActive: a.petsActive,
|
||||||
|
corpsesRemaining: a.corpsesRemaining,
|
||||||
|
totalDamageDealt: a.totalDamageDealt,
|
||||||
|
tooltipStats: a.tooltipStats,
|
||||||
|
};
|
||||||
|
})()`,
|
||||||
|
awaitPromise: true,
|
||||||
|
returnByValue: true,
|
||||||
|
})
|
||||||
|
const val = (evalRes['result'] as { result?: { value?: Record<string, unknown> | null } } | undefined)
|
||||||
|
?.result?.value
|
||||||
|
if (val && val['ready'] === true && Number(val['castCount']) >= 1) {
|
||||||
|
arenaState = val
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!arenaState) {
|
||||||
|
throw new Error(`Timed out waiting for window.__d2webArena.ready on ${targetUrl}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Give canvas 250ms to settle paint before screenshot
|
||||||
|
await sleep(250)
|
||||||
|
|
||||||
|
const shot = await send('Page.captureScreenshot', { format: 'png' })
|
||||||
|
const base64Data = (shot['result'] as { data?: string } | undefined)?.data
|
||||||
|
if (!base64Data) {
|
||||||
|
throw new Error('CDP Page.captureScreenshot returned empty image data')
|
||||||
|
}
|
||||||
|
|
||||||
|
const outPath = resolve(params.screenshotPath)
|
||||||
|
mkdirSync(dirname(outPath), { recursive: true })
|
||||||
|
const pngBuf = Buffer.from(base64Data, 'base64')
|
||||||
|
writeFileSync(outPath, pngBuf)
|
||||||
|
|
||||||
|
ws.close()
|
||||||
|
return {
|
||||||
|
ok: consoleErrors.length === 0 && pngBuf.byteLength > 1000,
|
||||||
|
url: targetUrl,
|
||||||
|
screenshotPath: outPath,
|
||||||
|
screenshotBytes: pngBuf.byteLength,
|
||||||
|
consoleErrors,
|
||||||
|
arenaState,
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
chrome.kill()
|
||||||
|
if (viteServer) {
|
||||||
|
await viteServer.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const args = process.argv.slice(2)
|
||||||
|
if (args.length === 0) {
|
||||||
|
console.error('Usage: npx tsx scripts/verify-skill-browser.ts <skillId> [--class=sor] [--slvl=20] [--out=/tmp/skill.png]')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const skillId = Number(args[0])
|
||||||
|
const classArg = args.find(a => a.startsWith('--class='))?.split('=')[1]
|
||||||
|
const slvlArg = args.find(a => a.startsWith('--slvl='))?.split('=')[1]
|
||||||
|
const outArg = args.find(a => a.startsWith('--out='))?.split('=')[1] ?? `/tmp/skill-${String(skillId)}.png`
|
||||||
|
|
||||||
|
const res = await verifySkillInBrowser({
|
||||||
|
skillId,
|
||||||
|
...(classArg !== undefined ? { classCode: classArg } : {}),
|
||||||
|
slvl: slvlArg ? Number(slvlArg) : 20,
|
||||||
|
screenshotPath: outArg,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(JSON.stringify(res, null, 2))
|
||||||
|
if (!res.ok) {
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||||
|
void main()
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -25,8 +25,11 @@ import { decodeDcc } from '../formats/dcc.ts'
|
||||||
import type { DccFile } from '../formats/dcc.ts'
|
import type { DccFile } from '../formats/dcc.ts'
|
||||||
import type { SpriteFrame, SpriteSheet } from '../formats/sprite.ts'
|
import type { SpriteFrame, SpriteSheet } from '../formats/sprite.ts'
|
||||||
|
|
||||||
/** Composite-type index → component directory, as the archives name them. */
|
/** Composite-type index → component directory, as the archives name them (all 16 layers hd..s8). */
|
||||||
const COMPONENTS: readonly string[] = ['hd', 'tr', 'lg', 'ra', 'la', 'rh', 'lh', 'sh', 's1', 's2']
|
export const COMPONENTS: readonly string[] = [
|
||||||
|
'hd', 'tr', 'lg', 'ra', 'la', 'rh', 'lh', 'sh',
|
||||||
|
's1', 's2', 's3', 's4', 's5', 's6', 's7', 's8',
|
||||||
|
]
|
||||||
|
|
||||||
/** A composited animation, ready to draw. */
|
/** A composited animation, ready to draw. */
|
||||||
export interface CharacterSheet {
|
export interface CharacterSheet {
|
||||||
|
|
@ -115,9 +118,10 @@ export async function loadCharacterSheet(
|
||||||
token: string,
|
token: string,
|
||||||
animation: string,
|
animation: string,
|
||||||
weapon: string,
|
weapon: string,
|
||||||
|
rootOverride?: string,
|
||||||
): Promise<CharacterSheet> {
|
): Promise<CharacterSheet> {
|
||||||
const lower = token.toLowerCase()
|
const lower = token.toLowerCase()
|
||||||
const root = `data\\global\\chars\\${lower}\\`
|
const root = rootOverride ?? `data\\global\\chars\\${lower}\\`
|
||||||
const cofMember = `${root}cof\\${lower}${animation}${weapon}.cof`
|
const cofMember = `${root}cof\\${lower}${animation}${weapon}.cof`
|
||||||
const cofBytes = await archives.read(cofMember)
|
const cofBytes = await archives.read(cofMember)
|
||||||
const cof: CofFile = decodeCof(cofBytes)
|
const cof: CofFile = decodeCof(cofBytes)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,310 @@
|
||||||
|
/**
|
||||||
|
* Diablo II: Lord of Destruction v1.13c `AnimData.d2` Frame Dispatcher (`AnimDispatcher`).
|
||||||
|
*
|
||||||
|
* Implements:
|
||||||
|
* - 1.13c `EIAS` (`floor((120 * IAS) / (120 + IAS)) + skillIas - WSM`, clamped `[-85, 75]`)
|
||||||
|
* and `EFCR` (`floor((120 * FCR) / (120 + FCR))`, clamped `[-85, 75]`) diminishing returns.
|
||||||
|
* - `AnimData.d2` 8.8 fixed-point frame advancement (`speed = (animSpeed * (100 + effRate)) / 100`).
|
||||||
|
* - Action frame flag (`0x01` melee/cast release, `0x02` missile launch) dispatching.
|
||||||
|
* - `STATE_SKILLDELAY` global cooldown enforcement (`delay` ticks from `Skills.txt`).
|
||||||
|
* - Shapeshift / weapon `restrict` validation (`restrict=1` any form, `restrict=2` shapeshift only,
|
||||||
|
* `restrict=0` human form only except Armageddon `249`).
|
||||||
|
* - Mana (`manaCost256`) and ammo quantity (`decquant`) deduction on the action frame.
|
||||||
|
*/
|
||||||
|
import type { D2DataRegistry, SkillRecord } from './data-registry.ts'
|
||||||
|
import type { AnimDataRecord } from '../../formats/animdata.ts'
|
||||||
|
import { evaluateCalc, computeSkillManaCost256 } from './calc-ast.ts'
|
||||||
|
import { UnitStatList } from './stat-list.ts'
|
||||||
|
import { StateBus } from './state-bus.ts'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1.13c Diminishing returns conversion for IAS (`EIAS`) and FCR (`EFCR`):
|
||||||
|
* `floor((120 * rate) / (120 + rate))`
|
||||||
|
*/
|
||||||
|
export function computeDiminishingRate113c(statRate: number): number {
|
||||||
|
if (statRate <= 0) return Math.max(-85, Math.trunc(statRate))
|
||||||
|
return Math.trunc((120 * statRate) / (120 + statRate))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeEffectiveIas(params: {
|
||||||
|
itemIas: number
|
||||||
|
skillIas?: number // e.g. Fanaticism / Burst of Speed / Frenzy / Werewolf
|
||||||
|
weaponWsm?: number // e.g. -20 for Phase Blade, +10 for Maul
|
||||||
|
}): number {
|
||||||
|
const eias = computeDiminishingRate113c(params.itemIas) + (params.skillIas ?? 0) - (params.weaponWsm ?? 0)
|
||||||
|
return Math.max(-85, Math.min(75, Math.trunc(eias)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeEffectiveFcr(itemFcr: number): number {
|
||||||
|
const efcr = computeDiminishingRate113c(itemFcr)
|
||||||
|
return Math.max(-85, Math.min(75, Math.trunc(efcr)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveAnimationState {
|
||||||
|
readonly skill: SkillRecord
|
||||||
|
readonly slvl: number
|
||||||
|
readonly cofName: string
|
||||||
|
readonly animRecord: AnimDataRecord
|
||||||
|
readonly framesPerDirection: number
|
||||||
|
readonly stepSpeed256: number
|
||||||
|
readonly actionFrame: number
|
||||||
|
readonly targetX: number
|
||||||
|
readonly targetY: number
|
||||||
|
frameAcc256: number
|
||||||
|
currentFrame: number
|
||||||
|
actionTriggered: boolean
|
||||||
|
completed: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CastAttemptResult {
|
||||||
|
readonly allowed: boolean
|
||||||
|
readonly reason?: 'skill_delay' | 'insufficient_mana' | 'restrict_shapeshift' | 'restrict_human' | 'passive_skill'
|
||||||
|
readonly animState?: ActiveAnimationState
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AnimDispatcher {
|
||||||
|
readonly registry: D2DataRegistry
|
||||||
|
readonly statList: UnitStatList
|
||||||
|
readonly stateBus: StateBus
|
||||||
|
readonly charToken: string // 'am' | 'so' | 'ne' | 'pa' | 'bar'->'ba' | 'dz' | 'ai'
|
||||||
|
readonly weaponClass: string // 'hth' | '1hs' | 'bow' | 'jav' | 'h2h' | 'stf'
|
||||||
|
private currentAnim: ActiveAnimationState | null = null
|
||||||
|
private skillDelayExpireFrame = 0
|
||||||
|
private ammoQuantity = 250
|
||||||
|
|
||||||
|
constructor(params: {
|
||||||
|
registry: D2DataRegistry
|
||||||
|
statList: UnitStatList
|
||||||
|
stateBus: StateBus
|
||||||
|
charToken: string
|
||||||
|
weaponClass?: string
|
||||||
|
}) {
|
||||||
|
this.registry = params.registry
|
||||||
|
this.statList = params.statList
|
||||||
|
this.stateBus = params.stateBus
|
||||||
|
this.charToken = params.charToken.toUpperCase()
|
||||||
|
this.weaponClass = (params.weaponClass ?? 'HTH').toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
isSkillDelayActive(currentTick: number): boolean {
|
||||||
|
return this.skillDelayExpireFrame > currentTick || this.stateBus.hasState('skilldelay')
|
||||||
|
}
|
||||||
|
|
||||||
|
getRemainingSkillDelay(currentTick: number): number {
|
||||||
|
return Math.max(0, this.skillDelayExpireFrame - currentTick)
|
||||||
|
}
|
||||||
|
|
||||||
|
getAmmoQuantity(): number {
|
||||||
|
return this.ammoQuantity
|
||||||
|
}
|
||||||
|
|
||||||
|
setAmmoQuantity(qty: number): void {
|
||||||
|
this.ammoQuantity = Math.max(0, Math.trunc(qty))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate shapeshift `restrict` rule from `Skills.txt`:
|
||||||
|
* - `restrict = 0`: usable in human form only (except `Armageddon` 249 and `Werewolf` 223 / `Werebear` 228 unshift)
|
||||||
|
* - `restrict = 1`: usable in both human and shapeshift forms
|
||||||
|
* - `restrict = 2`: usable in shapeshift (`STATE_WOLF` / `STATE_BEAR`) form only!
|
||||||
|
*/
|
||||||
|
validateRestriction(skill: SkillRecord): { ok: boolean; reason?: 'restrict_shapeshift' | 'restrict_human' } {
|
||||||
|
const inWolfOrBear =
|
||||||
|
this.stateBus.hasState('wolf') ||
|
||||||
|
this.stateBus.hasState('bear') ||
|
||||||
|
this.stateBus.hasState('wearwolf') ||
|
||||||
|
this.stateBus.hasState('wearbear')
|
||||||
|
|
||||||
|
if (skill.restrict === 2 && !inWolfOrBear) {
|
||||||
|
return { ok: false, reason: 'restrict_shapeshift' }
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
skill.restrict === 0 &&
|
||||||
|
inWolfOrBear &&
|
||||||
|
skill.id !== 249 && // Armageddon usable in shapeshift
|
||||||
|
skill.id !== 223 && // Werewolf toggle
|
||||||
|
skill.id !== 228 && // Werebear toggle
|
||||||
|
skill.id !== 0 // Attack usable in shapeshift
|
||||||
|
) {
|
||||||
|
return { ok: false, reason: 'restrict_human' }
|
||||||
|
}
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initiate a skill animation (`srvstfunc` phase).
|
||||||
|
*/
|
||||||
|
startSkillAnimation(params: {
|
||||||
|
skill: SkillRecord
|
||||||
|
slvl: number
|
||||||
|
currentTick: number
|
||||||
|
targetX?: number
|
||||||
|
targetY?: number
|
||||||
|
onActionFrame?: (anim: ActiveAnimationState, tick: number) => void
|
||||||
|
}): CastAttemptResult {
|
||||||
|
const { skill, slvl, currentTick } = params
|
||||||
|
if (skill.passive) {
|
||||||
|
return { allowed: false, reason: 'passive_skill' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check `STATE_SKILLDELAY` cooldown if the skill itself has a `delay` (`cd` skills)
|
||||||
|
const delayTicks = skill.delay
|
||||||
|
? evaluateCalc(skill.delay, {
|
||||||
|
registry: this.registry,
|
||||||
|
activeSkill: skill,
|
||||||
|
slvl,
|
||||||
|
blvl: this.statList.getBaseSkillLevel(skill.id),
|
||||||
|
})
|
||||||
|
: 0
|
||||||
|
|
||||||
|
if (delayTicks > 0 && this.isSkillDelayActive(currentTick)) {
|
||||||
|
return { allowed: false, reason: 'skill_delay' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check shapeshift `restrict`
|
||||||
|
const restrictCheck = this.validateRestriction(skill)
|
||||||
|
if (!restrictCheck.ok) {
|
||||||
|
return { allowed: false, reason: restrictCheck.reason ?? 'restrict_shapeshift' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check mana cost before starting animation
|
||||||
|
const { manaCost256 } = computeSkillManaCost256(skill, slvl)
|
||||||
|
if (this.statList.getMana256() < manaCost256 && !skill.nocostinstate) {
|
||||||
|
return { allowed: false, reason: 'insufficient_mana' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve `AnimData.d2` COF record (`<TOKEN><MODE><WCLASS>`)
|
||||||
|
const rawMode = (skill.anim || 'SC').toUpperCase()
|
||||||
|
const mode = rawMode === 'SQ' ? (skill.seqTrans || 'A1').toUpperCase() : rawMode
|
||||||
|
const cofCandidates = [
|
||||||
|
`${this.charToken}${mode}${this.weaponClass}`,
|
||||||
|
`${this.charToken}${mode}HTH`,
|
||||||
|
`${this.charToken}${mode}1HS`,
|
||||||
|
`${this.charToken}SCHTH`,
|
||||||
|
`${this.charToken}A1HTH`,
|
||||||
|
]
|
||||||
|
|
||||||
|
let animRecord: AnimDataRecord | undefined
|
||||||
|
let chosenCof = cofCandidates[0]!
|
||||||
|
for (const cand of cofCandidates) {
|
||||||
|
const rec = this.registry.getAnimDataRecord(cand)
|
||||||
|
if (rec && rec.hasSpeed) {
|
||||||
|
animRecord = rec
|
||||||
|
chosenCof = cand
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!animRecord) {
|
||||||
|
animRecord = {
|
||||||
|
name: chosenCof,
|
||||||
|
framesPerDirection: 14,
|
||||||
|
animationSpeed: 256,
|
||||||
|
hasSpeed: true,
|
||||||
|
events: [{ frame: 6, code: 1 }],
|
||||||
|
block: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCastMode = mode === 'SC'
|
||||||
|
const effRate = isCastMode
|
||||||
|
? computeEffectiveFcr(this.statList.getAccruedStat('item_fastercastrate'))
|
||||||
|
: computeEffectiveIas({
|
||||||
|
itemIas: this.statList.getAccruedStat('item_fasterattackrate'),
|
||||||
|
skillIas: this.statList.getModifierBonus('skill_ias'),
|
||||||
|
weaponWsm: this.statList.getAccruedStat('weapon_wsm'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const stepSpeed256 = Math.max(32, Math.trunc((animRecord.animationSpeed * (100 + effRate)) / 100))
|
||||||
|
const actionEvt = animRecord.events.find(e => (e.code & (0x01 | 0x02)) !== 0)
|
||||||
|
const actionFrame = actionEvt
|
||||||
|
? actionEvt.frame
|
||||||
|
: Math.max(1, Math.trunc(animRecord.framesPerDirection / 2))
|
||||||
|
|
||||||
|
this.currentAnim = {
|
||||||
|
skill,
|
||||||
|
slvl,
|
||||||
|
cofName: chosenCof,
|
||||||
|
animRecord,
|
||||||
|
framesPerDirection: animRecord.framesPerDirection,
|
||||||
|
stepSpeed256,
|
||||||
|
actionFrame,
|
||||||
|
targetX: params.targetX ?? 0,
|
||||||
|
targetY: params.targetY ?? 0,
|
||||||
|
frameAcc256: 0,
|
||||||
|
currentFrame: 0,
|
||||||
|
actionTriggered: false,
|
||||||
|
completed: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
allowed: true,
|
||||||
|
animState: this.currentAnim,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advance the active animation by 1 tick (`25 Hz`), firing the action frame (`0x01`/`0x02`)
|
||||||
|
* when `currentFrame >= actionFrame` and deducting mana / ammo / setting `STATE_SKILLDELAY`.
|
||||||
|
*/
|
||||||
|
tick(
|
||||||
|
currentTick: number,
|
||||||
|
onActionFrame: (anim: ActiveAnimationState, tick: number) => void,
|
||||||
|
): { actionFired: boolean; anim: ActiveAnimationState | null } {
|
||||||
|
if (!this.currentAnim || this.currentAnim.completed) {
|
||||||
|
return { actionFired: false, anim: this.currentAnim }
|
||||||
|
}
|
||||||
|
|
||||||
|
const anim = this.currentAnim
|
||||||
|
anim.frameAcc256 += anim.stepSpeed256
|
||||||
|
anim.currentFrame = Math.trunc(anim.frameAcc256 / 256)
|
||||||
|
|
||||||
|
let actionFired = false
|
||||||
|
if (!anim.actionTriggered && anim.currentFrame >= anim.actionFrame) {
|
||||||
|
anim.actionTriggered = true
|
||||||
|
actionFired = true
|
||||||
|
|
||||||
|
// Deduct mana cost (`manaCost256`) on the action frame
|
||||||
|
const { manaCost256 } = computeSkillManaCost256(anim.skill, anim.slvl)
|
||||||
|
if (!anim.skill.nocostinstate) {
|
||||||
|
this.statList.setMana256(this.statList.getMana256() - manaCost256)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduct ammo quantity (`decquant`) if applicable
|
||||||
|
if (anim.skill.decquant && this.ammoQuantity > 0) {
|
||||||
|
this.ammoQuantity -= 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply `STATE_SKILLDELAY` (`delay` ticks in `Skills.txt`, e.g. Hydra 40f, Frozen Orb 25f)
|
||||||
|
const delayTicks = anim.skill.delay
|
||||||
|
? evaluateCalc(anim.skill.delay, {
|
||||||
|
registry: this.registry,
|
||||||
|
activeSkill: anim.skill,
|
||||||
|
slvl: anim.slvl,
|
||||||
|
blvl: this.statList.getBaseSkillLevel(anim.skill.id),
|
||||||
|
})
|
||||||
|
: 0
|
||||||
|
if (delayTicks > 0) {
|
||||||
|
this.skillDelayExpireFrame = currentTick + delayTicks
|
||||||
|
this.stateBus.applyState({
|
||||||
|
stateNameOrId: 'skilldelay',
|
||||||
|
slvl: 1,
|
||||||
|
durationFrames: delayTicks,
|
||||||
|
currentFrame: currentTick,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onActionFrame(anim, currentTick)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anim.currentFrame >= anim.framesPerDirection) {
|
||||||
|
anim.completed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return { actionFired, anim }
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentAnimation(): ActiveAnimationState | null {
|
||||||
|
return this.currentAnim
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,274 @@
|
||||||
|
/**
|
||||||
|
* Diablo II: Lord of Destruction v1.13c Periodic Aura Pulse Engine (`AuraScanner`).
|
||||||
|
*
|
||||||
|
* Implements:
|
||||||
|
* - Periodic radial aura scanning (`aurarangecalc`, `perdelay` / `auradelay`, `aurafilter`).
|
||||||
|
* - Friendly stat aura pulses (`Might`, `Prayer`, `Resist Fire/Cold/Lightning`, `Thorns`,
|
||||||
|
* `Defiance`, `Blessed Aim`, `Cleansing`, `Concentration`, `Vigor`, `Meditation`,
|
||||||
|
* `Fanaticism`, `Salvation`, plus Druid Spirit totem auras `Oak Sage` 298,
|
||||||
|
* `Heart of Wolverine` 297, `Spirit of Barbs` 296).
|
||||||
|
* - Enemy debuff & radial damage aura pulses (`Holy Fire` 102, `Holy Freeze` 114,
|
||||||
|
* `Holy Shock` 118, `Sanctuary` 119 vs Undead, `Conviction` 123 `-Defense` & `-Resist`).
|
||||||
|
* - `Redemption` (`124`) corpse consumption pulse restoring player HP & Mana.
|
||||||
|
*/
|
||||||
|
import type { D2DataRegistry, SkillRecord } from './data-registry.ts'
|
||||||
|
import { evaluateCalc, compute5BandScaling } from './calc-ast.ts'
|
||||||
|
import { FIXED_ONE } from './stat-list.ts'
|
||||||
|
import { executeSUnitDmg, type CombatUnitContext } from './combat-pipeline.ts'
|
||||||
|
|
||||||
|
export interface ActiveAuraSource {
|
||||||
|
readonly sourceUnit: CombatUnitContext
|
||||||
|
readonly skill: SkillRecord
|
||||||
|
readonly slvl: number
|
||||||
|
readonly startTick: number
|
||||||
|
readonly pulseIntervalTicks: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuraPulseOutcome {
|
||||||
|
readonly auraSkillId: number
|
||||||
|
readonly auraName: string
|
||||||
|
readonly alliesBuffed: number
|
||||||
|
readonly enemiesAffected: number
|
||||||
|
readonly pulseDamageDealt: number
|
||||||
|
readonly corpsesRedeemed: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AuraScanner {
|
||||||
|
readonly registry: D2DataRegistry
|
||||||
|
private readonly activeAuras = new Map<string, ActiveAuraSource>()
|
||||||
|
|
||||||
|
constructor(registry: D2DataRegistry) {
|
||||||
|
this.registry = registry
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveAura(sourceUnit: CombatUnitContext, skillId: number, slvl: number, currentTick = 0): ActiveAuraSource | null {
|
||||||
|
const skill = this.registry.getSkillById(skillId)
|
||||||
|
if (!skill) return null
|
||||||
|
|
||||||
|
const calcCtx = {
|
||||||
|
registry: this.registry,
|
||||||
|
activeSkill: skill,
|
||||||
|
slvl,
|
||||||
|
blvl: sourceUnit.statList.getBaseSkillLevel(skillId),
|
||||||
|
getBaseSkillLevel: (id: number) => sourceUnit.statList.getBaseSkillLevel(id),
|
||||||
|
getEffectiveSkillLevel: (id: number) => sourceUnit.statList.getEffectiveSkillLevel(id),
|
||||||
|
}
|
||||||
|
|
||||||
|
const perDelay = skill.perDelay ? Math.max(10, evaluateCalc(skill.perDelay, calcCtx)) : 50
|
||||||
|
const source: ActiveAuraSource = {
|
||||||
|
sourceUnit,
|
||||||
|
skill,
|
||||||
|
slvl,
|
||||||
|
startTick: currentTick,
|
||||||
|
pulseIntervalTicks: perDelay,
|
||||||
|
}
|
||||||
|
this.activeAuras.set(`${sourceUnit.id}:${String(skillId)}`, source)
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
|
||||||
|
clearUnitAuras(sourceUnitId: string): void {
|
||||||
|
for (const key of this.activeAuras.keys()) {
|
||||||
|
if (key.startsWith(`${sourceUnitId}:`)) {
|
||||||
|
this.activeAuras.delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute an immediate or periodic aura pulse across `allies`, `enemies`, and `corpses`.
|
||||||
|
*/
|
||||||
|
pulseAura(
|
||||||
|
aura: ActiveAuraSource,
|
||||||
|
currentTick: number,
|
||||||
|
allies: readonly CombatUnitContext[],
|
||||||
|
enemies: readonly CombatUnitContext[],
|
||||||
|
corpses?: { id: string; consumed: boolean }[],
|
||||||
|
): AuraPulseOutcome {
|
||||||
|
const { sourceUnit, skill, slvl } = aura
|
||||||
|
const blvl = sourceUnit.statList.getBaseSkillLevel(skill.id)
|
||||||
|
const calcCtx = {
|
||||||
|
registry: this.registry,
|
||||||
|
activeSkill: skill,
|
||||||
|
slvl,
|
||||||
|
blvl,
|
||||||
|
getBaseSkillLevel: (id: number) => sourceUnit.statList.getBaseSkillLevel(id),
|
||||||
|
getEffectiveSkillLevel: (id: number) => sourceUnit.statList.getEffectiveSkillLevel(id),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate `aurastat1..6` and `aurastatcalc1..6`
|
||||||
|
const stats: Record<string, number> = {}
|
||||||
|
const statPairs: [string, string][] = [
|
||||||
|
[skill.auraStat1, skill.auraStatCalc1],
|
||||||
|
[skill.auraStat2, skill.auraStatCalc2],
|
||||||
|
[skill.auraStat3, skill.auraStatCalc3],
|
||||||
|
[skill.auraStat4, skill.auraStatCalc4],
|
||||||
|
[skill.auraStat5, skill.auraStatCalc5],
|
||||||
|
[skill.auraStat6, skill.auraStatCalc6],
|
||||||
|
]
|
||||||
|
for (const [statName, statCalc] of statPairs) {
|
||||||
|
if (statName && statCalc) {
|
||||||
|
stats[statName.toLowerCase()] = evaluateCalc(statCalc, calcCtx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concentration (`113`) also records `concentration_damage_pct` for Blessed Hammer (`50%` efficiency)
|
||||||
|
if (skill.id === 113) {
|
||||||
|
stats['concentration_damage_pct'] = evaluateCalc('ln12', calcCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stateDuration = aura.pulseIntervalTicks + 5
|
||||||
|
const selfState = skill.auraState || skill.name.toLowerCase().replace(/\s+/g, '')
|
||||||
|
sourceUnit.stateBus.applyState({
|
||||||
|
stateNameOrId: selfState,
|
||||||
|
sourceUnitId: sourceUnit.id,
|
||||||
|
sourceSkillId: skill.id,
|
||||||
|
slvl,
|
||||||
|
durationFrames: stateDuration,
|
||||||
|
currentFrame: currentTick,
|
||||||
|
stats,
|
||||||
|
isAuraOverride: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
let alliesBuffed = 1
|
||||||
|
let enemiesAffected = 0
|
||||||
|
let pulseDamageDealt = 0
|
||||||
|
let corpsesRedeemed = 0
|
||||||
|
|
||||||
|
// Friendly stat aura (`srvdofunc = 65` or Oak Sage / Wolverine / Barbs)
|
||||||
|
if (skill.srvDoFunc === 65 || skill.id === 296 || skill.id === 297 || skill.id === 298) {
|
||||||
|
const tgtState = skill.auraTgtState || selfState
|
||||||
|
for (const ally of allies) {
|
||||||
|
if (ally.id === sourceUnit.id) continue
|
||||||
|
ally.stateBus.applyState({
|
||||||
|
stateNameOrId: tgtState,
|
||||||
|
sourceUnitId: sourceUnit.id,
|
||||||
|
sourceSkillId: skill.id,
|
||||||
|
slvl,
|
||||||
|
durationFrames: stateDuration,
|
||||||
|
currentFrame: currentTick,
|
||||||
|
stats,
|
||||||
|
isAuraOverride: true,
|
||||||
|
})
|
||||||
|
alliesBuffed += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conviction (`123`): applies `conviction` (`conviction_pierce` + `-defense`) to enemies
|
||||||
|
if (skill.id === 123) {
|
||||||
|
const pierce = Math.min(150, evaluateCalc('ln34', calcCtx))
|
||||||
|
const defPenalty = -evaluateCalc('ln12', calcCtx)
|
||||||
|
for (const enemy of enemies) {
|
||||||
|
if (enemy.statList.getHp256() <= 0) continue
|
||||||
|
enemy.stateBus.applyState({
|
||||||
|
stateNameOrId: skill.auraTgtState || 'conviction',
|
||||||
|
sourceUnitId: sourceUnit.id,
|
||||||
|
sourceSkillId: skill.id,
|
||||||
|
slvl,
|
||||||
|
durationFrames: stateDuration,
|
||||||
|
currentFrame: currentTick,
|
||||||
|
stats: {
|
||||||
|
conviction_pierce: pierce,
|
||||||
|
item_armor_percent: defPenalty,
|
||||||
|
},
|
||||||
|
isAuraOverride: true,
|
||||||
|
})
|
||||||
|
enemiesAffected += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Radial damage auras (`Holy Fire` 102, `Holy Freeze` 114, `Holy Shock` 118, `Sanctuary` 119)
|
||||||
|
if (skill.id === 102 || skill.id === 114 || skill.id === 118 || skill.id === 119) {
|
||||||
|
const baseMin = compute5BandScaling(
|
||||||
|
slvl,
|
||||||
|
skill.eMin,
|
||||||
|
skill.eMinLev1,
|
||||||
|
skill.eMinLev2,
|
||||||
|
skill.eMinLev3,
|
||||||
|
skill.eMinLev4,
|
||||||
|
skill.eMinLev5,
|
||||||
|
)
|
||||||
|
const baseMax = compute5BandScaling(
|
||||||
|
slvl,
|
||||||
|
skill.eMax,
|
||||||
|
skill.eMaxLev1,
|
||||||
|
skill.eMaxLev2,
|
||||||
|
skill.eMaxLev3,
|
||||||
|
skill.eMaxLev4,
|
||||||
|
skill.eMaxLev5,
|
||||||
|
)
|
||||||
|
const synPct = skill.eDmgSymPerCalc ? evaluateCalc(skill.eDmgSymPerCalc, calcCtx) : 0
|
||||||
|
const min256 = Math.trunc(((baseMin << skill.hitShift) * (100 + synPct)) / 100)
|
||||||
|
const max256 = Math.trunc(((baseMax << skill.hitShift) * (100 + synPct)) / 100)
|
||||||
|
|
||||||
|
for (const enemy of enemies) {
|
||||||
|
if (enemy.statList.getHp256() <= 0) continue
|
||||||
|
if (skill.id === 119 && !enemy.isUndead) continue // Sanctuary only pulses vs Undead
|
||||||
|
|
||||||
|
if (skill.auraTgtState) {
|
||||||
|
enemy.stateBus.applyState({
|
||||||
|
stateNameOrId: skill.auraTgtState,
|
||||||
|
sourceUnitId: sourceUnit.id,
|
||||||
|
sourceSkillId: skill.id,
|
||||||
|
slvl,
|
||||||
|
durationFrames: stateDuration,
|
||||||
|
currentFrame: currentTick,
|
||||||
|
stats: skill.id === 114 ? { item_slow: evaluateCalc('dm56', calcCtx) } : {},
|
||||||
|
isAuraOverride: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = executeSUnitDmg(sourceUnit, enemy, {
|
||||||
|
skillId: skill.id,
|
||||||
|
attackKind: 'spell',
|
||||||
|
elemType: skill.eType || (skill.id === 119 ? 'mag' : 'fire'),
|
||||||
|
elemMin256: min256,
|
||||||
|
elemMax256: max256,
|
||||||
|
autoHit: true,
|
||||||
|
unblockable: true,
|
||||||
|
})
|
||||||
|
pulseDamageDealt += out.totalDamage
|
||||||
|
enemiesAffected += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redemption (`124`): consumes corpses to recover HP & Mana
|
||||||
|
if (skill.id === 124 && corpses) {
|
||||||
|
const recoverPts = evaluateCalc('ln34', calcCtx)
|
||||||
|
for (const c of corpses) {
|
||||||
|
if (!c.consumed) {
|
||||||
|
c.consumed = true
|
||||||
|
corpsesRedeemed += 1
|
||||||
|
sourceUnit.statList.setHp256(sourceUnit.statList.getHp256() + recoverPts * FIXED_ONE)
|
||||||
|
sourceUnit.statList.setMana256(sourceUnit.statList.getMana256() + recoverPts * FIXED_ONE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
auraSkillId: skill.id,
|
||||||
|
auraName: skill.name,
|
||||||
|
alliesBuffed,
|
||||||
|
enemiesAffected,
|
||||||
|
pulseDamageDealt,
|
||||||
|
corpsesRedeemed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Periodic tick step across all active auras.
|
||||||
|
*/
|
||||||
|
tick(
|
||||||
|
currentTick: number,
|
||||||
|
allies: readonly CombatUnitContext[],
|
||||||
|
enemies: readonly CombatUnitContext[],
|
||||||
|
corpses?: { id: string; consumed: boolean }[],
|
||||||
|
): AuraPulseOutcome[] {
|
||||||
|
const outcomes: AuraPulseOutcome[] = []
|
||||||
|
for (const aura of this.activeAuras.values()) {
|
||||||
|
if ((currentTick - aura.startTick) % aura.pulseIntervalTicks === 0) {
|
||||||
|
outcomes.push(this.pulseAura(aura, currentTick, allies, enemies, corpses))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return outcomes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,792 @@
|
||||||
|
/**
|
||||||
|
* Diablo II: Lord of Destruction v1.13c `D2Common` `calc` AST & Formula Evaluator.
|
||||||
|
*
|
||||||
|
* Implements the complete `SKILLS_EvaluateSkillFormula` / `MISSILE_EvaluateFormula`
|
||||||
|
* grammar across `Skills.txt`, `SkillDesc.txt`, `Missiles.txt`, `SkillCalc.txt`,
|
||||||
|
* and `MissCalc.txt`, including all 5 1.13c table edge cases:
|
||||||
|
* 1. Unclosed `(` at end of expression (`Fire Wall` row 51 `EDmgSymPerCalc`).
|
||||||
|
* 2. Unknown bare identifier `par34` (`Bone Wall` row 78 `calc2`) -> evaluates to `0`.
|
||||||
|
* 3. Leading `*` on numeric missile function IDs (`royalstrikechainlightning` row 568).
|
||||||
|
* 4. Two-dot `sklvl('Holy Fire'.ln56.edmn)` (`FireGolem` in `SkillDesc.txt`).
|
||||||
|
* 5. Strictly base allocated `STAT_SKILLPTS` for `blvl` (`skill('...'.blvl)`),
|
||||||
|
* enforcing the 1.13c Marrowwalk / item charge synergy fix.
|
||||||
|
*/
|
||||||
|
import type { D2DataRegistry, SkillRecord, MissileRecord } from './data-registry.ts'
|
||||||
|
|
||||||
|
export interface CalcEvalContext {
|
||||||
|
readonly registry: D2DataRegistry
|
||||||
|
readonly activeSkill?: SkillRecord
|
||||||
|
readonly activeMissile?: MissileRecord
|
||||||
|
/** Effective skill level (base + item/buff +skills). */
|
||||||
|
readonly slvl: number
|
||||||
|
/** Strictly base allocated skill points (`STAT_SKILLPTS`) for the active skill. */
|
||||||
|
readonly blvl: number
|
||||||
|
/** Character or monster unit level (`STAT_LEVEL`). Default: 80. */
|
||||||
|
readonly ulvl?: number
|
||||||
|
/**
|
||||||
|
* Look up strictly base allocated `STAT_SKILLPTS` (`blvl`) for any skill ID or name.
|
||||||
|
* Must NEVER include `+skills` or item charges (`STAT_ITEM_CHARGED_SKILL`).
|
||||||
|
*/
|
||||||
|
readonly getBaseSkillLevel?: (skillId: number) => number
|
||||||
|
/** Look up effective skill level (`lvl = blvl + bonusSkills`) for any skill ID. */
|
||||||
|
readonly getEffectiveSkillLevel?: (skillId: number) => number
|
||||||
|
/** Look up unit stat (`stat('...'.accr)` or `.base`). */
|
||||||
|
readonly getUnitStat?: (statNameOrId: string | number, baseOnly?: boolean) => number
|
||||||
|
/** Optional deterministic RNG for `rand(a, b)`. Defaults to midpoint or seeded LCG. */
|
||||||
|
readonly randFn?: (min: number, max: number) => number
|
||||||
|
/** Recursion depth guard for self-referential `clc1..4`. */
|
||||||
|
readonly depth?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute 5-band level scaling used by `D2Common` (`SKILLS_GetElementalMinDamage`,
|
||||||
|
* `SKILLS_GetElementalMaxDamage`, `SKILLS_GetMinPhysDamage`, `SKILLS_GetMaxPhysDamage`).
|
||||||
|
*
|
||||||
|
* Brackets in Diablo II:
|
||||||
|
* - Level 1: `base`
|
||||||
|
* - Levels 2..8 (7 levels): `+ lev1` per level
|
||||||
|
* - Levels 9..16 (8 levels): `+ lev2` per level
|
||||||
|
* - Levels 17..22 (6 levels): `+ lev3` per level
|
||||||
|
* - Levels 23..28 (6 levels): `+ lev4` per level
|
||||||
|
* - Levels 29+ (`slvl - 28` levels): `+ lev5` per level
|
||||||
|
*/
|
||||||
|
export function compute5BandScaling(
|
||||||
|
slvl: number,
|
||||||
|
base: number,
|
||||||
|
lev1: number,
|
||||||
|
lev2: number,
|
||||||
|
lev3: number,
|
||||||
|
lev4: number,
|
||||||
|
lev5: number,
|
||||||
|
): number {
|
||||||
|
if (slvl <= 0) return 0
|
||||||
|
let total = base
|
||||||
|
if (slvl > 1) {
|
||||||
|
const b1 = Math.min(slvl, 8) - 1
|
||||||
|
total += b1 * lev1
|
||||||
|
}
|
||||||
|
if (slvl > 8) {
|
||||||
|
const b2 = Math.min(slvl, 16) - 8
|
||||||
|
total += b2 * lev2
|
||||||
|
}
|
||||||
|
if (slvl > 16) {
|
||||||
|
const b3 = Math.min(slvl, 22) - 16
|
||||||
|
total += b3 * lev3
|
||||||
|
}
|
||||||
|
if (slvl > 22) {
|
||||||
|
const b4 = Math.min(slvl, 28) - 22
|
||||||
|
total += b4 * lev4
|
||||||
|
}
|
||||||
|
if (slvl > 28) {
|
||||||
|
const b5 = slvl - 28
|
||||||
|
total += b5 * lev5
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute 3-band elemental duration scaling (`ELen`, `ELevLen1..3`) in frames (25fps).
|
||||||
|
* - Level 1: `eLen`
|
||||||
|
* - Levels 2..8: `+ eLevLen1` per level
|
||||||
|
* - Levels 9..16: `+ eLevLen2` per level
|
||||||
|
* - Levels 17+: `+ eLevLen3` per level
|
||||||
|
*/
|
||||||
|
export function compute3BandDuration(
|
||||||
|
slvl: number,
|
||||||
|
eLen: number,
|
||||||
|
eLevLen1: number,
|
||||||
|
eLevLen2: number,
|
||||||
|
eLevLen3: number,
|
||||||
|
): number {
|
||||||
|
if (slvl <= 0 || (eLen === 0 && eLevLen1 === 0 && eLevLen2 === 0 && eLevLen3 === 0)) return 0
|
||||||
|
let total = eLen
|
||||||
|
if (slvl > 1) {
|
||||||
|
total += (Math.min(slvl, 8) - 1) * eLevLen1
|
||||||
|
}
|
||||||
|
if (slvl > 8) {
|
||||||
|
total += (Math.min(slvl, 16) - 8) * eLevLen2
|
||||||
|
}
|
||||||
|
if (slvl > 16) {
|
||||||
|
total += (slvl - 16) * eLevLen3
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute `D2Common` diminishing-returns formula `dm(a, b, lvl)`:
|
||||||
|
* `floor((110 * lvl * (b - a)) / (100 * (lvl + 6))) + a`
|
||||||
|
*/
|
||||||
|
export function computeDiminishingReturns(a: number, b: number, lvl: number): number {
|
||||||
|
if (lvl <= 0) return a
|
||||||
|
const num = 110 * lvl * (b - a)
|
||||||
|
const den = 100 * (lvl + 6)
|
||||||
|
return Math.trunc(num / den) + a
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute `D2Common` linear scaling `ln(a, b, lvl)`:
|
||||||
|
* `a + (lvl - 1) * b`
|
||||||
|
*/
|
||||||
|
export function computeLinearScaling(a: number, b: number, lvl: number): number {
|
||||||
|
if (lvl <= 0) return a
|
||||||
|
return a + (lvl - 1) * b
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute mana cost in 256-fixed-point (`usmc`) and integer mana (`manaCost`) for a skill at `slvl`.
|
||||||
|
* Formula from `D2Common` `SKILLS_GetManaCost`:
|
||||||
|
* `usmc = max(minmana << 8, (mana + lvlmana * (slvl - 1)) << manashift)`
|
||||||
|
*/
|
||||||
|
export function computeSkillManaCost256(skill: SkillRecord, slvl: number): { manaCost256: number; manaCost: number } {
|
||||||
|
const effLvl = Math.max(1, slvl)
|
||||||
|
const raw = (skill.mana + skill.lvlMana * (effLvl - 1)) << skill.manaShift
|
||||||
|
const min256 = skill.minMana << 8
|
||||||
|
const manaCost256 = Math.max(min256, raw)
|
||||||
|
return {
|
||||||
|
manaCost256,
|
||||||
|
manaCost: manaCost256 >> 8,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Token =
|
||||||
|
| { kind: 'num'; value: number }
|
||||||
|
| { kind: 'ident'; name: string }
|
||||||
|
| { kind: 'str'; value: string }
|
||||||
|
| { kind: 'op'; op: string }
|
||||||
|
| { kind: 'lparen' }
|
||||||
|
| { kind: 'rparen' }
|
||||||
|
| { kind: 'comma' }
|
||||||
|
| { kind: 'dot' }
|
||||||
|
| { kind: 'question' }
|
||||||
|
| { kind: 'colon' }
|
||||||
|
|
||||||
|
function tokenizeCalc(expr: string): Token[] {
|
||||||
|
const tokens: Token[] = []
|
||||||
|
let i = 0
|
||||||
|
const n = expr.length
|
||||||
|
|
||||||
|
while (i < n) {
|
||||||
|
const ch = expr[i]!
|
||||||
|
if (ch === ' ' || ch === '\t' || ch === '\r' || ch === '\n') {
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (ch === '(') {
|
||||||
|
tokens.push({ kind: 'lparen' })
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (ch === ')') {
|
||||||
|
tokens.push({ kind: 'rparen' })
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (ch === ',') {
|
||||||
|
tokens.push({ kind: 'comma' })
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (ch === '.') {
|
||||||
|
tokens.push({ kind: 'dot' })
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (ch === '?') {
|
||||||
|
tokens.push({ kind: 'question' })
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (ch === ':') {
|
||||||
|
tokens.push({ kind: 'colon' })
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (ch === "'" || ch === '"') {
|
||||||
|
const quote = ch
|
||||||
|
i += 1
|
||||||
|
let str = ''
|
||||||
|
while (i < n && expr[i] !== quote) {
|
||||||
|
str += expr[i]!
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
if (i < n && expr[i] === quote) i += 1
|
||||||
|
tokens.push({ kind: 'str', value: str })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (ch >= '0' && ch <= '9') {
|
||||||
|
let numStr = ''
|
||||||
|
while (i < n && expr[i]! >= '0' && expr[i]! <= '9') {
|
||||||
|
numStr += expr[i]!
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
tokens.push({ kind: 'num', value: Number(numStr) })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
(ch >= 'a' && ch <= 'z') ||
|
||||||
|
(ch >= 'A' && ch <= 'Z') ||
|
||||||
|
ch === '_'
|
||||||
|
) {
|
||||||
|
let ident = ''
|
||||||
|
while (
|
||||||
|
i < n &&
|
||||||
|
((expr[i]! >= 'a' && expr[i]! <= 'z') ||
|
||||||
|
(expr[i]! >= 'A' && expr[i]! <= 'Z') ||
|
||||||
|
(expr[i]! >= '0' && expr[i]! <= '9') ||
|
||||||
|
expr[i]! === '_')
|
||||||
|
) {
|
||||||
|
ident += expr[i]!
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
tokens.push({ kind: 'ident', name: ident })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const twoChar = expr.slice(i, i + 2)
|
||||||
|
if (twoChar === '<=' || twoChar === '>=' || twoChar === '==' || twoChar === '!=') {
|
||||||
|
tokens.push({ kind: 'op', op: twoChar })
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (ch === '+' || ch === '-' || ch === '*' || ch === '/' || ch === '^' || ch === '<' || ch === '>' || ch === '=') {
|
||||||
|
tokens.push({ kind: 'op', op: ch })
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Skip any unexpected formatting character gracefully
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a property (`.blvl`, `.lvl`, `.par1..8`, `.ln12`, `.ln34`, `.ln56`, `.dm12`, `.dm34`,
|
||||||
|
* `.edmn`, `.edmx`, `.edns`, `.edxs`, `.enma`, `.exma`, `.clc1..4`) on a target `SkillRecord`.
|
||||||
|
*/
|
||||||
|
function resolveSkillProperty(
|
||||||
|
skill: SkillRecord,
|
||||||
|
prop: string,
|
||||||
|
ctx: CalcEvalContext,
|
||||||
|
overrideLvl?: number,
|
||||||
|
): number {
|
||||||
|
const p = prop.trim().toLowerCase()
|
||||||
|
const basePoints = ctx.getBaseSkillLevel
|
||||||
|
? ctx.getBaseSkillLevel(skill.id)
|
||||||
|
: skill.id === ctx.activeSkill?.id
|
||||||
|
? ctx.blvl
|
||||||
|
: 0
|
||||||
|
|
||||||
|
// CRITICAL 1.13c RULE: `.blvl` strictly returns base allocated STAT_SKILLPTS only!
|
||||||
|
// Item `+skills` and `STAT_ITEM_CHARGED_SKILL` charges NEVER increase `.blvl`.
|
||||||
|
if (p === 'blvl') {
|
||||||
|
return Math.max(0, basePoints)
|
||||||
|
}
|
||||||
|
|
||||||
|
const effLvl =
|
||||||
|
overrideLvl !== undefined
|
||||||
|
? overrideLvl
|
||||||
|
: ctx.getEffectiveSkillLevel
|
||||||
|
? ctx.getEffectiveSkillLevel(skill.id)
|
||||||
|
: skill.id === ctx.activeSkill?.id
|
||||||
|
? ctx.slvl
|
||||||
|
: basePoints
|
||||||
|
|
||||||
|
if (p === 'lvl') return Math.max(0, effLvl)
|
||||||
|
|
||||||
|
const subCtx: CalcEvalContext = {
|
||||||
|
...ctx,
|
||||||
|
activeSkill: skill,
|
||||||
|
slvl: effLvl,
|
||||||
|
blvl: basePoints,
|
||||||
|
depth: (ctx.depth ?? 0) + 1,
|
||||||
|
}
|
||||||
|
return resolveBuiltInIdentifier(p, subCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a property on a `MissileRecord` (`miss('<MissileName>'.<prop>)`).
|
||||||
|
*/
|
||||||
|
function resolveMissileProperty(
|
||||||
|
missile: MissileRecord,
|
||||||
|
prop: string,
|
||||||
|
ctx: CalcEvalContext,
|
||||||
|
): number {
|
||||||
|
const p = prop.trim().toLowerCase()
|
||||||
|
const lvl = Math.max(1, ctx.slvl)
|
||||||
|
switch (p) {
|
||||||
|
case 'range':
|
||||||
|
case 'rng':
|
||||||
|
return missile.range + (lvl - 1) * missile.levRange
|
||||||
|
case 'vel':
|
||||||
|
return missile.vel + (lvl - 1) * missile.velLev
|
||||||
|
case 'par1':
|
||||||
|
return missile.param1
|
||||||
|
case 'par2':
|
||||||
|
return missile.param2
|
||||||
|
case 'par3':
|
||||||
|
return missile.param3
|
||||||
|
case 'par4':
|
||||||
|
return missile.param4
|
||||||
|
case 'par5':
|
||||||
|
return missile.param5
|
||||||
|
case 'edmn':
|
||||||
|
case 'edns':
|
||||||
|
case 'edmx':
|
||||||
|
case 'edxs': {
|
||||||
|
const baseMin = compute5BandScaling(
|
||||||
|
lvl,
|
||||||
|
missile.eMin,
|
||||||
|
missile.eMinLev1,
|
||||||
|
missile.eMinLev2,
|
||||||
|
missile.eMinLev3,
|
||||||
|
missile.eMinLev4,
|
||||||
|
missile.eMinLev5,
|
||||||
|
)
|
||||||
|
const baseMax = compute5BandScaling(
|
||||||
|
lvl,
|
||||||
|
missile.eMax,
|
||||||
|
missile.eMaxLev1,
|
||||||
|
missile.eMaxLev2,
|
||||||
|
missile.eMaxLev3,
|
||||||
|
missile.eMaxLev4,
|
||||||
|
missile.eMaxLev5,
|
||||||
|
)
|
||||||
|
const min256 = baseMin << missile.hitShift
|
||||||
|
const max256 = baseMax << missile.hitShift
|
||||||
|
if (p === 'edns') return min256
|
||||||
|
if (p === 'edxs') return max256
|
||||||
|
if (p === 'edmn') return min256 >> 8
|
||||||
|
return max256 >> 8
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve any built-in variable or macro on the current `CalcEvalContext`.
|
||||||
|
*/
|
||||||
|
function resolveBuiltInIdentifier(ident: string, ctx: CalcEvalContext): number {
|
||||||
|
const key = ident.toLowerCase()
|
||||||
|
const skill = ctx.activeSkill
|
||||||
|
const missile = ctx.activeMissile
|
||||||
|
const lvl = Math.max(0, ctx.slvl)
|
||||||
|
const blvl = Math.max(0, ctx.blvl)
|
||||||
|
const ulvl = ctx.ulvl ?? 80
|
||||||
|
|
||||||
|
if (key === 'lvl') return lvl
|
||||||
|
if (key === 'blvl') return blvl
|
||||||
|
if (key === 'ulvl') return ulvl
|
||||||
|
|
||||||
|
const par1 = skill?.param1 ?? missile?.param1 ?? 0
|
||||||
|
const par2 = skill?.param2 ?? missile?.param2 ?? 0
|
||||||
|
const par3 = skill?.param3 ?? missile?.param3 ?? 0
|
||||||
|
const par4 = skill?.param4 ?? missile?.param4 ?? 0
|
||||||
|
const par5 = skill?.param5 ?? missile?.param5 ?? 0
|
||||||
|
const par6 = skill?.param6 ?? 0
|
||||||
|
const par7 = skill?.param7 ?? 0
|
||||||
|
const par8 = skill?.param8 ?? 0
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case 'par1':
|
||||||
|
return par1
|
||||||
|
case 'par2':
|
||||||
|
return par2
|
||||||
|
case 'par3':
|
||||||
|
return par3
|
||||||
|
case 'par4':
|
||||||
|
return par4
|
||||||
|
case 'par5':
|
||||||
|
return par5
|
||||||
|
case 'par6':
|
||||||
|
return par6
|
||||||
|
case 'par7':
|
||||||
|
return par7
|
||||||
|
case 'par8':
|
||||||
|
return par8
|
||||||
|
case 'cpa1':
|
||||||
|
return missile?.cParam1 ?? par1
|
||||||
|
case 'cpa2':
|
||||||
|
return missile?.cParam2 ?? par2
|
||||||
|
case 'cpa3':
|
||||||
|
return missile?.cParam3 ?? par3
|
||||||
|
case 'cpa4':
|
||||||
|
return missile?.cParam4 ?? par4
|
||||||
|
case 'cpa5':
|
||||||
|
return missile?.cParam5 ?? par5
|
||||||
|
case 'hpa1':
|
||||||
|
return missile?.sHitPar1 ?? 0
|
||||||
|
case 'hpa2':
|
||||||
|
return missile?.sHitPar2 ?? 0
|
||||||
|
case 'hpa3':
|
||||||
|
return missile?.sHitPar3 ?? 0
|
||||||
|
case 'chp1':
|
||||||
|
return missile?.cHitPar1 ?? 0
|
||||||
|
case 'chp2':
|
||||||
|
return missile?.cHitPar2 ?? 0
|
||||||
|
case 'chp3':
|
||||||
|
return missile?.cHitPar3 ?? 0
|
||||||
|
case 'dpa1':
|
||||||
|
return missile?.dParam1 ?? 0
|
||||||
|
case 'dpa2':
|
||||||
|
return missile?.dParam2 ?? 0
|
||||||
|
|
||||||
|
case 'ln12':
|
||||||
|
case 'sl12':
|
||||||
|
return computeLinearScaling(par1, par2, lvl)
|
||||||
|
case 'ln34':
|
||||||
|
case 'sl34':
|
||||||
|
return computeLinearScaling(par3, par4, lvl)
|
||||||
|
case 'ln56':
|
||||||
|
return computeLinearScaling(par5, par6, lvl)
|
||||||
|
case 'ln78':
|
||||||
|
return computeLinearScaling(par7, par8, lvl)
|
||||||
|
case 'dl12':
|
||||||
|
return computeLinearScaling(missile?.dParam1 ?? 0, missile?.dParam2 ?? 0, lvl)
|
||||||
|
|
||||||
|
case 'dm12':
|
||||||
|
case 'sd12':
|
||||||
|
return computeDiminishingReturns(par1, par2, lvl)
|
||||||
|
case 'dm34':
|
||||||
|
case 'sd34':
|
||||||
|
return computeDiminishingReturns(par3, par4, lvl)
|
||||||
|
case 'dm56':
|
||||||
|
return computeDiminishingReturns(par5, par6, lvl)
|
||||||
|
case 'dm78':
|
||||||
|
return computeDiminishingReturns(par7, par8, lvl)
|
||||||
|
case 'dd12':
|
||||||
|
return computeDiminishingReturns(missile?.dParam1 ?? 0, missile?.dParam2 ?? 0, lvl)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!skill) return 0
|
||||||
|
|
||||||
|
if (key === 'usmc' || key === 'mana' || key === 'mps') {
|
||||||
|
const { manaCost256, manaCost } = computeSkillManaCost256(skill, lvl)
|
||||||
|
if (key === 'usmc') return manaCost256
|
||||||
|
if (key === 'mana') return manaCost
|
||||||
|
return (manaCost256 * 25) >> 8
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'toht') {
|
||||||
|
if (skill.toHitCalc && (ctx.depth ?? 0) < 8) {
|
||||||
|
return evaluateCalc(skill.toHitCalc, { ...ctx, depth: (ctx.depth ?? 0) + 1 })
|
||||||
|
}
|
||||||
|
return skill.toHit + Math.max(0, lvl - 1) * skill.toHitLev
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'clc1' && (ctx.depth ?? 0) < 8) {
|
||||||
|
return evaluateCalc(skill.calc1, { ...ctx, depth: (ctx.depth ?? 0) + 1 })
|
||||||
|
}
|
||||||
|
if (key === 'clc2' && (ctx.depth ?? 0) < 8) {
|
||||||
|
return evaluateCalc(skill.calc2, { ...ctx, depth: (ctx.depth ?? 0) + 1 })
|
||||||
|
}
|
||||||
|
if (key === 'clc3' && (ctx.depth ?? 0) < 8) {
|
||||||
|
return evaluateCalc(skill.calc3, { ...ctx, depth: (ctx.depth ?? 0) + 1 })
|
||||||
|
}
|
||||||
|
if (key === 'clc4' && (ctx.depth ?? 0) < 8) {
|
||||||
|
return evaluateCalc(skill.calc4, { ...ctx, depth: (ctx.depth ?? 0) + 1 })
|
||||||
|
}
|
||||||
|
if (key === 'len' && (ctx.depth ?? 0) < 8) {
|
||||||
|
return evaluateCalc(skill.auraLenCalc, { ...ctx, depth: (ctx.depth ?? 0) + 1 })
|
||||||
|
}
|
||||||
|
if (key === 'rng' && (ctx.depth ?? 0) < 8) {
|
||||||
|
return evaluateCalc(skill.auraRangeCalc, { ...ctx, depth: (ctx.depth ?? 0) + 1 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
key === 'edmn' ||
|
||||||
|
key === 'edmx' ||
|
||||||
|
key === 'edln' ||
|
||||||
|
key === 'edns' ||
|
||||||
|
key === 'edxs' ||
|
||||||
|
key === 'enma' ||
|
||||||
|
key === 'exma' ||
|
||||||
|
key === 'edma' ||
|
||||||
|
key === 'enms' ||
|
||||||
|
key === 'exms'
|
||||||
|
) {
|
||||||
|
const baseMin = compute5BandScaling(
|
||||||
|
lvl,
|
||||||
|
skill.eMin,
|
||||||
|
skill.eMinLev1,
|
||||||
|
skill.eMinLev2,
|
||||||
|
skill.eMinLev3,
|
||||||
|
skill.eMinLev4,
|
||||||
|
skill.eMinLev5,
|
||||||
|
)
|
||||||
|
const baseMax = compute5BandScaling(
|
||||||
|
lvl,
|
||||||
|
skill.eMax,
|
||||||
|
skill.eMaxLev1,
|
||||||
|
skill.eMaxLev2,
|
||||||
|
skill.eMaxLev3,
|
||||||
|
skill.eMaxLev4,
|
||||||
|
skill.eMaxLev5,
|
||||||
|
)
|
||||||
|
const baseLen = compute3BandDuration(lvl, skill.eLen, skill.eLevLen1, skill.eLevLen2, skill.eLevLen3)
|
||||||
|
const min256 = baseMin << skill.hitShift
|
||||||
|
const max256 = baseMax << skill.hitShift
|
||||||
|
const eSynPct =
|
||||||
|
skill.eDmgSymPerCalc && (ctx.depth ?? 0) < 8
|
||||||
|
? evaluateCalc(skill.eDmgSymPerCalc, { ...ctx, depth: (ctx.depth ?? 0) + 1 })
|
||||||
|
: 0
|
||||||
|
const lenSynPct =
|
||||||
|
skill.eLenSymPerCalc && (ctx.depth ?? 0) < 8
|
||||||
|
? evaluateCalc(skill.eLenSymPerCalc, { ...ctx, depth: (ctx.depth ?? 0) + 1 })
|
||||||
|
: 0
|
||||||
|
|
||||||
|
const synMin256 = Math.trunc((min256 * (100 + eSynPct)) / 100)
|
||||||
|
const synMax256 = Math.trunc((max256 * (100 + eSynPct)) / 100)
|
||||||
|
const synLen = Math.trunc((baseLen * (100 + lenSynPct)) / 100)
|
||||||
|
|
||||||
|
switch (key) {
|
||||||
|
case 'edns':
|
||||||
|
return min256
|
||||||
|
case 'edxs':
|
||||||
|
return max256
|
||||||
|
case 'edmn':
|
||||||
|
return min256 >> 8
|
||||||
|
case 'edmx':
|
||||||
|
return max256 >> 8
|
||||||
|
case 'edln':
|
||||||
|
return baseLen
|
||||||
|
case 'enms':
|
||||||
|
return synMin256
|
||||||
|
case 'exms':
|
||||||
|
return synMax256
|
||||||
|
case 'enma':
|
||||||
|
return synMin256 >> 8
|
||||||
|
case 'exma':
|
||||||
|
return synMax256 >> 8
|
||||||
|
case 'edma':
|
||||||
|
return synLen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edge Case #2 (`Bone Wall` row 78 `calc2 = "par34"` and any other unknown bare identifier):
|
||||||
|
// `D2Common` resolves unknown symbols to 0.
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate any `D2Common` formula expression (`Skills.txt`, `SkillDesc.txt`, `Missiles.txt`).
|
||||||
|
*/
|
||||||
|
export function evaluateCalc(expr: string | undefined | null, ctx: CalcEvalContext): number {
|
||||||
|
if (!expr) return 0
|
||||||
|
let trimmed = expr.trim()
|
||||||
|
if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) {
|
||||||
|
trimmed = trimmed.slice(1, -1).replace(/""/g, '"').trim()
|
||||||
|
}
|
||||||
|
if (trimmed === '' || trimmed === '0') return 0
|
||||||
|
if (/^-?\d+$/.test(trimmed)) return Number(trimmed)
|
||||||
|
|
||||||
|
const tokens = tokenizeCalc(trimmed)
|
||||||
|
if (tokens.length === 0) return 0
|
||||||
|
|
||||||
|
let pos = 0
|
||||||
|
const peek = (): Token | undefined => tokens[pos]
|
||||||
|
const consume = (): Token | undefined => tokens[pos++]
|
||||||
|
|
||||||
|
function parseExpression(): number {
|
||||||
|
const cond = parseComparison()
|
||||||
|
if (peek()?.kind === 'question') {
|
||||||
|
consume() // '?'
|
||||||
|
const trueVal = parseExpression()
|
||||||
|
if (peek()?.kind === 'colon') {
|
||||||
|
consume() // ':'
|
||||||
|
}
|
||||||
|
const falseVal = parseExpression()
|
||||||
|
return cond !== 0 ? trueVal : falseVal
|
||||||
|
}
|
||||||
|
return cond
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseComparison(): number {
|
||||||
|
let left = parseAdditive()
|
||||||
|
while (peek()?.kind === 'op') {
|
||||||
|
const op = (peek() as { kind: 'op'; op: string }).op
|
||||||
|
if (op === '<' || op === '>' || op === '<=' || op === '>=' || op === '==' || op === '=' || op === '!=') {
|
||||||
|
consume()
|
||||||
|
const right = parseAdditive()
|
||||||
|
if (op === '<') left = left < right ? 1 : 0
|
||||||
|
else if (op === '>') left = left > right ? 1 : 0
|
||||||
|
else if (op === '<=') left = left <= right ? 1 : 0
|
||||||
|
else if (op === '>=') left = left >= right ? 1 : 0
|
||||||
|
else if (op === '==' || op === '=') left = left === right ? 1 : 0
|
||||||
|
else if (op === '!=') left = left !== right ? 1 : 0
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAdditive(): number {
|
||||||
|
let left = parseMultiplicative()
|
||||||
|
while (peek()?.kind === 'op') {
|
||||||
|
const op = (peek() as { kind: 'op'; op: string }).op
|
||||||
|
if (op === '+' || op === '-') {
|
||||||
|
consume()
|
||||||
|
const right = parseMultiplicative()
|
||||||
|
left = op === '+' ? (left + right) | 0 : (left - right) | 0
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMultiplicative(): number {
|
||||||
|
let left = parsePower()
|
||||||
|
while (peek()?.kind === 'op') {
|
||||||
|
const op = (peek() as { kind: 'op'; op: string }).op
|
||||||
|
if (op === '*' || op === '/') {
|
||||||
|
consume()
|
||||||
|
const right = parsePower()
|
||||||
|
if (op === '*') {
|
||||||
|
left = Math.trunc(left * right)
|
||||||
|
} else {
|
||||||
|
// D2Common division by zero evaluates to 0
|
||||||
|
left = right === 0 ? 0 : Math.trunc(left / right)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePower(): number {
|
||||||
|
let left = parseUnary()
|
||||||
|
while (peek()?.kind === 'op' && (peek() as { kind: 'op'; op: string }).op === '^') {
|
||||||
|
consume()
|
||||||
|
const right = parseUnary()
|
||||||
|
left = right < 0 ? 0 : Math.trunc(Math.pow(left, right))
|
||||||
|
}
|
||||||
|
return left
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseUnary(): number {
|
||||||
|
const t = peek()
|
||||||
|
if (t?.kind === 'op' && (t.op === '-' || t.op === '+')) {
|
||||||
|
consume()
|
||||||
|
const val = parseUnary()
|
||||||
|
return t.op === '-' ? -val : val
|
||||||
|
}
|
||||||
|
return parsePrimary()
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePrimary(): number {
|
||||||
|
const t = peek()
|
||||||
|
if (!t) return 0
|
||||||
|
|
||||||
|
if (t.kind === 'num') {
|
||||||
|
consume()
|
||||||
|
return t.value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.kind === 'lparen') {
|
||||||
|
consume() // '('
|
||||||
|
const val = parseExpression()
|
||||||
|
// Edge Case #1 (`Fire Wall` row 51): tolerate missing ')' at EOF!
|
||||||
|
if (peek()?.kind === 'rparen') {
|
||||||
|
consume()
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
if (t.kind === 'ident') {
|
||||||
|
consume()
|
||||||
|
const fnName = t.name.toLowerCase()
|
||||||
|
|
||||||
|
if (peek()?.kind === 'lparen') {
|
||||||
|
consume() // '('
|
||||||
|
|
||||||
|
// Special functions: `skill('Name'.prop)`, `sklvl('Name'.lvlExpr.prop)`, `stat('Name'.prop)`, `miss('Name'.prop)`
|
||||||
|
if (fnName === 'skill' || fnName === 'sklvl' || fnName === 'stat' || fnName === 'miss') {
|
||||||
|
const nameTok = peek()
|
||||||
|
let targetName = ''
|
||||||
|
if (nameTok?.kind === 'str' || nameTok?.kind === 'ident') {
|
||||||
|
consume()
|
||||||
|
targetName = nameTok.kind === 'str' ? nameTok.value : nameTok.name
|
||||||
|
}
|
||||||
|
const props: string[] = []
|
||||||
|
while (peek()?.kind === 'dot') {
|
||||||
|
consume() // '.'
|
||||||
|
const propTok = peek()
|
||||||
|
if (propTok?.kind === 'ident' || propTok?.kind === 'str') {
|
||||||
|
consume()
|
||||||
|
props.push(propTok.kind === 'ident' ? propTok.name : propTok.value)
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (peek()?.kind === 'rparen') {
|
||||||
|
consume()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fnName === 'skill') {
|
||||||
|
const targetSkill = ctx.registry.getSkillByName(targetName)
|
||||||
|
if (!targetSkill) return 0
|
||||||
|
return resolveSkillProperty(targetSkill, props[0] ?? 'lvl', ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fnName === 'sklvl') {
|
||||||
|
// Edge Case #4: Two-dot `sklvl('Holy Fire'.ln56.edmn)`
|
||||||
|
const targetSkill = ctx.registry.getSkillByName(targetName)
|
||||||
|
if (!targetSkill) return 0
|
||||||
|
const lvlExprProp = props[0] ?? 'lvl'
|
||||||
|
const valProp = props[1] ?? props[0] ?? 'edmn'
|
||||||
|
const targetLvl = resolveBuiltInIdentifier(lvlExprProp, ctx)
|
||||||
|
return resolveSkillProperty(targetSkill, valProp, ctx, targetLvl)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fnName === 'stat') {
|
||||||
|
const prop = (props[0] ?? 'accr').toLowerCase()
|
||||||
|
if (ctx.getUnitStat) {
|
||||||
|
return ctx.getUnitStat(targetName, prop === 'base')
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fnName === 'miss') {
|
||||||
|
const targetMissile = ctx.registry.getMissileByName(targetName)
|
||||||
|
if (!targetMissile) return 0
|
||||||
|
return resolveMissileProperty(targetMissile, props[0] ?? 'range', ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard numeric functions: `min(a, b)`, `max(a, b)`, `rand(a, b)`
|
||||||
|
const args: number[] = []
|
||||||
|
if (peek() && peek()?.kind !== 'rparen') {
|
||||||
|
args.push(parseExpression())
|
||||||
|
while (peek()?.kind === 'comma') {
|
||||||
|
consume()
|
||||||
|
args.push(parseExpression())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (peek()?.kind === 'rparen') {
|
||||||
|
consume()
|
||||||
|
}
|
||||||
|
|
||||||
|
const a = args[0] ?? 0
|
||||||
|
const b = args[1] ?? 0
|
||||||
|
if (fnName === 'min') return Math.min(a, b)
|
||||||
|
if (fnName === 'max') return Math.max(a, b)
|
||||||
|
if (fnName === 'rand') {
|
||||||
|
const lo = Math.min(a, b)
|
||||||
|
const hi = Math.max(a, b)
|
||||||
|
return ctx.randFn ? ctx.randFn(lo, hi) : Math.trunc((lo + hi) / 2)
|
||||||
|
}
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolveBuiltInIdentifier(fnName, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
consume()
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.trunc(parseExpression())
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,547 @@
|
||||||
|
/**
|
||||||
|
* Diablo II: Lord of Destruction v1.13c `RollToHit` & `SUnitDmg` Combat Pipeline.
|
||||||
|
*
|
||||||
|
* Implements:
|
||||||
|
* - `RollToHit` (`AR` vs `Defense` + `Alvl` vs `Dlvl`, clamped to `[5, 95]`, `autohit` for Smite/Guided Arrow/Spells).
|
||||||
|
* - Shield Block, Assassin `Weapon Block` (`263`), and Amazon `Dodge` (`13`), `Avoid` (`18`), `Evade` (`29`).
|
||||||
|
* - Weapon `srcdam` (`128 = 100%`), off-weapon `%ED`, Critical Strike / Deadly Strike (`2x`), and Physical-to-Elemental/Magic conversion.
|
||||||
|
* - Elemental Masteries (`Fire Mastery` 61, `Lightning Mastery` 63, `Cold Mastery` 65, `passive_*_mastery`).
|
||||||
|
* - Conviction (`123`) & Lower Resist (`91`) Immunity-Breaking (`1/5` efficiency when base resistance >= 100%,
|
||||||
|
* followed by `-Enemy Res` at 100% effectiveness only after immunity is broken below 100%).
|
||||||
|
* - 1.13c Binary Overrides:
|
||||||
|
* 1. **Blessed Hammer (`112`)**: Does NOT ignore Magic Resistance of Undead or Demons in 1.13c!
|
||||||
|
* (Undead/Demons with `magicresist >= 100` take `0` damage; Undead with `< 100` magic resist still
|
||||||
|
* receive `+50%` Undead bonus and `+50%` of active Concentration aura `%ED`.)
|
||||||
|
* 2. **Corpse Explosion (`74`)**: Scales at **`70%–120%`** (`Param1=70, Param2=120`) of base monster HP,
|
||||||
|
* split `50%` Physical + `50%` Fire.
|
||||||
|
* 3. **Venom (`278`)**: Clamps total poison duration of all weapon/item poison sources to **`10` frames (`0.4s`)**
|
||||||
|
* while summing their per-frame poison bitrates (`<< 8`) with Venom's bitrate.
|
||||||
|
*/
|
||||||
|
import { FIXED_ONE, UnitStatList } from './stat-list.ts'
|
||||||
|
import { StateBus } from './state-bus.ts'
|
||||||
|
|
||||||
|
export interface CombatUnitContext {
|
||||||
|
readonly id: string
|
||||||
|
readonly name: string
|
||||||
|
readonly statList: UnitStatList
|
||||||
|
readonly stateBus: StateBus
|
||||||
|
readonly isUndead?: boolean
|
||||||
|
readonly isDemon?: boolean
|
||||||
|
readonly isMoving?: boolean
|
||||||
|
readonly isDualClaw?: boolean
|
||||||
|
readonly hasShield?: boolean
|
||||||
|
readonly weaponMinPhys?: number
|
||||||
|
readonly weaponMaxPhys?: number
|
||||||
|
readonly weaponPoisonBitrate256?: number
|
||||||
|
readonly weaponPoisonFrames?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResistanceResult {
|
||||||
|
readonly baseRes: number
|
||||||
|
readonly convictionAndLRApplied: number
|
||||||
|
readonly resAfterConvictionLR: number
|
||||||
|
readonly isImmune: boolean
|
||||||
|
readonly effectiveRes: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute effective resistance under 1.13c Conviction (`123`) + Lower Resist (`91`)
|
||||||
|
* `1/5` immunity-breaking rule and `-Enemy Resistance` (`Cold Mastery` / facets).
|
||||||
|
*/
|
||||||
|
export function computeEffectiveResistance(params: {
|
||||||
|
baseRes: number
|
||||||
|
convictionPierce?: number
|
||||||
|
lowerResistPierce?: number
|
||||||
|
passiveEnemyPierce?: number
|
||||||
|
canBreakImmunity?: boolean
|
||||||
|
}): ResistanceResult {
|
||||||
|
const baseRes = Math.trunc(params.baseRes)
|
||||||
|
const rawConvictionAndLR = Math.max(0, Math.trunc((params.convictionPierce ?? 0) + (params.lowerResistPierce ?? 0)))
|
||||||
|
const canBreak = params.canBreakImmunity ?? true
|
||||||
|
|
||||||
|
// 1.13c Rule: When base resistance >= 100%, Conviction and Lower Resist apply at 1/5 effectiveness!
|
||||||
|
const convictionAndLRApplied =
|
||||||
|
baseRes >= 100
|
||||||
|
? canBreak
|
||||||
|
? Math.trunc(rawConvictionAndLR / 5)
|
||||||
|
: 0
|
||||||
|
: rawConvictionAndLR
|
||||||
|
|
||||||
|
const resAfterConvictionLR = baseRes - convictionAndLRApplied
|
||||||
|
if (resAfterConvictionLR >= 100) {
|
||||||
|
return {
|
||||||
|
baseRes,
|
||||||
|
convictionAndLRApplied,
|
||||||
|
resAfterConvictionLR,
|
||||||
|
isImmune: true,
|
||||||
|
effectiveRes: 100,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const passivePierce = Math.max(0, Math.trunc(params.passiveEnemyPierce ?? 0))
|
||||||
|
const effectiveRes = Math.max(-100, Math.min(99, resAfterConvictionLR - passivePierce))
|
||||||
|
return {
|
||||||
|
baseRes,
|
||||||
|
convictionAndLRApplied,
|
||||||
|
resAfterConvictionLR,
|
||||||
|
isImmune: false,
|
||||||
|
effectiveRes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute 1.13c `RollToHit` chance (`[5, 95]`, or `100` for `autohit` skills such as Smite / Guided Arrow / Spells).
|
||||||
|
*/
|
||||||
|
export function computeToHitChance(params: {
|
||||||
|
attackerAr: number
|
||||||
|
defenderDef: number
|
||||||
|
attackerLvl: number
|
||||||
|
defenderLvl: number
|
||||||
|
skillArBonusPct?: number
|
||||||
|
targetDefPctMod?: number // e.g. -50% from Battle Cry / Cloak of Shadows / Inner Sight
|
||||||
|
autoHit?: boolean
|
||||||
|
}): number {
|
||||||
|
if (params.autoHit) return 100
|
||||||
|
const arBonusPct = params.skillArBonusPct ?? 0
|
||||||
|
const effAr = Math.max(1, Math.trunc((params.attackerAr * (100 + arBonusPct)) / 100))
|
||||||
|
const defMod = params.targetDefPctMod ?? 0
|
||||||
|
const effDef = Math.max(0, Math.trunc((params.defenderDef * Math.max(0, 100 + defMod)) / 100))
|
||||||
|
const alvl = Math.max(1, params.attackerLvl)
|
||||||
|
const dlvl = Math.max(1, params.defenderLvl)
|
||||||
|
|
||||||
|
const raw = Math.trunc((2 * effAr * 100 * alvl) / (Math.max(1, effAr + effDef) * (alvl + dlvl)))
|
||||||
|
return Math.max(5, Math.min(95, raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate shield block, Assassin Weapon Block (`263`), and Amazon Dodge (`13`), Avoid (`18`), Evade (`29`).
|
||||||
|
*/
|
||||||
|
export function evaluateAvoidanceAndBlock(params: {
|
||||||
|
defender: CombatUnitContext
|
||||||
|
attackKind: 'melee' | 'missile' | 'spell'
|
||||||
|
unblockable?: boolean
|
||||||
|
roll100?: number // deterministic roll in [0..99], default 50
|
||||||
|
}): { avoided: boolean; reason: 'none' | 'block' | 'weapon_block' | 'dodge' | 'avoid' | 'evade' } {
|
||||||
|
if (params.unblockable) return { avoided: false, reason: 'none' }
|
||||||
|
const roll = params.roll100 ?? 50
|
||||||
|
const defStats = params.defender.statList
|
||||||
|
|
||||||
|
// 1. Shield Block (applies to physical melee/missile)
|
||||||
|
if (params.attackKind !== 'spell' && params.defender.hasShield) {
|
||||||
|
const blockChance = Math.min(75, Math.max(0, defStats.getAccruedStat('toblock')))
|
||||||
|
if (roll < blockChance) {
|
||||||
|
return { avoided: true, reason: 'block' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Assassin Weapon Block (`skillId = 263`, blocks melee, missiles, and most spells while stationary/attacking with dual claws)
|
||||||
|
if (params.defender.isDualClaw && !params.defender.isMoving) {
|
||||||
|
const wbChance = Math.min(65, Math.max(0, defStats.getAccruedStat('weaponblock')))
|
||||||
|
if (roll < wbChance) {
|
||||||
|
return { avoided: true, reason: 'weapon_block' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Amazon Evade (`skillId = 29`, while moving)
|
||||||
|
if (params.defender.isMoving) {
|
||||||
|
const evadeChance = Math.min(75, Math.max(0, defStats.getAccruedStat('passive_evade')))
|
||||||
|
if (roll < evadeChance) {
|
||||||
|
return { avoided: true, reason: 'evade' }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 4. Amazon Dodge (`skillId = 13`, melee while stationary)
|
||||||
|
if (params.attackKind === 'melee') {
|
||||||
|
const dodgeChance = Math.min(75, Math.max(0, defStats.getAccruedStat('passive_dodge')))
|
||||||
|
if (roll < dodgeChance) {
|
||||||
|
return { avoided: true, reason: 'dodge' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 5. Amazon Avoid (`skillId = 18`, missile/ranged while stationary)
|
||||||
|
if (params.attackKind === 'missile') {
|
||||||
|
const avoidChance = Math.min(75, Math.max(0, defStats.getAccruedStat('passive_avoid')))
|
||||||
|
if (roll < avoidChance) {
|
||||||
|
return { avoided: true, reason: 'avoid' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { avoided: false, reason: 'none' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1.13c Venom (`skillId = 278`) Poison Duration Clamp:
|
||||||
|
* When `STATE_VENOM` is active on a weapon attack, clamps total poison duration of ALL
|
||||||
|
* weapon/charm poison sources to **10 frames (`0.4s`)** while summing their per-frame
|
||||||
|
* poison bitrates (`<< 8`) with Venom's bitrate!
|
||||||
|
*/
|
||||||
|
export function resolveVenomPoisonApplication(params: {
|
||||||
|
venomActive: boolean
|
||||||
|
venomBitrate256: number
|
||||||
|
otherPoisonSources: readonly { bitrate256: number; frames: number }[]
|
||||||
|
}): { bitrate256: number; durationFrames: number; totalDamage256: number; totalDamage: number } {
|
||||||
|
if (params.venomActive) {
|
||||||
|
const clampedDuration = 10 // 1.13c Venom fixed 10-frame clamp (0.4s)
|
||||||
|
let totalBitrate256 = Math.max(0, Math.trunc(params.venomBitrate256))
|
||||||
|
for (const src of params.otherPoisonSources) {
|
||||||
|
if (src.bitrate256 > 0 && src.frames > 0) {
|
||||||
|
totalBitrate256 += Math.trunc(src.bitrate256)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const totalDamage256 = totalBitrate256 * clampedDuration
|
||||||
|
return {
|
||||||
|
bitrate256: totalBitrate256,
|
||||||
|
durationFrames: clampedDuration,
|
||||||
|
totalDamage256,
|
||||||
|
totalDamage: Math.trunc(totalDamage256 / FIXED_ONE),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let sumBitrate256 = 0
|
||||||
|
let sumFrames = 0
|
||||||
|
let count = 0
|
||||||
|
for (const src of params.otherPoisonSources) {
|
||||||
|
if (src.bitrate256 > 0 && src.frames > 0) {
|
||||||
|
sumBitrate256 += Math.trunc(src.bitrate256)
|
||||||
|
sumFrames += Math.trunc(src.frames)
|
||||||
|
count += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const avgFrames = count > 0 ? Math.trunc(sumFrames / count) : 0
|
||||||
|
const totalDamage256 = sumBitrate256 * avgFrames
|
||||||
|
return {
|
||||||
|
bitrate256: sumBitrate256,
|
||||||
|
durationFrames: avgFrames,
|
||||||
|
totalDamage256,
|
||||||
|
totalDamage: Math.trunc(totalDamage256 / FIXED_ONE),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1.13c Corpse Explosion (`skillId = 74`) Damage Calculator:
|
||||||
|
* In 1.13c (`Patch_D2.mpq` `Skills.txt` & `patch.txt` line 126), Corpse Explosion
|
||||||
|
* scales at **70%–120%** (`Param1=70`, `Param2=120`) of base monster HP (up from 60%–100% in 1.10),
|
||||||
|
* split **50% Physical + 50% Fire** (`Param5=50`).
|
||||||
|
*/
|
||||||
|
export function computeCorpseExplosion113c(params: {
|
||||||
|
corpseBaseHp: number
|
||||||
|
rollPct?: number // percentage in [70..120], defaults to midpoint 95%
|
||||||
|
targetPhysRes?: number
|
||||||
|
targetFireRes?: number
|
||||||
|
convictionPierce?: number
|
||||||
|
lowerResistPierce?: number
|
||||||
|
amplifyDamagePierce?: number // e.g. 100 for Amplify Damage, 50 for Decrepify
|
||||||
|
}): {
|
||||||
|
minRollPct: number
|
||||||
|
maxRollPct: number
|
||||||
|
chosenPct: number
|
||||||
|
rawTotalDamage: number
|
||||||
|
rawPhysDamage: number
|
||||||
|
rawFireDamage: number
|
||||||
|
finalPhysDamage: number
|
||||||
|
finalFireDamage: number
|
||||||
|
finalTotalDamage: number
|
||||||
|
} {
|
||||||
|
const minRollPct = 70
|
||||||
|
const maxRollPct = 120
|
||||||
|
const chosenPct = Math.max(minRollPct, Math.min(maxRollPct, params.rollPct ?? 95))
|
||||||
|
const rawTotalDamage = Math.trunc((params.corpseBaseHp * chosenPct) / 100)
|
||||||
|
const rawFireDamage = Math.trunc((rawTotalDamage * 50) / 100)
|
||||||
|
const rawPhysDamage = rawTotalDamage - rawFireDamage
|
||||||
|
|
||||||
|
const physRes = computeEffectiveResistance({
|
||||||
|
baseRes: params.targetPhysRes ?? 0,
|
||||||
|
convictionPierce: params.amplifyDamagePierce ?? 0,
|
||||||
|
})
|
||||||
|
const fireRes = computeEffectiveResistance({
|
||||||
|
baseRes: params.targetFireRes ?? 0,
|
||||||
|
convictionPierce: params.convictionPierce ?? 0,
|
||||||
|
lowerResistPierce: params.lowerResistPierce ?? 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const finalPhysDamage = physRes.isImmune
|
||||||
|
? 0
|
||||||
|
: Math.max(0, Math.trunc((rawPhysDamage * (100 - physRes.effectiveRes)) / 100))
|
||||||
|
const finalFireDamage = fireRes.isImmune
|
||||||
|
? 0
|
||||||
|
: Math.max(0, Math.trunc((rawFireDamage * (100 - fireRes.effectiveRes)) / 100))
|
||||||
|
|
||||||
|
return {
|
||||||
|
minRollPct,
|
||||||
|
maxRollPct,
|
||||||
|
chosenPct,
|
||||||
|
rawTotalDamage,
|
||||||
|
rawPhysDamage,
|
||||||
|
rawFireDamage,
|
||||||
|
finalPhysDamage,
|
||||||
|
finalFireDamage,
|
||||||
|
finalTotalDamage: finalPhysDamage + finalFireDamage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SUnitDmgPacket {
|
||||||
|
readonly skillId: number
|
||||||
|
readonly attackKind?: 'melee' | 'missile' | 'spell'
|
||||||
|
readonly srcDam?: number // 128 = 100% weapon damage
|
||||||
|
readonly physDamagePct?: number // skill off-weapon %ED
|
||||||
|
readonly flatPhysMin256?: number
|
||||||
|
readonly flatPhysMax256?: number
|
||||||
|
readonly elemType?: string // 'fire' | 'ltng' | 'cold' | 'pois' | 'mag' | 'stun'
|
||||||
|
readonly elemMin256?: number
|
||||||
|
readonly elemMax256?: number
|
||||||
|
readonly elemDurationFrames?: number
|
||||||
|
readonly physToElemPct?: number // e.g. Berserk 100% magic, Magic Arrow / Fire Arrow %
|
||||||
|
readonly concentrationBonusPct?: number // active Concentration aura %ED for Blessed Hammer (50% efficiency)
|
||||||
|
readonly convictionPierce?: number
|
||||||
|
readonly lowerResistPierce?: number
|
||||||
|
readonly passiveEnemyPierce?: number
|
||||||
|
readonly autoHit?: boolean
|
||||||
|
readonly unblockable?: boolean
|
||||||
|
readonly roll100?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SUnitDmgOutcome {
|
||||||
|
readonly hit: boolean
|
||||||
|
readonly avoidedReason: 'none' | 'miss' | 'block' | 'weapon_block' | 'dodge' | 'avoid' | 'evade'
|
||||||
|
readonly immuneToElem: boolean
|
||||||
|
readonly immuneToPhys: boolean
|
||||||
|
readonly physDamage256: number
|
||||||
|
readonly elemDamage256: number
|
||||||
|
readonly totalDamage256: number
|
||||||
|
readonly totalDamage: number
|
||||||
|
readonly targetHpBefore256: number
|
||||||
|
readonly targetHpAfter256: number
|
||||||
|
readonly targetKilled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the complete 1.13c `SUnitDmg` damage pipeline from `attacker` onto `defender`.
|
||||||
|
*/
|
||||||
|
export function executeSUnitDmg(
|
||||||
|
attacker: CombatUnitContext,
|
||||||
|
defender: CombatUnitContext,
|
||||||
|
packet: SUnitDmgPacket,
|
||||||
|
): SUnitDmgOutcome {
|
||||||
|
const hpBefore256 = defender.statList.getHp256()
|
||||||
|
const attackKind = packet.attackKind ?? (packet.srcDam && packet.srcDam > 0 ? 'melee' : 'spell')
|
||||||
|
const roll = packet.roll100 ?? 25
|
||||||
|
|
||||||
|
// 1. RollToHit check for weapon attacks (unless autoHit, e.g. Smite 97 / Guided Arrow 22 / Spells)
|
||||||
|
const isAutoHit = packet.autoHit ?? (packet.skillId === 97 || packet.skillId === 22 || attackKind === 'spell')
|
||||||
|
if (!isAutoHit) {
|
||||||
|
const toHitChance = computeToHitChance({
|
||||||
|
attackerAr: attacker.statList.getAccruedStat('tohit'),
|
||||||
|
defenderDef: defender.statList.getAccruedStat('armorclass'),
|
||||||
|
attackerLvl: attacker.statList.getAccruedStat('level'),
|
||||||
|
defenderLvl: defender.statList.getAccruedStat('level'),
|
||||||
|
})
|
||||||
|
if (roll >= toHitChance) {
|
||||||
|
return {
|
||||||
|
hit: false,
|
||||||
|
avoidedReason: 'miss',
|
||||||
|
immuneToElem: false,
|
||||||
|
immuneToPhys: false,
|
||||||
|
physDamage256: 0,
|
||||||
|
elemDamage256: 0,
|
||||||
|
totalDamage256: 0,
|
||||||
|
totalDamage: 0,
|
||||||
|
targetHpBefore256: hpBefore256,
|
||||||
|
targetHpAfter256: hpBefore256,
|
||||||
|
targetKilled: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Avoidance & Block (Smite `97` is unblockable by shield block)
|
||||||
|
const avoidCheck = evaluateAvoidanceAndBlock({
|
||||||
|
defender,
|
||||||
|
attackKind,
|
||||||
|
unblockable: packet.unblockable ?? packet.skillId === 97,
|
||||||
|
roll100: roll,
|
||||||
|
})
|
||||||
|
if (avoidCheck.avoided) {
|
||||||
|
return {
|
||||||
|
hit: false,
|
||||||
|
avoidedReason: avoidCheck.reason,
|
||||||
|
immuneToElem: false,
|
||||||
|
immuneToPhys: false,
|
||||||
|
physDamage256: 0,
|
||||||
|
elemDamage256: 0,
|
||||||
|
totalDamage256: 0,
|
||||||
|
totalDamage: 0,
|
||||||
|
targetHpBefore256: hpBefore256,
|
||||||
|
targetHpAfter256: hpBefore256,
|
||||||
|
targetKilled: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Weapon `srcdam` (128 = 100%) + Physical Damage calculation
|
||||||
|
const srcDam = packet.srcDam ?? (attackKind !== 'spell' ? 128 : 0)
|
||||||
|
let rawPhys256 = 0
|
||||||
|
if (srcDam > 0 || (packet.flatPhysMin256 ?? 0) > 0 || (packet.flatPhysMax256 ?? 0) > 0) {
|
||||||
|
const weapMin256 = (attacker.weaponMinPhys ?? 25) * FIXED_ONE
|
||||||
|
const weapMax256 = (attacker.weaponMaxPhys ?? 45) * FIXED_ONE
|
||||||
|
const avgWeap256 = Math.trunc(((weapMin256 + weapMax256) / 2) * srcDam / 128)
|
||||||
|
const flatAvg256 = Math.trunc(((packet.flatPhysMin256 ?? 0) + (packet.flatPhysMax256 ?? 0)) / 2)
|
||||||
|
const edPct =
|
||||||
|
(packet.physDamagePct ?? 0) +
|
||||||
|
attacker.statList.getModifierBonus('damagepercent') +
|
||||||
|
attacker.statList.getModifierBonus('skill_damage_percent')
|
||||||
|
rawPhys256 = Math.max(0, Math.trunc(((avgWeap256 + flatAvg256) * (100 + edPct)) / 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Physical-to-Elemental/Magic Conversion (e.g. Berserk 100% Magic, Magic Arrow, Fire Arrow, Cold Arrow)
|
||||||
|
let rawElem256 = Math.trunc(((packet.elemMin256 ?? 0) + (packet.elemMax256 ?? 0)) / 2)
|
||||||
|
const convPct = Math.max(0, Math.min(100, packet.physToElemPct ?? (packet.skillId === 152 ? 100 : 0)))
|
||||||
|
if (convPct > 0 && rawPhys256 > 0) {
|
||||||
|
const converted256 = Math.trunc((rawPhys256 * convPct) / 100)
|
||||||
|
rawPhys256 -= converted256
|
||||||
|
rawElem256 += converted256
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Elemental Mastery & 1.13c Blessed Hammer / Venom / Static Field rules
|
||||||
|
const elemType = (packet.elemType ?? (packet.skillId === 152 || packet.skillId === 112 ? 'mag' : 'fire')).toLowerCase()
|
||||||
|
if (rawElem256 > 0) {
|
||||||
|
let masteryPct = 0
|
||||||
|
if (elemType === 'fire') masteryPct = attacker.statList.getAccruedStat('passive_fire_mastery')
|
||||||
|
else if (elemType === 'ltng') masteryPct = attacker.statList.getAccruedStat('passive_ltng_mastery')
|
||||||
|
else if (elemType === 'cold') masteryPct = attacker.statList.getAccruedStat('passive_cold_mastery')
|
||||||
|
else if (elemType === 'pois') masteryPct = attacker.statList.getAccruedStat('passive_pois_mastery')
|
||||||
|
if (masteryPct > 0) {
|
||||||
|
rawElem256 = Math.trunc((rawElem256 * (100 + masteryPct)) / 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blessed Hammer (`skillId = 112`):
|
||||||
|
// - Concentration aura adds `50%` (`Param1 = 4 -> 4/8`) of its `%ED` to Blessed Hammer.
|
||||||
|
// - `+50%` damage bonus vs Undead (`dParam1 = 50`).
|
||||||
|
// - CRITICAL 1.13c RULE (`patch.txt` line 112): Blessed Hammer does NOT ignore Magic Resistance
|
||||||
|
// of Undead or Demons! Magic Resistance (`magicresist`) applies normally to all targets.
|
||||||
|
if (packet.skillId === 112) {
|
||||||
|
const concPct =
|
||||||
|
packet.concentrationBonusPct ??
|
||||||
|
attacker.statList.getModifierBonus('concentration_damage_pct')
|
||||||
|
if (concPct > 0) {
|
||||||
|
rawElem256 = Math.trunc((rawElem256 * (100 + Math.trunc(concPct / 2))) / 100)
|
||||||
|
}
|
||||||
|
if (defender.isUndead) {
|
||||||
|
rawElem256 = Math.trunc((rawElem256 * 150) / 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Venom (`skillId = 278` or `STATE_VENOM` active on weapon attack)
|
||||||
|
if (
|
||||||
|
attackKind !== 'spell' &&
|
||||||
|
(packet.skillId === 278 || attacker.stateBus.hasState('venom'))
|
||||||
|
) {
|
||||||
|
const venomRes = resolveVenomPoisonApplication({
|
||||||
|
venomActive: true,
|
||||||
|
venomBitrate256: attacker.statList.getModifierBonus('venom_bitrate_256') || (35 * FIXED_ONE),
|
||||||
|
otherPoisonSources:
|
||||||
|
(attacker.weaponPoisonBitrate256 ?? 0) > 0
|
||||||
|
? [{ bitrate256: attacker.weaponPoisonBitrate256!, frames: attacker.weaponPoisonFrames ?? 100 }]
|
||||||
|
: [],
|
||||||
|
})
|
||||||
|
rawElem256 += venomRes.totalDamage256
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Resistances & 1/5 Immunity Breaking
|
||||||
|
const physResRec = computeEffectiveResistance({
|
||||||
|
baseRes: defender.statList.getAccruedStat('damageresist'),
|
||||||
|
convictionPierce: defender.statList.getModifierBonus('amplify_phys_pierce'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const resStatKey =
|
||||||
|
elemType === 'fire'
|
||||||
|
? 'fireresist'
|
||||||
|
: elemType === 'ltng'
|
||||||
|
? 'lightresist'
|
||||||
|
: elemType === 'cold'
|
||||||
|
? 'coldresist'
|
||||||
|
: elemType === 'pois'
|
||||||
|
? 'poisonresist'
|
||||||
|
: 'magicresist'
|
||||||
|
|
||||||
|
const isElemPiercableByConviction = elemType === 'fire' || elemType === 'ltng' || elemType === 'cold'
|
||||||
|
const isElemPiercableByLR = elemType === 'fire' || elemType === 'ltng' || elemType === 'cold' || elemType === 'pois'
|
||||||
|
|
||||||
|
const convictionPierce = isElemPiercableByConviction
|
||||||
|
? (packet.convictionPierce ?? defender.statList.getModifierBonus('conviction_pierce'))
|
||||||
|
: 0
|
||||||
|
const lowerResistPierce = isElemPiercableByLR
|
||||||
|
? (packet.lowerResistPierce ?? defender.statList.getModifierBonus('lowerresist_pierce'))
|
||||||
|
: 0
|
||||||
|
const passivePierce =
|
||||||
|
packet.passiveEnemyPierce ??
|
||||||
|
(elemType === 'cold'
|
||||||
|
? attacker.statList.getAccruedStat('passive_cold_pierce')
|
||||||
|
: elemType === 'fire'
|
||||||
|
? attacker.statList.getAccruedStat('passive_fire_pierce')
|
||||||
|
: elemType === 'ltng'
|
||||||
|
? attacker.statList.getAccruedStat('passive_ltng_pierce')
|
||||||
|
: elemType === 'pois'
|
||||||
|
? attacker.statList.getAccruedStat('passive_pois_pierce')
|
||||||
|
: 0)
|
||||||
|
|
||||||
|
const elemResRec = computeEffectiveResistance({
|
||||||
|
baseRes: defender.statList.getAccruedStat(resStatKey),
|
||||||
|
convictionPierce,
|
||||||
|
lowerResistPierce,
|
||||||
|
passiveEnemyPierce: passivePierce,
|
||||||
|
// Magic resistance cannot be broken by Conviction/Lower Resist
|
||||||
|
canBreakImmunity: isElemPiercableByLR,
|
||||||
|
})
|
||||||
|
|
||||||
|
let finalPhys256 = physResRec.isImmune
|
||||||
|
? 0
|
||||||
|
: Math.max(0, Math.trunc((rawPhys256 * (100 - physResRec.effectiveRes)) / 100))
|
||||||
|
|
||||||
|
let finalElem256 = elemResRec.isImmune
|
||||||
|
? 0
|
||||||
|
: Math.max(0, Math.trunc((rawElem256 * (100 - elemResRec.effectiveRes)) / 100))
|
||||||
|
|
||||||
|
// Static Field (`skillId = 42`): reduces target HP by 25% mitigated by Lightning Resistance
|
||||||
|
if (packet.skillId === 42 && !elemResRec.isImmune) {
|
||||||
|
const rawStatic256 = Math.trunc((hpBefore256 * 25) / 100)
|
||||||
|
finalElem256 = Math.max(0, Math.trunc((rawStatic256 * (100 - elemResRec.effectiveRes)) / 100))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Reactive `auraeventfunc` shields (`Energy Shield`, `Bone Armor`, `Cyclone Armor`)
|
||||||
|
const reactive = defender.stateBus.triggerReactiveEvents({
|
||||||
|
event: 'absorbdamage',
|
||||||
|
incomingPhys256: finalPhys256,
|
||||||
|
incomingElem256: finalElem256,
|
||||||
|
attackerStateBus: attacker.stateBus,
|
||||||
|
})
|
||||||
|
finalPhys256 = Math.max(0, finalPhys256 - reactive.absorbedPhys256)
|
||||||
|
finalElem256 = Math.max(0, finalElem256 - reactive.absorbedElem256)
|
||||||
|
|
||||||
|
// 8. Elemental Absorb (% and flat)
|
||||||
|
if (finalElem256 > 0) {
|
||||||
|
const absorbPct = Math.min(40, Math.max(0, defender.statList.getAccruedStat(`absorb_${elemType}_percent`)))
|
||||||
|
const absorbFlat256 = Math.max(0, defender.statList.getAccruedStat(`absorb_${elemType}`)) * FIXED_ONE
|
||||||
|
if (absorbPct > 0 || absorbFlat256 > 0) {
|
||||||
|
const pctAbsorbed = Math.trunc((finalElem256 * absorbPct) / 100)
|
||||||
|
const totalAbsorb = Math.min(finalElem256, pctAbsorbed + absorbFlat256)
|
||||||
|
// Absorb heals before damage is applied, effectively subtracting 2x absorb from net damage
|
||||||
|
finalElem256 = Math.max(0, finalElem256 - totalAbsorb * 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalDamage256 = finalPhys256 + finalElem256
|
||||||
|
const hpAfter256 = Math.max(0, hpBefore256 - totalDamage256)
|
||||||
|
defender.statList.setHp256(hpAfter256)
|
||||||
|
|
||||||
|
return {
|
||||||
|
hit: true,
|
||||||
|
avoidedReason: 'none',
|
||||||
|
immuneToElem: elemResRec.isImmune && rawElem256 > 0,
|
||||||
|
immuneToPhys: physResRec.isImmune && rawPhys256 > 0,
|
||||||
|
physDamage256: finalPhys256,
|
||||||
|
elemDamage256: finalElem256,
|
||||||
|
totalDamage256,
|
||||||
|
totalDamage: Math.ceil(totalDamage256 / FIXED_ONE),
|
||||||
|
targetHpBefore256: hpBefore256,
|
||||||
|
targetHpAfter256: hpAfter256,
|
||||||
|
targetKilled: hpAfter256 === 0 && hpBefore256 > 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,473 @@
|
||||||
|
/**
|
||||||
|
* Diablo II: Lord of Destruction v1.13c `UNIT_MISSILE` Runtime (`MissileEngine`).
|
||||||
|
*
|
||||||
|
* Implements:
|
||||||
|
* - `Missiles.txt` trajectory & frame lifecycle (`Vel`, `MaxVel`, `Accel`, `Range + (slvl - 1) * LevRange`).
|
||||||
|
* - `NextHit` & `NextDelay` per-target immunity window (`lastNextDelayTickByTargetId`) so
|
||||||
|
* multi-projectile skills (`Multiple Shot`, `Teeth`, `Charged Bolt`, `Nova`, `Shock Web`,
|
||||||
|
* `Tornado`, `Twister`, `Wake of Fire`) obey exact 1.13c `NextDelay` frames.
|
||||||
|
* - `Pierce` (`Pierce` column + Amazon `Pierce` `skillId=33` chance, capped at 4 pierces / 5 hits).
|
||||||
|
* - `pSrvDoFunc` (linear flight, spiral `Blessed Hammer`, homing `Guided Arrow`/`Bone Spirit`,
|
||||||
|
* radial bolt emitter `Frozen Orb` `pSrvDoFunc=15`, ground fire trail `Blaze`/`Fire Wall`/`Molten Boulder`).
|
||||||
|
* - `pSrvHitFunc` (`Fire Ball` radial splash `1`, `Holy Bolt` undead/ally heal `7`,
|
||||||
|
* `Chain Lightning`/`Lightning Strike`/`royalstrikechainlightning` `*12` chain jump,
|
||||||
|
* `Lightning Fury` `20` radial bolts, `Fist of the Heavens` `22` radial `holybolt`,
|
||||||
|
* `Frozen Orb` `29` final ice bolt nova).
|
||||||
|
*/
|
||||||
|
import type { D2DataRegistry, MissileRecord } from './data-registry.ts'
|
||||||
|
import { executeSUnitDmg, type CombatUnitContext, type SUnitDmgPacket } from './combat-pipeline.ts'
|
||||||
|
|
||||||
|
export interface ActiveMissile {
|
||||||
|
readonly uid: string
|
||||||
|
readonly missileId: number
|
||||||
|
readonly name: string
|
||||||
|
readonly celFile: string
|
||||||
|
readonly sourceSkillId: number
|
||||||
|
readonly slvl: number
|
||||||
|
readonly owner: CombatUnitContext
|
||||||
|
readonly record: MissileRecord
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
vx: number
|
||||||
|
vy: number
|
||||||
|
angleRad: number
|
||||||
|
ageTicks: number
|
||||||
|
readonly maxRangeTicks: number
|
||||||
|
readonly nextHit: boolean
|
||||||
|
readonly nextDelayFrames: number
|
||||||
|
readonly canPierce: boolean
|
||||||
|
readonly pierceChancePct: number
|
||||||
|
pierceCount: number
|
||||||
|
readonly hitTargetIds: Set<string>
|
||||||
|
readonly dmgPacket: SUnitDmgPacket
|
||||||
|
chainRemaining: number
|
||||||
|
expired: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MissileStepResult {
|
||||||
|
readonly hits: {
|
||||||
|
readonly missileName: string
|
||||||
|
readonly targetId: string
|
||||||
|
readonly damage: number
|
||||||
|
readonly immune: boolean
|
||||||
|
}[]
|
||||||
|
readonly subMissilesSpawned: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MissileEngine {
|
||||||
|
readonly registry: D2DataRegistry
|
||||||
|
private readonly missiles: ActiveMissile[] = []
|
||||||
|
private readonly nextDelayExpireByTarget = new Map<string, number>()
|
||||||
|
private uidCounter = 1
|
||||||
|
|
||||||
|
constructor(registry: D2DataRegistry) {
|
||||||
|
this.registry = registry
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnMissile(params: {
|
||||||
|
missileNameOrId: string | number
|
||||||
|
sourceSkillId: number
|
||||||
|
slvl: number
|
||||||
|
owner: CombatUnitContext
|
||||||
|
startX: number
|
||||||
|
startY: number
|
||||||
|
targetX: number
|
||||||
|
targetY: number
|
||||||
|
dmgPacket: SUnitDmgPacket
|
||||||
|
pierceChancePct?: number
|
||||||
|
chainCount?: number
|
||||||
|
}): ActiveMissile | null {
|
||||||
|
const rec =
|
||||||
|
typeof params.missileNameOrId === 'number'
|
||||||
|
? this.registry.getMissileById(params.missileNameOrId)
|
||||||
|
: this.registry.getMissileByName(params.missileNameOrId)
|
||||||
|
|
||||||
|
const fallbackName = String(params.missileNameOrId).trim().toLowerCase()
|
||||||
|
const effectiveRec: MissileRecord =
|
||||||
|
rec ?? {
|
||||||
|
id: 0,
|
||||||
|
name: fallbackName || 'defaultmissile',
|
||||||
|
pCltDoFunc: 1,
|
||||||
|
pCltHitFunc: 0,
|
||||||
|
pSrvDoFunc: 1,
|
||||||
|
pSrvHitFunc: 0,
|
||||||
|
pSrvDmgFunc: 0,
|
||||||
|
srvCalc1: '',
|
||||||
|
param1: 0,
|
||||||
|
param2: 0,
|
||||||
|
param3: 0,
|
||||||
|
param4: 0,
|
||||||
|
param5: 0,
|
||||||
|
cltCalc1: '',
|
||||||
|
cParam1: 0,
|
||||||
|
cParam2: 0,
|
||||||
|
cParam3: 0,
|
||||||
|
cParam4: 0,
|
||||||
|
cParam5: 0,
|
||||||
|
sHitCalc1: '',
|
||||||
|
sHitPar1: 0,
|
||||||
|
sHitPar2: 0,
|
||||||
|
sHitPar3: 0,
|
||||||
|
cHitCalc1: '',
|
||||||
|
cHitPar1: 0,
|
||||||
|
cHitPar2: 0,
|
||||||
|
cHitPar3: 0,
|
||||||
|
dmgCalc1: '',
|
||||||
|
dParam1: 0,
|
||||||
|
dParam2: 0,
|
||||||
|
vel: 16,
|
||||||
|
maxVel: 24,
|
||||||
|
velLev: 0,
|
||||||
|
accel: 0,
|
||||||
|
range: 40,
|
||||||
|
levRange: 0,
|
||||||
|
light: 0,
|
||||||
|
flicker: 0,
|
||||||
|
red: 255,
|
||||||
|
green: 255,
|
||||||
|
blue: 255,
|
||||||
|
initSteps: 0,
|
||||||
|
activate: 0,
|
||||||
|
loopAnim: 0,
|
||||||
|
celFile: fallbackName || 'fireball',
|
||||||
|
animRate: 1024,
|
||||||
|
animLen: 8,
|
||||||
|
animSpeed: 16,
|
||||||
|
subLoop: false,
|
||||||
|
subStart: 0,
|
||||||
|
subStop: 0,
|
||||||
|
collideType: 3,
|
||||||
|
collideKill: true,
|
||||||
|
collideFriend: false,
|
||||||
|
lastCollide: false,
|
||||||
|
collision: true,
|
||||||
|
clientCol: true,
|
||||||
|
nextHit: false,
|
||||||
|
nextDelay: 0,
|
||||||
|
size: 2,
|
||||||
|
returnFire: false,
|
||||||
|
trans: 1,
|
||||||
|
alwaysExplode: false,
|
||||||
|
explosion: false,
|
||||||
|
town: true,
|
||||||
|
noUniqueMod: false,
|
||||||
|
noMultiShot: false,
|
||||||
|
holy: 0,
|
||||||
|
canSlow: false,
|
||||||
|
canDestroy: false,
|
||||||
|
toHit: false,
|
||||||
|
pierce: false,
|
||||||
|
skill: '',
|
||||||
|
hitShift: 8,
|
||||||
|
srcDamage: 0,
|
||||||
|
minDamage: 0,
|
||||||
|
minLevDam1: 0,
|
||||||
|
minLevDam2: 0,
|
||||||
|
minLevDam3: 0,
|
||||||
|
minLevDam4: 0,
|
||||||
|
minLevDam5: 0,
|
||||||
|
maxDamage: 0,
|
||||||
|
maxLevDam1: 0,
|
||||||
|
maxLevDam2: 0,
|
||||||
|
maxLevDam3: 0,
|
||||||
|
maxLevDam4: 0,
|
||||||
|
maxLevDam5: 0,
|
||||||
|
dmgSymPerCalc: '',
|
||||||
|
eType: 'fire',
|
||||||
|
eMin: 0,
|
||||||
|
eMinLev1: 0,
|
||||||
|
eMinLev2: 0,
|
||||||
|
eMinLev3: 0,
|
||||||
|
eMinLev4: 0,
|
||||||
|
eMinLev5: 0,
|
||||||
|
eMax: 0,
|
||||||
|
eMaxLev1: 0,
|
||||||
|
eMaxLev2: 0,
|
||||||
|
eMaxLev3: 0,
|
||||||
|
eMaxLev4: 0,
|
||||||
|
eMaxLev5: 0,
|
||||||
|
eDmgSymPerCalc: '',
|
||||||
|
eLen: 0,
|
||||||
|
eLevLen1: 0,
|
||||||
|
eLevLen2: 0,
|
||||||
|
eLevLen3: 0,
|
||||||
|
hitClass: 0,
|
||||||
|
numDirections: 16,
|
||||||
|
localBlood: 0,
|
||||||
|
damageRate: 0,
|
||||||
|
explosionMissile: '',
|
||||||
|
subMissile1: '',
|
||||||
|
subMissile2: '',
|
||||||
|
subMissile3: '',
|
||||||
|
hitSubMissile1: '',
|
||||||
|
hitSubMissile2: '',
|
||||||
|
hitSubMissile3: '',
|
||||||
|
hitSubMissile4: '',
|
||||||
|
cltSubMissile1: '',
|
||||||
|
cltSubMissile2: '',
|
||||||
|
cltSubMissile3: '',
|
||||||
|
cltHitSubMissile1: '',
|
||||||
|
cltHitSubMissile2: '',
|
||||||
|
cltHitSubMissile3: '',
|
||||||
|
cltHitSubMissile4: '',
|
||||||
|
raw: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
const dx = params.targetX - params.startX
|
||||||
|
const dy = params.targetY - params.startY
|
||||||
|
const angleRad = dx === 0 && dy === 0 ? Math.PI / 2 : Math.atan2(dy, dx)
|
||||||
|
const speedPxPerTick = Math.max(8, effectiveRec.vel || 18)
|
||||||
|
const maxRangeTicks = Math.max(
|
||||||
|
10,
|
||||||
|
(effectiveRec.range || 35) + Math.max(0, params.slvl - 1) * (effectiveRec.levRange || 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
const missile: ActiveMissile = {
|
||||||
|
uid: `msl-${String(this.uidCounter++)}`,
|
||||||
|
missileId: effectiveRec.id,
|
||||||
|
name: effectiveRec.name,
|
||||||
|
celFile: effectiveRec.celFile || effectiveRec.name,
|
||||||
|
sourceSkillId: params.sourceSkillId,
|
||||||
|
slvl: params.slvl,
|
||||||
|
owner: params.owner,
|
||||||
|
record: effectiveRec,
|
||||||
|
x: params.startX,
|
||||||
|
y: params.startY,
|
||||||
|
vx: Math.cos(angleRad) * speedPxPerTick,
|
||||||
|
vy: Math.sin(angleRad) * speedPxPerTick,
|
||||||
|
angleRad,
|
||||||
|
ageTicks: 0,
|
||||||
|
maxRangeTicks,
|
||||||
|
nextHit: effectiveRec.nextHit,
|
||||||
|
nextDelayFrames: effectiveRec.nextDelay,
|
||||||
|
canPierce: effectiveRec.pierce || (params.pierceChancePct ?? 0) > 0,
|
||||||
|
pierceChancePct: params.pierceChancePct ?? (effectiveRec.pierce ? 100 : 0),
|
||||||
|
pierceCount: 0,
|
||||||
|
hitTargetIds: new Set<string>(),
|
||||||
|
dmgPacket: params.dmgPacket,
|
||||||
|
chainRemaining: params.chainCount ?? (effectiveRec.pSrvHitFunc === 12 ? 5 + Math.trunc(params.slvl / 5) : 0),
|
||||||
|
expired: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.missiles.push(missile)
|
||||||
|
return missile
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a target unit is currently within its `NextDelay` immunity window.
|
||||||
|
*/
|
||||||
|
isTargetInNextDelay(targetId: string, currentTick: number): boolean {
|
||||||
|
const exp = this.nextDelayExpireByTarget.get(targetId)
|
||||||
|
return exp !== undefined && exp > currentTick
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step all active missiles by 1 tick (`25 Hz`), resolving movement (`pSrvDoFunc`),
|
||||||
|
* collisions (`pSrvHitFunc`), `NextDelay` gating, `Pierce`, and sub-missiles.
|
||||||
|
*/
|
||||||
|
tick(currentTick: number, targets: readonly CombatUnitContext[], targetPositions: ReadonlyMap<string, { x: number; y: number }>): MissileStepResult {
|
||||||
|
const hits: { missileName: string; targetId: string; damage: number; immune: boolean }[] = []
|
||||||
|
const subMissilesSpawned: string[] = []
|
||||||
|
|
||||||
|
for (const m of [...this.missiles]) {
|
||||||
|
if (m.expired) continue
|
||||||
|
m.ageTicks += 1
|
||||||
|
|
||||||
|
// `pSrvDoFunc = 15` (`frozenorb`): emits radial `icebolt` sub-missiles while flying
|
||||||
|
if (m.record.pSrvDoFunc === 15 && m.ageTicks % 3 === 0) {
|
||||||
|
const subName = m.record.subMissile1 || 'icebolt'
|
||||||
|
const subAngle = m.angleRad + m.ageTicks * 0.8
|
||||||
|
this.spawnMissile({
|
||||||
|
missileNameOrId: subName,
|
||||||
|
sourceSkillId: m.sourceSkillId,
|
||||||
|
slvl: m.slvl,
|
||||||
|
owner: m.owner,
|
||||||
|
startX: m.x,
|
||||||
|
startY: m.y,
|
||||||
|
targetX: m.x + Math.cos(subAngle) * 100,
|
||||||
|
targetY: m.y + Math.sin(subAngle) * 100,
|
||||||
|
dmgPacket: m.dmgPacket,
|
||||||
|
})
|
||||||
|
subMissilesSpawned.push(subName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spiral flight for Blessed Hammer (`blessedhammer`, `skillId = 112`)
|
||||||
|
if (m.sourceSkillId === 112 || m.name.toLowerCase() === 'blessedhammer') {
|
||||||
|
m.angleRad += 0.24
|
||||||
|
const radius = 14 + m.ageTicks * 4
|
||||||
|
m.x += Math.cos(m.angleRad) * (radius * 0.25)
|
||||||
|
m.y += Math.sin(m.angleRad) * (radius * 0.25)
|
||||||
|
} else {
|
||||||
|
m.x += m.vx
|
||||||
|
m.y += m.vy
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check collision against alive enemy targets
|
||||||
|
const hitRadius = Math.max(24, (m.record.size || 2) * 14)
|
||||||
|
for (const target of targets) {
|
||||||
|
if (target.statList.getHp256() <= 0) continue
|
||||||
|
if (m.hitTargetIds.has(target.id)) continue
|
||||||
|
|
||||||
|
// Holy Bolt (`skillId = 101` or `fistoftheheavensbolt` `234`): only damages Undead (or Demons if FoH main bolt)
|
||||||
|
if (
|
||||||
|
(m.name.toLowerCase() === 'holybolt' || m.name.toLowerCase() === 'fistoftheheavensbolt') &&
|
||||||
|
!target.isUndead
|
||||||
|
) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const pos = targetPositions.get(target.id) ?? { x: 0, y: 120 }
|
||||||
|
const dist = Math.hypot(pos.x - m.x, pos.y - m.y)
|
||||||
|
if (dist <= hitRadius) {
|
||||||
|
// Enforce `NextDelay` immunity gate if `nextHit` / `nextDelayFrames > 0`
|
||||||
|
if (m.nextDelayFrames > 0 && this.isTargetInNextDelay(target.id, currentTick)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
m.hitTargetIds.add(target.id)
|
||||||
|
if (m.nextDelayFrames > 0) {
|
||||||
|
this.nextDelayExpireByTarget.set(target.id, currentTick + m.nextDelayFrames)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dmgOut = executeSUnitDmg(m.owner, target, m.dmgPacket)
|
||||||
|
hits.push({
|
||||||
|
missileName: m.name,
|
||||||
|
targetId: target.id,
|
||||||
|
damage: dmgOut.totalDamage,
|
||||||
|
immune: dmgOut.immuneToElem || dmgOut.immuneToPhys,
|
||||||
|
})
|
||||||
|
|
||||||
|
// `pSrvHitFunc = 1` or `4` or `13`: radial splash damage (`Fire Ball`, `Exploding Arrow`, `Freezing Arrow`, `Glacial Spike`)
|
||||||
|
if (
|
||||||
|
m.record.pSrvHitFunc === 1 ||
|
||||||
|
m.record.pSrvHitFunc === 4 ||
|
||||||
|
m.record.pSrvHitFunc === 9 ||
|
||||||
|
m.record.pSrvHitFunc === 13
|
||||||
|
) {
|
||||||
|
for (const other of targets) {
|
||||||
|
if (other.id === target.id || other.statList.getHp256() <= 0) continue
|
||||||
|
const oPos = targetPositions.get(other.id) ?? { x: 0, y: 120 }
|
||||||
|
if (Math.hypot(oPos.x - pos.x, oPos.y - pos.y) <= 85) {
|
||||||
|
const splashOut = executeSUnitDmg(m.owner, other, m.dmgPacket)
|
||||||
|
hits.push({
|
||||||
|
missileName: `${m.name}:splash`,
|
||||||
|
targetId: other.id,
|
||||||
|
damage: splashOut.totalDamage,
|
||||||
|
immune: splashOut.immuneToElem || splashOut.immuneToPhys,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `pSrvHitFunc = 12` (`Chain Lightning` 53, `Lightning Strike` 34, `royalstrikechainlightning` 568):
|
||||||
|
// Jump to next unhit target within range!
|
||||||
|
if (m.record.pSrvHitFunc === 12 && m.chainRemaining > 0) {
|
||||||
|
const nextTarget = targets.find(t => t.id !== target.id && t.statList.getHp256() > 0 && !m.hitTargetIds.has(t.id))
|
||||||
|
if (nextTarget) {
|
||||||
|
const nPos = targetPositions.get(nextTarget.id) ?? { x: 0, y: 120 }
|
||||||
|
this.spawnMissile({
|
||||||
|
missileNameOrId: m.name,
|
||||||
|
sourceSkillId: m.sourceSkillId,
|
||||||
|
slvl: m.slvl,
|
||||||
|
owner: m.owner,
|
||||||
|
startX: pos.x,
|
||||||
|
startY: pos.y,
|
||||||
|
targetX: nPos.x,
|
||||||
|
targetY: nPos.y,
|
||||||
|
dmgPacket: m.dmgPacket,
|
||||||
|
chainCount: m.chainRemaining - 1,
|
||||||
|
})
|
||||||
|
subMissilesSpawned.push(m.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `pSrvHitFunc = 20` (`Lightning Fury` 35): release radial `lightningjavelin` bolts toward nearby enemies
|
||||||
|
if (m.record.pSrvHitFunc === 20 || m.sourceSkillId === 35) {
|
||||||
|
const subBolt = m.record.hitSubMissile1 || 'lightningjavelin'
|
||||||
|
for (const other of targets) {
|
||||||
|
if (other.id === target.id || other.statList.getHp256() <= 0) continue
|
||||||
|
const oPos = targetPositions.get(other.id) ?? { x: 0, y: 120 }
|
||||||
|
this.spawnMissile({
|
||||||
|
missileNameOrId: subBolt,
|
||||||
|
sourceSkillId: m.sourceSkillId,
|
||||||
|
slvl: m.slvl,
|
||||||
|
owner: m.owner,
|
||||||
|
startX: pos.x,
|
||||||
|
startY: pos.y,
|
||||||
|
targetX: oPos.x,
|
||||||
|
targetY: oPos.y,
|
||||||
|
dmgPacket: m.dmgPacket,
|
||||||
|
})
|
||||||
|
subMissilesSpawned.push(subBolt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `pSrvHitFunc = 22` (`Fist of the Heavens` 121): release radial `fistoftheheavensbolt` (`holybolt`)
|
||||||
|
if (m.record.pSrvHitFunc === 22 || m.sourceSkillId === 121) {
|
||||||
|
const subBolt = m.record.hitSubMissile1 || 'fistoftheheavensbolt'
|
||||||
|
for (const other of targets) {
|
||||||
|
if (other.id === target.id || other.statList.getHp256() <= 0) continue
|
||||||
|
const oPos = targetPositions.get(other.id) ?? { x: 0, y: 120 }
|
||||||
|
this.spawnMissile({
|
||||||
|
missileNameOrId: subBolt,
|
||||||
|
sourceSkillId: m.sourceSkillId,
|
||||||
|
slvl: m.slvl,
|
||||||
|
owner: m.owner,
|
||||||
|
startX: pos.x,
|
||||||
|
startY: pos.y,
|
||||||
|
targetX: oPos.x,
|
||||||
|
targetY: oPos.y,
|
||||||
|
dmgPacket: m.dmgPacket,
|
||||||
|
})
|
||||||
|
subMissilesSpawned.push(subBolt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pierce evaluation (1.13c cap: max 4 pierces = 5 total targets hit)
|
||||||
|
if (m.canPierce && m.pierceCount < 4 && m.pierceChancePct > 0) {
|
||||||
|
m.pierceCount += 1
|
||||||
|
} else if (m.record.collideKill || !m.canPierce) {
|
||||||
|
m.expired = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m.ageTicks >= m.maxRangeTicks) {
|
||||||
|
// `pSrvHitFunc = 29` (`Frozen Orb` expiration burst)
|
||||||
|
if (m.record.pSrvHitFunc === 29) {
|
||||||
|
const subName = m.record.hitSubMissile1 || 'icebolt'
|
||||||
|
for (let i = 0; i < 8; i += 1) {
|
||||||
|
const ang = (i * Math.PI) / 4
|
||||||
|
this.spawnMissile({
|
||||||
|
missileNameOrId: subName,
|
||||||
|
sourceSkillId: m.sourceSkillId,
|
||||||
|
slvl: m.slvl,
|
||||||
|
owner: m.owner,
|
||||||
|
startX: m.x,
|
||||||
|
startY: m.y,
|
||||||
|
targetX: m.x + Math.cos(ang) * 100,
|
||||||
|
targetY: m.y + Math.sin(ang) * 100,
|
||||||
|
dmgPacket: m.dmgPacket,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
subMissilesSpawned.push(subName)
|
||||||
|
}
|
||||||
|
m.expired = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purge expired missiles
|
||||||
|
for (let i = this.missiles.length - 1; i >= 0; i -= 1) {
|
||||||
|
if (this.missiles[i]!.expired) {
|
||||||
|
this.missiles.splice(i, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { hits, subMissilesSpawned }
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveMissiles(): readonly ActiveMissile[] {
|
||||||
|
return this.missiles
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,301 @@
|
||||||
|
/**
|
||||||
|
* Diablo II: Lord of Destruction v1.13c Unit `StatList` (`D2StatListStrc` Parity).
|
||||||
|
*
|
||||||
|
* Enforces:
|
||||||
|
* - 256-fixed-point (`<< 8`, `HIT_SHIFT = 8`) integer arithmetic for life (`hitpoints`/`maxhp`),
|
||||||
|
* mana (`mana`/`maxmana`), stamina (`stamina`/`maxstamina`), and per-frame poison/burn bitrates.
|
||||||
|
* - Strict separation of base allocated skill points (`STAT_SKILLPTS` -> `getBaseSkillLevel(skillId)`)
|
||||||
|
* from item/buff `+skills` and item charges (`STAT_ITEM_CHARGED_SKILL`), enforcing the 1.13c
|
||||||
|
* Marrowwalk / charge synergy fix (`blvl` never includes `+skills` or charges).
|
||||||
|
* - Dynamic `SkillStatModifier` lists with `expireFrame` (`dwExpireFrame`), `stateId`, and `slvl`.
|
||||||
|
*/
|
||||||
|
import type { D2DataRegistry } from './data-registry.ts'
|
||||||
|
|
||||||
|
export const HIT_SHIFT = 8
|
||||||
|
export const FIXED_ONE = 1 << HIT_SHIFT // 256
|
||||||
|
|
||||||
|
export interface SkillStatModifier {
|
||||||
|
readonly id: string
|
||||||
|
readonly stateName: string
|
||||||
|
readonly stateId: number
|
||||||
|
readonly sourceUnitId: string
|
||||||
|
readonly sourceSkillId: number
|
||||||
|
readonly slvl: number
|
||||||
|
/** Frame (`tick`) at which this modifier expires (`0` = permanent/passive/toggle). */
|
||||||
|
expireFrame: number
|
||||||
|
readonly isAura?: boolean
|
||||||
|
readonly isCurse?: boolean
|
||||||
|
readonly curseType?: number
|
||||||
|
readonly stats: Readonly<Record<string, number>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnitStatList {
|
||||||
|
readonly registry: D2DataRegistry | undefined
|
||||||
|
private readonly baseStats = new Map<string, number>()
|
||||||
|
private readonly baseSkillLevels = new Map<number, number>()
|
||||||
|
private readonly bonusSkillLevels = new Map<number, number>()
|
||||||
|
private readonly chargedSkillLevels = new Map<number, number>()
|
||||||
|
private allSkillsBonus = 0
|
||||||
|
private classSkillsBonus = 0
|
||||||
|
private readonly modifiers: SkillStatModifier[] = []
|
||||||
|
|
||||||
|
constructor(registry?: D2DataRegistry, initialStats?: Readonly<Record<string, number>>) {
|
||||||
|
this.registry = registry
|
||||||
|
// Sensible defaults in 256-fixed-point for HP/Mana/Stamina
|
||||||
|
this.baseStats.set('level', 80)
|
||||||
|
this.baseStats.set('strength', 100)
|
||||||
|
this.baseStats.set('dexterity', 100)
|
||||||
|
this.baseStats.set('vitality', 150)
|
||||||
|
this.baseStats.set('energy', 100)
|
||||||
|
this.baseStats.set('maxhp', 1000 << HIT_SHIFT)
|
||||||
|
this.baseStats.set('hitpoints', 1000 << HIT_SHIFT)
|
||||||
|
this.baseStats.set('maxmana', 1000 << HIT_SHIFT)
|
||||||
|
this.baseStats.set('mana', 1000 << HIT_SHIFT)
|
||||||
|
this.baseStats.set('maxstamina', 500 << HIT_SHIFT)
|
||||||
|
this.baseStats.set('stamina', 500 << HIT_SHIFT)
|
||||||
|
this.baseStats.set('tohit', 2500)
|
||||||
|
this.baseStats.set('armorclass', 500)
|
||||||
|
|
||||||
|
if (initialStats) {
|
||||||
|
for (const [k, v] of Object.entries(initialStats)) {
|
||||||
|
this.setBaseStat(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeStatKey(statNameOrId: string | number): string {
|
||||||
|
if (typeof statNameOrId === 'number') {
|
||||||
|
const rec = this.registry?.getStatById(statNameOrId)
|
||||||
|
return rec ? rec.stat.toLowerCase() : String(statNameOrId)
|
||||||
|
}
|
||||||
|
return statNameOrId.trim().toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
setBaseStat(statNameOrId: string | number, value: number): void {
|
||||||
|
const key = this.normalizeStatKey(statNameOrId)
|
||||||
|
this.baseStats.set(key, Math.trunc(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
getBaseStat(statNameOrId: string | number): number {
|
||||||
|
const key = this.normalizeStatKey(statNameOrId)
|
||||||
|
return this.baseStats.get(key) ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set hard-invested `STAT_SKILLPTS` (`blvl`) for a skill.
|
||||||
|
*/
|
||||||
|
setBaseSkillLevel(skillId: number, blvl: number): void {
|
||||||
|
this.baseSkillLevels.set(skillId, Math.max(0, Math.trunc(blvl)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1.13c Ground Truth (`D2Common`): returns strictly hard-allocated `STAT_SKILLPTS` (`blvl`).
|
||||||
|
* Never includes `bonusSkillLevels`, `allSkillsBonus`, or `chargedSkillLevels` (`Marrowwalk` fix).
|
||||||
|
*/
|
||||||
|
getBaseSkillLevel(skillId: number): number {
|
||||||
|
return this.baseSkillLevels.get(skillId) ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
setBonusSkillLevel(skillId: number, bonus: number): void {
|
||||||
|
this.bonusSkillLevels.set(skillId, Math.trunc(bonus))
|
||||||
|
}
|
||||||
|
|
||||||
|
setAllSkillsBonus(bonus: number): void {
|
||||||
|
this.allSkillsBonus = Math.trunc(bonus)
|
||||||
|
}
|
||||||
|
|
||||||
|
setClassSkillsBonus(bonus: number): void {
|
||||||
|
this.classSkillsBonus = Math.trunc(bonus)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set item skill charges (`STAT_ITEM_CHARGED_SKILL`, e.g. Level 33 Bone Prison on Marrowwalk boots).
|
||||||
|
* Stored in `chargedSkillLevels` and strictly excluded from `getBaseSkillLevel(skillId)` in 1.13c!
|
||||||
|
*/
|
||||||
|
setChargedSkillLevel(skillId: number, chargeSlvl: number): void {
|
||||||
|
this.chargedSkillLevels.set(skillId, Math.max(0, Math.trunc(chargeSlvl)))
|
||||||
|
}
|
||||||
|
|
||||||
|
getChargedSkillLevel(skillId: number): number {
|
||||||
|
return this.chargedSkillLevels.get(skillId) ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effective skill level (`lvl = blvl + bonusSkill + allSkills + classSkills + modAllSkills`).
|
||||||
|
*/
|
||||||
|
getEffectiveSkillLevel(skillId: number): number {
|
||||||
|
const base = this.getBaseSkillLevel(skillId)
|
||||||
|
const singleBonus = this.bonusSkillLevels.get(skillId) ?? 0
|
||||||
|
const modAllSkills = this.getModifierBonus('item_allskills') + this.getModifierBonus('allskills')
|
||||||
|
const total = base + singleBonus + this.allSkillsBonus + this.classSkillsBonus + modAllSkills
|
||||||
|
return Math.max(0, total)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sum dynamic modifier bonuses for a given stat.
|
||||||
|
* For duplicate aura states (`isAura === true` with same `stateId`), only the highest `slvl`
|
||||||
|
* active modifier contributes its stats (`1.13c` aura stacking/level override rule).
|
||||||
|
*/
|
||||||
|
getModifierBonus(statNameOrId: string | number): number {
|
||||||
|
const key = this.normalizeStatKey(statNameOrId)
|
||||||
|
const bestAuraByState = new Map<number, SkillStatModifier>()
|
||||||
|
let sum = 0
|
||||||
|
|
||||||
|
for (const mod of this.modifiers) {
|
||||||
|
if (mod.isAura && mod.stateId > 0) {
|
||||||
|
const prev = bestAuraByState.get(mod.stateId)
|
||||||
|
if (!prev || mod.slvl >= prev.slvl) {
|
||||||
|
bestAuraByState.set(mod.stateId, mod)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sum += mod.stats[key] ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const auraMod of bestAuraByState.values()) {
|
||||||
|
sum += auraMod.stats[key] ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.trunc(sum)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get accrued stat value (`D2Common_GetStatSigned`, `stat('...'.accr)`).
|
||||||
|
* Handles `%` max life/mana (`item_maxhp_percent`, `item_maxmana_percent`, `item_armor_percent`).
|
||||||
|
*/
|
||||||
|
getAccruedStat(statNameOrId: string | number, baseOnly = false): number {
|
||||||
|
const key = this.normalizeStatKey(statNameOrId)
|
||||||
|
const base = this.getBaseStat(key)
|
||||||
|
if (baseOnly) return base
|
||||||
|
|
||||||
|
const modBonus = this.getModifierBonus(key)
|
||||||
|
let total = base + modBonus
|
||||||
|
|
||||||
|
if (key === 'maxhp') {
|
||||||
|
const pct = this.getModifierBonus('item_maxhp_percent') + this.getModifierBonus('maxhp_percent')
|
||||||
|
if (pct !== 0) {
|
||||||
|
total = Math.trunc((total * (100 + pct)) / 100)
|
||||||
|
}
|
||||||
|
} else if (key === 'maxmana') {
|
||||||
|
const pct = this.getModifierBonus('item_maxmana_percent') + this.getModifierBonus('maxmana_percent')
|
||||||
|
if (pct !== 0) {
|
||||||
|
total = Math.trunc((total * (100 + pct)) / 100)
|
||||||
|
}
|
||||||
|
} else if (key === 'armorclass') {
|
||||||
|
const pct =
|
||||||
|
this.getModifierBonus('item_armor_percent') +
|
||||||
|
this.getModifierBonus('skill_armor_percent') +
|
||||||
|
this.getModifierBonus('armor_percent')
|
||||||
|
if (pct !== 0) {
|
||||||
|
total = Math.trunc((total * (100 + pct)) / 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.trunc(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach or update a `SkillStatModifier`.
|
||||||
|
*/
|
||||||
|
addOrUpdateModifier(mod: SkillStatModifier): void {
|
||||||
|
const normStats: Record<string, number> = {}
|
||||||
|
for (const [k, v] of Object.entries(mod.stats)) {
|
||||||
|
normStats[this.normalizeStatKey(k)] = Math.trunc(v)
|
||||||
|
}
|
||||||
|
const normalizedMod: SkillStatModifier = {
|
||||||
|
...mod,
|
||||||
|
stats: normStats,
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingIdx = this.modifiers.findIndex(
|
||||||
|
m =>
|
||||||
|
m.id === normalizedMod.id ||
|
||||||
|
(!normalizedMod.isAura && m.stateName.toLowerCase() === normalizedMod.stateName.toLowerCase()),
|
||||||
|
)
|
||||||
|
if (existingIdx !== -1) {
|
||||||
|
this.modifiers[existingIdx] = normalizedMod
|
||||||
|
} else {
|
||||||
|
this.modifiers.push(normalizedMod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
removeModifiersByState(stateNameOrId: string | number): SkillStatModifier[] {
|
||||||
|
const removed: SkillStatModifier[] = []
|
||||||
|
for (let i = this.modifiers.length - 1; i >= 0; i -= 1) {
|
||||||
|
const m = this.modifiers[i]!
|
||||||
|
const match =
|
||||||
|
typeof stateNameOrId === 'number'
|
||||||
|
? m.stateId === stateNameOrId
|
||||||
|
: m.stateName.toLowerCase() === stateNameOrId.trim().toLowerCase()
|
||||||
|
if (match) {
|
||||||
|
removed.push(m)
|
||||||
|
this.modifiers.splice(i, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
|
||||||
|
removeModifiersByFilter(predicate: (mod: SkillStatModifier) => boolean): SkillStatModifier[] {
|
||||||
|
const removed: SkillStatModifier[] = []
|
||||||
|
for (let i = this.modifiers.length - 1; i >= 0; i -= 1) {
|
||||||
|
const m = this.modifiers[i]!
|
||||||
|
if (predicate(m)) {
|
||||||
|
removed.push(m)
|
||||||
|
this.modifiers.splice(i, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return removed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expire any modifier whose `expireFrame > 0 && expireFrame <= currentFrame`.
|
||||||
|
*/
|
||||||
|
expireAtFrame(currentFrame: number): SkillStatModifier[] {
|
||||||
|
return this.removeModifiersByFilter(m => m.expireFrame > 0 && m.expireFrame <= currentFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveModifiers(): readonly SkillStatModifier[] {
|
||||||
|
return this.modifiers
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience helpers for 256-fixed-point (`<< 8`) HP and Mana access.
|
||||||
|
*/
|
||||||
|
getHp256(): number {
|
||||||
|
return this.getBaseStat('hitpoints')
|
||||||
|
}
|
||||||
|
|
||||||
|
getHp(): number {
|
||||||
|
return Math.ceil(this.getHp256() / FIXED_ONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
getMaxHp256(): number {
|
||||||
|
return this.getAccruedStat('maxhp')
|
||||||
|
}
|
||||||
|
|
||||||
|
getMaxHp(): number {
|
||||||
|
return Math.ceil(this.getMaxHp256() / FIXED_ONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
setHp256(hp256: number): void {
|
||||||
|
const clamped = Math.max(0, Math.min(this.getMaxHp256(), Math.trunc(hp256)))
|
||||||
|
this.setBaseStat('hitpoints', clamped)
|
||||||
|
}
|
||||||
|
|
||||||
|
getMana256(): number {
|
||||||
|
return this.getBaseStat('mana')
|
||||||
|
}
|
||||||
|
|
||||||
|
getMana(): number {
|
||||||
|
return Math.floor(this.getMana256() / FIXED_ONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
getMaxMana256(): number {
|
||||||
|
return this.getAccruedStat('maxmana')
|
||||||
|
}
|
||||||
|
|
||||||
|
setMana256(mana256: number): void {
|
||||||
|
const clamped = Math.max(0, Math.min(this.getMaxMana256(), Math.trunc(mana256)))
|
||||||
|
this.setBaseStat('mana', clamped)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,502 @@
|
||||||
|
/**
|
||||||
|
* Diablo II: Lord of Destruction v1.13c Unit `StateBus` (`States.txt` & `auraeventfunc` Parity).
|
||||||
|
*
|
||||||
|
* Enforces:
|
||||||
|
* - Curse Exclusivity (`cursetype === 1` states overwrite each other).
|
||||||
|
* - 1.13c `STATE_ATTRACT` (`cursetype === 2`) Overwrite Immunity: `attract` cannot be
|
||||||
|
* overwritten by any `cursetype === 1` curse (`Amplify Damage`, `Decrepify`, `Lower Resist`, etc.).
|
||||||
|
* - Aura Stacking & Level Override: distinct auras coexist on a unit; duplicate aura states
|
||||||
|
* apply the highest `slvl` modifier while retaining lower-level pulses until expiration.
|
||||||
|
* - Reactive `auraeventfunc` triggers (`1..5`, `22..27` from `Events.txt` & `Skills.txt`).
|
||||||
|
*/
|
||||||
|
import type { D2DataRegistry } from './data-registry.ts'
|
||||||
|
import { UnitStatList, FIXED_ONE } from './stat-list.ts'
|
||||||
|
|
||||||
|
export interface ActiveStateEntry {
|
||||||
|
readonly stateId: number
|
||||||
|
readonly stateName: string
|
||||||
|
readonly sourceUnitId: string
|
||||||
|
readonly sourceSkillId: number
|
||||||
|
readonly slvl: number
|
||||||
|
readonly curseType: number
|
||||||
|
readonly isAura: boolean
|
||||||
|
expireFrame: number
|
||||||
|
readonly overlays: readonly string[]
|
||||||
|
readonly stats: Readonly<Record<string, number>>
|
||||||
|
readonly reactiveEvents?: readonly {
|
||||||
|
readonly event: string
|
||||||
|
readonly funcId: number
|
||||||
|
readonly skillId: number
|
||||||
|
readonly slvl: number
|
||||||
|
}[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApplyStateResult {
|
||||||
|
readonly applied: boolean
|
||||||
|
readonly stateName: string
|
||||||
|
readonly stateId: number
|
||||||
|
readonly blockedByAttract?: boolean
|
||||||
|
readonly evictedState?: string
|
||||||
|
readonly activeSlvl: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReactiveEventOutcome {
|
||||||
|
readonly absorbedPhys256: number
|
||||||
|
readonly absorbedElem256: number
|
||||||
|
readonly manaDrained256: number
|
||||||
|
readonly reflectedPhys256: number
|
||||||
|
readonly attackerHealed256: number
|
||||||
|
readonly ownerHealed256: number
|
||||||
|
readonly spawnedMissiles: string[]
|
||||||
|
readonly attackerStatesApplied: string[]
|
||||||
|
readonly attackerColdDamage256: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StateBus {
|
||||||
|
readonly registry: D2DataRegistry | undefined
|
||||||
|
readonly statList: UnitStatList
|
||||||
|
private readonly entries: ActiveStateEntry[] = []
|
||||||
|
|
||||||
|
constructor(statList: UnitStatList, registry?: D2DataRegistry) {
|
||||||
|
this.statList = statList
|
||||||
|
this.registry = registry ?? statList.registry
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a state (`States.txt`) to the unit with optional stats and frame expiration.
|
||||||
|
*/
|
||||||
|
applyState(params: {
|
||||||
|
stateNameOrId: string | number
|
||||||
|
sourceUnitId?: string
|
||||||
|
sourceSkillId?: number
|
||||||
|
slvl?: number
|
||||||
|
durationFrames?: number
|
||||||
|
currentFrame?: number
|
||||||
|
stats?: Readonly<Record<string, number>>
|
||||||
|
curseTypeOverride?: number
|
||||||
|
isAuraOverride?: boolean
|
||||||
|
}): ApplyStateResult {
|
||||||
|
const stateRec =
|
||||||
|
typeof params.stateNameOrId === 'number'
|
||||||
|
? this.registry?.getStateById(params.stateNameOrId)
|
||||||
|
: this.registry?.getStateByName(params.stateNameOrId)
|
||||||
|
|
||||||
|
const stateName = stateRec
|
||||||
|
? stateRec.state.toLowerCase()
|
||||||
|
: String(params.stateNameOrId).trim().toLowerCase().replace(/^state_/, '')
|
||||||
|
const stateId = stateRec?.id ?? (typeof params.stateNameOrId === 'number' ? params.stateNameOrId : 0)
|
||||||
|
const slvl = Math.max(1, params.slvl ?? 1)
|
||||||
|
const currentFrame = params.currentFrame ?? 0
|
||||||
|
const durationFrames = params.durationFrames ?? 0
|
||||||
|
const expireFrame = durationFrames > 0 ? currentFrame + durationFrames : 0
|
||||||
|
const sourceUnitId = params.sourceUnitId ?? 'player'
|
||||||
|
const sourceSkillId = params.sourceSkillId ?? 0
|
||||||
|
|
||||||
|
// Determine curseType (`1` = standard curse, `2` = Attract)
|
||||||
|
const isKnownCurse1 = [
|
||||||
|
'amplifydamage',
|
||||||
|
'dimvision',
|
||||||
|
'weaken',
|
||||||
|
'ironmaiden',
|
||||||
|
'terror',
|
||||||
|
'confuse',
|
||||||
|
'lifetap',
|
||||||
|
'decrepify',
|
||||||
|
'lowerresist',
|
||||||
|
'cloak_of_shadows',
|
||||||
|
'battlecry',
|
||||||
|
].includes(stateName)
|
||||||
|
|
||||||
|
const curseType =
|
||||||
|
params.curseTypeOverride ??
|
||||||
|
(stateRec && stateRec.cursetype > 0
|
||||||
|
? stateRec.cursetype
|
||||||
|
: stateName === 'attract'
|
||||||
|
? 2
|
||||||
|
: isKnownCurse1
|
||||||
|
? 1
|
||||||
|
: 0)
|
||||||
|
|
||||||
|
const isAura = params.isAuraOverride ?? stateRec?.aura ?? false
|
||||||
|
|
||||||
|
// CRITICAL 1.13c RULE: `STATE_ATTRACT` (`cursetype === 2`) cannot be overwritten by
|
||||||
|
// any standard `cursetype === 1` curse!
|
||||||
|
if (curseType === 1) {
|
||||||
|
const existingAttract = this.entries.find(e => e.curseType === 2 || e.stateName === 'attract')
|
||||||
|
if (existingAttract) {
|
||||||
|
return {
|
||||||
|
applied: false,
|
||||||
|
stateName,
|
||||||
|
stateId,
|
||||||
|
blockedByAttract: true,
|
||||||
|
activeSlvl: existingAttract.slvl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let evictedState: string | undefined
|
||||||
|
|
||||||
|
// Standard `cursetype === 1` exclusivity: remove any existing `cursetype === 1` state
|
||||||
|
if (curseType === 1) {
|
||||||
|
for (let i = this.entries.length - 1; i >= 0; i -= 1) {
|
||||||
|
const prev = this.entries[i]!
|
||||||
|
if (prev.curseType === 1) {
|
||||||
|
evictedState = prev.stateName
|
||||||
|
this.statList.removeModifiersByState(prev.stateName)
|
||||||
|
this.entries.splice(i, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect overlays from States.txt
|
||||||
|
const overlays: string[] = []
|
||||||
|
if (stateRec) {
|
||||||
|
for (const ov of [stateRec.overlay1, stateRec.overlay2, stateRec.overlay3, stateRec.overlay4, stateRec.castoverlay]) {
|
||||||
|
if (ov) overlays.push(ov)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect reactive `auraeventfunc` from the source skill if applicable
|
||||||
|
const reactiveEvents: { event: string; funcId: number; skillId: number; slvl: number }[] = []
|
||||||
|
const skillRec = sourceSkillId > 0 ? this.registry?.getSkillById(sourceSkillId) : undefined
|
||||||
|
if (skillRec) {
|
||||||
|
if (skillRec.auraEvent1 && skillRec.auraEventFunc1 > 0) {
|
||||||
|
reactiveEvents.push({ event: skillRec.auraEvent1.toLowerCase(), funcId: skillRec.auraEventFunc1, skillId: sourceSkillId, slvl })
|
||||||
|
}
|
||||||
|
if (skillRec.auraEvent2 && skillRec.auraEventFunc2 > 0) {
|
||||||
|
reactiveEvents.push({ event: skillRec.auraEvent2.toLowerCase(), funcId: skillRec.auraEventFunc2, skillId: sourceSkillId, slvl })
|
||||||
|
}
|
||||||
|
if (skillRec.auraEvent3 && skillRec.auraEventFunc3 > 0) {
|
||||||
|
reactiveEvents.push({ event: skillRec.auraEvent3.toLowerCase(), funcId: skillRec.auraEventFunc3, skillId: sourceSkillId, slvl })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryId = isAura ? `aura:${stateName}:${sourceUnitId}:${String(slvl)}` : `state:${stateName}`
|
||||||
|
const newEntry: ActiveStateEntry = {
|
||||||
|
stateId,
|
||||||
|
stateName,
|
||||||
|
sourceUnitId,
|
||||||
|
sourceSkillId,
|
||||||
|
slvl,
|
||||||
|
curseType,
|
||||||
|
isAura,
|
||||||
|
expireFrame,
|
||||||
|
overlays,
|
||||||
|
stats: params.stats ?? {},
|
||||||
|
reactiveEvents,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAura) {
|
||||||
|
// Replace entry from the same source + state, or keep alongside other sources for lingering pulse
|
||||||
|
const idx = this.entries.findIndex(
|
||||||
|
e => e.stateName === stateName && e.sourceUnitId === sourceUnitId && e.slvl === slvl,
|
||||||
|
)
|
||||||
|
if (idx !== -1) {
|
||||||
|
this.entries[idx] = newEntry
|
||||||
|
} else {
|
||||||
|
this.entries.push(newEntry)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const idx = this.entries.findIndex(e => e.stateName === stateName)
|
||||||
|
if (idx !== -1) {
|
||||||
|
if (this.entries[idx]!.slvl > slvl && expireFrame > 0) {
|
||||||
|
// Higher level buff remains active
|
||||||
|
return {
|
||||||
|
applied: false,
|
||||||
|
stateName,
|
||||||
|
stateId,
|
||||||
|
activeSlvl: this.entries[idx]!.slvl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.entries[idx] = newEntry
|
||||||
|
} else {
|
||||||
|
this.entries.push(newEntry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.statList.addOrUpdateModifier({
|
||||||
|
id: entryId,
|
||||||
|
stateName,
|
||||||
|
stateId,
|
||||||
|
sourceUnitId,
|
||||||
|
sourceSkillId,
|
||||||
|
slvl,
|
||||||
|
expireFrame,
|
||||||
|
isAura,
|
||||||
|
isCurse: curseType > 0,
|
||||||
|
curseType,
|
||||||
|
stats: params.stats ?? {},
|
||||||
|
})
|
||||||
|
|
||||||
|
const maxActiveSlvl = Math.max(
|
||||||
|
...this.entries.filter(e => e.stateName === stateName).map(e => e.slvl),
|
||||||
|
slvl,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
applied: true,
|
||||||
|
stateName,
|
||||||
|
stateId,
|
||||||
|
...(evictedState !== undefined ? { evictedState } : {}),
|
||||||
|
activeSlvl: maxActiveSlvl,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a specific state by name or ID.
|
||||||
|
*/
|
||||||
|
removeState(stateNameOrId: string | number): void {
|
||||||
|
const targetName =
|
||||||
|
typeof stateNameOrId === 'string'
|
||||||
|
? stateNameOrId.trim().toLowerCase().replace(/^state_/, '')
|
||||||
|
: undefined
|
||||||
|
for (let i = this.entries.length - 1; i >= 0; i -= 1) {
|
||||||
|
const e = this.entries[i]!
|
||||||
|
if (
|
||||||
|
(typeof stateNameOrId === 'number' && e.stateId === stateNameOrId) ||
|
||||||
|
(targetName !== undefined && e.stateName === targetName)
|
||||||
|
) {
|
||||||
|
this.entries.splice(i, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.statList.removeModifiersByState(targetName ?? stateNameOrId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advance clock to `currentFrame` and expire any state whose `expireFrame <= currentFrame`.
|
||||||
|
*/
|
||||||
|
tick(currentFrame: number): string[] {
|
||||||
|
const expiredStates: string[] = []
|
||||||
|
for (let i = this.entries.length - 1; i >= 0; i -= 1) {
|
||||||
|
const e = this.entries[i]!
|
||||||
|
if (e.expireFrame > 0 && e.expireFrame <= currentFrame) {
|
||||||
|
expiredStates.push(e.stateName)
|
||||||
|
this.entries.splice(i, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.statList.expireAtFrame(currentFrame)
|
||||||
|
return expiredStates
|
||||||
|
}
|
||||||
|
|
||||||
|
hasState(stateNameOrId: string | number): boolean {
|
||||||
|
if (typeof stateNameOrId === 'number') {
|
||||||
|
return this.entries.some(e => e.stateId === stateNameOrId)
|
||||||
|
}
|
||||||
|
const clean = stateNameOrId.trim().toLowerCase().replace(/^state_/, '')
|
||||||
|
return this.entries.some(e => e.stateName === clean)
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveStateNames(): string[] {
|
||||||
|
return Array.from(new Set(this.entries.map(e => `STATE_${e.stateName.toUpperCase()}`)))
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveOverlays(): string[] {
|
||||||
|
const set = new Set<string>()
|
||||||
|
for (const e of this.entries) {
|
||||||
|
for (const ov of e.overlays) {
|
||||||
|
set.add(ov)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(set)
|
||||||
|
}
|
||||||
|
|
||||||
|
getEffectiveAuraSlvl(stateName: string): number {
|
||||||
|
const clean = stateName.trim().toLowerCase().replace(/^state_/, '')
|
||||||
|
let best = 0
|
||||||
|
for (const e of this.entries) {
|
||||||
|
if (e.stateName === clean && e.slvl > best) {
|
||||||
|
best = e.slvl
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger reactive `auraeventfunc` handlers (`Chilling/Frozen/Shiver Armor`,
|
||||||
|
* `Bone Armor`, `Cyclone Armor`, `Energy Shield`, `Iron Maiden`, `Life Tap`,
|
||||||
|
* `Clay/Blood Golem`) for a combat event.
|
||||||
|
*/
|
||||||
|
triggerReactiveEvents(params: {
|
||||||
|
event: 'hitbymissile' | 'attackedinmelee' | 'damagedinmelee' | 'damagedbymissile' | 'absorbdamage' | 'domeleedamage'
|
||||||
|
incomingPhys256?: number
|
||||||
|
incomingElem256?: number
|
||||||
|
attackerStateBus?: StateBus
|
||||||
|
ownerUnitStatList?: UnitStatList
|
||||||
|
}): ReactiveEventOutcome {
|
||||||
|
let remPhys256 = params.incomingPhys256 ?? 0
|
||||||
|
let remElem256 = params.incomingElem256 ?? 0
|
||||||
|
let absorbedPhys256 = 0
|
||||||
|
let absorbedElem256 = 0
|
||||||
|
let manaDrained256 = 0
|
||||||
|
let reflectedPhys256 = 0
|
||||||
|
let attackerHealed256 = 0
|
||||||
|
let ownerHealed256 = 0
|
||||||
|
const spawnedMissiles: string[] = []
|
||||||
|
const attackerStatesApplied: string[] = []
|
||||||
|
let attackerColdDamage256 = 0
|
||||||
|
|
||||||
|
for (const entry of [...this.entries]) {
|
||||||
|
const stateName = entry.stateName
|
||||||
|
|
||||||
|
// 1. Energy Shield (`state = energyshield` or `auraeventfunc = 24`) on `absorbdamage`
|
||||||
|
if (
|
||||||
|
params.event === 'absorbdamage' &&
|
||||||
|
(stateName === 'energyshield' || entry.reactiveEvents?.some(r => r.funcId === 24))
|
||||||
|
) {
|
||||||
|
const totalIncoming256 = remPhys256 + remElem256
|
||||||
|
if (totalIncoming256 > 0) {
|
||||||
|
const absorbPct = Math.min(95, entry.stats['energyshield_pct'] ?? (20 + Math.min(75, entry.slvl * 3)))
|
||||||
|
const tkBlvl = this.statList.getBaseSkillLevel(43) // Telekinesis (skillId = 43) blvl only!
|
||||||
|
// 1.13c Telekinesis synergy: base 200% (32/16 = 2.0 mana per HP), -6.25% (1/16) per blvl in Telekinesis
|
||||||
|
const manaRatio16 = Math.max(12, 32 - tkBlvl)
|
||||||
|
const desiredRedirect256 = Math.trunc((totalIncoming256 * absorbPct) / 100)
|
||||||
|
const requiredMana256 = Math.trunc((desiredRedirect256 * manaRatio16) / 16)
|
||||||
|
const currentMana256 = this.statList.getMana256()
|
||||||
|
|
||||||
|
if (currentMana256 >= requiredMana256) {
|
||||||
|
this.statList.setMana256(currentMana256 - requiredMana256)
|
||||||
|
manaDrained256 += requiredMana256
|
||||||
|
const physPart = Math.trunc((remPhys256 * absorbPct) / 100)
|
||||||
|
const elemPart = Math.trunc((remElem256 * absorbPct) / 100)
|
||||||
|
absorbedPhys256 += physPart
|
||||||
|
absorbedElem256 += elemPart
|
||||||
|
remPhys256 -= physPart
|
||||||
|
remElem256 -= elemPart
|
||||||
|
} else {
|
||||||
|
// Partial absorb and Energy Shield collapses
|
||||||
|
const actualRedirect256 = Math.trunc((currentMana256 * 16) / manaRatio16)
|
||||||
|
this.statList.setMana256(0)
|
||||||
|
manaDrained256 += currentMana256
|
||||||
|
const physPart = Math.min(remPhys256, actualRedirect256)
|
||||||
|
const elemPart = Math.min(remElem256, actualRedirect256 - physPart)
|
||||||
|
absorbedPhys256 += physPart
|
||||||
|
absorbedElem256 += elemPart
|
||||||
|
remPhys256 -= physPart
|
||||||
|
remElem256 -= elemPart
|
||||||
|
this.removeState('energyshield')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Bone Armor (`state = bonearmor` or `auraeventfunc = 22`) absorbs physical damage
|
||||||
|
if (
|
||||||
|
(params.event === 'damagedinmelee' || params.event === 'absorbdamage') &&
|
||||||
|
stateName === 'bonearmor' &&
|
||||||
|
remPhys256 > 0
|
||||||
|
) {
|
||||||
|
const shieldPool256 = (entry.stats['damagearmor'] ?? (entry.slvl * 15)) * FIXED_ONE
|
||||||
|
const absorb = Math.min(remPhys256, shieldPool256)
|
||||||
|
absorbedPhys256 += absorb
|
||||||
|
remPhys256 -= absorb
|
||||||
|
const newPool = Math.floor((shieldPool256 - absorb) / FIXED_ONE)
|
||||||
|
if (newPool <= 0) {
|
||||||
|
this.removeState('bonearmor')
|
||||||
|
} else {
|
||||||
|
this.applyState({
|
||||||
|
stateNameOrId: 'bonearmor',
|
||||||
|
slvl: entry.slvl,
|
||||||
|
stats: { ...entry.stats, damagearmor: newPool },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Cyclone Armor (`state = cyclonearmor` or `auraeventfunc = 25`) absorbs elemental damage
|
||||||
|
if (
|
||||||
|
params.event === 'absorbdamage' &&
|
||||||
|
stateName === 'cyclonearmor' &&
|
||||||
|
remElem256 > 0
|
||||||
|
) {
|
||||||
|
const shieldPool256 = (entry.stats['magicarmor'] ?? (40 + entry.slvl * 15)) * FIXED_ONE
|
||||||
|
const absorb = Math.min(remElem256, shieldPool256)
|
||||||
|
absorbedElem256 += absorb
|
||||||
|
remElem256 -= absorb
|
||||||
|
const newPool = Math.floor((shieldPool256 - absorb) / FIXED_ONE)
|
||||||
|
if (newPool <= 0) {
|
||||||
|
this.removeState('cyclonearmor')
|
||||||
|
} else {
|
||||||
|
this.applyState({
|
||||||
|
stateNameOrId: 'cyclonearmor',
|
||||||
|
slvl: entry.slvl,
|
||||||
|
stats: { ...entry.stats, magicarmor: newPool },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Chilling Armor (`state = chillingarmor`, `auraeventfunc = 1`) on `hitbymissile`
|
||||||
|
if (params.event === 'hitbymissile' && stateName === 'chillingarmor') {
|
||||||
|
spawnedMissiles.push('chillingarmorbolt')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Frozen Armor (`state = frozenarmor`, `auraeventfunc = 2`) on `attackedinmelee`
|
||||||
|
if (params.event === 'attackedinmelee' && stateName === 'frozenarmor') {
|
||||||
|
attackerStatesApplied.push('freeze')
|
||||||
|
params.attackerStateBus?.applyState({
|
||||||
|
stateNameOrId: 'freeze',
|
||||||
|
slvl: entry.slvl,
|
||||||
|
durationFrames: 30 + entry.slvl * 3,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Shiver Armor (`state = shiverarmor`, `auraeventfunc = 3`) on `attackedinmelee`
|
||||||
|
if (params.event === 'attackedinmelee' && stateName === 'shiverarmor') {
|
||||||
|
attackerStatesApplied.push('cold')
|
||||||
|
attackerColdDamage256 += (6 + entry.slvl * 4) * FIXED_ONE
|
||||||
|
params.attackerStateBus?.applyState({
|
||||||
|
stateNameOrId: 'cold',
|
||||||
|
slvl: entry.slvl,
|
||||||
|
durationFrames: 100,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Iron Maiden (`state = ironmaiden`, `auraeventfunc = 4`) on `damagedinmelee`
|
||||||
|
if (params.event === 'damagedinmelee' && stateName === 'ironmaiden' && remPhys256 > 0) {
|
||||||
|
const reflectPct = entry.stats['thorns_percent'] ?? (175 + entry.slvl * 25)
|
||||||
|
reflectedPhys256 += Math.trunc((remPhys256 * reflectPct) / 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Life Tap (`state = lifetap`, `auraeventfunc = 5`) on `damagedinmelee` / `damagedbymissile`
|
||||||
|
if (
|
||||||
|
(params.event === 'damagedinmelee' || params.event === 'damagedbymissile') &&
|
||||||
|
stateName === 'lifetap' &&
|
||||||
|
remPhys256 > 0
|
||||||
|
) {
|
||||||
|
attackerHealed256 += Math.trunc(remPhys256 / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Blood Golem (`state = bloodgolem`, `auraeventfunc = 23/26`)
|
||||||
|
// CRITICAL 1.13c RULE (`patch.txt` line 124): Blood Golem no longer has negative shared life
|
||||||
|
// when taking damage (`damagedinmelee` does NOT drain owner HP!), while positive life steal
|
||||||
|
// on `domeleedamage` heals the Necromancer (`30%`).
|
||||||
|
if (params.event === 'domeleedamage' && stateName === 'bloodgolem' && remPhys256 > 0) {
|
||||||
|
const heal = Math.trunc((remPhys256 * 30) / 100)
|
||||||
|
ownerHealed256 += heal
|
||||||
|
if (params.ownerUnitStatList) {
|
||||||
|
params.ownerUnitStatList.setHp256(params.ownerUnitStatList.getHp256() + heal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Clay Golem (`state = claygolem`, `auraeventfunc = 27`) slows enemy on melee contact
|
||||||
|
if (params.event === 'attackedinmelee' && stateName === 'claygolem') {
|
||||||
|
attackerStatesApplied.push('slow')
|
||||||
|
params.attackerStateBus?.applyState({
|
||||||
|
stateNameOrId: 'slow',
|
||||||
|
slvl: entry.slvl,
|
||||||
|
durationFrames: 200,
|
||||||
|
stats: { item_slow: Math.min(75, 15 + entry.slvl * 3) },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
absorbedPhys256,
|
||||||
|
absorbedElem256,
|
||||||
|
manaDrained256,
|
||||||
|
reflectedPhys256,
|
||||||
|
attackerHealed256,
|
||||||
|
ownerHealed256,
|
||||||
|
spawnedMissiles,
|
||||||
|
attackerStatesApplied,
|
||||||
|
attackerColdDamage256,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,419 @@
|
||||||
|
/**
|
||||||
|
* Diablo II: Lord of Destruction v1.13c `SUMMON_CreatePet` Unit Manager (`SummonManager`).
|
||||||
|
*
|
||||||
|
* Implements:
|
||||||
|
* - `PetType.txt` group exclusivity (`group=1` Druid Spirit Wolf / Dire Wolf / Grizzly;
|
||||||
|
* `golem` `idx=3` Clay/Blood/Iron/Fire Golem; `spirit` `idx=11` Oak/Wolverine/Barbs;
|
||||||
|
* `vine` `idx=12` Poison/Carrion/Solar Vine; `shadowwarrior` `group=4` Shadow Warrior/Master).
|
||||||
|
* - `assassintrap` (`idx=17`) 5-trap cap with FIFO eviction when a 6th trap is laid.
|
||||||
|
* - Dynamic `petmax` formula evaluation (`Raise Skeleton`, `Raise Skeletal Mage`, `Revive`,
|
||||||
|
* `Raven`, `Summon Spirit Wolf`, `Summon Fenris`, `Hydra`).
|
||||||
|
* - Corpse consumption requirement for `Raise Skeleton` (`70`), `Raise Skeletal Mage` (`80`),
|
||||||
|
* and `Revive` (`95`).
|
||||||
|
* - Execution of all 17 summoned pet & trap AI sub-skills (`IDs 281..338`).
|
||||||
|
*/
|
||||||
|
import type { D2DataRegistry } from './data-registry.ts'
|
||||||
|
import { evaluateCalc } from './calc-ast.ts'
|
||||||
|
import { FIXED_ONE, UnitStatList } from './stat-list.ts'
|
||||||
|
import { StateBus } from './state-bus.ts'
|
||||||
|
import { executeSUnitDmg, type CombatUnitContext } from './combat-pipeline.ts'
|
||||||
|
import type { MissileEngine } from './missile-engine.ts'
|
||||||
|
import type { AuraScanner } from './aura-scanner.ts'
|
||||||
|
|
||||||
|
export interface SummonedPetUnit {
|
||||||
|
readonly uid: string
|
||||||
|
readonly monId: string
|
||||||
|
readonly code: string
|
||||||
|
readonly petType: string
|
||||||
|
readonly petGroup: number
|
||||||
|
readonly sourceSkillId: number
|
||||||
|
readonly slvl: number
|
||||||
|
readonly unsummonable: boolean
|
||||||
|
readonly subSkillIds: readonly number[]
|
||||||
|
readonly statList: UnitStatList
|
||||||
|
readonly stateBus: StateBus
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
hp: number
|
||||||
|
maxHp: number
|
||||||
|
shotsRemaining: number
|
||||||
|
rageBuffActive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePetOutcome {
|
||||||
|
readonly created: boolean
|
||||||
|
readonly reason?: 'missing_corpse' | 'invalid_skill'
|
||||||
|
readonly pet?: SummonedPetUnit
|
||||||
|
readonly evictedPetUids: string[]
|
||||||
|
readonly activeCountForType: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SummonManager {
|
||||||
|
readonly registry: D2DataRegistry
|
||||||
|
private readonly pets: SummonedPetUnit[] = []
|
||||||
|
private uidCounter = 1
|
||||||
|
|
||||||
|
constructor(registry: D2DataRegistry) {
|
||||||
|
this.registry = registry
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a summoned pet, trap, totem, or hydra (`SUMMON_CreatePet`).
|
||||||
|
*/
|
||||||
|
createPet(params: {
|
||||||
|
owner: CombatUnitContext
|
||||||
|
skillId: number
|
||||||
|
slvl: number
|
||||||
|
x?: number
|
||||||
|
y?: number
|
||||||
|
corpses?: { id: string; consumed: boolean; baseMaxHp?: number }[]
|
||||||
|
}): CreatePetOutcome {
|
||||||
|
const skill = this.registry.getSkillById(params.skillId)
|
||||||
|
if (!skill) {
|
||||||
|
return { created: false, reason: 'invalid_skill', evictedPetUids: [], activeCountForType: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Corpse-consuming Necromancer summons (`Raise Skeleton` 70, `Raise Skeletal Mage` 80, `Revive` 95)
|
||||||
|
const requiresCorpse = params.skillId === 70 || params.skillId === 80 || params.skillId === 95
|
||||||
|
if (requiresCorpse && params.corpses) {
|
||||||
|
const availCorpse = params.corpses.find(c => !c.consumed)
|
||||||
|
if (!availCorpse) {
|
||||||
|
return { created: false, reason: 'missing_corpse', evictedPetUids: [], activeCountForType: 0 }
|
||||||
|
}
|
||||||
|
availCorpse.consumed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const petTypeName = (
|
||||||
|
skill.petType ||
|
||||||
|
(params.skillId === 261 || params.skillId === 262 || params.skillId === 271 || params.skillId === 272 || params.skillId === 276
|
||||||
|
? 'assassintrap'
|
||||||
|
: params.skillId === 62
|
||||||
|
? 'hydra'
|
||||||
|
: 'summon')
|
||||||
|
).toLowerCase()
|
||||||
|
|
||||||
|
const petTypeRec = this.registry.getPetType(petTypeName)
|
||||||
|
const petGroup =
|
||||||
|
petTypeRec?.group ??
|
||||||
|
([227, 237, 247].includes(params.skillId)
|
||||||
|
? 1 // Druid Spirit Wolf / Dire Wolf / Grizzly group
|
||||||
|
: [226, 236, 246].includes(params.skillId)
|
||||||
|
? 2 // Druid Oak Sage / Wolverine / Barbs spirit group
|
||||||
|
: [222, 231, 241].includes(params.skillId)
|
||||||
|
? 3 // Druid Vine group
|
||||||
|
: [268, 279].includes(params.skillId)
|
||||||
|
? 4 // Assassin Shadow Warrior / Master group
|
||||||
|
: 0)
|
||||||
|
|
||||||
|
const evictedPetUids: string[] = []
|
||||||
|
|
||||||
|
// 1. Evict any existing pets in the same `petGroup` (`group > 0`) that have a different `petType`
|
||||||
|
// (e.g. summoning Grizzly evicts Spirit Wolves / Dire Wolves; summoning Shadow Master evicts Shadow Warrior)
|
||||||
|
if (petGroup > 0) {
|
||||||
|
for (let i = this.pets.length - 1; i >= 0; i -= 1) {
|
||||||
|
const p = this.pets[i]!
|
||||||
|
if (p.petGroup === petGroup && p.petType !== petTypeName) {
|
||||||
|
evictedPetUids.push(p.uid)
|
||||||
|
this.pets.splice(i, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Determine max cap (`petmax` calc from `Skills.txt`, or `PetType.txt` `basemax`, or `5` for Assassin traps)
|
||||||
|
const calcCtx = {
|
||||||
|
registry: this.registry,
|
||||||
|
activeSkill: skill,
|
||||||
|
slvl: params.slvl,
|
||||||
|
blvl: params.owner.statList.getBaseSkillLevel(params.skillId),
|
||||||
|
getBaseSkillLevel: (id: number) => params.owner.statList.getBaseSkillLevel(id),
|
||||||
|
getEffectiveSkillLevel: (id: number) => params.owner.statList.getEffectiveSkillLevel(id),
|
||||||
|
}
|
||||||
|
|
||||||
|
let maxPets = 1
|
||||||
|
if (petTypeName === 'assassintrap') {
|
||||||
|
maxPets = 5 // 1.13c Assassin trap cap = 5 active traps
|
||||||
|
} else if (skill.petMax) {
|
||||||
|
maxPets = Math.max(1, evaluateCalc(skill.petMax, calcCtx))
|
||||||
|
} else if (petTypeRec && petTypeRec.baseMax > 0) {
|
||||||
|
maxPets = petTypeRec.baseMax
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Enforce FIFO eviction when `activeCount >= maxPets`
|
||||||
|
const existingOfType = this.pets.filter(p => p.petType === petTypeName)
|
||||||
|
while (existingOfType.length >= maxPets) {
|
||||||
|
const oldest = existingOfType.shift()!
|
||||||
|
const idx = this.pets.findIndex(p => p.uid === oldest.uid)
|
||||||
|
if (idx !== -1) {
|
||||||
|
evictedPetUids.push(oldest.uid)
|
||||||
|
this.pets.splice(idx, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Resolve MonStats record and sub-skills (`IDs 281..338`)
|
||||||
|
const monId = (skill.summon || skill.name).toLowerCase()
|
||||||
|
const monRec = this.registry.getMonStats(monId)
|
||||||
|
const code = monRec?.code || (petTypeName === 'assassintrap' ? 'lg' : 'sk')
|
||||||
|
|
||||||
|
// Compute 1.13c Skeleton Mastery (`69`) / Golem Mastery (`79`) / Summon Resist (`89`) / Lycanthropy (`224`) bonuses
|
||||||
|
const skelMasteryLvl = params.owner.statList.getEffectiveSkillLevel(69)
|
||||||
|
const golemMasteryLvl = params.owner.statList.getEffectiveSkillLevel(79)
|
||||||
|
const summonResLvl = params.owner.statList.getEffectiveSkillLevel(89)
|
||||||
|
const baseHp = (monRec?.maxHP || 150) + params.slvl * 35 + skelMasteryLvl * 16 + golemMasteryLvl * 25
|
||||||
|
const petStatList = new UnitStatList(this.registry, {
|
||||||
|
level: params.owner.statList.getAccruedStat('level'),
|
||||||
|
maxhp: baseHp * FIXED_ONE,
|
||||||
|
hitpoints: baseHp * FIXED_ONE,
|
||||||
|
fireresist: summonResLvl > 0 ? Math.min(75, 20 + summonResLvl * 3) : 0,
|
||||||
|
lightresist: summonResLvl > 0 ? Math.min(75, 20 + summonResLvl * 3) : 0,
|
||||||
|
coldresist: summonResLvl > 0 ? Math.min(75, 20 + summonResLvl * 3) : 0,
|
||||||
|
poisonresist: summonResLvl > 0 ? Math.min(75, 20 + summonResLvl * 3) : 0,
|
||||||
|
})
|
||||||
|
const petStateBus = new StateBus(petStatList, this.registry)
|
||||||
|
|
||||||
|
// Map pet/trap AI sub-skills (`IDs 281..338`)
|
||||||
|
const subSkillIds: number[] = []
|
||||||
|
if (params.skillId === 262) subSkillIds.push(281) // Wake Of Destruction Sentry
|
||||||
|
if (params.skillId === 268 || params.skillId === 279) subSkillIds.push(292) // Teleport 2
|
||||||
|
if (params.skillId === 222) subSkillIds.push(294) // Vine Attack
|
||||||
|
if (params.skillId === 246) subSkillIds.push(296) // Barbs Aura
|
||||||
|
if (params.skillId === 236) subSkillIds.push(297) // Wolverine Aura
|
||||||
|
if (params.skillId === 226) subSkillIds.push(298) // Oak Sage Aura
|
||||||
|
if (params.skillId === 261) subSkillIds.push(306) // BoltSentry
|
||||||
|
if (params.skillId === 231) subSkillIds.push(307) // CorpseCycler
|
||||||
|
if (params.skillId === 272) subSkillIds.push(311) // mon inferno sentry
|
||||||
|
if (params.skillId === 276) subSkillIds.push(312, 324) // mon death sentry + death sentry ltng
|
||||||
|
if (params.skillId === 271) subSkillIds.push(313) // sentry lightning
|
||||||
|
if (params.skillId === 237) subSkillIds.push(314) // fenris rage
|
||||||
|
if (params.skillId === 241) subSkillIds.push(325) // VineCycler
|
||||||
|
if (params.skillId === 247) subSkillIds.push(326) // BearSmite
|
||||||
|
if (params.skillId === 62) subSkillIds.push(337) // HydraMissile
|
||||||
|
if (params.skillId === 80) subSkillIds.push(338) // NecromageMissile
|
||||||
|
|
||||||
|
const pet: SummonedPetUnit = {
|
||||||
|
uid: `pet-${String(this.uidCounter++)}`,
|
||||||
|
monId,
|
||||||
|
code,
|
||||||
|
petType: petTypeName,
|
||||||
|
petGroup,
|
||||||
|
sourceSkillId: params.skillId,
|
||||||
|
slvl: params.slvl,
|
||||||
|
unsummonable: petTypeRec?.unsummon ?? true,
|
||||||
|
subSkillIds,
|
||||||
|
statList: petStatList,
|
||||||
|
stateBus: petStateBus,
|
||||||
|
x: (params.x ?? 0) + (this.pets.length % 5) * 18 - 36,
|
||||||
|
y: (params.y ?? 60),
|
||||||
|
hp: baseHp,
|
||||||
|
maxHp: baseHp,
|
||||||
|
shotsRemaining: petTypeName === 'assassintrap' ? (params.skillId === 262 ? 5 : 10) : 999,
|
||||||
|
rageBuffActive: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pets.push(pet)
|
||||||
|
const activeCountForType = this.pets.filter(p => p.petType === petTypeName).length
|
||||||
|
|
||||||
|
return {
|
||||||
|
created: true,
|
||||||
|
pet,
|
||||||
|
evictedPetUids,
|
||||||
|
activeCountForType,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsummon (`skillId = 3`): dismisses the most recent unsummonable pet.
|
||||||
|
*/
|
||||||
|
unsummonPet(targetUid?: string): SummonedPetUnit | null {
|
||||||
|
if (targetUid) {
|
||||||
|
const idx = this.pets.findIndex(p => p.uid === targetUid && p.unsummonable)
|
||||||
|
if (idx !== -1) {
|
||||||
|
return this.pets.splice(idx, 1)[0] ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let i = this.pets.length - 1; i >= 0; i -= 1) {
|
||||||
|
if (this.pets[i]!.unsummonable) {
|
||||||
|
return this.pets.splice(i, 1)[0] ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Step all active pets, traps, vines, totems, and hydras (`17` sub-skills `281..338`).
|
||||||
|
*/
|
||||||
|
tickPets(params: {
|
||||||
|
currentTick: number
|
||||||
|
owner: CombatUnitContext
|
||||||
|
enemies: readonly CombatUnitContext[]
|
||||||
|
enemyPositions: ReadonlyMap<string, { x: number; y: number }>
|
||||||
|
corpses?: { id: string; consumed: boolean; baseMaxHp?: number }[]
|
||||||
|
missileEngine: MissileEngine
|
||||||
|
auraScanner: AuraScanner
|
||||||
|
}): {
|
||||||
|
petAttacks: number
|
||||||
|
subSkillsExecuted: number[]
|
||||||
|
damageDealt: number
|
||||||
|
corpsesConsumedByPets: number
|
||||||
|
} {
|
||||||
|
let petAttacks = 0
|
||||||
|
const subSkillsExecuted: number[] = []
|
||||||
|
let damageDealt = 0
|
||||||
|
let corpsesConsumedByPets = 0
|
||||||
|
|
||||||
|
const aliveEnemies = params.enemies.filter(e => e.statList.getHp256() > 0)
|
||||||
|
const primaryTarget = aliveEnemies[0]
|
||||||
|
const primaryPos = primaryTarget ? (params.enemyPositions.get(primaryTarget.id) ?? { x: 0, y: 120 }) : { x: 0, y: 120 }
|
||||||
|
|
||||||
|
for (const pet of [...this.pets]) {
|
||||||
|
if (pet.hp <= 0 || pet.shotsRemaining <= 0) continue
|
||||||
|
|
||||||
|
// Execute pet's AI sub-skills (`IDs 281..338`)
|
||||||
|
for (const subId of pet.subSkillIds) {
|
||||||
|
const subSkill = this.registry.getSkillById(subId)
|
||||||
|
if (!subSkill) continue
|
||||||
|
|
||||||
|
// Totem Auras (`296` Barbs Aura, `297` Wolverine Aura, `298` Oak Sage Aura)
|
||||||
|
if (subId === 296 || subId === 297 || subId === 298) {
|
||||||
|
const auraSrc = params.auraScanner.setActiveAura(params.owner, subId, pet.slvl, params.currentTick)
|
||||||
|
if (auraSrc) {
|
||||||
|
params.auraScanner.pulseAura(auraSrc, params.currentTick, [params.owner], aliveEnemies)
|
||||||
|
subSkillsExecuted.push(subId)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Corpse-consuming vines & dire wolves (`307` CorpseCycler, `325` VineCycler, `314` fenris rage)
|
||||||
|
if ((subId === 307 || subId === 325 || subId === 314) && params.corpses) {
|
||||||
|
const corpse = params.corpses.find(c => !c.consumed)
|
||||||
|
if (corpse) {
|
||||||
|
corpse.consumed = true
|
||||||
|
corpsesConsumedByPets += 1
|
||||||
|
subSkillsExecuted.push(subId)
|
||||||
|
if (subId === 307) {
|
||||||
|
// Carrion Vine heals owner % max HP
|
||||||
|
const heal = Math.trunc((params.owner.statList.getMaxHp256() * (4 + pet.slvl)) / 100)
|
||||||
|
params.owner.statList.setHp256(params.owner.statList.getHp256() + heal)
|
||||||
|
} else if (subId === 325) {
|
||||||
|
// Solar Creeper restores owner % max Mana
|
||||||
|
const mana = Math.trunc((params.owner.statList.getMaxMana256() * (4 + pet.slvl)) / 100)
|
||||||
|
params.owner.statList.setMana256(params.owner.statList.getMana256() + mana)
|
||||||
|
} else if (subId === 314) {
|
||||||
|
// Dire Wolf (`fenris rage`) gains +100% damage buff after eating corpse
|
||||||
|
pet.rageBuffActive = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Death Sentry (`312` `mon death sentry`): explodes an available corpse if present!
|
||||||
|
if (subId === 312 && params.corpses) {
|
||||||
|
const corpse = params.corpses.find(c => !c.consumed)
|
||||||
|
if (corpse && primaryTarget) {
|
||||||
|
corpse.consumed = true
|
||||||
|
corpsesConsumedByPets += 1
|
||||||
|
subSkillsExecuted.push(subId)
|
||||||
|
pet.shotsRemaining -= 1
|
||||||
|
const ceDmg = Math.trunc(((corpse.baseMaxHp ?? 1000) * 60) / 100) * FIXED_ONE
|
||||||
|
const out = executeSUnitDmg(params.owner, primaryTarget, {
|
||||||
|
skillId: 312,
|
||||||
|
attackKind: 'spell',
|
||||||
|
flatPhysMin256: Math.trunc(ceDmg / 2),
|
||||||
|
flatPhysMax256: Math.trunc(ceDmg / 2),
|
||||||
|
elemType: 'fire',
|
||||||
|
elemMin256: Math.trunc(ceDmg / 2),
|
||||||
|
elemMax256: Math.trunc(ceDmg / 2),
|
||||||
|
autoHit: true,
|
||||||
|
})
|
||||||
|
damageDealt += out.totalDamage
|
||||||
|
petAttacks += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missile-firing traps, hydras, and skeletal mages (`281`, `306`, `311`, `313`, `324`, `337`, `338`)
|
||||||
|
if (
|
||||||
|
primaryTarget &&
|
||||||
|
(subId === 281 ||
|
||||||
|
subId === 306 ||
|
||||||
|
subId === 311 ||
|
||||||
|
subId === 313 ||
|
||||||
|
subId === 324 ||
|
||||||
|
subId === 337 ||
|
||||||
|
subId === 338)
|
||||||
|
) {
|
||||||
|
const mslName =
|
||||||
|
subSkill.srvMissile ||
|
||||||
|
subSkill.srvMissileA ||
|
||||||
|
(subId === 281
|
||||||
|
? 'wake of destruction maker'
|
||||||
|
: subId === 306
|
||||||
|
? 'chargedbolt'
|
||||||
|
: subId === 311
|
||||||
|
? 'inferno1'
|
||||||
|
: 'sentrylightningbolt')
|
||||||
|
params.missileEngine.spawnMissile({
|
||||||
|
missileNameOrId: mslName,
|
||||||
|
sourceSkillId: pet.sourceSkillId,
|
||||||
|
slvl: pet.slvl,
|
||||||
|
owner: params.owner,
|
||||||
|
startX: pet.x,
|
||||||
|
startY: pet.y,
|
||||||
|
targetX: primaryPos.x,
|
||||||
|
targetY: primaryPos.y,
|
||||||
|
dmgPacket: {
|
||||||
|
skillId: pet.sourceSkillId,
|
||||||
|
attackKind: 'spell',
|
||||||
|
elemType: subId === 281 || subId === 311 || subId === 337 ? 'fire' : 'ltng',
|
||||||
|
elemMin256: (25 + pet.slvl * 12) * FIXED_ONE,
|
||||||
|
elemMax256: (45 + pet.slvl * 18) * FIXED_ONE,
|
||||||
|
autoHit: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
subSkillsExecuted.push(subId)
|
||||||
|
petAttacks += 1
|
||||||
|
if (pet.petType === 'assassintrap') {
|
||||||
|
pet.shotsRemaining -= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard melee attack for non-totem combat pets (`Skeleton`, `Golem`, `Wolf`, `Grizzly`, `Valkyrie`, `Shadow`)
|
||||||
|
if (
|
||||||
|
primaryTarget &&
|
||||||
|
pet.petGroup !== 2 &&
|
||||||
|
pet.petType !== 'assassintrap' &&
|
||||||
|
pet.petType !== 'hydra' &&
|
||||||
|
params.currentTick % 10 === 0
|
||||||
|
) {
|
||||||
|
const basePetPhys = (20 + pet.slvl * 10) * (pet.rageBuffActive ? 2 : 1) * FIXED_ONE
|
||||||
|
const out = executeSUnitDmg(params.owner, primaryTarget, {
|
||||||
|
skillId: pet.sourceSkillId,
|
||||||
|
attackKind: 'melee',
|
||||||
|
srcDam: 0,
|
||||||
|
flatPhysMin256: basePetPhys,
|
||||||
|
flatPhysMax256: basePetPhys,
|
||||||
|
autoHit: true,
|
||||||
|
})
|
||||||
|
damageDealt += out.totalDamage
|
||||||
|
petAttacks += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove exhausted traps
|
||||||
|
for (let i = this.pets.length - 1; i >= 0; i -= 1) {
|
||||||
|
if (this.pets[i]!.shotsRemaining <= 0) {
|
||||||
|
this.pets.splice(i, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
petAttacks,
|
||||||
|
subSkillsExecuted,
|
||||||
|
damageDealt,
|
||||||
|
corpsesConsumedByPets,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getActivePets(): readonly SummonedPetUnit[] {
|
||||||
|
return this.pets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,439 @@
|
||||||
|
/**
|
||||||
|
* Diablo II: Lord of Destruction v1.13c Deterministic Skill Verification Arena (`WorldArena`).
|
||||||
|
*
|
||||||
|
* Pre-spawns:
|
||||||
|
* - Player Unit configured to any of the 7 classes (`ama`, `sor`, `nec`, `pal`, `bar`, `dru`, `ass`),
|
||||||
|
* with exact 1.13c `UnitStatList`, `StateBus`, and `AnimDispatcher`.
|
||||||
|
* - 4 Target Dummies (`dummy-normal`, `dummy-undead`, `dummy-demon`, `dummy-immune`)
|
||||||
|
* and 3 Targetable Monster Corpses (`corpse-1`, `corpse-2`, `corpse-3`).
|
||||||
|
* - Exposes the `ArenaDebugState` (`window.__d2webArena`) inspection & `triggerCast()` API.
|
||||||
|
*/
|
||||||
|
import type { D2DataRegistry } from './data-registry.ts'
|
||||||
|
import { FIXED_ONE, UnitStatList } from './stat-list.ts'
|
||||||
|
import { StateBus } from './state-bus.ts'
|
||||||
|
import type { CombatUnitContext } from './combat-pipeline.ts'
|
||||||
|
import { AnimDispatcher } from './anim-dispatcher.ts'
|
||||||
|
import { MissileEngine } from './missile-engine.ts'
|
||||||
|
import { AuraScanner } from './aura-scanner.ts'
|
||||||
|
import { SummonManager } from './summon-manager.ts'
|
||||||
|
import { evaluateSkill113c, executeSkill113c } from '../skills/registry.ts'
|
||||||
|
import type { SkillEvalResult, SkillExecOutcome } from '../skills/types.ts'
|
||||||
|
import { resolvePlayerCharToken } from '../../render/unit-sprites.ts'
|
||||||
|
|
||||||
|
export interface ArenaDebugState {
|
||||||
|
ready: boolean
|
||||||
|
classCode: string // 'ama' | 'sor' | 'nec' | 'pal' | 'bar' | 'dru' | 'ass'
|
||||||
|
charToken: string // 'am' | 'so' | 'ne' | 'pa' | 'ba' | 'dz' | 'ai'
|
||||||
|
skillId: number
|
||||||
|
skillName: string
|
||||||
|
slvl: number
|
||||||
|
tick: number
|
||||||
|
castCount: number
|
||||||
|
actionFrameTriggered: boolean
|
||||||
|
playerAnimMode: string
|
||||||
|
playerAnimFrame: number
|
||||||
|
activeStates: string[]
|
||||||
|
activeOverlays: string[]
|
||||||
|
missilesSpawned: number
|
||||||
|
missilesActive: { id: string; celFile: string; x: number; y: number }[]
|
||||||
|
petsActive: { monId: string; code: string; petType: string; hp: number }[]
|
||||||
|
corpsesRemaining: number
|
||||||
|
totalDamageDealt: number
|
||||||
|
targetLog: {
|
||||||
|
targetId: string
|
||||||
|
hp: number
|
||||||
|
maxHp: number
|
||||||
|
states: string[]
|
||||||
|
lastDamage: number
|
||||||
|
}[]
|
||||||
|
tooltipStats: {
|
||||||
|
minDmg: number
|
||||||
|
maxDmg: number
|
||||||
|
manaCost: number
|
||||||
|
toHit: number
|
||||||
|
radius: number
|
||||||
|
durationFrames: number
|
||||||
|
}
|
||||||
|
triggerCast: (targetX?: number, targetY?: number) => SkillExecOutcome | null
|
||||||
|
stepTicks: (count: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WorldArena {
|
||||||
|
readonly registry: D2DataRegistry
|
||||||
|
readonly classCode: string
|
||||||
|
readonly charToken: string
|
||||||
|
readonly skillId: number
|
||||||
|
readonly slvl: number
|
||||||
|
readonly player: CombatUnitContext
|
||||||
|
readonly targets: CombatUnitContext[] = []
|
||||||
|
readonly targetPositions = new Map<string, { x: number; y: number }>()
|
||||||
|
readonly corpses: { id: string; consumed: boolean; baseMaxHp: number; x: number; y: number }[] = []
|
||||||
|
readonly animDispatcher: AnimDispatcher
|
||||||
|
readonly missileEngine: MissileEngine
|
||||||
|
readonly auraScanner: AuraScanner
|
||||||
|
readonly summonManager: SummonManager
|
||||||
|
|
||||||
|
tickCount = 0
|
||||||
|
castCount = 0
|
||||||
|
actionFrameTriggered = false
|
||||||
|
totalMissilesSpawned = 0
|
||||||
|
totalDamageDealt = 0
|
||||||
|
lastOutcome: SkillExecOutcome | null = null
|
||||||
|
lastEval: SkillEvalResult
|
||||||
|
|
||||||
|
constructor(params: {
|
||||||
|
registry: D2DataRegistry
|
||||||
|
classCode?: string
|
||||||
|
skillId: number
|
||||||
|
slvl?: number
|
||||||
|
blvl?: number
|
||||||
|
synergies?: readonly [number, number][]
|
||||||
|
}) {
|
||||||
|
this.registry = params.registry
|
||||||
|
this.skillId = params.skillId
|
||||||
|
this.slvl = Math.max(1, params.slvl ?? 20)
|
||||||
|
const blvl = Math.max(1, params.blvl ?? this.slvl)
|
||||||
|
|
||||||
|
const skillRec = this.registry.getSkillById(this.skillId) ?? this.registry.getSkillById(0)!
|
||||||
|
this.classCode = (params.classCode || skillRec.charClass || 'sor').toLowerCase()
|
||||||
|
this.charToken = resolvePlayerCharToken(this.classCode)
|
||||||
|
|
||||||
|
const playerStats = new UnitStatList(this.registry, {
|
||||||
|
level: 85,
|
||||||
|
strength: 150,
|
||||||
|
dexterity: 150,
|
||||||
|
vitality: 200,
|
||||||
|
energy: 200,
|
||||||
|
maxhp: 2500 * FIXED_ONE,
|
||||||
|
hitpoints: 2500 * FIXED_ONE,
|
||||||
|
maxmana: 2500 * FIXED_ONE,
|
||||||
|
mana: 2500 * FIXED_ONE,
|
||||||
|
tohit: 8000,
|
||||||
|
armorclass: 1200,
|
||||||
|
})
|
||||||
|
playerStats.setBaseSkillLevel(this.skillId, blvl)
|
||||||
|
if (this.slvl > blvl) {
|
||||||
|
playerStats.setBonusSkillLevel(this.skillId, this.slvl - blvl)
|
||||||
|
}
|
||||||
|
if (params.synergies) {
|
||||||
|
for (const [synId, synBlvl] of params.synergies) {
|
||||||
|
playerStats.setBaseSkillLevel(synId, synBlvl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const playerStateBus = new StateBus(playerStats, this.registry)
|
||||||
|
this.player = {
|
||||||
|
id: 'player',
|
||||||
|
name: `Hero_${this.classCode.toUpperCase()}`,
|
||||||
|
statList: playerStats,
|
||||||
|
stateBus: playerStateBus,
|
||||||
|
isDualClaw: this.classCode === 'ass',
|
||||||
|
hasShield: this.classCode === 'pal',
|
||||||
|
weaponMinPhys: 45,
|
||||||
|
weaponMaxPhys: 85,
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the skill requires shapeshift (`restrict === 2`: Feral Rage, Maul, Rabies, Fire Claws, Hunger, Shock Wave, Fury),
|
||||||
|
// automatically enter Werewolf/Werebear form so it can be cast immediately in the arena
|
||||||
|
if (skillRec.restrict === 2) {
|
||||||
|
const form = skillRec.id === 233 || skillRec.id === 243 ? 'bear' : 'wolf'
|
||||||
|
playerStateBus.applyState({ stateNameOrId: form, slvl: 20, durationFrames: 5000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
this.animDispatcher = new AnimDispatcher({
|
||||||
|
registry: this.registry,
|
||||||
|
statList: playerStats,
|
||||||
|
stateBus: playerStateBus,
|
||||||
|
charToken: this.charToken,
|
||||||
|
weaponClass:
|
||||||
|
this.classCode === 'ama'
|
||||||
|
? 'bow'
|
||||||
|
: this.classCode === 'ass'
|
||||||
|
? 'h2h'
|
||||||
|
: this.classCode === 'pal' || this.classCode === 'bar'
|
||||||
|
? '1hs'
|
||||||
|
: 'hth',
|
||||||
|
})
|
||||||
|
this.missileEngine = new MissileEngine(this.registry)
|
||||||
|
this.auraScanner = new AuraScanner(this.registry)
|
||||||
|
this.summonManager = new SummonManager(this.registry)
|
||||||
|
|
||||||
|
// Spawn Target Dummy Cluster:
|
||||||
|
// 1. `dummy-normal`: Normal Beast Monster (10,000 HP, 0% resists)
|
||||||
|
this.spawnDummy('dummy-normal', 'Fallen Dummy', { x: 0, y: 110 }, { isUndead: false, isDemon: false, res: 0 })
|
||||||
|
// 2. `dummy-undead`: Undead Monster (10,000 HP, Undead=true)
|
||||||
|
this.spawnDummy('dummy-undead', 'Skeleton Dummy', { x: -65, y: 115 }, { isUndead: true, isDemon: false, res: 0 })
|
||||||
|
// 3. `dummy-demon`: Demon Monster (10,000 HP, Demon=true)
|
||||||
|
this.spawnDummy('dummy-demon', 'Balrog Dummy', { x: 65, y: 115 }, { isUndead: false, isDemon: true, res: 0 })
|
||||||
|
// 4. `dummy-immune`: Elemental Immune Monster (10,000 HP, 110% resists)
|
||||||
|
this.spawnDummy('dummy-immune', 'Immune Dummy', { x: 130, y: 120 }, { isUndead: false, isDemon: true, res: 110 })
|
||||||
|
|
||||||
|
// Spawn 3 Targetable Monster Corpses (`corpse-1..3`)
|
||||||
|
this.corpses.push(
|
||||||
|
{ id: 'corpse-1', consumed: false, baseMaxHp: 1200, x: -35, y: 80 },
|
||||||
|
{ id: 'corpse-2', consumed: false, baseMaxHp: 1200, x: 0, y: 80 },
|
||||||
|
{ id: 'corpse-3', consumed: false, baseMaxHp: 1200, x: 35, y: 80 },
|
||||||
|
)
|
||||||
|
|
||||||
|
this.lastEval = evaluateSkill113c({
|
||||||
|
registry: this.registry,
|
||||||
|
skill: skillRec,
|
||||||
|
slvl: this.slvl,
|
||||||
|
blvl: playerStats.getBaseSkillLevel(this.skillId),
|
||||||
|
statList: playerStats,
|
||||||
|
stateBus: playerStateBus,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawnDummy(
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
pos: { x: number; y: number },
|
||||||
|
opts: { isUndead: boolean; isDemon: boolean; res: number },
|
||||||
|
): void {
|
||||||
|
const st = new UnitStatList(this.registry, {
|
||||||
|
level: 80,
|
||||||
|
maxhp: 10000 * FIXED_ONE,
|
||||||
|
hitpoints: 10000 * FIXED_ONE,
|
||||||
|
armorclass: 400,
|
||||||
|
fireresist: opts.res,
|
||||||
|
lightresist: opts.res,
|
||||||
|
coldresist: opts.res,
|
||||||
|
poisonresist: opts.res,
|
||||||
|
magicresist: 0,
|
||||||
|
damageresist: 0,
|
||||||
|
})
|
||||||
|
const sb = new StateBus(st, this.registry)
|
||||||
|
const dummy: CombatUnitContext = {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
statList: st,
|
||||||
|
stateBus: sb,
|
||||||
|
isUndead: opts.isUndead,
|
||||||
|
isDemon: opts.isDemon,
|
||||||
|
}
|
||||||
|
this.targets.push(dummy)
|
||||||
|
this.targetPositions.set(id, pos)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cast or activate `this.skillId` (or an explicit `overrideSkillId`), stepping through
|
||||||
|
* the `AnimData.d2` action frame and missile/pet ticks so gameplay effects take place immediately.
|
||||||
|
*/
|
||||||
|
triggerCast(targetX = 0, targetY = 110, overrideSkillId?: number): SkillExecOutcome {
|
||||||
|
const idToCast = overrideSkillId ?? this.skillId
|
||||||
|
const skillRec = this.registry.getSkillById(idToCast) ?? this.registry.getSkillById(0)!
|
||||||
|
const effSlvl = Math.max(1, this.player.statList.getEffectiveSkillLevel(idToCast) || this.slvl)
|
||||||
|
const effBlvl = this.player.statList.getBaseSkillLevel(idToCast) || effSlvl
|
||||||
|
|
||||||
|
// Ensure infinite mana refill in arena before casting
|
||||||
|
this.player.statList.setMana256(this.player.statList.getMaxMana256())
|
||||||
|
|
||||||
|
const evalRes = evaluateSkill113c({
|
||||||
|
registry: this.registry,
|
||||||
|
skill: skillRec,
|
||||||
|
slvl: effSlvl,
|
||||||
|
blvl: effBlvl,
|
||||||
|
statList: this.player.statList,
|
||||||
|
stateBus: this.player.stateBus,
|
||||||
|
})
|
||||||
|
this.lastEval = evalRes
|
||||||
|
|
||||||
|
let outcome: SkillExecOutcome | null = null
|
||||||
|
|
||||||
|
if (skillRec.passive || skillRec.aura) {
|
||||||
|
this.actionFrameTriggered = true
|
||||||
|
this.castCount += 1
|
||||||
|
outcome = executeSkill113c({
|
||||||
|
registry: this.registry,
|
||||||
|
skill: skillRec,
|
||||||
|
evalResult: evalRes,
|
||||||
|
caster: this.player,
|
||||||
|
targets: this.targets,
|
||||||
|
targetPositions: this.targetPositions,
|
||||||
|
corpses: this.corpses,
|
||||||
|
missileEngine: this.missileEngine,
|
||||||
|
auraScanner: this.auraScanner,
|
||||||
|
summonManager: this.summonManager,
|
||||||
|
currentTick: this.tickCount,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const attempt = this.animDispatcher.startSkillAnimation({
|
||||||
|
skill: skillRec,
|
||||||
|
slvl: effSlvl,
|
||||||
|
currentTick: this.tickCount,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (attempt.allowed) {
|
||||||
|
// Step animation until action frame fires
|
||||||
|
for (let i = 0; i < 25; i += 1) {
|
||||||
|
this.tickCount += 1
|
||||||
|
const step = this.animDispatcher.tick(this.tickCount, (_anim, tick) => {
|
||||||
|
this.actionFrameTriggered = true
|
||||||
|
this.castCount += 1
|
||||||
|
outcome = executeSkill113c({
|
||||||
|
registry: this.registry,
|
||||||
|
skill: skillRec,
|
||||||
|
evalResult: evalRes,
|
||||||
|
caster: this.player,
|
||||||
|
targets: this.targets,
|
||||||
|
targetPositions: this.targetPositions,
|
||||||
|
corpses: this.corpses,
|
||||||
|
missileEngine: this.missileEngine,
|
||||||
|
auraScanner: this.auraScanner,
|
||||||
|
summonManager: this.summonManager,
|
||||||
|
currentTick: tick,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if (step.actionFired) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!outcome) {
|
||||||
|
// Fallback immediate execution if skill was on cooldown
|
||||||
|
this.actionFrameTriggered = true
|
||||||
|
this.castCount += 1
|
||||||
|
outcome = executeSkill113c({
|
||||||
|
registry: this.registry,
|
||||||
|
skill: skillRec,
|
||||||
|
evalResult: evalRes,
|
||||||
|
caster: this.player,
|
||||||
|
targets: this.targets,
|
||||||
|
targetPositions: this.targetPositions,
|
||||||
|
corpses: this.corpses,
|
||||||
|
missileEngine: this.missileEngine,
|
||||||
|
auraScanner: this.auraScanner,
|
||||||
|
summonManager: this.summonManager,
|
||||||
|
currentTick: this.tickCount,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.totalMissilesSpawned += outcome.missilesSpawned.length
|
||||||
|
this.totalDamageDealt += outcome.totalDamageDealt
|
||||||
|
this.lastOutcome = outcome
|
||||||
|
|
||||||
|
// Step 12 simulation ticks so spawned missiles/pets/auras travel and impact dummies
|
||||||
|
this.stepTicks(12)
|
||||||
|
|
||||||
|
return outcome
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Advance the simulation arena by `count` ticks (`25 Hz`).
|
||||||
|
*/
|
||||||
|
stepTicks(count: number): void {
|
||||||
|
const steps = Math.max(0, Math.trunc(count))
|
||||||
|
for (let i = 0; i < steps; i += 1) {
|
||||||
|
this.tickCount += 1
|
||||||
|
this.player.stateBus.tick(this.tickCount)
|
||||||
|
for (const t of this.targets) {
|
||||||
|
t.stateBus.tick(this.tickCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
const mStep = this.missileEngine.tick(this.tickCount, this.targets, this.targetPositions)
|
||||||
|
for (const h of mStep.hits) {
|
||||||
|
this.totalDamageDealt += h.damage
|
||||||
|
}
|
||||||
|
this.totalMissilesSpawned += mStep.subMissilesSpawned.length
|
||||||
|
|
||||||
|
const auraStep = this.auraScanner.tick(this.tickCount, [this.player], this.targets, this.corpses)
|
||||||
|
for (const a of auraStep) {
|
||||||
|
this.totalDamageDealt += a.pulseDamageDealt
|
||||||
|
}
|
||||||
|
|
||||||
|
const petStep = this.summonManager.tickPets({
|
||||||
|
currentTick: this.tickCount,
|
||||||
|
owner: this.player,
|
||||||
|
enemies: this.targets,
|
||||||
|
enemyPositions: this.targetPositions,
|
||||||
|
corpses: this.corpses,
|
||||||
|
missileEngine: this.missileEngine,
|
||||||
|
auraScanner: this.auraScanner,
|
||||||
|
})
|
||||||
|
this.totalDamageDealt += petStep.damageDealt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the complete `ArenaDebugState` snapshot for `window.__d2webArena` & automated tests.
|
||||||
|
*/
|
||||||
|
getDebugState(): ArenaDebugState {
|
||||||
|
const anim = this.animDispatcher.getCurrentAnimation()
|
||||||
|
const skillRec = this.registry.getSkillById(this.skillId) ?? this.registry.getSkillById(0)!
|
||||||
|
const activeStates = new Set<string>(this.player.stateBus.getActiveStateNames())
|
||||||
|
const activeOverlays = new Set<string>(this.player.stateBus.getActiveOverlays())
|
||||||
|
|
||||||
|
for (const t of this.targets) {
|
||||||
|
for (const st of t.stateBus.getActiveStateNames()) activeStates.add(st)
|
||||||
|
for (const ov of t.stateBus.getActiveOverlays()) activeOverlays.add(ov)
|
||||||
|
}
|
||||||
|
if (skillRec.castOverlay) activeOverlays.add(skillRec.castOverlay)
|
||||||
|
if (skillRec.cltOverlayA) activeOverlays.add(skillRec.cltOverlayA)
|
||||||
|
|
||||||
|
const targetLog = this.targets.map(t => {
|
||||||
|
const maxHp = t.statList.getMaxHp()
|
||||||
|
const hp = t.statList.getHp()
|
||||||
|
return {
|
||||||
|
targetId: t.id,
|
||||||
|
hp,
|
||||||
|
maxHp,
|
||||||
|
states: t.stateBus.getActiveStateNames(),
|
||||||
|
lastDamage: Math.max(0, maxHp - hp),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const minDmg = Math.max(this.lastEval.minElemDmg, this.lastEval.minPhysDmg)
|
||||||
|
const maxDmg = Math.max(this.lastEval.maxElemDmg, this.lastEval.maxPhysDmg)
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready: true,
|
||||||
|
classCode: this.classCode,
|
||||||
|
charToken: this.charToken,
|
||||||
|
skillId: this.skillId,
|
||||||
|
skillName: skillRec.name,
|
||||||
|
slvl: this.slvl,
|
||||||
|
tick: this.tickCount,
|
||||||
|
castCount: this.castCount,
|
||||||
|
actionFrameTriggered: this.actionFrameTriggered,
|
||||||
|
playerAnimMode: anim ? anim.skill.anim || 'SC' : skillRec.anim || 'SC',
|
||||||
|
playerAnimFrame: anim ? anim.currentFrame : 6,
|
||||||
|
activeStates: Array.from(activeStates),
|
||||||
|
activeOverlays: Array.from(activeOverlays),
|
||||||
|
missilesSpawned: this.totalMissilesSpawned,
|
||||||
|
missilesActive: this.missileEngine.getActiveMissiles().map(m => ({
|
||||||
|
id: m.uid,
|
||||||
|
celFile: m.celFile,
|
||||||
|
x: Math.round(m.x),
|
||||||
|
y: Math.round(m.y),
|
||||||
|
})),
|
||||||
|
petsActive: this.summonManager.getActivePets().map(p => ({
|
||||||
|
monId: p.monId,
|
||||||
|
code: p.code,
|
||||||
|
petType: p.petType,
|
||||||
|
hp: p.hp,
|
||||||
|
})),
|
||||||
|
corpsesRemaining: this.corpses.filter(c => !c.consumed).length,
|
||||||
|
totalDamageDealt: this.totalDamageDealt,
|
||||||
|
targetLog,
|
||||||
|
tooltipStats: {
|
||||||
|
minDmg,
|
||||||
|
maxDmg,
|
||||||
|
manaCost: this.lastEval.manaCost,
|
||||||
|
toHit: this.lastEval.toHitBonusPct,
|
||||||
|
radius: this.lastEval.radiusSubtiles,
|
||||||
|
durationFrames: this.lastEval.durationFrames,
|
||||||
|
},
|
||||||
|
triggerCast: (tx?: number, ty?: number) => this.triggerCast(tx, ty),
|
||||||
|
stepTicks: (cnt: number) => this.stepTicks(cnt),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,905 @@
|
||||||
|
/**
|
||||||
|
* Diablo II: Lord of Destruction v1.13c Zero-Conflict Skill Registry (`src/game/skills/registry.ts`).
|
||||||
|
*
|
||||||
|
* 1. Auto-discovers per-skill implementation modules in `src/game/skills/impl/<class>/skill-<id3>-<slug>.ts`
|
||||||
|
* via `import.meta.glob('./impl/**\/*.ts', { eager: true })` so isolated `git worktree` skill branches
|
||||||
|
* add only their own file and never touch any shared registry file.
|
||||||
|
* 2. Provides complete 1.13c data-driven evaluation (`evaluateSkill113c`) and runtime execution
|
||||||
|
* (`executeSkill113c`) across all 221 player skills (`210` class skills + `11` universal skills)
|
||||||
|
* and `17` summoned pet/trap AI sub-skills (`IDs 281..338`), directly from `Patch_D2.mpq`.
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
SkillEvalContext,
|
||||||
|
SkillEvalResult,
|
||||||
|
SkillExecContext,
|
||||||
|
SkillExecOutcome,
|
||||||
|
SkillModule,
|
||||||
|
} from './types.ts'
|
||||||
|
import {
|
||||||
|
compute3BandDuration,
|
||||||
|
compute5BandScaling,
|
||||||
|
computeSkillManaCost256,
|
||||||
|
evaluateCalc,
|
||||||
|
} from '../engine/calc-ast.ts'
|
||||||
|
import { FIXED_ONE } from '../engine/stat-list.ts'
|
||||||
|
import { computeCorpseExplosion113c, executeSUnitDmg } from '../engine/combat-pipeline.ts'
|
||||||
|
|
||||||
|
const REGISTERED_SKILL_MODULES = new Map<number, SkillModule>()
|
||||||
|
|
||||||
|
// Auto-discover any per-skill module under `./impl/**/*.ts` in Vite & Vitest
|
||||||
|
const globLoader = (
|
||||||
|
import.meta as unknown as {
|
||||||
|
glob?: <T>(pattern: string, opts: { eager: boolean }) => Record<string, T>
|
||||||
|
}
|
||||||
|
).glob
|
||||||
|
|
||||||
|
if (typeof globLoader === 'function') {
|
||||||
|
const discovered = globLoader<{ readonly default?: SkillModule; readonly skillModule?: SkillModule }>(
|
||||||
|
'./impl/**/*.ts',
|
||||||
|
{ eager: true },
|
||||||
|
)
|
||||||
|
for (const mod of Object.values(discovered)) {
|
||||||
|
const handler = mod.skillModule ?? mod.default
|
||||||
|
if (handler !== undefined && typeof handler.skillId === 'number') {
|
||||||
|
REGISTERED_SKILL_MODULES.set(handler.skillId, handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerSkillModule(mod: SkillModule): void {
|
||||||
|
REGISTERED_SKILL_MODULES.set(mod.skillId, mod)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRegisteredSkillModule(skillId: number): SkillModule | undefined {
|
||||||
|
return REGISTERED_SKILL_MODULES.get(skillId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listRegisteredSkillModules(): SkillModule[] {
|
||||||
|
return Array.from(REGISTERED_SKILL_MODULES.values()).sort((a, b) => a.skillId - b.skillId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate exact 1.13c numbers (`MinDmg`, `MaxDmg`, `ManaCost`, `ToHit`, `Radius`, `Duration`,
|
||||||
|
* `SynergyBonusPct`, `PetMax`, `CooldownFrames`) for any skill from `Skills.txt` + `Missiles.txt`.
|
||||||
|
*/
|
||||||
|
export function evaluateSkill113c(ctx: SkillEvalContext): SkillEvalResult {
|
||||||
|
const customMod = REGISTERED_SKILL_MODULES.get(ctx.skill.id)
|
||||||
|
if (customMod?.evaluate) {
|
||||||
|
return customMod.evaluate(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { registry, skill, slvl, blvl, statList } = ctx
|
||||||
|
const effLvl = Math.max(1, slvl)
|
||||||
|
const calcCtx = {
|
||||||
|
registry,
|
||||||
|
activeSkill: skill,
|
||||||
|
slvl: effLvl,
|
||||||
|
blvl,
|
||||||
|
ulvl: statList.getAccruedStat('level'),
|
||||||
|
getBaseSkillLevel: (id: number) => statList.getBaseSkillLevel(id),
|
||||||
|
getEffectiveSkillLevel: (id: number) => statList.getEffectiveSkillLevel(id),
|
||||||
|
getUnitStat: (statNameOrId: string | number, baseOnly?: boolean) =>
|
||||||
|
statList.getAccruedStat(statNameOrId, baseOnly),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Mana cost (`usmc` in 256-fixed-point and integer `manaCost`)
|
||||||
|
const { manaCost256, manaCost } = computeSkillManaCost256(skill, effLvl)
|
||||||
|
|
||||||
|
// 2. Attack Rating bonus (`toht`)
|
||||||
|
const toHitBonusPct = skill.toHitCalc
|
||||||
|
? evaluateCalc(skill.toHitCalc, calcCtx)
|
||||||
|
: skill.toHit + (effLvl - 1) * skill.toHitLev
|
||||||
|
|
||||||
|
// 3. Synergy percentage (`EDmgSymPerCalc` or `DmgSymPerCalc`)
|
||||||
|
const eSynPct = skill.eDmgSymPerCalc ? evaluateCalc(skill.eDmgSymPerCalc, calcCtx) : 0
|
||||||
|
const physSynPct = skill.dmgSymPerCalc ? evaluateCalc(skill.dmgSymPerCalc, calcCtx) : 0
|
||||||
|
// Dragon Claw (`260`) 1.13c synergy: +4% damage per `blvl` in Claw Mastery (`252`) (`Param8=4`)
|
||||||
|
const dragonClawSyn =
|
||||||
|
skill.id === 260 ? statList.getBaseSkillLevel(252) * (skill.param8 || 4) : 0
|
||||||
|
const synergyBonusPct = eSynPct !== 0 ? eSynPct : physSynPct + dragonClawSyn
|
||||||
|
|
||||||
|
// 4. Physical flat & %ED damage (`MinDam`..`MinLevDam5`, `MaxDam`..`MaxLevDam5`, `calc1`)
|
||||||
|
const basePhysMin = compute5BandScaling(
|
||||||
|
effLvl,
|
||||||
|
skill.minDam,
|
||||||
|
skill.minLevDam1,
|
||||||
|
skill.minLevDam2,
|
||||||
|
skill.minLevDam3,
|
||||||
|
skill.minLevDam4,
|
||||||
|
skill.minLevDam5,
|
||||||
|
)
|
||||||
|
const basePhysMax = compute5BandScaling(
|
||||||
|
effLvl,
|
||||||
|
skill.maxDam,
|
||||||
|
skill.maxLevDam1,
|
||||||
|
skill.maxLevDam2,
|
||||||
|
skill.maxLevDam3,
|
||||||
|
skill.maxLevDam4,
|
||||||
|
skill.maxLevDam5,
|
||||||
|
)
|
||||||
|
const physMinScaled = Math.trunc((basePhysMin * (100 + physSynPct)) / 100)
|
||||||
|
const physMaxScaled = Math.trunc((basePhysMax * (100 + physSynPct)) / 100)
|
||||||
|
const calc1Val = skill.calc1 ? evaluateCalc(skill.calc1, calcCtx) : 0
|
||||||
|
const physDamagePct =
|
||||||
|
skill.srcDam > 0 ? calc1Val + physSynPct + dragonClawSyn : physSynPct
|
||||||
|
|
||||||
|
// 5. Elemental / Magic / Poison damage (`EMin`..`EMinLev5`, `EMax`..`EMaxLev5`, or primary missile fallback)
|
||||||
|
let baseEMin = compute5BandScaling(
|
||||||
|
effLvl,
|
||||||
|
skill.eMin,
|
||||||
|
skill.eMinLev1,
|
||||||
|
skill.eMinLev2,
|
||||||
|
skill.eMinLev3,
|
||||||
|
skill.eMinLev4,
|
||||||
|
skill.eMinLev5,
|
||||||
|
)
|
||||||
|
let baseEMax = compute5BandScaling(
|
||||||
|
effLvl,
|
||||||
|
skill.eMax,
|
||||||
|
skill.eMaxLev1,
|
||||||
|
skill.eMaxLev2,
|
||||||
|
skill.eMaxLev3,
|
||||||
|
skill.eMaxLev4,
|
||||||
|
skill.eMaxLev5,
|
||||||
|
)
|
||||||
|
let elemHitShift = skill.hitShift
|
||||||
|
let elemType = skill.eType
|
||||||
|
|
||||||
|
const primaryMissileName = skill.srvMissile || skill.srvMissileA || skill.cltMissile || skill.cltMissileA
|
||||||
|
if (baseEMin === 0 && baseEMax === 0 && primaryMissileName) {
|
||||||
|
const msl = registry.getMissileByName(primaryMissileName)
|
||||||
|
if (msl && (msl.eMin > 0 || msl.eMax > 0)) {
|
||||||
|
baseEMin = compute5BandScaling(
|
||||||
|
effLvl,
|
||||||
|
msl.eMin,
|
||||||
|
msl.eMinLev1,
|
||||||
|
msl.eMinLev2,
|
||||||
|
msl.eMinLev3,
|
||||||
|
msl.eMinLev4,
|
||||||
|
msl.eMinLev5,
|
||||||
|
)
|
||||||
|
baseEMax = compute5BandScaling(
|
||||||
|
effLvl,
|
||||||
|
msl.eMax,
|
||||||
|
msl.eMaxLev1,
|
||||||
|
msl.eMaxLev2,
|
||||||
|
msl.eMaxLev3,
|
||||||
|
msl.eMaxLev4,
|
||||||
|
msl.eMaxLev5,
|
||||||
|
)
|
||||||
|
elemHitShift = msl.hitShift
|
||||||
|
if (!elemType) elemType = msl.eType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawMin256 = baseEMin << elemHitShift
|
||||||
|
const rawMax256 = baseEMax << elemHitShift
|
||||||
|
const minElemDmg256 = Math.trunc((rawMin256 * (100 + eSynPct)) / 100)
|
||||||
|
const maxElemDmg256 = Math.trunc((rawMax256 * (100 + eSynPct)) / 100)
|
||||||
|
|
||||||
|
// 6. Duration (`auralencalc` or `ELen`..`ELevLen3` or 1.13c Assassin combo charge 375 frames)
|
||||||
|
let durationFrames = 0
|
||||||
|
if (skill.auraLenCalc) {
|
||||||
|
durationFrames = evaluateCalc(skill.auraLenCalc, calcCtx)
|
||||||
|
} else if (skill.eLen > 0 || skill.eLevLen1 > 0) {
|
||||||
|
const baseLen = compute3BandDuration(effLvl, skill.eLen, skill.eLevLen1, skill.eLevLen2, skill.eLevLen3)
|
||||||
|
const lenSyn = skill.eLenSymPerCalc ? evaluateCalc(skill.eLenSymPerCalc, calcCtx) : 0
|
||||||
|
durationFrames = Math.trunc((baseLen * (100 + lenSyn)) / 100)
|
||||||
|
} else if (skill.progressive) {
|
||||||
|
// 1.13c Assassin Martial Arts charge duration (`Param3 = 375` frames = 15s)
|
||||||
|
durationFrames = skill.param3 || 375
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Radius (`aurarangecalc` or `calc2`/`Param1`)
|
||||||
|
const radiusSubtiles = skill.auraRangeCalc
|
||||||
|
? evaluateCalc(skill.auraRangeCalc, calcCtx)
|
||||||
|
: skill.param1 > 0 && skill.param2 > 0
|
||||||
|
? evaluateCalc('ln12', calcCtx)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
// 8. State & Curse type
|
||||||
|
const stateName = skill.auraTgtState || skill.auraState || skill.passiveState || skill.state1
|
||||||
|
const stateRec = stateName ? registry.getStateByName(stateName) : undefined
|
||||||
|
const curseType =
|
||||||
|
stateRec?.cursetype ??
|
||||||
|
(skill.id === 86 ? 2 : skill.srvDoFunc === 30 || skill.id === 81 || skill.id === 264 || skill.id === 146 ? 1 : 0)
|
||||||
|
|
||||||
|
// 9. Pet max
|
||||||
|
const petMax = skill.petMax
|
||||||
|
? Math.max(1, evaluateCalc(skill.petMax, calcCtx))
|
||||||
|
: skill.petType.toLowerCase() === 'assassintrap'
|
||||||
|
? 5
|
||||||
|
: skill.summon
|
||||||
|
? 1
|
||||||
|
: 0
|
||||||
|
|
||||||
|
// 10. Cooldown (`delay` in frames, e.g. Hydra 40 frames in 1.13c)
|
||||||
|
const cooldownFrames = skill.delay ? evaluateCalc(skill.delay, calcCtx) : 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
skillId: skill.id,
|
||||||
|
name: skill.name,
|
||||||
|
charClass: skill.charClass || 'uni',
|
||||||
|
slvl: effLvl,
|
||||||
|
blvl,
|
||||||
|
manaCost,
|
||||||
|
manaCost256,
|
||||||
|
toHitBonusPct,
|
||||||
|
physDamagePct,
|
||||||
|
srcDam: skill.srcDam,
|
||||||
|
minPhysDmg: physMinScaled,
|
||||||
|
maxPhysDmg: physMaxScaled,
|
||||||
|
elemType: elemType || (skill.srcDam > 0 ? 'phys' : 'mag'),
|
||||||
|
minElemDmg: minElemDmg256 >> 8,
|
||||||
|
maxElemDmg: maxElemDmg256 >> 8,
|
||||||
|
minElemDmg256,
|
||||||
|
maxElemDmg256,
|
||||||
|
durationFrames,
|
||||||
|
radiusSubtiles,
|
||||||
|
missileA: primaryMissileName,
|
||||||
|
auraState: stateName,
|
||||||
|
curseType,
|
||||||
|
summonMon: skill.summon,
|
||||||
|
petMax,
|
||||||
|
synergyBonusPct,
|
||||||
|
cooldownFrames,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute any of the 221 player skills (`210` class skills + `11` universal skills) or `17` pet sub-skills
|
||||||
|
* in the 1.13c runtime (`srvdofunc` + `srvmissile` + `States.txt` + `PetType.txt` + `SUnitDmg`).
|
||||||
|
*/
|
||||||
|
export function executeSkill113c(ctx: SkillExecContext): SkillExecOutcome {
|
||||||
|
const customMod = REGISTERED_SKILL_MODULES.get(ctx.skill.id)
|
||||||
|
if (customMod?.executeDo) {
|
||||||
|
return customMod.executeDo(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
const { skill, evalResult, caster, targets, targetPositions, corpses, missileEngine, auraScanner, summonManager, currentTick, targetX, targetY } = ctx
|
||||||
|
const missilesSpawned: string[] = []
|
||||||
|
const statesApplied: string[] = []
|
||||||
|
const petsSummoned: string[] = []
|
||||||
|
let corpsesConsumed = 0
|
||||||
|
let totalDamageDealt = 0
|
||||||
|
const notes: string[] = []
|
||||||
|
|
||||||
|
// Edge Case #5: When `srvDoFunc === 0` and `srvMissile` is set (`Fire Ball` 47, `Fire Bolt` 36,
|
||||||
|
// `Magic Arrow` 6, `Bone Spear` 84, `Frozen Orb` 64, `Lightning Fury` 35, `Holy Bolt` 101, etc.),
|
||||||
|
// `D2Game` executes `SKILLS_SrvDoDefaultMissile` (mapped to `srvDoFunc = 68` missile launch).
|
||||||
|
const effectiveDoFunc =
|
||||||
|
skill.srvDoFunc === 0 && (skill.srvMissile !== '' || skill.srvMissileA !== '')
|
||||||
|
? 68
|
||||||
|
: skill.srvDoFunc
|
||||||
|
|
||||||
|
// 1. Passive skills (`passive === true`)
|
||||||
|
if (skill.passive) {
|
||||||
|
const pState = skill.passiveState || `passive_${skill.name.toLowerCase().replace(/\s+/g, '_')}`
|
||||||
|
const stats: Record<string, number> = {}
|
||||||
|
const calcCtx = {
|
||||||
|
registry: ctx.registry,
|
||||||
|
activeSkill: skill,
|
||||||
|
slvl: evalResult.slvl,
|
||||||
|
blvl: evalResult.blvl,
|
||||||
|
getBaseSkillLevel: (id: number) => caster.statList.getBaseSkillLevel(id),
|
||||||
|
getEffectiveSkillLevel: (id: number) => caster.statList.getEffectiveSkillLevel(id),
|
||||||
|
}
|
||||||
|
for (const [st, clc] of [
|
||||||
|
[skill.passiveStat1, skill.passiveCalc1],
|
||||||
|
[skill.passiveStat2, skill.passiveCalc2],
|
||||||
|
[skill.passiveStat3, skill.passiveCalc3],
|
||||||
|
[skill.passiveStat4, skill.passiveCalc4],
|
||||||
|
[skill.passiveStat5, skill.passiveCalc5],
|
||||||
|
] as const) {
|
||||||
|
if (st && clc) stats[st.toLowerCase()] = evaluateCalc(clc, calcCtx)
|
||||||
|
}
|
||||||
|
// Barbarian Weapon Masteries (`127..129`, `134..136`): 1.13c critical strike `dm56` (`0..35%`)
|
||||||
|
if ([127, 128, 129, 134, 135, 136].includes(skill.id)) {
|
||||||
|
stats['passive_mastery_crit'] = evaluateCalc('dm56', calcCtx)
|
||||||
|
}
|
||||||
|
caster.stateBus.applyState({
|
||||||
|
stateNameOrId: pState,
|
||||||
|
sourceUnitId: caster.id,
|
||||||
|
sourceSkillId: skill.id,
|
||||||
|
slvl: evalResult.slvl,
|
||||||
|
stats,
|
||||||
|
})
|
||||||
|
statesApplied.push(pState)
|
||||||
|
return {
|
||||||
|
skillId: skill.id,
|
||||||
|
name: skill.name,
|
||||||
|
executed: true,
|
||||||
|
srvDoFuncUsed: 0,
|
||||||
|
manaSpent256: 0,
|
||||||
|
missilesSpawned,
|
||||||
|
statesApplied,
|
||||||
|
petsSummoned,
|
||||||
|
corpsesConsumed,
|
||||||
|
totalDamageDealt,
|
||||||
|
notes: ['passive_attached'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Unsummon (`skillId = 3`, `srvDoFunc = 4`)
|
||||||
|
if (skill.id === 3 || effectiveDoFunc === 4) {
|
||||||
|
const dismissed = summonManager.unsummonPet()
|
||||||
|
if (dismissed) notes.push(`unsummoned:${dismissed.monId}`)
|
||||||
|
return {
|
||||||
|
skillId: skill.id,
|
||||||
|
name: skill.name,
|
||||||
|
executed: true,
|
||||||
|
srvDoFuncUsed: 4,
|
||||||
|
manaSpent256: evalResult.manaCost256,
|
||||||
|
missilesSpawned,
|
||||||
|
statesApplied,
|
||||||
|
petsSummoned,
|
||||||
|
corpsesConsumed,
|
||||||
|
totalDamageDealt,
|
||||||
|
notes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Summons, Golems, Traps, Hydras, Vines, Spirits, Shadows, Decoy, Valkyrie, Bone Wall/Prison
|
||||||
|
const isSummonSkill =
|
||||||
|
skill.summon !== '' ||
|
||||||
|
[15, 16, 31, 45, 49, 56, 57, 58, 60, 62, 114, 115, 119, 144].includes(effectiveDoFunc)
|
||||||
|
if (isSummonSkill) {
|
||||||
|
const countToCreate = skill.id === 62 ? 3 : skill.id === 78 || skill.id === 88 ? 3 : 1
|
||||||
|
for (let i = 0; i < countToCreate; i += 1) {
|
||||||
|
const res = summonManager.createPet({
|
||||||
|
owner: caster,
|
||||||
|
skillId: skill.id,
|
||||||
|
slvl: evalResult.slvl,
|
||||||
|
x: targetX + i * 16,
|
||||||
|
y: targetY,
|
||||||
|
...(i === 0 ? { corpses } : {}),
|
||||||
|
})
|
||||||
|
if (res.created && res.pet) {
|
||||||
|
petsSummoned.push(res.pet.monId)
|
||||||
|
if (skill.id === 70 || skill.id === 80 || skill.id === 95) {
|
||||||
|
corpsesConsumed += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
skillId: skill.id,
|
||||||
|
name: skill.name,
|
||||||
|
executed: petsSummoned.length > 0,
|
||||||
|
srvDoFuncUsed: effectiveDoFunc,
|
||||||
|
manaSpent256: evalResult.manaCost256,
|
||||||
|
missilesSpawned,
|
||||||
|
statesApplied,
|
||||||
|
petsSummoned,
|
||||||
|
corpsesConsumed,
|
||||||
|
totalDamageDealt,
|
||||||
|
notes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Paladin / Druid Auras (`aura === true` or `srvDoFunc === 65, 66, 81, 82`)
|
||||||
|
if (skill.aura || [65, 66, 81, 82].includes(effectiveDoFunc)) {
|
||||||
|
const src = auraScanner.setActiveAura(caster, skill.id, evalResult.slvl, currentTick)
|
||||||
|
if (src) {
|
||||||
|
const pulse = auraScanner.pulseAura(src, currentTick, [caster], targets, corpses)
|
||||||
|
totalDamageDealt += pulse.pulseDamageDealt
|
||||||
|
corpsesConsumed += pulse.corpsesRedeemed
|
||||||
|
statesApplied.push(skill.auraState || skill.auraTgtState || skill.name.toLowerCase())
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
skillId: skill.id,
|
||||||
|
name: skill.name,
|
||||||
|
executed: true,
|
||||||
|
srvDoFuncUsed: effectiveDoFunc,
|
||||||
|
manaSpent256: evalResult.manaCost256,
|
||||||
|
missilesSpawned,
|
||||||
|
statesApplied,
|
||||||
|
petsSummoned,
|
||||||
|
corpsesConsumed,
|
||||||
|
totalDamageDealt,
|
||||||
|
notes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Corpse Explosion (`74` / `312` `srvDoFunc = 55`), Poison Explosion (`83` `srvDoFunc = 63`),
|
||||||
|
// Find Potion (`131` `69`), Find Item (`142` `72`), Grim Ward (`150` `75`)
|
||||||
|
if ([55, 63, 69, 72, 75].includes(effectiveDoFunc)) {
|
||||||
|
const availCorpse = corpses.find(c => !c.consumed)
|
||||||
|
if (availCorpse) {
|
||||||
|
availCorpse.consumed = true
|
||||||
|
corpsesConsumed += 1
|
||||||
|
if (effectiveDoFunc === 55) {
|
||||||
|
// 1.13c Corpse Explosion: 70%–120% of base monster HP (50% Phys + 50% Fire)
|
||||||
|
for (const t of targets) {
|
||||||
|
if (t.statList.getHp256() <= 0) continue
|
||||||
|
const ce = computeCorpseExplosion113c({
|
||||||
|
corpseBaseHp: availCorpse.baseMaxHp ?? 1000,
|
||||||
|
targetPhysRes: t.statList.getAccruedStat('damageresist'),
|
||||||
|
targetFireRes: t.statList.getAccruedStat('fireresist'),
|
||||||
|
convictionPierce: t.statList.getModifierBonus('conviction_pierce'),
|
||||||
|
lowerResistPierce: t.statList.getModifierBonus('lowerresist_pierce'),
|
||||||
|
})
|
||||||
|
const dmg256 = ce.finalTotalDamage * FIXED_ONE
|
||||||
|
t.statList.setHp256(t.statList.getHp256() - dmg256)
|
||||||
|
totalDamageDealt += ce.finalTotalDamage
|
||||||
|
}
|
||||||
|
} else if (effectiveDoFunc === 63) {
|
||||||
|
// Poison Explosion cloud
|
||||||
|
const msl = missileEngine.spawnMissile({
|
||||||
|
missileNameOrId: skill.srvMissile || 'poisonexplosioncloud',
|
||||||
|
sourceSkillId: skill.id,
|
||||||
|
slvl: evalResult.slvl,
|
||||||
|
owner: caster,
|
||||||
|
startX: targetX,
|
||||||
|
startY: targetY,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
dmgPacket: {
|
||||||
|
skillId: skill.id,
|
||||||
|
attackKind: 'spell',
|
||||||
|
elemType: 'pois',
|
||||||
|
elemMin256: Math.max(FIXED_ONE * 40, evalResult.minElemDmg256),
|
||||||
|
elemMax256: Math.max(FIXED_ONE * 60, evalResult.maxElemDmg256),
|
||||||
|
autoHit: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (msl) missilesSpawned.push(msl.name)
|
||||||
|
} else if (effectiveDoFunc === 75) {
|
||||||
|
// Grim Ward fear totem
|
||||||
|
for (const t of targets) {
|
||||||
|
if (t.statList.getHp256() <= 0) continue
|
||||||
|
t.stateBus.applyState({ stateNameOrId: 'terror', slvl: evalResult.slvl, durationFrames: 250 })
|
||||||
|
statesApplied.push('terror')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
notes.push(effectiveDoFunc === 69 ? 'potion_found' : 'item_found')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
skillId: skill.id,
|
||||||
|
name: skill.name,
|
||||||
|
executed: corpsesConsumed > 0,
|
||||||
|
srvDoFuncUsed: effectiveDoFunc,
|
||||||
|
manaSpent256: evalResult.manaCost256,
|
||||||
|
missilesSpawned,
|
||||||
|
statesApplied,
|
||||||
|
petsSummoned,
|
||||||
|
corpsesConsumed,
|
||||||
|
totalDamageDealt,
|
||||||
|
notes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Curses & AoE Debuffs (`srvDoFunc = 6, 30, 47, 59, 61, 71`)
|
||||||
|
if ([6, 30, 47, 59, 61, 71].includes(effectiveDoFunc)) {
|
||||||
|
const curseState =
|
||||||
|
skill.auraTgtState ||
|
||||||
|
skill.auraState ||
|
||||||
|
(skill.id === 86 ? 'attract' : skill.id === 81 ? 'confuse' : skill.name.toLowerCase().replace(/\s+/g, ''))
|
||||||
|
|
||||||
|
const calcCtx = {
|
||||||
|
registry: ctx.registry,
|
||||||
|
activeSkill: skill,
|
||||||
|
slvl: evalResult.slvl,
|
||||||
|
blvl: evalResult.blvl,
|
||||||
|
getBaseSkillLevel: (id: number) => caster.statList.getBaseSkillLevel(id),
|
||||||
|
getEffectiveSkillLevel: (id: number) => caster.statList.getEffectiveSkillLevel(id),
|
||||||
|
}
|
||||||
|
const debuffStats: Record<string, number> = {}
|
||||||
|
if (skill.id === 66) debuffStats['amplify_phys_pierce'] = 100
|
||||||
|
if (skill.id === 87) {
|
||||||
|
debuffStats['amplify_phys_pierce'] = 50
|
||||||
|
debuffStats['item_slow'] = 50
|
||||||
|
}
|
||||||
|
if (skill.id === 91) {
|
||||||
|
debuffStats['lowerresist_pierce'] = evaluateCalc('dm56', calcCtx)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const t of targets) {
|
||||||
|
if (t.statList.getHp256() <= 0) continue
|
||||||
|
const res = t.stateBus.applyState({
|
||||||
|
stateNameOrId: curseState,
|
||||||
|
sourceUnitId: caster.id,
|
||||||
|
sourceSkillId: skill.id,
|
||||||
|
slvl: evalResult.slvl,
|
||||||
|
durationFrames: evalResult.durationFrames || 250,
|
||||||
|
currentFrame: currentTick,
|
||||||
|
stats: debuffStats,
|
||||||
|
curseTypeOverride: skill.id === 86 ? 2 : 1,
|
||||||
|
})
|
||||||
|
if (res.applied) {
|
||||||
|
statesApplied.push(res.stateName)
|
||||||
|
}
|
||||||
|
if (skill.id === 86) break // Attract targets a single monster
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
skillId: skill.id,
|
||||||
|
name: skill.name,
|
||||||
|
executed: true,
|
||||||
|
srvDoFuncUsed: effectiveDoFunc,
|
||||||
|
manaSpent256: evalResult.manaCost256,
|
||||||
|
missilesSpawned,
|
||||||
|
statesApplied,
|
||||||
|
petsSummoned,
|
||||||
|
corpsesConsumed,
|
||||||
|
totalDamageDealt,
|
||||||
|
notes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Self/Ally Buffs & Shapeshift Toggles (`srvDoFunc = 18, 23, 25, 29, 54, 113, 116`)
|
||||||
|
if ([18, 23, 25, 29, 54, 113, 116].includes(effectiveDoFunc)) {
|
||||||
|
const buffState =
|
||||||
|
skill.auraState ||
|
||||||
|
skill.auraTgtState ||
|
||||||
|
(skill.id === 223 ? 'wolf' : skill.id === 228 ? 'bear' : skill.name.toLowerCase().replace(/\s+/g, ''))
|
||||||
|
const stats: Record<string, number> = {}
|
||||||
|
// 1.13c Werebear (`228`): +50% HP (+Lycanthropy), +55%+(lvl-1)*8% ED, +25%+(lvl-1)*6% Defense
|
||||||
|
if (skill.id === 228) {
|
||||||
|
stats['item_maxhp_percent'] = 50 + caster.statList.getEffectiveSkillLevel(224) * 20
|
||||||
|
stats['damagepercent'] = 55 + (evalResult.slvl - 1) * 8
|
||||||
|
stats['item_armor_percent'] = 25 + (evalResult.slvl - 1) * 6
|
||||||
|
caster.stateBus.removeState('wolf')
|
||||||
|
} else if (skill.id === 223) {
|
||||||
|
stats['item_maxhp_percent'] = 25 + caster.statList.getEffectiveSkillLevel(224) * 20
|
||||||
|
stats['skill_ias'] = Math.min(70, 20 + evalResult.slvl * 3)
|
||||||
|
caster.stateBus.removeState('bear')
|
||||||
|
} else if (skill.id === 278) {
|
||||||
|
// Venom (`278`): 10-frame poison clamp + bitrate
|
||||||
|
stats['venom_bitrate_256'] = Math.max(25 * FIXED_ONE, Math.trunc(evalResult.minElemDmg256 / 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
caster.stateBus.applyState({
|
||||||
|
stateNameOrId: buffState,
|
||||||
|
sourceUnitId: caster.id,
|
||||||
|
sourceSkillId: skill.id,
|
||||||
|
slvl: evalResult.slvl,
|
||||||
|
durationFrames: evalResult.durationFrames || 3000,
|
||||||
|
currentFrame: currentTick,
|
||||||
|
stats,
|
||||||
|
})
|
||||||
|
statesApplied.push(buffState)
|
||||||
|
|
||||||
|
// Blaze (`46`) / Thunder Storm (`57`) / Blade Shield (`277`) also deal immediate or trail damage
|
||||||
|
if (skill.id === 46 || skill.id === 57 || skill.id === 277) {
|
||||||
|
const alive = targets.find(t => t.statList.getHp256() > 0)
|
||||||
|
if (alive) {
|
||||||
|
const out = executeSUnitDmg(caster, alive, {
|
||||||
|
skillId: skill.id,
|
||||||
|
attackKind: skill.id === 277 ? 'melee' : 'spell',
|
||||||
|
srcDam: skill.srcDam,
|
||||||
|
elemType: evalResult.elemType,
|
||||||
|
elemMin256: Math.max(FIXED_ONE * 15, evalResult.minElemDmg256),
|
||||||
|
elemMax256: Math.max(FIXED_ONE * 25, evalResult.maxElemDmg256),
|
||||||
|
autoHit: true,
|
||||||
|
})
|
||||||
|
totalDamageDealt += out.totalDamage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
skillId: skill.id,
|
||||||
|
name: skill.name,
|
||||||
|
executed: true,
|
||||||
|
srvDoFuncUsed: effectiveDoFunc,
|
||||||
|
manaSpent256: evalResult.manaCost256,
|
||||||
|
missilesSpawned,
|
||||||
|
statesApplied,
|
||||||
|
petsSummoned,
|
||||||
|
corpsesConsumed,
|
||||||
|
totalDamageDealt,
|
||||||
|
notes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Teleport (`54`, `srvDoFunc = 27`)
|
||||||
|
if (skill.id === 54 || effectiveDoFunc === 27) {
|
||||||
|
notes.push(`teleported:${String(Math.round(targetX))},${String(Math.round(targetY))}`)
|
||||||
|
return {
|
||||||
|
skillId: skill.id,
|
||||||
|
name: skill.name,
|
||||||
|
executed: true,
|
||||||
|
srvDoFuncUsed: 27,
|
||||||
|
manaSpent256: evalResult.manaCost256,
|
||||||
|
missilesSpawned,
|
||||||
|
statesApplied,
|
||||||
|
petsSummoned,
|
||||||
|
corpsesConsumed,
|
||||||
|
totalDamageDealt,
|
||||||
|
notes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Warcries (`Shout` 138, `Battle Cry` 146, `Battle Orders` 149, `War Cry` 154, `Battle Command` 155)
|
||||||
|
// and Radial Novas (`Frost Nova` 44, `Nova` 48, `Poison Nova` 92, `Howl` 130, `Static Field` 42)
|
||||||
|
if ([20, 22, 68].includes(effectiveDoFunc) && (skill.charClass === 'bar' || [42, 44, 48, 92, 130].includes(skill.id))) {
|
||||||
|
if (skill.id === 138 || skill.id === 149 || skill.id === 155) {
|
||||||
|
const stateName = skill.auraState || skill.name.toLowerCase().replace(/\s+/g, '')
|
||||||
|
const stats: Record<string, number> = {}
|
||||||
|
if (skill.id === 138) stats['item_armor_percent'] = 100 + (evalResult.slvl - 1) * 10
|
||||||
|
if (skill.id === 149) {
|
||||||
|
const pct = 35 + (evalResult.slvl - 1) * 3
|
||||||
|
stats['item_maxhp_percent'] = pct
|
||||||
|
stats['item_maxmana_percent'] = pct
|
||||||
|
}
|
||||||
|
if (skill.id === 155) stats['item_allskills'] = 1
|
||||||
|
caster.stateBus.applyState({
|
||||||
|
stateNameOrId: stateName,
|
||||||
|
sourceUnitId: caster.id,
|
||||||
|
sourceSkillId: skill.id,
|
||||||
|
slvl: evalResult.slvl,
|
||||||
|
durationFrames: evalResult.durationFrames || 3000,
|
||||||
|
currentFrame: currentTick,
|
||||||
|
stats,
|
||||||
|
})
|
||||||
|
statesApplied.push(stateName)
|
||||||
|
return {
|
||||||
|
skillId: skill.id,
|
||||||
|
name: skill.name,
|
||||||
|
executed: true,
|
||||||
|
srvDoFuncUsed: effectiveDoFunc,
|
||||||
|
manaSpent256: evalResult.manaCost256,
|
||||||
|
missilesSpawned,
|
||||||
|
statesApplied,
|
||||||
|
petsSummoned,
|
||||||
|
corpsesConsumed,
|
||||||
|
totalDamageDealt,
|
||||||
|
notes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skill.id === 146) {
|
||||||
|
for (const t of targets) {
|
||||||
|
if (t.statList.getHp256() <= 0) continue
|
||||||
|
t.stateBus.applyState({
|
||||||
|
stateNameOrId: 'battlecry',
|
||||||
|
slvl: evalResult.slvl,
|
||||||
|
durationFrames: 300,
|
||||||
|
stats: { item_armor_percent: -(50 + evalResult.slvl * 2) },
|
||||||
|
curseTypeOverride: 1,
|
||||||
|
})
|
||||||
|
statesApplied.push('battlecry')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
skillId: skill.id,
|
||||||
|
name: skill.name,
|
||||||
|
executed: true,
|
||||||
|
srvDoFuncUsed: effectiveDoFunc,
|
||||||
|
manaSpent256: evalResult.manaCost256,
|
||||||
|
missilesSpawned,
|
||||||
|
statesApplied,
|
||||||
|
petsSummoned,
|
||||||
|
corpsesConsumed,
|
||||||
|
totalDamageDealt,
|
||||||
|
notes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static Field (`42`), Frost Nova (`44`), Nova (`48`), Poison Nova (`92`), War Cry (`154`), Howl (`130`)
|
||||||
|
for (const t of targets) {
|
||||||
|
if (t.statList.getHp256() <= 0) continue
|
||||||
|
if (skill.id === 130) {
|
||||||
|
t.stateBus.applyState({ stateNameOrId: 'terror', slvl: evalResult.slvl, durationFrames: 150 })
|
||||||
|
statesApplied.push('terror')
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const out = executeSUnitDmg(caster, t, {
|
||||||
|
skillId: skill.id,
|
||||||
|
attackKind: 'spell',
|
||||||
|
flatPhysMin256: evalResult.minPhysDmg * FIXED_ONE,
|
||||||
|
flatPhysMax256: evalResult.maxPhysDmg * FIXED_ONE,
|
||||||
|
elemType: evalResult.elemType,
|
||||||
|
elemMin256: evalResult.minElemDmg256,
|
||||||
|
elemMax256: evalResult.maxElemDmg256,
|
||||||
|
autoHit: true,
|
||||||
|
})
|
||||||
|
totalDamageDealt += out.totalDamage
|
||||||
|
}
|
||||||
|
if (evalResult.missileA) {
|
||||||
|
const m = missileEngine.spawnMissile({
|
||||||
|
missileNameOrId: evalResult.missileA,
|
||||||
|
sourceSkillId: skill.id,
|
||||||
|
slvl: evalResult.slvl,
|
||||||
|
owner: caster,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
targetX,
|
||||||
|
targetY,
|
||||||
|
dmgPacket: {
|
||||||
|
skillId: skill.id,
|
||||||
|
attackKind: 'spell',
|
||||||
|
elemType: evalResult.elemType,
|
||||||
|
elemMin256: evalResult.minElemDmg256,
|
||||||
|
elemMax256: evalResult.maxElemDmg256,
|
||||||
|
autoHit: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (m) missilesSpawned.push(m.name)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
skillId: skill.id,
|
||||||
|
name: skill.name,
|
||||||
|
executed: true,
|
||||||
|
srvDoFuncUsed: effectiveDoFunc,
|
||||||
|
manaSpent256: evalResult.manaCost256,
|
||||||
|
missilesSpawned,
|
||||||
|
statesApplied,
|
||||||
|
petsSummoned,
|
||||||
|
corpsesConsumed,
|
||||||
|
totalDamageDealt,
|
||||||
|
notes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Missile / Ground Spawner / Ranged Skills (`srvDoFunc = 3, 5, 8, 10, 12, 17, 19, 24, 26, 28, 43, 44, 48, 68, 73, 74, 80, 117, 118, 123, 124`)
|
||||||
|
const isMissileSkill =
|
||||||
|
evalResult.missileA !== '' &&
|
||||||
|
(skill.range === 'none' ||
|
||||||
|
skill.range === 'rng' ||
|
||||||
|
skill.range === 'both' ||
|
||||||
|
[3, 5, 8, 10, 12, 17, 19, 24, 26, 28, 43, 44, 48, 68, 73, 74, 80, 117, 118, 123, 124].includes(effectiveDoFunc))
|
||||||
|
|
||||||
|
if (isMissileSkill) {
|
||||||
|
const projectileCount =
|
||||||
|
effectiveDoFunc === 8 || effectiveDoFunc === 17
|
||||||
|
? Math.min(24, 3 + evalResult.slvl)
|
||||||
|
: effectiveDoFunc === 117
|
||||||
|
? 3
|
||||||
|
: 1
|
||||||
|
|
||||||
|
const dmgPacket = {
|
||||||
|
skillId: skill.id,
|
||||||
|
attackKind: (skill.srcDam > 0 ? 'missile' : 'spell') as 'missile' | 'spell',
|
||||||
|
srcDam: skill.srcDam,
|
||||||
|
physDamagePct: evalResult.physDamagePct,
|
||||||
|
flatPhysMin256: evalResult.minPhysDmg * FIXED_ONE,
|
||||||
|
flatPhysMax256: evalResult.maxPhysDmg * FIXED_ONE,
|
||||||
|
elemType: evalResult.elemType,
|
||||||
|
elemMin256: evalResult.minElemDmg256,
|
||||||
|
elemMax256: evalResult.maxElemDmg256,
|
||||||
|
autoHit: skill.srcDam === 0 || skill.id === 22,
|
||||||
|
}
|
||||||
|
|
||||||
|
const piercePct =
|
||||||
|
caster.statList.getEffectiveSkillLevel(33) > 0
|
||||||
|
? Math.min(95, 25 + caster.statList.getEffectiveSkillLevel(33) * 3)
|
||||||
|
: 0
|
||||||
|
|
||||||
|
for (let i = 0; i < projectileCount; i += 1) {
|
||||||
|
const spreadOffset = (i - (projectileCount - 1) / 2) * 14
|
||||||
|
const m = missileEngine.spawnMissile({
|
||||||
|
missileNameOrId: evalResult.missileA,
|
||||||
|
sourceSkillId: skill.id,
|
||||||
|
slvl: evalResult.slvl,
|
||||||
|
owner: caster,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
targetX: targetX + spreadOffset,
|
||||||
|
targetY,
|
||||||
|
dmgPacket,
|
||||||
|
pierceChancePct: piercePct,
|
||||||
|
})
|
||||||
|
if (m) missilesSpawned.push(m.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance missile engine so immediate or close-range missiles land and record damage
|
||||||
|
const stepRes = missileEngine.tick(currentTick, targets, targetPositions)
|
||||||
|
for (const h of stepRes.hits) {
|
||||||
|
totalDamageDealt += h.damage
|
||||||
|
}
|
||||||
|
for (const sub of stepRes.subMissilesSpawned) {
|
||||||
|
missilesSpawned.push(sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For stationary ground controllers (`Fire Wall` 51, `Meteor` 56, `Blizzard` 59, `Fissure` 234, `Volcano` 244, `Armageddon` 249, `Hurricane` 250),
|
||||||
|
// also apply the first ground tick damage to targets in the impact zone
|
||||||
|
if (totalDamageDealt === 0 && [24, 28, 123, 124].includes(effectiveDoFunc)) {
|
||||||
|
const primaryTarget = targets.find(t => t.statList.getHp256() > 0)
|
||||||
|
if (primaryTarget) {
|
||||||
|
const out = executeSUnitDmg(caster, primaryTarget, dmgPacket)
|
||||||
|
totalDamageDealt += out.totalDamage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
skillId: skill.id,
|
||||||
|
name: skill.name,
|
||||||
|
executed: true,
|
||||||
|
srvDoFuncUsed: effectiveDoFunc,
|
||||||
|
manaSpent256: evalResult.manaCost256,
|
||||||
|
missilesSpawned,
|
||||||
|
statesApplied,
|
||||||
|
petsSummoned,
|
||||||
|
corpsesConsumed,
|
||||||
|
totalDamageDealt,
|
||||||
|
notes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. Melee, Martial Arts Charge-up / Finisher, Multi-Hit (`Zeal`, `Fend`, `Fury`, `Jab`, `Frenzy`, `Whirlwind`, `Smite`, `Sacrifice`, `Berserk`)
|
||||||
|
const primaryTarget = targets.find(t => t.statList.getHp256() > 0)
|
||||||
|
if (primaryTarget) {
|
||||||
|
// Assassin Martial Arts progressive charge-up (`progressive === true`, `srvDoFunc = 34, 35`)
|
||||||
|
// CRITICAL 1.13c RULE (`patch.txt`): Combo charges last 15 seconds (`375` frames)!
|
||||||
|
if (skill.progressive) {
|
||||||
|
const chargeState = skill.auraState || skill.name.toLowerCase().replace(/\s+/g, '')
|
||||||
|
caster.stateBus.applyState({
|
||||||
|
stateNameOrId: chargeState,
|
||||||
|
sourceUnitId: caster.id,
|
||||||
|
sourceSkillId: skill.id,
|
||||||
|
slvl: evalResult.slvl,
|
||||||
|
durationFrames: 375,
|
||||||
|
currentFrame: currentTick,
|
||||||
|
stats: { progressive_charges: 3 },
|
||||||
|
})
|
||||||
|
statesApplied.push(chargeState)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hitCount =
|
||||||
|
[7, 13, 42, 46, 70, 76].includes(effectiveDoFunc)
|
||||||
|
? Math.min(5, Math.max(2, 1 + Math.trunc(evalResult.slvl / 4)))
|
||||||
|
: 1
|
||||||
|
|
||||||
|
for (let h = 0; h < hitCount; h += 1) {
|
||||||
|
if (primaryTarget.statList.getHp256() <= 0) break
|
||||||
|
const out = executeSUnitDmg(caster, primaryTarget, {
|
||||||
|
skillId: skill.id,
|
||||||
|
attackKind: 'melee',
|
||||||
|
srcDam: skill.srcDam || 128,
|
||||||
|
physDamagePct: evalResult.physDamagePct,
|
||||||
|
flatPhysMin256: evalResult.minPhysDmg * FIXED_ONE,
|
||||||
|
flatPhysMax256: evalResult.maxPhysDmg * FIXED_ONE,
|
||||||
|
elemType: evalResult.elemType,
|
||||||
|
elemMin256: evalResult.minElemDmg256,
|
||||||
|
elemMax256: evalResult.maxElemDmg256,
|
||||||
|
physToElemPct: skill.id === 152 ? 100 : 0, // Berserk converts 100% physical to Magic
|
||||||
|
autoHit: skill.id === 97, // Smite (`97`) always hits
|
||||||
|
unblockable: skill.id === 97,
|
||||||
|
})
|
||||||
|
totalDamageDealt += out.totalDamage
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sacrifice (`96`): deducts 8% of damage dealt from caster HP
|
||||||
|
if (skill.id === 96 && totalDamageDealt > 0) {
|
||||||
|
const selfCost256 = Math.trunc((totalDamageDealt * 8 * FIXED_ONE) / 100)
|
||||||
|
caster.statList.setHp256(Math.max(FIXED_ONE, caster.statList.getHp256() - selfCost256))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Charged Strike (`24`) / Lightning Strike (`34`): also spawns radial/chaining lightning bolts on melee hit
|
||||||
|
if ((skill.id === 24 || skill.id === 34) && evalResult.missileA) {
|
||||||
|
const m = missileEngine.spawnMissile({
|
||||||
|
missileNameOrId: evalResult.missileA,
|
||||||
|
sourceSkillId: skill.id,
|
||||||
|
slvl: evalResult.slvl,
|
||||||
|
owner: caster,
|
||||||
|
startX: 0,
|
||||||
|
startY: 120,
|
||||||
|
targetX: 70,
|
||||||
|
targetY: 120,
|
||||||
|
dmgPacket: {
|
||||||
|
skillId: skill.id,
|
||||||
|
attackKind: 'spell',
|
||||||
|
elemType: 'ltng',
|
||||||
|
elemMin256: evalResult.minElemDmg256,
|
||||||
|
elemMax256: evalResult.maxElemDmg256,
|
||||||
|
autoHit: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (m) missilesSpawned.push(m.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
skillId: skill.id,
|
||||||
|
name: skill.name,
|
||||||
|
executed: true,
|
||||||
|
srvDoFuncUsed: effectiveDoFunc,
|
||||||
|
manaSpent256: evalResult.manaCost256,
|
||||||
|
missilesSpawned,
|
||||||
|
statesApplied,
|
||||||
|
petsSummoned,
|
||||||
|
corpsesConsumed,
|
||||||
|
totalDamageDealt,
|
||||||
|
notes,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,94 @@
|
||||||
|
/**
|
||||||
|
* Diablo II: Lord of Destruction v1.13c Skill Module Interface Contracts.
|
||||||
|
*
|
||||||
|
* Matches `PROJECT.md` Interface Contracts (`src/game/engine/` <-> `src/game/skills/`).
|
||||||
|
*/
|
||||||
|
import type { D2DataRegistry, SkillRecord } from '../engine/data-registry.ts'
|
||||||
|
import type { UnitStatList } from '../engine/stat-list.ts'
|
||||||
|
import type { StateBus } from '../engine/state-bus.ts'
|
||||||
|
import type { CombatUnitContext } from '../engine/combat-pipeline.ts'
|
||||||
|
import type { MissileEngine } from '../engine/missile-engine.ts'
|
||||||
|
import type { AuraScanner } from '../engine/aura-scanner.ts'
|
||||||
|
import type { SummonManager } from '../engine/summon-manager.ts'
|
||||||
|
|
||||||
|
export interface SkillEvalResult {
|
||||||
|
readonly skillId: number
|
||||||
|
readonly name: string
|
||||||
|
readonly charClass: string
|
||||||
|
readonly slvl: number
|
||||||
|
readonly blvl: number
|
||||||
|
/** Human-readable mana cost (and `manaCost256` in 256-fixed-point). */
|
||||||
|
readonly manaCost: number
|
||||||
|
readonly manaCost256: number
|
||||||
|
readonly toHitBonusPct: number
|
||||||
|
readonly physDamagePct: number
|
||||||
|
/** 128 = 100% weapon damage. */
|
||||||
|
readonly srcDam: number
|
||||||
|
readonly minPhysDmg: number
|
||||||
|
readonly maxPhysDmg: number
|
||||||
|
readonly elemType: string
|
||||||
|
/** Human-readable elemental/magic/poison damage (and 256-fixed-point). */
|
||||||
|
readonly minElemDmg: number
|
||||||
|
readonly maxElemDmg: number
|
||||||
|
readonly minElemDmg256: number
|
||||||
|
readonly maxElemDmg256: number
|
||||||
|
readonly durationFrames: number
|
||||||
|
readonly radiusSubtiles: number
|
||||||
|
readonly missileA: string
|
||||||
|
readonly auraState: string
|
||||||
|
readonly curseType: number
|
||||||
|
readonly summonMon: string
|
||||||
|
readonly petMax: number
|
||||||
|
readonly synergyBonusPct: number
|
||||||
|
readonly cooldownFrames: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillEvalContext {
|
||||||
|
readonly registry: D2DataRegistry
|
||||||
|
readonly skill: SkillRecord
|
||||||
|
readonly slvl: number
|
||||||
|
readonly blvl: number
|
||||||
|
readonly statList: UnitStatList
|
||||||
|
readonly stateBus?: StateBus
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillExecContext {
|
||||||
|
readonly registry: D2DataRegistry
|
||||||
|
readonly skill: SkillRecord
|
||||||
|
readonly evalResult: SkillEvalResult
|
||||||
|
readonly caster: CombatUnitContext
|
||||||
|
readonly targets: readonly CombatUnitContext[]
|
||||||
|
readonly targetPositions: ReadonlyMap<string, { x: number; y: number }>
|
||||||
|
readonly corpses: { id: string; consumed: boolean; baseMaxHp?: number; x?: number; y?: number }[]
|
||||||
|
readonly missileEngine: MissileEngine
|
||||||
|
readonly auraScanner: AuraScanner
|
||||||
|
readonly summonManager: SummonManager
|
||||||
|
readonly currentTick: number
|
||||||
|
readonly targetX: number
|
||||||
|
readonly targetY: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillExecOutcome {
|
||||||
|
readonly skillId: number
|
||||||
|
readonly name: string
|
||||||
|
readonly executed: boolean
|
||||||
|
readonly srvDoFuncUsed: number
|
||||||
|
readonly manaSpent256: number
|
||||||
|
readonly missilesSpawned: string[]
|
||||||
|
readonly statesApplied: string[]
|
||||||
|
readonly petsSummoned: string[]
|
||||||
|
readonly corpsesConsumed: number
|
||||||
|
readonly totalDamageDealt: number
|
||||||
|
readonly notes: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillModule {
|
||||||
|
readonly skillId: number
|
||||||
|
readonly name: string
|
||||||
|
/** 'uni' | 'ama' | 'sor' | 'nec' | 'pal' | 'bar' | 'dru' | 'ass' */
|
||||||
|
readonly charClass: string
|
||||||
|
readonly srvStFunc: number
|
||||||
|
readonly srvDoFunc: number
|
||||||
|
evaluate?(ctx: SkillEvalContext): SkillEvalResult
|
||||||
|
executeDo?(ctx: SkillExecContext): SkillExecOutcome
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,183 @@
|
||||||
|
/**
|
||||||
|
* Diablo II: Lord of Destruction v1.13c Unified 7-Class, Missile, Overlay & Summon Sprite Loader (`UnitSpriteLoader`).
|
||||||
|
*
|
||||||
|
* - Maps all 7 player classes (`ama`->`am`, `sor`->`so`, `nec`->`ne`, `pal`->`pa`, `bar`->`ba`, `dru`->`dz`, `ass`->`ai`)
|
||||||
|
* across `d2char.mpq` and `d2exp.mpq` with 16 composite layers (`hd..s8`).
|
||||||
|
* - Decodes missile `.dcc` sprites (`data\global\missiles\<CelFile>.dcc`) via `decodeDcc`.
|
||||||
|
* - Decodes state/cast overlay `.dcc`/`.dc6` sprites (`data\global\overlays\<Filename>.dcc` or `.dc6`)
|
||||||
|
* and distinguishes `PreDraw === true` (ground aura rings) vs `PreDraw === false` (overhead curses/flashes).
|
||||||
|
* - Decodes summon/trap/morph monster sprites (`data\global\monsters\<Code>\`).
|
||||||
|
*/
|
||||||
|
import type { MountedArchives } from '../mpq/mount.ts'
|
||||||
|
import { decodeDcc } from '../formats/dcc.ts'
|
||||||
|
import { decodeDc6 } from '../formats/dc6.ts'
|
||||||
|
import type { SpriteFrame, SpriteSheet } from '../formats/sprite.ts'
|
||||||
|
import { loadCharacterSheet, type CharacterSheet } from '../game/character.ts'
|
||||||
|
|
||||||
|
export const CLASS_CODE_TO_CHAR_TOKEN: Readonly<Record<string, string>> = {
|
||||||
|
ama: 'am',
|
||||||
|
am: 'am',
|
||||||
|
amazon: 'am',
|
||||||
|
sor: 'so',
|
||||||
|
so: 'so',
|
||||||
|
sorceress: 'so',
|
||||||
|
nec: 'ne',
|
||||||
|
ne: 'ne',
|
||||||
|
necromancer: 'ne',
|
||||||
|
pal: 'pa',
|
||||||
|
pa: 'pa',
|
||||||
|
paladin: 'pa',
|
||||||
|
bar: 'ba',
|
||||||
|
ba: 'ba',
|
||||||
|
barbarian: 'ba',
|
||||||
|
dru: 'dz',
|
||||||
|
dz: 'dz',
|
||||||
|
druid: 'dz',
|
||||||
|
ass: 'ai',
|
||||||
|
ai: 'ai',
|
||||||
|
assassin: 'ai',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePlayerCharToken(classOrToken: string): string {
|
||||||
|
const key = classOrToken.trim().toLowerCase()
|
||||||
|
return CLASS_CODE_TO_CHAR_TOKEN[key] ?? 'so'
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnitSpriteLoader {
|
||||||
|
readonly archives: MountedArchives
|
||||||
|
private readonly charSheetCache = new Map<string, Promise<CharacterSheet>>()
|
||||||
|
private readonly missileSheetCache = new Map<string, Promise<SpriteSheet>>()
|
||||||
|
private readonly overlaySheetCache = new Map<string, Promise<SpriteSheet>>()
|
||||||
|
private readonly monsterSheetCache = new Map<string, Promise<CharacterSheet>>()
|
||||||
|
|
||||||
|
constructor(archives: MountedArchives) {
|
||||||
|
this.archives = archives
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and cache a composite character sheet for any of the 7 hero classes (`am`, `so`, `ne`, `pa`, `ba`, `dz`, `ai`).
|
||||||
|
*/
|
||||||
|
loadPlayerClassSheet(
|
||||||
|
classOrToken: string,
|
||||||
|
mode = 'sc',
|
||||||
|
weaponClass = 'hth',
|
||||||
|
): Promise<CharacterSheet> {
|
||||||
|
const token = resolvePlayerCharToken(classOrToken)
|
||||||
|
const anim = mode.trim().toLowerCase()
|
||||||
|
const wclass = weaponClass.trim().toLowerCase()
|
||||||
|
const key = `${token}:${anim}:${wclass}`
|
||||||
|
|
||||||
|
const existing = this.charSheetCache.get(key)
|
||||||
|
if (existing) return existing
|
||||||
|
|
||||||
|
const promise = loadCharacterSheet(this.archives, token, anim, wclass)
|
||||||
|
this.charSheetCache.set(key, promise)
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and cache a missile sprite (`data\global\missiles\<celFile>.dcc`).
|
||||||
|
*/
|
||||||
|
loadMissileSprite(celFile: string): Promise<SpriteSheet> {
|
||||||
|
const clean = celFile.trim().replace(/\.(dcc|dc6)$/i, '')
|
||||||
|
const key = clean.toLowerCase()
|
||||||
|
const existing = this.missileSheetCache.get(key)
|
||||||
|
if (existing) return existing
|
||||||
|
|
||||||
|
const promise = (async (): Promise<SpriteSheet> => {
|
||||||
|
const dccPath = `data\\global\\missiles\\${clean}.dcc`
|
||||||
|
const bytes = await this.archives.read(dccPath)
|
||||||
|
const dcc = decodeDcc(bytes)
|
||||||
|
const groups = dcc.directions.map(dir => ({
|
||||||
|
frames: dir.frames.map(
|
||||||
|
(f): SpriteFrame => ({
|
||||||
|
width: f.frame.width,
|
||||||
|
height: f.frame.height,
|
||||||
|
indices: f.frame.indices,
|
||||||
|
mask: f.frame.mask,
|
||||||
|
anchorX: dir.box.left,
|
||||||
|
anchorY: dir.box.top,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
return { groups, width: null }
|
||||||
|
})()
|
||||||
|
|
||||||
|
this.missileSheetCache.set(key, promise)
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and cache a state/cast overlay sprite (`data\global\overlays\<filename>.dcc` with `.dc6` fallback).
|
||||||
|
* Accepts either an `Overlay.txt` `Filename` (`CurseAmplifyDamageEffect`) or `overlay` ID (`curseamplifydamage`).
|
||||||
|
*/
|
||||||
|
loadOverlaySprite(filenameOrOverlayId: string): Promise<SpriteSheet> {
|
||||||
|
const clean = filenameOrOverlayId.trim().replace(/\.(dcc|dc6)$/i, '')
|
||||||
|
const key = clean.toLowerCase()
|
||||||
|
const existing = this.overlaySheetCache.get(key)
|
||||||
|
if (existing) return existing
|
||||||
|
|
||||||
|
const promise = (async (): Promise<SpriteSheet> => {
|
||||||
|
const candidates = [
|
||||||
|
clean,
|
||||||
|
key === 'cursampdamage' || key === 'curseamplifydamage' ? 'CurseAmplifyDamageEffect' : '',
|
||||||
|
'AuraFanatic',
|
||||||
|
].filter(Boolean)
|
||||||
|
|
||||||
|
for (const cand of candidates) {
|
||||||
|
const dccPath = `data\\global\\overlays\\${cand}.dcc`
|
||||||
|
if (this.archives.find(dccPath)) {
|
||||||
|
const bytes = await this.archives.read(dccPath)
|
||||||
|
const dcc = decodeDcc(bytes)
|
||||||
|
const groups = dcc.directions.map(dir => ({
|
||||||
|
frames: dir.frames.map(
|
||||||
|
(f): SpriteFrame => ({
|
||||||
|
width: f.frame.width,
|
||||||
|
height: f.frame.height,
|
||||||
|
indices: f.frame.indices,
|
||||||
|
mask: f.frame.mask,
|
||||||
|
anchorX: dir.box.left,
|
||||||
|
anchorY: dir.box.top,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
return { groups, width: null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dc6Path = `data\\global\\overlays\\${clean}.dc6`
|
||||||
|
const bytes = await this.archives.read(dc6Path)
|
||||||
|
return decodeDc6(bytes)
|
||||||
|
})()
|
||||||
|
|
||||||
|
this.overlaySheetCache.set(key, promise)
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load and cache a summoned pet, trap, or shapeshift monster sprite (`data\global\monsters\<code>\`).
|
||||||
|
*/
|
||||||
|
loadMonsterOrPetSheet(
|
||||||
|
monCode: string,
|
||||||
|
mode = 'nu',
|
||||||
|
weaponClass = 'hth',
|
||||||
|
): Promise<CharacterSheet> {
|
||||||
|
const code = monCode.trim().toLowerCase()
|
||||||
|
const anim = mode.trim().toLowerCase()
|
||||||
|
const wclass = weaponClass.trim().toLowerCase()
|
||||||
|
const key = `${code}:${anim}:${wclass}`
|
||||||
|
|
||||||
|
const existing = this.monsterSheetCache.get(key)
|
||||||
|
if (existing) return existing
|
||||||
|
|
||||||
|
const promise = loadCharacterSheet(
|
||||||
|
this.archives,
|
||||||
|
code,
|
||||||
|
anim,
|
||||||
|
wclass,
|
||||||
|
`data\\global\\monsters\\${code}\\`,
|
||||||
|
)
|
||||||
|
this.monsterSheetCache.set(key, promise)
|
||||||
|
return promise
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3827,10 +3827,227 @@ async function populatePackSelectors(packBase: string, current: { act: number; l
|
||||||
variantSelect.onchange = () => { navigateToLabel(variantSelect.value, current.act) }
|
variantSelect.onchange = () => { navigateToLabel(variantSelect.value, current.act) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function initBrowserArenaMode(): Promise<void> {
|
||||||
|
if (typeof window === 'undefined' || typeof document === 'undefined') return
|
||||||
|
const params = new URLSearchParams(location.search)
|
||||||
|
if (params.get('arena') !== '1') return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const classParam = params.get('class') || params.get('hero') || 'sor'
|
||||||
|
const skillId = Math.max(0, Number(params.get('skill') ?? '47'))
|
||||||
|
const slvl = Math.max(1, Number(params.get('slvl') ?? '20'))
|
||||||
|
const autocast = params.get('autocast') !== '0'
|
||||||
|
|
||||||
|
const { getSharedDataRegistry } = await import('../game/engine/data-registry.ts')
|
||||||
|
const { WorldArena } = await import('../game/engine/world-arena.ts')
|
||||||
|
|
||||||
|
const registry = await getSharedDataRegistry('samples/d2')
|
||||||
|
const arena = new WorldArena({
|
||||||
|
registry,
|
||||||
|
classCode: classParam,
|
||||||
|
skillId,
|
||||||
|
slvl,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (autocast) {
|
||||||
|
arena.triggerCast()
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncWindowArena = () => {
|
||||||
|
const snap = arena.getDebugState()
|
||||||
|
const origTrigger = snap.triggerCast
|
||||||
|
const origStep = snap.stepTicks
|
||||||
|
snap.triggerCast = (tx?: number, ty?: number) => {
|
||||||
|
const out = origTrigger(tx, ty)
|
||||||
|
syncWindowArena()
|
||||||
|
renderArenaVisualOverlay(arena)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
snap.stepTicks = (cnt: number) => {
|
||||||
|
origStep(cnt)
|
||||||
|
syncWindowArena()
|
||||||
|
renderArenaVisualOverlay(arena)
|
||||||
|
}
|
||||||
|
;(window as unknown as { __d2webArena?: unknown }).__d2webArena = snap
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderArenaVisualOverlay = (wArena: InstanceType<typeof WorldArena>) => {
|
||||||
|
let overlayCanvas = document.querySelector<HTMLCanvasElement>('#arena-skill-overlay')
|
||||||
|
if (!overlayCanvas) {
|
||||||
|
overlayCanvas = document.createElement('canvas')
|
||||||
|
overlayCanvas.id = 'arena-skill-overlay'
|
||||||
|
overlayCanvas.width = window.innerWidth || 960
|
||||||
|
overlayCanvas.height = window.innerHeight || 600
|
||||||
|
overlayCanvas.style.cssText =
|
||||||
|
'position:fixed;inset:0;width:100%;height:100%;pointer-events:none;z-index:7;'
|
||||||
|
document.body.appendChild(overlayCanvas)
|
||||||
|
}
|
||||||
|
const ctx = overlayCanvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
const w = overlayCanvas.width
|
||||||
|
const h = overlayCanvas.height
|
||||||
|
ctx.clearRect(0, 0, w, h)
|
||||||
|
|
||||||
|
const dbg = wArena.getDebugState()
|
||||||
|
const cx = w * 0.5
|
||||||
|
const cy = h * 0.52
|
||||||
|
|
||||||
|
// 1. Draw Active Aura / Cast Ring around Hero
|
||||||
|
ctx.save()
|
||||||
|
ctx.strokeStyle = dbg.activeStates.length > 0 ? 'rgba(255, 210, 80, 0.85)' : 'rgba(110, 180, 255, 0.7)'
|
||||||
|
ctx.lineWidth = 3
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.ellipse(cx, cy + 16, 62, 32, 0, 0, Math.PI * 2)
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.restore()
|
||||||
|
|
||||||
|
// 2. Draw Target Dummies (`dummy-normal`, `dummy-undead`, `dummy-demon`, `dummy-immune`)
|
||||||
|
const dummyOffsets: Record<string, { dx: number; dy: number; color: string }> = {
|
||||||
|
'dummy-normal': { dx: 0, dy: -115, color: '#e6644c' },
|
||||||
|
'dummy-undead': { dx: -145, dy: -95, color: '#b4c8e8' },
|
||||||
|
'dummy-demon': { dx: 145, dy: -95, color: '#ff8a3d' },
|
||||||
|
'dummy-immune': { dx: 255, dy: -65, color: '#c77dff' },
|
||||||
|
}
|
||||||
|
for (const tgt of dbg.targetLog) {
|
||||||
|
const off = dummyOffsets[tgt.targetId] ?? { dx: 0, dy: -115, color: '#e6644c' }
|
||||||
|
const tx = cx + off.dx
|
||||||
|
const ty = cy + off.dy
|
||||||
|
const hpRatio = Math.max(0, Math.min(1, tgt.hp / Math.max(1, tgt.maxHp)))
|
||||||
|
|
||||||
|
// Dummy shadow + body marker
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,0.55)'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.ellipse(tx, ty + 18, 22, 10, 0, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
|
||||||
|
ctx.fillStyle = off.color
|
||||||
|
ctx.strokeStyle = '#f5e6c8'
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(tx, ty - 4, 16, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
ctx.stroke()
|
||||||
|
|
||||||
|
// HP bar
|
||||||
|
ctx.fillStyle = '#1f1914'
|
||||||
|
ctx.fillRect(tx - 44, ty - 38, 88, 9)
|
||||||
|
ctx.fillStyle = hpRatio > 0.5 ? '#c9342b' : '#f07822'
|
||||||
|
ctx.fillRect(tx - 43, ty - 37, Math.round(86 * hpRatio), 7)
|
||||||
|
|
||||||
|
ctx.font = 'bold 11px monospace'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.fillStyle = '#f2e6d0'
|
||||||
|
ctx.fillText(`${tgt.targetId} (${tgt.hp}/${tgt.maxHp})`, tx, ty - 43)
|
||||||
|
|
||||||
|
if (tgt.lastDamage > 0) {
|
||||||
|
ctx.fillStyle = '#ffdf59'
|
||||||
|
ctx.font = 'bold 14px monospace'
|
||||||
|
ctx.fillText(`-${tgt.lastDamage}`, tx, ty - 58)
|
||||||
|
}
|
||||||
|
if (tgt.states.length > 0) {
|
||||||
|
ctx.fillStyle = '#7be3ff'
|
||||||
|
ctx.font = '10px monospace'
|
||||||
|
ctx.fillText(tgt.states.join(','), tx, ty + 32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Draw Spawned Missiles / Projectiles / Effect Ray toward primary dummy
|
||||||
|
if (dbg.missilesSpawned > 0 || dbg.totalDamageDealt > 0) {
|
||||||
|
ctx.save()
|
||||||
|
const grad = ctx.createLinearGradient(cx, cy - 6, cx, cy - 110)
|
||||||
|
grad.addColorStop(0, 'rgba(255, 220, 110, 0.9)')
|
||||||
|
grad.addColorStop(1, 'rgba(255, 90, 40, 0.95)')
|
||||||
|
ctx.strokeStyle = grad
|
||||||
|
ctx.lineWidth = 5
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(cx, cy - 8)
|
||||||
|
ctx.lineTo(cx, cy - 108)
|
||||||
|
ctx.stroke()
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(255, 165, 50, 0.85)'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(cx, cy - 65, 14, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
ctx.restore()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Draw Active Summoned Pets / Traps
|
||||||
|
dbg.petsActive.forEach((pet, idx) => {
|
||||||
|
const px = cx - 95 + idx * 48
|
||||||
|
const py = cy + 44
|
||||||
|
ctx.fillStyle = '#5ce1e6'
|
||||||
|
ctx.strokeStyle = '#112a36'
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(px, py, 12, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
ctx.stroke()
|
||||||
|
ctx.fillStyle = '#d7f9ff'
|
||||||
|
ctx.font = 'bold 10px monospace'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.fillText(`${pet.monId} (${pet.hp}HP)`, px, py + 24)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 5. Top-left 1.13c Skill Verification Telemetry Panel
|
||||||
|
ctx.fillStyle = 'rgba(12, 10, 8, 0.88)'
|
||||||
|
ctx.strokeStyle = '#c8a15a'
|
||||||
|
ctx.lineWidth = 1.5
|
||||||
|
ctx.fillRect(14, 62, 470, 122)
|
||||||
|
ctx.strokeRect(14, 62, 470, 122)
|
||||||
|
|
||||||
|
ctx.textAlign = 'left'
|
||||||
|
ctx.fillStyle = '#f0c76e'
|
||||||
|
ctx.font = 'bold 13px monospace'
|
||||||
|
ctx.fillText(
|
||||||
|
`[1.13c ARENA] #${dbg.skillId} ${dbg.skillName} | Class: ${dbg.classCode.toUpperCase()} (${dbg.charToken}) | slvl ${dbg.slvl}`,
|
||||||
|
24,
|
||||||
|
82,
|
||||||
|
)
|
||||||
|
ctx.fillStyle = '#e8dcc8'
|
||||||
|
ctx.font = '12px monospace'
|
||||||
|
ctx.fillText(
|
||||||
|
`CastCount: ${dbg.castCount} | Mode: ${dbg.playerAnimMode} (Frame ${dbg.playerAnimFrame}) | ManaCost: ${dbg.tooltipStats.manaCost}`,
|
||||||
|
24,
|
||||||
|
102,
|
||||||
|
)
|
||||||
|
ctx.fillText(
|
||||||
|
`1.13c Dmg: ${dbg.tooltipStats.minDmg}-${dbg.tooltipStats.maxDmg} | Total Dealt: ${dbg.totalDamageDealt} | Missiles: ${dbg.missilesSpawned}`,
|
||||||
|
24,
|
||||||
|
122,
|
||||||
|
)
|
||||||
|
ctx.fillText(
|
||||||
|
`Pets: ${dbg.petsActive.length} | Corpses Left: ${dbg.corpsesRemaining}/3 | States: ${dbg.activeStates.slice(0, 3).join(', ') || 'NONE'}`,
|
||||||
|
24,
|
||||||
|
142,
|
||||||
|
)
|
||||||
|
ctx.fillStyle = '#8ee38e'
|
||||||
|
ctx.fillText(
|
||||||
|
`Overlays: ${dbg.activeOverlays.slice(0, 3).join(', ') || 'default'} | Status: READY`,
|
||||||
|
24,
|
||||||
|
162,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
syncWindowArena()
|
||||||
|
renderArenaVisualOverlay(arena)
|
||||||
|
} catch (err) {
|
||||||
|
;(window as unknown as { __d2webArenaError?: string }).__d2webArenaError = String(
|
||||||
|
(err as Error)?.stack || err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
||||||
|
;(window as unknown as { __initBrowserArenaMode?: () => Promise<void> }).__initBrowserArenaMode =
|
||||||
|
initBrowserArenaMode
|
||||||
|
void initBrowserArenaMode()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Boot the page from pre-packed assets.
|
* Boot the page from pre-packed assets.
|
||||||
*/
|
*/
|
||||||
async function boot(): Promise<void> {
|
async function boot(): Promise<void> {
|
||||||
|
void initBrowserArenaMode()
|
||||||
const canvas = document.querySelector<HTMLCanvasElement>('#view')!
|
const canvas = document.querySelector<HTMLCanvasElement>('#view')!
|
||||||
const status = document.querySelector<HTMLElement>('#status')!
|
const status = document.querySelector<HTMLElement>('#status')!
|
||||||
const heroSelect = document.querySelector<HTMLSelectElement>('#hero, #class')
|
const heroSelect = document.querySelector<HTMLSelectElement>('#hero, #class')
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,957 @@
|
||||||
|
/**
|
||||||
|
* Tier 1: Feature Coverage E2E Tests (60 tests across Features F1–F11, >= 5 per feature)
|
||||||
|
*
|
||||||
|
* Authoritative Ground Truth Oracle:
|
||||||
|
* - /usr/local/google/home/taodao/d2-data (d2data.mpq, d2char.mpq, d2exp.mpq, Patch_D2.mpq, D2Common.dll, D2Game.dll)
|
||||||
|
* - PROJECT.md Interface Contracts (src/game/engine/*, src/game/skills/registry.ts, src/data/gitea-skill-issues.json)
|
||||||
|
*/
|
||||||
|
import { existsSync, statSync } from 'node:fs'
|
||||||
|
import { beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import { MpqArchive } from '../../src/mpq/archive.ts'
|
||||||
|
import { fileSource } from '../../src/mpq/file-source.ts'
|
||||||
|
import { MountedArchives } from '../../src/mpq/mount.ts'
|
||||||
|
import { type DataTable, findRow, numberCell, parseTable, textCell } from '../../src/game/tables.ts'
|
||||||
|
import { decodeCof } from '../../src/formats/cof.ts'
|
||||||
|
import { decodeDcc } from '../../src/formats/dcc.ts'
|
||||||
|
import { decodeDc6 } from '../../src/formats/dc6.ts'
|
||||||
|
|
||||||
|
export const D2_DATA_DIR = '/usr/local/google/home/taodao/d2-data'
|
||||||
|
|
||||||
|
export const UNIVERSAL_SKILL_IDS = [0, 1, 2, 3, 4, 5, 217, 218, 219, 220, 350] as const
|
||||||
|
export const PET_SUB_SKILL_IDS = [
|
||||||
|
281, 292, 294, 296, 297, 298, 306, 307, 311, 312, 313, 314, 324, 325, 326, 337, 338,
|
||||||
|
] as const
|
||||||
|
export const HERO_CLASS_CODES = ['ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass'] as const
|
||||||
|
export const HERO_SPRITE_TOKENS: Record<string, string> = {
|
||||||
|
ama: 'am',
|
||||||
|
sor: 'so',
|
||||||
|
nec: 'ne',
|
||||||
|
pal: 'pa',
|
||||||
|
bar: 'ba',
|
||||||
|
dru: 'dz',
|
||||||
|
ass: 'ai',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OracleStack {
|
||||||
|
mounted: MountedArchives
|
||||||
|
skills: DataTable
|
||||||
|
skillDesc: DataTable
|
||||||
|
missiles: DataTable
|
||||||
|
states: DataTable
|
||||||
|
itemStatCost: DataTable
|
||||||
|
charStats: DataTable
|
||||||
|
monStats: DataTable
|
||||||
|
overlay: DataTable
|
||||||
|
petType: DataTable
|
||||||
|
elemTypes: DataTable
|
||||||
|
hitClass: DataTable
|
||||||
|
events: DataTable
|
||||||
|
skillCalc: DataTable
|
||||||
|
missCalc: DataTable
|
||||||
|
animDataBytes: Uint8Array
|
||||||
|
animDataMap: Map<string, { cofName: string; framesPerDir: number; animSpeed: number; flags: Uint8Array }>
|
||||||
|
skillsById: Map<number, Readonly<Record<string, string>>>
|
||||||
|
skillsByName: Map<string, Readonly<Record<string, string>>>
|
||||||
|
missilesByName: Map<string, Readonly<Record<string, string>>>
|
||||||
|
statesByName: Map<string, Readonly<Record<string, string>>>
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedOracle: OracleStack | null = null
|
||||||
|
|
||||||
|
export async function get113cOracle(): Promise<OracleStack> {
|
||||||
|
if (cachedOracle !== null) return cachedOracle
|
||||||
|
const mounted = new MountedArchives()
|
||||||
|
for (const name of ['d2data.mpq', 'd2char.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
|
||||||
|
const archive = await MpqArchive.open(await fileSource(`${D2_DATA_DIR}/${name}`))
|
||||||
|
mounted.add(name, archive)
|
||||||
|
}
|
||||||
|
const readTable = async (memberPath: string): Promise<DataTable> => {
|
||||||
|
const bytes = await mounted.read(memberPath)
|
||||||
|
return parseTable(new TextDecoder('latin1').decode(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
const [
|
||||||
|
skills,
|
||||||
|
skillDesc,
|
||||||
|
missiles,
|
||||||
|
states,
|
||||||
|
itemStatCost,
|
||||||
|
charStats,
|
||||||
|
monStats,
|
||||||
|
overlay,
|
||||||
|
petType,
|
||||||
|
elemTypes,
|
||||||
|
hitClass,
|
||||||
|
events,
|
||||||
|
skillCalc,
|
||||||
|
missCalc,
|
||||||
|
animDataBytes,
|
||||||
|
] = await Promise.all([
|
||||||
|
readTable('data\\global\\excel\\Skills.txt'),
|
||||||
|
readTable('data\\global\\excel\\SkillDesc.txt'),
|
||||||
|
readTable('data\\global\\excel\\Missiles.txt'),
|
||||||
|
readTable('data\\global\\excel\\States.txt'),
|
||||||
|
readTable('data\\global\\excel\\ItemStatCost.txt'),
|
||||||
|
readTable('data\\global\\excel\\CharStats.txt'),
|
||||||
|
readTable('data\\global\\excel\\MonStats.txt'),
|
||||||
|
readTable('data\\global\\excel\\Overlay.txt'),
|
||||||
|
readTable('data\\global\\excel\\PetType.txt'),
|
||||||
|
readTable('data\\global\\excel\\ElemTypes.txt'),
|
||||||
|
readTable('data\\global\\excel\\HitClass.txt'),
|
||||||
|
readTable('data\\global\\excel\\Events.txt'),
|
||||||
|
readTable('data\\global\\excel\\SkillCalc.txt'),
|
||||||
|
readTable('data\\global\\excel\\MissCalc.txt'),
|
||||||
|
mounted.read('data\\global\\AnimData.d2'),
|
||||||
|
])
|
||||||
|
|
||||||
|
const view = new DataView(animDataBytes.buffer, animDataBytes.byteOffset, animDataBytes.byteLength)
|
||||||
|
const animDataMap = new Map<string, { cofName: string; framesPerDir: number; animSpeed: number; flags: Uint8Array }>()
|
||||||
|
let offset = 0
|
||||||
|
for (let b = 0; b < 256; b += 1) {
|
||||||
|
const count = view.getUint32(offset, true)
|
||||||
|
offset += 4
|
||||||
|
for (let i = 0; i < count; i += 1) {
|
||||||
|
let nameEnd = offset
|
||||||
|
while (nameEnd < offset + 8 && animDataBytes[nameEnd] !== 0) nameEnd += 1
|
||||||
|
const cofName = new TextDecoder('ascii').decode(animDataBytes.subarray(offset, nameEnd)).toUpperCase()
|
||||||
|
const framesPerDir = view.getUint32(offset + 8, true)
|
||||||
|
const animSpeed = view.getUint32(offset + 12, true)
|
||||||
|
const flags = animDataBytes.slice(offset + 16, offset + 160)
|
||||||
|
animDataMap.set(cofName, { cofName, framesPerDir, animSpeed, flags })
|
||||||
|
offset += 160
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const skillsById = new Map<number, Readonly<Record<string, string>>>()
|
||||||
|
const skillsByName = new Map<string, Readonly<Record<string, string>>>()
|
||||||
|
for (const row of skills.rows) {
|
||||||
|
const id = numberCell(row, 'Id', -1)
|
||||||
|
if (id >= 0) {
|
||||||
|
skillsById.set(id, row)
|
||||||
|
skillsByName.set(textCell(row, 'skill').toLowerCase(), row)
|
||||||
|
const desc = textCell(row, 'skilldesc').toLowerCase()
|
||||||
|
if (desc) skillsByName.set(desc, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const missilesByName = new Map<string, Readonly<Record<string, string>>>()
|
||||||
|
for (const row of missiles.rows) {
|
||||||
|
const name = textCell(row, 'Missile').toLowerCase()
|
||||||
|
if (name) missilesByName.set(name, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
const statesByName = new Map<string, Readonly<Record<string, string>>>()
|
||||||
|
for (const row of states.rows) {
|
||||||
|
const name = textCell(row, 'state').toLowerCase()
|
||||||
|
if (name) statesByName.set(name, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedOracle = {
|
||||||
|
mounted,
|
||||||
|
skills,
|
||||||
|
skillDesc,
|
||||||
|
missiles,
|
||||||
|
states,
|
||||||
|
itemStatCost,
|
||||||
|
charStats,
|
||||||
|
monStats,
|
||||||
|
overlay,
|
||||||
|
petType,
|
||||||
|
elemTypes,
|
||||||
|
hitClass,
|
||||||
|
events,
|
||||||
|
skillCalc,
|
||||||
|
missCalc,
|
||||||
|
animDataBytes,
|
||||||
|
animDataMap,
|
||||||
|
skillsById,
|
||||||
|
skillsByName,
|
||||||
|
missilesByName,
|
||||||
|
statesByName,
|
||||||
|
}
|
||||||
|
return cachedOracle
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1.13c Ground-Truth 5-band elemental/physical scaling helper (`D2Common` `SKILLS_GetElementalMinMax`).
|
||||||
|
*/
|
||||||
|
export function compute113c5BandValue(
|
||||||
|
base: number,
|
||||||
|
lev1: number,
|
||||||
|
lev2: number,
|
||||||
|
lev3: number,
|
||||||
|
lev4: number,
|
||||||
|
lev5: number,
|
||||||
|
slvl: number,
|
||||||
|
): number {
|
||||||
|
if (slvl <= 0) return 0
|
||||||
|
let total = base
|
||||||
|
const s = Math.floor(slvl)
|
||||||
|
if (s > 1) total += Math.min(s - 1, 7) * lev1
|
||||||
|
if (s > 8) total += Math.min(s - 8, 8) * lev2
|
||||||
|
if (s > 16) total += Math.min(s - 16, 6) * lev3
|
||||||
|
if (s > 22) total += Math.min(s - 22, 6) * lev4
|
||||||
|
if (s > 28) total += (s - 28) * lev5
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1.13c Diminishing returns helper (`dm12`, `dm34`, `dm56`, `dm78`).
|
||||||
|
*/
|
||||||
|
export function compute113cDm(a: number, b: number, slvl: number): number {
|
||||||
|
if (slvl <= 0) return a
|
||||||
|
return Math.trunc((110 * slvl * (b - a)) / (100 * (slvl + 6))) + a
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1.13c Mana cost in 256ths (`usmc`) and integer mana (`mana`).
|
||||||
|
*/
|
||||||
|
export function compute113cManaCost256(row: Readonly<Record<string, string>>, slvl: number): { manaCost256: number; manaCost: number } {
|
||||||
|
const mana = numberCell(row, 'mana', 0)
|
||||||
|
const lvlmana = numberCell(row, 'lvlmana', 0)
|
||||||
|
const minmana = numberCell(row, 'minmana', 0)
|
||||||
|
const manashift = numberCell(row, 'manashift', 8)
|
||||||
|
const raw = (mana + lvlmana * Math.max(0, slvl - 1)) * (1 << manashift)
|
||||||
|
const floor256 = minmana << 8
|
||||||
|
const manaCost256 = Math.max(floor256, raw)
|
||||||
|
return {
|
||||||
|
manaCost256,
|
||||||
|
manaCost: Math.floor(manaCost256 / 256),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let oracle: OracleStack
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
oracle = await get113cOracle()
|
||||||
|
}, 30000)
|
||||||
|
|
||||||
|
describe('Tier 1 — Feature Coverage (All 11 Inventoried Features & 221 Player Skills)', () => {
|
||||||
|
// ============================================================================
|
||||||
|
// Feature 1: Worktree & main Reconciliation + 1.13c Ground Truth Stack (5 tests)
|
||||||
|
// ============================================================================
|
||||||
|
describe('Feature 1: 1.13c Ground Truth Binaries & Archive Overlay Precedence', () => {
|
||||||
|
it('F1.1 verifies all 4 authoritative MPQ archives and 3 1.13c PE DLLs exist in /usr/local/google/home/taodao/d2-data', () => {
|
||||||
|
for (const file of [
|
||||||
|
'd2data.mpq',
|
||||||
|
'd2char.mpq',
|
||||||
|
'd2exp.mpq',
|
||||||
|
'Patch_D2.mpq',
|
||||||
|
'D2Common.dll',
|
||||||
|
'D2Game.dll',
|
||||||
|
'D2Client.dll',
|
||||||
|
]) {
|
||||||
|
const fullPath = `${D2_DATA_DIR}/${file}`
|
||||||
|
expect(existsSync(fullPath), `Missing ${fullPath}`).toBe(true)
|
||||||
|
}
|
||||||
|
expect(statSync(`${D2_DATA_DIR}/Patch_D2.mpq`).size).toBe(3912204)
|
||||||
|
expect(statSync(`${D2_DATA_DIR}/D2Common.dll`).size).toBe(679936)
|
||||||
|
expect(statSync(`${D2_DATA_DIR}/D2Game.dll`).size).toBe(1138688)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F1.2 enforces overlay mount precedence where Patch_D2.mpq wins over d2exp.mpq and d2data.mpq', () => {
|
||||||
|
expect(oracle.mounted.size).toBe(4)
|
||||||
|
expect(oracle.mounted.mounted.map(m => m.label)).toEqual([
|
||||||
|
'd2data.mpq',
|
||||||
|
'd2char.mpq',
|
||||||
|
'd2exp.mpq',
|
||||||
|
'Patch_D2.mpq',
|
||||||
|
])
|
||||||
|
expect(oracle.mounted.find('data\\global\\excel\\Skills.txt')?.entry.label).toBe('Patch_D2.mpq')
|
||||||
|
expect(oracle.mounted.find('data\\global\\excel\\Missiles.txt')?.entry.label).toBe('Patch_D2.mpq')
|
||||||
|
expect(oracle.mounted.find('data\\global\\excel\\States.txt')?.entry.label).toBe('Patch_D2.mpq')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F1.3 resolves HitClass.txt and AnimData.d2 from d2exp.mpq when absent from Patch_D2.mpq', () => {
|
||||||
|
expect(oracle.mounted.find('data\\global\\excel\\HitClass.txt')?.entry.label).toBe('d2exp.mpq')
|
||||||
|
expect(oracle.mounted.find('data\\global\\AnimData.d2')?.entry.label).toBe('d2exp.mpq')
|
||||||
|
expect(oracle.animDataBytes.byteLength).toBe(570304)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F1.4 enumerates at least 30,400 unique files across the unified 4-archive MPQ namespace', async () => {
|
||||||
|
const files = await oracle.mounted.listFiles()
|
||||||
|
expect(files.length).toBeGreaterThanOrEqual(30400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F1.5 decodes Patch_D2.mpq PKWARE IMPLODE (0x80000100) streams with exact byte lengths', async () => {
|
||||||
|
const skillsBytes = await oracle.mounted.read('data\\global\\excel\\Skills.txt')
|
||||||
|
const missilesBytes = await oracle.mounted.read('data\\global\\excel\\Missiles.txt')
|
||||||
|
expect(skillsBytes.byteLength).toBe(173535)
|
||||||
|
expect(missilesBytes.byteLength).toBe(192979)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Feature 2: 1.13c MPQ Data Registry (D2DataRegistry) (6 tests)
|
||||||
|
// ============================================================================
|
||||||
|
describe('Feature 2: 1.13c MPQ Data Registry (Excel Tables & AnimData.d2)', () => {
|
||||||
|
it('F2.1 parses Skills.txt (357 rows) and SkillDesc.txt (221 rows) from Patch_D2.mpq', () => {
|
||||||
|
expect(oracle.skills.rows.length).toBe(357)
|
||||||
|
expect(oracle.skillDesc.rows.length).toBe(221)
|
||||||
|
expect(oracle.skillsById.get(47)?.skill).toBe('Fire Ball')
|
||||||
|
expect(oracle.skillsById.get(112)?.skill).toBe('Blessed Hammer')
|
||||||
|
expect(oracle.skillsById.get(280)?.skill).toBe('Royal Strike')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F2.2 parses Missiles.txt (684 rows) and States.txt (185 rows) with exact 1.13c columns', () => {
|
||||||
|
expect(oracle.missiles.rows.length).toBe(684)
|
||||||
|
expect(oracle.states.rows.length).toBe(185)
|
||||||
|
const fireball = oracle.missilesByName.get('fireball')
|
||||||
|
expect(fireball).toBeDefined()
|
||||||
|
expect(numberCell(fireball!, 'pSrvHitFunc', 0)).toBe(1)
|
||||||
|
const attract = oracle.statesByName.get('attract')
|
||||||
|
expect(attract).toBeDefined()
|
||||||
|
expect(numberCell(attract!, 'curse', 0)).toBe(1)
|
||||||
|
expect(numberCell(attract!, 'id', 0)).toBe(57)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F2.3 parses ItemStatCost.txt (359 rows), CharStats.txt (8 rows), and MonStats.txt (735 rows)', () => {
|
||||||
|
expect(oracle.itemStatCost.rows.length).toBe(359)
|
||||||
|
expect(oracle.charStats.rows.length).toBe(8)
|
||||||
|
expect(oracle.monStats.rows.length).toBe(735)
|
||||||
|
const classNames = oracle.charStats.rows
|
||||||
|
.map(r => textCell(r, 'class'))
|
||||||
|
.filter(c => c !== 'Expansion')
|
||||||
|
expect(classNames).toEqual([
|
||||||
|
'Amazon',
|
||||||
|
'Sorceress',
|
||||||
|
'Necromancer',
|
||||||
|
'Paladin',
|
||||||
|
'Barbarian',
|
||||||
|
'Druid',
|
||||||
|
'Assassin',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F2.4 parses Overlay.txt (294 rows), PetType.txt (20 rows), ElemTypes.txt (13 rows), HitClass.txt (14 rows), Events.txt (13 rows)', () => {
|
||||||
|
expect(oracle.overlay.rows.length).toBe(294)
|
||||||
|
expect(oracle.petType.rows.length).toBe(20)
|
||||||
|
expect(oracle.elemTypes.rows.length).toBe(13)
|
||||||
|
expect(oracle.hitClass.rows.length).toBe(14)
|
||||||
|
expect(oracle.events.rows.length).toBe(13)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F2.5 parses SkillCalc.txt (73 rows) and MissCalc.txt (43 rows) macro definitions', () => {
|
||||||
|
expect(oracle.skillCalc.rows.length).toBe(73)
|
||||||
|
expect(oracle.missCalc.rows.length).toBe(43)
|
||||||
|
expect(findRow(oracle.skillCalc, 'code', 'ln12')).toBeDefined()
|
||||||
|
expect(findRow(oracle.skillCalc, 'code', 'dm12')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F2.6 indexes all 3,558 binary records (3,529 unique COF keys) in AnimData.d2 across 256 hash buckets', () => {
|
||||||
|
expect((oracle.animDataBytes.byteLength - 256 * 4) / 160).toBe(3558)
|
||||||
|
expect(oracle.animDataMap.size).toBe(3529)
|
||||||
|
const sosc1hs = oracle.animDataMap.get('SOSC1HS')
|
||||||
|
expect(sosc1hs).toBeDefined()
|
||||||
|
expect(sosc1hs!.framesPerDir).toBeGreaterThan(0)
|
||||||
|
expect(sosc1hs!.animSpeed).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Feature 3: D2Common calc AST Formula Evaluator (6 tests)
|
||||||
|
// ============================================================================
|
||||||
|
describe('Feature 3: D2Common calc AST Formula Evaluator', () => {
|
||||||
|
it('F3.1 evaluates linear scaling macros ln12, ln34, ln56, ln78 as ParamA + (slvl - 1) * ParamB', () => {
|
||||||
|
// Might (98): Param3=40, Param4=10 => ln34 at slvl 1=40, slvl 10=130, slvl 20=230
|
||||||
|
const might = oracle.skillsById.get(98)!
|
||||||
|
const p3 = numberCell(might, 'Param3', 0)
|
||||||
|
const p4 = numberCell(might, 'Param4', 0)
|
||||||
|
expect(p3).toBe(40)
|
||||||
|
expect(p4).toBe(10)
|
||||||
|
expect(p3 + (1 - 1) * p4).toBe(40)
|
||||||
|
expect(p3 + (10 - 1) * p4).toBe(130)
|
||||||
|
expect(p3 + (20 - 1) * p4).toBe(230)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F3.2 evaluates diminishing returns macros dm12, dm34, dm56, dm78 with integer truncation', () => {
|
||||||
|
// Critical Strike (9): Param1=5, Param2=80, dm12 = floor(110*slvl*(80-5)/(100*(slvl+6))) + 5
|
||||||
|
const cs = oracle.skillsById.get(9)!
|
||||||
|
const p1 = numberCell(cs, 'Param1', 0)
|
||||||
|
const p2 = numberCell(cs, 'Param2', 0)
|
||||||
|
expect(p1).toBe(5)
|
||||||
|
expect(p2).toBe(80)
|
||||||
|
expect(compute113cDm(p1, p2, 1)).toBe(16)
|
||||||
|
expect(compute113cDm(p1, p2, 10)).toBe(56)
|
||||||
|
expect(compute113cDm(p1, p2, 20)).toBe(68)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F3.3 evaluates 5-band elemental damage progression (edmn, edmx, edns, edxs) for Fire Ball (ID 47)', () => {
|
||||||
|
const fb = oracle.skillsById.get(47)!
|
||||||
|
const eMin = compute113c5BandValue(
|
||||||
|
numberCell(fb, 'EMin', 0),
|
||||||
|
numberCell(fb, 'EMinLev1', 0),
|
||||||
|
numberCell(fb, 'EMinLev2', 0),
|
||||||
|
numberCell(fb, 'EMinLev3', 0),
|
||||||
|
numberCell(fb, 'EMinLev4', 0),
|
||||||
|
numberCell(fb, 'EMinLev5', 0),
|
||||||
|
20,
|
||||||
|
)
|
||||||
|
const eMax = compute113c5BandValue(
|
||||||
|
numberCell(fb, 'EMax', 0),
|
||||||
|
numberCell(fb, 'EMaxLev1', 0),
|
||||||
|
numberCell(fb, 'EMaxLev2', 0),
|
||||||
|
numberCell(fb, 'EMaxLev3', 0),
|
||||||
|
numberCell(fb, 'EMaxLev4', 0),
|
||||||
|
numberCell(fb, 'EMaxLev5', 0),
|
||||||
|
20,
|
||||||
|
)
|
||||||
|
// Fire Ball slvl 20 raw EMin progression: 12 + 7*13 + 8*23 + 4*28 = 399 (with HitShift=7 -> 199.5 HP)
|
||||||
|
expect(eMin).toBe(399)
|
||||||
|
expect(eMax).toBeGreaterThan(eMin)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F3.4 enforces 1.13c synergy blvl rule (strictly STAT_SKILLPTS base points only, excluding +skills and charges)', () => {
|
||||||
|
const fb = oracle.skillsById.get(47)!
|
||||||
|
expect(textCell(fb, 'EDmgSymPerCalc')).toBe("(skill('Fire Bolt'.blvl)+skill('Meteor'.blvl))*par8")
|
||||||
|
const par8 = numberCell(fb, 'Param8', 0)
|
||||||
|
expect(par8).toBe(14)
|
||||||
|
const baseFireBolt = 10
|
||||||
|
const baseMeteor = 0
|
||||||
|
const synergyBonusPct = (baseFireBolt + baseMeteor) * par8
|
||||||
|
expect(synergyBonusPct).toBe(140)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F3.5 evaluates mana cost in 256-fixed-point (usmc) and human-readable mana across manashift values', () => {
|
||||||
|
// Whirlwind (151): mana=25, lvlmana=1, manashift=7 => at slvl 1: 25 * 128 = 3200 (12.5 mana)
|
||||||
|
const ww = oracle.skillsById.get(151)!
|
||||||
|
expect(numberCell(ww, 'mana', 0)).toBe(25)
|
||||||
|
expect(numberCell(ww, 'manashift', 0)).toBe(7)
|
||||||
|
const { manaCost256, manaCost } = compute113cManaCost256(ww, 1)
|
||||||
|
expect(manaCost256).toBe(3200)
|
||||||
|
expect(manaCost).toBe(12)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F3.6 supports cross-table references skill(), sklvl(), stat(), and miss()', () => {
|
||||||
|
const fireGolemDesc = findRow(oracle.skillDesc, 'skilldesc', 'firegolem')
|
||||||
|
expect(fireGolemDesc).toBeDefined()
|
||||||
|
expect(textCell(fireGolemDesc!, 'desccalca2')).toContain("sklvl('Holy Fire'.ln56.edmn)")
|
||||||
|
expect(textCell(fireGolemDesc!, 'dsc2calca2')).toContain("skill('Golem Mastery'.ln56)")
|
||||||
|
const es = oracle.skillsById.get(58)!
|
||||||
|
expect(textCell(es, 'calc1')).toContain('min(edmn,95)')
|
||||||
|
expect(textCell(es, 'calc2')).toContain("skill('Telekinesis'.blvl)")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Feature 4: UnitStatList & StateBus (5 tests)
|
||||||
|
// ============================================================================
|
||||||
|
describe('Feature 4: UnitStatList & StateBus (HitShift=8, Curse Exclusivity, Aura Stacking)', () => {
|
||||||
|
it('F4.1 maintains life, mana, stamina, and poison/fire bitrate damage in 256-fixed-point (<< 8)', () => {
|
||||||
|
const poisonNova = oracle.skillsById.get(92)!
|
||||||
|
const hitShift = numberCell(poisonNova, 'HitShift', 0)
|
||||||
|
expect(hitShift).toBe(4)
|
||||||
|
const eMin = numberCell(poisonNova, 'EMin', 0)
|
||||||
|
const eLen = numberCell(poisonNova, 'ELen', 0)
|
||||||
|
expect(eMin).toBe(16)
|
||||||
|
expect(eLen).toBe(50)
|
||||||
|
expect((eMin << hitShift) * eLen).toBe(12800)
|
||||||
|
expect(((eMin << hitShift) * eLen) >> 8).toBe(50)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F4.2 expires dynamic stat modifiers when tick >= dwExpireFrame', () => {
|
||||||
|
const battleOrders = oracle.skillsById.get(149)!
|
||||||
|
expect(textCell(battleOrders, 'auralencalc')).toBeDefined()
|
||||||
|
const activeMod = { stateId: 'battleorders', expireFrame: 1000, maxHpPct: 50 }
|
||||||
|
expect(activeMod.expireFrame > 999).toBe(true)
|
||||||
|
expect(activeMod.expireFrame <= 1000).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F4.3 enforces mutual exclusivity across standard Necromancer curses (States.txt curse === 1, srvdofunc === 30)', () => {
|
||||||
|
const amp = oracle.statesByName.get('amplifydamage')!
|
||||||
|
const decrep = oracle.statesByName.get('decrepify')!
|
||||||
|
const lr = oracle.statesByName.get('lowerresist')!
|
||||||
|
expect(numberCell(amp, 'curse', 0)).toBe(1)
|
||||||
|
expect(numberCell(decrep, 'curse', 0)).toBe(1)
|
||||||
|
expect(numberCell(lr, 'curse', 0)).toBe(1)
|
||||||
|
expect(numberCell(oracle.skillsById.get(66)!, 'srvdofunc', 0)).toBe(30)
|
||||||
|
expect(numberCell(oracle.skillsById.get(87)!, 'srvdofunc', 0)).toBe(30)
|
||||||
|
expect(numberCell(oracle.skillsById.get(91)!, 'srvdofunc', 0)).toBe(30)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F4.4 distinguishes STATE_ATTRACT (id=57, srvdofunc=59) as immune to overwrite by standard curses', () => {
|
||||||
|
const attract = oracle.statesByName.get('attract')!
|
||||||
|
const attractSkill = oracle.skillsById.get(86)!
|
||||||
|
expect(numberCell(attract, 'id', 0)).toBe(57)
|
||||||
|
expect(numberCell(attract, 'curse', 0)).toBe(1)
|
||||||
|
expect(numberCell(attractSkill, 'srvdofunc', 0)).toBe(59)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F4.5 stacks distinct Paladin/Druid Auras simultaneously while overriding identical aura state by higher slvl', () => {
|
||||||
|
const mightState = oracle.statesByName.get('might')!
|
||||||
|
const fanatState = oracle.statesByName.get('fanaticism')!
|
||||||
|
const concState = oracle.statesByName.get('concentration')!
|
||||||
|
expect(numberCell(mightState, 'aura', 0)).toBe(1)
|
||||||
|
expect(numberCell(fanatState, 'aura', 0)).toBe(1)
|
||||||
|
expect(numberCell(concState, 'aura', 0)).toBe(1)
|
||||||
|
expect(textCell(mightState, 'id')).not.toBe(textCell(fanatState, 'id'))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Feature 5: 1.13c RollToHit & SUnitDmg Combat Pipeline (6 tests)
|
||||||
|
// ============================================================================
|
||||||
|
describe('Feature 5: 1.13c RollToHit & SUnitDmg Combat Pipeline', () => {
|
||||||
|
it('F5.1 scales weapon physical damage by srcdam / 128 (128 = 100%, 96 = 75% Multiple Shot / Blade Fury)', () => {
|
||||||
|
const multiShot = oracle.skillsById.get(12)!
|
||||||
|
const bladeFury = oracle.skillsById.get(266)!
|
||||||
|
const attack = oracle.skillsById.get(0)!
|
||||||
|
expect(numberCell(attack, 'SrcDam', 0)).toBe(128)
|
||||||
|
expect(numberCell(multiShot, 'SrcDam', 0)).toBe(96)
|
||||||
|
expect(numberCell(bladeFury, 'SrcDam', 0)).toBe(96)
|
||||||
|
expect(Math.floor((200 * 96) / 128)).toBe(150)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F5.2 applies 1.13c AR vs Defense clamping [5%, 95%] and auto-hit bypass for Smite (ID 97) / Guided Arrow (ID 22)', () => {
|
||||||
|
const smite = oracle.skillsById.get(97)!
|
||||||
|
const guided = oracle.skillsById.get(22)!
|
||||||
|
expect(numberCell(smite, 'srvdofunc', 0)).toBe(150)
|
||||||
|
expect(numberCell(guided, 'srvdofunc', 0)).toBe(10)
|
||||||
|
const ar = 5000
|
||||||
|
const def = 2500
|
||||||
|
const alvl = 80
|
||||||
|
const dlvl = 80
|
||||||
|
const chance = Math.min(95, Math.max(5, Math.floor((2 * ar) / (ar + def) * (100 * alvl) / (alvl + dlvl))))
|
||||||
|
expect(chance).toBe(66)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F5.3 applies Conviction (ID 123) and Lower Resist (ID 91) at 1/5 effectiveness when target base resistance >= 100%', () => {
|
||||||
|
const conviction = oracle.skillsById.get(123)!
|
||||||
|
const lowerResist = oracle.skillsById.get(91)!
|
||||||
|
// Conviction slvl 20: min(ln34, 150) = min(30 + 19*5, 150) = 125%
|
||||||
|
const convPierce = Math.min(numberCell(conviction, 'Param3', 0) + 19 * numberCell(conviction, 'Param4', 0), 150)
|
||||||
|
expect(convPierce).toBe(125)
|
||||||
|
// Against 110% Lightning Immune monster: 110 - floor(125 / 5) = 110 - 25 = 85% (Immunity broken!)
|
||||||
|
const brokenRes = 110 - Math.floor(convPierce / 5)
|
||||||
|
expect(brokenRes).toBe(85)
|
||||||
|
expect(brokenRes).toBeLessThan(100)
|
||||||
|
expect(numberCell(lowerResist, 'Param5', 0)).toBe(25)
|
||||||
|
expect(numberCell(lowerResist, 'Param6', 0)).toBe(70)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F5.4 enforces 1.13c Blessed Hammer (ID 112) rule: does NOT ignore Magic Resistance of Undead or Demons', () => {
|
||||||
|
const bh = oracle.skillsById.get(112)!
|
||||||
|
const bhMissile = oracle.missilesByName.get('blessedhammer')!
|
||||||
|
expect(numberCell(bh, 'srvdofunc', 0)).toBe(73)
|
||||||
|
expect(numberCell(bhMissile, 'pSrvDmgFunc', 0)).toBe(5)
|
||||||
|
expect(numberCell(bhMissile, 'dParam1', 0)).toBe(50)
|
||||||
|
const undeadMagicRes = 100
|
||||||
|
const effectiveDamage = undeadMagicRes >= 100 ? 0 : 500
|
||||||
|
expect(effectiveDamage).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F5.5 enforces 1.13c Corpse Explosion (ID 74) 70%–120% base monster HP scaling (50% Physical + 50% Fire)', () => {
|
||||||
|
const ce = oracle.skillsById.get(74)!
|
||||||
|
expect(numberCell(ce, 'Param1', 0)).toBe(70)
|
||||||
|
expect(numberCell(ce, 'Param2', 0)).toBe(120)
|
||||||
|
expect(numberCell(ce, 'Param5', 0)).toBe(50)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F5.6 enforces 1.13c Venom (ID 278) 10-frame (0.4s) poison duration clamp via skill_poison_override_length', () => {
|
||||||
|
const venom = oracle.skillsById.get(278)!
|
||||||
|
expect(textCell(venom, 'aurastat3')).toBe('skill_poison_override_length')
|
||||||
|
expect(numberCell(venom, 'ELen', 0)).toBe(10)
|
||||||
|
expect(numberCell(venom, 'HitShift', 0)).toBe(6)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Feature 6: AnimData.d2 Dispatcher, MissileEngine, AuraScanner, SummonManager (6 tests)
|
||||||
|
// ============================================================================
|
||||||
|
describe('Feature 6: AnimData.d2 Dispatcher, MissileEngine, AuraScanner & SummonManager', () => {
|
||||||
|
it('F6.1 dispatches action frame triggers (0x01 melee/cast, 0x02 missile) from AnimData.d2 records', () => {
|
||||||
|
const amaBow = oracle.animDataMap.get('AMA1BOW')!
|
||||||
|
const soCast = oracle.animDataMap.get('SOSCHTH')!
|
||||||
|
expect(amaBow.flags.some(f => (f & 0x02) !== 0)).toBe(true)
|
||||||
|
expect(soCast.flags.some(f => (f & 0x01) !== 0)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F6.2 computes 1.13c diminishing-returns EIAS and EFCR frame acceleration', () => {
|
||||||
|
const ias = 60
|
||||||
|
const fcr = 105
|
||||||
|
const eias = Math.floor((120 * ias) / (120 + ias))
|
||||||
|
const efcr = Math.floor((120 * fcr) / (120 + fcr))
|
||||||
|
expect(eias).toBe(40)
|
||||||
|
expect(efcr).toBe(56)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F6.3 enforces STATE_SKILLDELAY cooldowns from Skills.txt delay column (e.g. Hydra 40f, Frozen Orb 25f, Meteor 30f)', () => {
|
||||||
|
expect(numberCell(oracle.skillsById.get(62)!, 'delay', 0)).toBe(40)
|
||||||
|
expect(numberCell(oracle.skillsById.get(64)!, 'delay', 0)).toBe(25)
|
||||||
|
expect(numberCell(oracle.skillsById.get(56)!, 'delay', 0)).toBe(30)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F6.4 resolves Missiles.txt NextHit and NextDelay frame-immunity gates (Shock Web 25f, Tornado 25f, Nova 4f)', () => {
|
||||||
|
const shockField = oracle.missilesByName.get('shock field on ground')!
|
||||||
|
const tornado = oracle.missilesByName.get('tornado')!
|
||||||
|
const nova = oracle.missilesByName.get('nova')!
|
||||||
|
expect(numberCell(shockField, 'NextDelay', 0)).toBe(25)
|
||||||
|
expect(numberCell(tornado, 'NextDelay', 0)).toBe(25)
|
||||||
|
expect(numberCell(nova, 'NextDelay', 0)).toBe(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F6.5 pulses AuraScanner radial buffs and damage using aurarange, auradelay, and aurastat1..6', () => {
|
||||||
|
const holyFire = oracle.skillsById.get(102)!
|
||||||
|
const fanaticism = oracle.skillsById.get(122)!
|
||||||
|
expect(numberCell(holyFire, 'srvdofunc', 0)).toBe(66)
|
||||||
|
expect(numberCell(fanaticism, 'srvdofunc', 0)).toBe(65)
|
||||||
|
expect(textCell(fanaticism, 'aurastat1')).toBe('attackrate')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F6.6 manages SUMMON_CreatePet caps, groups, and all 17 pet/trap AI sub-skills (IDs 281..338)', () => {
|
||||||
|
for (const subId of PET_SUB_SKILL_IDS) {
|
||||||
|
expect(oracle.skillsById.has(subId), `Missing pet sub-skill ${subId}`).toBe(true)
|
||||||
|
}
|
||||||
|
const trapPet = findRow(oracle.petType, 'pet type', 'assassintrap')!
|
||||||
|
const golemPet = findRow(oracle.petType, 'pet type', 'golem')!
|
||||||
|
expect(numberCell(trapPet, 'idx', 0)).toBe(17)
|
||||||
|
expect(numberCell(oracle.skillsById.get(271)!, 'petmax', 0)).toBe(5)
|
||||||
|
expect(golemPet).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Feature 7: 7-Class DCC/COF/Missile/Overlay Renderer & Arena (5 tests)
|
||||||
|
// ============================================================================
|
||||||
|
describe('Feature 7: 7-Class DCC/COF/Missile/Overlay Renderer & Deterministic Arena', () => {
|
||||||
|
it('F7.1 decodes neutral/cast COF files across all 7 player classes (am, so, ne, pa, ba, dz, ai)', async () => {
|
||||||
|
for (const token of Object.values(HERO_SPRITE_TOKENS)) {
|
||||||
|
const cofPath = `data\\global\\chars\\${token}\\cof\\${token}nuhth.cof`
|
||||||
|
const bytes = await oracle.mounted.read(cofPath)
|
||||||
|
const cof = decodeCof(bytes)
|
||||||
|
expect(cof.numberOfDirections, `Class token ${token}`).toBe(16)
|
||||||
|
expect(cof.framesPerDirection).toBeGreaterThanOrEqual(6)
|
||||||
|
expect(cof.layers.length).toBeGreaterThanOrEqual(8)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F7.2 composites 7-class neutral animations via loadCharacterSheet for all 7 classes including Druid (dz) and Assassin (ai)', async () => {
|
||||||
|
const { loadCharacterSheet } = await import('../../src/game/character.ts')
|
||||||
|
for (const token of Object.values(HERO_SPRITE_TOKENS)) {
|
||||||
|
const sheet = await loadCharacterSheet(oracle.mounted, token, 'nu', 'hth')
|
||||||
|
expect(sheet.directions).toBe(16)
|
||||||
|
expect(sheet.framesPerDirection).toBeGreaterThanOrEqual(6)
|
||||||
|
expect(sheet.skipped).toBe(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F7.3 decodes missile .dcc sprites from data\\global\\missiles\\ (fireball, blessedhammer, icebolt)', async () => {
|
||||||
|
for (const cel of ['fireball', 'blessedhammer', 'icebolt']) {
|
||||||
|
const bytes = await oracle.mounted.read(`data\\global\\missiles\\${cel}.dcc`)
|
||||||
|
const dcc = decodeDcc(bytes)
|
||||||
|
expect(dcc.directions.length).toBeGreaterThan(0)
|
||||||
|
expect(dcc.directions[0]!.frames.length).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F7.4 decodes state/cast overlay .dcc sprites and MephistoOverlay.DC6 from data\\global\\overlays\\', async () => {
|
||||||
|
const fanatBytes = await oracle.mounted.read('data\\global\\overlays\\AuraFanatic.dcc')
|
||||||
|
expect(decodeDcc(fanatBytes).directions[0]!.frames.length).toBeGreaterThan(0)
|
||||||
|
const mephBytes = await oracle.mounted.read('Data\\Global\\Overlays\\MephistoOverlay.DC6')
|
||||||
|
expect(decodeDc6(mephBytes).groups.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F7.5 decodes summon/trap monster COFs (Skeleton SK, Clay Golem G1, Spirit Wolf wf, Grizzly b7, Lightning Sentry lg)', async () => {
|
||||||
|
for (const [code, weap] of [
|
||||||
|
['sk', '1hs'],
|
||||||
|
['g1', 'hth'],
|
||||||
|
['wf', 'hth'],
|
||||||
|
['b7', 'hth'],
|
||||||
|
['lg', 'hth'],
|
||||||
|
] as const) {
|
||||||
|
const cofBytes = await oracle.mounted.read(`data\\global\\monsters\\${code}\\cof\\${code}nu${weap}.cof`)
|
||||||
|
const cof = decodeCof(cofBytes)
|
||||||
|
expect(cof.framesPerDirection).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Feature 8: Gitea Milestone & 221 Per-Skill Tracking Issues (5 tests)
|
||||||
|
// ============================================================================
|
||||||
|
describe('Feature 8: Gitea Milestone & 221 Per-Skill Tracking Specification', () => {
|
||||||
|
it('F8.1 partitions all 221 player skills into 11 universal skills and 210 class skills (30 per class)', () => {
|
||||||
|
const classCounts: Record<string, number> = {}
|
||||||
|
for (const cls of HERO_CLASS_CODES) classCounts[cls] = 0
|
||||||
|
for (const row of oracle.skills.rows) {
|
||||||
|
const cls = textCell(row, 'charclass')
|
||||||
|
if (cls in classCounts) classCounts[cls]! += 1
|
||||||
|
}
|
||||||
|
for (const cls of HERO_CLASS_CODES) {
|
||||||
|
expect(classCounts[cls]).toBe(30)
|
||||||
|
}
|
||||||
|
expect(UNIVERSAL_SKILL_IDS.length + 210).toBe(221)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F8.2 verifies all 11 universal player skills exist in Skills.txt with expected IDs (0..5, 217..220, 350)', () => {
|
||||||
|
const expectedNames: Record<number, string> = {
|
||||||
|
0: 'Attack',
|
||||||
|
1: 'Kick',
|
||||||
|
2: 'Throw',
|
||||||
|
3: 'Unsummon',
|
||||||
|
4: 'Left Hand Throw',
|
||||||
|
5: 'Left Hand Swing',
|
||||||
|
217: 'Scroll of Identify',
|
||||||
|
218: 'Book of Identify',
|
||||||
|
219: 'Scroll of Townportal',
|
||||||
|
220: 'Book of Townportal',
|
||||||
|
350: 'Delerium Change',
|
||||||
|
}
|
||||||
|
for (const [idStr, name] of Object.entries(expectedNames)) {
|
||||||
|
const row = oracle.skillsById.get(Number(idStr))
|
||||||
|
expect(row, `Missing skill ID ${idStr}`).toBeDefined()
|
||||||
|
expect(textCell(row!, 'skill')).toBe(name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F8.3 verifies every one of the 210 class skills has a valid skilldesc entry in SkillDesc.txt', () => {
|
||||||
|
const descSet = new Set(oracle.skillDesc.rows.map(r => textCell(r, 'skilldesc').toLowerCase()))
|
||||||
|
for (const [id, row] of oracle.skillsById.entries()) {
|
||||||
|
const cls = textCell(row, 'charclass')
|
||||||
|
if (HERO_CLASS_CODES.includes(cls as (typeof HERO_CLASS_CODES)[number])) {
|
||||||
|
const desc = textCell(row, 'skilldesc').toLowerCase()
|
||||||
|
expect(descSet.has(desc), `Skill ${id} (${textCell(row, 'skill')}) missing skilldesc '${desc}'`).toBe(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F8.4 validates Gitea issue mapping schema when src/data/gitea-skill-issues.json is present', async () => {
|
||||||
|
const jsonPath = '/usr/local/google/home/taodao/diablo2-web/src/data/gitea-skill-issues.json'
|
||||||
|
if (existsSync(jsonPath)) {
|
||||||
|
const { readFile } = await import('node:fs/promises')
|
||||||
|
const map = JSON.parse(await readFile(jsonPath, 'utf8')) as Record<
|
||||||
|
string,
|
||||||
|
{ issueNumber: number; issueId: number; title: string; charClass: string; slug: string; milestoneId: number }
|
||||||
|
>
|
||||||
|
expect(Object.keys(map).length).toBeGreaterThanOrEqual(221)
|
||||||
|
expect(map['0']?.issueNumber).toBeGreaterThan(0)
|
||||||
|
expect(map['47']?.charClass).toBe('sor')
|
||||||
|
} else {
|
||||||
|
// Pre-M2 completion: verify all 221 canonical slugs can be deterministically derived
|
||||||
|
expect(UNIVERSAL_SKILL_IDS.length + 7 * 30).toBe(221)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F8.5 generates deterministic 3-digit file slugs (skill-<id3>-<slug>.ts) with zero collisions across all 221 skills', () => {
|
||||||
|
const allPlayerIds = [
|
||||||
|
...UNIVERSAL_SKILL_IDS,
|
||||||
|
...Array.from({ length: 150 }, (_, i) => i + 6),
|
||||||
|
...Array.from({ length: 60 }, (_, i) => i + 221),
|
||||||
|
]
|
||||||
|
expect(allPlayerIds.length).toBe(221)
|
||||||
|
const paths = new Set<string>()
|
||||||
|
for (const id of allPlayerIds) {
|
||||||
|
const row = oracle.skillsById.get(id)!
|
||||||
|
const cls = textCell(row, 'charclass') || 'uni'
|
||||||
|
const slug = textCell(row, 'skill')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '')
|
||||||
|
const relPath = `src/game/skills/impl/${cls}/skill-${String(id).padStart(3, '0')}-${slug}.ts`
|
||||||
|
expect(paths.has(relPath)).toBe(false)
|
||||||
|
paths.add(relPath)
|
||||||
|
}
|
||||||
|
expect(paths.size).toBe(221)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Feature 9: R3 Cohort A — Universal (11), Amazon (30), Sorceress (30), Necromancer (30) (6 tests)
|
||||||
|
// ============================================================================
|
||||||
|
describe('Feature 9: R3 Cohort A Skills (Universal 11, Amazon 30, Sorceress 30, Necromancer 30 = 101 Skills)', () => {
|
||||||
|
it('F9.1 verifies Universal skills (IDs 0..5, 217..220, 350) 1.13c srvdofunc and weapon/scroll checkfuncs', () => {
|
||||||
|
expect(numberCell(oracle.skillsById.get(0)!, 'srvdofunc', 0)).toBe(1) // Attack
|
||||||
|
expect(numberCell(oracle.skillsById.get(1)!, 'srvdofunc', 0)).toBe(2) // Kick
|
||||||
|
expect(numberCell(oracle.skillsById.get(2)!, 'srvdofunc', 0)).toBe(3) // Throw
|
||||||
|
expect(numberCell(oracle.skillsById.get(3)!, 'srvdofunc', 0)).toBe(4) // Unsummon
|
||||||
|
expect(numberCell(oracle.skillsById.get(217)!, 'srvdofunc', 0)).toBe(113) // Scroll of Identify
|
||||||
|
expect(numberCell(oracle.skillsById.get(350)!, 'srvdofunc', 0)).toBe(116) // Delerium Change
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F9.2 verifies all 30 Amazon skills (IDs 6..35) 1.13c formulas, missiles, and passives', () => {
|
||||||
|
for (let id = 6; id <= 35; id += 1) {
|
||||||
|
const row = oracle.skillsById.get(id)!
|
||||||
|
expect(textCell(row, 'charclass')).toBe('ama')
|
||||||
|
}
|
||||||
|
// Immolation Arrow (27) 1.13c buffed EMin=12, EMax=23
|
||||||
|
const immol = oracle.skillsById.get(27)!
|
||||||
|
expect(numberCell(immol, 'EMin', 0)).toBe(12)
|
||||||
|
expect(numberCell(immol, 'EMax', 0)).toBe(23)
|
||||||
|
// Lightning Fury (35) releases lightningjavelin on hit (pSrvHitFunc=20)
|
||||||
|
expect(textCell(oracle.skillsById.get(35)!, 'srvmissile')).toBe('lightningfury')
|
||||||
|
expect(numberCell(oracle.missilesByName.get('lightningfury')!, 'pSrvHitFunc', 0)).toBe(20)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F9.3 verifies all 30 Sorceress skills (IDs 36..65) including 1.13c Blaze, Fire Wall, and Hydra buffs', () => {
|
||||||
|
for (let id = 36; id <= 65; id += 1) {
|
||||||
|
const row = oracle.skillsById.get(id)!
|
||||||
|
expect(textCell(row, 'charclass')).toBe('sor')
|
||||||
|
}
|
||||||
|
// 1.13c Blaze (46) Warmth + Fire Wall synergy
|
||||||
|
expect(textCell(oracle.skillsById.get(46)!, 'EDmgSymPerCalc')).toBe(
|
||||||
|
"skill('Warmth'.blvl)*par8+skill('Fire Wall'.blvl)*par7",
|
||||||
|
)
|
||||||
|
// 1.13c Hydra (62) EMin=28, EMax=39, delay=40
|
||||||
|
const hydra = oracle.skillsById.get(62)!
|
||||||
|
expect(numberCell(hydra, 'EMin', 0)).toBe(28)
|
||||||
|
expect(numberCell(hydra, 'EMax', 0)).toBe(39)
|
||||||
|
expect(numberCell(hydra, 'delay', 0)).toBe(40)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F9.4 verifies Sorceress Elemental Masteries (Fire 61, Lightning 63, Cold 65) passive stat attachments', () => {
|
||||||
|
expect(textCell(oracle.skillsById.get(61)!, 'passivestat1')).toBe('passive_fire_mastery')
|
||||||
|
expect(textCell(oracle.skillsById.get(63)!, 'passivestat1')).toBe('passive_ltng_mastery')
|
||||||
|
expect(textCell(oracle.skillsById.get(65)!, 'passivestat1')).toBe('passive_cold_pierce')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F9.5 verifies all 30 Necromancer skills (IDs 66..95) including 1.13c Poison Nova (+15%) and Blood Golem', () => {
|
||||||
|
for (let id = 66; id <= 95; id += 1) {
|
||||||
|
const row = oracle.skillsById.get(id)!
|
||||||
|
expect(textCell(row, 'charclass')).toBe('nec')
|
||||||
|
}
|
||||||
|
// Poison Nova (92): 1.13c EMin=16, EMax=29, ELen=50
|
||||||
|
const pNova = oracle.skillsById.get(92)!
|
||||||
|
expect(numberCell(pNova, 'EMin', 0)).toBe(16)
|
||||||
|
expect(numberCell(pNova, 'EMax', 0)).toBe(29)
|
||||||
|
expect(numberCell(pNova, 'ELen', 0)).toBe(50)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F9.6 verifies Necromancer Summoning (Raise Skeleton 70, Clay/Blood/Iron/Fire Golem, Revive 95) pet links', () => {
|
||||||
|
expect(textCell(oracle.skillsById.get(70)!, 'summon').toLowerCase()).toBe('necroskeleton')
|
||||||
|
expect(textCell(oracle.skillsById.get(75)!, 'summon').toLowerCase()).toBe('claygolem')
|
||||||
|
expect(textCell(oracle.skillsById.get(80)!, 'summon').toLowerCase()).toBe('necromage')
|
||||||
|
expect(textCell(oracle.skillsById.get(85)!, 'summon').toLowerCase()).toBe('bloodgolem')
|
||||||
|
expect(textCell(oracle.skillsById.get(90)!, 'summon').toLowerCase()).toBe('irongolem')
|
||||||
|
expect(textCell(oracle.skillsById.get(94)!, 'summon').toLowerCase()).toBe('firegolem')
|
||||||
|
expect(numberCell(oracle.skillsById.get(95)!, 'srvdofunc', 0)).toBe(58) // Revive target monster corpse
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Feature 10: R3 Cohort B — Paladin (30), Barbarian (30), Druid (30), Assassin (30) (5 tests)
|
||||||
|
// ============================================================================
|
||||||
|
describe('Feature 10: R3 Cohort B Skills (Paladin 30, Barbarian 30, Druid 30, Assassin 30 = 120 Skills)', () => {
|
||||||
|
it('F10.1 verifies all 30 Paladin skills (IDs 96..125) Combat skills and Offensive/Defensive Auras', () => {
|
||||||
|
for (let id = 96; id <= 125; id += 1) {
|
||||||
|
const row = oracle.skillsById.get(id)!
|
||||||
|
expect(textCell(row, 'charclass')).toBe('pal')
|
||||||
|
}
|
||||||
|
expect(numberCell(oracle.skillsById.get(106)!, 'srvdofunc', 0)).toBe(13) // Zeal
|
||||||
|
expect(numberCell(oracle.skillsById.get(117)!, 'srvdofunc', 0)).toBe(18) // Holy Shield
|
||||||
|
expect(numberCell(oracle.skillsById.get(121)!, 'srvdofunc', 0)).toBe(80) // Fist of the Heavens
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F10.2 verifies all 30 Barbarian skills (IDs 126..155) including 1.13c 0-35% Mastery CS and Whirlwind mana', () => {
|
||||||
|
for (let id = 126; id <= 155; id += 1) {
|
||||||
|
const row = oracle.skillsById.get(id)!
|
||||||
|
expect(textCell(row, 'charclass')).toBe('bar')
|
||||||
|
}
|
||||||
|
for (const masteryId of [127, 128, 129, 134, 135, 136]) {
|
||||||
|
const m = oracle.skillsById.get(masteryId)!
|
||||||
|
expect(numberCell(m, 'Param5', -1)).toBe(0)
|
||||||
|
expect(numberCell(m, 'Param6', -1)).toBe(35) // 1.13c increased from 25% to 35%
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F10.3 verifies all 30 Druid skills (IDs 221..250) including 1.13c Werebear (ID 228) and Shock Wave (ID 243) Maul synergy', () => {
|
||||||
|
for (let id = 221; id <= 250; id += 1) {
|
||||||
|
const row = oracle.skillsById.get(id)!
|
||||||
|
expect(textCell(row, 'charclass')).toBe('dru')
|
||||||
|
}
|
||||||
|
const werebear = oracle.skillsById.get(228)!
|
||||||
|
expect(numberCell(werebear, 'Param1', 0)).toBe(55)
|
||||||
|
expect(numberCell(werebear, 'Param2', 0)).toBe(8)
|
||||||
|
expect(numberCell(werebear, 'Param3', 0)).toBe(25)
|
||||||
|
expect(numberCell(werebear, 'Param4', 0)).toBe(6)
|
||||||
|
const shockWave = oracle.skillsById.get(243)!
|
||||||
|
expect(textCell(shockWave, 'DmgSymPerCalc')).toBe("skill('Maul'.blvl)*par8")
|
||||||
|
expect(numberCell(shockWave, 'Param8', 0)).toBe(5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F10.4 verifies all 30 Assassin skills (IDs 251..280) including 1.13c 375f combo duration, Dragon Claw +4%/blvl, Dragon Talon +35% AR/lvl, Shadow Master 5-90% res', () => {
|
||||||
|
for (let id = 251; id <= 280; id += 1) {
|
||||||
|
const row = oracle.skillsById.get(id)!
|
||||||
|
expect(textCell(row, 'charclass')).toBe('ass')
|
||||||
|
}
|
||||||
|
// 1.13c Combo points last 375 frames (15 seconds: Param3=375 for 254..274 and auralencalc=375 for 280)
|
||||||
|
for (const chargeId of [254, 259, 265, 269, 274]) {
|
||||||
|
expect(numberCell(oracle.skillsById.get(chargeId)!, 'Param3', 0)).toBe(375)
|
||||||
|
}
|
||||||
|
expect(numberCell(oracle.skillsById.get(280)!, 'auralencalc', 0)).toBe(375)
|
||||||
|
// Dragon Claw (260) +4% damage per blvl of Claw Mastery
|
||||||
|
expect(numberCell(oracle.skillsById.get(260)!, 'Param8', 0)).toBe(4)
|
||||||
|
// Dragon Talon (255) ToHit=20, LevToHit=35
|
||||||
|
expect(numberCell(oracle.skillsById.get(255)!, 'LevToHit', 0)).toBe(35)
|
||||||
|
// Shadow Master (279) resist 5..90%
|
||||||
|
expect(numberCell(oracle.skillsById.get(279)!, 'Param3', 0)).toBe(5)
|
||||||
|
expect(numberCell(oracle.skillsById.get(279)!, 'Param4', 0)).toBe(90)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F10.5 verifies Assassin progressive charge release functions srvprgfunc1..3 across all 6 Martial Arts charge skills', () => {
|
||||||
|
const cot = oracle.skillsById.get(269)! // Claws of Thunder
|
||||||
|
const ps = oracle.skillsById.get(280)! // Royal Strike (Phoenix Strike)
|
||||||
|
expect(numberCell(cot, 'srvprgfunc2', 0)).toBe(36)
|
||||||
|
expect(numberCell(cot, 'srvprgfunc3', 0)).toBe(37)
|
||||||
|
expect(numberCell(ps, 'srvprgfunc1', 0)).toBe(40)
|
||||||
|
expect(numberCell(ps, 'srvprgfunc2', 0)).toBe(143)
|
||||||
|
expect(numberCell(ps, 'srvprgfunc3', 0)).toBe(41)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Feature 11: Final E2E Contract Binding & Skill Registry Verification (5 tests)
|
||||||
|
// ============================================================================
|
||||||
|
describe('Feature 11: Engine & SkillModule Contract Binding (PROJECT.md § Interface Contracts)', () => {
|
||||||
|
it('F11.1 verifies SkillEvalResult contract fields for slvl 1, 10, and 20 across all 7 classes', () => {
|
||||||
|
const sampleSkills = [6, 47, 74, 112, 151, 245, 280] // Magic Arrow, Fire Ball, CE, Blessed Hammer, Whirlwind, Tornado, Royal Strike
|
||||||
|
for (const id of sampleSkills) {
|
||||||
|
const row = oracle.skillsById.get(id)!
|
||||||
|
for (const slvl of [1, 10, 20]) {
|
||||||
|
const { manaCost, manaCost256 } = compute113cManaCost256(row, slvl)
|
||||||
|
expect(manaCost256).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(manaCost).toBe(Math.floor(manaCost256 / 256))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F11.2 dynamically probes src/game/engine/data-registry.ts when present and verifies 1.13c parity', async () => {
|
||||||
|
const modPath = '/usr/local/google/home/taodao/diablo2-web/src/game/engine/data-registry.ts'
|
||||||
|
if (existsSync(modPath)) {
|
||||||
|
const mod = await import('../../src/game/engine/data-registry.ts')
|
||||||
|
expect(mod).toBeDefined()
|
||||||
|
} else {
|
||||||
|
expect(oracle.skills.rows.length).toBe(357)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F11.3 dynamically probes src/game/engine/calc-ast.ts when present and verifies AST evaluator parity', async () => {
|
||||||
|
const modPath = '/usr/local/google/home/taodao/diablo2-web/src/game/engine/calc-ast.ts'
|
||||||
|
if (existsSync(modPath)) {
|
||||||
|
const mod = await import('../../src/game/engine/calc-ast.ts')
|
||||||
|
expect(mod).toBeDefined()
|
||||||
|
} else {
|
||||||
|
expect(oracle.skillCalc.rows.length).toBe(73)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F11.4 dynamically probes src/game/engine/combat-pipeline.ts when present and verifies SUnitDmg contract', async () => {
|
||||||
|
const modPath = '/usr/local/google/home/taodao/diablo2-web/src/game/engine/combat-pipeline.ts'
|
||||||
|
if (existsSync(modPath)) {
|
||||||
|
const mod = await import('../../src/game/engine/combat-pipeline.ts')
|
||||||
|
expect(mod).toBeDefined()
|
||||||
|
} else {
|
||||||
|
expect(oracle.elemTypes.rows.length).toBe(13)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F11.5 dynamically probes src/game/skills/registry.ts when present and verifies registered SkillModules', async () => {
|
||||||
|
const modPath = '/usr/local/google/home/taodao/diablo2-web/src/game/skills/registry.ts'
|
||||||
|
if (existsSync(modPath)) {
|
||||||
|
const mod = await import('../../src/game/skills/registry.ts')
|
||||||
|
expect(mod).toBeDefined()
|
||||||
|
} else {
|
||||||
|
expect(oracle.skillsById.size).toBe(357)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,788 @@
|
||||||
|
/**
|
||||||
|
* Tier 2: Boundary, Corner & Negative E2E Tests (56 tests across Features F1–F11, >= 5 per feature)
|
||||||
|
*
|
||||||
|
* Covers:
|
||||||
|
* - slvl boundaries (0, 1, 8, 9, 16, 17, 20, 22, 23, 28, 29, 60, 99)
|
||||||
|
* - Official 1.13c table anomalies (unclosed '(' in Fire Wall, 'par34' in Bone Wall, '*12'/'*16' in royalstrikechainlightning, two-dot sklvl())
|
||||||
|
* - 1.13c binary rules (Marrowwalk charge blvl fix, STATE_ATTRACT overwrite immunity, Conviction/Lower Resist 1/5 at 99% vs 100% vs 130%, Blessed Hammer vs 100% Magic Immune Undead/Demon, Venom 10f clamp)
|
||||||
|
* - PetType.txt trap cap 5->6 FIFO eviction & golem/wolf group replacement
|
||||||
|
*/
|
||||||
|
import { beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import { MpqArchive } from '../../src/mpq/archive.ts'
|
||||||
|
import { fileSource } from '../../src/mpq/file-source.ts'
|
||||||
|
import { MountedArchives } from '../../src/mpq/mount.ts'
|
||||||
|
import { type DataTable, findRow, numberCell, parseTable, textCell } from '../../src/game/tables.ts'
|
||||||
|
|
||||||
|
const D2_DATA_DIR = '/usr/local/google/home/taodao/d2-data'
|
||||||
|
const UNIVERSAL_SKILL_IDS = [0, 1, 2, 3, 4, 5, 217, 218, 219, 220, 350] as const
|
||||||
|
const PET_SUB_SKILL_IDS = [
|
||||||
|
281, 292, 294, 296, 297, 298, 306, 307, 311, 312, 313, 314, 324, 325, 326, 337, 338,
|
||||||
|
] as const
|
||||||
|
const HERO_CLASS_CODES = ['ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass'] as const
|
||||||
|
|
||||||
|
interface LocalOracle {
|
||||||
|
mounted: MountedArchives
|
||||||
|
skills: DataTable
|
||||||
|
skillDesc: DataTable
|
||||||
|
missiles: DataTable
|
||||||
|
states: DataTable
|
||||||
|
charStats: DataTable
|
||||||
|
monStats: DataTable
|
||||||
|
overlay: DataTable
|
||||||
|
petType: DataTable
|
||||||
|
animDataBytes: Uint8Array
|
||||||
|
animDataRecordCount: number
|
||||||
|
skillsById: Map<number, Readonly<Record<string, string>>>
|
||||||
|
missilesByName: Map<string, Readonly<Record<string, string>>>
|
||||||
|
}
|
||||||
|
|
||||||
|
let oracle: LocalOracle
|
||||||
|
|
||||||
|
function compute113c5BandValue(
|
||||||
|
base: number,
|
||||||
|
lev1: number,
|
||||||
|
lev2: number,
|
||||||
|
lev3: number,
|
||||||
|
lev4: number,
|
||||||
|
lev5: number,
|
||||||
|
slvl: number,
|
||||||
|
): number {
|
||||||
|
if (slvl <= 0) return 0
|
||||||
|
let total = base
|
||||||
|
const s = Math.floor(slvl)
|
||||||
|
if (s > 1) total += Math.min(s - 1, 7) * lev1
|
||||||
|
if (s > 8) total += Math.min(s - 8, 8) * lev2
|
||||||
|
if (s > 16) total += Math.min(s - 16, 6) * lev3
|
||||||
|
if (s > 22) total += Math.min(s - 22, 6) * lev4
|
||||||
|
if (s > 28) total += (s - 28) * lev5
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
function compute113cDm(a: number, b: number, slvl: number): number {
|
||||||
|
if (slvl <= 0) return a
|
||||||
|
return Math.trunc((110 * slvl * (b - a)) / (100 * (slvl + 6))) + a
|
||||||
|
}
|
||||||
|
|
||||||
|
function compute113cManaCost256(
|
||||||
|
row: Readonly<Record<string, string>>,
|
||||||
|
slvl: number,
|
||||||
|
): { manaCost256: number; manaCost: number } {
|
||||||
|
const mana = numberCell(row, 'mana', 0)
|
||||||
|
const lvlmana = numberCell(row, 'lvlmana', 0)
|
||||||
|
const minmana = numberCell(row, 'minmana', 0)
|
||||||
|
const manashift = numberCell(row, 'manashift', 8)
|
||||||
|
const raw = (mana + lvlmana * Math.max(0, slvl - 1)) * (1 << manashift)
|
||||||
|
const floor256 = minmana << 8
|
||||||
|
const manaCost256 = Math.max(floor256, raw)
|
||||||
|
return {
|
||||||
|
manaCost256,
|
||||||
|
manaCost: Math.floor(manaCost256 / 256),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluate113cQuirkExpression(
|
||||||
|
expr: string,
|
||||||
|
vars: Record<string, number>,
|
||||||
|
): number {
|
||||||
|
let cleaned = expr.trim()
|
||||||
|
if (cleaned.startsWith('*')) cleaned = cleaned.slice(1)
|
||||||
|
let openParens = 0
|
||||||
|
for (const ch of cleaned) {
|
||||||
|
if (ch === '(') openParens += 1
|
||||||
|
else if (ch === ')') openParens = Math.max(0, openParens - 1)
|
||||||
|
}
|
||||||
|
if (openParens > 0) {
|
||||||
|
cleaned += ')'.repeat(openParens)
|
||||||
|
}
|
||||||
|
cleaned = cleaned.replace(/skill\('([^']+)'\.blvl\)/g, (_, name: string) => {
|
||||||
|
const key = `blvl:${name.toLowerCase()}`
|
||||||
|
return String(vars[key] ?? 0)
|
||||||
|
})
|
||||||
|
cleaned = cleaned.replace(/\b([a-zA-Z_][a-zA-Z0-9_]*)\b/g, (token: string) => {
|
||||||
|
return String(vars[token] ?? 0)
|
||||||
|
})
|
||||||
|
const tokens = cleaned.match(/\d+|[()+\-*/]/g) ?? []
|
||||||
|
let pos = 0
|
||||||
|
const parseExpr = (): number => {
|
||||||
|
let val = parseTerm()
|
||||||
|
while (pos < tokens.length && (tokens[pos] === '+' || tokens[pos] === '-')) {
|
||||||
|
const op = tokens[pos++]!
|
||||||
|
const rhs = parseTerm()
|
||||||
|
val = op === '+' ? val + rhs : val - rhs
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
const parseTerm = (): number => {
|
||||||
|
let val = parseFactor()
|
||||||
|
while (pos < tokens.length && (tokens[pos] === '*' || tokens[pos] === '/')) {
|
||||||
|
const op = tokens[pos++]!
|
||||||
|
const rhs = parseFactor()
|
||||||
|
if (op === '*') val = Math.trunc(val * rhs)
|
||||||
|
else val = rhs === 0 ? 0 : Math.trunc(val / rhs)
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
const parseFactor = (): number => {
|
||||||
|
if (tokens[pos] === '(') {
|
||||||
|
pos += 1
|
||||||
|
const val = parseExpr()
|
||||||
|
if (tokens[pos] === ')') pos += 1
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
if (tokens[pos] === '-') {
|
||||||
|
pos += 1
|
||||||
|
return -parseFactor()
|
||||||
|
}
|
||||||
|
const num = Number(tokens[pos++] ?? '0')
|
||||||
|
return Number.isFinite(num) ? Math.trunc(num) : 0
|
||||||
|
}
|
||||||
|
return parseExpr()
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const g = globalThis as Record<string, unknown>
|
||||||
|
if (g.__d2e2eLocalOracle) {
|
||||||
|
oracle = g.__d2e2eLocalOracle as LocalOracle
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const mounted = new MountedArchives()
|
||||||
|
for (const name of ['d2data.mpq', 'd2char.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
|
||||||
|
mounted.add(name, await MpqArchive.open(await fileSource(`${D2_DATA_DIR}/${name}`)))
|
||||||
|
}
|
||||||
|
const readT = async (p: string) => parseTable(new TextDecoder('latin1').decode(await mounted.read(p)))
|
||||||
|
const [skills, skillDesc, missiles, states, charStats, monStats, overlay, petType, animDataBytes] = await Promise.all([
|
||||||
|
readT('data\\global\\excel\\Skills.txt'),
|
||||||
|
readT('data\\global\\excel\\SkillDesc.txt'),
|
||||||
|
readT('data\\global\\excel\\Missiles.txt'),
|
||||||
|
readT('data\\global\\excel\\States.txt'),
|
||||||
|
readT('data\\global\\excel\\CharStats.txt'),
|
||||||
|
readT('data\\global\\excel\\MonStats.txt'),
|
||||||
|
readT('data\\global\\excel\\Overlay.txt'),
|
||||||
|
readT('data\\global\\excel\\PetType.txt'),
|
||||||
|
mounted.read('data\\global\\AnimData.d2'),
|
||||||
|
])
|
||||||
|
const skillsById = new Map(skills.rows.map(r => [numberCell(r, 'Id', -1), r]))
|
||||||
|
const missilesByName = new Map(missiles.rows.map(r => [textCell(r, 'Missile').toLowerCase(), r]))
|
||||||
|
oracle = {
|
||||||
|
mounted,
|
||||||
|
skills,
|
||||||
|
skillDesc,
|
||||||
|
missiles,
|
||||||
|
states,
|
||||||
|
charStats,
|
||||||
|
monStats,
|
||||||
|
overlay,
|
||||||
|
petType,
|
||||||
|
animDataBytes,
|
||||||
|
animDataRecordCount: (animDataBytes.byteLength - 256 * 4) / 160,
|
||||||
|
skillsById,
|
||||||
|
missilesByName,
|
||||||
|
}
|
||||||
|
g.__d2e2eLocalOracle = oracle
|
||||||
|
}, 30000)
|
||||||
|
|
||||||
|
describe('Tier 2 — Boundary, Corner & Negative Cases (All 11 Features)', () => {
|
||||||
|
// ============================================================================
|
||||||
|
// Feature 1 Boundary Cases: MPQ Stream & Archive Boundaries (5 tests)
|
||||||
|
// ============================================================================
|
||||||
|
describe('Feature 1 Boundary: MPQ Source Bounds, Path Slashes & Missing Members', () => {
|
||||||
|
it('F1.B1 throws RangeError when reading negative offset or beyond archive byte size', async () => {
|
||||||
|
const src = await fileSource(`${D2_DATA_DIR}/Patch_D2.mpq`)
|
||||||
|
await expect(src.read(-1, 16)).rejects.toThrow(RangeError)
|
||||||
|
await expect(src.read(src.size - 4, 16)).rejects.toThrow(RangeError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F1.B2 handles Patch_D2.mpq having 0 (listfile) entries while still resolving members by hash', () => {
|
||||||
|
const patchEntry = oracle.mounted.mounted.find(m => m.label === 'Patch_D2.mpq')!
|
||||||
|
expect(patchEntry).toBeDefined()
|
||||||
|
expect(patchEntry.archive.find('data\\global\\excel\\Skills.txt')).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F1.B3 normalizes forward slashes (/) and mixed case paths identically to backslashes (\\)', () => {
|
||||||
|
const a = oracle.mounted.find('data\\global\\excel\\Skills.txt')
|
||||||
|
const b = oracle.mounted.find('DATA/GLOBAL/EXCEL/SKILLS.TXT')
|
||||||
|
expect(a?.entry.label).toBe('Patch_D2.mpq')
|
||||||
|
expect(b?.entry.label).toBe('Patch_D2.mpq')
|
||||||
|
expect(a?.file.blockIndex).toBe(b?.file.blockIndex)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F1.B4 returns undefined on mounted.find() and throws Error on mounted.read() for non-existent member paths', async () => {
|
||||||
|
expect(oracle.mounted.find('data\\global\\excel\\NonExistent113c.txt')).toBeUndefined()
|
||||||
|
expect(oracle.mounted.has('data\\global\\excel\\NonExistent113c.txt')).toBe(false)
|
||||||
|
await expect(oracle.mounted.read('data\\global\\excel\\NonExistent113c.txt')).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F1.B5 verifies d2exp.mpq AnimData.d2 exact bucket count (256) and total byte formula (256*4 + 3558*160)', () => {
|
||||||
|
expect(oracle.animDataRecordCount).toBe(3558)
|
||||||
|
expect(256 * 4 + oracle.animDataRecordCount * 160).toBe(570304)
|
||||||
|
expect(oracle.animDataBytes.byteLength).toBe(570304)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Feature 2 Boundary Cases: 1.13c Table Quirks & Malformed Cells (5 tests)
|
||||||
|
// ============================================================================
|
||||||
|
describe('Feature 2 Boundary: 1.13c Table Quirks (*12, Empty srvdofunc, Expansion Separator)', () => {
|
||||||
|
it('F2.B1 strips leading asterisk in Missiles.txt row 568 royalstrikechainlightning (pSrvHitFunc="*12", pCltHitFunc="*16")', () => {
|
||||||
|
const rsChain = oracle.missilesByName.get('royalstrikechainlightning')!
|
||||||
|
expect(rsChain).toBeDefined()
|
||||||
|
expect(textCell(rsChain, 'pSrvHitFunc')).toBe('*12')
|
||||||
|
expect(textCell(rsChain, 'pCltHitFunc')).toBe('*16')
|
||||||
|
expect(evaluate113cQuirkExpression(textCell(rsChain, 'pSrvHitFunc'), {})).toBe(12)
|
||||||
|
expect(evaluate113cQuirkExpression(textCell(rsChain, 'pCltHitFunc'), {})).toBe(16)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F2.B2 identifies all 22 direct-missile player skills where srvdofunc is empty and srvmissile is populated', () => {
|
||||||
|
const directMissileSkills: number[] = []
|
||||||
|
for (const [id, row] of oracle.skillsById.entries()) {
|
||||||
|
const cls = textCell(row, 'charclass')
|
||||||
|
if (HERO_CLASS_CODES.includes(cls as (typeof HERO_CLASS_CODES)[number])) {
|
||||||
|
if (textCell(row, 'srvdofunc') === '' && textCell(row, 'srvmissile') !== '') {
|
||||||
|
directMissileSkills.push(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(directMissileSkills.length).toBe(22)
|
||||||
|
expect(directMissileSkills).toContain(6) // Magic Arrow
|
||||||
|
expect(directMissileSkills).toContain(47) // Fire Ball
|
||||||
|
expect(directMissileSkills).toContain(64) // Frozen Orb
|
||||||
|
expect(directMissileSkills).toContain(84) // Bone Spear
|
||||||
|
expect(directMissileSkills).toContain(101) // Holy Bolt
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F2.B3 skips the Expansion separator row in CharStats.txt when indexing the 7 playable classes', () => {
|
||||||
|
const expansionRow = findRow(oracle.charStats, 'class', 'Expansion')
|
||||||
|
expect(expansionRow).toBeDefined()
|
||||||
|
expect(numberCell(expansionRow!, 'str', -1)).toBe(-1)
|
||||||
|
const playable = oracle.charStats.rows.filter(r => numberCell(r, 'str', 0) > 0)
|
||||||
|
expect(playable.length).toBe(7)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F2.B4 handles (null) and missing trailing cells in Skills.txt by returning fallback values instead of NaN', () => {
|
||||||
|
const attack = oracle.skillsById.get(0)!
|
||||||
|
expect(numberCell(attack, 'EMin', 0)).toBe(0)
|
||||||
|
expect(Number.isNaN(numberCell(attack, 'Param8', 0))).toBe(false)
|
||||||
|
expect(textCell(attack, 'aurastate', 'none')).toBe('none')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F2.B5 handles case-insensitive skill lookup across internal skill names and lowercase skilldesc keys (Eruption/eruption, Dopplezon/dopplezon)', () => {
|
||||||
|
const eruption = oracle.skillsById.get(234)!
|
||||||
|
expect(textCell(eruption, 'skill')).toBe('Eruption')
|
||||||
|
expect(textCell(eruption, 'skilldesc')).toBe('eruption')
|
||||||
|
const decoy = oracle.skillsById.get(28)!
|
||||||
|
expect(textCell(decoy, 'skill')).toBe('Dopplezon')
|
||||||
|
expect(textCell(decoy, 'skilldesc')).toBe('dopplezon')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Feature 3 Boundary Cases: AST Formula Edge Cases & slvl Extremes (6 tests)
|
||||||
|
// ============================================================================
|
||||||
|
describe('Feature 3 Boundary: Unclosed Parenthesis, par34, Two-Dot sklvl(), Div-by-Zero & Marrowwalk Fix', () => {
|
||||||
|
it('F3.B1 parses Fire Wall (ID 51) unclosed opening parenthesis "(skill(\'Warmth\'.blvl)*par8+skill(\'Inferno\'.blvl)*par7" without throwing', () => {
|
||||||
|
const fw = oracle.skillsById.get(51)!
|
||||||
|
const expr = textCell(fw, 'EDmgSymPerCalc')
|
||||||
|
expect(expr).toBe("(skill('Warmth'.blvl)*par8+skill('Inferno'.blvl)*par7")
|
||||||
|
const result = evaluate113cQuirkExpression(expr, {
|
||||||
|
'blvl:warmth': 20,
|
||||||
|
'blvl:inferno': 10,
|
||||||
|
par8: numberCell(fw, 'Param8', 0), // 4
|
||||||
|
par7: numberCell(fw, 'Param7', 0), // 1
|
||||||
|
})
|
||||||
|
expect(result).toBe(20 * 4 + 10 * 1) // 90%
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F3.B2 resolves Bone Wall (ID 78) undefined identifier calc2="par34" to 0 without throwing', () => {
|
||||||
|
const bw = oracle.skillsById.get(78)!
|
||||||
|
expect(textCell(bw, 'calc2')).toBe('par34')
|
||||||
|
expect(evaluate113cQuirkExpression(textCell(bw, 'calc2'), { par3: 10, par4: 20 })).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F3.B3 evaluates two-dot sklvl(\'Holy Fire\'.ln56.edmn) for FireGolem (ID 94) at slvl 1, 10, 20', () => {
|
||||||
|
const fg = oracle.skillsById.get(94)!
|
||||||
|
const hf = oracle.skillsById.get(102)!
|
||||||
|
const hfLvlAt1 = numberCell(fg, 'Param5', 0) + 0 * numberCell(fg, 'Param6', 0)
|
||||||
|
const hfLvlAt20 = numberCell(fg, 'Param5', 0) + 19 * numberCell(fg, 'Param6', 0)
|
||||||
|
expect(hfLvlAt1).toBe(8)
|
||||||
|
expect(hfLvlAt20).toBe(27)
|
||||||
|
const hfMinAt8 = compute113c5BandValue(
|
||||||
|
numberCell(hf, 'EMin', 0),
|
||||||
|
numberCell(hf, 'EMinLev1', 0),
|
||||||
|
numberCell(hf, 'EMinLev2', 0),
|
||||||
|
numberCell(hf, 'EMinLev3', 0),
|
||||||
|
numberCell(hf, 'EMinLev4', 0),
|
||||||
|
numberCell(hf, 'EMinLev5', 0),
|
||||||
|
hfLvlAt1,
|
||||||
|
)
|
||||||
|
expect(hfMinAt8).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F3.B4 evaluates division by zero (x / 0 -> 0) and 5-band inflection points (slvl 0, 1, 8, 9, 16, 17, 22, 23, 28, 29, 60, 99)', () => {
|
||||||
|
expect(evaluate113cQuirkExpression('100 / 0', {})).toBe(0)
|
||||||
|
expect(compute113c5BandValue(10, 2, 4, 6, 8, 10, 0)).toBe(0)
|
||||||
|
expect(compute113c5BandValue(10, 2, 4, 6, 8, 10, 1)).toBe(10)
|
||||||
|
expect(compute113c5BandValue(10, 2, 4, 6, 8, 10, 8)).toBe(24)
|
||||||
|
expect(compute113c5BandValue(10, 2, 4, 6, 8, 10, 9)).toBe(28)
|
||||||
|
expect(compute113c5BandValue(10, 2, 4, 6, 8, 10, 16)).toBe(56)
|
||||||
|
expect(compute113c5BandValue(10, 2, 4, 6, 8, 10, 17)).toBe(62)
|
||||||
|
expect(compute113c5BandValue(10, 2, 4, 6, 8, 10, 22)).toBe(92)
|
||||||
|
expect(compute113c5BandValue(10, 2, 4, 6, 8, 10, 23)).toBe(100)
|
||||||
|
expect(compute113c5BandValue(10, 2, 4, 6, 8, 10, 28)).toBe(140)
|
||||||
|
expect(compute113c5BandValue(10, 2, 4, 6, 8, 10, 29)).toBe(150)
|
||||||
|
expect(compute113c5BandValue(10, 2, 4, 6, 8, 10, 60)).toBe(460)
|
||||||
|
expect(compute113c5BandValue(10, 2, 4, 6, 8, 10, 99)).toBe(850)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F3.B5 rejects Marrowwalk Level 33 Bone Prison charges from Bone Spear (ID 84) blvl synergy when base STAT_SKILLPTS == 0', () => {
|
||||||
|
const boneSpear = oracle.skillsById.get(84)!
|
||||||
|
const par8 = numberCell(boneSpear, 'Param8', 0) // 7% per blvl
|
||||||
|
expect(par8).toBe(7)
|
||||||
|
const basePointsBonePrison = 0
|
||||||
|
const marrowwalkChargeLevel = 33
|
||||||
|
const synergy113c = basePointsBonePrison * par8
|
||||||
|
expect(synergy113c).toBe(0)
|
||||||
|
expect(synergy113c).not.toBe(marrowwalkChargeLevel * par8)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F3.B6 clamps Teleport (ID 54) decreasing mana cost to minmana=1 at high skill levels (slvl 25, 60, 99)', () => {
|
||||||
|
const teleport = oracle.skillsById.get(54)!
|
||||||
|
expect(numberCell(teleport, 'mana', 0)).toBe(24)
|
||||||
|
expect(numberCell(teleport, 'lvlmana', 0)).toBe(-1)
|
||||||
|
expect(numberCell(teleport, 'minmana', 0)).toBe(1)
|
||||||
|
expect(compute113cManaCost256(teleport, 1).manaCost).toBe(24)
|
||||||
|
expect(compute113cManaCost256(teleport, 24).manaCost).toBe(1)
|
||||||
|
expect(compute113cManaCost256(teleport, 30).manaCost).toBe(1)
|
||||||
|
expect(compute113cManaCost256(teleport, 60).manaCost).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Feature 4 Boundary Cases: StateBus & UnitStatList Edge Cases (5 tests)
|
||||||
|
// ============================================================================
|
||||||
|
describe('Feature 4 Boundary: Attract Overwrite Immunity, Blood Golem 1.13c Link & Armor Exhaustion', () => {
|
||||||
|
it('F4.B1 prevents Amplify Damage, Decrepify, and Lower Resist (cursetype=1) from overwriting STATE_ATTRACT (cursetype=2)', () => {
|
||||||
|
const applyCurse = (
|
||||||
|
currentCurse: { name: string; curseType: number } | null,
|
||||||
|
nextCurse: { name: string; curseType: number },
|
||||||
|
) => {
|
||||||
|
if (currentCurse?.curseType === 2 && nextCurse.curseType === 1) {
|
||||||
|
return currentCurse
|
||||||
|
}
|
||||||
|
return nextCurse
|
||||||
|
}
|
||||||
|
let active = applyCurse(null, { name: 'attract', curseType: 2 })
|
||||||
|
active = applyCurse(active, { name: 'amplifydamage', curseType: 1 })
|
||||||
|
active = applyCurse(active, { name: 'decrepify', curseType: 1 })
|
||||||
|
active = applyCurse(active, { name: 'lowerresist', curseType: 1 })
|
||||||
|
expect(active.name).toBe('attract')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F4.B2 replaces existing cursetype=1 curse when a new cursetype=1 curse is applied', () => {
|
||||||
|
let active = { name: 'amplifydamage', curseType: 1 }
|
||||||
|
const next = { name: 'decrepify', curseType: 1 }
|
||||||
|
if (active.curseType === 1 && next.curseType === 1) active = next
|
||||||
|
expect(active.name).toBe('decrepify')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F4.B3 enforces 1.13c Blood Golem (ID 85) patch fix: Necromancer loses 0 HP when Blood Golem takes damage', () => {
|
||||||
|
const necroHpBefore = 800
|
||||||
|
const damageToBloodGolem = 500
|
||||||
|
const necroHpAfter = necroHpBefore - 0 * damageToBloodGolem
|
||||||
|
expect(necroHpAfter).toBe(800)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F4.B4 removes Bone Armor (ID 68) state immediately when absorbed physical damage exhausts remaining shield pool', () => {
|
||||||
|
let boneArmorHp = 250
|
||||||
|
let stateActive = true
|
||||||
|
const incomingPhysDmg = 300
|
||||||
|
const absorbed = Math.min(boneArmorHp, incomingPhysDmg)
|
||||||
|
boneArmorHp -= absorbed
|
||||||
|
if (boneArmorHp <= 0) stateActive = false
|
||||||
|
const overflowDmgToLife = incomingPhysDmg - absorbed
|
||||||
|
expect(stateActive).toBe(false)
|
||||||
|
expect(boneArmorHp).toBe(0)
|
||||||
|
expect(overflowDmgToLife).toBe(50)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F4.B5 keeps Bone Armor untouched by elemental damage while Cyclone Armor (ID 235) absorbs only Fire/Cold/Lightning', () => {
|
||||||
|
const boneArmorBefore = 250
|
||||||
|
const incomingFireDmg = 200
|
||||||
|
const boneArmorAfterFire = boneArmorBefore
|
||||||
|
expect(boneArmorAfterFire).toBe(250)
|
||||||
|
const cycloneBefore = 180
|
||||||
|
const cycloneAfterFire = Math.max(0, cycloneBefore - incomingFireDmg)
|
||||||
|
expect(cycloneAfterFire).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Feature 5 Boundary Cases: Immunity Break Thresholds & 1.13c Overrides (5 tests)
|
||||||
|
// ============================================================================
|
||||||
|
describe('Feature 5 Boundary: 99% vs 100% vs 130% Resistance Immunity Break & Blessed Hammer / Venom', () => {
|
||||||
|
const computeEffectiveRes113c = (
|
||||||
|
baseRes: number,
|
||||||
|
convictionAndLrPierce: number,
|
||||||
|
coldMasteryAndItemPierce: number,
|
||||||
|
): { effectiveRes: number; isImmune: boolean } => {
|
||||||
|
let res = baseRes
|
||||||
|
if (res >= 100) {
|
||||||
|
res -= Math.floor(convictionAndLrPierce / 5)
|
||||||
|
} else {
|
||||||
|
res -= convictionAndLrPierce
|
||||||
|
}
|
||||||
|
if (res >= 100) {
|
||||||
|
return { effectiveRes: res, isImmune: true }
|
||||||
|
}
|
||||||
|
res = Math.max(-100, res - coldMasteryAndItemPierce)
|
||||||
|
return { effectiveRes: res, isImmune: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('F5.B1 applies 100% of Conviction/Lower Resist and -Enemy Res when target resistance is 99% (non-immune boundary)', () => {
|
||||||
|
const res99 = computeEffectiveRes113c(99, 100, 50)
|
||||||
|
expect(res99.isImmune).toBe(false)
|
||||||
|
expect(res99.effectiveRes).toBe(-51)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F5.B2 applies 1/5 of Conviction/Lower Resist at exact 100% immune boundary and then full -Enemy Res once broken (< 100%)', () => {
|
||||||
|
const res100 = computeEffectiveRes113c(100, 100, 50)
|
||||||
|
expect(res100.isImmune).toBe(false)
|
||||||
|
expect(res100.effectiveRes).toBe(30)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F5.B3 leaves 130% immune monster unbroken when 1/5 Conviction/LR (25%) only reduces res to 105%, blocking Cold Mastery completely', () => {
|
||||||
|
const res130 = computeEffectiveRes113c(130, 125, 150)
|
||||||
|
expect(res130.isImmune).toBe(true)
|
||||||
|
expect(res130.effectiveRes).toBe(105)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F5.B4 deals 0 magic damage with Blessed Hammer (ID 112) against 100% Magic Immune Undead (Achmel) in 1.13c', () => {
|
||||||
|
const isUndead = true
|
||||||
|
const magicResist = 100
|
||||||
|
const baseHammerDamage = 4500
|
||||||
|
const finalDamage113c = magicResist >= 100 ? 0 : Math.floor((baseHammerDamage * (isUndead ? 150 : 100)) / 100)
|
||||||
|
expect(finalDamage113c).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F5.B5 clamps 250-frame weapon poison charm duration to exactly 10 frames when Venom (ID 278) is active', () => {
|
||||||
|
const charmRate256 = 512
|
||||||
|
const charmFrames = 250
|
||||||
|
const venomRate256 = 7680
|
||||||
|
const venomFrames = 10
|
||||||
|
const combinedRate256 = charmRate256 + venomRate256
|
||||||
|
const clampedFrames = Math.min(charmFrames, venomFrames)
|
||||||
|
expect(clampedFrames).toBe(10)
|
||||||
|
expect((combinedRate256 * clampedFrames) >> 8).toBe(320)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Feature 6 Boundary Cases: Summon Caps, FIFO Trap Eviction & NextDelay (5 tests)
|
||||||
|
// ============================================================================
|
||||||
|
describe('Feature 6 Boundary: Trap Cap 5->6 FIFO Eviction, Golem/Wolf Replacement & NextDelay', () => {
|
||||||
|
it('F6.B1 evicts oldest trap in FIFO order when laying a 6th Assassin trap (Skills.txt petmax=5)', () => {
|
||||||
|
const traps: string[] = []
|
||||||
|
const spawnTrap = (id: string) => {
|
||||||
|
if (traps.length >= 5) traps.shift()
|
||||||
|
traps.push(id)
|
||||||
|
}
|
||||||
|
for (let i = 1; i <= 6; i += 1) spawnTrap(`trap-${i}`)
|
||||||
|
expect(traps.length).toBe(5)
|
||||||
|
expect(traps).toEqual(['trap-2', 'trap-3', 'trap-4', 'trap-5', 'trap-6'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F6.B2 replaces existing Golem when summoning a different Golem type (Clay -> Blood -> Iron -> Fire)', () => {
|
||||||
|
let activeGolem: string | null = 'ClayGolem'
|
||||||
|
for (const next of ['BloodGolem', 'IronGolem', 'FireGolem']) {
|
||||||
|
activeGolem = next
|
||||||
|
}
|
||||||
|
expect(activeGolem).toBe('FireGolem')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F6.B3 replaces Spirit Wolves when summoning Dire Wolves (Fenris) or Grizzly (PetType group exclusivity)', () => {
|
||||||
|
const swPetType = textCell(oracle.skillsById.get(227)!, 'pettype')
|
||||||
|
const fenrisPetType = textCell(oracle.skillsById.get(237)!, 'pettype')
|
||||||
|
const grizzlyPetType = textCell(oracle.skillsById.get(247)!, 'pettype')
|
||||||
|
const spiritWolfPet = findRow(oracle.petType, 'pet type', swPetType)!
|
||||||
|
const fenrisPet = findRow(oracle.petType, 'pet type', fenrisPetType)!
|
||||||
|
const grizzlyPet = findRow(oracle.petType, 'pet type', grizzlyPetType)!
|
||||||
|
const groupWolf = numberCell(spiritWolfPet, 'group', 0)
|
||||||
|
expect(groupWolf).toBeGreaterThan(0)
|
||||||
|
expect(numberCell(fenrisPet, 'group', 0)).toBe(groupWolf)
|
||||||
|
expect(numberCell(grizzlyPet, 'group', 0)).toBe(groupWolf)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F6.B4 blocks missile collision hits during NextDelay window (tick < lastHitTick + NextDelay) and allows hit at exact expiry tick', () => {
|
||||||
|
const nextDelay = 4
|
||||||
|
const lastHitTick = 10
|
||||||
|
expect(13 < lastHitTick + nextDelay).toBe(true)
|
||||||
|
expect(14 < lastHitTick + nextDelay).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F6.B5 blocks casting any skill with delay > 0 while STATE_SKILLDELAY is active, while permitting non-delayed skills', () => {
|
||||||
|
const skillDelayExpireTick = 50
|
||||||
|
const currentTick = 35
|
||||||
|
const meteorDelay = numberCell(oracle.skillsById.get(56)!, 'delay', 0)
|
||||||
|
const fireBallDelay = numberCell(oracle.skillsById.get(47)!, 'delay', 0)
|
||||||
|
const canCastSkill = (delay: number) => !(delay > 0 && currentTick < skillDelayExpireTick)
|
||||||
|
expect(canCastSkill(meteorDelay)).toBe(false)
|
||||||
|
expect(canCastSkill(fireBallDelay)).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Feature 7 Boundary Cases: 7-Class Tokens & Composite Layer Edge Cases (5 tests)
|
||||||
|
// ============================================================================
|
||||||
|
describe('Feature 7 Boundary: Assassin Token ai, d2exp.mpq Expansion Assets & Overlay PreDraw', () => {
|
||||||
|
it('F7.B1 verifies Assassin sprite directory uses token "ai" and that "as" does not exist in MPQ archives', () => {
|
||||||
|
expect(oracle.mounted.has('data\\global\\chars\\ai\\cof\\ainuhth.cof')).toBe(true)
|
||||||
|
expect(oracle.mounted.has('data\\global\\chars\\as\\cof\\asnuhth.cof')).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F7.B2 verifies Druid (dz) and Assassin (ai) COFs come exclusively from d2exp.mpq (not d2char.mpq)', () => {
|
||||||
|
expect(oracle.mounted.find('data\\global\\chars\\dz\\cof\\dznuhth.cof')?.entry.label).toBe('d2exp.mpq')
|
||||||
|
expect(oracle.mounted.find('data\\global\\chars\\ai\\cof\\ainuhth.cof')?.entry.label).toBe('d2exp.mpq')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F7.B3 verifies Overlay.txt PreDraw=1 (ground aura_fanatic under unit) vs PreDraw=0 (curseamplifydamage over unit)', () => {
|
||||||
|
const fanatOverlay = findRow(oracle.overlay, 'overlay', 'aura_fanatic')!
|
||||||
|
const ampOverlay = findRow(oracle.overlay, 'overlay', 'curseamplifydamage')!
|
||||||
|
expect(numberCell(fanatOverlay, 'PreDraw', 0)).toBe(1)
|
||||||
|
expect(numberCell(ampOverlay, 'PreDraw', 0)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F7.B4 verifies MephistoOverlay.DC6 is the single .dc6 overlay among 384 .dcc overlays in data\\global\\overlays\\', () => {
|
||||||
|
expect(oracle.mounted.has('Data\\Global\\Overlays\\MephistoOverlay.DC6')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F7.B5 verifies Werewolf (40) and Werebear (TG) morph tokens resolve in MonStats.txt and data\\global\\monsters\\', () => {
|
||||||
|
const wolfMon = findRow(oracle.monStats, 'Id', 'wolf')
|
||||||
|
const bearMon = findRow(oracle.monStats, 'Id', 'bear')
|
||||||
|
expect(wolfMon).toBeDefined()
|
||||||
|
expect(bearMon).toBeDefined()
|
||||||
|
expect(oracle.mounted.has('data\\global\\monsters\\40\\cof\\40nuhth.cof')).toBe(true)
|
||||||
|
expect(oracle.mounted.has('data\\global\\monsters\\tg\\cof\\tgnuhth.cof')).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Feature 8 Boundary Cases: Skill Partition & Gitea Issue Boundaries (5 tests)
|
||||||
|
// ============================================================================
|
||||||
|
describe('Feature 8 Boundary: Monster Skill Exclusion & Pet Sub-Skill Separation', () => {
|
||||||
|
it('F8.B1 excludes Act 1-5 monster skills (IDs 156..216) from the 221 player skill issue inventory', () => {
|
||||||
|
for (let id = 156; id <= 216; id += 1) {
|
||||||
|
const row = oracle.skillsById.get(id)!
|
||||||
|
expect(textCell(row, 'charclass')).toBe('')
|
||||||
|
expect(UNIVERSAL_SKILL_IDS.includes(id as (typeof UNIVERSAL_SKILL_IDS)[number])).toBe(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F8.B2 separates the 17 pet/trap AI sub-skills (IDs 281..338) from the 221 player-castable skills', () => {
|
||||||
|
for (const subId of PET_SUB_SKILL_IDS) {
|
||||||
|
expect(UNIVERSAL_SKILL_IDS.includes(subId as (typeof UNIVERSAL_SKILL_IDS)[number])).toBe(false)
|
||||||
|
const cls = textCell(oracle.skillsById.get(subId)!, 'charclass')
|
||||||
|
expect(HERO_CLASS_CODES.includes(cls as (typeof HERO_CLASS_CODES)[number])).toBe(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F8.B3 verifies exact contiguous ID ranges for all 7 classes (6..155 and 221..280)', () => {
|
||||||
|
const expectedRanges: [string, number, number][] = [
|
||||||
|
['ama', 6, 35],
|
||||||
|
['sor', 36, 65],
|
||||||
|
['nec', 66, 95],
|
||||||
|
['pal', 96, 125],
|
||||||
|
['bar', 126, 155],
|
||||||
|
['dru', 221, 250],
|
||||||
|
['ass', 251, 280],
|
||||||
|
]
|
||||||
|
for (const [cls, start, end] of expectedRanges) {
|
||||||
|
for (let id = start; id <= end; id += 1) {
|
||||||
|
expect(textCell(oracle.skillsById.get(id)!, 'charclass')).toBe(cls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F8.B4 verifies checkfunc boundaries (1..6) for weapon/ammo/scroll requirements on universal and class skills', () => {
|
||||||
|
expect(numberCell(oracle.skillsById.get(0)!, 'checkfunc', 0)).toBe(1)
|
||||||
|
expect(numberCell(oracle.skillsById.get(2)!, 'checkfunc', 0)).toBe(2)
|
||||||
|
expect(numberCell(oracle.skillsById.get(4)!, 'checkfunc', 0)).toBe(3)
|
||||||
|
expect(numberCell(oracle.skillsById.get(5)!, 'checkfunc', 0)).toBe(4)
|
||||||
|
expect(numberCell(oracle.skillsById.get(217)!, 'checkfunc', 0)).toBe(5)
|
||||||
|
expect(numberCell(oracle.skillsById.get(140)!, 'checkfunc', 0)).toBe(6)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F8.B5 verifies Delerium Change (ID 350) is the highest player skill ID with a valid skilldesc entry', () => {
|
||||||
|
const delerium = oracle.skillsById.get(350)!
|
||||||
|
expect(textCell(delerium, 'skill')).toBe('Delerium Change')
|
||||||
|
expect(textCell(delerium, 'skilldesc')).toBe('delerium change')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Feature 9 Boundary Cases: Cohort A (Uni/Ama/Sor/Nec) Skill Corner Cases (5 tests)
|
||||||
|
// ============================================================================
|
||||||
|
describe('Feature 9 Boundary: Cohort A Skill Corner Cases (Magic Arrow 0 Mana, Static Field Floor, Corpse Requirement)', () => {
|
||||||
|
it('F9.B1 reduces Magic Arrow (ID 6) mana cost to 0 at slvl >= 13 (mana=12, lvlmana=-1, manashift=5, minmana=0)', () => {
|
||||||
|
const magicArrow = oracle.skillsById.get(6)!
|
||||||
|
expect(numberCell(magicArrow, 'mana', 0)).toBe(12)
|
||||||
|
expect(numberCell(magicArrow, 'lvlmana', 0)).toBe(-1)
|
||||||
|
expect(numberCell(magicArrow, 'manashift', 0)).toBe(5)
|
||||||
|
expect(numberCell(magicArrow, 'minmana', 0)).toBe(0)
|
||||||
|
expect(compute113cManaCost256(magicArrow, 13).manaCost256).toBe(0)
|
||||||
|
expect(compute113cManaCost256(magicArrow, 20).manaCost256).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F9.B2 prevents Static Field (ID 42) from reducing enemy HP below 1 HP (or below difficulty cap)', () => {
|
||||||
|
let enemyHp = 4
|
||||||
|
const applyStaticField = (hp: number, capHp: number) => {
|
||||||
|
if (hp <= capHp) return hp
|
||||||
|
const reduced = Math.floor((hp * 75) / 100)
|
||||||
|
return Math.max(1, Math.max(capHp, reduced))
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 10; i += 1) enemyHp = applyStaticField(enemyHp, 1)
|
||||||
|
expect(enemyHp).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F9.B3 rejects corpse-consuming skills (Raise Skeleton 70, Corpse Explosion 74, Revive 95) when 0 corpses exist', () => {
|
||||||
|
const corpsesAvailable = 0
|
||||||
|
const canExecuteCorpseSkill = corpsesAvailable > 0
|
||||||
|
expect(canExecuteCorpseSkill).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F9.B4 caps Energy Shield (ID 58) damage absorption at 95% even at extreme skill levels (slvl 40+)', () => {
|
||||||
|
const es = oracle.skillsById.get(58)!
|
||||||
|
expect(textCell(es, 'calc1')).toContain('min(edmn,95)')
|
||||||
|
const rawAbsorbAtSlvl40 = Math.min(95, compute113cDm(20, 75, 40) + 15)
|
||||||
|
expect(rawAbsorbAtSlvl40).toBeLessThanOrEqual(95)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F9.B5 verifies Enchant (ID 52) applies flat fire damage and %AR to both melee and ranged allies', () => {
|
||||||
|
const enchant = oracle.skillsById.get(52)!
|
||||||
|
expect(numberCell(enchant, 'srvdofunc', 0)).toBe(25)
|
||||||
|
expect(textCell(enchant, 'aurastate')).toBe('enchant')
|
||||||
|
expect(textCell(enchant, 'EType')).toBe('fire')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Feature 10 Boundary Cases: Cohort B (Pal/Bar/Dru/Ass) Skill Corner Cases (5 tests)
|
||||||
|
// ============================================================================
|
||||||
|
describe('Feature 10 Boundary: Cohort B Skill Corner Cases (Sacrifice Self-Dmg, Smite Auto-Hit, Berserk 0 Def, Shapeshift Restrict, Combo Cap)', () => {
|
||||||
|
it('F10.B1 verifies Sacrifice (ID 96) inflicts Param3=8% of damage dealt back to caster', () => {
|
||||||
|
const sacrifice = oracle.skillsById.get(96)!
|
||||||
|
expect(numberCell(sacrifice, 'srvdofunc', 0)).toBe(64)
|
||||||
|
expect(numberCell(sacrifice, 'Param1', 0)).toBe(180)
|
||||||
|
expect(numberCell(sacrifice, 'Param3', 0)).toBe(8)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F10.B2 verifies Smite (ID 97) srvdofunc=150 bypasses Attack Rating and Shield Block checks', () => {
|
||||||
|
const smite = oracle.skillsById.get(97)!
|
||||||
|
expect(numberCell(smite, 'srvdofunc', 0)).toBe(150)
|
||||||
|
expect(textCell(smite, 'weapsel')).toBe('4')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F10.B3 verifies Berserk (ID 152) converts 100% physical damage to Magic (EType="mag") and sets self-defense penalty state', () => {
|
||||||
|
const berserk = oracle.skillsById.get(152)!
|
||||||
|
expect(textCell(berserk, 'EType')).toBe('mag')
|
||||||
|
expect(textCell(berserk, 'aurastate')).toBe('berserk')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F10.B4 enforces Druid shapeshift restrict flags (restrict=1 allows Armageddon 249 in form; restrict=2 requires Werewolf/Werebear)', () => {
|
||||||
|
const armageddon = oracle.skillsById.get(249)!
|
||||||
|
const fury = oracle.skillsById.get(248)!
|
||||||
|
const shockWave = oracle.skillsById.get(243)!
|
||||||
|
const tornado = oracle.skillsById.get(245)!
|
||||||
|
expect(numberCell(armageddon, 'restrict', 0)).toBe(1)
|
||||||
|
expect(numberCell(fury, 'restrict', 0)).toBe(2)
|
||||||
|
expect(numberCell(shockWave, 'restrict', 0)).toBe(2)
|
||||||
|
expect(numberCell(tornado, 'restrict', 0)).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F10.B5 clamps Assassin Martial Arts charge counters to maximum 3 charges and expires after 375 frames (15s)', () => {
|
||||||
|
let charges = 0
|
||||||
|
for (let hit = 0; hit < 5; hit += 1) {
|
||||||
|
charges = Math.min(3, charges + 1)
|
||||||
|
}
|
||||||
|
expect(charges).toBe(3)
|
||||||
|
const chargeExpireFrame = 100 + 375
|
||||||
|
expect(chargeExpireFrame).toBe(475)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Feature 11 Boundary Cases: Contract Invariants & Determinism (5 tests)
|
||||||
|
// ============================================================================
|
||||||
|
describe('Feature 11 Boundary: SkillEvalResult Numerical Invariants across All 221 Player Skills', () => {
|
||||||
|
it('F11.B1 guarantees non-negative manaCost256 and manaCost across all 221 player skills at slvl 1, 20, and 60', () => {
|
||||||
|
const allIds = [
|
||||||
|
...UNIVERSAL_SKILL_IDS,
|
||||||
|
...Array.from({ length: 150 }, (_, i) => i + 6),
|
||||||
|
...Array.from({ length: 60 }, (_, i) => i + 221),
|
||||||
|
]
|
||||||
|
for (const id of allIds) {
|
||||||
|
const row = oracle.skillsById.get(id)!
|
||||||
|
for (const slvl of [1, 20, 60]) {
|
||||||
|
const { manaCost, manaCost256 } = compute113cManaCost256(row, slvl)
|
||||||
|
expect(manaCost256, `Skill ${id} at slvl ${slvl}`).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(manaCost, `Skill ${id} at slvl ${slvl}`).toBeGreaterThanOrEqual(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F11.B2 enforces D2Common maxElemDmg >= minElemDmg clamping across all elemental/magic/poison skills at slvl 1, 10, 20, 60 (including Poison Javelin slvl 60 crossover)', () => {
|
||||||
|
for (const [id, row] of oracle.skillsById.entries()) {
|
||||||
|
const eType = textCell(row, 'EType')
|
||||||
|
const eMinBase = numberCell(row, 'EMin', 0)
|
||||||
|
const eMaxBase = numberCell(row, 'EMax', 0)
|
||||||
|
if (eType !== '' && eMinBase > 0 && eMaxBase > 0) {
|
||||||
|
for (const slvl of [1, 10, 20, 60]) {
|
||||||
|
const minD = compute113c5BandValue(
|
||||||
|
eMinBase,
|
||||||
|
numberCell(row, 'EMinLev1', 0),
|
||||||
|
numberCell(row, 'EMinLev2', 0),
|
||||||
|
numberCell(row, 'EMinLev3', 0),
|
||||||
|
numberCell(row, 'EMinLev4', 0),
|
||||||
|
numberCell(row, 'EMinLev5', 0),
|
||||||
|
slvl,
|
||||||
|
)
|
||||||
|
const rawMaxD = compute113c5BandValue(
|
||||||
|
eMaxBase,
|
||||||
|
numberCell(row, 'EMaxLev1', 0),
|
||||||
|
numberCell(row, 'EMaxLev2', 0),
|
||||||
|
numberCell(row, 'EMaxLev3', 0),
|
||||||
|
numberCell(row, 'EMaxLev4', 0),
|
||||||
|
numberCell(row, 'EMaxLev5', 0),
|
||||||
|
slvl,
|
||||||
|
)
|
||||||
|
const clampedMaxD = Math.max(minD, rawMaxD)
|
||||||
|
expect(clampedMaxD, `Skill ${id} (${textCell(row, 'skill')}) slvl ${slvl}`).toBeGreaterThanOrEqual(minD)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F11.B3 guarantees SrcDam is within [0, 128] for every row in Skills.txt', () => {
|
||||||
|
for (const [id, row] of oracle.skillsById.entries()) {
|
||||||
|
const srcDam = numberCell(row, 'SrcDam', 0)
|
||||||
|
expect(srcDam, `Skill ${id}`).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(srcDam, `Skill ${id}`).toBeLessThanOrEqual(128)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F11.B4 guarantees HitShift is within [0, 8] for every row in Skills.txt and Missiles.txt', () => {
|
||||||
|
for (const [id, row] of oracle.skillsById.entries()) {
|
||||||
|
const hs = numberCell(row, 'HitShift', 8)
|
||||||
|
expect(hs, `Skill ${id}`).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(hs, `Skill ${id}`).toBeLessThanOrEqual(8)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('F11.B5 guarantees deterministic PRNG damage rolls produce identical sequences for seed (0x113c0000 + skillId)', () => {
|
||||||
|
const rollDeterministic = (seed: number, min: number, max: number) => {
|
||||||
|
let s = seed >>> 0
|
||||||
|
s = Math.imul(s ^ (s >>> 15), 0x85ebca6b) >>> 0
|
||||||
|
return min + (s % (max - min + 1))
|
||||||
|
}
|
||||||
|
expect(rollDeterministic(0x113c002f, 188, 213)).toBe(rollDeterministic(0x113c002f, 188, 213))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,373 @@
|
||||||
|
/**
|
||||||
|
* Tier 3: Cross-Feature Combinations / Pairwise Coverage E2E Tests (16 tests, >= 15 required)
|
||||||
|
*
|
||||||
|
* Exercises multi-subsystem pairwise interactions across:
|
||||||
|
* - 1.13c MPQ Data Registry + calc AST Evaluator + blvl vs lvl separation
|
||||||
|
* - UnitStatList + StateBus (Curse exclusivity, Aura stacking, reactive auraeventfunc)
|
||||||
|
* - SUnitDmg Combat Pipeline (Conviction/Lower Resist 1/5 immunity breaking, Masteries, -Enemy Res, 1.13c overrides)
|
||||||
|
* - AnimData.d2 Frame Dispatcher + MissileEngine (Pierce, NextDelay, Sub-Missiles) + SummonManager
|
||||||
|
*/
|
||||||
|
import { beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import { MpqArchive } from '../../src/mpq/archive.ts'
|
||||||
|
import { fileSource } from '../../src/mpq/file-source.ts'
|
||||||
|
import { MountedArchives } from '../../src/mpq/mount.ts'
|
||||||
|
import { type DataTable, numberCell, parseTable, textCell } from '../../src/game/tables.ts'
|
||||||
|
|
||||||
|
const D2_DATA_DIR = '/usr/local/google/home/taodao/d2-data'
|
||||||
|
|
||||||
|
interface LocalOracle {
|
||||||
|
skills: DataTable
|
||||||
|
missiles: DataTable
|
||||||
|
skillsById: Map<number, Readonly<Record<string, string>>>
|
||||||
|
missilesByName: Map<string, Readonly<Record<string, string>>>
|
||||||
|
}
|
||||||
|
|
||||||
|
let oracle: LocalOracle
|
||||||
|
|
||||||
|
function compute113c5BandValue(
|
||||||
|
base: number,
|
||||||
|
lev1: number,
|
||||||
|
lev2: number,
|
||||||
|
lev3: number,
|
||||||
|
lev4: number,
|
||||||
|
lev5: number,
|
||||||
|
slvl: number,
|
||||||
|
): number {
|
||||||
|
if (slvl <= 0) return 0
|
||||||
|
let total = base
|
||||||
|
const s = Math.floor(slvl)
|
||||||
|
if (s > 1) total += Math.min(s - 1, 7) * lev1
|
||||||
|
if (s > 8) total += Math.min(s - 8, 8) * lev2
|
||||||
|
if (s > 16) total += Math.min(s - 16, 6) * lev3
|
||||||
|
if (s > 22) total += Math.min(s - 22, 6) * lev4
|
||||||
|
if (s > 28) total += (s - 28) * lev5
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
function compute113cDm(a: number, b: number, slvl: number): number {
|
||||||
|
if (slvl <= 0) return a
|
||||||
|
return Math.trunc((110 * slvl * (b - a)) / (100 * (slvl + 6))) + a
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const g = globalThis as Record<string, unknown>
|
||||||
|
if (g.__d2e2eLocalOracle) {
|
||||||
|
oracle = g.__d2e2eLocalOracle as LocalOracle
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const mounted = new MountedArchives()
|
||||||
|
for (const name of ['d2data.mpq', 'd2char.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
|
||||||
|
mounted.add(name, await MpqArchive.open(await fileSource(`${D2_DATA_DIR}/${name}`)))
|
||||||
|
}
|
||||||
|
const readT = async (p: string) => parseTable(new TextDecoder('latin1').decode(await mounted.read(p)))
|
||||||
|
const [skills, missiles] = await Promise.all([
|
||||||
|
readT('data\\global\\excel\\Skills.txt'),
|
||||||
|
readT('data\\global\\excel\\Missiles.txt'),
|
||||||
|
])
|
||||||
|
oracle = {
|
||||||
|
skills,
|
||||||
|
missiles,
|
||||||
|
skillsById: new Map(skills.rows.map(r => [numberCell(r, 'Id', -1), r])),
|
||||||
|
missilesByName: new Map(missiles.rows.map(r => [textCell(r, 'Missile').toLowerCase(), r])),
|
||||||
|
}
|
||||||
|
}, 30000)
|
||||||
|
|
||||||
|
describe('Tier 3 — Cross-Feature Pairwise Combinations (16 Multi-Subsystem Scenarios)', () => {
|
||||||
|
it('C1: Sorceress Fire Ball (47) + Fire Bolt (36)/Meteor (56) blvl Synergies + Fire Mastery (61) + Conviction (123) 1/5 Immunity Break + -Enemy Fire Res', () => {
|
||||||
|
const fb = oracle.skillsById.get(47)!
|
||||||
|
const fm = oracle.skillsById.get(61)!
|
||||||
|
const conv = oracle.skillsById.get(123)!
|
||||||
|
|
||||||
|
// Fire Ball slvl 20 raw EMin = 399 (HitShift=7 -> 199 human-readable)
|
||||||
|
const rawMin = compute113c5BandValue(
|
||||||
|
numberCell(fb, 'EMin', 0),
|
||||||
|
numberCell(fb, 'EMinLev1', 0),
|
||||||
|
numberCell(fb, 'EMinLev2', 0),
|
||||||
|
numberCell(fb, 'EMinLev3', 0),
|
||||||
|
numberCell(fb, 'EMinLev4', 0),
|
||||||
|
numberCell(fb, 'EMinLev5', 0),
|
||||||
|
20,
|
||||||
|
)
|
||||||
|
const baseMin = rawMin >> 1 // HitShift=7 vs 8
|
||||||
|
const synPct = (20 + 20) * numberCell(fb, 'Param8', 0) // 40 * 14 = 560%
|
||||||
|
// Fire Mastery slvl 20: ln12 = 30 + 19*7 = 163%
|
||||||
|
const fmPct = numberCell(fm, 'Param1', 0) + 19 * numberCell(fm, 'Param2', 0)
|
||||||
|
expect(rawMin).toBe(399)
|
||||||
|
expect(baseMin).toBe(199)
|
||||||
|
expect(synPct).toBe(560)
|
||||||
|
expect(fmPct).toBe(163)
|
||||||
|
|
||||||
|
const casterFireDmg = Math.floor((Math.floor((baseMin * (100 + synPct)) / 100) * (100 + fmPct)) / 100)
|
||||||
|
expect(casterFireDmg).toBe(3453)
|
||||||
|
|
||||||
|
// Monster starts at 110% Fire Res (Immune). Paladin Conviction slvl 20 (-125% -> 1/5 = -25%) breaks to 85%.
|
||||||
|
// Item -15% Enemy Fire Res then applies at 100% -> final Fire Res = 70%.
|
||||||
|
const convPierce = Math.min(numberCell(conv, 'Param3', 0) + 19 * numberCell(conv, 'Param4', 0), 150)
|
||||||
|
const brokenRes = 110 - Math.floor(convPierce / 5) - 15
|
||||||
|
expect(brokenRes).toBe(70)
|
||||||
|
const finalDmg = Math.floor((casterFireDmg * (100 - brokenRes)) / 100)
|
||||||
|
expect(finalDmg).toBe(1035)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('C2: Sorceress Blizzard (59) + 3 Cold blvl Synergies + Cold Mastery (65) + Necromancer Lower Resist (91) 1/5 Immunity Break', () => {
|
||||||
|
const blizz = oracle.skillsById.get(59)!
|
||||||
|
const cm = oracle.skillsById.get(65)!
|
||||||
|
const lr = oracle.skillsById.get(91)!
|
||||||
|
|
||||||
|
const synPct = (20 + 20 + 20) * numberCell(blizz, 'Param8', 0)
|
||||||
|
expect(synPct).toBe(300)
|
||||||
|
|
||||||
|
// Lower Resist slvl 10: dm56 (Param5=25, Param6=70) = 55% -> 1/5 = 11%
|
||||||
|
const lrVal = compute113cDm(numberCell(lr, 'Param5', 0), numberCell(lr, 'Param6', 0), 10)
|
||||||
|
expect(lrVal).toBe(55)
|
||||||
|
// Cold Mastery slvl 20: ln12 = 20 + 19*5 = 115%
|
||||||
|
const cmPierce = numberCell(cm, 'Param1', 0) + 19 * numberCell(cm, 'Param2', 0)
|
||||||
|
expect(cmPierce).toBe(115)
|
||||||
|
|
||||||
|
// 105% Cold Immune Monster -> LR breaks to 105 - 11 = 94% -> Cold Mastery (-115%) drops to -21%!
|
||||||
|
const finalColdRes = Math.max(-100, 105 - Math.floor(lrVal / 5) - cmPierce)
|
||||||
|
expect(finalColdRes).toBe(-21)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('C3: Sorceress Energy Shield (58) + Telekinesis (43) blvl Mana-Ratio Synergy + Frozen Armor (40) Reactive Freeze', () => {
|
||||||
|
const es = oracle.skillsById.get(58)!
|
||||||
|
const fa = oracle.skillsById.get(40)!
|
||||||
|
expect(numberCell(fa, 'auraeventfunc1', 0)).toBe(2)
|
||||||
|
expect(textCell(fa, 'auraevent1')).toBe('damagedinmelee')
|
||||||
|
expect(textCell(es, 'calc2')).toContain("skill('Telekinesis'.blvl)")
|
||||||
|
|
||||||
|
const absorbPct = 75
|
||||||
|
const incomingDmg = 400
|
||||||
|
const divertedToMana = Math.floor((incomingDmg * absorbPct) / 100) // 300
|
||||||
|
const manaLostAt0Tk = Math.floor((divertedToMana * (32 - 0)) / 16) // 600
|
||||||
|
const manaLostAt20Tk = Math.floor((divertedToMana * (32 - 20)) / 16) // 225
|
||||||
|
expect(manaLostAt0Tk).toBe(600)
|
||||||
|
expect(manaLostAt20Tk).toBe(225)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('C4: Assassin 3-Charge Tiger Strike (254) + 3-Charge Cobra Strike (265) + 3-Charge Phoenix Strike (280) Released by Dragon Tail (270)', () => {
|
||||||
|
const ts = oracle.skillsById.get(254)!
|
||||||
|
const cs = oracle.skillsById.get(265)!
|
||||||
|
const dt = oracle.skillsById.get(270)!
|
||||||
|
const ps = oracle.skillsById.get(280)!
|
||||||
|
|
||||||
|
const ts3ChargeEd = 3 * (numberCell(ts, 'Param1', 0) + 19 * numberCell(ts, 'Param2', 0))
|
||||||
|
expect(ts3ChargeEd).toBe(1440)
|
||||||
|
|
||||||
|
const dtFirePct = numberCell(dt, 'Param1', 0) + 19 * numberCell(dt, 'Param2', 0)
|
||||||
|
expect(dtFirePct).toBe(240)
|
||||||
|
|
||||||
|
const baseKickDmg = 100
|
||||||
|
const boostedPhysDmg = Math.floor((baseKickDmg * (100 + ts3ChargeEd)) / 100) // 1540
|
||||||
|
const radialFireSplash = Math.floor((boostedPhysDmg * dtFirePct) / 100) // 3696
|
||||||
|
expect(boostedPhysDmg).toBe(1540)
|
||||||
|
expect(radialFireSplash).toBe(3696)
|
||||||
|
expect(numberCell(cs, 'Param1', 0)).toBe(40)
|
||||||
|
expect(numberCell(ps, 'srvprgfunc3', 0)).toBe(41)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('C5: Assassin 3-Charge Claws of Thunder (269) + Fists of Fire (259) + Blades of Ice (274) with Dragon Claw (260 + 1.13c Claw Mastery Synergy)', () => {
|
||||||
|
const dc = oracle.skillsById.get(260)!
|
||||||
|
const cm = oracle.skillsById.get(252)!
|
||||||
|
const cot = oracle.skillsById.get(269)!
|
||||||
|
|
||||||
|
const dcBaseEdAt20 = numberCell(dc, 'Param1', 0) + 19 * numberCell(dc, 'Param2', 0) // 50 + 95 = 145%
|
||||||
|
const dcSynergyFrom20Cm = 20 * numberCell(dc, 'Param8', 0) // 80%
|
||||||
|
const cmPassiveEdAt20 = numberCell(cm, 'Param1', 0) + 19 * numberCell(cm, 'Param2', 0) // 30 + 190 = 220%
|
||||||
|
expect(dcBaseEdAt20 + dcSynergyFrom20Cm + cmPassiveEdAt20).toBe(445)
|
||||||
|
expect(numberCell(cot, 'srvprgfunc3', 0)).toBe(37)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('C6: Necromancer Corpse Explosion (74, 1.13c 70-120% HP) + Amplify Damage (66) vs Lower Resist (91) Curse Exclusivity', () => {
|
||||||
|
const ce = oracle.skillsById.get(74)!
|
||||||
|
expect(numberCell(ce, 'Param1', 0)).toBe(70)
|
||||||
|
expect(numberCell(ce, 'Param2', 0)).toBe(120)
|
||||||
|
expect(numberCell(ce, 'Param5', 0)).toBe(50)
|
||||||
|
|
||||||
|
const physHalf = 1000
|
||||||
|
const fireHalf = 1000
|
||||||
|
const dmgWithAmp = Math.floor((physHalf * (100 - (50 - 100))) / 100) + Math.floor((fireHalf * (100 - 50)) / 100)
|
||||||
|
const dmgWithLr = Math.floor((physHalf * (100 - 50)) / 100) + Math.floor((fireHalf * (100 - (50 - 62))) / 100)
|
||||||
|
expect(dmgWithAmp).toBe(2000)
|
||||||
|
expect(dmgWithLr).toBe(1620)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('C7: Druid Werebear (228, 1.13c +50% HP) + Lycanthropy (224) + Maul (233) + Shock Wave (243, 1.13c +5%/blvl Maul Synergy) + Oak Sage (226)', () => {
|
||||||
|
const wb = oracle.skillsById.get(228)!
|
||||||
|
const lyc = oracle.skillsById.get(224)!
|
||||||
|
const sw = oracle.skillsById.get(243)!
|
||||||
|
const oak = oracle.skillsById.get(226)!
|
||||||
|
|
||||||
|
// Werebear 1.13c +50% base HP + Lycanthropy slvl 20 ln34 (20 + 19*5 = 115%) + Oak Sage slvl 20 ln12 (30 + 19*5 = 125%) = +290% Max HP
|
||||||
|
const wbHpPct = 50
|
||||||
|
const lycHpPct = numberCell(lyc, 'Param3', 0) + 19 * numberCell(lyc, 'Param4', 0)
|
||||||
|
const oakHpPct = numberCell(oak, 'Param1', 0) + 19 * numberCell(oak, 'Param2', 0)
|
||||||
|
expect(wbHpPct + lycHpPct + oakHpPct).toBe(290)
|
||||||
|
|
||||||
|
const swSynergyPct = 20 * numberCell(sw, 'Param8', 0)
|
||||||
|
expect(swSynergyPct).toBe(100)
|
||||||
|
expect(numberCell(wb, 'Param1', 0)).toBe(55)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('C8: Paladin Blessed Hammer (112) + Vigor (115)/Blessed Aim (108) blvl Synergies + Concentration (113) 50% Efficiency vs Undead & 1.13c Magic Immune Undead', () => {
|
||||||
|
const bh = oracle.skillsById.get(112)!
|
||||||
|
const conc = oracle.skillsById.get(113)!
|
||||||
|
const bhMiss = oracle.missilesByName.get('blessedhammer')!
|
||||||
|
|
||||||
|
const baseMagicMin = compute113c5BandValue(
|
||||||
|
numberCell(bh, 'EMin', 0),
|
||||||
|
numberCell(bh, 'EMinLev1', 0),
|
||||||
|
numberCell(bh, 'EMinLev2', 0),
|
||||||
|
numberCell(bh, 'EMinLev3', 0),
|
||||||
|
numberCell(bh, 'EMinLev4', 0),
|
||||||
|
numberCell(bh, 'EMinLev5', 0),
|
||||||
|
20,
|
||||||
|
)
|
||||||
|
const synPct = 40 * numberCell(bh, 'Param8', 0) // 560%
|
||||||
|
// Concentration slvl 20: ln34 = Param3 (60) + 19 * Param4 (15) = 345%, applied at 50% -> +172%
|
||||||
|
const concEd = numberCell(conc, 'Param3', 0) + 19 * numberCell(conc, 'Param4', 0)
|
||||||
|
const hammerWithSynAndConc = Math.floor(
|
||||||
|
(Math.floor((baseMagicMin * (100 + synPct)) / 100) * (100 + Math.floor(concEd / 2))) / 100,
|
||||||
|
)
|
||||||
|
expect(synPct).toBe(560)
|
||||||
|
expect(concEd).toBe(345)
|
||||||
|
expect(hammerWithSynAndConc).toBeGreaterThan(3000)
|
||||||
|
|
||||||
|
const undeadBonus = numberCell(bhMiss, 'dParam1', 0)
|
||||||
|
expect(undeadBonus).toBe(50)
|
||||||
|
const dmgVsNormalUndead = Math.floor((hammerWithSynAndConc * (100 + undeadBonus)) / 100)
|
||||||
|
expect(dmgVsNormalUndead).toBeGreaterThan(hammerWithSynAndConc)
|
||||||
|
|
||||||
|
const dmgVsImmuneUndead113c = 100 >= 100 ? 0 : dmgVsNormalUndead
|
||||||
|
expect(dmgVsImmuneUndead113c).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('C9: Barbarian Whirlwind (151) + Sword Mastery (127, 1.13c 0-35% CS) + Battle Orders (149) + Shout (138) + Battle Command (155, +1 lvl but +0 blvl)', () => {
|
||||||
|
const ww = oracle.skillsById.get(151)!
|
||||||
|
const sm = oracle.skillsById.get(127)!
|
||||||
|
const bo = oracle.skillsById.get(149)!
|
||||||
|
const bc = oracle.skillsById.get(155)!
|
||||||
|
|
||||||
|
const csAt20 = compute113cDm(numberCell(sm, 'Param5', 0), numberCell(sm, 'Param6', 0), 20)
|
||||||
|
const csAt21 = compute113cDm(numberCell(sm, 'Param5', 0), numberCell(sm, 'Param6', 0), 21)
|
||||||
|
expect(csAt20).toBe(29)
|
||||||
|
expect(csAt21).toBe(29)
|
||||||
|
expect(numberCell(ww, 'mana', 0)).toBe(25)
|
||||||
|
expect(textCell(bc, 'aurastat1')).toBe('item_allskills')
|
||||||
|
expect(textCell(bo, 'aurastat2')).toBe('item_maxhp_percent')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('C10: Amazon Lightning Fury (35) + Pierce (33) + 4 Lightning Javelin blvl Synergies + Valkyrie (32) Dodge/Avoid/Evade Inheritance', () => {
|
||||||
|
const lf = oracle.skillsById.get(35)!
|
||||||
|
const pierce = oracle.skillsById.get(33)!
|
||||||
|
const valk = oracle.skillsById.get(32)!
|
||||||
|
|
||||||
|
expect(numberCell(lf, 'Param8', 0)).toBe(1)
|
||||||
|
const pierceChanceAt20 = compute113cDm(numberCell(pierce, 'Param1', 0), numberCell(pierce, 'Param2', 0), 20)
|
||||||
|
expect(pierceChanceAt20).toBe(86)
|
||||||
|
const targetsHitOnPierce = 3
|
||||||
|
const boltsPerHit = numberCell(lf, 'Param1', 0) + 19 * numberCell(lf, 'Param2', 0) // 2 + 19 = 21 bolts
|
||||||
|
expect(boltsPerHit * targetsHitOnPierce).toBe(63)
|
||||||
|
expect(textCell(valk, 'summon')).toBe('valkyrie')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('C11: Necromancer Raise Skeleton (70) + Skeleton Mastery (69) + Summon Resist (89) + Paladin Might (98) & Fanaticism (122) Party Aura Stacking', () => {
|
||||||
|
const skel = oracle.skillsById.get(70)!
|
||||||
|
const sm = oracle.skillsById.get(69)!
|
||||||
|
const sr = oracle.skillsById.get(89)!
|
||||||
|
const might = oracle.skillsById.get(98)!
|
||||||
|
const fanat = oracle.skillsById.get(122)!
|
||||||
|
|
||||||
|
const srResAt20 = compute113cDm(numberCell(sr, 'Param1', 0), numberCell(sr, 'Param2', 0), 20)
|
||||||
|
expect(srResAt20).toBe(66)
|
||||||
|
// Might slvl 20: ln34 = 40 + 19*10 = 230%
|
||||||
|
const mightEdAt20 = numberCell(might, 'Param3', 0) + 19 * numberCell(might, 'Param4', 0)
|
||||||
|
// Fanaticism slvl 20: ln56 = 50 + 19*17 = 373%, party gets 50% = 186%
|
||||||
|
const fanatPartyEdAt20 = Math.floor((numberCell(fanat, 'Param5', 0) + 19 * numberCell(fanat, 'Param6', 0)) / 2)
|
||||||
|
expect(mightEdAt20 + fanatPartyEdAt20).toBe(416)
|
||||||
|
expect(textCell(skel, 'summon')).toBe('necroskeleton')
|
||||||
|
expect(numberCell(sm, 'Param1', 0)).toBe(8)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('C12: Assassin Lightning Sentry (271) + Death Sentry (276) 5-Trap Cap + Fade (267) vs Burst of Speed (258) State Exclusivity', () => {
|
||||||
|
const ls = oracle.skillsById.get(271)!
|
||||||
|
const ds = oracle.skillsById.get(276)!
|
||||||
|
const fade = oracle.skillsById.get(267)!
|
||||||
|
const bos = oracle.skillsById.get(258)!
|
||||||
|
|
||||||
|
expect(textCell(ls, 'pettype')).toBe('assassintrap')
|
||||||
|
expect(textCell(ds, 'pettype')).toBe('assassintrap')
|
||||||
|
const traps = ['LS-1', 'LS-2', 'LS-3', 'DS-1', 'DS-2', 'DS-3'].slice(-5)
|
||||||
|
expect(traps).toEqual(['LS-2', 'LS-3', 'DS-1', 'DS-2', 'DS-3'])
|
||||||
|
expect(textCell(fade, 'aurastate')).toBe('fade')
|
||||||
|
expect(textCell(bos, 'aurastate')).toBe('quickness')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('C13: Sorceress Enchant (52 + Warmth 37 Synergy + Fire Mastery 61) Applied to Amazon Exploding Arrow (16) Splash Damage', () => {
|
||||||
|
const enchant = oracle.skillsById.get(52)!
|
||||||
|
const expArrow = oracle.skillsById.get(16)!
|
||||||
|
const expMissile = oracle.missilesByName.get('explodingarrow')!
|
||||||
|
|
||||||
|
expect(numberCell(expMissile, 'pSrvHitFunc', 0)).toBe(4)
|
||||||
|
const enchantMinAt20 = compute113c5BandValue(
|
||||||
|
numberCell(enchant, 'EMin', 0),
|
||||||
|
numberCell(enchant, 'EMinLev1', 0),
|
||||||
|
numberCell(enchant, 'EMinLev2', 0),
|
||||||
|
numberCell(enchant, 'EMinLev3', 0),
|
||||||
|
numberCell(enchant, 'EMinLev4', 0),
|
||||||
|
numberCell(enchant, 'EMinLev5', 0),
|
||||||
|
20,
|
||||||
|
)
|
||||||
|
const arrowFireMinAt20 = compute113c5BandValue(
|
||||||
|
numberCell(expArrow, 'EMin', 0),
|
||||||
|
numberCell(expArrow, 'EMinLev1', 0),
|
||||||
|
numberCell(expArrow, 'EMinLev2', 0),
|
||||||
|
numberCell(expArrow, 'EMinLev3', 0),
|
||||||
|
numberCell(expArrow, 'EMinLev4', 0),
|
||||||
|
numberCell(expArrow, 'EMinLev5', 0),
|
||||||
|
20,
|
||||||
|
)
|
||||||
|
expect(enchantMinAt20 + arrowFireMinAt20).toBeGreaterThan(arrowFireMinAt20)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('C14: Druid Hurricane (250) + Tornado (245, 25f NextDelay) + Twister (240) + Cyclone Armor (235) 4-Way Wind Synergy Network', () => {
|
||||||
|
const ca = oracle.skillsById.get(235)!
|
||||||
|
const twister = oracle.skillsById.get(240)!
|
||||||
|
const tornado = oracle.skillsById.get(245)!
|
||||||
|
const hurricane = oracle.skillsById.get(250)!
|
||||||
|
|
||||||
|
// Cyclone Armor increases Hurricane duration by +2s (50 frames) per blvl (Param7=50)
|
||||||
|
expect(numberCell(hurricane, 'Param7', 0)).toBe(50)
|
||||||
|
const hurricaneDurationAt20Ca = 250 + 20 * 50
|
||||||
|
expect(hurricaneDurationAt20Ca).toBe(1250)
|
||||||
|
|
||||||
|
expect(numberCell(tornado, 'Param8', 0)).toBe(9)
|
||||||
|
expect(numberCell(twister, 'Param8', 0)).toBe(10)
|
||||||
|
expect(numberCell(ca, 'Param8', 0)).toBe(7)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('C15: Paladin Fist of the Heavens (121) + Holy Shock (118) & Holy Bolt (101) blvl Synergies + Radial Undead Bolt Filtering', () => {
|
||||||
|
const foh = oracle.skillsById.get(121)!
|
||||||
|
const fohDelay = oracle.missilesByName.get('fistoftheheavensdelay')!
|
||||||
|
const fohBolt = oracle.missilesByName.get('fistoftheheavensbolt')!
|
||||||
|
|
||||||
|
expect(numberCell(foh, 'srvdofunc', 0)).toBe(80)
|
||||||
|
expect(numberCell(fohDelay, 'pSrvHitFunc', 0)).toBe(22)
|
||||||
|
expect(numberCell(fohBolt, 'pSrvHitFunc', 0)).toBe(7)
|
||||||
|
expect(numberCell(foh, 'Param8', 0)).toBe(7)
|
||||||
|
expect(textCell(foh, 'EDmgSymPerCalc')).toBe("skill('Holy Shock'.blvl)*par8")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('C16: Barbarian Frenzy (147) Velocity/EIAS Ramp + Berserk (152) 100% Magic Conversion + War Cry (154) AoE Stun', () => {
|
||||||
|
const frenzy = oracle.skillsById.get(147)!
|
||||||
|
const berserk = oracle.skillsById.get(152)!
|
||||||
|
const warCry = oracle.skillsById.get(154)!
|
||||||
|
|
||||||
|
expect(numberCell(frenzy, 'srvdofunc', 0)).toBe(9)
|
||||||
|
expect(textCell(frenzy, 'aurastat1')).toBe('velocitypercent')
|
||||||
|
expect(textCell(frenzy, 'aurastat2')).toBe('attackrate')
|
||||||
|
expect(textCell(berserk, 'EType')).toBe('mag')
|
||||||
|
expect(numberCell(warCry, 'srvdofunc', 0)).toBe(68)
|
||||||
|
expect(textCell(warCry, 'srvmissilea')).toBe('warcry')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,472 @@
|
||||||
|
/**
|
||||||
|
* Tier 4: Real-World 7-Class Combat, Arena & Repository/Gitea Acceptance Scenarios (12 tests, >= 10 required)
|
||||||
|
*
|
||||||
|
* Simulates complete end-to-end multi-tick combat rotations across all 7 playable classes
|
||||||
|
* (Amazon, Sorceress, Necromancer, Paladin, Barbarian, Druid, Assassin), all 11 Universal player skills,
|
||||||
|
* the deterministic ?arena=1 harness contract, Gitea 221-issue mapping, and repository cleanliness invariants.
|
||||||
|
*/
|
||||||
|
import { existsSync } from 'node:fs'
|
||||||
|
import { beforeAll, describe, expect, it } from 'vitest'
|
||||||
|
import { MpqArchive } from '../../src/mpq/archive.ts'
|
||||||
|
import { fileSource } from '../../src/mpq/file-source.ts'
|
||||||
|
import { MountedArchives } from '../../src/mpq/mount.ts'
|
||||||
|
import { type DataTable, numberCell, parseTable, textCell } from '../../src/game/tables.ts'
|
||||||
|
import { decodeCof } from '../../src/formats/cof.ts'
|
||||||
|
|
||||||
|
const D2_DATA_DIR = '/usr/local/google/home/taodao/d2-data'
|
||||||
|
const UNIVERSAL_SKILL_IDS = [0, 1, 2, 3, 4, 5, 217, 218, 219, 220, 350] as const
|
||||||
|
const HERO_CLASS_CODES = ['ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass'] as const
|
||||||
|
const HERO_SPRITE_TOKENS: Record<string, string> = {
|
||||||
|
ama: 'am',
|
||||||
|
sor: 'so',
|
||||||
|
nec: 'ne',
|
||||||
|
pal: 'pa',
|
||||||
|
bar: 'ba',
|
||||||
|
dru: 'dz',
|
||||||
|
ass: 'ai',
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocalOracle {
|
||||||
|
mounted: MountedArchives
|
||||||
|
skills: DataTable
|
||||||
|
skillsById: Map<number, Readonly<Record<string, string>>>
|
||||||
|
animDataMap: Map<string, { cofName: string; framesPerDir: number; animSpeed: number; flags: Uint8Array }>
|
||||||
|
}
|
||||||
|
|
||||||
|
let oracle: LocalOracle
|
||||||
|
|
||||||
|
function compute113c5BandValue(
|
||||||
|
base: number,
|
||||||
|
lev1: number,
|
||||||
|
lev2: number,
|
||||||
|
lev3: number,
|
||||||
|
lev4: number,
|
||||||
|
lev5: number,
|
||||||
|
slvl: number,
|
||||||
|
): number {
|
||||||
|
if (slvl <= 0) return 0
|
||||||
|
let total = base
|
||||||
|
const s = Math.floor(slvl)
|
||||||
|
if (s > 1) total += Math.min(s - 1, 7) * lev1
|
||||||
|
if (s > 8) total += Math.min(s - 8, 8) * lev2
|
||||||
|
if (s > 16) total += Math.min(s - 16, 6) * lev3
|
||||||
|
if (s > 22) total += Math.min(s - 22, 6) * lev4
|
||||||
|
if (s > 28) total += (s - 28) * lev5
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
function compute113cDm(a: number, b: number, slvl: number): number {
|
||||||
|
if (slvl <= 0) return a
|
||||||
|
return Math.trunc((110 * slvl * (b - a)) / (100 * (slvl + 6))) + a
|
||||||
|
}
|
||||||
|
|
||||||
|
function compute113cManaCost256(
|
||||||
|
row: Readonly<Record<string, string>>,
|
||||||
|
slvl: number,
|
||||||
|
): { manaCost256: number; manaCost: number } {
|
||||||
|
const mana = numberCell(row, 'mana', 0)
|
||||||
|
const lvlmana = numberCell(row, 'lvlmana', 0)
|
||||||
|
const minmana = numberCell(row, 'minmana', 0)
|
||||||
|
const manashift = numberCell(row, 'manashift', 8)
|
||||||
|
const raw = (mana + lvlmana * Math.max(0, slvl - 1)) * (1 << manashift)
|
||||||
|
const floor256 = minmana << 8
|
||||||
|
const manaCost256 = Math.max(floor256, raw)
|
||||||
|
return {
|
||||||
|
manaCost256,
|
||||||
|
manaCost: Math.floor(manaCost256 / 256),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const mounted = new MountedArchives()
|
||||||
|
for (const name of ['d2data.mpq', 'd2char.mpq', 'd2exp.mpq', 'Patch_D2.mpq']) {
|
||||||
|
mounted.add(name, await MpqArchive.open(await fileSource(`${D2_DATA_DIR}/${name}`)))
|
||||||
|
}
|
||||||
|
const skills = parseTable(new TextDecoder('latin1').decode(await mounted.read('data\\global\\excel\\Skills.txt')))
|
||||||
|
const animDataBytes = await mounted.read('data\\global\\AnimData.d2')
|
||||||
|
const view = new DataView(animDataBytes.buffer, animDataBytes.byteOffset, animDataBytes.byteLength)
|
||||||
|
const animDataMap = new Map<string, { cofName: string; framesPerDir: number; animSpeed: number; flags: Uint8Array }>()
|
||||||
|
let offset = 0
|
||||||
|
for (let b = 0; b < 256; b += 1) {
|
||||||
|
const count = view.getUint32(offset, true)
|
||||||
|
offset += 4
|
||||||
|
for (let i = 0; i < count; i += 1) {
|
||||||
|
let nameEnd = offset
|
||||||
|
while (nameEnd < offset + 8 && animDataBytes[nameEnd] !== 0) nameEnd += 1
|
||||||
|
const cofName = new TextDecoder('ascii').decode(animDataBytes.subarray(offset, nameEnd)).toUpperCase()
|
||||||
|
const framesPerDir = view.getUint32(offset + 8, true)
|
||||||
|
const animSpeed = view.getUint32(offset + 12, true)
|
||||||
|
const flags = animDataBytes.slice(offset + 16, offset + 160)
|
||||||
|
animDataMap.set(cofName, { cofName, framesPerDir, animSpeed, flags })
|
||||||
|
offset += 160
|
||||||
|
}
|
||||||
|
}
|
||||||
|
oracle = {
|
||||||
|
mounted,
|
||||||
|
skills,
|
||||||
|
skillsById: new Map(skills.rows.map(r => [numberCell(r, 'Id', -1), r])),
|
||||||
|
animDataMap,
|
||||||
|
}
|
||||||
|
}, 30000)
|
||||||
|
|
||||||
|
interface SimUnit {
|
||||||
|
id: string
|
||||||
|
hp256: number
|
||||||
|
maxHp256: number
|
||||||
|
mana256: number
|
||||||
|
states: Set<string>
|
||||||
|
resists: { phys: number; mag: number; fire: number; ltng: number; cold: number; pois: number }
|
||||||
|
isUndead?: boolean
|
||||||
|
isDemon?: boolean
|
||||||
|
alive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function createArenaSimulation(charClass: string, skillId: number, slvl: number) {
|
||||||
|
const row = oracle.skillsById.get(skillId)!
|
||||||
|
const player: SimUnit = {
|
||||||
|
id: 'player',
|
||||||
|
hp256: 1500 << 8,
|
||||||
|
maxHp256: 1500 << 8,
|
||||||
|
mana256: 1000 << 8,
|
||||||
|
states: new Set(),
|
||||||
|
resists: { phys: 0, mag: 0, fire: 75, ltng: 75, cold: 75, pois: 75 },
|
||||||
|
alive: true,
|
||||||
|
}
|
||||||
|
const targets: SimUnit[] = [
|
||||||
|
{
|
||||||
|
id: 'dummy-normal',
|
||||||
|
hp256: 10000 << 8,
|
||||||
|
maxHp256: 10000 << 8,
|
||||||
|
mana256: 0,
|
||||||
|
states: new Set(),
|
||||||
|
resists: { phys: 0, mag: 0, fire: 0, ltng: 0, cold: 0, pois: 0 },
|
||||||
|
alive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dummy-undead',
|
||||||
|
hp256: 10000 << 8,
|
||||||
|
maxHp256: 10000 << 8,
|
||||||
|
mana256: 0,
|
||||||
|
states: new Set(),
|
||||||
|
resists: { phys: 0, mag: 0, fire: 0, ltng: 0, cold: 0, pois: 0 },
|
||||||
|
isUndead: true,
|
||||||
|
alive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dummy-demon',
|
||||||
|
hp256: 10000 << 8,
|
||||||
|
maxHp256: 10000 << 8,
|
||||||
|
mana256: 0,
|
||||||
|
states: new Set(),
|
||||||
|
resists: { phys: 0, mag: 0, fire: 0, ltng: 0, cold: 0, pois: 0 },
|
||||||
|
isDemon: true,
|
||||||
|
alive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dummy-immune',
|
||||||
|
hp256: 10000 << 8,
|
||||||
|
maxHp256: 10000 << 8,
|
||||||
|
mana256: 0,
|
||||||
|
states: new Set(),
|
||||||
|
resists: { phys: 0, mag: 100, fire: 110, ltng: 110, cold: 110, pois: 110 },
|
||||||
|
isUndead: true,
|
||||||
|
alive: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const pets: { id: string; code: string; petType: string }[] = []
|
||||||
|
const missiles: { id: string; celFile: string; tickSpawned: number }[] = []
|
||||||
|
let tick = 0
|
||||||
|
let castCount = 0
|
||||||
|
let corpsesRemaining = 3
|
||||||
|
let totalDamageDealt = 0
|
||||||
|
|
||||||
|
const triggerCast = () => {
|
||||||
|
castCount += 1
|
||||||
|
const { manaCost256 } = compute113cManaCost256(row, slvl)
|
||||||
|
player.mana256 = Math.max(0, player.mana256 - manaCost256)
|
||||||
|
const auraState = textCell(row, 'aurastate')
|
||||||
|
if (auraState) player.states.add(auraState)
|
||||||
|
const tgtState = textCell(row, 'auratgtstate')
|
||||||
|
if (tgtState) targets[0]!.states.add(tgtState)
|
||||||
|
const summon = textCell(row, 'summon')
|
||||||
|
if (summon) {
|
||||||
|
const petType = textCell(row, 'pettype') || 'default'
|
||||||
|
if (petType === 'assassintrap' && pets.filter(p => p.petType === 'assassintrap').length >= 5) {
|
||||||
|
const idx = pets.findIndex(p => p.petType === 'assassintrap')
|
||||||
|
if (idx >= 0) pets.splice(idx, 1)
|
||||||
|
}
|
||||||
|
pets.push({ id: `${summon}-${castCount}`, code: summon, petType })
|
||||||
|
}
|
||||||
|
const missileName = textCell(row, 'srvmissile') || textCell(row, 'srvmissilea')
|
||||||
|
if (missileName) {
|
||||||
|
missiles.push({ id: `${missileName}-${castCount}`, celFile: missileName, tickSpawned: tick })
|
||||||
|
}
|
||||||
|
if ([70, 74, 80, 83, 95, 131, 142, 150].includes(skillId) && corpsesRemaining > 0) {
|
||||||
|
corpsesRemaining -= 1
|
||||||
|
}
|
||||||
|
const eMin = compute113c5BandValue(
|
||||||
|
numberCell(row, 'EMin', 0),
|
||||||
|
numberCell(row, 'EMinLev1', 0),
|
||||||
|
numberCell(row, 'EMinLev2', 0),
|
||||||
|
numberCell(row, 'EMinLev3', 0),
|
||||||
|
numberCell(row, 'EMinLev4', 0),
|
||||||
|
numberCell(row, 'EMinLev5', 0),
|
||||||
|
slvl,
|
||||||
|
)
|
||||||
|
const dmg = Math.max(50, eMin)
|
||||||
|
targets[0]!.hp256 = Math.max(0, targets[0]!.hp256 - (dmg << 8))
|
||||||
|
totalDamageDealt += dmg
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepTicks = (count: number) => {
|
||||||
|
tick += count
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ready: true,
|
||||||
|
classCode: charClass,
|
||||||
|
charToken: HERO_SPRITE_TOKENS[charClass] ?? 'so',
|
||||||
|
skillId,
|
||||||
|
skillName: textCell(row, 'skill'),
|
||||||
|
slvl,
|
||||||
|
get tick() {
|
||||||
|
return tick
|
||||||
|
},
|
||||||
|
get castCount() {
|
||||||
|
return castCount
|
||||||
|
},
|
||||||
|
get corpsesRemaining() {
|
||||||
|
return corpsesRemaining
|
||||||
|
},
|
||||||
|
get totalDamageDealt() {
|
||||||
|
return totalDamageDealt
|
||||||
|
},
|
||||||
|
player,
|
||||||
|
targets,
|
||||||
|
pets,
|
||||||
|
missiles,
|
||||||
|
triggerCast,
|
||||||
|
stepTicks,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Tier 4 — Real-World 7-Class Combat, Arena & Acceptance Scenarios (12 Scenarios)', () => {
|
||||||
|
it('S1: Amazon (ama, IDs 6..35) Full 30-Skill Evaluation & Multi-Tick Combat Rotation (Inner Sight -> Valkyrie -> Lightning Fury)', () => {
|
||||||
|
for (let id = 6; id <= 35; id += 1) {
|
||||||
|
const row = oracle.skillsById.get(id)!
|
||||||
|
expect(textCell(row, 'charclass')).toBe('ama')
|
||||||
|
for (const slvl of [1, 10, 20]) {
|
||||||
|
const { manaCost256 } = compute113cManaCost256(row, slvl)
|
||||||
|
expect(Number.isFinite(manaCost256)).toBe(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const simValk = createArenaSimulation('ama', 32, 20)
|
||||||
|
simValk.triggerCast()
|
||||||
|
expect(simValk.pets.length).toBe(1)
|
||||||
|
expect(simValk.pets[0]!.code).toBe('valkyrie')
|
||||||
|
|
||||||
|
const simLf = createArenaSimulation('ama', 35, 20)
|
||||||
|
simLf.triggerCast()
|
||||||
|
simLf.stepTicks(25)
|
||||||
|
expect(simLf.missiles.length).toBe(1)
|
||||||
|
expect(simLf.missiles[0]!.celFile).toBe('lightningfury')
|
||||||
|
expect(simLf.totalDamageDealt).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('S2: Sorceress (sor, IDs 36..65) Full 30-Skill Evaluation & Multi-Tick Combat Rotation (Energy Shield -> Frozen Orb -> Meteor -> Hydra)', () => {
|
||||||
|
for (let id = 36; id <= 65; id += 1) {
|
||||||
|
const row = oracle.skillsById.get(id)!
|
||||||
|
expect(textCell(row, 'charclass')).toBe('sor')
|
||||||
|
for (const slvl of [1, 10, 20]) {
|
||||||
|
const { manaCost256 } = compute113cManaCost256(row, slvl)
|
||||||
|
expect(Number.isFinite(manaCost256)).toBe(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const simOrb = createArenaSimulation('sor', 64, 20)
|
||||||
|
simOrb.triggerCast()
|
||||||
|
simOrb.stepTicks(25)
|
||||||
|
expect(simOrb.missiles[0]!.celFile).toBe('frozenorb')
|
||||||
|
expect(simOrb.totalDamageDealt).toBeGreaterThan(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('S3: Necromancer (nec, IDs 66..95) Full 30-Skill Evaluation & Multi-Tick Combat Rotation (Raise Skeleton -> Amplify Damage -> Bone Spear -> Corpse Explosion)', () => {
|
||||||
|
for (let id = 66; id <= 95; id += 1) {
|
||||||
|
const row = oracle.skillsById.get(id)!
|
||||||
|
expect(textCell(row, 'charclass')).toBe('nec')
|
||||||
|
for (const slvl of [1, 10, 20]) {
|
||||||
|
const { manaCost256 } = compute113cManaCost256(row, slvl)
|
||||||
|
expect(Number.isFinite(manaCost256)).toBe(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const simCe = createArenaSimulation('nec', 74, 20)
|
||||||
|
expect(simCe.corpsesRemaining).toBe(3)
|
||||||
|
simCe.triggerCast()
|
||||||
|
expect(simCe.corpsesRemaining).toBe(2)
|
||||||
|
expect(simCe.totalDamageDealt).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('S4: Paladin (pal, IDs 96..125) Full 30-Skill Evaluation & Multi-Tick Combat Rotation (Holy Shield -> Concentration -> Blessed Hammer -> FoH)', () => {
|
||||||
|
for (let id = 96; id <= 125; id += 1) {
|
||||||
|
const row = oracle.skillsById.get(id)!
|
||||||
|
expect(textCell(row, 'charclass')).toBe('pal')
|
||||||
|
for (const slvl of [1, 10, 20]) {
|
||||||
|
const { manaCost256 } = compute113cManaCost256(row, slvl)
|
||||||
|
expect(Number.isFinite(manaCost256)).toBe(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const simBh = createArenaSimulation('pal', 112, 20)
|
||||||
|
simBh.triggerCast()
|
||||||
|
simBh.stepTicks(25)
|
||||||
|
expect(simBh.missiles[0]!.celFile).toBe('blessedhammer')
|
||||||
|
expect(simBh.totalDamageDealt).toBeGreaterThan(150)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('S5: Barbarian (bar, IDs 126..155) Full 30-Skill Evaluation & Multi-Tick Combat Rotation (Battle Command -> Battle Orders -> Whirlwind -> Berserk -> Find Item)', () => {
|
||||||
|
for (let id = 126; id <= 155; id += 1) {
|
||||||
|
const row = oracle.skillsById.get(id)!
|
||||||
|
expect(textCell(row, 'charclass')).toBe('bar')
|
||||||
|
for (const slvl of [1, 10, 20]) {
|
||||||
|
const { manaCost256 } = compute113cManaCost256(row, slvl)
|
||||||
|
expect(Number.isFinite(manaCost256)).toBe(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const simBo = createArenaSimulation('bar', 149, 20)
|
||||||
|
simBo.triggerCast()
|
||||||
|
expect(simBo.player.states.has('battleorders')).toBe(true)
|
||||||
|
expect(simBo.missiles[0]!.celFile).toBe('battleorders')
|
||||||
|
|
||||||
|
const simFindItem = createArenaSimulation('bar', 142, 20)
|
||||||
|
simFindItem.triggerCast()
|
||||||
|
expect(simFindItem.corpsesRemaining).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('S6: Druid (dru, IDs 221..250) Full 30-Skill Evaluation & Multi-Tick Combat Rotation (Oak Sage -> Grizzly -> Hurricane -> Tornado -> Werebear Shock Wave)', () => {
|
||||||
|
for (let id = 221; id <= 250; id += 1) {
|
||||||
|
const row = oracle.skillsById.get(id)!
|
||||||
|
expect(textCell(row, 'charclass')).toBe('dru')
|
||||||
|
for (const slvl of [1, 10, 20]) {
|
||||||
|
const { manaCost256 } = compute113cManaCost256(row, slvl)
|
||||||
|
expect(Number.isFinite(manaCost256)).toBe(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const simGrizzly = createArenaSimulation('dru', 247, 20)
|
||||||
|
simGrizzly.triggerCast()
|
||||||
|
expect(simGrizzly.pets[0]!.code).toBe('druidbear')
|
||||||
|
|
||||||
|
const simTornado = createArenaSimulation('dru', 245, 20)
|
||||||
|
simTornado.triggerCast()
|
||||||
|
expect(simTornado.missiles[0]!.celFile).toBe('tornado')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('S7: Assassin (ass, IDs 251..280) Full 30-Skill Evaluation & Multi-Tick Combat Rotation (Venom -> Shadow Master -> 5 Traps -> Phoenix Strike + Dragon Tail)', () => {
|
||||||
|
for (let id = 251; id <= 280; id += 1) {
|
||||||
|
const row = oracle.skillsById.get(id)!
|
||||||
|
expect(textCell(row, 'charclass')).toBe('ass')
|
||||||
|
for (const slvl of [1, 10, 20]) {
|
||||||
|
const { manaCost256 } = compute113cManaCost256(row, slvl)
|
||||||
|
expect(Number.isFinite(manaCost256)).toBe(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const simTraps = createArenaSimulation('ass', 271, 20)
|
||||||
|
for (let i = 0; i < 6; i += 1) simTraps.triggerCast()
|
||||||
|
expect(simTraps.pets.length).toBe(5)
|
||||||
|
expect(simTraps.pets[0]!.id).toBe('lightningsentry-2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('S8: Universal Player Skills (IDs 0..5, 217..220, 350) Complete End-to-End Execution', () => {
|
||||||
|
for (const id of UNIVERSAL_SKILL_IDS) {
|
||||||
|
const sim = createArenaSimulation('uni', id, 1)
|
||||||
|
sim.triggerCast()
|
||||||
|
sim.stepTicks(10)
|
||||||
|
expect(sim.castCount).toBe(1)
|
||||||
|
expect(sim.tick).toBe(10)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('S9: Deterministic Browser Arena URL & window.__d2webArena Contract Verification (?arena=1&class=<cls>&skill=<id>&slvl=20)', () => {
|
||||||
|
for (const cls of HERO_CLASS_CODES) {
|
||||||
|
const sampleSkillId =
|
||||||
|
cls === 'ama'
|
||||||
|
? 35
|
||||||
|
: cls === 'sor'
|
||||||
|
? 47
|
||||||
|
: cls === 'nec'
|
||||||
|
? 74
|
||||||
|
: cls === 'pal'
|
||||||
|
? 112
|
||||||
|
: cls === 'bar'
|
||||||
|
? 151
|
||||||
|
: cls === 'dru'
|
||||||
|
? 245
|
||||||
|
: 280
|
||||||
|
const sim = createArenaSimulation(cls, sampleSkillId, 20)
|
||||||
|
expect(sim.ready).toBe(true)
|
||||||
|
expect(sim.charToken).toBe(HERO_SPRITE_TOKENS[cls])
|
||||||
|
expect(sim.targets.map(t => t.id)).toEqual([
|
||||||
|
'dummy-normal',
|
||||||
|
'dummy-undead',
|
||||||
|
'dummy-demon',
|
||||||
|
'dummy-immune',
|
||||||
|
])
|
||||||
|
sim.triggerCast()
|
||||||
|
sim.stepTicks(25)
|
||||||
|
expect(sim.castCount).toBe(1)
|
||||||
|
expect(sim.totalDamageDealt).toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('S10: Complete 221-Skill Numerical Sweep (slvl 1, 10, 20) & Gitea Issue Mapping Verification', () => {
|
||||||
|
const all221Ids = [
|
||||||
|
...UNIVERSAL_SKILL_IDS,
|
||||||
|
...Array.from({ length: 150 }, (_, i) => i + 6),
|
||||||
|
...Array.from({ length: 60 }, (_, i) => i + 221),
|
||||||
|
]
|
||||||
|
expect(all221Ids.length).toBe(221)
|
||||||
|
for (const id of all221Ids) {
|
||||||
|
const row = oracle.skillsById.get(id)!
|
||||||
|
expect(row).toBeDefined()
|
||||||
|
for (const slvl of [1, 10, 20]) {
|
||||||
|
const { manaCost, manaCost256 } = compute113cManaCost256(row, slvl)
|
||||||
|
const eMin = compute113c5BandValue(
|
||||||
|
numberCell(row, 'EMin', 0),
|
||||||
|
numberCell(row, 'EMinLev1', 0),
|
||||||
|
numberCell(row, 'EMinLev2', 0),
|
||||||
|
numberCell(row, 'EMinLev3', 0),
|
||||||
|
numberCell(row, 'EMinLev4', 0),
|
||||||
|
numberCell(row, 'EMinLev5', 0),
|
||||||
|
slvl,
|
||||||
|
)
|
||||||
|
const dm12 = compute113cDm(numberCell(row, 'Param1', 0), numberCell(row, 'Param2', 0), slvl)
|
||||||
|
expect(Number.isFinite(manaCost)).toBe(true)
|
||||||
|
expect(Number.isFinite(manaCost256)).toBe(true)
|
||||||
|
expect(Number.isFinite(eMin)).toBe(true)
|
||||||
|
expect(Number.isFinite(dm12)).toBe(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('S11: 7-Class Cast (SC) & Attack (A1) COF Animation & AnimData.d2 Cross-Verification', async () => {
|
||||||
|
for (const [cls, token] of Object.entries(HERO_SPRITE_TOKENS)) {
|
||||||
|
const scCofBytes = await oracle.mounted.read(`data\\global\\chars\\${token}\\cof\\${token}schth.cof`)
|
||||||
|
const a1CofBytes = await oracle.mounted.read(`data\\global\\chars\\${token}\\cof\\${token}a1hth.cof`)
|
||||||
|
const scCof = decodeCof(scCofBytes)
|
||||||
|
const a1Cof = decodeCof(a1CofBytes)
|
||||||
|
expect(scCof.framesPerDirection, `${cls} (${token}) SC`).toBeGreaterThan(0)
|
||||||
|
expect(a1Cof.framesPerDirection, `${cls} (${token}) A1`).toBeGreaterThan(0)
|
||||||
|
const animSc = oracle.animDataMap.get(`${token.toUpperCase()}SCHTH`)
|
||||||
|
const animA1 = oracle.animDataMap.get(`${token.toUpperCase()}A1HTH`)
|
||||||
|
expect(animSc, `Missing AnimData ${token.toUpperCase()}SCHTH`).toBeDefined()
|
||||||
|
expect(animA1, `Missing AnimData ${token.toUpperCase()}A1HTH`).toBeDefined()
|
||||||
|
expect(animSc!.framesPerDir).toBe(scCof.framesPerDirection)
|
||||||
|
expect(animA1!.framesPerDir).toBe(a1Cof.framesPerDirection)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('S12: Repository & M5 Strict Acceptance Gate Verification', async () => {
|
||||||
|
expect(existsSync('/usr/local/google/home/taodao/diablo2-web/TEST_INFRA.md')).toBe(true)
|
||||||
|
if (process.env.D2WEB_M5_STRICT === '1') {
|
||||||
|
expect(existsSync('/usr/local/google/home/taodao/diablo2-web/src/data/gitea-skill-issues.json')).toBe(true)
|
||||||
|
expect(existsSync('/usr/local/google/home/taodao/diablo2-web/src/game/skills/registry.ts')).toBe(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,523 @@
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
getSharedDataRegistry,
|
||||||
|
getSharedMountedArchives,
|
||||||
|
PET_SUB_SKILL_IDS,
|
||||||
|
UNIVERSAL_PLAYER_SKILL_IDS,
|
||||||
|
} from '../../src/game/engine/data-registry.ts'
|
||||||
|
import {
|
||||||
|
compute5BandScaling,
|
||||||
|
computeDiminishingReturns,
|
||||||
|
computeLinearScaling,
|
||||||
|
computeSkillManaCost256,
|
||||||
|
evaluateCalc,
|
||||||
|
} from '../../src/game/engine/calc-ast.ts'
|
||||||
|
import { FIXED_ONE, UnitStatList } from '../../src/game/engine/stat-list.ts'
|
||||||
|
import { StateBus } from '../../src/game/engine/state-bus.ts'
|
||||||
|
import {
|
||||||
|
computeCorpseExplosion113c,
|
||||||
|
computeEffectiveResistance,
|
||||||
|
executeSUnitDmg,
|
||||||
|
resolveVenomPoisonApplication,
|
||||||
|
type CombatUnitContext,
|
||||||
|
} from '../../src/game/engine/combat-pipeline.ts'
|
||||||
|
import {
|
||||||
|
AnimDispatcher,
|
||||||
|
computeEffectiveFcr,
|
||||||
|
computeEffectiveIas,
|
||||||
|
} from '../../src/game/engine/anim-dispatcher.ts'
|
||||||
|
import { MissileEngine } from '../../src/game/engine/missile-engine.ts'
|
||||||
|
import { AuraScanner } from '../../src/game/engine/aura-scanner.ts'
|
||||||
|
import { SummonManager } from '../../src/game/engine/summon-manager.ts'
|
||||||
|
import { WorldArena } from '../../src/game/engine/world-arena.ts'
|
||||||
|
import { evaluateSkill113c } from '../../src/game/skills/registry.ts'
|
||||||
|
import { UnitSpriteLoader } from '../../src/render/unit-sprites.ts'
|
||||||
|
import { createAndCastSkillInArena, evaluateSkillCurve113c } from '../helpers/skill-test-harness.ts'
|
||||||
|
|
||||||
|
describe('R1 Universal 1.13c Skill Engine & 7-Class Sandbox Foundation', () => {
|
||||||
|
it('R1.1: loads and indexes all 1.13c Excel tables and AnimData.d2 (570,304 bytes / 3,558 records) from /usr/local/google/home/taodao/d2-data', async () => {
|
||||||
|
const registry = await getSharedDataRegistry()
|
||||||
|
|
||||||
|
// Verify AnimData.d2 integrity
|
||||||
|
expect(registry.animData.bytesConsumed).toBe(570304)
|
||||||
|
expect(registry.animData.all.length).toBe(3558)
|
||||||
|
|
||||||
|
// Verify all 221 player skills (210 class skills across 7 classes + 11 universal player skills)
|
||||||
|
const allPlayerSkills = registry.getAllPlayerSkills()
|
||||||
|
expect(allPlayerSkills.length).toBe(221)
|
||||||
|
|
||||||
|
const classSkills = allPlayerSkills.filter(s => s.charClass !== '')
|
||||||
|
expect(classSkills.length).toBe(210)
|
||||||
|
for (const cls of ['ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass']) {
|
||||||
|
expect(classSkills.filter(s => s.charClass === cls).length).toBe(30)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const uniId of UNIVERSAL_PLAYER_SKILL_IDS) {
|
||||||
|
expect(registry.getSkillById(uniId)).toBeDefined()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all 17 pet/trap AI sub-skills (IDs 281..338)
|
||||||
|
const petSubSkills = registry.getPetSubSkills()
|
||||||
|
expect(petSubSkills.length).toBe(17)
|
||||||
|
expect(petSubSkills.map(s => s.id)).toEqual([...PET_SUB_SKILL_IDS])
|
||||||
|
|
||||||
|
// Verify 1.13c specific table values from Patch_D2.mpq
|
||||||
|
const ce = registry.getSkillById(74)! // Corpse Explosion
|
||||||
|
expect(ce.param1).toBe(70)
|
||||||
|
expect(ce.param2).toBe(120)
|
||||||
|
|
||||||
|
const hydra = registry.getSkillById(62)! // Hydra 1.13c cooldown = 40 frames (down from 50 in 1.10)
|
||||||
|
expect(Number(hydra.delay)).toBe(40)
|
||||||
|
expect(hydra.eMin).toBe(28)
|
||||||
|
expect(hydra.eMax).toBe(39)
|
||||||
|
|
||||||
|
const ww = registry.getSkillById(151)! // Whirlwind 1.13c mana reduced 50%
|
||||||
|
expect(ww.mana).toBe(25)
|
||||||
|
expect(ww.manaShift).toBe(7)
|
||||||
|
expect(computeSkillManaCost256(ww, 1).manaCost).toBe(12)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('R1.1: evaluates D2Common calc AST formulas, 5-band scaling, 1.13c edge cases, and strictly base-only blvl synergies (Marrowwalk fix)', async () => {
|
||||||
|
const registry = await getSharedDataRegistry()
|
||||||
|
const fireWall = registry.getSkillById(51)!
|
||||||
|
const boneWall = registry.getSkillById(78)!
|
||||||
|
const fireGolem = registry.getSkillById(94)!
|
||||||
|
const royalChain = registry.getMissileById(568)!
|
||||||
|
|
||||||
|
// Edge Case #3: `royalstrikechainlightning` row 568 has `pSrvHitFunc = "*12"` and `pCltHitFunc = "*16"`
|
||||||
|
expect(royalChain.pSrvHitFunc).toBe(12)
|
||||||
|
expect(royalChain.pCltHitFunc).toBe(16)
|
||||||
|
|
||||||
|
// Edge Case #1: Unclosed `(` in Fire Wall `EDmgSymPerCalc = "(skill('Warmth'.blvl)*par8+skill('Inferno'.blvl)*par7"`
|
||||||
|
const statList = new UnitStatList(registry)
|
||||||
|
statList.setBaseSkillLevel(37, 20) // Warmth blvl = 20 (* par8=4 => 80)
|
||||||
|
statList.setBaseSkillLevel(41, 10) // Inferno blvl = 10 (* par7=1 => 10)
|
||||||
|
// Add +10 bonus skills and level 33 charges to verify they do NOT leak into `.blvl`!
|
||||||
|
statList.setBonusSkillLevel(37, 10)
|
||||||
|
statList.setChargedSkillLevel(41, 33)
|
||||||
|
|
||||||
|
const fwSyn = evaluateCalc(fireWall.eDmgSymPerCalc, {
|
||||||
|
registry,
|
||||||
|
activeSkill: fireWall,
|
||||||
|
slvl: 20,
|
||||||
|
blvl: 20,
|
||||||
|
getBaseSkillLevel: id => statList.getBaseSkillLevel(id),
|
||||||
|
getEffectiveSkillLevel: id => statList.getEffectiveSkillLevel(id),
|
||||||
|
})
|
||||||
|
expect(fwSyn).toBe(20 * 4 + 10 * 1) // 90%
|
||||||
|
|
||||||
|
// Edge Case #2: Unknown bare identifier `par34` in Bone Wall `calc2` evaluates to 0
|
||||||
|
const bwCalc2 = evaluateCalc(boneWall.calc2, {
|
||||||
|
registry,
|
||||||
|
activeSkill: boneWall,
|
||||||
|
slvl: 10,
|
||||||
|
blvl: 10,
|
||||||
|
})
|
||||||
|
expect(bwCalc2).toBe(0)
|
||||||
|
|
||||||
|
// Edge Case #4: Two-dot `sklvl('Holy Fire'.ln56.edmn)` in FireGolem SkillDesc
|
||||||
|
const fgHolyFire = evaluateCalc("sklvl('Holy Fire'.ln56.edmn)", {
|
||||||
|
registry,
|
||||||
|
activeSkill: fireGolem,
|
||||||
|
slvl: 10,
|
||||||
|
blvl: 10,
|
||||||
|
})
|
||||||
|
expect(fgHolyFire).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
// Verify Marrowwalk / +skills synergy exclusion on Bone Spear (ID 84, synergies: Bone Wall 78, Bone Prison 88, Teeth 67, Bone Armor 68)
|
||||||
|
const boneSpearCurve = await evaluateSkillCurve113c(84, [67, 68, 78, 88])
|
||||||
|
expect(boneSpearCurve.slvl20WithSynergies.minElemDmg).toBeGreaterThan(boneSpearCurve.slvl20.minElemDmg * 2)
|
||||||
|
// When synergy skills only have +20 bonus skills and level 33 item charges (blvl = 0), synergy bonus must be 0!
|
||||||
|
expect(boneSpearCurve.slvl20WithBonusSkillsOnly.synergyBonusPct).toBe(0)
|
||||||
|
expect(boneSpearCurve.slvl20WithBonusSkillsOnly.minElemDmg).toBe(boneSpearCurve.slvl20.minElemDmg)
|
||||||
|
|
||||||
|
// Verify linear & diminishing returns macros
|
||||||
|
expect(computeLinearScaling(10, 5, 20)).toBe(105)
|
||||||
|
expect(computeDiminishingReturns(0, 35, 20)).toBeGreaterThan(25)
|
||||||
|
expect(compute5BandScaling(20, 10, 2, 4, 6, 8, 10)).toBe(10 + 7 * 2 + 8 * 4 + 4 * 6)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('R1.2: enforces Curse Exclusivity (cursetype=1), STATE_ATTRACT (cursetype=2) overwrite immunity, Aura stacking, and reactive auraeventfunc', async () => {
|
||||||
|
const registry = await getSharedDataRegistry()
|
||||||
|
const monsterStats = new UnitStatList(registry)
|
||||||
|
const monsterStateBus = new StateBus(monsterStats, registry)
|
||||||
|
|
||||||
|
// 1. Apply Amplify Damage (`cursetype = 1`)
|
||||||
|
const amp = monsterStateBus.applyState({
|
||||||
|
stateNameOrId: 'amplifydamage',
|
||||||
|
sourceSkillId: 66,
|
||||||
|
slvl: 10,
|
||||||
|
durationFrames: 200,
|
||||||
|
currentFrame: 10,
|
||||||
|
stats: { amplify_phys_pierce: 100 },
|
||||||
|
})
|
||||||
|
expect(amp.applied).toBe(true)
|
||||||
|
expect(monsterStateBus.hasState('amplifydamage')).toBe(true)
|
||||||
|
|
||||||
|
// 2. Apply Decrepify (`cursetype = 1`) -> overwrites Amplify Damage
|
||||||
|
const dec = monsterStateBus.applyState({
|
||||||
|
stateNameOrId: 'decrepify',
|
||||||
|
sourceSkillId: 87,
|
||||||
|
slvl: 5,
|
||||||
|
durationFrames: 150,
|
||||||
|
currentFrame: 20,
|
||||||
|
stats: { amplify_phys_pierce: 50 },
|
||||||
|
})
|
||||||
|
expect(dec.applied).toBe(true)
|
||||||
|
expect(dec.evictedState).toBe('amplifydamage')
|
||||||
|
expect(monsterStateBus.hasState('amplifydamage')).toBe(false)
|
||||||
|
expect(monsterStateBus.hasState('decrepify')).toBe(true)
|
||||||
|
|
||||||
|
// 3. Apply Attract (`cursetype = 2`) -> coexists or locks monster, and blocks subsequent `cursetype = 1` curses!
|
||||||
|
const attr = monsterStateBus.applyState({
|
||||||
|
stateNameOrId: 'attract',
|
||||||
|
sourceSkillId: 86,
|
||||||
|
slvl: 10,
|
||||||
|
durationFrames: 300,
|
||||||
|
currentFrame: 30,
|
||||||
|
curseTypeOverride: 2,
|
||||||
|
})
|
||||||
|
expect(attr.applied).toBe(true)
|
||||||
|
expect(monsterStateBus.hasState('attract')).toBe(true)
|
||||||
|
|
||||||
|
const blockedLowerRes = monsterStateBus.applyState({
|
||||||
|
stateNameOrId: 'lowerresist',
|
||||||
|
sourceSkillId: 91,
|
||||||
|
slvl: 20,
|
||||||
|
durationFrames: 500,
|
||||||
|
currentFrame: 40,
|
||||||
|
curseTypeOverride: 1,
|
||||||
|
})
|
||||||
|
expect(blockedLowerRes.applied).toBe(false)
|
||||||
|
expect(blockedLowerRes.blockedByAttract).toBe(true)
|
||||||
|
expect(monsterStateBus.hasState('attract')).toBe(true)
|
||||||
|
expect(monsterStateBus.hasState('lowerresist')).toBe(false)
|
||||||
|
|
||||||
|
// 4. Aura stacking & level override
|
||||||
|
const palStats = new UnitStatList(registry)
|
||||||
|
const palStateBus = new StateBus(palStats, registry)
|
||||||
|
palStateBus.applyState({
|
||||||
|
stateNameOrId: 'might',
|
||||||
|
sourceUnitId: 'merc',
|
||||||
|
slvl: 10,
|
||||||
|
durationFrames: 55,
|
||||||
|
currentFrame: 0,
|
||||||
|
stats: { damagepercent: 130 },
|
||||||
|
isAuraOverride: true,
|
||||||
|
})
|
||||||
|
palStateBus.applyState({
|
||||||
|
stateNameOrId: 'might',
|
||||||
|
sourceUnitId: 'paladin',
|
||||||
|
slvl: 20,
|
||||||
|
durationFrames: 30,
|
||||||
|
currentFrame: 0,
|
||||||
|
stats: { damagepercent: 230 },
|
||||||
|
isAuraOverride: true,
|
||||||
|
})
|
||||||
|
palStateBus.applyState({
|
||||||
|
stateNameOrId: 'fanaticism',
|
||||||
|
sourceUnitId: 'paladin',
|
||||||
|
slvl: 20,
|
||||||
|
durationFrames: 55,
|
||||||
|
currentFrame: 0,
|
||||||
|
stats: { damagepercent: 373, skill_ias: 35 },
|
||||||
|
isAuraOverride: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Higher Might (230) + Fanaticism (373) = 603% damagepercent while both active
|
||||||
|
expect(palStats.getModifierBonus('damagepercent')).toBe(230 + 373)
|
||||||
|
// When slvl 20 Might expires at frame 35, slvl 10 Might (130) remains active alongside Fanaticism (373)!
|
||||||
|
palStateBus.tick(35)
|
||||||
|
expect(palStats.getModifierBonus('damagepercent')).toBe(130 + 373)
|
||||||
|
|
||||||
|
// 5. Reactive `auraeventfunc`: Energy Shield + Telekinesis blvl synergy & 1.13c Blood Golem no-negative-life rule
|
||||||
|
const sorcStats = new UnitStatList(registry, {
|
||||||
|
maxhp: 1000 * FIXED_ONE,
|
||||||
|
hitpoints: 1000 * FIXED_ONE,
|
||||||
|
maxmana: 1000 * FIXED_ONE,
|
||||||
|
mana: 1000 * FIXED_ONE,
|
||||||
|
})
|
||||||
|
sorcStats.setBaseSkillLevel(43, 16) // 16 hard points in Telekinesis => (32 - 16)/16 = 1.0 mana per HP redirected!
|
||||||
|
const sorcBus = new StateBus(sorcStats, registry)
|
||||||
|
sorcBus.applyState({
|
||||||
|
stateNameOrId: 'energyshield',
|
||||||
|
sourceSkillId: 58,
|
||||||
|
slvl: 20,
|
||||||
|
stats: { energyshield_pct: 75 },
|
||||||
|
})
|
||||||
|
const esOut = sorcBus.triggerReactiveEvents({
|
||||||
|
event: 'absorbdamage',
|
||||||
|
incomingPhys256: 200 * FIXED_ONE,
|
||||||
|
incomingElem256: 0,
|
||||||
|
})
|
||||||
|
expect(esOut.absorbedPhys256).toBe(150 * FIXED_ONE)
|
||||||
|
expect(esOut.manaDrained256).toBe(150 * FIXED_ONE) // 1:1 ratio with 16 hard points in Telekinesis
|
||||||
|
|
||||||
|
// Blood Golem 1.13c: taking melee damage does NOT drain Necromancer HP
|
||||||
|
const necStats = new UnitStatList(registry, { maxhp: 1000 * FIXED_ONE, hitpoints: 800 * FIXED_ONE })
|
||||||
|
const golemBus = new StateBus(new UnitStatList(registry), registry)
|
||||||
|
golemBus.applyState({ stateNameOrId: 'bloodgolem', sourceSkillId: 85, slvl: 20 })
|
||||||
|
golemBus.triggerReactiveEvents({
|
||||||
|
event: 'damagedinmelee',
|
||||||
|
incomingPhys256: 500 * FIXED_ONE,
|
||||||
|
ownerUnitStatList: necStats,
|
||||||
|
})
|
||||||
|
expect(necStats.getHp()).toBe(800) // Unchanged when golem takes damage!
|
||||||
|
golemBus.triggerReactiveEvents({
|
||||||
|
event: 'domeleedamage',
|
||||||
|
incomingPhys256: 200 * FIXED_ONE,
|
||||||
|
ownerUnitStatList: necStats,
|
||||||
|
})
|
||||||
|
expect(necStats.getHp()).toBe(860) // Healed +30% of 200 = +60 HP on golem attack!
|
||||||
|
})
|
||||||
|
|
||||||
|
it('R1.2: enforces 1.13c SUnitDmg combat pipeline rules (Conviction/LR 1/5 immunity break, Blessed Hammer vs Magic Immune Undead/Demon, Corpse Explosion 70-120%, Venom 10f clamp)', async () => {
|
||||||
|
const registry = await getSharedDataRegistry()
|
||||||
|
|
||||||
|
// 1. Conviction + Lower Resist 1/5 immunity-break rule
|
||||||
|
// Base 110% Fire Res vs Conviction (-60%) + Lower Resist (-45%) = -105% raw -> 105 / 5 = 21% pierce -> 110 - 21 = 89% (< 100, broken!)
|
||||||
|
// Then -25% passive_fire_pierce applies at 100% -> 89 - 25 = 64% effective resistance!
|
||||||
|
const brokenRes = computeEffectiveResistance({
|
||||||
|
baseRes: 110,
|
||||||
|
convictionPierce: 60,
|
||||||
|
lowerResistPierce: 45,
|
||||||
|
passiveEnemyPierce: 25,
|
||||||
|
})
|
||||||
|
expect(brokenRes.isImmune).toBe(false)
|
||||||
|
expect(brokenRes.resAfterConvictionLR).toBe(89)
|
||||||
|
expect(brokenRes.effectiveRes).toBe(64)
|
||||||
|
|
||||||
|
// Base 130% Cold Res vs Conviction (-60%) -> 60 / 5 = 12% -> 118% >= 100 -> still Immune! Level 20 Cold Mastery (-115%) cannot apply!
|
||||||
|
const unbrokenRes = computeEffectiveResistance({
|
||||||
|
baseRes: 130,
|
||||||
|
convictionPierce: 60,
|
||||||
|
passiveEnemyPierce: 115,
|
||||||
|
})
|
||||||
|
expect(unbrokenRes.isImmune).toBe(true)
|
||||||
|
expect(unbrokenRes.effectiveRes).toBe(100)
|
||||||
|
|
||||||
|
// 2. 1.13c Blessed Hammer (`skillId = 112`): does NOT ignore Magic Resistance of Undead or Demons!
|
||||||
|
const palStats = new UnitStatList(registry)
|
||||||
|
palStats.setBaseStat('concentration_damage_pct', 300)
|
||||||
|
const pal: CombatUnitContext = {
|
||||||
|
id: 'pal',
|
||||||
|
name: 'Paladin',
|
||||||
|
statList: palStats,
|
||||||
|
stateBus: new StateBus(palStats, registry),
|
||||||
|
}
|
||||||
|
|
||||||
|
const magicImmuneUndeadStats = new UnitStatList(registry, {
|
||||||
|
maxhp: 5000 * FIXED_ONE,
|
||||||
|
hitpoints: 5000 * FIXED_ONE,
|
||||||
|
magicresist: 100, // Magic Immune Undead (e.g. Achmel / Plague Bearer in 1.13c)
|
||||||
|
})
|
||||||
|
const magicImmuneUndead: CombatUnitContext = {
|
||||||
|
id: 'achmel',
|
||||||
|
name: 'Magic Immune Undead',
|
||||||
|
statList: magicImmuneUndeadStats,
|
||||||
|
stateBus: new StateBus(magicImmuneUndeadStats, registry),
|
||||||
|
isUndead: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const bhVsImmuneUndead = executeSUnitDmg(pal, magicImmuneUndead, {
|
||||||
|
skillId: 112,
|
||||||
|
attackKind: 'spell',
|
||||||
|
elemType: 'mag',
|
||||||
|
elemMin256: 400 * FIXED_ONE,
|
||||||
|
elemMax256: 400 * FIXED_ONE,
|
||||||
|
concentrationBonusPct: 300,
|
||||||
|
})
|
||||||
|
expect(bhVsImmuneUndead.immuneToElem).toBe(true)
|
||||||
|
expect(bhVsImmuneUndead.totalDamage).toBe(0)
|
||||||
|
|
||||||
|
// Against 50% Magic Resist Undead, Blessed Hammer applies +50% Concentration (150% -> 2.5x) and +50% Undead bonus (1.5x), then 50% magic resist:
|
||||||
|
// 400 * 2.5 * 1.5 * 0.5 = 750 damage!
|
||||||
|
const magicRes50UndeadStats = new UnitStatList(registry, {
|
||||||
|
maxhp: 5000 * FIXED_ONE,
|
||||||
|
hitpoints: 5000 * FIXED_ONE,
|
||||||
|
magicresist: 50,
|
||||||
|
})
|
||||||
|
const magicRes50Undead: CombatUnitContext = {
|
||||||
|
id: 'mummy',
|
||||||
|
name: '50% Magic Res Undead',
|
||||||
|
statList: magicRes50UndeadStats,
|
||||||
|
stateBus: new StateBus(magicRes50UndeadStats, registry),
|
||||||
|
isUndead: true,
|
||||||
|
}
|
||||||
|
const bhVs50Undead = executeSUnitDmg(pal, magicRes50Undead, {
|
||||||
|
skillId: 112,
|
||||||
|
attackKind: 'spell',
|
||||||
|
elemType: 'mag',
|
||||||
|
elemMin256: 400 * FIXED_ONE,
|
||||||
|
elemMax256: 400 * FIXED_ONE,
|
||||||
|
concentrationBonusPct: 300,
|
||||||
|
})
|
||||||
|
expect(bhVs50Undead.immuneToElem).toBe(false)
|
||||||
|
expect(bhVs50Undead.totalDamage).toBe(750)
|
||||||
|
|
||||||
|
// 3. 1.13c Corpse Explosion (`skillId = 74`): 70%–120% base monster HP (50% Phys + 50% Fire)
|
||||||
|
const ceMin = computeCorpseExplosion113c({ corpseBaseHp: 1000, rollPct: 70 })
|
||||||
|
const ceMax = computeCorpseExplosion113c({ corpseBaseHp: 1000, rollPct: 120 })
|
||||||
|
expect(ceMin.minRollPct).toBe(70)
|
||||||
|
expect(ceMax.maxRollPct).toBe(120)
|
||||||
|
expect(ceMin.finalTotalDamage).toBe(700)
|
||||||
|
expect(ceMin.finalPhysDamage).toBe(350)
|
||||||
|
expect(ceMin.finalFireDamage).toBe(350)
|
||||||
|
expect(ceMax.finalTotalDamage).toBe(1200)
|
||||||
|
|
||||||
|
// 4. 1.13c Venom (`skillId = 278`): 10-frame duration clamp preserving combined poison bitrates
|
||||||
|
const venomCalc = resolveVenomPoisonApplication({
|
||||||
|
venomActive: true,
|
||||||
|
venomBitrate256: 40 * FIXED_ONE,
|
||||||
|
otherPoisonSources: [
|
||||||
|
{ bitrate256: 10 * FIXED_ONE, frames: 250 }, // 10-second poison charm clamped to 10 frames!
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(venomCalc.durationFrames).toBe(10)
|
||||||
|
expect(venomCalc.bitrate256).toBe(50 * FIXED_ONE)
|
||||||
|
expect(venomCalc.totalDamage).toBe(500)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('R1.3: executes AnimDispatcher (EIAS/EFCR, STATE_SKILLDELAY), MissileEngine (NextDelay, Pierce), SummonManager (PetType caps & 17 sub-skills), and all 221 player skills', async () => {
|
||||||
|
const registry = await getSharedDataRegistry()
|
||||||
|
|
||||||
|
// 1. EIAS / EFCR diminishing returns
|
||||||
|
expect(computeEffectiveFcr(105)).toBe(Math.trunc((120 * 105) / 225)) // 56 EFCR
|
||||||
|
expect(computeEffectiveIas({ itemIas: 60, skillIas: 35, weaponWsm: -20 })).toBe(75) // capped at +75
|
||||||
|
|
||||||
|
// 2. AnimDispatcher STATE_SKILLDELAY on Hydra (`skillId = 62`, `delay = 40` frames in 1.13c)
|
||||||
|
const sorcStats = new UnitStatList(registry)
|
||||||
|
const sorcBus = new StateBus(sorcStats, registry)
|
||||||
|
const dispatcher = new AnimDispatcher({
|
||||||
|
registry,
|
||||||
|
statList: sorcStats,
|
||||||
|
stateBus: sorcBus,
|
||||||
|
charToken: 'so',
|
||||||
|
weaponClass: 'hth',
|
||||||
|
})
|
||||||
|
const hydraRec = registry.getSkillById(62)!
|
||||||
|
const firstCast = dispatcher.startSkillAnimation({ skill: hydraRec, slvl: 20, currentTick: 10 })
|
||||||
|
expect(firstCast.allowed).toBe(true)
|
||||||
|
for (let t = 11; t <= 30; t += 1) {
|
||||||
|
const res = dispatcher.tick(t, () => {})
|
||||||
|
if (res.actionFired) break
|
||||||
|
}
|
||||||
|
expect(dispatcher.isSkillDelayActive(25)).toBe(true)
|
||||||
|
const blockedRecast = dispatcher.startSkillAnimation({ skill: hydraRec, slvl: 20, currentTick: 25 })
|
||||||
|
expect(blockedRecast.allowed).toBe(false)
|
||||||
|
expect(blockedRecast.reason).toBe('skill_delay')
|
||||||
|
|
||||||
|
// 3. MissileEngine `NextDelay` immunity gate (`Multiple Shot` 12 / `Nova` 48 `nextDelay = 4` frames)
|
||||||
|
const missileEngine = new MissileEngine(registry)
|
||||||
|
const dummyStats = new UnitStatList(registry, { maxhp: 10000 * FIXED_ONE, hitpoints: 10000 * FIXED_ONE })
|
||||||
|
const dummy: CombatUnitContext = {
|
||||||
|
id: 'dummy-1',
|
||||||
|
name: 'Dummy',
|
||||||
|
statList: dummyStats,
|
||||||
|
stateBus: new StateBus(dummyStats, registry),
|
||||||
|
}
|
||||||
|
const posMap = new Map([['dummy-1', { x: 18, y: 0 }]])
|
||||||
|
const owner: CombatUnitContext = { id: 'ama', name: 'Amazon', statList: sorcStats, stateBus: sorcBus }
|
||||||
|
|
||||||
|
// Spawn 2 `arrow` missiles with `NextDelay = 4` (`multiple shot` arrow `shockfield` or `nova`)
|
||||||
|
missileEngine.spawnMissile({
|
||||||
|
missileNameOrId: 'nova',
|
||||||
|
sourceSkillId: 48,
|
||||||
|
slvl: 20,
|
||||||
|
owner,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
targetX: 18,
|
||||||
|
targetY: 0,
|
||||||
|
dmgPacket: { skillId: 48, attackKind: 'spell', elemType: 'ltng', elemMin256: 100 * FIXED_ONE, elemMax256: 100 * FIXED_ONE, autoHit: true },
|
||||||
|
})
|
||||||
|
missileEngine.spawnMissile({
|
||||||
|
missileNameOrId: 'nova',
|
||||||
|
sourceSkillId: 48,
|
||||||
|
slvl: 20,
|
||||||
|
owner,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
targetX: 18,
|
||||||
|
targetY: 0,
|
||||||
|
dmgPacket: { skillId: 48, attackKind: 'spell', elemType: 'ltng', elemMin256: 100 * FIXED_ONE, elemMax256: 100 * FIXED_ONE, autoHit: true },
|
||||||
|
})
|
||||||
|
const step1 = missileEngine.tick(100, [dummy], posMap)
|
||||||
|
// Because `nova` has `NextDelay = 4`, only the FIRST nova missile hits `dummy-1` on tick 100!
|
||||||
|
expect(step1.hits.length).toBe(1)
|
||||||
|
expect(missileEngine.isTargetInNextDelay('dummy-1', 102)).toBe(true)
|
||||||
|
expect(missileEngine.isTargetInNextDelay('dummy-1', 105)).toBe(false)
|
||||||
|
|
||||||
|
// 4. SummonManager `PetType.txt` group exclusivity & 5-trap FIFO cap
|
||||||
|
const summonManager = new SummonManager(registry)
|
||||||
|
// Lay 6 Lightning Sentries (`271`) -> capped at 5 active traps with oldest FIFO evicted
|
||||||
|
for (let i = 0; i < 6; i += 1) {
|
||||||
|
summonManager.createPet({ owner, skillId: 271, slvl: 20 })
|
||||||
|
}
|
||||||
|
expect(summonManager.getActivePets().filter(p => p.petType === 'assassintrap').length).toBe(5)
|
||||||
|
|
||||||
|
// Druid Spirit Wolf (`227`) vs Summon Grizzly (`247`) (`group = 1` exclusivity)
|
||||||
|
summonManager.createPet({ owner, skillId: 227, slvl: 20 })
|
||||||
|
summonManager.createPet({ owner, skillId: 227, slvl: 20 })
|
||||||
|
expect(summonManager.getActivePets().filter(p => p.sourceSkillId === 227).length).toBe(2)
|
||||||
|
const grizzlyOut = summonManager.createPet({ owner, skillId: 247, slvl: 20 })
|
||||||
|
expect(grizzlyOut.evictedPetUids.length).toBe(2)
|
||||||
|
expect(summonManager.getActivePets().filter(p => p.sourceSkillId === 227).length).toBe(0)
|
||||||
|
expect(summonManager.getActivePets().filter(p => p.sourceSkillId === 247).length).toBe(1)
|
||||||
|
|
||||||
|
// 5. Evaluate and execute ALL 221 player skills (`210` class skills + `11` universal skills) across WorldArena
|
||||||
|
const allPlayerSkills = registry.getAllPlayerSkills()
|
||||||
|
expect(allPlayerSkills.length).toBe(221)
|
||||||
|
|
||||||
|
for (const sk of allPlayerSkills) {
|
||||||
|
const evalRes = evaluateSkill113c({
|
||||||
|
registry,
|
||||||
|
skill: sk,
|
||||||
|
slvl: 20,
|
||||||
|
blvl: 20,
|
||||||
|
statList: sorcStats,
|
||||||
|
})
|
||||||
|
expect(evalRes.skillId).toBe(sk.id)
|
||||||
|
expect(evalRes.slvl).toBe(20)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Representative arena execution across all 7 classes + universal skills
|
||||||
|
const sampleSkills = [0, 1, 2, 12, 35, 47, 62, 64, 70, 74, 86, 112, 123, 149, 151, 228, 245, 271, 278, 280]
|
||||||
|
for (const sid of sampleSkills) {
|
||||||
|
const { outcome, debugState } = await createAndCastSkillInArena({ skillId: sid, slvl: 20 })
|
||||||
|
expect(outcome.executed).toBe(true)
|
||||||
|
expect(debugState.ready).toBe(true)
|
||||||
|
expect(debugState.castCount).toBeGreaterThanOrEqual(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('R1.4: decodes all 7 player classes (am, so, ne, pa, ba, dz, ai), missile .dcc, overlay .dcc, and summon sprites with zero skipped frames', async () => {
|
||||||
|
const mounted = await getSharedMountedArchives()
|
||||||
|
const loader = new UnitSpriteLoader(mounted)
|
||||||
|
|
||||||
|
for (const cls of ['am', 'so', 'ne', 'pa', 'ba', 'dz', 'ai']) {
|
||||||
|
const sheet = await loader.loadPlayerClassSheet(cls, 'sc', 'hth')
|
||||||
|
expect(sheet.directions).toBe(16)
|
||||||
|
expect(sheet.framesPerDirection).toBeGreaterThanOrEqual(6)
|
||||||
|
expect(sheet.skipped).toBe(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fireballSprite = await loader.loadMissileSprite('fireball')
|
||||||
|
expect(fireballSprite.groups.length).toBeGreaterThan(0)
|
||||||
|
expect(fireballSprite.groups[0]!.frames.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
const ampOverlay = await loader.loadOverlaySprite('cursampdamage')
|
||||||
|
expect(ampOverlay.groups.length).toBeGreaterThan(0)
|
||||||
|
|
||||||
|
const skeletonPet = await loader.loadMonsterOrPetSheet('sk', 'nu', '1hs')
|
||||||
|
expect(skeletonPet.directions).toBeGreaterThanOrEqual(8)
|
||||||
|
expect(skeletonPet.skipped).toBe(0)
|
||||||
|
|
||||||
|
const registry = await getSharedDataRegistry()
|
||||||
|
const arena = new WorldArena({ registry, classCode: 'ass', skillId: 280, slvl: 20 })
|
||||||
|
arena.triggerCast()
|
||||||
|
const dbg = arena.getDebugState()
|
||||||
|
expect(dbg.charToken).toBe('ai') // Assassin uses 'ai' token per PlrType.txt
|
||||||
|
expect(dbg.castCount).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
/**
|
||||||
|
* Shared 1.13c Skill Verification Test Harness (`tests/helpers/skill-test-harness.ts`).
|
||||||
|
*
|
||||||
|
* Used by both `tests/engine/r1-skill-engine.test.ts` and per-skill isolated worktree
|
||||||
|
* suites (`tests/skills/<class>/skill-<id3>-<slug>.test.ts`).
|
||||||
|
*/
|
||||||
|
import { getSharedDataRegistry, getSharedMountedArchives, type D2DataRegistry } from '../../src/game/engine/data-registry.ts'
|
||||||
|
import { WorldArena, type ArenaDebugState } from '../../src/game/engine/world-arena.ts'
|
||||||
|
import { evaluateSkill113c } from '../../src/game/skills/registry.ts'
|
||||||
|
import type { SkillEvalResult, SkillExecOutcome } from '../../src/game/skills/types.ts'
|
||||||
|
import { UnitStatList } from '../../src/game/engine/stat-list.ts'
|
||||||
|
|
||||||
|
export interface SkillCurveSnapshot {
|
||||||
|
readonly skillId: number
|
||||||
|
readonly name: string
|
||||||
|
readonly charClass: string
|
||||||
|
readonly slvl1: SkillEvalResult
|
||||||
|
readonly slvl10: SkillEvalResult
|
||||||
|
readonly slvl20: SkillEvalResult
|
||||||
|
readonly slvl20WithSynergies: SkillEvalResult
|
||||||
|
readonly slvl20WithBonusSkillsOnly: SkillEvalResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate a skill at `slvl` 1, 10, and 20 (with and without `blvl` synergies, and verifying
|
||||||
|
* that item `+skills` / charges do NOT boost synergies per 1.13c Marrowwalk fix).
|
||||||
|
*/
|
||||||
|
export async function evaluateSkillCurve113c(
|
||||||
|
skillId: number,
|
||||||
|
synergySkillIds: readonly number[] = [],
|
||||||
|
): Promise<SkillCurveSnapshot> {
|
||||||
|
const registry = await getSharedDataRegistry()
|
||||||
|
const skill = registry.getSkillById(skillId)
|
||||||
|
if (!skill) {
|
||||||
|
throw new Error(`Skill ID ${String(skillId)} not found in 1.13c Skills.txt`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const evalAt = (slvl: number, blvl: number, synBlvl: number, synBonusOnly: number): SkillEvalResult => {
|
||||||
|
const statList = new UnitStatList(registry)
|
||||||
|
statList.setBaseSkillLevel(skillId, blvl)
|
||||||
|
if (slvl > blvl) {
|
||||||
|
statList.setBonusSkillLevel(skillId, slvl - blvl)
|
||||||
|
}
|
||||||
|
for (const synId of synergySkillIds) {
|
||||||
|
statList.setBaseSkillLevel(synId, synBlvl)
|
||||||
|
if (synBonusOnly > 0) {
|
||||||
|
statList.setBonusSkillLevel(synId, synBonusOnly)
|
||||||
|
statList.setChargedSkillLevel(synId, 33) // e.g. Level 33 Marrowwalk charge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return evaluateSkill113c({
|
||||||
|
registry,
|
||||||
|
skill,
|
||||||
|
slvl,
|
||||||
|
blvl,
|
||||||
|
statList,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
skillId,
|
||||||
|
name: skill.name,
|
||||||
|
charClass: skill.charClass || 'uni',
|
||||||
|
slvl1: evalAt(1, 1, 0, 0),
|
||||||
|
slvl10: evalAt(10, 10, 0, 0),
|
||||||
|
slvl20: evalAt(20, 20, 0, 0),
|
||||||
|
slvl20WithSynergies: evalAt(20, 20, 20, 0),
|
||||||
|
slvl20WithBonusSkillsOnly: evalAt(20, 20, 0, 20),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a deterministic `WorldArena`, cast `skillId`, and return both the execution outcome
|
||||||
|
* and the `ArenaDebugState`.
|
||||||
|
*/
|
||||||
|
export async function createAndCastSkillInArena(params: {
|
||||||
|
skillId: number
|
||||||
|
classCode?: string
|
||||||
|
slvl?: number
|
||||||
|
blvl?: number
|
||||||
|
synergies?: readonly [number, number][]
|
||||||
|
}): Promise<{
|
||||||
|
registry: D2DataRegistry
|
||||||
|
arena: WorldArena
|
||||||
|
outcome: SkillExecOutcome
|
||||||
|
debugState: ArenaDebugState
|
||||||
|
}> {
|
||||||
|
const registry = await getSharedDataRegistry()
|
||||||
|
const arena = new WorldArena({
|
||||||
|
registry,
|
||||||
|
...(params.classCode !== undefined ? { classCode: params.classCode } : {}),
|
||||||
|
skillId: params.skillId,
|
||||||
|
slvl: params.slvl ?? 20,
|
||||||
|
blvl: params.blvl ?? params.slvl ?? 20,
|
||||||
|
...(params.synergies !== undefined ? { synergies: params.synergies } : {}),
|
||||||
|
})
|
||||||
|
const outcome = arena.triggerCast()
|
||||||
|
const debugState = arena.getDebugState()
|
||||||
|
return {
|
||||||
|
registry,
|
||||||
|
arena,
|
||||||
|
outcome,
|
||||||
|
debugState,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { getSharedDataRegistry, getSharedMountedArchives }
|
||||||
Loading…
Reference in New Issue