diablo2-web/scripts/create-gitea-skills-milesto...

546 lines
21 KiB
TypeScript

/**
* 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)
})