546 lines
21 KiB
TypeScript
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)
|
|
})
|