/** * 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 = { 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 { // 1. Parse survey markdown table to verify exact 221 skill coverage const mdText = readFileSync(SURVEY_SKILLS_MD, 'utf8') const mdRows = new Map() 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() 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() 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 #\`, pushed to \`origin/main\`, verified Issue is \`closed\`, and removed worktree \`../wt-skill-${spec.skillId}\` + branch \`feat/skill-${spec.skillId}\`. ` } async function giteaRequest(path: string, init?: RequestInit): Promise { const url = path.startsWith('http') ? path : `${GITEA_BASE_API}${path}` const headers: Record = { Authorization: `token ${GITEA_TOKEN}`, Accept: 'application/json', ...(init?.headers as Record | 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>( '/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> { const map = new Map() for (let page = 1; page <= 10; page++) { const issues = await giteaRequest>( `/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 { 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() // 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 = {} 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) })