566 lines
18 KiB
TypeScript
566 lines
18 KiB
TypeScript
import * as fs from 'fs'
|
|
|
|
function generate() {
|
|
const rawData = JSON.parse(fs.readFileSync('scratch/skills_tree_data.json', 'utf8'))
|
|
const nodes = rawData.nodes
|
|
const paramsMap = rawData.paramsMap
|
|
|
|
const content = `/**
|
|
* Canonical 210-Skill Tree System, Prerequisite DAG, and Synergy Engine for Diablo II (v1.13c).
|
|
*
|
|
* Ground truth reference:
|
|
* - D2Common.dll (0x6fd70000)
|
|
* - data\\global\\excel\\Skills.txt (357 rows, IDs 6..35, 36..65, 66..95, 96..125, 126..155, 221..250, 251..280)
|
|
* - data\\global\\excel\\SkillDesc.txt (221 rows, grid placement and tabs)
|
|
* - docs\\d2kb\\kb_440_Skills.txt.txt (synergy math, DmgSymPerCalc, EDmgSymPerCalc, ELenSymPerCalc)
|
|
* - docs\\d2kb\\kb_371_Formulae_Guide_by_Xeno_and_Kingpin.txt (blvl base points invariant)
|
|
*/
|
|
|
|
import type { CharacterClassCode } from './classes.ts'
|
|
import type { D2Table } from './acts.ts'
|
|
import { cell } from './acts.ts'
|
|
|
|
export type { CharacterClassCode }
|
|
|
|
/** Canonical node representing a single skill in the skill tree. */
|
|
export interface SkillNode {
|
|
readonly id: number
|
|
readonly name: string
|
|
readonly classCode: CharacterClassCode
|
|
readonly tabIndex: number // 0..2
|
|
readonly tabName: string
|
|
readonly row: number // 1..6
|
|
readonly col: number // 1..3
|
|
readonly reqlevel: number
|
|
readonly reqskills: readonly number[]
|
|
readonly dependents: readonly number[]
|
|
readonly synergyFormulas: {
|
|
readonly physical?: string | undefined
|
|
readonly elemental?: string | undefined
|
|
readonly duration?: string | undefined
|
|
}
|
|
readonly params?: {
|
|
readonly par8?: number | undefined
|
|
readonly par7?: number | undefined
|
|
readonly par6?: number | undefined
|
|
readonly par5?: number | undefined
|
|
} | undefined
|
|
}
|
|
|
|
/** State tracking invested hard points and unspent skill points for a character. */
|
|
export interface SkillTreeState {
|
|
readonly hardPoints: Record<number, number> // skillId -> 0..20 hard points (blvl)
|
|
unspentPoints: number
|
|
}
|
|
|
|
/** Skill tree catalog: array, record, or map of skill nodes. */
|
|
export type SkillTreeCatalog =
|
|
| readonly SkillNode[]
|
|
| Record<number, SkillNode>
|
|
| Map<number, SkillNode>
|
|
| ReadonlyMap<number, SkillNode>
|
|
|
|
/** Canonical Skill Tabs per class (3 tabs each). */
|
|
export const CANONICAL_SKILL_TABS: Readonly<Record<CharacterClassCode, readonly string[]>> = Object.freeze({
|
|
ama: ['Bow and Crossbow Skills', 'Passive and Magic Skills', 'Javelin and Spear Skills'],
|
|
sor: ['Fire Spells', 'Lightning Spells', 'Cold Spells'],
|
|
nec: ['Curses', 'Poison and Bone Spells', 'Summoning Spells'],
|
|
pal: ['Combat Skills', 'Offensive Auras', 'Defensive Auras'],
|
|
bar: ['Combat Skills', 'Combat Masteries', 'Warcries'],
|
|
dru: ['Summoning', 'Shape Shifting', 'Elemental'],
|
|
ass: ['Traps', 'Shadow Disciplines', 'Martial Arts'],
|
|
})
|
|
|
|
/** Canonical synergy parameters (par8, par7, par6, par5) from 1.13c Skills.txt. */
|
|
export const CANONICAL_SKILL_PARAMS: Readonly<Record<number, {
|
|
readonly par8?: number
|
|
readonly par7?: number
|
|
readonly par6?: number
|
|
readonly par5?: number
|
|
}>> = Object.freeze(${JSON.stringify(paramsMap, null, 2)})
|
|
|
|
/** Canonical 210 skills tree catalog embedded for hermetic zero-IO execution. */
|
|
export const CANONICAL_SKILL_TREE: readonly SkillNode[] = Object.freeze([
|
|
${nodes
|
|
.map(
|
|
(n: any) =>
|
|
` Object.freeze({\n id: ${n.id},\n name: ${JSON.stringify(n.name)},\n classCode: ${JSON.stringify(
|
|
n.classCode
|
|
)} as CharacterClassCode,\n tabIndex: ${n.tabIndex},\n tabName: ${JSON.stringify(
|
|
n.tabName
|
|
)},\n row: ${n.row},\n col: ${n.col},\n reqlevel: ${n.reqlevel},\n reqskills: Object.freeze(${JSON.stringify(
|
|
n.reqskills
|
|
)}),\n dependents: Object.freeze(${JSON.stringify(
|
|
n.dependents
|
|
)}),\n synergyFormulas: Object.freeze({\n physical: ${
|
|
n.synergyFormulas.physical ? JSON.stringify(n.synergyFormulas.physical) : 'undefined'
|
|
},\n elemental: ${
|
|
n.synergyFormulas.elemental ? JSON.stringify(n.synergyFormulas.elemental) : 'undefined'
|
|
},\n duration: ${
|
|
n.synergyFormulas.duration ? JSON.stringify(n.synergyFormulas.duration) : 'undefined'
|
|
},\n }),\n params: ${
|
|
n.params ? `Object.freeze(${JSON.stringify(n.params)})` : 'undefined'
|
|
},\n })`
|
|
)
|
|
.join(',\n')}
|
|
])
|
|
|
|
/** Canonical lookup map: skillId -> SkillNode */
|
|
export const CANONICAL_SKILL_MAP: ReadonlyMap<number, SkillNode> = new Map(
|
|
CANONICAL_SKILL_TREE.map(node => [node.id, node])
|
|
)
|
|
|
|
/**
|
|
* Normalizes any SkillTreeCatalog representation into a standard Map<number, SkillNode>.
|
|
*/
|
|
export function asCatalogMap(tree: SkillTreeCatalog): Map<number, SkillNode> {
|
|
if (tree instanceof Map) {
|
|
return tree as Map<number, SkillNode>
|
|
}
|
|
const map = new Map<number, SkillNode>()
|
|
if (Array.isArray(tree)) {
|
|
for (const node of tree) {
|
|
map.set(node.id, node)
|
|
}
|
|
} else {
|
|
for (const key of Object.keys(tree)) {
|
|
const id = Number(key)
|
|
if (!Number.isNaN(id)) {
|
|
map.set(id, (tree as Record<number, SkillNode>)[id])
|
|
}
|
|
}
|
|
}
|
|
return map
|
|
}
|
|
|
|
/**
|
|
* Retrieves a skill node by its numeric ID.
|
|
*/
|
|
export function getSkillNode(tree: SkillTreeCatalog, skillId: number): SkillNode | undefined {
|
|
if (tree instanceof Map) return tree.get(skillId)
|
|
if (Array.isArray(tree)) return tree.find(n => n.id === skillId)
|
|
return (tree as Record<number, SkillNode>)[skillId]
|
|
}
|
|
|
|
/**
|
|
* Returns all skills belonging to a given character class.
|
|
*/
|
|
export function getSkillsForClass(
|
|
tree: SkillTreeCatalog,
|
|
classCode: CharacterClassCode
|
|
): readonly SkillNode[] {
|
|
const map = asCatalogMap(tree)
|
|
const result: SkillNode[] = []
|
|
for (const node of map.values()) {
|
|
if (node.classCode === classCode) {
|
|
result.push(node)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Returns all skills belonging to a specific class tab (0..2).
|
|
*/
|
|
export function getSkillsForTab(
|
|
tree: SkillTreeCatalog,
|
|
classCode: CharacterClassCode,
|
|
tabIndex: number
|
|
): readonly SkillNode[] {
|
|
const map = asCatalogMap(tree)
|
|
const result: SkillNode[] = []
|
|
for (const node of map.values()) {
|
|
if (node.classCode === classCode && node.tabIndex === tabIndex) {
|
|
result.push(node)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Creates a clean skill tree state with the specified number of unspent points.
|
|
*/
|
|
export function createSkillTreeState(unspentPoints: number = 0): SkillTreeState {
|
|
return {
|
|
hardPoints: {},
|
|
unspentPoints: Math.max(0, unspentPoints),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks whether a character can allocate a hard point into a skill.
|
|
*
|
|
* Rules:
|
|
* 1. Must have at least 1 unspent skill point.
|
|
* 2. Player level must be >= skill.reqlevel.
|
|
* 3. Hard points invested cannot exceed 20.
|
|
* 4. All direct prerequisites (reqskills) must have at least 1 allocated hard point.
|
|
*/
|
|
export function canAllocate(
|
|
tree: SkillTreeCatalog,
|
|
state: SkillTreeState,
|
|
skillId: number,
|
|
playerLevel: number
|
|
): boolean {
|
|
if (state.unspentPoints <= 0) return false
|
|
const map = asCatalogMap(tree)
|
|
const node = map.get(skillId)
|
|
if (!node) return false
|
|
if (playerLevel < node.reqlevel) return false
|
|
const currentPoints = state.hardPoints[skillId] ?? 0
|
|
if (currentPoints >= 20) return false
|
|
for (const prereqId of node.reqskills) {
|
|
if ((state.hardPoints[prereqId] ?? 0) < 1) return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Allocates 1 hard point into the target skill if eligible, deducting from unspent points.
|
|
* Returns true if successful, false otherwise.
|
|
*/
|
|
export function allocatePoint(
|
|
tree: SkillTreeCatalog,
|
|
state: SkillTreeState,
|
|
skillId: number,
|
|
playerLevel: number
|
|
): boolean {
|
|
if (!canAllocate(tree, state, skillId, playerLevel)) return false
|
|
state.hardPoints[skillId] = (state.hardPoints[skillId] ?? 0) + 1
|
|
state.unspentPoints -= 1
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Checks whether a skill point can be safely deallocated.
|
|
*
|
|
* Rules:
|
|
* 1. Skill must currently have at least 1 hard point.
|
|
* 2. If dropping from > 1 points, deallocation is always valid.
|
|
* 3. If dropping from 1 to 0 points, no skill that depends on this skill
|
|
* (in its transitive prerequisite chain) can have any allocated hard points.
|
|
*/
|
|
export function canDeallocate(
|
|
tree: SkillTreeCatalog,
|
|
state: SkillTreeState,
|
|
skillId: number
|
|
): boolean {
|
|
const currentPoints = state.hardPoints[skillId] ?? 0
|
|
if (currentPoints <= 0) return false
|
|
if (currentPoints > 1) return true
|
|
|
|
// Dropping from 1 to 0: verify no transitive dependent has invested points
|
|
const map = asCatalogMap(tree)
|
|
const node = map.get(skillId)
|
|
if (!node) return true
|
|
|
|
const visited = new Set<number>()
|
|
const queue = [...node.dependents]
|
|
while (queue.length > 0) {
|
|
const depId = queue.shift()!
|
|
if (visited.has(depId)) continue
|
|
visited.add(depId)
|
|
if ((state.hardPoints[depId] ?? 0) > 0) return false
|
|
const depNode = map.get(depId)
|
|
if (depNode) {
|
|
queue.push(...depNode.dependents)
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Deallocates 1 point from the target skill if valid, returning it to unspent points.
|
|
* Returns true if successful, false otherwise.
|
|
*/
|
|
export function deallocatePoint(
|
|
tree: SkillTreeCatalog,
|
|
state: SkillTreeState,
|
|
skillId: number
|
|
): boolean {
|
|
if (!canDeallocate(tree, state, skillId)) return false
|
|
state.hardPoints[skillId] = (state.hardPoints[skillId] ?? 0) - 1
|
|
state.unspentPoints += 1
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* Resets all allocated skill points to 0 and refunds them to unspent points.
|
|
* Returns the total number of refunded skill points.
|
|
*/
|
|
export function respec(state: SkillTreeState): number {
|
|
let refunded = 0
|
|
for (const skillIdStr of Object.keys(state.hardPoints)) {
|
|
const id = Number(skillIdStr)
|
|
const pts = state.hardPoints[id] ?? 0
|
|
refunded += pts
|
|
state.hardPoints[id] = 0
|
|
}
|
|
state.unspentPoints += refunded
|
|
return refunded
|
|
}
|
|
|
|
/**
|
|
* Evaluates 1.13c synergy formulas strictly against base allocated hard points (blvl).
|
|
*
|
|
* Invariants:
|
|
* 1. Only physically allocated base points (blvl, 0..20) are counted. Soft points (+skills)
|
|
* from equipment, charms, or buffs MUST NOT alter synergy calculations.
|
|
* 2. Formulas support expressions such as:
|
|
* (skill('Fire Ball'.blvl)+skill('Meteor'.blvl))*par8
|
|
* 3. Tolerates unclosed parentheses (e.g. Fire Wall '(skill(\\'Warmth\\'.blvl)*par8+skill(\\'Inferno\\'.blvl)*par7').
|
|
* 4. Gracefully falls back to 1.0 (no bonus) on empty or malformed syntax without throwing exceptions.
|
|
*/
|
|
export function calculateSynergyMultiplier(
|
|
tree: SkillTreeCatalog,
|
|
state: SkillTreeState,
|
|
skillId: number,
|
|
damageType: 'physical' | 'elemental' | 'duration' = 'elemental'
|
|
): number {
|
|
const map = asCatalogMap(tree)
|
|
const node = map.get(skillId)
|
|
if (!node) return 1.0
|
|
|
|
const formula = damageType === 'physical'
|
|
? node.synergyFormulas.physical
|
|
: damageType === 'duration'
|
|
? node.synergyFormulas.duration
|
|
: node.synergyFormulas.elemental
|
|
|
|
if (!formula || formula.trim() === '' || formula.trim() === '-') return 1.0
|
|
|
|
// Build name lookup map
|
|
const nameToId = new Map<string, number>()
|
|
for (const [id, s] of map.entries()) {
|
|
nameToId.set(s.name.trim().toLowerCase(), id)
|
|
}
|
|
|
|
try {
|
|
let sanitized = formula
|
|
|
|
// Substitute skill('<Name>'.blvl) with hard points
|
|
const regex = /skill\\(\\s*['"]([^'"]+)['"]\\s*\\.blvl\\s*\\)/gi
|
|
sanitized = sanitized.replace(regex, (_match, skillName: string) => {
|
|
const targetId = nameToId.get(skillName.trim().toLowerCase())
|
|
if (targetId === undefined) return '0'
|
|
const basePoints = Math.max(0, Math.min(20, state.hardPoints[targetId] ?? 0))
|
|
return String(basePoints)
|
|
})
|
|
|
|
// Resolve parameter multipliers
|
|
const paramOverrides = (node as any).params
|
|
const canonParams = CANONICAL_SKILL_PARAMS[node.id]
|
|
|
|
const p8 = (node as any).param8 ?? paramOverrides?.par8 ?? canonParams?.par8 ?? 16
|
|
const p7 = (node as any).param7 ?? paramOverrides?.par7 ?? canonParams?.par7 ?? 8
|
|
const p6 = (node as any).param6 ?? paramOverrides?.par6 ?? canonParams?.par6 ?? 5
|
|
const p5 = (node as any).param5 ?? paramOverrides?.par5 ?? canonParams?.par5 ?? 0
|
|
|
|
sanitized = sanitized.replace(/\\bpar8\\b/gi, String(p8))
|
|
sanitized = sanitized.replace(/\\bpar7\\b/gi, String(p7))
|
|
sanitized = sanitized.replace(/\\bpar6\\b/gi, String(p6))
|
|
sanitized = sanitized.replace(/\\bpar5\\b/gi, String(p5))
|
|
|
|
// Resilience: balance unclosed parentheses
|
|
const openCount = (sanitized.match(/\\(/g) || []).length
|
|
const closeCount = (sanitized.match(/\\)/g) || []).length
|
|
if (openCount > closeCount) {
|
|
sanitized += ')'.repeat(openCount - closeCount)
|
|
} else if (closeCount > openCount) {
|
|
sanitized = '('.repeat(closeCount - openCount) + sanitized
|
|
}
|
|
|
|
// Strict validation: arithmetic characters only (- placed at start to avoid range)
|
|
if (!/^[-+0-9*/().\\s]+$/.test(sanitized)) {
|
|
return 1.0
|
|
}
|
|
|
|
// eslint-disable-next-line no-new-func
|
|
const bonusPct = Number(new Function(\`return (\${sanitized})\`)())
|
|
if (typeof bonusPct !== 'number' || Number.isNaN(bonusPct) || bonusPct <= 0) {
|
|
return 1.0
|
|
}
|
|
|
|
return 1.0 + bonusPct / 100.0
|
|
} catch {
|
|
return 1.0
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validates prerequisite DAG integrity:
|
|
* - 0 cycles (strictly acyclic)
|
|
* - 0 level inversions (parent reqlevel <= child reqlevel)
|
|
* - 0 unresolved prerequisite dependencies
|
|
*/
|
|
export function validatePrerequisiteDAG(tree: SkillTreeCatalog): {
|
|
valid: boolean
|
|
cycles: number[]
|
|
inversions: Array<{ parent: number; child: number; parentLevel: number; childLevel: number }>
|
|
unresolved: Array<{ skillId: number; missingReq: number }>
|
|
} {
|
|
const map = asCatalogMap(tree)
|
|
const nodes = Array.from(map.values())
|
|
|
|
const inDegree = new Map<number, number>()
|
|
const adj = new Map<number, number[]>()
|
|
const unresolved: Array<{ skillId: number; missingReq: number }> = []
|
|
const inversions: Array<{ parent: number; child: number; parentLevel: number; childLevel: number }> = []
|
|
|
|
for (const node of nodes) {
|
|
inDegree.set(node.id, node.reqskills.length)
|
|
if (!adj.has(node.id)) adj.set(node.id, [])
|
|
}
|
|
|
|
for (const node of nodes) {
|
|
for (const reqId of node.reqskills) {
|
|
const parent = map.get(reqId)
|
|
if (!parent) {
|
|
unresolved.push({ skillId: node.id, missingReq: reqId })
|
|
} else {
|
|
if (parent.reqlevel > node.reqlevel) {
|
|
inversions.push({
|
|
parent: parent.id,
|
|
child: node.id,
|
|
parentLevel: parent.reqlevel,
|
|
childLevel: node.reqlevel,
|
|
})
|
|
}
|
|
if (!adj.has(reqId)) adj.set(reqId, [])
|
|
adj.get(reqId)!.push(node.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Kahn's algorithm for topological sorting
|
|
const queue: number[] = []
|
|
for (const [id, deg] of inDegree.entries()) {
|
|
if (deg === 0) queue.push(id)
|
|
}
|
|
|
|
let processedCount = 0
|
|
while (queue.length > 0) {
|
|
const u = queue.shift()!
|
|
processedCount++
|
|
for (const v of adj.get(u) || []) {
|
|
const cur = inDegree.get(v)! - 1
|
|
inDegree.set(v, cur)
|
|
if (cur === 0) queue.push(v)
|
|
}
|
|
}
|
|
|
|
const cycleNodeIds: number[] = []
|
|
if (processedCount < nodes.length) {
|
|
for (const [id, deg] of inDegree.entries()) {
|
|
if (deg > 0) cycleNodeIds.push(id)
|
|
}
|
|
}
|
|
|
|
const valid = cycleNodeIds.length === 0 && inversions.length === 0 && unresolved.length === 0
|
|
|
|
return {
|
|
valid,
|
|
cycles: cycleNodeIds,
|
|
inversions,
|
|
unresolved,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parses skill tree nodes dynamically from 1.13c Skills.txt and SkillDesc.txt tables.
|
|
*/
|
|
export function parseSkillTreeTable(skillsTable: D2Table, skilldescTable: D2Table): SkillNode[] {
|
|
const descMap = new Map<string, { page: number; row: number; col: number }>()
|
|
for (const r of skilldescTable.rows) {
|
|
const key = cell(skilldescTable, r, 'skilldesc')
|
|
if (key) {
|
|
descMap.set(key, {
|
|
page: Number(cell(skilldescTable, r, 'SkillPage')),
|
|
row: Number(cell(skilldescTable, r, 'SkillRow')),
|
|
col: Number(cell(skilldescTable, r, 'SkillColumn')),
|
|
})
|
|
}
|
|
}
|
|
|
|
const nameToId = new Map<string, number>()
|
|
for (const r of skillsTable.rows) {
|
|
const id = Number(cell(skillsTable, r, 'Id'))
|
|
const name = cell(skillsTable, r, 'skill')
|
|
nameToId.set(name.toLowerCase(), id)
|
|
}
|
|
|
|
const classCodes: CharacterClassCode[] = ['ama', 'sor', 'nec', 'pal', 'bar', 'dru', 'ass']
|
|
const nodes: SkillNode[] = []
|
|
|
|
for (const r of skillsTable.rows) {
|
|
const charclass = cell(skillsTable, r, 'charclass') as CharacterClassCode
|
|
if (!classCodes.includes(charclass)) continue
|
|
|
|
const id = Number(cell(skillsTable, r, 'Id'))
|
|
const name = cell(skillsTable, r, 'skill')
|
|
const descKey = cell(skillsTable, r, 'skilldesc')
|
|
const desc = descMap.get(descKey) ?? { page: 1, row: 1, col: 1 }
|
|
const tabIndex = desc.page - 1
|
|
const tabName = CANONICAL_SKILL_TABS[charclass][tabIndex] ?? ''
|
|
const reqlevel = Number(cell(skillsTable, r, 'reqlevel') || 1)
|
|
|
|
const reqskills: number[] = []
|
|
for (const reqCol of ['reqskill1', 'reqskill2', 'reqskill3']) {
|
|
const reqName = cell(skillsTable, r, reqCol)
|
|
if (reqName) {
|
|
const reqId = nameToId.get(reqName.toLowerCase())
|
|
if (reqId !== undefined && reqId > 0) {
|
|
reqskills.push(reqId)
|
|
}
|
|
}
|
|
}
|
|
|
|
const physical = cell(skillsTable, r, 'DmgSymPerCalc') || undefined
|
|
const elemental = cell(skillsTable, r, 'EDmgSymPerCalc') || undefined
|
|
const duration = cell(skillsTable, r, 'ELenSymPerCalc') || undefined
|
|
|
|
const par8 = cell(skillsTable, r, 'Param8') ? Number(cell(skillsTable, r, 'Param8')) : undefined
|
|
const par7 = cell(skillsTable, r, 'Param7') ? Number(cell(skillsTable, r, 'Param7')) : undefined
|
|
const par6 = cell(skillsTable, r, 'Param6') ? Number(cell(skillsTable, r, 'Param6')) : undefined
|
|
const par5 = cell(skillsTable, r, 'Param5') ? Number(cell(skillsTable, r, 'Param5')) : undefined
|
|
|
|
nodes.push({
|
|
id,
|
|
name,
|
|
classCode: charclass,
|
|
tabIndex,
|
|
tabName,
|
|
row: desc.row,
|
|
col: desc.col,
|
|
reqlevel,
|
|
reqskills: Object.freeze(reqskills),
|
|
dependents: [],
|
|
synergyFormulas: Object.freeze({ physical, elemental, duration }),
|
|
params: (par8 !== undefined || par7 !== undefined || par6 !== undefined || par5 !== undefined)
|
|
? Object.freeze({ par8, par7, par6, par5 })
|
|
: undefined,
|
|
})
|
|
}
|
|
|
|
// Populate dependents
|
|
const nodeMap = new Map<number, SkillNode>(nodes.map(n => [n.id, n]))
|
|
for (const node of nodes) {
|
|
for (const reqId of node.reqskills) {
|
|
const parent = nodeMap.get(reqId)
|
|
if (parent) {
|
|
;(parent.dependents as number[]).push(node.id)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nodes
|
|
}
|
|
`
|
|
|
|
fs.writeFileSync('src/game/skill-tree.ts', content, 'utf8')
|
|
console.log('Successfully wrote src/game/skill-tree.ts')
|
|
}
|
|
|
|
generate()
|