From 676a20ae3b64a29e5643ca7be93ff89937146630 Mon Sep 17 00:00:00 2001 From: Ericran <6282173+Ericran@users.noreply.github.com> Date: Thu, 3 Sep 2026 12:16:47 -0400 Subject: [PATCH 1/2] Add table groups for repeated data, and use them for two games Two config shapes don't fit the form's one-address-one-value contract, and both are the part of their file people most want to see: - Dragonwilds' KnownPlayerList is a repeated INI key, one Unreal struct literal per player. getRaw resolves a repeated key to its LAST occurrence - right for a scalar that appears twice, useless for a list - so the roster was invisible bar one row. - Enshrouded's userGroups is a JSON array of role objects holding the passwords and permission flags, which renders today as one generic group per index. Adds an optional `table` to Group. A field maps one address to one scalar model, and table rows are neither, so bending FieldDef to fit would have meant changing the form contract for one shape; a group can simply carry a table instead of fields, and every existing path is untouched. TableSpec is a union of the two row sources, and the distinction is not cosmetic - it is exactly what decides whether a table can be edited: - `struct-rows` reads every occurrence of one address via the new ConfigDoc.getAllRaw(), parsing each with a small Unreal struct-literal parser. No cell is separately addressable, so these are READ-ONLY. - `array-rows` addresses each cell as `path..`, which the array-expanding JSON format already reads and writes. Cells therefore bind to ordinary field models and inherit the codec, the JSON type coercion, the write-error reporting and the dirty flag with no new write path at all. Neither mode adds or removes rows: json.ts will not write through a missing index, and for Dragonwilds the server would overwrite an invented row anyway. The Dragonwilds table is read-only for a second reason beyond addressability - the server owns that list and rewrites the whole file on shutdown, which is why the entry sets stopWarning. That is the same call this plugin already makes for Minecraft's ops.json and whitelist.json. One caveat, stated plainly: the KnownPlayerList line format is not documented anywhere public. It is inferred from Unreal's conventions, so the parser is deliberately tolerant (quotes optional, field order irrelevant, unknown fields kept) and fails VISIBLY - a line it does not recognise is printed verbatim under the table rather than dropped, so a wrong guess looks like unexpected text instead of a missing player. Co-Authored-By: Claude Opus 5 --- README.md | 44 ++++- frontend/src/components/ConfigEditor.vue | 13 +- frontend/src/components/ConfigTable.test.ts | 185 ++++++++++++++++++ frontend/src/components/ConfigTable.vue | 138 +++++++++++++ frontend/src/composables/useConfigForm.ts | 79 +++++++- frontend/src/formats/ini.ts | 11 +- frontend/src/formats/types.ts | 64 ++++++ frontend/src/formats/unrealStruct.test.ts | 70 +++++++ frontend/src/formats/unrealStruct.ts | 111 +++++++++++ frontend/src/games/fields.ts | 2 + frontend/src/games/registry.test.ts | 22 +++ .../src/games/schemas/dragonwilds.test.ts | 22 +++ frontend/src/games/schemas/dragonwilds.ts | 29 +++ frontend/src/games/schemas/enshrouded.ts | 39 +++- frontend/src/icons.ts | 1 + frontend/src/styles.css | 53 +++++ 16 files changed, 867 insertions(+), 16 deletions(-) create mode 100644 frontend/src/components/ConfigTable.test.ts create mode 100644 frontend/src/components/ConfigTable.vue create mode 100644 frontend/src/formats/unrealStruct.test.ts create mode 100644 frontend/src/formats/unrealStruct.ts diff --git a/README.md b/README.md index 9d214fb..174b16c 100644 --- a/README.md +++ b/README.md @@ -165,10 +165,17 @@ generic editor rather than labelling a Bedrock config with Java's fields. `enshrouded_server.json` keeps its access control in a `userGroups` array - one object per role, each with a password and five permission flags, and the password a player types decides which role they join as. That array is the part hosts -edit most, so this file is parsed with the array-walking JSON format (the one -Minecraft's player lists use) rather than the plain one: each role becomes its -own `userGroups[0]`, `userGroups[1]` group of typed fields instead of a single -input holding the whole array as one line of JSON. +edit most, so it gets an **editable table**: one row per role, one column per +field, permission flags as toggles. + +That works because the file is parsed with the array-walking JSON format (the +one Minecraft's player lists use) rather than the plain one. Every cell - +`userGroups.0.password` - is a real address the format reads and writes, so the +table binds to ordinary field models and gets the codec, the JSON type coercion +(`reservedSlots` stays a number, `canKickBan` stays a boolean), the write-error +reporting and the dirty flag for free. Rows can be edited but not added or +removed: `json.ts` refuses to write through a missing index, so the form cannot +grow a list, and adding a role stays a job for the plain editor. The trade-off is at the other end. An *empty* array contributes no addresses, so `tags` and the ban list are invisible on a fresh server - they round-trip @@ -180,6 +187,35 @@ Everything under `gameSettings` is only read when `gameSettingsPreset` is ignores the file's. The editor says so in a banner, because the edit otherwise saves cleanly and changes nothing. +### The Dragonwilds players table + +`KnownPlayerList` is a *repeated* key - the server appends one line per player +who has entered the admin password, each an Unreal struct literal: + +``` +KnownPlayerList=(UserId="0002-...",UserName="...",Privileges=2,LastAdminPassword="...",bIsBanned=False) +``` + +`getRaw` resolves a repeated key to its **last** occurrence, which is right for +a scalar that appears twice and useless for a list. So `ConfigDoc` gained +`getAllRaw()`, and a Group can carry a `TableSpec` instead of fields: one row +per occurrence, columns mapped from the struct's fields, `bIsBanned` as a +checkmark. A table hangs off the *group* rather than being another `FType` +because a field maps one address to one scalar model, and these rows are +neither. + +It is **read-only**, deliberately. The server owns the list and rewrites the +whole file on shutdown - the same reason the entry sets `stopWarning` - so an +edit here races the process that wrote it. Bans belong in the in-game Server +Management screen. That is the same call this plugin already makes for +Minecraft's `ops.json` and `whitelist.json`. + +The line format is not documented anywhere public; it is inferred from Unreal's +conventions. The parser is therefore tolerant (quotes optional, field order +irrelevant, unknown fields kept) and fails *visibly*: a line it does not +recognise is printed verbatim under the table rather than dropped, so an +unexpected format looks like unexpected text instead of a missing player. + ### The Dragonwilds section header `DedicatedServer.ini` is an ordinary Unreal INI, with one trap. Jagex's guide diff --git a/frontend/src/components/ConfigEditor.vue b/frontend/src/components/ConfigEditor.vue index 8b86168..3ee275f 100644 --- a/frontend/src/components/ConfigEditor.vue +++ b/frontend/src/components/ConfigEditor.vue @@ -5,6 +5,7 @@ import { NAlert, NDivider, NForm, NFormItem } from 'naive-ui'; import type { FileEditorProps, ServerData } from '@gameap/plugin-sdk'; import { useServer } from '@gameap/plugin-sdk'; import FieldInput from './FieldInput.vue'; +import ConfigTable from './ConfigTable.vue'; import { useConfigForm } from '../composables/useConfigForm'; import { resolve, type GameConfig } from '../games/registry'; @@ -193,7 +194,7 @@ const note = game?.note; Keys not in the schema - edited as raw values, preserved verbatim.

-
+
diff --git a/frontend/src/components/ConfigTable.test.ts b/frontend/src/components/ConfigTable.test.ts new file mode 100644 index 0000000..c91ca51 --- /dev/null +++ b/frontend/src/components/ConfigTable.test.ts @@ -0,0 +1,185 @@ +// @vitest-environment jsdom +import { mount } from '@vue/test-utils'; +import { describe, expect, it } from 'vitest'; +import ConfigTable from './ConfigTable.vue'; +import { addr } from '../formats/shared'; +import { useConfigForm } from '../composables/useConfigForm'; +import { resolve } from '../games/registry'; +import type { TableSpec } from '../formats/types'; + +const SECTION = '/Script/Dominion.DedicatedServerSettings'; +const ADDRESS = addr(SECTION, 'KnownPlayerList'); + +const spec = (): TableSpec => + resolve('rsdw', 'DedicatedServer.ini')!.schema!.find((g) => g.id === 'players')!.table!; + +/** Parse a real INI through the game's own format, as ConfigEditor does. */ +function docFor(lines: string[]) { + const text = ['[/script/dominion.dedicatedserversettings]', 'ServerName=x', ...lines, ''].join('\n'); + return resolve('rsdw', 'DedicatedServer.ini')!.format.parse(text)!; +} + +const player = (id: string, name: string, priv: string, pw: string, banned: string) => + `KnownPlayerList=(UserId="${id}",UserName="${name}",Privileges=${priv},LastAdminPassword="${pw}",bIsBanned=${banned})`; + +describe('ConfigTable (struct rows, read-only)', () => { + it('renders one row per entry, with the mapped columns in order', () => { + const doc = docFor([ + player('0002-1111', 'Ann', '2', 'pw1', 'False'), + player('0002-2222', 'Bob', '0', 'pw2', 'True'), + ]); + const wrapper = mount(ConfigTable, { props: { spec: spec(), doc } }); + + expect(wrapper.findAll('thead th').map((th) => th.text())).toEqual([ + '#', + 'User ID', + 'User Name', + 'Privileges', + 'Last Admin Password', + 'Is Banned', + ]); + + const rows = wrapper.findAll('tbody tr'); + expect(rows).toHaveLength(2); + // Column 0 is the row number, so the mapped columns start at 1. + expect(rows[0].findAll('td').slice(0, 5).map((td) => td.text())).toEqual([ + '1', + '0002-1111', + 'Ann', + '2', + 'pw1', + ]); + expect(rows[1].findAll('td')[2].text()).toBe('Bob'); + }); + + it('shows Is Banned as a checkmark, present only when banned', () => { + const doc = docFor([ + player('1', 'Ann', '2', 'pw', 'False'), + player('2', 'Bob', '0', 'pw', 'True'), + ]); + const rows = mount(ConfigTable, { props: { spec: spec(), doc } }).findAll('tbody tr'); + expect(rows[0].find('td:last-child [data-icon="check"]').exists()).toBe(false); + expect(rows[1].find('td:last-child [data-icon="check"]').exists()).toBe(true); + }); + + it('says so when the file has no entries yet', () => { + const wrapper = mount(ConfigTable, { props: { spec: spec(), doc: docFor([]) } }); + expect(wrapper.find('table').exists()).toBe(false); + expect(wrapper.text()).toMatch(/admin password/i); + }); + + it('shows an unrecognised entry verbatim instead of dropping it', () => { + // The line format is inferred, not documented, so a mismatch has to be + // visible - a missing player would look like the server lost one. + const doc = docFor([player('1', 'Ann', '2', 'pw', 'False'), 'KnownPlayerList=something-else']); + const wrapper = mount(ConfigTable, { props: { spec: spec(), doc } }); + expect(wrapper.findAll('tbody tr')).toHaveLength(1); + expect(wrapper.text()).toContain('did not match the expected format'); + expect(wrapper.find('pre').text()).toBe('something-else'); + }); + + it('reads every occurrence, not the last one getRaw would give', () => { + const doc = docFor([ + player('1', 'Ann', '2', 'pw', 'False'), + player('2', 'Bob', '0', 'pw', 'False'), + player('3', 'Cat', '0', 'pw', 'False'), + ]); + expect(doc.getRaw(ADDRESS)).toContain('Cat'); // last only + expect(mount(ConfigTable, { props: { spec: spec(), doc } }).findAll('tbody tr')).toHaveLength(3); + }); +}); + +describe('ConfigTable (array rows, editable)', () => { + const game = () => resolve('enshrouded', 'enshrouded_server.json')!; + const usergroups = () => game().schema!.find((g) => g.id === 'usergroups')!.table!; + + const groupJson = (name: string, password: string, kickBan: boolean, slots: number) => + `{ "name": "${name}", "password": "${password}", "canKickBan": ${kickBan}, ` + + `"canAccessInventories": true, "canEditWorld": true, "canEditBase": true, ` + + `"canExtendBase": false, "reservedSlots": ${slots} }`; + + /** Mount the table over a real config, wired to a real form - as ConfigEditor does. */ + function open(groups: string[]) { + const g = game(); + const json = `{\n "name": "S",\n "userGroups": [${groups.join(',')}]\n}\n`; + const doc = g.format.parse(json)!; + const form = useConfigForm(doc, g.schema!, g.format.codec); + const wrapper = mount(ConfigTable, { + props: { spec: usergroups(), doc, models: form.models }, + }); + return { doc, form, wrapper }; + } + + it('renders one row per user group, with a control in every cell', () => { + const { wrapper } = open([groupJson('Admin', 'a', true, 2), groupJson('Guest', 'g', false, 0)]); + + expect(wrapper.findAll('thead th').map((th) => th.text())).toEqual([ + '#', + 'Name', + 'Password', + 'Kick / Ban', + 'Inventories', + 'Edit World', + 'Edit Base', + 'Extend Base', + 'Reserved Slots', + ]); + + const rows = wrapper.findAll('tbody tr'); + expect(rows).toHaveLength(2); + // Unlike the read-only table, every cell is an input bound to the doc. + expect(rows[0].findAll('input')).toHaveLength(8); + expect((rows[0].findAll('input')[0].element as HTMLInputElement).value).toBe('Admin'); + expect((rows[1].findAll('input')[0].element as HTMLInputElement).value).toBe('Guest'); + }); + + it('writes an edited cell back with the right JSON type', async () => { + const { doc, form, wrapper } = open([groupJson('Admin', 'a', true, 2)]); + const inputs = wrapper.findAll('tbody tr')[0].findAll('input'); + + await inputs[1].setValue('new-password'); // Password (text) + await inputs[2].setValue(false); // Kick / Ban (bool toggle) + await inputs[7].setValue('5'); // Reserved Slots (number) + + expect(form.writeError.value).toBeNull(); + expect(form.dirty.value).toBe(true); + + const out = JSON.parse(doc.serialize()); + expect(out.userGroups[0].password).toBe('new-password'); + expect(out.userGroups[0].canKickBan).toBe(false); + expect(out.userGroups[0].reservedSlots).toBe(5); + // The types the file came with have to survive - a quoted number or a + // stringified bool is a config the game rejects. + expect(typeof out.userGroups[0].canKickBan).toBe('boolean'); + expect(typeof out.userGroups[0].reservedSlots).toBe('number'); + // Untouched cells and untouched groups are unchanged. + expect(out.userGroups[0].name).toBe('Admin'); + expect(out.userGroups[0].canEditWorld).toBe(true); + }); + + it('edits the right row when several groups share a column', async () => { + // Cell addresses are per-row, so row 2's password must not land on row 1. + const { doc, wrapper } = open([groupJson('Admin', 'a', true, 0), groupJson('Guest', 'g', false, 0)]); + await wrapper.findAll('tbody tr')[1].findAll('input')[1].setValue('guest-pw'); + + const out = JSON.parse(doc.serialize()); + expect(out.userGroups[0].password).toBe('a'); + expect(out.userGroups[1].password).toBe('guest-pw'); + }); + + it('puts a checkbox in a bool column, bound to that cell', () => { + // How a bool is *presented* is FieldInput's business and is tested + // there; the table's job is to put the right control in the cell and + // bind it to the right address. + const { wrapper } = open([groupJson('Admin', 'a', true, 0)]); + const kickBan = wrapper.findAll('tbody tr')[0].findAll('input')[2]; + expect(kickBan.attributes('type')).toBe('checkbox'); + expect((kickBan.element as HTMLInputElement).checked).toBe(true); + }); + + it('explains itself when the file defines no user groups', () => { + const { wrapper } = open([]); + expect(wrapper.find('table').exists()).toBe(false); + expect(wrapper.text()).toMatch(/no user groups/i); + }); +}); diff --git a/frontend/src/components/ConfigTable.vue b/frontend/src/components/ConfigTable.vue new file mode 100644 index 0000000..e39679a --- /dev/null +++ b/frontend/src/components/ConfigTable.vue @@ -0,0 +1,138 @@ + + + diff --git a/frontend/src/composables/useConfigForm.ts b/frontend/src/composables/useConfigForm.ts index a0e239b..872e439 100644 --- a/frontend/src/composables/useConfigForm.ts +++ b/frontend/src/composables/useConfigForm.ts @@ -9,6 +9,39 @@ */ import { computed, ref, type WritableComputedRef } from 'vue'; import type { Codec, ConfigDoc, ConfigValue, FieldDef, FType, Group, Schema } from '../formats/types'; +import { escapeSegment, splitAddress } from '../formats/shared'; + +/** Is `address` inside the array/object at `path` (not the path itself)? */ +function isUnder(address: string, path: string): boolean { + const prefix = splitAddress(path); + const parts = splitAddress(address); + return parts.length > prefix.length && prefix.every((p, i) => parts[i] === p); +} + +/** + * The row indices an array-backed table actually has, ascending. + * + * Read from the document rather than assumed: an array may be empty, and the + * form must never invent a row the file doesn't have - writing to + * `userGroups.3.password` when there are three groups would be rejected by the + * format anyway (it won't grow a list), and would render an input that silently + * does nothing. + */ +export function arrayTableRows(doc: ConfigDoc, path: string): number[] { + const prefix = splitAddress(path); + const seen = new Set(); + for (const key of doc.keys()) { + if (!isUnder(key, path)) continue; + const index = Number(splitAddress(key)[prefix.length]); + if (Number.isInteger(index) && index >= 0) seen.add(index); + } + return [...seen].sort((a, b) => a - b); +} + +/** Address of one cell. The single spelling both the form and the table use. */ +export function cellAddress(path: string, row: number, key: string): string { + return `${path}.${row}.${escapeSegment(key)}`; +} /** * Guess a widget for a key the schema doesn't describe. Anything that isn't @@ -30,11 +63,22 @@ export function inferType(raw: string): FType { */ export function inferGroups(doc: ConfigDoc, schema: Schema): Group[] { const norm = doc.normKey ? (a: string) => doc.normKey!(a) : (a: string) => a; - const known = new Set(schema.flatMap((g) => g.fields.map((f) => norm(f.key)))); + // A struct table's address counts as covered too, or the repeated key it + // renders as rows would ALSO show up here as a lone raw field holding its + // last line. + const known = new Set([ + ...schema.flatMap((g) => g.fields.map((f) => norm(f.key))), + ...schema.flatMap((g) => (g.table?.kind === 'struct-rows' ? [norm(g.table.address)] : [])), + ]); + // An array table covers a whole subtree, not one address: every + // `userGroups.0.password` under it is rendered as a cell, so listing them + // again as loose fields would show the same value twice in two places. + const tablePaths = schema.flatMap((g) => (g.table?.kind === 'array-rows' ? [g.table.path] : [])); const bySection = new Map(); for (const key of doc.keys()) { if (known.has(norm(key))) continue; + if (tablePaths.some((path) => isUnder(key, path))) continue; const section = doc.sectionOf(key); const fields = bySection.get(section) ?? []; fields.push({ key, label: doc.labelOf(key), type: inferType(doc.getRaw(key) ?? '') }); @@ -73,10 +117,39 @@ export function useConfigForm(doc: ConfigDoc, schema: Schema, codec: Codec) { } const inferred = inferGroups(doc, schema); - const groups = computed(() => [...schema.filter((g) => g.fields.length), ...inferred]); + // A table-only group has no fields but plenty to render, so it must survive + // the empty-group filter. + const groups = computed(() => [ + ...schema.filter((g) => g.fields.length || g.table), + ...inferred, + ]); + + /** + * Cells of every array-backed table, as ordinary fields. + * + * Deliberately not a separate write path: a cell address is a real address, + * so routing it through the same models gives it the format's type coercion + * (a `reservedSlots` of 2 stays a JSON number), the same writeError + * reporting, and the same dirty flag - for free, and without the editor + * having to know a table is involved. + */ + const cells: FieldDef[] = []; + for (const group of schema) { + if (group.table?.kind !== 'array-rows') continue; + const { path, columns } = group.table; + for (const row of arrayTableRows(doc, path)) { + for (const col of columns) { + cells.push({ + key: cellAddress(path, row, col.key), + label: `${col.label} (row ${row + 1})`, + type: col.type ?? 'text', + }); + } + } + } const models: Record> = {}; - for (const group of [...schema, ...inferred]) { + for (const group of [...schema, ...inferred, { fields: cells } as Group]) { for (const f of group.fields) { // Two groups may name the same key; they then share one model. if (models[f.key]) continue; diff --git a/frontend/src/formats/ini.ts b/frontend/src/formats/ini.ts index 229541d..fcebe25 100644 --- a/frontend/src/formats/ini.ts +++ b/frontend/src/formats/ini.ts @@ -43,10 +43,15 @@ export function makeIniFormat(id: string, opts: IniOptions = {}): Format { const lines: Line[] = rawLines.map((t) => ({ text: t })); const table = orderedTable(norm); // address -> line index (last occurrence) const sectionLast: Record = {}; // norm(section) -> last line index inside it + // norm(address) -> EVERY line index, in file order. `table` keeps only the + // last, which is right for a repeated scalar; a repeated key used as a + // list (Unreal's KnownPlayerList) needs all of them. + const allLines: Record = Object.create(null); const reindex = () => { table.clear(); for (const k of Object.keys(sectionLast)) delete sectionLast[k]; + for (const k of Object.keys(allLines)) delete allLines[k]; let cur = ''; lines.forEach((line, i) => { const sm = line.text.match(SECTION); @@ -69,7 +74,9 @@ export function makeIniFormat(id: string, opts: IniOptions = {}): Format { } const key = m[1].trim(); line.key = key; - table.set(addr(cur, key), i); + const address = addr(cur, key); + table.set(address, i); + (allLines[norm(address)] ??= []).push(i); sectionLast[norm(cur)] = i; }); }; @@ -86,6 +93,8 @@ export function makeIniFormat(id: string, opts: IniOptions = {}): Format { const m = lines[i].text.match(KV); return m ? m[2] : undefined; }, + getAllRaw: (a) => + (allLines[norm(a)] ?? []).map((i) => lines[i].text.match(KV)?.[2] ?? ''), setRaw: (a, val) => { const i = table.get(a); if (i !== undefined) { diff --git a/frontend/src/formats/types.ts b/frontend/src/formats/types.ts index 5a31f7d..bdf4a72 100644 --- a/frontend/src/formats/types.ts +++ b/frontend/src/formats/types.ts @@ -33,12 +33,65 @@ export interface FieldDef { help?: string; } +/** One column of a table. `key` names a field within each row. */ +export interface TableColumn { + key: string; + label: string; + /** Widget and coercion for the cell. Defaults to 'text'. */ + type?: FType; +} + +/** + * Rows are repeated occurrences of ONE address whose value is an Unreal struct + * literal: `KnownPlayerList=(UserId="..",bIsBanned=False)`, one line per row. + * + * Read-only. A cell here is not addressable on its own - the whole struct is a + * single value - so editing one would mean patching a substring of a line the + * running server also writes. + */ +export interface StructRowTable { + kind: 'struct-rows'; + /** Address of the repeated key, e.g. addr(section, 'KnownPlayerList'). */ + address: string; + columns: TableColumn[]; + /** Shown when the key appears nowhere in the file. */ + empty?: string; +} + +/** + * Rows are the elements of an ARRAY, addressed by index: `path.0.name`, + * `path.1.name`, ... Each cell is therefore a real address the format already + * reads and writes, so these tables are editable through the ordinary model + * path - same codec, same type coercion, same dirty tracking as any field. + * + * Needs a format that walks into arrays (json.ts in `arrays: 'expand'` mode). + */ +export interface ArrayRowTable { + kind: 'array-rows'; + /** Dotted path of the array itself, e.g. 'userGroups'. */ + path: string; + columns: TableColumn[]; + /** Shown when the array is absent or empty. */ + empty?: string; +} + +/** + * A table hangs off a Group rather than being another `FType` on purpose. A + * field maps one address to one scalar model; table rows are neither (many + * values, each a record), so modelling them as a field would bend the whole + * form contract for one shape. A group can simply carry a table instead of + * fields, and every existing code path is untouched. + */ +export type TableSpec = StructRowTable | ArrayRowTable; + export interface Group { id: string; title: string; /** GIcon registry name (see src/icons.ts). */ icon: IconName; fields: FieldDef[]; + /** Renders below this group's fields; a group may have a table and no fields. */ + table?: TableSpec; } /** A curated, human-labelled schema is just an ordered list of groups. */ @@ -59,6 +112,17 @@ export interface ConfigDoc { keys(): string[]; has(address: string): boolean; getRaw(address: string): string | undefined; + /** + * EVERY raw value for an address, in file order. + * + * `getRaw` deliberately returns the last occurrence of a repeated key - + * the one the game reads for a scalar setting. But Unreal also uses a + * repeated key to express a LIST (one `KnownPlayerList=(...)` line per + * player), and for those the earlier lines are the data, not shadowed + * duplicates. Formats that can't repeat a key may omit this; callers fall + * back to `getRaw`. + */ + getAllRaw?(address: string): string[]; /** * Set (creating the entry if absent). `typeHint` lets formats whose raw * spelling does not carry enough information choose the right on-disk type diff --git a/frontend/src/formats/unrealStruct.test.ts b/frontend/src/formats/unrealStruct.test.ts new file mode 100644 index 0000000..f5b9d79 --- /dev/null +++ b/frontend/src/formats/unrealStruct.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { isStructTrue, parseUnrealStruct, structField } from './unrealStruct'; + +const ROW = + '(UserId="0002-1234-5678",UserName="Test Player",Privileges=2,LastAdminPassword="hunter2",bIsBanned=False)'; + +describe('parseUnrealStruct', () => { + it('parses a player row into its fields, unquoting strings', () => { + expect(parseUnrealStruct(ROW)).toEqual({ + UserId: '0002-1234-5678', + UserName: 'Test Player', + Privileges: '2', + LastAdminPassword: 'hunter2', + bIsBanned: 'False', + }); + }); + + it('keeps a comma inside a quoted value in that value', () => { + // The failure this prevents is quiet and ugly: a naive split(',') would + // tear the name in two and shift every later column one place left, so + // the table would show a password under "Privileges". + const parsed = parseUnrealStruct('(UserName="Smith, John",Privileges=1)')!; + expect(parsed.UserName).toBe('Smith, John'); + expect(parsed.Privileges).toBe('1'); + }); + + it('handles escaped quotes, nested structs and odd spacing', () => { + expect(parseUnrealStruct('(UserName="He said \\"hi\\"",Meta=(A=1,B=2), Privileges = 3 )')).toEqual({ + UserName: 'He said "hi"', + Meta: '(A=1,B=2)', + Privileges: '3', + }); + }); + + it('tolerates unknown and missing fields rather than failing', () => { + // The exact field set isn't documented, so a newer server adding a + // column must not blank the whole row. + const parsed = parseUnrealStruct('(UserId="1",SomethingNew="x")')!; + expect(parsed.UserId).toBe('1'); + expect(parsed.SomethingNew).toBe('x'); + expect(parsed.UserName).toBeUndefined(); + }); + + it('returns null for anything that is not a struct literal', () => { + // Null means "show the raw line", which is the difference between not + // understanding a line and silently dropping a player. + for (const raw of ['', 'PlainValue', '"quoted"', '(no pairs here)', '(=1)', 'UserId="1"']) { + expect(parseUnrealStruct(raw), raw).toBeNull(); + } + }); +}); + +describe('structField', () => { + it('finds a field however the file capitalises it', () => { + // Same reason the INI section is matched case-blind: Unreal is not + // consistent between what it declares and what it writes. + const parsed = parseUnrealStruct('(userid="1",BISBANNED=True)')!; + expect(structField(parsed, 'UserId')).toBe('1'); + expect(structField(parsed, 'bIsBanned')).toBe('True'); + expect(structField(parsed, 'Missing')).toBeUndefined(); + }); +}); + +describe('isStructTrue', () => { + it('accepts every spelling Unreal uses for true', () => { + for (const v of ['True', 'true', 'TRUE', '1', 'yes', 'on']) expect(isStructTrue(v), v).toBe(true); + for (const v of ['False', 'false', '0', 'no', '', 'maybe']) expect(isStructTrue(v), v).toBe(false); + expect(isStructTrue(undefined)).toBe(false); + }); +}); diff --git a/frontend/src/formats/unrealStruct.ts b/frontend/src/formats/unrealStruct.ts new file mode 100644 index 0000000..80cd13c --- /dev/null +++ b/frontend/src/formats/unrealStruct.ts @@ -0,0 +1,111 @@ +/** + * Unreal struct-literal values: `(UserId="abc",Privileges=2,bIsBanned=False)`. + * + * Unreal serialises a struct-valued config property as a parenthesised list of + * `Key=Value` pairs, and a LIST of structs as the same key repeated once per + * element. Dragonwilds writes its player roster that way + * (`KnownPlayerList=(...)` per player), which is the only reason this exists. + * + * Parsing is deliberately tolerant, because the exact spelling is not + * documented anywhere public - it is inferred from Unreal's own conventions. + * So: quotes optional, whitespace optional, field order irrelevant, unknown + * fields kept, and anything that doesn't look like a struct returns null rather + * than a half-parsed record. A caller that gets null shows the raw text instead, + * which is the difference between "we don't understand this line" and "we + * silently dropped your data". + * + * Read-only: nothing here writes. The game owns this list and rewrites the file + * on shutdown, so the editor reports it rather than competing for it. + */ + +/** Strip one layer of double quotes and undo backslash escapes, if present. */ +function unquote(value: string): string { + const m = value.match(/^"([\s\S]*)"$/); + return m ? m[1].replace(/\\(["\\])/g, '$1') : value; +} + +/** + * Split on top-level commas: ones outside quotes and outside any nested + * parens/brackets. A naive `split(',')` would tear `UserName="Smith, J"` in two + * and shift every later column one place left. + */ +function splitTopLevel(body: string): string[] { + const parts: string[] = []; + let part = ''; + let depth = 0; + let inQuote = false; + let escaped = false; + + for (const c of body) { + if (escaped) { + part += c; + escaped = false; + continue; + } + if (inQuote) { + if (c === '\\') escaped = true; + else if (c === '"') inQuote = false; + part += c; + continue; + } + if (c === '"') inQuote = true; + else if (c === '(' || c === '[') depth++; + else if (c === ')' || c === ']') depth--; + else if (c === ',' && depth === 0) { + parts.push(part); + part = ''; + continue; + } + part += c; + } + parts.push(part); + return parts; +} + +/** + * Parse one struct literal into its fields, or null if `raw` isn't one. + * + * Keys are returned exactly as written; use {@link structField} to read one + * without having to match the file's capitalisation. + */ +export function parseUnrealStruct(raw: string): Record | null { + const text = raw.trim(); + if (!text.startsWith('(') || !text.endsWith(')')) return null; + + const out: Record = {}; + let found = 0; + for (const item of splitTopLevel(text.slice(1, -1))) { + const eq = item.indexOf('='); + if (eq <= 0) continue; + const key = item.slice(0, eq).trim(); + // An identifier, or this isn't a Key=Value pair we should trust. + if (!/^[A-Za-z_]\w*$/.test(key)) continue; + out[key] = unquote(item.slice(eq + 1).trim()); + found++; + } + return found > 0 ? out : null; +} + +/** + * Read one field, matching the name case-insensitively. + * + * Unreal is inconsistent about case between what its headers declare and what + * it writes (the same reason this game's INI section is matched case-blind), so + * a schema asking for `bIsBanned` must still find `bisbanned`. + */ +export function structField( + fields: Record, + key: string, +): string | undefined { + if (key in fields) return fields[key]; + const wanted = key.toLowerCase(); + for (const [k, v] of Object.entries(fields)) { + if (k.toLowerCase() === wanted) return v; + } + return undefined; +} + +/** Unreal accepts several spellings for a config boolean; treat them all as true. */ +export function isStructTrue(value: string | undefined): boolean { + return value !== undefined && /^(true|1|yes|on)$/i.test(value.trim()); +} diff --git a/frontend/src/games/fields.ts b/frontend/src/games/fields.ts index 8139f62..d5b27d6 100644 --- a/frontend/src/games/fields.ts +++ b/frontend/src/games/fields.ts @@ -28,6 +28,8 @@ export function section(s: string) { b: (key: string, label: string) => b(addr(s, key), label), t: (key: string, label: string) => t(addr(s, key), label), sel: (key: string, label: string, options: string[]) => sel(addr(s, key), label, options), + /** The bare section-qualified address, for things that aren't fields (a TableSpec). */ + at: (key: string) => addr(s, key), }; } diff --git a/frontend/src/games/registry.test.ts b/frontend/src/games/registry.test.ts index f20f070..8ad4a3f 100644 --- a/frontend/src/games/registry.test.ts +++ b/frontend/src/games/registry.test.ts @@ -679,6 +679,28 @@ describe('RuneScape: Dragonwilds', () => { expect(g.format.parse(sample)!.getRaw(addr(SECTION, 'ServerGuid'))).toBe('A1B2C3D4'); }); + it('reads every KnownPlayerList line, not just the last one', () => { + // getRaw resolves a repeated key to its last occurrence - correct for a + // scalar, useless for a list. getAllRaw is what makes the roster visible. + const doc = dw().format.parse( + [ + '[/script/dominion.dedicatedserversettings]', + 'ServerName=x', + 'KnownPlayerList=(UserId="1",UserName="Ann",Privileges=2,LastAdminPassword="pw",bIsBanned=False)', + 'KnownPlayerList=(UserId="2",UserName="Bob",Privileges=0,LastAdminPassword="",bIsBanned=True)', + '', + ].join('\n'), + )!; + const all = doc.getAllRaw!(addr(SECTION, 'KnownPlayerList')); + expect(all).toHaveLength(2); + expect(all[0]).toContain('UserName="Ann"'); + expect(all[1]).toContain('UserName="Bob"'); + // The scalar accessor still behaves as before for everything else. + expect(doc.getRaw(addr(SECTION, 'ServerName'))).toBe('x'); + expect(doc.getAllRaw!(addr(SECTION, 'ServerName'))).toEqual(['x']); + expect(doc.getAllRaw!(addr(SECTION, 'Nope'))).toEqual([]); + }); + it('warns to stop the server first, and explains both platform folders', () => { const g = dw(); diff --git a/frontend/src/games/schemas/dragonwilds.test.ts b/frontend/src/games/schemas/dragonwilds.test.ts index 62641b9..7e223dc 100644 --- a/frontend/src/games/schemas/dragonwilds.test.ts +++ b/frontend/src/games/schemas/dragonwilds.test.ts @@ -66,6 +66,28 @@ describe('dragonwildsSchema', () => { expect(byKey.has(addr(SECTION, 'bCanSaveAllSections'))).toBe(false); }); + it('renders the player roster as a table, not as fields', () => { + // KnownPlayerList repeats once per player, so it is a list rather than a + // setting; a scalar field would show only the last line. + const players = dragonwildsSchema.find((g) => g.id === 'players')!; + expect(players.title).toBe('Players'); + // Same icon as Server / Identity, as asked. + expect(players.icon).toBe(dragonwildsSchema.find((g) => g.id === 'identity')!.icon); + expect(players.fields).toEqual([]); + // struct-rows, not array-rows: the rows are repeated INI lines, and the + // kind is what makes the table read-only rather than editable. + expect(players.table?.kind).toBe('struct-rows'); + if (players.table?.kind !== 'struct-rows') throw new Error('expected a struct-rows table'); + expect(players.table.address).toBe(addr(SECTION, 'KnownPlayerList')); + expect(players.table.columns).toEqual([ + { key: 'UserId', label: 'User ID' }, + { key: 'UserName', label: 'User Name' }, + { key: 'Privileges', label: 'Privileges' }, + { key: 'LastAdminPassword', label: 'Last Admin Password' }, + { key: 'bIsBanned', label: 'Is Banned', type: 'bool' }, + ]); + }); + it('has unique group ids and field keys', () => { expect(new Set(dragonwildsSchema.map((group) => group.id)).size).toBe(dragonwildsSchema.length); expect(new Set(fields.map((field) => field.key)).size).toBe(fields.length); diff --git a/frontend/src/games/schemas/dragonwilds.ts b/frontend/src/games/schemas/dragonwilds.ts index de9843e..cfb7621 100644 --- a/frontend/src/games/schemas/dragonwilds.ts +++ b/frontend/src/games/schemas/dragonwilds.ts @@ -19,6 +19,12 @@ * a `t` field like the rest because the form has no read-only text control; the * warning is the guard. * + * `KnownPlayerList` is a repeated key, one line per player who has used the + * admin password, each an Unreal struct literal. It renders as the Players table + * rather than as fields - see TableSpec in formats/types.ts for why that hangs + * off the group - and is read-only: the server appends to it and rewrites the + * file on shutdown, so bans belong in the in-game Server Management screen. + * * `bCanSaveAllSections` is Unreal's config-save bookkeeping rather than a game * setting, and it lives in `[SectionsToSave]`, but it is shown under Server / * Identity because it governs whether the server rewrites this file at all - @@ -63,4 +69,27 @@ export const dragonwildsSchema: Schema = [ s.t('WorldPassword', 'World password (empty = anyone can join)'), ], }, + { + id: 'players', + title: 'Players', + icon: 'id-card', + // No fields: this group is the table. One row per KnownPlayerList line. + fields: [], + table: { + // Repeated key, one Unreal struct literal per line - and read-only: + // a cell isn't separately addressable, and the server owns the list. + kind: 'struct-rows', + address: s.at('KnownPlayerList'), + columns: [ + { key: 'UserId', label: 'User ID' }, + { key: 'UserName', label: 'User Name' }, + { key: 'Privileges', label: 'Privileges' }, + { key: 'LastAdminPassword', label: 'Last Admin Password' }, + { key: 'bIsBanned', label: 'Is Banned', type: 'bool' }, + ], + empty: + 'No players recorded yet. The server adds an entry the first time someone enters the admin ' + + 'password on the Server Management screen.', + }, + }, ]; diff --git a/frontend/src/games/schemas/enshrouded.ts b/frontend/src/games/schemas/enshrouded.ts index 0a92abd..27c419f 100644 --- a/frontend/src/games/schemas/enshrouded.ts +++ b/frontend/src/games/schemas/enshrouded.ts @@ -11,14 +11,15 @@ * "Custom". The registry note says so, because editing those factors under any * other preset looks like it worked and changes nothing. * - * Deliberately left to the generic groups: + * `userGroups` is an array of roles - name, password, five permission flags and + * reserved slots - and the part hosts edit most, so it gets an editable table: + * one row per role, one column per field. It works because the registry parses + * this file with the array-expanding JSON format, which makes every cell + * (`userGroups.0.password`) a real address the form can read and write with the + * right JSON type. Rows can be edited but not added or removed; the format + * won't grow a list, and a half-built role is worse than none. * - * - `userGroups` - a variable-length array of roles (name, password, five - * permission flags, reserved slots). The registry parses this file with the - * array-expanding JSON format, so each role renders as its own - * `userGroups[N]` group with typed fields, which is what makes the passwords - * and permission flags editable at all - a curated schema cannot address - * slots that may or may not exist. + * Deliberately left to the generic groups: * * - `tags` and the ban list (`bannedAccounts`, `bans` on older servers) - arrays * the server owns. Existing entries are editable in place; adding one stays a @@ -78,6 +79,30 @@ export const enshroudedSchema: Schema = [ ]), ], }, + { + id: 'usergroups', + title: 'User Groups', + icon: 'users', + // No fields: this group is the table. One row per role in userGroups. + fields: [], + table: { + kind: 'array-rows', + path: 'userGroups', + columns: [ + { key: 'name', label: 'Name', type: 'text' }, + { key: 'password', label: 'Password', type: 'text' }, + { key: 'canKickBan', label: 'Kick / Ban', type: 'bool' }, + { key: 'canAccessInventories', label: 'Inventories', type: 'bool' }, + { key: 'canEditWorld', label: 'Edit World', type: 'bool' }, + { key: 'canEditBase', label: 'Edit Base', type: 'bool' }, + { key: 'canExtendBase', label: 'Extend Base', type: 'bool' }, + { key: 'reservedSlots', label: 'Reserved Slots', type: 'number' }, + ], + empty: + 'This file defines no user groups. A server that has never been started, or one still using the ' + + 'pre-Update-2 top-level password, has none - add them in the plain file editor.', + }, + }, { id: 'player', title: 'Player', diff --git a/frontend/src/icons.ts b/frontend/src/icons.ts index 3463b5f..acb945e 100644 --- a/frontend/src/icons.ts +++ b/frontend/src/icons.ts @@ -38,6 +38,7 @@ export const PLUGIN_ICONS = { // here, and registerPluginIcons() skips it once a panel ships it. 'box-open': 'fa-solid fa-box-open', 'bug': 'fa-solid fa-bug', + 'check': 'fa-solid fa-check', 'comment': 'fa-solid fa-comment', 'comment-slash': 'fa-solid fa-comment-slash', 'crosshairs': 'fa-solid fa-crosshairs', diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 70838ea..6d7412f 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -113,3 +113,56 @@ .gce-raw--modal .n-input__textarea-el { min-height: calc(var(--gameap-plugin-editor-height, calc(100vh - 250px)) - 7rem); } + +/* Table groups (a repeated key rendered one row per occurrence: Dragonwilds' + player roster, Enshrouded's user groups). A plain table rather than n-table - + nothing here needs a component's behaviour, and the panel's Tailwind build + does not ship the utilities it would take. Its own scroll container, because + a row of ids and passwords is wider than a tab and the page must never scroll + sideways. */ +.gce-table-scroll { + overflow-x: auto; + margin-bottom: 0.5rem; +} + +.gce-table { + width: 100%; + border-collapse: collapse; + font-size: 0.8125rem; +} + +.gce-table th, +.gce-table td { + padding: 0.375rem 0.5rem; + text-align: left; + vertical-align: middle; + white-space: nowrap; +} + +.gce-table th { + font-weight: 500; + color: var(--gameap-text-muted); + border-bottom: 1px solid var(--gameap-border); +} + +.gce-table tbody tr + tr td { + border-top: 1px solid var(--gameap-border); +} + +/* The row number: a fixed, unobtrusive gutter so the first real column lines up. */ +.gce-table-index { + width: 2rem; + color: var(--gameap-text-muted); +} + +/* A row the struct parser did not recognise, shown verbatim rather than dropped. */ +.gce-table-raw { + overflow-x: auto; + margin: 0 0 0.25rem; + padding: 0.25rem 0.5rem; + border-radius: 3px; + background-color: var(--gameap-body-bg, transparent); + border: 1px solid var(--gameap-border); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace; + font-size: 0.75rem; +} From 0db70ddc76b4855594bb8f7c3d787e89f3bf5495 Mon Sep 17 00:00:00 2001 From: Ericran <6282173+Ericran@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:00:22 -0400 Subject: [PATCH 2/2] Use the table groups for ARK and the four player lists The two shapes added with TableSpec were derived from Enshrouded and Dragonwilds, but they describe files upstream already ships. Applying them where they fit turns out to fix the same defect twice more. ARK's Game.ini (struct-rows, no new mechanism): HarvestResourceItemAmountClassMultipliers=(ClassName="..._Wood_C",Multiplier=2.0) HarvestResourceItemAmountClassMultipliers=(ClassName="..._Stone_C",Multiplier=1.5) Same repeated-key shape as KnownPlayerList, so getRaw resolved it to the last line and the rest of the list was invisible - that one showing as an opaque struct string. Eight lists now render as read-only tables: harvest amounts, stack sizes, auto-unlocked engrams, engram overrides and the four per-species dino damage/resistance lists. Not the ones whose payload is itself a list of structs (ConfigOverrideSupplyCrateItems and friends), where columns would be a worse view than the raw line, nor the subscripted single structs (LevelExperienceRampOverrides), which are one struct with a hundred members. Minecraft ops.json / whitelist.json and Bedrock allowlist.json / permissions.json (array-rows over the document root): each file IS the array, so its addresses start at the row index and there is no key to hang a path off. The empty path now means the root rather than one empty segment. They rendered as one titled group per player before - thirty operators, thirty headings. Four things the mechanism needed to fit them: - hideWhenEmpty drops a table-only group with no rows instead of showing its empty note. A curated field is worth rendering empty because an empty input can be filled in; no table kind adds rows, so an empty table is a dead section - and a stock Game.ini has none of these eight keys. - inferGroups now excludes the cells a table actually renders, not the whole subtree under its path. A key no column names stays visible, which matters most for a root path: it spans the entire file. - TableColumn takes `options`, so Bedrock's visitor/member/operator is a select rather than free text the server would refuse to boot on. - TableSpec takes `note`. The struct-rows footer was Dragonwilds' wording, which told an ARK admin to unban people from a Server Management screen ARK has no such thing as; the default is now generic and Dragonwilds carries its own. Also widens the struct parser to accept a subscripted member (ExperiencePointsForLevel[0]=10). Unreal writes fixed-size array members that way, and rejecting them failed the whole row - reporting a valid line as unrecognised. 351 tests (was 318), typecheck and bundle-shape guard clean. Co-Authored-By: Claude Opus 5 --- README.md | 74 ++++++- frontend/src/components/ConfigTable.test.ts | 67 +++++- frontend/src/components/ConfigTable.vue | 32 ++- .../src/composables/useConfigForm.test.ts | 102 ++++++++- frontend/src/composables/useConfigForm.ts | 106 +++++---- frontend/src/formats/types.ts | 41 +++- frontend/src/formats/unrealStruct.test.ts | 22 ++ frontend/src/formats/unrealStruct.ts | 8 +- frontend/src/games/registry.test.ts | 28 ++- frontend/src/games/registry.ts | 10 +- frontend/src/games/schemas/ark.test.ts | 150 +++++++++++++ frontend/src/games/schemas/ark.ts | 108 +++++++++- frontend/src/games/schemas/dragonwilds.ts | 7 + .../src/games/schemas/playerlists.test.ts | 201 ++++++++++++++++++ frontend/src/games/schemas/playerlists.ts | 133 ++++++++++++ 15 files changed, 1009 insertions(+), 80 deletions(-) create mode 100644 frontend/src/games/schemas/ark.test.ts create mode 100644 frontend/src/games/schemas/playerlists.test.ts create mode 100644 frontend/src/games/schemas/playerlists.ts diff --git a/README.md b/README.md index 174b16c..fdaa034 100644 --- a/README.md +++ b/README.md @@ -149,11 +149,25 @@ server root; per-world settings live in `config/paper-world-defaults.yml`, which isn't curated). All three are read at startup - restart after saving. `ops.json` / `whitelist.json` (and Bedrock's `allowlist.json` / -`permissions.json`) are lists of players rather than settings, so they get no -curated schema: the generic editor renders one group per entry, and existing -entries can be edited in place. Adding or removing players stays a job for -`/op`, `/deop` and `/whitelist`, because the running server rewrites these files -whenever the list changes - the editor warns before saving to one. +`permissions.json`) are lists of players rather than settings, and each file *is* +a bare JSON array. They render as a **table, one row per player**. Before that +the generic editor gave one titled group per entry, so a server with thirty +operators was thirty headings and comparing two players meant scrolling between +them. + +These are the only tables whose path is the document **root** (`''`): there is no +key to hang a dotted path off, so their addresses start straight at the row index +(`0.name`). Cells are editable in place and keep the file's JSON types - an ops +`level` stays a number, `bypassesPlayerLimit` stays a boolean, and Bedrock's +`permission` is a select over the three values the server accepts. Adding or +removing players stays a job for `/op`, `/deop` and `/whitelist`, because the +running server rewrites these files whenever the list changes - the editor warns +before saving to one. + +A key no column names is *not* hidden by the table. Coverage is per cell rather +than per subtree, so anything unexpected in a row still falls through to a +generic group - which matters here more than anywhere, because a root path spans +the entire file. Java and Bedrock both call their main config `server.properties` and share almost none of its keys. With a `game_id` each resolves to its own schema; when @@ -207,8 +221,9 @@ neither. It is **read-only**, deliberately. The server owns the list and rewrites the whole file on shutdown - the same reason the entry sets `stopWarning` - so an edit here races the process that wrote it. Bans belong in the in-game Server -Management screen. That is the same call this plugin already makes for -Minecraft's `ops.json` and `whitelist.json`. +Management screen. Minecraft's player lists get the same call one step softer: +their rows *are* editable, because each cell is a real address, but adding and +removing players still belongs to the game for exactly this reason. The line format is not documented anywhere public; it is inferred from Unreal's conventions. The parser is therefore tolerant (quotes optional, field order @@ -247,6 +262,40 @@ The platform folder is not knowable up front, so the entry lists all four in documents `Linux`, Jagex's own guide `LinuxServer`), and a Windows build run under Proton writes the *Windows* folders even on a Linux node. +### The ARK override lists + +`Game.ini` is half settings and half *lists*, and ARK writes a list as one +repeated key with an Unreal struct literal per line: + +``` +HarvestResourceItemAmountClassMultipliers=(ClassName="PrimalItemResource_Wood_C",Multiplier=2.0) +HarvestResourceItemAmountClassMultipliers=(ClassName="PrimalItemResource_Stone_C",Multiplier=1.5) +``` + +Same shape as Dragonwilds' roster, same consequence: `getRaw` resolves a repeated +key to its **last** occurrence, so every line but the final one was invisible and +that one showed as an opaque struct string. Eight of these render as read-only +struct tables - per-resource harvest amounts, item stack sizes, auto-unlocked +engrams, engram overrides, and the four per-species dino damage/resistance lists. + +Each sets `hideWhenEmpty`, so its group appears only when the file has that key. +The asymmetry with fields is deliberate: a curated field is worth rendering empty +because an empty input is something you can fill in, but no table kind adds rows, +so an empty table is a dead section - and a stock `Game.ini` has none of these +lines. + +`ConfigOverrideItemMaxQuantity` nests a second struct inside the first +(`Quantity=(MaxItemQuantity=200,bIgnoreMultiplier=true)`). The parser splits the +top level only, so that cell holds the inner literal verbatim - short enough to +read, and honest about what the file says. + +Not covered, deliberately: the lists whose payload is itself a list of structs +(`ConfigOverrideSupplyCrateItems`, `ConfigOverrideItemCraftingCosts`, +`ConfigAddNPCSpawnEntriesContainer`), where columns would be a worse view than +the raw line the Advanced group already gives; and the subscripted single structs +(`LevelExperienceRampOverrides`, `PerLevelStatsMultiplier_*`), which are one +struct with a hundred members rather than a hundred rows. + ## How it works A GameAP plugin is a single `.wasm` file with two parts: @@ -346,6 +395,9 @@ and renders anything not in the schema generically so nothing is ever hidden. - **Case-insensitive keys** for ARK/Unreal INI, so editing a game-written `AllowThirdPersonPlayer` never appends a duplicate `allowThirdPersonPlayer`. - **Info notes** (e.g. CS2's config-layering caveat) shown inline. +- **Tables** for the parts of a config that are a list rather than a setting: + Minecraft's and Bedrock's player lists, Enshrouded's user groups, ARK's + `Game.ini` override lists, and Dragonwilds' player roster. ## Adding a game @@ -357,6 +409,14 @@ and renders anything not in the schema generically so nothing is ever hidden. `dir`, `format`, `schema`). Both the tab and a game-gated file editor wire up automatically. +If part of the file is a *list* rather than a set of settings, give its group a +`table` instead of fields (see `TableSpec` in `src/formats/types.ts`). +`array-rows` is editable and addresses each cell by index - use `path: ''` when +the file itself is the array; `struct-rows` is read-only and reads every +occurrence of one repeated key via `getAllRaw()`. Add `hideWhenEmpty` for an +optional list, and `note` where the default footer's reasoning doesn't fit the +file. + If the game belongs to an engine family that is already covered (Source, GoldSource, idTech/`set`-dialect, Arma), add a row to that family's `defs` table instead of the main registry - `family()` fills in the file name, format and diff --git a/frontend/src/components/ConfigTable.test.ts b/frontend/src/components/ConfigTable.test.ts index c91ca51..da962b1 100644 --- a/frontend/src/components/ConfigTable.test.ts +++ b/frontend/src/components/ConfigTable.test.ts @@ -1,10 +1,11 @@ // @vitest-environment jsdom import { mount } from '@vue/test-utils'; +import { NSelect } from 'naive-ui'; import { describe, expect, it } from 'vitest'; import ConfigTable from './ConfigTable.vue'; import { addr } from '../formats/shared'; import { useConfigForm } from '../composables/useConfigForm'; -import { resolve } from '../games/registry'; +import { games, resolve } from '../games/registry'; import type { TableSpec } from '../formats/types'; const SECTION = '/Script/Dominion.DedicatedServerSettings'; @@ -183,3 +184,67 @@ describe('ConfigTable (array rows, editable)', () => { expect(wrapper.text()).toMatch(/no user groups/i); }); }); + +describe('ConfigTable footer note', () => { + /** Mount a struct table over ARK's Game.ini, which has no note of its own. */ + function arkHarvest() { + const g = games.find((x) => x.gameId === 'ark' && x.fileName === 'Game.ini')!; + const doc = g.format.parse( + [ + '[/script/shootergame.shootergamemode]', + 'HarvestResourceItemAmountClassMultipliers=(ClassName="PrimalItemResource_Wood_C",Multiplier=2.0)', + 'HarvestResourceItemAmountClassMultipliers=(ClassName="PrimalItemResource_Stone_C",Multiplier=1.5)', + '', + ].join('\n'), + )!; + const table = g.schema!.find((x) => x.id === 'harvest-classes')!.table!; + return mount(ConfigTable, { props: { spec: table, doc } }); + } + + it('renders ARK override lines as rows', () => { + const wrapper = arkHarvest(); + expect(wrapper.findAll('thead th').map((th) => th.text())).toEqual(['#', 'Resource class', 'Multiplier']); + const rows = wrapper.findAll('tbody tr'); + expect(rows).toHaveLength(2); + expect(rows[0].findAll('td').map((td) => td.text())).toEqual(['1', 'PrimalItemResource_Wood_C', '2.0']); + expect(rows[1].findAll('td')[2].text()).toBe('1.5'); + // Read-only: no control anywhere in the table. + expect(wrapper.findAll('input')).toHaveLength(0); + }); + + it('explains a struct table generically when the schema gives no reason', () => { + // The default used to be Dragonwilds' wording, which told an ARK admin + // to unban people from a Server Management screen ARK does not have. + const text = arkHarvest().text(); + expect(text).toMatch(/read-only/i); + expect(text).toMatch(/plain file editor/i); + expect(text).not.toMatch(/server management/i); + }); + + it('uses the schema\'s own note where one is given', () => { + const doc = docFor([player('1', 'Ann', '2', 'pw', 'False')]); + const text = mount(ConfigTable, { props: { spec: spec(), doc } }).text(); + expect(text).toMatch(/server management/i); + expect(text).toMatch(/would overwrite an edit made here/i); + }); +}); + +describe('ConfigTable select columns', () => { + it('hands a select column its options and current value', () => { + const g = resolve('minecraft-bedrock', 'permissions.json')!; + const doc = g.format.parse('[{ "xuid": "2535000000000001", "permission": "operator" }]')!; + const form = useConfigForm(doc, g.schema!, g.format.codec); + const wrapper = mount(ConfigTable, { + props: { spec: g.schema![0].table!, doc, models: form.models }, + }); + + const select = wrapper.findComponent(NSelect); + expect(select.exists()).toBe(true); + expect(select.props('value')).toBe('operator'); + expect(select.props('options')).toEqual([ + { label: 'visitor', value: 'visitor' }, + { label: 'member', value: 'member' }, + { label: 'operator', value: 'operator' }, + ]); + }); +}); diff --git a/frontend/src/components/ConfigTable.vue b/frontend/src/components/ConfigTable.vue index e39679a..578eba6 100644 --- a/frontend/src/components/ConfigTable.vue +++ b/frontend/src/components/ConfigTable.vue @@ -25,7 +25,7 @@ import { computed } from 'vue'; import type { ConfigDoc, ConfigValue, TableSpec } from '../formats/types'; import type { WritableComputedRef } from 'vue'; -import { arrayTableRows, cellAddress } from '../composables/useConfigForm'; +import { arrayTableRows, cellAddress, tableRowCount } from '../composables/useConfigForm'; import { isStructTrue, parseUnrealStruct, structField } from '../formats/unrealStruct'; import FieldInput from './FieldInput.vue'; @@ -55,9 +55,24 @@ const arrayRows = computed(() => props.spec.kind === 'array-rows' ? arrayTableRows(props.doc, props.spec.path) : [], ); -const isEmpty = computed(() => - props.spec.kind === 'array-rows' ? arrayRows.value.length === 0 : structRows.value.total === 0, -); +const isEmpty = computed(() => tableRowCount(props.doc, props.spec) === 0); + +/** + * Why this table behaves the way it does, shown under it. + * + * A schema can replace it, because the reason is not the same for every file. A + * struct table is read-only either because the SERVER owns the list and would + * overwrite an edit (Dragonwilds' roster) or simply because a struct literal is + * one indivisible value (ARK's override lists) - and telling an ARK admin to use + * the in-game Server Management screen would be nonsense. + */ +const DEFAULT_NOTE: Record = { + 'struct-rows': + 'Read-only: each row is a single value in the file, so a cell cannot be addressed on its own. Change ' + + 'these lines in the plain file editor.', + 'array-rows': 'Edits apply to the entries already in the file. Adding or removing one needs the plain file editor.', +}; +const footer = computed(() => props.spec.note ?? DEFAULT_NOTE[props.spec.kind]); const cell = (row: Record, key: string) => structField(row, key) ?? ''; const address = (row: number, key: string) => @@ -89,6 +104,7 @@ const address = (row: number, key: string) => v-if="models?.[address(row, col.key)]" v-model="models[address(row, col.key)].value" :type="col.type ?? 'text'" + :options="col.options" :disabled="saving" /> @@ -127,12 +143,6 @@ const address = (row: number, key: string) =>
{{ raw }}
-

- Written by the server. Ban and unban from the in-game Server Management screen - the server rewrites this - file and would overwrite an edit made here. -

-

- Edits apply to the entries already in the file. Adding or removing one needs the plain file editor. -

+

{{ footer }}

diff --git a/frontend/src/composables/useConfigForm.test.ts b/frontend/src/composables/useConfigForm.test.ts index b929b6e..fef4900 100644 --- a/frontend/src/composables/useConfigForm.test.ts +++ b/frontend/src/composables/useConfigForm.test.ts @@ -4,12 +4,12 @@ * the codec into the doc. */ import { describe, expect, it } from 'vitest'; -import { inferGroups, inferType, useConfigForm } from './useConfigForm'; +import { arrayTableRows, cellAddress, inferGroups, inferType, tableCells, tableRowCount, useConfigForm } from './useConfigForm'; import { makeIniFormat, iniFormat } from '../formats/ini'; import { keyvalueFormat } from '../formats/keyvalue'; -import { jsonFormat } from '../formats/json'; +import { jsonFormat, jsonListFormat } from '../formats/json'; import { addr } from '../formats/shared'; -import type { Schema } from '../formats/types'; +import type { Schema, TableColumn } from '../formats/types'; describe('inferType', () => { it('recognises booleans case-insensitively', () => { @@ -224,3 +224,99 @@ describe('useConfigForm', () => { expect(JSON.parse(doc.serialize()).List).toEqual([1, 2]); }); }); + +describe('table paths', () => { + const LIST = '[{ "name": "Ann", "level": 4 }, { "name": "Bob", "level": 1 }]'; + const NESTED = '{ "roles": [{ "name": "Ann" }, { "name": "Bob" }] }'; + + const rootTable = (columns: TableColumn[] = [{ key: 'name', label: 'Player' }]): Schema => [ + { id: 'list', title: 'List', icon: 'users', fields: [], table: { kind: 'array-rows', path: '', columns } }, + ]; + + it('treats the empty path as the document root, not as one empty segment', () => { + // A file that IS the array addresses its cells as `0.name`. Splitting + // '' would give [''], which prefixes nothing, so the table would find no + // rows and quietly render its empty note over a full file. + const doc = jsonListFormat.parse(LIST)!; + expect(doc.keys()).toEqual(['0.name', '0.level', '1.name', '1.level']); + expect(arrayTableRows(doc, '')).toEqual([0, 1]); + expect(cellAddress('', 0, 'name')).toBe('0.name'); + }); + + it('still prefixes a named path', () => { + const doc = jsonListFormat.parse(NESTED)!; + expect(arrayTableRows(doc, 'roles')).toEqual([0, 1]); + expect(cellAddress('roles', 1, 'name')).toBe('roles.1.name'); + // A path that names nothing has no rows - it must not fall back to root. + expect(arrayTableRows(doc, 'missing')).toEqual([]); + }); + + it('covers the cells a table renders, and only those', () => { + // The root path spans the whole file, so excluding its subtree would + // hide every key. Coverage is per cell: a column'd key is a cell, an + // uncovered one stays a normal field in a generic group. + const doc = jsonListFormat.parse(LIST)!; + expect(tableCells(doc, rootTable()).map((f) => f.key)).toEqual(['0.name', '1.name']); + const inferred = inferGroups(doc, rootTable()); + expect(inferred.flatMap((g) => g.fields.map((f) => f.key))).toEqual(['0.level', '1.level']); + }); + + it('carries a column\'s select options onto its cells', () => { + const doc = jsonListFormat.parse(LIST)!; + const withSelect = rootTable([{ key: 'name', label: 'Player', type: 'select', options: ['Ann', 'Bob'] }]); + const cells = tableCells(doc, withSelect); + expect(cells[0].type).toBe('select'); + expect(cells[0].options).toEqual(['Ann', 'Bob']); + // A column without options must not grow an empty one - FieldInput + // treats `[]` and undefined differently for a non-select. + expect(tableCells(doc, rootTable())[0]).not.toHaveProperty('options'); + }); +}); + +describe('hideWhenEmpty', () => { + const withRows = (hideWhenEmpty?: boolean): Schema => [ + { + id: 'list', + title: 'List', + icon: 'users', + fields: [], + table: { kind: 'array-rows', path: 'roles', columns: [{ key: 'name', label: 'Player' }], hideWhenEmpty }, + }, + ]; + + it('counts rows the same way for both table kinds', () => { + const json = jsonListFormat.parse('{ "roles": [{ "name": "Ann" }] }')!; + expect(tableRowCount(json, withRows()[0].table!)).toBe(1); + + const ci = makeIniFormat('t', { caseInsensitive: true }); + const ini = ci.parse('[s]\nList=(A=1)\nList=(A=2)\n')!; + expect(tableRowCount(ini, { kind: 'struct-rows', address: addr('s', 'List'), columns: [] })).toBe(2); + expect(tableRowCount(ini, { kind: 'struct-rows', address: addr('s', 'Nope'), columns: [] })).toBe(0); + }); + + it('keeps an empty table by default, so its note can explain itself', () => { + const doc = jsonListFormat.parse('{ "roles": [] }')!; + expect(useConfigForm(doc, withRows(), jsonListFormat.codec).groups.value.map((g) => g.title)).toEqual(['List']); + }); + + it('drops an empty table when the schema asks, and keeps a populated one', () => { + const empty = jsonListFormat.parse('{ "roles": [] }')!; + expect(useConfigForm(empty, withRows(true), jsonListFormat.codec).groups.value.map((g) => g.title)).toEqual([]); + + const full = jsonListFormat.parse('{ "roles": [{ "name": "Ann" }] }')!; + expect(useConfigForm(full, withRows(true), jsonListFormat.codec).groups.value.map((g) => g.title)).toEqual([ + 'List', + ]); + }); + + it('never drops a group that has fields, whatever its table says', () => { + const doc = jsonListFormat.parse('{ "roles": [], "other": 1 }')!; + const mixed: Schema = [ + { + ...withRows(true)[0], + fields: [{ key: 'other', label: 'Other', type: 'number' }], + }, + ]; + expect(useConfigForm(doc, mixed, jsonListFormat.codec).groups.value.map((g) => g.title)).toEqual(['List']); + }); +}); diff --git a/frontend/src/composables/useConfigForm.ts b/frontend/src/composables/useConfigForm.ts index 872e439..d7d6a9d 100644 --- a/frontend/src/composables/useConfigForm.ts +++ b/frontend/src/composables/useConfigForm.ts @@ -8,12 +8,23 @@ * back through the codec) and it is pure enough to test directly. */ import { computed, ref, type WritableComputedRef } from 'vue'; -import type { Codec, ConfigDoc, ConfigValue, FieldDef, FType, Group, Schema } from '../formats/types'; +import type { Codec, ConfigDoc, ConfigValue, FieldDef, FType, Group, Schema, TableSpec } from '../formats/types'; import { escapeSegment, splitAddress } from '../formats/shared'; +/** + * Segments of a table path. + * + * `''` is the document ROOT, for a file that simply is the array - Minecraft's + * ops.json and whitelist.json, Bedrock's allowlist.json and permissions.json. + * Their addresses start straight at the row index (`0.name`), so an empty path + * has to mean "no prefix at all"; splitAddress would hand back one empty + * segment, which matches nothing. + */ +const pathSegments = (path: string): string[] => (path === '' ? [] : splitAddress(path)); + /** Is `address` inside the array/object at `path` (not the path itself)? */ function isUnder(address: string, path: string): boolean { - const prefix = splitAddress(path); + const prefix = pathSegments(path); const parts = splitAddress(address); return parts.length > prefix.length && prefix.every((p, i) => parts[i] === p); } @@ -28,7 +39,7 @@ function isUnder(address: string, path: string): boolean { * does nothing. */ export function arrayTableRows(doc: ConfigDoc, path: string): number[] { - const prefix = splitAddress(path); + const prefix = pathSegments(path); const seen = new Set(); for (const key of doc.keys()) { if (!isUnder(key, path)) continue; @@ -40,7 +51,46 @@ export function arrayTableRows(doc: ConfigDoc, path: string): number[] { /** Address of one cell. The single spelling both the form and the table use. */ export function cellAddress(path: string, row: number, key: string): string { - return `${path}.${row}.${escapeSegment(key)}`; + const tail = `${row}.${escapeSegment(key)}`; + return path === '' ? tail : `${path}.${tail}`; +} + +/** + * Every cell of every array-backed table in the schema, as ordinary fields. + * + * Deliberately not a separate write path: a cell address is a real address, so + * routing it through the same models gives it the format's type coercion (a + * `reservedSlots` of 2 stays a JSON number), the same writeError reporting, and + * the same dirty flag - for free, and without the editor having to know a table + * is involved. It is also what tells inferGroups which keys are already shown. + */ +export function tableCells(doc: ConfigDoc, schema: Schema): FieldDef[] { + const cells: FieldDef[] = []; + for (const group of schema) { + if (group.table?.kind !== 'array-rows') continue; + const { path, columns } = group.table; + for (const row of arrayTableRows(doc, path)) { + for (const col of columns) { + cells.push({ + key: cellAddress(path, row, col.key), + label: `${col.label} (row ${row + 1})`, + type: col.type ?? 'text', + ...(col.options ? { options: col.options } : {}), + }); + } + } + } + return cells; +} + +/** + * How many rows a table would render. Shared by the form, which drops an + * optional table that has none, and by ConfigTable, which shows its empty note. + */ +export function tableRowCount(doc: ConfigDoc, spec: TableSpec): number { + return spec.kind === 'array-rows' + ? arrayTableRows(doc, spec.path).length + : (doc.getAllRaw?.(spec.address) ?? []).length; } /** @@ -65,20 +115,22 @@ export function inferGroups(doc: ConfigDoc, schema: Schema): Group[] { const norm = doc.normKey ? (a: string) => doc.normKey!(a) : (a: string) => a; // A struct table's address counts as covered too, or the repeated key it // renders as rows would ALSO show up here as a lone raw field holding its - // last line. + // last line. So does every cell an array table renders, or the same value + // would be editable from two places at once. + // + // Only those cells, though - NOT the whole subtree under the table's path. + // A key inside a row that no column names stays visible here, so adding a + // table narrows what the form LABELS rather than what it shows. That + // matters most for a root-path table, whose path spans the entire file. const known = new Set([ ...schema.flatMap((g) => g.fields.map((f) => norm(f.key))), ...schema.flatMap((g) => (g.table?.kind === 'struct-rows' ? [norm(g.table.address)] : [])), + ...tableCells(doc, schema).map((f) => norm(f.key)), ]); - // An array table covers a whole subtree, not one address: every - // `userGroups.0.password` under it is rendered as a cell, so listing them - // again as loose fields would show the same value twice in two places. - const tablePaths = schema.flatMap((g) => (g.table?.kind === 'array-rows' ? [g.table.path] : [])); const bySection = new Map(); for (const key of doc.keys()) { if (known.has(norm(key))) continue; - if (tablePaths.some((path) => isUnder(key, path))) continue; const section = doc.sectionOf(key); const fields = bySection.get(section) ?? []; fields.push({ key, label: doc.labelOf(key), type: inferType(doc.getRaw(key) ?? '') }); @@ -118,35 +170,13 @@ export function useConfigForm(doc: ConfigDoc, schema: Schema, codec: Codec) { const inferred = inferGroups(doc, schema); // A table-only group has no fields but plenty to render, so it must survive - // the empty-group filter. - const groups = computed(() => [ - ...schema.filter((g) => g.fields.length || g.table), - ...inferred, - ]); + // the empty-group filter - unless it is an optional list this file doesn't + // use and the schema asked for it to be dropped (see hideWhenEmpty). + const keep = (g: Group): boolean => + g.fields.length > 0 || (!!g.table && !(g.table.hideWhenEmpty && tableRowCount(doc, g.table) === 0)); + const groups = computed(() => [...schema.filter(keep), ...inferred]); - /** - * Cells of every array-backed table, as ordinary fields. - * - * Deliberately not a separate write path: a cell address is a real address, - * so routing it through the same models gives it the format's type coercion - * (a `reservedSlots` of 2 stays a JSON number), the same writeError - * reporting, and the same dirty flag - for free, and without the editor - * having to know a table is involved. - */ - const cells: FieldDef[] = []; - for (const group of schema) { - if (group.table?.kind !== 'array-rows') continue; - const { path, columns } = group.table; - for (const row of arrayTableRows(doc, path)) { - for (const col of columns) { - cells.push({ - key: cellAddress(path, row, col.key), - label: `${col.label} (row ${row + 1})`, - type: col.type ?? 'text', - }); - } - } - } + const cells = tableCells(doc, schema); const models: Record> = {}; for (const group of [...schema, ...inferred, { fields: cells } as Group]) { diff --git a/frontend/src/formats/types.ts b/frontend/src/formats/types.ts index bdf4a72..d16d9bd 100644 --- a/frontend/src/formats/types.ts +++ b/frontend/src/formats/types.ts @@ -39,6 +39,28 @@ export interface TableColumn { label: string; /** Widget and coercion for the cell. Defaults to 'text'. */ type?: FType; + /** Choices for a `select` cell (Bedrock's visitor/member/operator). */ + options?: string[]; +} + +/** What every table kind carries, whatever its rows are read from. */ +interface TableBase { + columns: TableColumn[]; + /** Shown instead of the table when it has no rows. */ + empty?: string; + /** + * Drop the whole group when there are no rows, instead of showing `empty`. + * + * For a table over an OPTIONAL list. A curated field renders even when the + * file omits it, because an empty input is something you can fill in; an + * empty table is not - no kind here adds rows - so a form that always shows + * one is a form with a dead section in it. ARK's Game.ini is the case that + * needs this: eight override lists, of which a given server uses none or + * two. + */ + hideWhenEmpty?: boolean; + /** Replaces the default footer note under the table. */ + note?: string; } /** @@ -49,13 +71,10 @@ export interface TableColumn { * single value - so editing one would mean patching a substring of a line the * running server also writes. */ -export interface StructRowTable { +export interface StructRowTable extends TableBase { kind: 'struct-rows'; /** Address of the repeated key, e.g. addr(section, 'KnownPlayerList'). */ address: string; - columns: TableColumn[]; - /** Shown when the key appears nowhere in the file. */ - empty?: string; } /** @@ -66,13 +85,17 @@ export interface StructRowTable { * * Needs a format that walks into arrays (json.ts in `arrays: 'expand'` mode). */ -export interface ArrayRowTable { +export interface ArrayRowTable extends TableBase { kind: 'array-rows'; - /** Dotted path of the array itself, e.g. 'userGroups'. */ + /** + * Dotted path of the array itself, e.g. 'userGroups'. + * + * `''` is the document root, for a file that IS the array: Minecraft's + * ops.json and whitelist.json, and Bedrock's allowlist.json and + * permissions.json, are all a bare list of records with nothing to hang a + * name off. + */ path: string; - columns: TableColumn[]; - /** Shown when the array is absent or empty. */ - empty?: string; } /** diff --git a/frontend/src/formats/unrealStruct.test.ts b/frontend/src/formats/unrealStruct.test.ts index f5b9d79..ca6bcb6 100644 --- a/frontend/src/formats/unrealStruct.test.ts +++ b/frontend/src/formats/unrealStruct.test.ts @@ -61,6 +61,28 @@ describe('structField', () => { }); }); +describe('parseUnrealStruct, subscripted members', () => { + it('accepts a fixed-size array member, which Unreal writes with an index', () => { + // ARK's LevelExperienceRampOverrides is written this way. Rejecting the + // subscript made the whole struct parse as null, so a perfectly valid + // line was reported to the user as unrecognised. + expect(parseUnrealStruct('(ExperiencePointsForLevel[0]=10,ExperiencePointsForLevel[1]=25)')).toEqual({ + 'ExperiencePointsForLevel[0]': '10', + 'ExperiencePointsForLevel[1]': '25', + }); + }); + + it('still refuses things that are not identifiers', () => { + // A tolerant parser is not a credulous one: a key it cannot vouch for + // is skipped, and a struct with nothing left is null rather than {}. + expect(parseUnrealStruct('(1Bad=x)')).toBeNull(); + expect(parseUnrealStruct('(Also[bad]=x)')).toBeNull(); + expect(parseUnrealStruct('(Trailing[0=x)')).toBeNull(); + // ...but one good pair alongside a bad one still yields the good one. + expect(parseUnrealStruct('(1Bad=x,Good[2]=y)')).toEqual({ 'Good[2]': 'y' }); + }); +}); + describe('isStructTrue', () => { it('accepts every spelling Unreal uses for true', () => { for (const v of ['True', 'true', 'TRUE', '1', 'yes', 'on']) expect(isStructTrue(v), v).toBe(true); diff --git a/frontend/src/formats/unrealStruct.ts b/frontend/src/formats/unrealStruct.ts index 80cd13c..81d35a7 100644 --- a/frontend/src/formats/unrealStruct.ts +++ b/frontend/src/formats/unrealStruct.ts @@ -78,8 +78,12 @@ export function parseUnrealStruct(raw: string): Record | null { const eq = item.indexOf('='); if (eq <= 0) continue; const key = item.slice(0, eq).trim(); - // An identifier, or this isn't a Key=Value pair we should trust. - if (!/^[A-Za-z_]\w*$/.test(key)) continue; + // An identifier - optionally subscripted, because Unreal writes a + // fixed-size array member as `ExperiencePointsForLevel[0]=10` inside the + // struct (ARK's LevelExperienceRampOverrides is the common one). Without + // the subscript the whole row fails to parse and is reported as + // unrecognised, which is a confusing way to say "this is fine". + if (!/^[A-Za-z_]\w*(?:\[\d+\])?$/.test(key)) continue; out[key] = unquote(item.slice(eq + 1).trim()); found++; } diff --git a/frontend/src/games/registry.test.ts b/frontend/src/games/registry.test.ts index 8ad4a3f..367040e 100644 --- a/frontend/src/games/registry.test.ts +++ b/frontend/src/games/registry.test.ts @@ -352,12 +352,34 @@ describe('the Minecraft family', () => { expect(doc.keys()).toEqual(['0.name', '0.level']); expect(doc.setRaw('0.level', '3')).toBe(true); expect(JSON.parse(doc.serialize())).toEqual([{ name: 'Notch', level: 3 }]); - // The list files have no curated schema on purpose - entries vary in - // count, so the generic editor renders one group per player. - expect(g.schema).toBeUndefined(); expect(g.note).toBeTruthy(); }); + it('gives every player list a root-path table instead of a group per player', () => { + // These four files are a bare JSON array, so their addresses start at + // the row index and the table's path is the document root. Getting that + // wrong is silent: the table finds no rows and the generic editor + // renders one titled group per entry, which is what it did before. + const sharedNote = resolve('minecraft', 'ops.json')!.note; + expect(sharedNote).toMatch(/edit the entries/i); + for (const [game, file] of [ + ['minecraft', 'ops.json'], + ['minecraft', 'whitelist.json'], + ['minecraft-bedrock', 'allowlist.json'], + ['minecraft-bedrock', 'permissions.json'], + ] as const) { + const g = resolve(game, file)!; + expect(g.schema, file).toHaveLength(1); + const table = g.schema![0].table; + expect(table?.kind, file).toBe('array-rows'); + if (table?.kind !== 'array-rows') throw new Error(`${file}: expected an array-rows table`); + expect(table.path, file).toBe(''); + expect(g.schema![0].fields, file).toEqual([]); + // Editable, matching what the shared note already promises. + expect(g.note, file).toBe(sharedNote); + } + }); + it('warns before saving the files the running server rewrites itself', () => { for (const file of ['ops.json', 'whitelist.json']) { expect(resolve('minecraft', file)!.stopWarning, file).toBe(true); diff --git a/frontend/src/games/registry.ts b/frontend/src/games/registry.ts index 4a37eba..37ffddb 100644 --- a/frontend/src/games/registry.ts +++ b/frontend/src/games/registry.ts @@ -33,6 +33,7 @@ import { mtaSchema } from './schemas/mta'; import { factorioSchema } from './schemas/factorio'; import { enshroudedSchema } from './schemas/enshrouded'; import { dragonwildsSchema } from './schemas/dragonwilds'; +import { allowlistSchema, opsSchema, permissionsSchema, whitelistSchema } from './schemas/playerlists'; import { sourceGames } from './source'; import { goldSourceGames } from './goldsource'; import { idTechGames } from './idtech'; @@ -263,9 +264,11 @@ export const games: GameConfig[] = [ gameName: 'Minecraft (operators)', fileName: 'ops.json', dir: '', - // No schema: the file is a list of players, not a set of settings - the - // generic editor renders one group per entry. + // The file is a list of players rather than a set of settings, so the + // schema is one root-path table: a row each, instead of the generic + // editor's one titled group per entry. format: jsonListFormat, + schema: opsSchema, note: PLAYER_LIST_NOTE, stopWarning: true, }, @@ -275,6 +278,7 @@ export const games: GameConfig[] = [ fileName: 'whitelist.json', dir: '', format: jsonListFormat, + schema: whitelistSchema, note: PLAYER_LIST_NOTE, stopWarning: true, }, @@ -295,6 +299,7 @@ export const games: GameConfig[] = [ fileName: 'allowlist.json', dir: '', format: jsonListFormat, + schema: allowlistSchema, note: PLAYER_LIST_NOTE, stopWarning: true, }, @@ -304,6 +309,7 @@ export const games: GameConfig[] = [ fileName: 'permissions.json', dir: '', format: jsonListFormat, + schema: permissionsSchema, note: PLAYER_LIST_NOTE, stopWarning: true, }, diff --git a/frontend/src/games/schemas/ark.test.ts b/frontend/src/games/schemas/ark.test.ts new file mode 100644 index 0000000..fabd86c --- /dev/null +++ b/frontend/src/games/schemas/ark.test.ts @@ -0,0 +1,150 @@ +/** + * ARK's Game.ini override lists. + * + * The GameUserSettings half of this schema is plain fields and is covered by the + * registry suite. What is worth its own tests is the half that is not fields: + * eight repeated keys, each a list written as one Unreal struct literal per + * line, which the form could not show at all before it had tables. + * + * The fixture is written the way a server writes it - lowercase section header, + * mixed struct spelling - because case is exactly what a section-qualified + * address gets wrong. + */ +import { describe, expect, it } from 'vitest'; +import { useConfigForm } from '../../composables/useConfigForm'; +import { parseUnrealStruct, structField } from '../../formats/unrealStruct'; +import { addr, addrSection } from '../../formats/shared'; +import { games, type GameConfig } from '../registry'; +import { arkGameIniSchema } from './ark'; + +const SECTION = '/script/shootergame.shootergamemode'; +const gameIni = (): GameConfig => games.find((g) => g.gameId === 'ark' && g.fileName === 'Game.ini')!; + +const GAME_INI = [ + '[/script/shootergame.shootergamemode]', + 'BabyMatureSpeedMultiplier=10.0', + 'bUseCorpseLocator=True', + 'HarvestResourceItemAmountClassMultipliers=(ClassName="PrimalItemResource_Wood_C",Multiplier=2.0)', + 'HarvestResourceItemAmountClassMultipliers=(ClassName="PrimalItemResource_Stone_C",Multiplier=1.5)', + 'HarvestResourceItemAmountClassMultipliers=(ClassName="PrimalItemResource_Fibers_C",Multiplier=3.0)', + 'EngramEntryAutoUnlocks=(EngramClassName="EngramEntry_StoneHatchet_C",LevelToAutoUnlock=1)', + 'OverrideNamedEngramEntries=(EngramClassName="EngramEntry_Campfire_C",EngramHidden=False,' + + 'EngramPointsCost=0,EngramLevelRequirement=1,RemoveEngramPreReq=True)', + 'ConfigOverrideItemMaxQuantity=(ItemClassString="PrimalItemResource_Wood_C",' + + 'Quantity=(MaxItemQuantity=200,bIgnoreMultiplier=true))', + 'DinoClassDamageMultipliers=(ClassName="SpinoCharacter_BP_C",Multiplier=1.0)', + 'TamedDinoClassResistanceMultipliers=(ClassName="Rex_Character_BP_C",Multiplier=0.5)', + '', +].join('\n'); + +const tables = arkGameIniSchema.filter((g) => g.table); + +function open(text: string) { + const g = gameIni(); + const doc = g.format.parse(text)!; + return { doc, form: useConfigForm(doc, g.schema!, g.format.codec) }; +} + +describe('arkGameIniSchema override lists', () => { + it('declares them all as read-only struct tables that vanish when unused', () => { + expect(tables).toHaveLength(8); + for (const group of tables) { + const table = group.table!; + // struct-rows is what makes them read-only: an ARK override line is + // one value, so no cell in it is separately addressable. + expect(table.kind, group.id).toBe('struct-rows'); + expect(group.fields, group.id).toEqual([]); + // Unlike a curated field, an empty table is not something you can + // fill in - and a stock Game.ini has none of these lines. + expect(table.hideWhenEmpty, group.id).toBe(true); + } + expect(new Set(arkGameIniSchema.map((g) => g.id)).size).toBe(arkGameIniSchema.length); + }); + + it('addresses every list inside the game-mode section', () => { + // A bare key would address the file's implicit top-level section, find + // nothing, and render eight empty tables - which hideWhenEmpty would + // then hide, making the mistake invisible. + for (const group of tables) { + const table = group.table!; + if (table.kind !== 'struct-rows') throw new Error('expected struct-rows'); + expect(addrSection(table.address), group.id).toBe(SECTION); + } + }); + + it('names struct members ARK actually writes', () => { + // Column keys are looked up inside the struct, so a misspelling is a + // silent empty column rather than an error. + const doc = gameIni().format.parse(GAME_INI)!; + for (const group of tables) { + const table = group.table!; + if (table.kind !== 'struct-rows') throw new Error('expected struct-rows'); + const raws = doc.getAllRaw!(table.address); + if (raws.length === 0) continue; // not in this fixture + const fields = parseUnrealStruct(raws[0]); + expect(fields, `${group.id} should parse`).not.toBeNull(); + for (const col of table.columns) { + expect(structField(fields!, col.key), `${group.id}: ${col.key}`).toBeDefined(); + } + } + }); + + it('shows every line of a repeated key, not just the last one', () => { + // The bug this exists for: getRaw resolves a repeated key to its final + // occurrence, so two of these three harvest lines were invisible and the + // third showed as a raw struct string. + const { doc, form } = open(GAME_INI); + const address = addr(SECTION, 'HarvestResourceItemAmountClassMultipliers'); + expect(doc.getRaw(address)).toContain('Fibers'); + expect(doc.getAllRaw!(address)).toHaveLength(3); + + const shown = form.groups.value.map((g) => g.title); + expect(shown).toContain('Harvest amounts (per resource)'); + // ...and the key must not ALSO appear as a loose raw field holding that + // last line, which would offer the same data twice, one copy truncated. + const loose = form.groups.value.flatMap((g) => g.fields.map((f) => f.key)); + expect(loose).not.toContain(address); + }); + + it('hides the lists this file does not use, and keeps the ones it does', () => { + const { form } = open(GAME_INI); + const shown = form.groups.value.map((g) => g.title); + // Present in the fixture. + expect(shown).toContain('Harvest amounts (per resource)'); + expect(shown).toContain('Auto-unlocked engrams'); + expect(shown).toContain('Engram overrides'); + expect(shown).toContain('Item stack sizes'); + expect(shown).toContain('Wild dino damage (per species)'); + expect(shown).toContain('Tamed dino resistance (per species)'); + // Absent from it - and a section reading "no entries" eight times over + // is what hideWhenEmpty exists to prevent. + expect(shown).not.toContain('Wild dino resistance (per species)'); + expect(shown).not.toContain('Tamed dino damage (per species)'); + }); + + it('shows no tables at all for a stock Game.ini', () => { + const { form } = open(['[/script/shootergame.shootergamemode]', 'BabyMatureSpeedMultiplier=1.0', ''].join('\n')); + expect(form.groups.value.every((g) => !g.table)).toBe(true); + // The curated fields still render, empty ones included - the asymmetry + // is deliberate: an empty input can be filled in, an empty table cannot. + expect(form.groups.value.map((g) => g.title)).toContain('Breeding & Imprinting'); + }); + + it('reads a nested struct as written rather than dropping the row', () => { + // ConfigOverrideItemMaxQuantity wraps its payload in a second struct. + // Splitting only the top level means the cell holds the inner literal + // verbatim, which is readable; losing the row would not be. + const doc = gameIni().format.parse(GAME_INI)!; + const raws = doc.getAllRaw!(addr(SECTION, 'ConfigOverrideItemMaxQuantity')); + const fields = parseUnrealStruct(raws[0])!; + expect(structField(fields, 'ItemClassString')).toBe('PrimalItemResource_Wood_C'); + expect(structField(fields, 'Quantity')).toBe('(MaxItemQuantity=200,bIgnoreMultiplier=true)'); + }); + + it('leaves a file it only read byte-identical', () => { + // Every table is read-only, so opening this tab must not rewrite a + // single line - including the eight repeated keys. + const { doc } = open(GAME_INI); + expect(doc.serialize()).toBe(GAME_INI); + }); +}); diff --git a/frontend/src/games/schemas/ark.ts b/frontend/src/games/schemas/ark.ts index bc00376..9ef8a43 100644 --- a/frontend/src/games/schemas/ark.ts +++ b/frontend/src/games/schemas/ark.ts @@ -3,11 +3,36 @@ * * Keys live under several INI sections, so field addresses are section-qualified * via section(). ARK INI keys are case-insensitive (handled by the ci INI format), - * booleans are True/False, strings unquoted. Repeated/array keys - * (PerLevelStatsMultiplier[...], engram overrides, etc.) are intentionally NOT - * in the schema - they fall through to the raw "Advanced" groups untouched. + * booleans are True/False, strings unquoted. + * + * Game.ini's override LISTS are the other half of this file, and a plain field + * cannot show them. ARK writes a list as one repeated key, one Unreal struct + * literal per line: + * + * HarvestResourceItemAmountClassMultipliers=(ClassName="...",Multiplier=2.0) + * HarvestResourceItemAmountClassMultipliers=(ClassName="...",Multiplier=1.5) + * + * and `getRaw` resolves a repeated key to its LAST occurrence - correct for a + * scalar written twice, wrong for a list - so every line but the final one was + * invisible, and that one showed as an opaque struct string. They render as + * struct tables instead, one row per line, read-only because a cell is not + * separately addressable. + * + * Each is hidden when the file has no such line. A curated field is worth + * rendering empty (an empty input is something you can fill in), but no table + * kind adds rows, so an empty one is a dead section - and a stock Game.ini has + * none of these. + * + * Still not covered, deliberately: the deeply nested lists whose payload is + * itself a list of structs (ConfigOverrideSupplyCrateItems, + * ConfigOverrideItemCraftingCosts, ConfigAddNPCSpawnEntriesContainer). Columns + * would be a worse view of those than the raw line, which is what the Advanced + * group already gives. Same for the subscripted single structs + * (LevelExperienceRampOverrides, PerLevelStatsMultiplier_*): one struct with a + * hundred members is a row a hundred columns wide. */ -import type { Schema } from '../../formats/types'; +import type { Group, Schema, TableColumn } from '../../formats/types'; +import type { IconName } from '../../icons'; import { section } from '../fields'; const ss = section('ServerSettings'); @@ -16,6 +41,27 @@ const gsess = section('/Script/Engine.GameSession'); const motd = section('MessageOfTheDay'); const gm = section('/script/shootergame.shootergamemode'); +/** A group that is nothing but a read-only table over one repeated Game.ini key. */ +const overrideList = ( + id: string, + title: string, + icon: IconName, + key: string, + columns: TableColumn[], +): Group => ({ + id, + title, + icon, + fields: [], + table: { kind: 'struct-rows', address: gm.at(key), columns, hideWhenEmpty: true }, +}); + +/** The shape shared by every per-class multiplier list: what, and by how much. */ +const classMultiplier = (label: string): TableColumn[] => [ + { key: 'ClassName', label }, + { key: 'Multiplier', label: 'Multiplier' }, +]; + export const arkGameUserSettingsSchema: Schema = [ { id: 'identity', @@ -126,4 +172,58 @@ export const arkGameIniSchema: Schema = [ gm.b('bAllowUnlimitedRespecs', 'Unlimited mindwipes'), ], }, + overrideList( + 'harvest-classes', + 'Harvest amounts (per resource)', + 'box-open', + 'HarvestResourceItemAmountClassMultipliers', + classMultiplier('Resource class'), + ), + overrideList('stack-sizes', 'Item stack sizes', 'cubes', 'ConfigOverrideItemMaxQuantity', [ + { key: 'ItemClassString', label: 'Item class' }, + // Nested: `Quantity=(MaxItemQuantity=200,bIgnoreMultiplier=true)`. The + // struct parser splits the top level only, so this cell holds the inner + // literal verbatim - short enough to read, and honest about what the + // file says. + { key: 'Quantity', label: 'Quantity (struct)' }, + ]), + overrideList('engram-unlocks', 'Auto-unlocked engrams', 'puzzle-piece', 'EngramEntryAutoUnlocks', [ + { key: 'EngramClassName', label: 'Engram class' }, + { key: 'LevelToAutoUnlock', label: 'Unlocked at level' }, + ]), + overrideList('engram-overrides', 'Engram overrides', 'puzzle-piece', 'OverrideNamedEngramEntries', [ + { key: 'EngramClassName', label: 'Engram class' }, + { key: 'EngramHidden', label: 'Hidden', type: 'bool' }, + { key: 'EngramPointsCost', label: 'Point cost' }, + { key: 'EngramLevelRequirement', label: 'Level required' }, + { key: 'RemoveEngramPreReq', label: 'Drop prerequisites', type: 'bool' }, + ]), + overrideList( + 'dino-damage', + 'Wild dino damage (per species)', + 'paw', + 'DinoClassDamageMultipliers', + classMultiplier('Dino class'), + ), + overrideList( + 'dino-resistance', + 'Wild dino resistance (per species)', + 'paw', + 'DinoClassResistanceMultipliers', + classMultiplier('Dino class'), + ), + overrideList( + 'tamed-dino-damage', + 'Tamed dino damage (per species)', + 'paw', + 'TamedDinoClassDamageMultipliers', + classMultiplier('Dino class'), + ), + overrideList( + 'tamed-dino-resistance', + 'Tamed dino resistance (per species)', + 'paw', + 'TamedDinoClassResistanceMultipliers', + classMultiplier('Dino class'), + ), ]; diff --git a/frontend/src/games/schemas/dragonwilds.ts b/frontend/src/games/schemas/dragonwilds.ts index cfb7621..744c256 100644 --- a/frontend/src/games/schemas/dragonwilds.ts +++ b/frontend/src/games/schemas/dragonwilds.ts @@ -90,6 +90,13 @@ export const dragonwildsSchema: Schema = [ empty: 'No players recorded yet. The server adds an entry the first time someone enters the admin ' + 'password on the Server Management screen.', + // Read-only here for a stronger reason than the default note gives: + // the server does not merely own the value, it rewrites this file on + // shutdown, so an edit made here would be discarded rather than just + // being awkward to express. + note: + 'Written by the server. Ban and unban from the in-game Server Management screen - the server ' + + 'rewrites this file and would overwrite an edit made here.', }, }, ]; diff --git a/frontend/src/games/schemas/playerlists.test.ts b/frontend/src/games/schemas/playerlists.test.ts new file mode 100644 index 0000000..a9b185f --- /dev/null +++ b/frontend/src/games/schemas/playerlists.test.ts @@ -0,0 +1,201 @@ +/** + * The four player lists, end to end. + * + * A column key here is not a label - it is half of a real address (`0.name`), + * so a typo produces a column of empty inputs rather than an error. Every test below + * therefore drives a real file through the game's own format rather than + * asserting against the schema alone. + */ +import { describe, expect, it } from 'vitest'; +import { useConfigForm } from '../../composables/useConfigForm'; +import { resolve, type GameConfig } from '../registry'; +import { allowlistSchema, opsSchema, permissionsSchema, whitelistSchema } from './playerlists'; + +const OPS = `[ + { + "uuid": "d8d5a923-7b20-43d8-883b-1150148d6955", + "name": "Notch", + "level": 4, + "bypassesPlayerLimit": false + }, + { + "uuid": "aaaaaaaa-0000-0000-0000-000000000001", + "name": "Herobrine", + "level": 2, + "bypassesPlayerLimit": true + } +] +`; + +const WHITELIST = `[ + { "uuid": "d8d5a923-7b20-43d8-883b-1150148d6955", "name": "Notch" } +] +`; + +const ALLOWLIST = `[ + { "ignoresPlayerLimit": false, "name": "Gamertag One" }, + { "ignoresPlayerLimit": true, "name": "Gamertag Two", "xuid": "2535000000000001" } +] +`; + +const PERMISSIONS = `[ + { "permission": "operator", "xuid": "2535000000000001" }, + { "permission": "visitor", "xuid": "2535000000000002" } +] +`; + +/** Mount a config the way ConfigEditor does: parse, then build the form. */ +function open(game: GameConfig, text: string) { + const doc = game.format.parse(text)!; + expect(doc, `${game.fileName} should parse`).not.toBeNull(); + return { doc, form: useConfigForm(doc, game.schema ?? [], game.format.codec) }; +} + +const columns = (schema: typeof opsSchema) => { + const table = schema[0].table; + if (table?.kind !== 'array-rows') throw new Error('expected an array-rows table'); + return table.columns; +}; + +describe('player list schemas', () => { + it('names columns the files actually have', () => { + // Read the keys out of a real file rather than restating the schema: + // this is the assertion that fails if Mojang renames a field or a + // column key is misspelled. + const keysOf = (game: GameConfig, text: string) => + new Set(game.format.parse(text)!.keys().map((k) => k.split('.')[1])); + + const cases: [GameConfig, string, typeof opsSchema][] = [ + [resolve('minecraft', 'ops.json')!, OPS, opsSchema], + [resolve('minecraft', 'whitelist.json')!, WHITELIST, whitelistSchema], + [resolve('minecraft-bedrock', 'allowlist.json')!, ALLOWLIST, allowlistSchema], + [resolve('minecraft-bedrock', 'permissions.json')!, PERMISSIONS, permissionsSchema], + ]; + for (const [game, text, schema] of cases) { + const present = keysOf(game, text); + for (const col of columns(schema)) { + expect(present.has(col.key), `${game.fileName}: no ${col.key} in the file`).toBe(true); + } + } + }); + + it('offers exactly the three permission levels Bedrock accepts', () => { + // An unrecognised level stops the server from starting, so the list is + // not decorative. + const permission = columns(permissionsSchema).find((c) => c.key === 'permission')!; + expect(permission.type).toBe('select'); + expect(permission.options).toEqual(['visitor', 'member', 'operator']); + }); + + it('types the ops level as a number and the flags as bools', () => { + const byKey = new Map(columns(opsSchema).map((c) => [c.key, c])); + expect(byKey.get('level')?.type).toBe('number'); + expect(byKey.get('bypassesPlayerLimit')?.type).toBe('bool'); + // The identity columns are plain text; a uuid is not a number. + expect(byKey.get('uuid')?.type).toBeUndefined(); + expect(byKey.get('name')?.type).toBeUndefined(); + }); +}); + +describe('ops.json, end to end', () => { + const game = () => resolve('minecraft', 'ops.json')!; + + it('renders one table and no per-player groups', () => { + const { form } = open(game(), OPS); + // Before the table this was `[0]`, `[1]`, ... - one heading per + // operator, each holding the same four fields. + expect(form.groups.value.map((g) => g.title)).toEqual(['Operators']); + expect(form.models['0.name'].value).toBe('Notch'); + expect(form.models['1.level'].value).toBe(2); + expect(form.models['1.bypassesPlayerLimit'].value).toBe(true); + }); + + it('writes a cell back with its JSON type intact', () => { + const { doc, form } = open(game(), OPS); + form.models['1.level'].value = 3; + form.models['0.bypassesPlayerLimit'].value = true; + + expect(form.writeError.value).toBeNull(); + expect(form.dirty.value).toBe(true); + + const out = JSON.parse(doc.serialize()); + expect(out[1].level).toBe(3); + expect(typeof out[1].level).toBe('number'); + expect(out[0].bypassesPlayerLimit).toBe(true); + expect(typeof out[0].bypassesPlayerLimit).toBe('boolean'); + // Everything the edit didn't name is untouched, ordering included. + expect(out[0].uuid).toBe('d8d5a923-7b20-43d8-883b-1150148d6955'); + expect(out[1].name).toBe('Herobrine'); + expect(out).toHaveLength(2); + }); + + it('leaves a list it only read byte-identical', () => { + const { doc } = open(game(), OPS); + expect(doc.serialize()).toBe(OPS); + }); + + it('still shows a key no column names, rather than hiding it', () => { + // The table's path is the document root, so excluding its whole subtree + // would hide every key in the file. Only the cells it renders are + // covered; anything else falls through to a generic group, as it would + // in any other file. + const { form } = open(game(), '[{ "name": "Notch", "level": 4, "futureField": "x" }]\n'); + expect(form.models['futureField']).toBeUndefined(); + expect(form.models['0.futureField'].value).toBe('x'); + expect(form.groups.value.map((g) => g.title)).toEqual(['Operators', '[0]']); + expect(form.groups.value[1].fields.map((f) => f.key)).toEqual(['0.futureField']); + }); + + it('says the list is empty instead of showing a bare table', () => { + const { doc, form } = open(game(), '[]\n'); + const table = form.groups.value[0].table; + expect(form.groups.value.map((g) => g.title)).toEqual(['Operators']); + // Kept, not hidden: an empty op list is a fact worth stating, unlike an + // ARK override list nobody set. + expect(table?.hideWhenEmpty).toBeUndefined(); + expect(table?.empty).toMatch(/no operators/i); + expect(doc.serialize()).toBe('[]\n'); + }); +}); + +describe('the Bedrock lists, end to end', () => { + it('reads an allow list whose rows disagree about which keys they have', () => { + // xuid only appears once a player has connected, so row 1 has it and + // row 0 does not. Every row still gets a cell for every column: the + // missing one reads empty and stays absent from the file until someone + // types in it, which is the one way to add an xuid the server will + // match on. + const game = resolve('minecraft-bedrock', 'allowlist.json')!; + const { doc, form } = open(game, ALLOWLIST); + expect(form.groups.value.map((g) => g.title)).toEqual(['Allowed players']); + expect(form.models['0.name'].value).toBe('Gamertag One'); + expect(form.models['1.xuid'].value).toBe('2535000000000001'); + expect(form.models['1.ignoresPlayerLimit'].value).toBe(true); + + expect(form.models['0.xuid'].value).toBe(''); + expect(form.dirty.value).toBe(false); + expect(JSON.parse(doc.serialize())[0]).not.toHaveProperty('xuid'); + + form.models['0.xuid'].value = '2535000000000009'; + expect(form.writeError.value).toBeNull(); + const out = JSON.parse(doc.serialize()); + expect(out[0].xuid).toBe('2535000000000009'); + expect(out[0].name).toBe('Gamertag One'); + expect(out).toHaveLength(2); + }); + + it('edits a permission level and keeps it a string', () => { + const game = resolve('minecraft-bedrock', 'permissions.json')!; + const { doc, form } = open(game, PERMISSIONS); + expect(form.models['0.permission'].value).toBe('operator'); + + form.models['1.permission'].value = 'member'; + expect(form.writeError.value).toBeNull(); + + const out = JSON.parse(doc.serialize()); + expect(out[1].permission).toBe('member'); + expect(typeof out[1].permission).toBe('string'); + expect(out[1].xuid).toBe('2535000000000002'); + expect(out[0].permission).toBe('operator'); + }); +}); diff --git a/frontend/src/games/schemas/playerlists.ts b/frontend/src/games/schemas/playerlists.ts new file mode 100644 index 0000000..0b1f917 --- /dev/null +++ b/frontend/src/games/schemas/playerlists.ts @@ -0,0 +1,133 @@ +/** + * The player lists - Minecraft's ops.json and whitelist.json, Bedrock's + * allowlist.json and permissions.json. + * + * Each of these files IS a JSON array of records. There is no root object to + * hang a dotted path off, so the array-expanding JSON format addresses them + * straight from the row index (`0.name`, `0.level`) and the table's path is the + * document root, `''`. + * + * Without a table they render through the generic editor as one group per + * player - four labelled fields under a heading called `[0]`, then `[1]`, then + * `[2]`. That is technically complete and practically unusable: a server with + * thirty operators is thirty headings, and comparing two players means scrolling + * between them. One row each is the whole point. + * + * They stay editable, matching what the registry's PLAYER_LIST_NOTE already + * promises: you can change an entry the file holds, but adding or removing a + * player is done in-game or from the console, because the server rewrites these + * files itself whenever the list changes. + * + * Columns lead with the name rather than the id. The uuid/xuid is what the + * server matches on and has to be shown, but it is not what an admin is looking + * for when they open the list. + */ +import type { Schema } from '../../formats/types'; + +/** Java: ops.json - uuid, name, permission level, and the player-cap bypass. */ +export const opsSchema: Schema = [ + { + id: 'operators', + title: 'Operators', + icon: 'user-shield', + fields: [], + table: { + kind: 'array-rows', + path: '', + columns: [ + { key: 'name', label: 'Player' }, + { key: 'uuid', label: 'UUID' }, + // 1 moderate, 2 gamemaster, 3 admin, 4 owner. A number, not a + // select: the file holds a JSON number and the codec keeps it + // one, and a level outside 1-4 is something to show, not clamp. + { key: 'level', label: 'Level (1-4)', type: 'number' }, + { key: 'bypassesPlayerLimit', label: 'Bypasses player limit', type: 'bool' }, + ], + empty: 'No operators. Op a player in-game or from the console with /op .', + }, + }, +]; + +/** Java: whitelist.json - just who is allowed in. */ +export const whitelistSchema: Schema = [ + { + id: 'whitelist', + title: 'Whitelisted players', + icon: 'users', + fields: [], + table: { + kind: 'array-rows', + path: '', + columns: [ + { key: 'name', label: 'Player' }, + { key: 'uuid', label: 'UUID' }, + ], + empty: + 'The whitelist is empty. With white-list=true in server.properties that means nobody can join - ' + + 'add players with /whitelist add .', + }, + }, +]; + +/** + * Bedrock: allowlist.json - the Bedrock spelling of a whitelist. + * + * `xuid` is optional and often absent until the player has connected once. Its + * cell is still there on such a row, reading empty; typing in it adds the key, + * which is what an admin wants when they are pinning an entry to an account + * rather than a gamertag. + */ +export const allowlistSchema: Schema = [ + { + id: 'allowlist', + title: 'Allowed players', + icon: 'users', + fields: [], + table: { + kind: 'array-rows', + path: '', + columns: [ + { key: 'name', label: 'Gamertag' }, + { key: 'xuid', label: 'XUID (blank until first join)' }, + { key: 'ignoresPlayerLimit', label: 'Ignores player limit', type: 'bool' }, + ], + empty: + 'The allow list is empty. With allow-list=true in server.properties that means nobody can join - ' + + 'add players with /allowlist add "".', + }, + }, +]; + +/** + * Bedrock: permissions.json - one permission level per XUID. + * + * Bedrock keys this file by XUID only; there is no name field, which is exactly + * why a table beats one group per entry here. Reading a column of ids is bad, + * but reading thirty headings that are each an id is worse. + */ +export const permissionsSchema: Schema = [ + { + id: 'permissions', + title: 'Player permissions', + icon: 'user-shield', + fields: [], + table: { + kind: 'array-rows', + path: '', + columns: [ + { key: 'xuid', label: 'XUID' }, + // The server refuses to start on an unrecognised level, so this + // is a select. FieldInput keeps a value outside the list rather + // than blanking it, so an unknown one is visible instead of + // being quietly wiped on the next save. + { + key: 'permission', + label: 'Permission', + type: 'select', + options: ['visitor', 'member', 'operator'], + }, + ], + empty: 'No per-player permissions. Everyone gets the default-player-permission-level from server.properties.', + }, + }, +];