+ This tool is driven by schema.json, published alongside the documentation.
+ If you are running locally, generate it first with{' '}
+ .scripts/generate-docs.ps1.
+
+ )
+}
diff --git a/app/src/components/ImportPanel.tsx b/app/src/components/ImportPanel.tsx
new file mode 100644
index 0000000..4f32d1f
--- /dev/null
+++ b/app/src/components/ImportPanel.tsx
@@ -0,0 +1,107 @@
+import { useState } from 'react'
+
+import { parseConfig, type ParseProblem } from '../parse'
+import type { Indices } from '../schema'
+import type { Doc } from '../state'
+
+/**
+ * Paste an existing config to load it into the editor and have it checked.
+ */
+
+interface Props {
+ indices: Indices
+ onLoad: (doc: Doc) => void
+ onClose: () => void
+}
+
+export function ImportPanel({ indices, onLoad, onClose }: Props) {
+ const [text, setText] = useState('')
+ const [problems, setProblems] = useState(null)
+ const [count, setCount] = useState(0)
+ const [pending, setPending] = useState(null)
+
+ const check = () => {
+ const result = parseConfig(text, indices)
+ setProblems(result.problems)
+ setCount(result.entryCount)
+ setPending(result.doc)
+ }
+
+ const errors = problems?.filter((p) => p.severity === 'error') ?? []
+ const warnings = problems?.filter((p) => p.severity === 'warning') ?? []
+
+ return (
+
+
+
Check an existing config
+
+
+
+
+ Paste `+AbilityEdits` entries below. Anything the mod would silently ignore is listed
+ here, and loading replaces what is currently in the editor.
+
+ {value} is abstract, so no ability has one. Entries are matched by exact
+ class, so this will never match — name one of the concrete classes that derive from it.
+
+ )}
+
+ {value !== '' && viaCatchAll && (
+
+ No dedicated editor — only the {resolved?.fields.length ?? 0} shared {family} fields
+ apply.
+
+ )}
+
+ )
+}
diff --git a/app/src/emit.test.ts b/app/src/emit.test.ts
new file mode 100644
index 0000000..ae98f56
--- /dev/null
+++ b/app/src/emit.test.ts
@@ -0,0 +1,123 @@
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+
+import { describe, expect, it } from 'vitest'
+
+import { emitEntry } from './emit'
+import { buildIndices } from './schema'
+import type { Schema } from './types'
+import { newNode } from './state'
+
+const schema = JSON.parse(
+ readFileSync(resolve(import.meta.dirname, '..', '..', 'docs', 'schema.json'), 'utf-8'),
+) as Schema
+const indices = buildIndices(schema)
+
+describe('emitEntry', () => {
+ it('writes the guard immediately before its value', () => {
+ const entry = newNode({
+ Ability: 'SwordSlice',
+ CostMode: 'eACEM_Merge',
+ Costs: [newNode({ Class: 'X2AbilityCost_ActionPoints', Mode: 'eACEM_Merge', NumPoints: 1 })],
+ })
+
+ expect(emitEntry(entry, indices)).toBe(
+ [
+ '+AbilityEdits=( \\\\',
+ ' Ability=SwordSlice, \\\\',
+ ' CostMode=eACEM_Merge, \\\\',
+ ' Costs=( \\\\',
+ ' ( \\\\',
+ ' Class="X2AbilityCost_ActionPoints", \\\\',
+ ' Mode=eACEM_Merge, \\\\',
+ ' SetNumPoints=true, \\\\',
+ ' NumPoints=1 \\\\',
+ ' ) \\\\',
+ ' ) \\\\',
+ ')',
+ ].join('\n'),
+ )
+ })
+
+ it('separates array elements with a comma after the closing paren', () => {
+ const entry = newNode({
+ Ability: 'SwordSlice',
+ Costs: [
+ newNode({ Class: 'X2AbilityCost_ActionPoints', NumPoints: 1 }),
+ newNode({ Class: 'X2AbilityCost_Charges', NumCharges: 2 }),
+ ],
+ })
+
+ const out = emitEntry(entry, indices)
+ expect(out).toContain(' ), \\\\')
+ expect(out).toContain(' ) \\\\\n ) \\\\')
+ })
+
+ it('quotes strings, leaves names bare', () => {
+ const entry = newNode({
+ Ability: 'CombatProtocol',
+ Effects: [newNode({ Class: 'X2Effect_ApplyWeaponDamage', Slot: 'eAES_Target' })],
+ })
+
+ const out = emitEntry(entry, indices)
+ expect(out).toContain('Class="X2Effect_ApplyWeaponDamage"')
+ expect(out).toContain('Ability=CombatProtocol')
+ expect(out).toContain('Slot=eAES_Target')
+ })
+
+ it('forces a decimal point on floats', () => {
+ const entry = newNode({
+ Ability: 'SomeConeAbility',
+ MultiTargetStyle: newNode({ ConeEndDiameter: 20, ConeLength: 28.5 }),
+ })
+
+ const out = emitEntry(entry, indices)
+ expect(out).toContain('ConeEndDiameter=20.0')
+ expect(out).toContain('ConeLength=28.5')
+ })
+
+ it('renders name arrays as a bare parenthesised list', () => {
+ const entry = newNode({
+ Ability: 'SwordSlice',
+ AdditionalAbilities: ['Blademaster', 'Bladestorm'],
+ AdditionalAbilitiesMode: 'eNAEM_Merge',
+ })
+
+ const out = emitEntry(entry, indices)
+ expect(out).toContain('AdditionalAbilities=(Blademaster, Bladestorm)')
+ expect(out).toContain('AdditionalAbilitiesMode=eNAEM_Merge')
+ })
+
+ it('nests a single struct without wrapping it in array parens', () => {
+ const entry = newNode({
+ Ability: 'AidProtocol',
+ Cooldown: newNode({ Class: 'XComGame.X2AbilityCooldown_PerPlayerType', NumTurns: 9 }),
+ })
+
+ const out = emitEntry(entry, indices)
+ expect(out).toContain(' Cooldown=( \\\\')
+ expect(out).toContain(' Class="XComGame.X2AbilityCooldown_PerPlayerType", \\\\')
+ expect(out).not.toContain(' ( \\\\')
+ })
+
+ it('emits keys in schema order, not insertion order', () => {
+ const scrambled = newNode({ Costs: [newNode({ Class: 'X2AbilityCost_Ammo' })], Ability: 'X' })
+ const out = emitEntry(scrambled, indices)
+ expect(out.indexOf('Ability=X')).toBeLessThan(out.indexOf('Costs=('))
+ })
+
+ it('omits empty arrays and empty nested structs entirely', () => {
+ const entry = newNode({ Ability: 'X', Costs: [], Cooldown: newNode({}) })
+ expect(emitEntry(entry, indices)).toBe('+AbilityEdits=( \\\\\n Ability=X \\\\\n)')
+ })
+
+ it('never emits internal bookkeeping keys', () => {
+ expect(emitEntry(newNode({ Ability: 'X' }), indices)).not.toContain('_id')
+ })
+
+ it('ends every line but the last with the continuation marker', () => {
+ const lines = emitEntry(newNode({ Ability: 'X' }), indices).split('\n')
+ for (const line of lines.slice(0, -1)) expect(line.endsWith(' \\\\')).toBe(true)
+ expect(lines.at(-1)).toBe(')')
+ })
+})
diff --git a/app/src/emit.ts b/app/src/emit.ts
new file mode 100644
index 0000000..a3e63bc
--- /dev/null
+++ b/app/src/emit.ts
@@ -0,0 +1,128 @@
+import type { Indices, StructField } from './schema'
+import { configKeys, type Doc, type Node, type Value } from './state'
+
+/**
+ * Renders a document as `+AbilityEdits` entries.
+ */
+
+const INDENT = ' '
+const CONT = ' \\\\'
+const ROOT_STRUCT = 'AbilityEdit'
+const ROOT_KEY = 'AbilityEdits'
+export const SECTION = '[AbilityEditor.X2DLCInfo_AbilityEditor]'
+
+export function isBareName(value: string): boolean {
+ return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value)
+}
+
+export function hasUnrepresentableChars(value: string): boolean {
+ return /["(),\r\n]/.test(value)
+}
+
+function formatScalar(value: Value, type: string): string {
+ if (typeof value === 'boolean') {
+ return value ? 'true' : 'false'
+ }
+
+ if (typeof value === 'number') {
+ if (type === 'float') {
+ return Number.isInteger(value) ? value.toFixed(1) : String(value)
+ }
+ return String(value)
+ }
+
+ const text = String(value)
+ return type === 'string' ? `"${text}"` : text
+}
+
+function emittableFields(indices: Indices, structName: string): StructField[] {
+ return indices.structByName.get(structName)?.fields.filter((f) => f.kind !== 'guard') ?? []
+}
+
+export function nestedStructName(type: string): string | null {
+ const match = /^array<(\w+)>$/.exec(type)
+ if (match?.[1]) return /Edit$/.test(match[1]) ? match[1] : null
+ return /Edit$/.test(type) ? type : null
+}
+
+function emitGroups(node: Node, structName: string, indices: Indices, depth: number): string[][] {
+ const pad = INDENT.repeat(depth)
+ const groups: string[][] = []
+ const present = new Set(configKeys(node))
+
+ for (const field of emittableFields(indices, structName)) {
+ if (!present.has(field.name)) continue
+ const value = node[field.name]
+ if (value === undefined) continue
+
+ const childStruct = nestedStructName(field.type)
+ const isArrayType = field.type.startsWith('array<')
+
+ if (childStruct && isArrayType && Array.isArray(value)) {
+ const children = value as Node[]
+ if (children.length === 0) continue
+
+ const lines: string[] = [`${pad}${field.name}=(`]
+ children.forEach((child, index) => {
+ const body = flatten(emitGroups(child, childStruct, indices, depth + 2))
+ lines.push(`${pad}${INDENT}(`)
+ lines.push(...body)
+ lines.push(`${pad}${INDENT})${index < children.length - 1 ? ',' : ''}`)
+ })
+ lines.push(`${pad})`)
+ groups.push(lines)
+ continue
+ }
+
+ // A single nested struct.
+ if (childStruct && !isArrayType && value && typeof value === 'object' && !Array.isArray(value)) {
+ const body = flatten(emitGroups(value as Node, childStruct, indices, depth + 1))
+ if (body.length === 0) continue
+ groups.push([`${pad}${field.name}=(`, ...body, `${pad})`])
+ continue
+ }
+
+ // A name or string array.
+ if (Array.isArray(value)) {
+ const items = value as string[]
+ if (items.length === 0) continue
+ groups.push([`${pad}${field.name}=(${items.join(', ')})`])
+ continue
+ }
+
+ // A scalar. A guarded one emits `SetX=true` immediately before the value.
+ if (field.guard) groups.push([`${pad}${field.guard}=true`])
+ groups.push([`${pad}${field.name}=${formatScalar(value, field.type)}`])
+ }
+
+ return groups
+}
+
+function flatten(groups: string[][]): string[] {
+ const out: string[] = []
+ groups.forEach((group, groupIndex) => {
+ const isLastGroup = groupIndex === groups.length - 1
+ group.forEach((line, lineIndex) => {
+ const isLastLine = lineIndex === group.length - 1
+ out.push(isLastLine && !isLastGroup ? `${line},` : line)
+ })
+ })
+ return out
+}
+
+export function emitEntry(entry: Node, indices: Indices): string {
+ const body = flatten(emitGroups(entry, ROOT_STRUCT, indices, 1))
+ if (body.length === 0) return `+${ROOT_KEY}=()`
+
+ const lines = [`+${ROOT_KEY}=(`, ...body, ')']
+ return lines.map((line, index) => (index < lines.length - 1 ? line + CONT : line)).join('\n')
+}
+
+export interface EmitOptions {
+ includeSection?: boolean
+}
+
+export function emit(doc: Doc, indices: Indices, options: EmitOptions = {}): string {
+ const body = doc.entries.map((entry) => emitEntry(entry, indices)).join('\n\n')
+ return options.includeSection === false ? body : `${SECTION}\n\n${body}\n`
+}
diff --git a/app/src/main.tsx b/app/src/main.tsx
new file mode 100644
index 0000000..47d1e4f
--- /dev/null
+++ b/app/src/main.tsx
@@ -0,0 +1,14 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+
+import { App } from './App'
+import './styles.css'
+
+const container = document.getElementById('root')
+if (!container) throw new Error('Missing #root')
+
+createRoot(container).render(
+
+
+ ,
+)
diff --git a/app/src/parse.test.ts b/app/src/parse.test.ts
new file mode 100644
index 0000000..1081e0c
--- /dev/null
+++ b/app/src/parse.test.ts
@@ -0,0 +1,203 @@
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+
+import { describe, expect, it } from 'vitest'
+
+import { emit, emitEntry } from './emit'
+import { parseConfig } from './parse'
+import { buildIndices } from './schema'
+import type { Schema } from './types'
+import { newNode, type Node } from './state'
+
+const schema = JSON.parse(
+ readFileSync(resolve(import.meta.dirname, '..', '..', 'docs', 'schema.json'), 'utf-8'),
+) as Schema
+const indices = buildIndices(schema)
+
+function strip(value: unknown): unknown {
+ if (Array.isArray(value)) return value.map(strip)
+ if (value && typeof value === 'object') {
+ return Object.fromEntries(
+ Object.entries(value as Node)
+ .filter(([key]) => !key.startsWith('_'))
+ .map(([key, item]) => [key, strip(item)]),
+ )
+ }
+ return value
+}
+
+describe('parseConfig', () => {
+ it('reads a documented example back into entries', () => {
+ const source = `
+[AbilityEditor.X2DLCInfo_AbilityEditor]
+
++AbilityEdits=( \\\\
+ Ability=SwordSlice, \\\\
+ CostMode=eACEM_Merge, \\\\
+ Costs=( \\\\
+ ( \\\\
+ Class="X2AbilityCost_ActionPoints", \\\\
+ Mode=eACEM_Merge, \\\\
+ SetNumPoints=true, \\\\
+ NumPoints=1, \\\\
+ SetConsumeAllPoints=true, \\\\
+ ConsumeAllPoints=false \\\\
+ ) \\\\
+ ) \\\\
+)
+`
+ const { doc, problems } = parseConfig(source, indices)
+
+ expect(problems.filter((p) => p.severity === 'error')).toEqual([])
+ expect(doc?.entries).toHaveLength(1)
+
+ const entry = doc!.entries[0]!
+ expect(entry['Ability']).toBe('SwordSlice')
+ expect(entry['CostMode']).toBe('eACEM_Merge')
+
+ const costs = entry['Costs'] as Node[]
+ expect(costs).toHaveLength(1)
+ expect(costs[0]!['Class']).toBe('X2AbilityCost_ActionPoints')
+ expect(costs[0]!['NumPoints']).toBe(1)
+ expect(costs[0]!['ConsumeAllPoints']).toBe(false)
+ })
+
+ it('does not carry Set guards into state', () => {
+ const { doc } = parseConfig('+AbilityEdits=(Ability=X, SetHostility=true, Hostility=eHostility_Neutral)', indices)
+ const entry = doc!.entries[0]!
+ expect(entry['Hostility']).toBe('eHostility_Neutral')
+ expect(entry).not.toHaveProperty('SetHostility')
+ })
+
+ it('reads several entries', () => {
+ const source = `
++AbilityEdits=(Ability=A)
++AbilityEdits=(Ability=B)
+`
+ expect(parseConfig(source, indices).doc?.entries.map((e) => e['Ability'])).toEqual(['A', 'B'])
+ })
+
+ it('reads name arrays', () => {
+ const { doc } = parseConfig(
+ '+AbilityEdits=(Ability=X, AdditionalAbilities=(Blademaster, Bladestorm), AdditionalAbilitiesMode=eNAEM_Merge)',
+ indices,
+ )
+ expect(doc!.entries[0]!['AdditionalAbilities']).toEqual(['Blademaster', 'Bladestorm'])
+ })
+
+ it('reads a single nested struct', () => {
+ const { doc } = parseConfig(
+ '+AbilityEdits=(Ability=X, Cooldown=(Class="X2AbilityCooldown", SetNumTurns=true, NumTurns=5))',
+ indices,
+ )
+ const cooldown = doc!.entries[0]!['Cooldown'] as Node
+ expect(cooldown['Class']).toBe('X2AbilityCooldown')
+ expect(cooldown['NumTurns']).toBe(5)
+ })
+
+ it('tolerates a single backslash continuation', () => {
+ const source = '+AbilityEdits=( \\\n Ability=X \\\n)'
+ expect(parseConfig(source, indices).doc?.entries[0]?.['Ability']).toBe('X')
+ })
+
+ it('ignores comments', () => {
+ const source = '; a comment\n+AbilityEdits=(Ability=X) ; trailing\n'
+ const { doc, problems } = parseConfig(source, indices)
+ expect(doc?.entries[0]?.['Ability']).toBe('X')
+ expect(problems.filter((p) => p.severity === 'error')).toEqual([])
+ })
+})
+
+describe('linting an existing config', () => {
+ it('reports a misspelled key, which the game drops without logging', () => {
+ const { problems } = parseConfig('+AbilityEdits=(Ability=X, Hostilty=eHostility_Neutral)', indices)
+ const issue = problems.find((p) => p.title.includes('Hostilty'))
+ expect(issue?.severity).toBe('error')
+ })
+
+ it('reports a value written without its Set guard', () => {
+ const { problems, doc } = parseConfig('+AbilityEdits=(Ability=X, Hostility=eHostility_Neutral)', indices)
+ const issue = problems.find((p) => p.title === 'Hostility was being ignored')
+ expect(issue?.severity).toBe('warning')
+ expect(doc!.entries[0]!['Hostility']).toBe('eHostility_Neutral')
+ })
+
+ it('drops a value whose guard is explicitly false', () => {
+ const { problems, doc } = parseConfig(
+ '+AbilityEdits=(Ability=X, SetHostility=false, Hostility=eHostility_Neutral)',
+ indices,
+ )
+ expect(doc!.entries[0]).not.toHaveProperty('Hostility')
+ expect(problems.some((p) => p.title.includes('SetHostility=false'))).toBe(true)
+ })
+
+ it('reports a guard with no value beside it', () => {
+ const { problems, doc } = parseConfig('+AbilityEdits=(Ability=X, SetTriggerChance=true)', indices)
+ expect(problems.some((p) => p.title.includes('with no TriggerChance'))).toBe(true)
+ expect(doc!.entries[0]!['TriggerChance']).toBe(0)
+ })
+
+ it('flags a bare AbilityEdits= that would wipe every other mod’s entries', () => {
+ const { problems } = parseConfig('AbilityEdits=(Ability=X)', indices)
+ const issue = problems.find((p) => p.title.includes('without a leading +'))
+ expect(issue?.severity).toBe('error')
+ })
+
+ it('says so when there is nothing to read', () => {
+ const { doc, problems } = parseConfig('[SomeSection]\nFoo=Bar', indices)
+ expect(doc).toBeNull()
+ expect(problems[0]?.title).toBe('No +AbilityEdits entries found')
+ })
+})
+
+describe('round trip', () => {
+ const cases: Node[] = [
+ newNode({ Ability: 'SwordSlice' }),
+ newNode({
+ Ability: 'SwordSlice',
+ CostMode: 'eACEM_Merge',
+ Costs: [
+ newNode({ Class: 'X2AbilityCost_ActionPoints', Mode: 'eACEM_Merge', NumPoints: 1, ConsumeAllPoints: false }),
+ newNode({ Class: 'X2AbilityCost_Charges', Mode: 'eACEM_Merge', NumCharges: 2 }),
+ ],
+ }),
+ newNode({
+ Ability: 'CombatProtocol',
+ Effects: [
+ newNode({
+ Class: 'X2Effect_ApplyWeaponDamage',
+ Slot: 'eAES_Target',
+ Mode: 'eAEM_Merge',
+ WeaponDamageValue: newNode({ Damage: 3, Spread: 0 }),
+ }),
+ ],
+ TargetConditions: [
+ newNode({ Class: 'X2Condition_UnitProperty', Mode: 'eAEM_Merge', ExcludeOrganic: true }),
+ ],
+ }),
+ newNode({
+ Ability: 'SomeConeAbility',
+ MultiTargetStyle: newNode({ ConeEndDiameter: 20, ConeLength: 28.5 }),
+ AdditionalAbilities: ['Blademaster'],
+ AdditionalAbilitiesMode: 'eNAEM_Merge',
+ }),
+ ]
+
+ it.each(cases.map((entry, index) => [index, entry] as const))(
+ 'parse(emit(entry)) === entry [case %i]',
+ (_index, entry) => {
+ const text = emitEntry(entry, indices)
+ const { doc, problems } = parseConfig(text, indices)
+
+ expect(problems.filter((p) => p.severity === 'error')).toEqual([])
+ expect(strip(doc!.entries[0]!)).toEqual(strip(entry))
+ },
+ )
+
+ it('is stable over a second pass', () => {
+ const doc = { schemaHash: schema.sourceHash, entries: cases }
+ const once = emit(doc, indices)
+ const reparsed = parseConfig(once, indices).doc!
+ expect(emit(reparsed, indices)).toBe(once)
+ })
+})
diff --git a/app/src/parse.ts b/app/src/parse.ts
new file mode 100644
index 0000000..7501f7b
--- /dev/null
+++ b/app/src/parse.ts
@@ -0,0 +1,401 @@
+import { nestedStructName } from './emit'
+import type { Indices, StructField } from './schema'
+import { newNode, type Doc, type Node, type Value } from './state'
+
+/**
+ * Reads `+AbilityEdits` entries back out of an .ini.
+ */
+
+export interface ParseProblem {
+ severity: 'error' | 'warning'
+ line: number
+ title: string
+ detail: string
+}
+
+export interface ParseResult {
+ doc: Doc | null
+ problems: ParseProblem[]
+ entryCount: number
+}
+
+// --------------------------------------------------------------------------- //
+// Tokenising
+// --------------------------------------------------------------------------- //
+
+interface Raw {
+ pairs: { key: string; value: RawValue; line: number }[]
+}
+
+type RawValue =
+ | { kind: 'scalar'; text: string; quoted: boolean; line: number }
+ | { kind: 'list'; items: RawValue[]; line: number }
+ | { kind: 'block'; raw: Raw; line: number }
+
+function flatten(source: string): { text: string; lineAt: number[] } {
+ const out: string[] = []
+ const lineAt: number[] = []
+ const lines = source.split(/\r?\n/)
+
+ lines.forEach((line, index) => {
+ let text = line.replace(/\s*\\+\s*$/, ' ')
+
+ let inQuote = false
+ for (let i = 0; i < text.length; i += 1) {
+ const ch = text[i]
+ if (ch === '"') inQuote = !inQuote
+ else if (ch === ';' && !inQuote) {
+ text = text.slice(0, i)
+ break
+ }
+ }
+
+ for (const ch of text) {
+ out.push(ch)
+ lineAt.push(index + 1)
+ }
+ out.push(' ')
+ lineAt.push(index + 1)
+ })
+
+ return { text: out.join(''), lineAt }
+}
+
+class Cursor {
+ constructor(
+ readonly text: string,
+ readonly lineAt: number[],
+ public pos = 0,
+ ) {}
+
+ get line(): number {
+ return this.lineAt[Math.min(this.pos, this.lineAt.length - 1)] ?? 1
+ }
+
+ skipSpace(): void {
+ while (this.pos < this.text.length && /\s/.test(this.text[this.pos] ?? '')) this.pos += 1
+ }
+
+ peek(): string {
+ return this.text[this.pos] ?? ''
+ }
+
+ eat(ch: string): boolean {
+ this.skipSpace()
+ if (this.peek() === ch) {
+ this.pos += 1
+ return true
+ }
+ return false
+ }
+}
+
+function readToken(cursor: Cursor): { text: string; quoted: boolean } {
+ cursor.skipSpace()
+
+ if (cursor.peek() === '"') {
+ cursor.pos += 1
+ let text = ''
+ while (cursor.pos < cursor.text.length && cursor.peek() !== '"') {
+ text += cursor.peek()
+ cursor.pos += 1
+ }
+ cursor.pos += 1
+ return { text, quoted: true }
+ }
+
+ let text = ''
+ while (cursor.pos < cursor.text.length && !/[,()=]/.test(cursor.peek())) {
+ text += cursor.peek()
+ cursor.pos += 1
+ }
+ return { text: text.trim(), quoted: false }
+}
+
+function readParenValue(cursor: Cursor): RawValue {
+ const line = cursor.line
+ cursor.skipSpace()
+
+ // Empty.
+ if (cursor.eat(')')) return { kind: 'list', items: [], line }
+
+ // An array of structs: the first thing inside is another paren.
+ if (cursor.peek() === '(') {
+ const items: RawValue[] = []
+ do {
+ cursor.skipSpace()
+ if (cursor.peek() !== '(') break
+ cursor.pos += 1
+ items.push(readParenValue(cursor))
+ } while (cursor.eat(','))
+ cursor.eat(')')
+ return { kind: 'list', items, line }
+ }
+
+ const save = cursor.pos
+ const first = readToken(cursor)
+ cursor.skipSpace()
+ const isBlock = cursor.peek() === '='
+ cursor.pos = save
+
+ if (isBlock) {
+ const raw = readPairs(cursor, ')')
+ cursor.eat(')')
+ return { kind: 'block', raw, line }
+ }
+
+ // A plain value list, e.g. (Blademaster, Bladestorm).
+ const items: RawValue[] = []
+ if (first.text !== '' || cursor.peek() !== ')') {
+ do {
+ const token = readToken(cursor)
+ items.push({ kind: 'scalar', text: token.text, quoted: token.quoted, line: cursor.line })
+ } while (cursor.eat(','))
+ }
+ cursor.eat(')')
+ return { kind: 'list', items, line }
+}
+
+function readValue(cursor: Cursor): RawValue {
+ cursor.skipSpace()
+ if (cursor.eat('(')) return readParenValue(cursor)
+ const line = cursor.line
+ const token = readToken(cursor)
+ return { kind: 'scalar', text: token.text, quoted: token.quoted, line }
+}
+
+function readPairs(cursor: Cursor, stop: string): Raw {
+ const pairs: Raw['pairs'] = []
+
+ for (;;) {
+ cursor.skipSpace()
+ if (cursor.pos >= cursor.text.length || cursor.peek() === stop) break
+
+ const line = cursor.line
+ const key = readToken(cursor)
+ if (key.text === '') {
+ cursor.pos += 1
+ continue
+ }
+ if (!cursor.eat('=')) continue
+
+ pairs.push({ key: key.text, value: readValue(cursor), line })
+ if (!cursor.eat(',')) break
+ }
+
+ return { pairs }
+}
+
+// --------------------------------------------------------------------------- //
+// Mapping onto the schema
+// --------------------------------------------------------------------------- //
+
+function scalarValue(raw: Extract, type: string): Value {
+ const text = raw.text
+ if (type === 'bool') return text.toLowerCase() === 'true'
+ if (type === 'int') {
+ const parsed = Number.parseInt(text, 10)
+ return Number.isNaN(parsed) ? text : parsed
+ }
+ if (type === 'float') {
+ const parsed = Number.parseFloat(text)
+ return Number.isNaN(parsed) ? text : parsed
+ }
+ return text
+}
+
+function zeroFor(type: string): Value {
+ if (type === 'bool') return false
+ if (type === 'int' || type === 'float') return 0
+ return ''
+}
+
+interface Ctx {
+ indices: Indices
+ problems: ParseProblem[]
+}
+
+function fieldsOf(ctx: Ctx, structName: string): Map {
+ const fields = ctx.indices.structByName.get(structName)?.fields ?? []
+ return new Map(fields.map((f) => [f.name.toLowerCase(), f]))
+}
+
+function buildNode(ctx: Ctx, raw: Raw, structName: string, where: string): Node {
+ const node = newNode()
+ const byName = fieldsOf(ctx, structName)
+ const seenGuards = new Map()
+
+ for (const pair of raw.pairs) {
+ const field = byName.get(pair.key.toLowerCase())
+
+ if (!field && /^set./i.test(pair.key)) {
+ const target = byName.get(pair.key.slice(3).toLowerCase())
+ if (target) {
+ const on =
+ pair.value.kind === 'scalar' && pair.value.text.toLowerCase() !== 'false'
+ seenGuards.set(target.name, { value: on, line: pair.line })
+ continue
+ }
+ }
+
+ if (!field) {
+ ctx.problems.push({
+ severity: 'error',
+ line: pair.line,
+ title: `Unknown key ${pair.key}`,
+ detail: `${where} has no field called ${pair.key}. The game's .ini parser drops unknown keys without logging anything, so this has been doing nothing. It is not carried into the output below.`,
+ })
+ continue
+ }
+
+ if (field.kind === 'guard') continue
+
+ const childStruct = nestedStructName(field.type)
+ const isArrayType = field.type.startsWith('array<')
+
+ if (childStruct && isArrayType) {
+ const items = pair.value.kind === 'list' ? pair.value.items : []
+ node[field.name] = items
+ .filter((item): item is Extract => item.kind === 'block')
+ .map((item) => buildNode(ctx, item.raw, childStruct, childStruct))
+ continue
+ }
+
+ if (childStruct) {
+ if (pair.value.kind === 'block') {
+ node[field.name] = buildNode(ctx, pair.value.raw, childStruct, childStruct)
+ }
+ continue
+ }
+
+ if (isArrayType) {
+ const items = pair.value.kind === 'list' ? pair.value.items : [pair.value]
+ node[field.name] = items
+ .filter((item): item is Extract => item.kind === 'scalar')
+ .map((item) => item.text)
+ .filter((text) => text !== '')
+ continue
+ }
+
+ if (pair.value.kind === 'scalar') {
+ node[field.name] = scalarValue(pair.value, field.type)
+ }
+ }
+
+ reconcileGuards(ctx, node, byName, seenGuards, where)
+ return node
+}
+
+/**
+ * Cross-checks `SetX` guards against the values beside them.
+ */
+function reconcileGuards(
+ ctx: Ctx,
+ node: Node,
+ byName: Map,
+ guards: Map,
+ where: string,
+): void {
+ for (const field of byName.values()) {
+ if (!field.guard) continue
+
+ const guard = guards.get(field.name)
+ const hasValue = node[field.name] !== undefined
+
+ if (hasValue && !guard) {
+ ctx.problems.push({
+ severity: 'warning',
+ line: 0,
+ title: `${field.name} was being ignored`,
+ detail: `${where} sets ${field.name} without ${field.guard}=true, so the mod skipped it. The output below adds the guard, which is almost certainly what was intended.`,
+ })
+ continue
+ }
+
+ if (guard && !guard.value && hasValue) {
+ delete node[field.name]
+ ctx.problems.push({
+ severity: 'warning',
+ line: guard.line,
+ title: `${field.guard}=false, so ${field.name} is dropped`,
+ detail: 'The guard is explicitly off, so the value beside it never applied.',
+ })
+ continue
+ }
+
+ if (guard?.value && !hasValue) {
+ node[field.name] = zeroFor(field.type)
+ ctx.problems.push({
+ severity: 'warning',
+ line: guard.line,
+ title: `${field.guard}=true with no ${field.name}`,
+ detail: `The guard is on but no value was given, so the mod applied the default for a ${field.type}. Set it explicitly if that was not intended.`,
+ })
+ }
+ }
+}
+
+// --------------------------------------------------------------------------- //
+// Entry point
+// --------------------------------------------------------------------------- //
+
+const ENTRY = /\+?\s*AbilityEdits\s*=/gi
+
+export function parseConfig(source: string, indices: Indices): ParseResult {
+ const ctx: Ctx = { indices, problems: [] }
+ const { text, lineAt } = flatten(source)
+
+ const entries: Node[] = []
+ ENTRY.lastIndex = 0
+
+ for (;;) {
+ const match = ENTRY.exec(text)
+ if (!match) break
+
+ const line = lineAt[match.index] ?? 1
+
+ if (!text.slice(match.index, match.index + match[0].length).trimStart().startsWith('+')) {
+ ctx.problems.push({
+ severity: 'error',
+ line,
+ title: 'AbilityEdits= without a leading +',
+ detail:
+ 'Without the +, this replaces the entire list of edits rather than adding to it, discarding every entry from this and any other mod. Write +AbilityEdits=.',
+ })
+ }
+
+ const cursor = new Cursor(text, lineAt, match.index + match[0].length)
+ if (!cursor.eat('(')) continue
+
+ const value = readParenValue(cursor)
+ if (value.kind !== 'block') {
+ ctx.problems.push({
+ severity: 'error',
+ line,
+ title: 'Entry is empty or malformed',
+ detail: 'Expected +AbilityEdits=( Ability=..., ... ).',
+ })
+ continue
+ }
+
+ entries.push(buildNode(ctx, value.raw, 'AbilityEdit', 'An +AbilityEdits entry'))
+ ENTRY.lastIndex = cursor.pos
+ }
+
+ if (entries.length === 0) {
+ ctx.problems.push({
+ severity: 'error',
+ line: 1,
+ title: 'No +AbilityEdits entries found',
+ detail:
+ 'Paste the entries themselves, with or without the [AbilityEditor.X2DLCInfo_AbilityEditor] header. The legacy 1.x +Abilities format is not read here.',
+ })
+ return { doc: null, problems: ctx.problems, entryCount: 0 }
+ }
+
+ return {
+ doc: { schemaHash: indices.schema.sourceHash, entries },
+ problems: ctx.problems,
+ entryCount: entries.length,
+ }
+}
diff --git a/app/src/schema.test.ts b/app/src/schema.test.ts
new file mode 100644
index 0000000..a19cf57
--- /dev/null
+++ b/app/src/schema.test.ts
@@ -0,0 +1,32 @@
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+
+import { describe, expect, it } from 'vitest'
+
+import { schemaUrlFrom } from './schema'
+
+describe('schemaUrlFrom', () => {
+ it('resolves one level above the app, in production', () => {
+ expect(schemaUrlFrom('https://boundir.github.io/AbilityEditor/app/')).toBe(
+ 'https://boundir.github.io/AbilityEditor/schema.json',
+ )
+ })
+
+ it('resolves the same way against the dev server', () => {
+ expect(schemaUrlFrom('http://localhost:5173/AbilityEditor/app/')).toBe(
+ 'http://localhost:5173/AbilityEditor/schema.json',
+ )
+ })
+
+ it('matches the path vite.config.ts serves in dev', () => {
+ const config = readFileSync(resolve(import.meta.dirname, '..', 'vite.config.ts'), 'utf-8')
+ const base = /const BASE = '([^']+)'/.exec(config)?.[1]
+ expect(base).toBeDefined()
+
+ const appUrl = `http://localhost${base}`
+ expect(new URL(schemaUrlFrom(appUrl)).pathname).toBe(
+ new URL('../schema.json', appUrl).pathname,
+ )
+ expect(new URL(schemaUrlFrom(appUrl)).pathname).toBe('/AbilityEditor/schema.json')
+ })
+})
diff --git a/app/src/schema.ts b/app/src/schema.ts
new file mode 100644
index 0000000..4a29523
--- /dev/null
+++ b/app/src/schema.ts
@@ -0,0 +1,190 @@
+import type { Editor, EditorField, Family, Schema, SchemaEnum, Struct, TemplateField } from './types'
+
+export type * from './types'
+
+/**
+ * Loading and indexing docs/schema.json.
+ */
+
+const DB_NAME = 'ae-config-builder'
+const STORE = 'schema'
+const CACHE_KEY = 'latest'
+
+export function schemaUrlFrom(baseHref: string): string {
+ return new URL('../schema.json', baseHref).href
+}
+
+export function defaultSchemaUrl(): string {
+ return schemaUrlFrom(document.baseURI)
+}
+
+export interface Indices {
+ schema: Schema
+ familyByName: Map
+ editorsByFamily: Map
+ editorByGameClass: Map
+ fieldsByEditor: Map>
+ catchAllByFamily: Map
+ structByName: Map
+ enumByName: Map
+ templateByConfig: Map
+ familyByStruct: Map
+}
+
+export function buildIndices(schema: Schema): Indices {
+ const familyByName = new Map()
+ const editorsByFamily = new Map()
+ const editorByGameClass = new Map()
+ const fieldsByEditor = new Map>()
+ const catchAllByFamily = new Map()
+ const familyByStruct = new Map()
+
+ for (const family of schema.families) {
+ familyByName.set(family.name, family)
+ familyByStruct.set(family.editStruct, family)
+
+ const byClass = new Map(family.editors.map((e) => [e.class, e]))
+ const ordered = family.dispatchOrder
+ .map((cls) => byClass.get(cls))
+ .filter((e): e is Editor => e !== undefined)
+ editorsByFamily.set(family.name, ordered)
+
+ for (const editor of family.editors) {
+ if (editor.catchAll) {
+ catchAllByFamily.set(family.name, editor)
+ } else if (editor.gameClass) {
+ editorByGameClass.set(editor.gameClass, editor)
+ }
+ fieldsByEditor.set(editor.class, new Map(editor.fields.map((f) => [f.config, f])))
+ }
+ }
+
+ return {
+ schema,
+ familyByName,
+ editorsByFamily,
+ editorByGameClass,
+ fieldsByEditor,
+ catchAllByFamily,
+ structByName: new Map(schema.structs.map((s) => [s.name, s])),
+ enumByName: new Map(schema.enums.map((e) => [e.name, e])),
+ templateByConfig: new Map(schema.template.fields.map((f) => [f.config, f])),
+ familyByStruct,
+ }
+}
+
+export function resolveEditor(
+ indices: Indices,
+ familyName: string,
+ className: string,
+): { editor: Editor | undefined; viaCatchAll: boolean } {
+ const bare = className.includes('.') ? className.slice(className.lastIndexOf('.') + 1) : className
+ const exact = indices.editorByGameClass.get(bare)
+
+ if (exact) {
+ const family = indices.familyByName.get(familyName)
+ if (family?.editors.some((e) => e.class === exact.class)) {
+ return { editor: exact, viaCatchAll: false }
+ }
+ }
+ return { editor: indices.catchAllByFamily.get(familyName), viaCatchAll: true }
+}
+
+export interface FieldGroup {
+ source: string | null
+ label: string
+ fields: EditorField[]
+}
+
+export function groupFields(editor: Editor): FieldGroup[] {
+ const own: EditorField[] = []
+ const inherited = new Map()
+
+ for (const field of editor.fields) {
+ if (field.inheritedFrom) {
+ const bucket = inherited.get(field.inheritedFrom)
+ if (bucket) bucket.push(field)
+ else inherited.set(field.inheritedFrom, [field])
+ } else {
+ own.push(field)
+ }
+ }
+
+ const groups: FieldGroup[] = []
+ if (own.length > 0) {
+ groups.push({ source: null, label: `${editor.gameClass || editor.class} fields`, fields: own })
+ }
+ for (const [source, fields] of inherited) {
+ groups.push({ source, label: `Shared — from ${source}`, fields })
+ }
+ return groups
+}
+
+// --------------------------------------------------------------------------- //
+// Fetch + cache
+// --------------------------------------------------------------------------- //
+
+function openDb(): Promise {
+ return new Promise((resolve) => {
+ if (!('indexedDB' in globalThis)) return resolve(null)
+ let request: IDBOpenDBRequest
+ try {
+ request = indexedDB.open(DB_NAME, 1)
+ } catch {
+ return resolve(null)
+ }
+ request.onupgradeneeded = () => {
+ if (!request.result.objectStoreNames.contains(STORE)) request.result.createObjectStore(STORE)
+ }
+ request.onsuccess = () => resolve(request.result)
+ request.onerror = () => resolve(null)
+ })
+}
+
+async function readCache(): Promise {
+ const db = await openDb()
+ if (!db) return null
+ return new Promise((resolve) => {
+ try {
+ const request = db.transaction(STORE, 'readonly').objectStore(STORE).get(CACHE_KEY)
+ request.onsuccess = () => resolve((request.result as Schema | undefined) ?? null)
+ request.onerror = () => resolve(null)
+ } catch {
+ resolve(null)
+ }
+ })
+}
+
+async function writeCache(schema: Schema): Promise {
+ const db = await openDb()
+ if (!db) return
+ try {
+ db.transaction(STORE, 'readwrite').objectStore(STORE).put(schema, CACHE_KEY)
+ } catch {
+ }
+}
+
+export interface LoadResult {
+ schema: Schema
+ fromCache: boolean
+}
+
+export async function loadSchema(url: string = defaultSchemaUrl()): Promise {
+ try {
+ const response = await fetch(url, { cache: 'no-cache' })
+ if (!response.ok) throw new Error(`${response.status} ${response.statusText}`)
+
+ const schema = (await response.json()) as Schema
+ if (!schema.families || !schema.sourceHash) {
+ throw new Error('That file does not look like an Ability Editor schema.')
+ }
+ void writeCache(schema)
+ return { schema, fromCache: false }
+ } catch (error) {
+ const cached = await readCache()
+ if (cached) return { schema: cached, fromCache: true }
+ throw new Error(
+ `Could not load the schema from ${url}. ${error instanceof Error ? error.message : String(error)}`,
+ )
+ }
+}
diff --git a/app/src/state.test.ts b/app/src/state.test.ts
new file mode 100644
index 0000000..ee4422f
--- /dev/null
+++ b/app/src/state.test.ts
@@ -0,0 +1,76 @@
+import { describe, expect, it } from 'vitest'
+
+import { emptyDoc, newNode, setAtPath, type Doc, type Node } from './state'
+
+function doc(entry: Node): Doc {
+ return { schemaHash: 'test', entries: [entry] }
+}
+
+describe('setAtPath', () => {
+ it('creates a missing array when adding its first element', () => {
+ const before = doc(newNode({ Ability: 'X' }))
+ const after = setAtPath(before, [0, 'Effects', 0], newNode({ Class: 'X2Effect_Persistent' }))
+
+ const effects = after.entries[0]?.['Effects'] as Node[] | undefined
+ expect(effects).toHaveLength(1)
+ expect(effects?.[0]?.['Class']).toBe('X2Effect_Persistent')
+ })
+
+ it('appends to an array that already exists', () => {
+ const before = doc(newNode({ Ability: 'X', Costs: [newNode({ Class: 'A' })] }))
+ const after = setAtPath(before, [0, 'Costs', 1], newNode({ Class: 'B' }))
+
+ const costs = after.entries[0]?.['Costs'] as Node[]
+ expect(costs.map((c) => c['Class'])).toEqual(['A', 'B'])
+ })
+
+ it('creates missing containers several levels deep', () => {
+ const before = doc(newNode({ Ability: 'X', Effects: [newNode({ Class: 'E' })] }))
+ const after = setAtPath(
+ before,
+ [0, 'Effects', 0, 'TargetConditions', 0],
+ newNode({ Class: 'X2Condition_UnitProperty' }),
+ )
+
+ const effects = after.entries[0]?.['Effects'] as Node[]
+ const conditions = effects[0]?.['TargetConditions'] as Node[] | undefined
+ expect(conditions).toHaveLength(1)
+ expect(conditions?.[0]?.['Class']).toBe('X2Condition_UnitProperty')
+ })
+
+ it('creates a missing object for a single nested slot', () => {
+ const before = doc(newNode({ Ability: 'X' }))
+ const after = setAtPath(before, [0, 'Cooldown', 'NumTurns'], 5)
+
+ expect((after.entries[0]?.['Cooldown'] as Node)['NumTurns']).toBe(5)
+ })
+
+ it('removes an array element without disturbing its siblings', () => {
+ const before = doc(
+ newNode({ Ability: 'X', Costs: [newNode({ Class: 'A' }), newNode({ Class: 'B' })] }),
+ )
+ const after = setAtPath(before, [0, 'Costs', 0], undefined)
+
+ expect((after.entries[0]?.['Costs'] as Node[]).map((c) => c['Class'])).toEqual(['B'])
+ })
+
+ it('removes a key when set to undefined', () => {
+ const before = doc(newNode({ Ability: 'X', Hostility: 'eHostility_Offensive' }))
+ const after = setAtPath(before, [0, 'Hostility'], undefined)
+
+ expect(after.entries[0]).not.toHaveProperty('Hostility')
+ })
+
+ it('does not mutate the document it was given', () => {
+ const before = doc(newNode({ Ability: 'X' }))
+ const snapshot = JSON.stringify(before)
+ setAtPath(before, [0, 'Effects', 0], newNode({ Class: 'E' }))
+
+ expect(JSON.stringify(before)).toBe(snapshot)
+ })
+
+ it('leaves an unknown entry index alone rather than throwing', () => {
+ const before = emptyDoc('test')
+ expect(setAtPath(before, [9, 'Ability'], 'X')).toBe(before)
+ })
+})
diff --git a/app/src/state.ts b/app/src/state.ts
new file mode 100644
index 0000000..eeb7e6b
--- /dev/null
+++ b/app/src/state.ts
@@ -0,0 +1,115 @@
+/**
+ * Document state.
+ */
+
+export type Scalar = string | number | boolean
+export type Value = Scalar | string[] | Node | Node[]
+
+export interface Node {
+ _id: string
+ [key: string]: Value | undefined
+}
+
+export interface Doc {
+ schemaHash: string
+ entries: Node[]
+}
+
+let counter = 0
+export function newId(prefix = 'n'): string {
+ counter += 1
+ return `${prefix}${counter}`
+}
+
+export function newNode(initial: Record = {}): Node {
+ return { _id: newId(), ...initial }
+}
+
+export function emptyDoc(schemaHash: string): Doc {
+ return { schemaHash, entries: [newNode({ Ability: '' })] }
+}
+
+export function isInternalKey(key: string): boolean {
+ return key.startsWith('_')
+}
+
+export function configKeys(node: Node): string[] {
+ return Object.keys(node).filter((k) => !isInternalKey(k))
+}
+
+// --------------------------------------------------------------------------- //
+// Immutable updates
+// --------------------------------------------------------------------------- //
+
+export type Path = (string | number)[]
+
+function cloneAt(value: Value | undefined): Value | undefined {
+ if (Array.isArray(value)) return [...value] as Value
+ if (value && typeof value === 'object') return { ...(value as Node) }
+ return value
+}
+
+export function setAtPath(doc: Doc, path: Path, value: Value | undefined): Doc {
+ if (path.length === 0) return doc
+
+ const entries = [...doc.entries]
+ const [entryIndex, ...rest] = path
+
+ if (typeof entryIndex !== 'number') return doc
+ const entry = entries[entryIndex]
+ if (!entry) return doc
+
+ if (rest.length === 0) {
+ if (value === undefined) entries.splice(entryIndex, 1)
+ else entries[entryIndex] = value as Node
+ return { ...doc, entries }
+ }
+
+ entries[entryIndex] = setIn(entry, rest, value) as Node
+ return { ...doc, entries }
+}
+
+/**
+ * The container a path step needs to descend into, created when absent.
+ */
+function containerFor(existing: Value | undefined, nextStep: string | number | undefined): Value {
+ if (existing !== undefined) return cloneAt(existing) as Value
+ return typeof nextStep === 'number' ? ([] as unknown as Value) : ({} as unknown as Value)
+}
+
+function setIn(container: Value, path: Path, value: Value | undefined): Value {
+ const [head, ...rest] = path
+ if (head === undefined) return value as Value
+
+ if (typeof head === 'number') {
+ const list = [...(container as Node[])]
+ if (rest.length === 0) {
+ if (value === undefined) list.splice(head, 1)
+ else list[head] = value as Node
+ } else {
+ list[head] = setIn(containerFor(list[head], rest[0]), rest, value) as Node
+ }
+ return list as Value
+ }
+
+ const node = { ...(container as Node) }
+ if (rest.length === 0) {
+ if (value === undefined) delete node[head]
+ else node[head] = value
+ } else {
+ node[head] = setIn(containerFor(node[head], rest[0]), rest, value)
+ }
+ return node as Value
+}
+
+export function getAtPath(doc: Doc, path: Path): Value | undefined {
+ let current: Value | undefined = doc.entries as unknown as Value
+ for (const step of path) {
+ if (current === undefined) return undefined
+ current =
+ typeof step === 'number'
+ ? (current as Node[])[step]
+ : (current as Node)[step]
+ }
+ return current
+}
diff --git a/app/src/styles.css b/app/src/styles.css
new file mode 100644
index 0000000..aefd47c
--- /dev/null
+++ b/app/src/styles.css
@@ -0,0 +1,322 @@
+:root {
+ --bg: #fdfdfd;
+ --panel: #ffffff;
+ --border: #dfe3e6;
+ --text: #1f2429;
+ --muted: #6b7680;
+ --accent: #00786f;
+ --accent-soft: #e2f3f1;
+ --error: #b3261e;
+ --error-soft: #fceceb;
+ --warn: #8a6100;
+ --warn-soft: #fdf3dc;
+ --code-bg: #f4f6f7;
+ --radius: 6px;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #14181b;
+ --panel: #1c2124;
+ --border: #2e353a;
+ --text: #e6eaed;
+ --muted: #9aa5ad;
+ --accent: #4ddbcb;
+ --accent-soft: #10312e;
+ --error: #ff8a80;
+ --error-soft: #35191a;
+ --warn: #f0c56b;
+ --warn-soft: #332a14;
+ --code-bg: #101416;
+ }
+}
+
+* { box-sizing: border-box; }
+
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--text);
+ font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
+}
+
+code, pre { font-family: ui-monospace, SFMono-Regular, "Cascadia Code", Consolas, monospace; }
+
+button {
+ font: inherit;
+ color: inherit;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 4px 10px;
+ cursor: pointer;
+}
+button:hover { border-color: var(--accent); }
+
+input, select {
+ font: inherit;
+ color: inherit;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 4px 8px;
+ min-width: 0;
+}
+input:focus-visible, select:focus-visible, button:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+}
+
+/* ---------- layout ---------- */
+
+.layout {
+ display: grid;
+ grid-template-columns: 220px minmax(0, 1fr) minmax(320px, 34%);
+ grid-template-rows: auto 1fr;
+ grid-template-areas: "top top top" "entries editor output";
+ height: 100vh;
+}
+
+.topbar {
+ grid-area: top;
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding: 10px 16px;
+ border-bottom: 1px solid var(--border);
+ background: var(--panel);
+}
+.topbar h1 { font-size: 15px; margin: 0; font-weight: 600; }
+.topbar__link { margin-left: auto; color: var(--accent); }
+.search { flex: 0 1 240px; }
+
+.banner {
+ grid-column: 1 / -1;
+ margin: 0;
+ padding: 8px 16px;
+ font-size: 13px;
+}
+.banner--warn { background: var(--warn-soft); color: var(--warn); }
+
+.entries, .editor, .output { overflow-y: auto; }
+
+.entries {
+ grid-area: entries;
+ border-right: 1px solid var(--border);
+ padding: 12px;
+}
+.entries__head { display: flex; align-items: center; justify-content: space-between; }
+.entries__head h2 { font-size: 12px; text-transform: uppercase; color: var(--muted); margin: 0; }
+.entries ul { list-style: none; margin: 12px 0 0; padding: 0; }
+.entries li { display: flex; gap: 4px; margin-bottom: 4px; }
+.entries li > button:first-child {
+ flex: 1;
+ display: flex;
+ justify-content: space-between;
+ gap: 8px;
+ text-align: left;
+}
+.entries li > button.is-selected { background: var(--accent-soft); border-color: var(--accent); }
+.entries__remove { padding: 4px 8px; color: var(--muted); }
+
+.editor { grid-area: editor; padding: 16px 20px; }
+.output {
+ grid-area: output;
+ border-left: 1px solid var(--border);
+ padding: 12px 16px;
+ background: var(--panel);
+}
+.output__head { display: flex; align-items: center; justify-content: space-between; }
+.output__head h2 { font-size: 12px; text-transform: uppercase; color: var(--muted); margin: 0; }
+.output pre {
+ background: var(--code-bg);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 10px;
+ font-size: 12.5px;
+ white-space: pre-wrap;
+ word-break: break-word;
+ max-height: 45vh;
+ overflow: auto;
+}
+
+/* ---------- fields ---------- */
+
+.fields { display: grid; gap: 2px; }
+
+.field {
+ display: grid;
+ grid-template-columns: minmax(170px, 260px) minmax(0, 1fr) auto;
+ grid-template-areas: "toggle control meta" ". desc desc";
+ gap: 4px 12px;
+ align-items: center;
+ padding: 5px 8px;
+ border-radius: var(--radius);
+}
+.field:hover { background: var(--code-bg); }
+.field--on { background: var(--accent-soft); }
+.field--on:hover { background: var(--accent-soft); }
+
+.field__toggle { grid-area: toggle; display: flex; align-items: center; gap: 8px; cursor: pointer; }
+.field__toggle code { font-size: 13px; }
+.field__control { grid-area: control; }
+.field__control input, .field__control select { width: 100%; }
+.field__meta { grid-area: meta; display: flex; gap: 8px; font-size: 11.5px; color: var(--muted); }
+.field__game { font-family: ui-monospace, monospace; }
+
+.field__desc { grid-area: desc; margin: 0; font-size: 12.5px; color: var(--muted); }
+.field__desc--derived { opacity: 0.6; font-style: italic; }
+.field:not(.field--on) .field__desc { display: none; }
+
+/* ---------- groups and nesting ---------- */
+
+.group, .nested {
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ margin: 10px 0;
+ background: var(--panel);
+}
+.group > summary, .nested > summary {
+ cursor: pointer;
+ padding: 7px 10px;
+ font-weight: 600;
+ font-size: 13.5px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.group > .fields { padding: 4px 8px 10px; }
+.group__count {
+ font-weight: 400;
+ color: var(--muted);
+ background: var(--code-bg);
+ border-radius: 10px;
+ padding: 0 7px;
+ font-size: 11.5px;
+}
+.group__set { font-weight: 400; color: var(--accent); font-size: 11.5px; }
+
+.nested__desc { margin: 0 10px 8px; font-size: 12.5px; color: var(--muted); }
+.nested__remove { margin-left: auto; }
+
+.nested__mode {
+ margin: 0 10px 8px;
+ padding: 4px;
+ border: 1px dashed var(--border);
+ border-radius: var(--radius);
+}
+.nested__alert {
+ margin-left: auto;
+ font-weight: 400;
+ font-size: 11.5px;
+ color: var(--warn);
+ background: var(--warn-soft);
+ border-radius: 10px;
+ padding: 0 7px;
+}
+
+.card {
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ margin: 8px 10px;
+ padding: 8px;
+ background: var(--bg);
+}
+.card__bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 6px;
+}
+.card__title { font-size: 12px; color: var(--muted); font-family: ui-monospace, monospace; }
+
+.add { margin: 4px 10px 10px; }
+.add--single { display: block; margin: 10px 0; }
+
+.classpicker { margin-bottom: 10px; }
+.classpicker label { display: flex; align-items: center; gap: 10px; }
+.classpicker label span { font-weight: 600; font-size: 13.5px; }
+.classpicker input { flex: 1; }
+.classpicker__note { margin: 6px 0 0; font-size: 12.5px; color: var(--warn); }
+
+/* ---------- chips ---------- */
+
+.chips { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
+.chip { display: inline-flex; align-items: center; }
+.chip input { width: 130px; border-radius: var(--radius) 0 0 var(--radius); }
+.chip button { border-radius: 0 var(--radius) var(--radius) 0; border-left: none; padding: 4px 7px; }
+.chip__add { font-size: 12.5px; }
+
+/* ---------- issues ---------- */
+
+.issues { display: grid; gap: 6px; margin-top: 14px; }
+.issues--ok { color: var(--muted); font-size: 13px; }
+.issue {
+ display: grid;
+ gap: 2px;
+ text-align: left;
+ padding: 8px 10px;
+ font-size: 12.5px;
+ border-left-width: 3px;
+}
+.issue--error { background: var(--error-soft); border-left-color: var(--error); }
+.issue--error strong { color: var(--error); }
+.issue--warning { background: var(--warn-soft); border-left-color: var(--warn); }
+.issue--warning strong { color: var(--warn); }
+.issue span { color: var(--muted); }
+
+.pill { border-radius: 10px; padding: 0 6px; font-size: 11px; }
+.pill--error { background: var(--error-soft); color: var(--error); }
+.pill--warn { background: var(--warn-soft); color: var(--warn); }
+
+.empty { color: var(--muted); font-size: 13px; }
+
+.state { padding: 40px; color: var(--muted); }
+.state--error { color: var(--text); max-width: 60ch; }
+.state--error h1 { font-size: 18px; }
+
+@media (max-width: 1100px) {
+ .layout {
+ grid-template-columns: 1fr;
+ grid-template-areas: "top" "entries" "editor" "output";
+ height: auto;
+ }
+ .entries, .output { border: none; border-top: 1px solid var(--border); }
+}
+
+/* ---------- import panel ---------- */
+
+.import {
+ border: 1px solid var(--accent);
+ border-radius: var(--radius);
+ padding: 12px;
+ margin-bottom: 16px;
+ background: var(--panel);
+}
+.import__head { display: flex; align-items: center; justify-content: space-between; }
+.import__head h2 { font-size: 14px; margin: 0; }
+.import__intro { font-size: 12.5px; color: var(--muted); margin: 6px 0 10px; }
+.import textarea {
+ width: 100%;
+ font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
+ font-size: 12.5px;
+ background: var(--code-bg);
+ color: var(--text);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 8px;
+ resize: vertical;
+}
+.import__actions { display: flex; gap: 8px; margin-top: 8px; }
+.import__results { margin-top: 12px; }
+.import__summary { font-size: 13px; margin: 0 0 8px; }
+.import__summary strong { color: var(--error); }
+.import .issue { display: block; }
+.issue__line {
+ display: inline-block;
+ margin-right: 8px;
+ font-weight: 400;
+ color: var(--muted);
+ font-family: ui-monospace, monospace;
+ font-size: 11.5px;
+}
diff --git a/app/src/types.ts b/app/src/types.ts
new file mode 100644
index 0000000..60959cc
--- /dev/null
+++ b/app/src/types.ts
@@ -0,0 +1,94 @@
+/**
+ * Shape of docs/schema.json.
+ */
+
+export type FieldKind = 'scalar' | 'namearray' | 'nested'
+
+export interface EditorField {
+ config: string
+ type: string
+ kind: FieldKind
+ gameField: string | null
+ origin: 'base' | 'derived'
+ inheritedFrom: string | null
+ description: string
+ guard?: string
+ modeField?: string
+ modeEnum?: string
+ replaceOnly?: boolean
+}
+
+export interface NotEditable {
+ name: string
+ reason: string
+}
+
+export interface Editor {
+ class: string
+ extends: string | null
+ gameClass: string
+ catchAll: boolean
+ abstract?: boolean
+ fields: EditorField[]
+ notEditable?: NotEditable[]
+}
+
+export interface Family {
+ name: string
+ configKey: string
+ editStruct: string
+ wired: boolean
+ dispatchOrder: string[]
+ intro: string
+ editors: Editor[]
+}
+
+export interface StructField {
+ name: string
+ type: string
+ kind: 'plain' | 'guard' | 'optionalScalar' | 'mode' | 'modedArray' | 'nested' | 'plainArray'
+ description: string
+ guard?: string
+ modeField?: string
+ modeEnum?: string
+}
+
+export interface Struct {
+ name: string
+ fields: StructField[]
+}
+
+export interface EnumValue {
+ name: string
+ description: string
+}
+
+export interface SchemaEnum {
+ name: string
+ values: EnumValue[]
+}
+
+export interface TemplateField {
+ config: string
+ type: string
+ kind: 'scalar' | 'namearray'
+ gameField: string | null
+ description: string
+ guard?: string
+ modeField?: string
+ modeEnum?: string
+ replaceOnly?: boolean
+}
+
+export interface Schema {
+ $comment: string
+ sourceHash: string
+ enums: SchemaEnum[]
+ structs: Struct[]
+ template: {
+ fields: TemplateField[]
+ notEditable: NotEditable[]
+ }
+ families: Family[]
+ warnings: string[]
+}
diff --git a/app/src/validate.test.ts b/app/src/validate.test.ts
new file mode 100644
index 0000000..679ca3b
--- /dev/null
+++ b/app/src/validate.test.ts
@@ -0,0 +1,207 @@
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+
+import { describe, expect, it } from 'vitest'
+
+import { buildIndices } from './schema'
+import type { Schema } from './types'
+import { newNode, type Doc, type Node } from './state'
+import { validate } from './validate'
+
+/**
+ * Runs against the real docs/schema.json.
+ */
+
+const schema = JSON.parse(
+ readFileSync(resolve(import.meta.dirname, '..', '..', 'docs', 'schema.json'), 'utf-8'),
+) as Schema
+const indices = buildIndices(schema)
+
+function docOf(...entries: Node[]): Doc {
+ return { schemaHash: schema.sourceHash, entries }
+}
+
+function titles(doc: Doc): string[] {
+ return validate(doc, indices).map((i) => i.title)
+}
+
+describe('the schema this app is built against', () => {
+ it('has the nine families the UI expects', () => {
+ expect(schema.families).toHaveLength(9)
+ })
+
+ it('gives every family a catch-all, so an unknown class always resolves', () => {
+ for (const family of schema.families) {
+ expect(family.editors.some((e) => e.catchAll)).toBe(true)
+ }
+ })
+
+ it('still has an empty shared field set for Conditions', () => {
+ const conditions = indices.catchAllByFamily.get('Conditions')
+ expect(conditions?.fields).toHaveLength(0)
+ })
+})
+
+describe('required fields', () => {
+ it('flags a missing Ability', () => {
+ expect(titles(docOf(newNode({ Ability: '' })))).toContain('Ability is required')
+ })
+
+ it('flags an Ability that is not a bare name', () => {
+ expect(titles(docOf(newNode({ Ability: 'Sword Slice' })))).toContain(
+ '"Sword Slice" is not a valid ability name',
+ )
+ })
+
+ it('accepts a well-formed entry without complaint', () => {
+ expect(titles(docOf(newNode({ Ability: 'SwordSlice' })))).toEqual([])
+ })
+})
+
+describe('destructive mode defaults', () => {
+ it('treats Costs without CostMode as an error, not a warning', () => {
+ const doc = docOf(
+ newNode({
+ Ability: 'SwordSlice',
+ Costs: [newNode({ Class: 'X2AbilityCost_ActionPoints', Mode: 'eACEM_Merge' })],
+ }),
+ )
+ const issue = validate(doc, indices).find((i) =>
+ i.title.startsWith('CostMode is not set'),
+ )
+ expect(issue?.severity).toBe('error')
+ })
+
+ it('escalates when several entries each default to Replace', () => {
+ const doc = docOf(
+ newNode({
+ Ability: 'X',
+ CostMode: 'eACEM_Merge',
+ Costs: [
+ newNode({ Class: 'X2AbilityCost_ActionPoints' }),
+ newNode({ Class: 'X2AbilityCost_Ammo' }),
+ ],
+ }),
+ )
+ const issue = validate(doc, indices).find((i) => i.title.includes('only the last will survive'))
+ expect(issue?.severity).toBe('error')
+ })
+
+ it('warns once when a single entry omits its Mode', () => {
+ const doc = docOf(
+ newNode({
+ Ability: 'X',
+ CostMode: 'eACEM_Merge',
+ Costs: [newNode({ Class: 'X2AbilityCost_Ammo' })],
+ }),
+ )
+ const issue = validate(doc, indices).find((i) => i.title.includes('no Mode'))
+ expect(issue?.severity).toBe('warning')
+ })
+
+ it('warns that eNAEM_AddOnly does not append', () => {
+ const doc = docOf(
+ newNode({
+ Ability: 'X',
+ AdditionalAbilities: ['Blademaster'],
+ AdditionalAbilitiesMode: 'eNAEM_AddOnly',
+ }),
+ )
+ expect(titles(doc)).toContain('AdditionalAbilitiesMode=eNAEM_AddOnly does not append')
+ })
+})
+
+describe('class matching', () => {
+ it('rejects two entries targeting the same class and slot', () => {
+ const doc = docOf(
+ newNode({
+ Ability: 'X',
+ CostMode: 'eACEM_Merge',
+ Costs: [
+ newNode({ Class: 'X2AbilityCost_Ammo', Mode: 'eACEM_Merge' }),
+ newNode({ Class: 'X2AbilityCost_Ammo', Mode: 'eACEM_Merge' }),
+ ],
+ }),
+ )
+ const issue = validate(doc, indices).find((i) => i.title.includes('Two Costs entries'))
+ expect(issue?.severity).toBe('error')
+ })
+
+ it('allows the same effect class in two different slots', () => {
+ const doc = docOf(
+ newNode({
+ Ability: 'X',
+ Effects: [
+ newNode({ Class: 'X2Effect_Persistent', Slot: 'eAES_Target', Mode: 'eAEM_Merge' }),
+ newNode({ Class: 'X2Effect_Persistent', Slot: 'eAES_Shooter', Mode: 'eAEM_Merge' }),
+ ],
+ }),
+ )
+ expect(titles(doc).some((t) => t.includes('Two Effects entries'))).toBe(false)
+ })
+
+ it('rejects an empty Class in an array family', () => {
+ const doc = docOf(
+ newNode({ Ability: 'X', CostMode: 'eACEM_Merge', Costs: [newNode({ Mode: 'eACEM_Merge' })] }),
+ )
+ expect(titles(doc)).toContain('Class is empty')
+ })
+
+ it('warns when a class has no dedicated editor', () => {
+ const doc = docOf(
+ newNode({
+ Ability: 'X',
+ CostMode: 'eACEM_Merge',
+ Costs: [newNode({ Class: 'MyMod.X2AbilityCost_Morale', Mode: 'eACEM_Merge' })],
+ }),
+ )
+ expect(titles(doc)).toContain('No dedicated editor for MyMod.X2AbilityCost_Morale')
+ })
+
+ it('resolves a package-qualified name to its bare class', () => {
+ const doc = docOf(
+ newNode({
+ Ability: 'X',
+ CostMode: 'eACEM_Merge',
+ Costs: [newNode({ Class: 'XComGame.X2AbilityCost_Ammo', Mode: 'eACEM_Merge' })],
+ }),
+ )
+ expect(titles(doc).some((t) => t.startsWith('No dedicated editor'))).toBe(false)
+ })
+})
+
+describe('single-object slots', () => {
+ it('warns that omitting Class edits in place and may be a silent no-op', () => {
+ const doc = docOf(newNode({ Ability: 'X', Cooldown: newNode({ NumTurns: 3 }) }))
+ expect(titles(doc)).toContain('Cooldown edits whatever is already there')
+ })
+
+ it('warns that naming a different Class resets unset fields', () => {
+ const doc = docOf(
+ newNode({
+ Ability: 'X',
+ Cooldown: newNode({ Class: 'X2AbilityCooldown_PerPlayerType', NumTurns: 9 }),
+ }),
+ )
+ expect(titles(doc)).toContain('Cooldown may be replaced rather than edited')
+ })
+})
+
+describe('unrepresentable values', () => {
+ it('rejects a string containing characters the format cannot escape', () => {
+ const doc = docOf(
+ newNode({
+ Ability: 'X',
+ Effects: [
+ newNode({
+ Class: 'X2Effect_Persistent',
+ Mode: 'eAEM_Merge',
+ FriendlyName: 'He said "no", loudly',
+ }),
+ ],
+ }),
+ )
+ const issue = validate(doc, indices).find((i) => i.title.includes('cannot represent'))
+ expect(issue?.severity).toBe('error')
+ })
+})
diff --git a/app/src/validate.ts b/app/src/validate.ts
new file mode 100644
index 0000000..6006ee0
--- /dev/null
+++ b/app/src/validate.ts
@@ -0,0 +1,314 @@
+import { hasUnrepresentableChars, isBareName, nestedStructName } from './emit'
+import type { Indices } from './schema'
+import { resolveEditor } from './schema'
+import { configKeys, type Doc, type Node, type Path } from './state'
+
+/**
+ * Config validation.
+ */
+
+export type Severity = 'error' | 'warning'
+
+export interface Issue {
+ severity: Severity
+ path: Path
+ title: string
+ detail: string
+}
+
+/** Array families */
+const ARRAY_FAMILY_KEYS = new Set(['Costs', 'Effects', 'Triggers'])
+
+const CONDITION_KEYS = new Set([
+ 'ShooterConditions',
+ 'TargetConditions',
+ 'MultiTargetConditions',
+ 'ApplyDamageModConditions',
+ 'LethalDamageConditions',
+ 'ToHitConditions',
+])
+
+/** Single-object slots */
+const SINGLE_OBJECT_KEYS = new Set([
+ 'Cooldown',
+ 'Charges',
+ 'ToHitCalc',
+ 'ToHitOwnerOnMissCalc',
+ 'TargetStyle',
+ 'MultiTargetStyle',
+])
+
+function familyForKey(indices: Indices, key: string, structName: string): string | undefined {
+ if (CONDITION_KEYS.has(key)) return 'Conditions'
+ const struct = indices.structByName.get(structName)
+ const field = struct?.fields.find((f) => f.name === key)
+ if (!field) return undefined
+ const child = nestedStructName(field.type)
+ return child ? indices.familyByStruct.get(child)?.name : undefined
+}
+
+export function validate(doc: Doc, indices: Indices): Issue[] {
+ const issues: Issue[] = []
+
+ doc.entries.forEach((entry, index) => {
+ validateNode(entry, 'AbilityEdit', [index], indices, issues)
+
+ const ability = entry['Ability']
+ if (typeof ability !== 'string' || ability.trim() === '') {
+ issues.push({
+ severity: 'error',
+ path: [index],
+ title: 'Ability is required',
+ detail: 'Every entry needs the template name of the ability it edits, e.g. SwordSlice.',
+ })
+ } else if (!isBareName(ability)) {
+ issues.push({
+ severity: 'error',
+ path: [index],
+ title: `"${ability}" is not a valid ability name`,
+ detail:
+ 'Ability is a name, so it must be a bare token: letters, digits and underscores, not starting with a digit.',
+ })
+ }
+ })
+
+ return issues
+}
+
+function validateNode(
+ node: Node,
+ structName: string,
+ path: Path,
+ indices: Indices,
+ issues: Issue[],
+): void {
+ const struct = indices.structByName.get(structName)
+ if (!struct) return
+
+ const keys = configKeys(node)
+
+ for (const key of keys) {
+ const field = struct.fields.find((f) => f.name === key)
+ if (!field) continue
+ const value = node[key]
+ if (value === undefined) continue
+
+ if (typeof value === 'string' && field.type === 'string' && hasUnrepresentableChars(value)) {
+ issues.push({
+ severity: 'error',
+ path: [...path, key],
+ title: `${key} contains a character this format cannot represent`,
+ detail:
+ 'The .ini dialect has no escaping, so a value containing " ( ) , or a newline cannot be written.',
+ })
+ }
+
+ if (typeof value === 'string' && field.type === 'name' && value !== '' && !isBareName(value)) {
+ issues.push({
+ severity: 'error',
+ path: [...path, key],
+ title: `${key} must be a bare name`,
+ detail: 'Letters, digits and underscores only, not starting with a digit.',
+ })
+ }
+
+ const childStruct = nestedStructName(field.type)
+
+ if (childStruct && Array.isArray(value) && field.type.startsWith('array<')) {
+ const children = value as Node[]
+ checkArrayModes(node, key, children, path, indices, issues)
+ checkDuplicateClasses(key, children, path, issues)
+
+ children.forEach((child, childIndex) => {
+ const childPath = [...path, key, childIndex]
+ validateNode(child, childStruct, childPath, indices, issues)
+ checkClassResolution(child, key, structName, childPath, indices, issues)
+ })
+ continue
+ }
+
+ // A single nested struct.
+ if (childStruct && value && typeof value === 'object' && !Array.isArray(value)) {
+ const child = value as Node
+ const childPath = [...path, key]
+ validateNode(child, childStruct, childPath, indices, issues)
+
+ if (SINGLE_OBJECT_KEYS.has(key)) checkSingleObjectSlot(child, key, childPath, issues)
+ continue
+ }
+
+ if (Array.isArray(value) && field.modeField && !keys.includes(field.modeField)) {
+ issues.push({
+ severity: 'warning',
+ path: [...path, key],
+ title: `${key} will replace the ability's existing list`,
+ detail: `${field.modeField} defaults to the first enum value, which is a Replace. Set it to Merge to add to the existing values instead.`,
+ })
+ }
+
+ if (value === 'eNAEM_AddOnly') {
+ issues.push({
+ severity: 'warning',
+ path: [...path, key],
+ title: `${key}=eNAEM_AddOnly does not append`,
+ detail:
+ 'For name arrays, AddOnly writes your values only when the target array is currently empty. To add without removing, use eNAEM_Merge.',
+ })
+ }
+ }
+
+ checkRemoveCharges(node, structName, path, keys, issues)
+}
+
+function checkArrayModes(
+ parent: Node,
+ key: string,
+ children: Node[],
+ path: Path,
+ indices: Indices,
+ issues: Issue[],
+): void {
+ const family = familyForKey(indices, key, 'AbilityEdit')
+
+ if (key === 'Costs' && parent['CostMode'] === undefined && children.length > 0) {
+ issues.push({
+ severity: 'error',
+ path: [...path, key],
+ title: 'CostMode is not set, so every existing cost will be deleted',
+ detail:
+ 'Omitting CostMode defaults it to eACEM_ReplaceAll, which clears the ability’s entire cost list and keeps only what is listed here. Set CostMode=eACEM_Merge unless that is genuinely what you want.',
+ })
+ }
+
+ const missingMode = children.filter((child) => child['Mode'] === undefined)
+ if (missingMode.length === 0) {
+ return
+ }
+
+ const severity: Severity = children.length > 1 ? 'error' : 'warning'
+ issues.push({
+ severity,
+ path: [...path, key],
+ title:
+ children.length > 1
+ ? `${missingMode.length} of ${children.length} ${key} entries have no Mode — only the last will survive`
+ : `${key} entry has no Mode, so the existing list will be cleared`,
+ detail:
+ children.length > 1
+ ? 'Mode defaults to a Replace, and each entry re-clears the array before adding itself. Set Mode on every entry, normally to Merge.'
+ : `Mode defaults to the first enum value, which clears the ${family ?? key} array before adding this entry. Set it to Merge to edit in place instead.`,
+ })
+}
+
+function checkDuplicateClasses(key: string, children: Node[], path: Path, issues: Issue[]): void {
+ const seen = new Map()
+
+ children.forEach((child, index) => {
+ const cls = child['Class']
+ if (typeof cls !== 'string' || cls === '') return
+ // Effects are additionally keyed by slot.
+ const slot = typeof child['Slot'] === 'string' ? child['Slot'] : ''
+ const composite = `${cls}\u0000${slot}`
+
+ const first = seen.get(composite)
+ if (first !== undefined) {
+ issues.push({
+ severity: 'error',
+ path: [...path, key, index],
+ title: `Two ${key} entries target ${cls}`,
+ detail:
+ 'Entries are matched by exact class equality, so the second edits whatever the first just created rather than a separate object. Merge them into one entry.',
+ })
+ } else {
+ seen.set(composite, index)
+ }
+ })
+}
+
+function checkClassResolution(
+ child: Node,
+ key: string,
+ parentStruct: string,
+ path: Path,
+ indices: Indices,
+ issues: Issue[],
+): void {
+ const cls = child['Class']
+ const family = familyForKey(indices, key, parentStruct)
+
+ if (typeof cls !== 'string' || cls.trim() === '') {
+ if (ARRAY_FAMILY_KEYS.has(key) || CONDITION_KEYS.has(key)) {
+ issues.push({
+ severity: 'error',
+ path: [...path, 'Class'],
+ title: 'Class is empty',
+ detail:
+ 'Without a class there is nothing to match against, so the entry falls back to the family base class and adds a useless object.',
+ })
+ }
+ return
+ }
+
+ if (!family) return
+ const { viaCatchAll } = resolveEditor(indices, family, cls)
+ if (!viaCatchAll) return
+
+ const catchAll = indices.catchAllByFamily.get(family)
+ const sharedCount = catchAll?.fields.length ?? 0
+
+ issues.push({
+ severity: 'warning',
+ path: [...path, 'Class'],
+ title: `No dedicated editor for ${cls}`,
+ detail:
+ sharedCount === 0
+ ? `Nothing in the ${family} family will apply to it: the shared field set is empty, so only Class and Mode do anything.`
+ : `Only the ${sharedCount} shared ${family} fields will apply. Class-specific fields will be ignored.`,
+ })
+}
+
+function checkSingleObjectSlot(child: Node, key: string, path: Path, issues: Issue[]): void {
+ const cls = child['Class']
+ const hasClass = typeof cls === 'string' && cls.trim() !== ''
+ const otherKeys = configKeys(child).filter((k) => k !== 'Class')
+
+ if (!hasClass && otherKeys.length > 0) {
+ issues.push({
+ severity: 'warning',
+ path,
+ title: `${key} edits whatever is already there`,
+ detail: `With no Class this edits the ability’s existing ${key} in place — and does nothing at all, silently, if the ability has none. Name a Class to create one.`,
+ })
+ return
+ }
+
+ if (hasClass && otherKeys.length > 0) {
+ issues.push({
+ severity: 'warning',
+ path,
+ title: `${key} may be replaced rather than edited`,
+ detail: `If the ability’s current ${key} is not exactly ${String(cls)}, it is replaced by a fresh instance — and every field you have not set here reverts to that class’s defaults, not the ability’s old values.`,
+ })
+ }
+}
+
+function checkRemoveCharges(
+ node: Node,
+ structName: string,
+ path: Path,
+ keys: string[],
+ issues: Issue[],
+): void {
+ if (structName !== 'ChargesEdit') return
+ if (node['RemoveCharges'] !== true) return
+
+ const others = keys.filter((k) => k !== 'RemoveCharges')
+ if (others.length === 0) return
+
+ issues.push({
+ severity: 'warning',
+ path,
+ title: 'RemoveCharges ignores the rest of this block',
+ detail: `RemoveCharges=true removes the charges object and returns immediately, so ${others.join(', ')} will have no effect.`,
+ })
+}
diff --git a/app/tsconfig.json b/app/tsconfig.json
new file mode 100644
index 0000000..4512f45
--- /dev/null
+++ b/app/tsconfig.json
@@ -0,0 +1,22 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "jsx": "react-jsx",
+ "types": ["vite/client", "node"],
+
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true,
+ "skipLibCheck": true,
+ "noEmit": true
+ },
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/app/vite.config.ts b/app/vite.config.ts
new file mode 100644
index 0000000..0d4b992
--- /dev/null
+++ b/app/vite.config.ts
@@ -0,0 +1,54 @@
+import { readFileSync } from 'node:fs'
+import { resolve } from 'node:path'
+
+import react from '@vitejs/plugin-react'
+import { defineConfig, type Plugin } from 'vite'
+
+const BASE = '/AbilityEditor/app/'
+
+function serveSchemaInDev(): Plugin {
+ const schemaPath = resolve(import.meta.dirname, '..', 'docs', 'schema.json')
+
+ const servedPaths = new Set([
+ new URL('../schema.json', `http://localhost${BASE}`).pathname,
+ '/schema.json',
+ ])
+
+ return {
+ name: 'ae-serve-schema-dev',
+ apply: 'serve',
+ configureServer(server) {
+ server.middlewares.use((req, res, next) => {
+ if (!req.url || !servedPaths.has(new URL(req.url, 'http://localhost').pathname)) {
+ return next()
+ }
+ try {
+ res.setHeader('Content-Type', 'application/json')
+ res.setHeader('Cache-Control', 'no-store')
+ res.end(readFileSync(schemaPath))
+ } catch (error) {
+ res.statusCode = 404
+ res.end(
+ JSON.stringify({
+ error: `Could not read ${schemaPath}. Run .scripts/generate-docs.ps1 first.`,
+ detail: String(error),
+ }),
+ )
+ }
+ })
+ },
+ }
+}
+
+export default defineConfig({
+ base: BASE,
+ plugins: [react(), serveSchemaInDev()],
+ build: {
+ outDir: 'dist',
+ emptyOutDir: true,
+ sourcemap: true,
+ },
+ server: {
+ watch: { usePolling: true, interval: 300 },
+ },
+})
diff --git a/docs/app/index.md b/docs/app/index.md
new file mode 100644
index 0000000..0060e5a
--- /dev/null
+++ b/docs/app/index.md
@@ -0,0 +1,21 @@
+---
+search:
+ exclude: true
+---
+
+# Config builder
+
+!!! info "How to run locally"
+
+ ```bash
+ cd app
+ npm install
+ npm run dev
+ ```
+
+ That serves the builder at , reading
+ `docs/schema.json` straight from the working tree — so it always reflects whatever
+ `.scripts/generate-docs.ps1` last produced.
+
+The published version lives at
+[boundir.github.io/AbilityEditor/app/](https://boundir.github.io/AbilityEditor/app/).
diff --git a/docs/index.md b/docs/index.md
index 9e81e3f..5edd21a 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -25,10 +25,17 @@ Letting a Ranger keep acting after Slash is just:
)
```
+!!! tip "Build it without hand-writing the .ini"
+ The **[config builder](app/index.md)** turns the reference below into a form: pick an ability, pick a
+ class, tick the fields you want. It writes the entry for you and flags the mistakes the mod
+ itself never reports - a missing `Set` guard, or a `Mode` left at its default where that
+ quietly deletes the ability's existing costs.
+
## Start here
+- **[Config builder](app/index.md)** - fill in a form, get a valid entry.
- **[Install](getting-started/installation.md)** - get the mod running and find your config file.
- **[Config syntax](getting-started/config-syntax.md)** - how an entry is put together, and the
`Set` guard rule that trips up everyone once.
diff --git a/mkdocs.yml b/mkdocs.yml
index 018476a..3ffd55e 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -61,6 +61,7 @@ markdown_extensions:
nav:
- Home: index.md
+ - Config builder: app/index.md
- Getting started:
- getting-started/installation.md
- getting-started/config-syntax.md