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.

-
+
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 @@ + + + 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; +}