diff --git a/README.md b/README.md
index 9d214fb..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
@@ -165,10 +179,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 +201,36 @@ 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. 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
+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
@@ -211,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:
@@ -310,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
@@ -321,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/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.
-
+
@@ -212,6 +213,16 @@ const note = game?.note;
+
+
+
diff --git a/frontend/src/components/ConfigTable.test.ts b/frontend/src/components/ConfigTable.test.ts
new file mode 100644
index 0000000..da962b1
--- /dev/null
+++ b/frontend/src/components/ConfigTable.test.ts
@@ -0,0 +1,250 @@
+// @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 { games, 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);
+ });
+});
+
+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
new file mode 100644
index 0000000..578eba6
--- /dev/null
+++ b/frontend/src/components/ConfigTable.vue
@@ -0,0 +1,148 @@
+
+
+
+
+ {{ spec.empty ?? 'No entries in this file yet.' }}
+
+
+
+
+
+
+
+
+
+ {{ structRows.unparsed.length }} entr{{ structRows.unparsed.length === 1 ? 'y' : 'ies' }} did not
+ match the expected format and are shown as written:
+
+
{{ raw }}
+
+
+ {{ 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 a0e239b..d7d6a9d 100644
--- a/frontend/src/composables/useConfigForm.ts
+++ b/frontend/src/composables/useConfigForm.ts
@@ -8,7 +8,90 @@
* 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 = pathSegments(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 = pathSegments(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 {
+ 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;
+}
/**
* Guess a widget for a key the schema doesn't describe. Anything that isn't
@@ -30,7 +113,20 @@ 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. 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)),
+ ]);
const bySection = new Map();
for (const key of doc.keys()) {
@@ -73,10 +169,17 @@ 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 - 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]);
+
+ const cells = tableCells(doc, schema);
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..d16d9bd 100644
--- a/frontend/src/formats/types.ts
+++ b/frontend/src/formats/types.ts
@@ -33,12 +33,88 @@ 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;
+ /** 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;
+}
+
+/**
+ * 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 extends TableBase {
+ kind: 'struct-rows';
+ /** Address of the repeated key, e.g. addr(section, 'KnownPlayerList'). */
+ address: 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 extends TableBase {
+ kind: 'array-rows';
+ /**
+ * 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;
+}
+
+/**
+ * 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 +135,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..ca6bcb6
--- /dev/null
+++ b/frontend/src/formats/unrealStruct.test.ts
@@ -0,0 +1,92 @@
+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('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);
+ 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..81d35a7
--- /dev/null
+++ b/frontend/src/formats/unrealStruct.ts
@@ -0,0 +1,115 @@
+/**
+ * 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 - 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++;
+ }
+ 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..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);
@@ -679,6 +701,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/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.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..744c256 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,34 @@ 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.',
+ // 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/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/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.',
+ },
+ },
+];
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;
+}