diff --git a/README.md b/README.md index 8e14cfd..3b861fe 100644 --- a/README.md +++ b/README.md @@ -232,7 +232,8 @@ Every `Config.*` value from `config.lua`, including server-only sections, and ev file-owned because it decides whether SQL configuration is loaded. Lists, nested objects, vectors, and numeric-keyed Lua tables use structured editors instead of raw JSON. Shipped schema rows stay editable but cannot be renamed, converted, or removed. Every list and table still accepts any number -of additional rows; administrator-added rows remain removable. +of additional rows; administrator-added rows remain removable. Company job keys are intentionally +fully removable because `Config.Companies.Definitions` is a freely managed job collection. Media API keys and server peppers are never returned in plaintext to the NUI. Existing secrets are shown only as configured and are replaced only when an administrator enters a new value. @@ -562,8 +563,9 @@ Select `rtx`, `quasar`, `vms`, `rx`, `nolag`, `sn`, `esx_property`, or `qbx_prop Company jobs, public profiles, service numbers, services, permissions, locations, and default availability are configured under `Config.Companies.Definitions`. Definitions are not limited to the -shipped jobs: add any number of company IDs in the in-game configurator, choose `Table`, and fill the -freely configurable `Job` value in the generated full company template. +shipped jobs: add any number of company IDs in the in-game configurator and fill the freely +configurable `Job` value in the automatically generated full company template. Existing job keys can +also be removed; the remaining Companies settings stay available as normal individual fields. ### Weazel News diff --git a/frontend/src/components/AdminConfigValueEditor.vue b/frontend/src/components/AdminConfigValueEditor.vue index 590583e..9184296 100644 --- a/frontend/src/components/AdminConfigValueEditor.vue +++ b/frontend/src/components/AdminConfigValueEditor.vue @@ -107,6 +107,16 @@ const tableStructure = computed(() => const mapStructure = computed(() => props.structure?.kind === 'map' ? props.structure : null, ) +const canAddTableField = computed(() => { + const key = newObjectKey.value.trim() + if ( + !key || + key === '__skyType' || + Object.prototype.hasOwnProperty.call(tableValue.value, key) + ) + return false + return !tableStructure.value?.mutableKeys || /^[a-z0-9_-]+$/.test(key) +}) const tableEntries = computed(() => Object.entries(tableValue.value).filter( ([key]) => key !== '__skyType' && (!mapType.value || key !== 'entries'), @@ -163,6 +173,40 @@ function blankCollectionValue(kind: ValueKind, siblings: unknown[]): unknown { return template === undefined ? blankValue(kind) : blankLike(template) } +function blankFromStructure(structure: AdminConfiguratorStructure): unknown { + if (structure.kind === 'optionalString') return '' + if (structure.kind === 'value') return blankValue(structure.valueType) + if (structure.kind === 'vector') { + const axes = ['x', 'y', 'z', 'w'].slice( + 0, + Number(structure.vectorType.slice(-1)), + ) + return Object.fromEntries([ + ['__skyType', structure.vectorType], + ...axes.map((axis) => [axis, 0]), + ]) + } + if (structure.kind === 'list') { + return structure.items.map(blankFromStructure) + } + if (structure.kind === 'map') { + return { + __skyType: 'map', + entries: structure.entries.map((entry) => ({ + key: entry.key, + keyType: entry.keyType, + value: blankFromStructure(entry.structure), + })), + } + } + return Object.fromEntries( + Object.entries(structure.fields).map(([key, field]) => [ + key, + blankFromStructure(field), + ]), + ) +} + function updateScalar(event: Event): void { const target = event.target if (!(target instanceof HTMLInputElement)) return @@ -217,12 +261,30 @@ function updateTableField(key: string, value: unknown): void { emit('update:modelValue', { ...tableValue.value, [key]: value }) } -function removeTableField(key: string): void { - if ( - tableStructure.value && - Object.prototype.hasOwnProperty.call(tableStructure.value.fields, key) +function tableFieldStructure( + key: string, +): AdminConfiguratorStructure | undefined { + if (!tableStructure.value) return undefined + return ( + tableStructure.value.fields[key] ?? + (tableStructure.value.mutableKeys + ? tableStructure.value.template + : undefined) ) - return +} + +function isFixedTableField(key: string): boolean { + return ( + tableStructure.value?.mutableKeys !== true && + Object.prototype.hasOwnProperty.call( + tableStructure.value?.fields ?? {}, + key, + ) + ) +} + +function removeTableField(key: string): void { + if (isFixedTableField(key)) return const next = { ...tableValue.value } delete next[key] emit('update:modelValue', next) @@ -230,18 +292,18 @@ function removeTableField(key: string): void { function addTableField(): void { const key = newObjectKey.value.trim() - if ( - !key || - key === '__skyType' || - Object.prototype.hasOwnProperty.call(tableValue.value, key) - ) - return + if (!canAddTableField.value) return + const template = tableStructure.value?.mutableKeys + ? tableStructure.value.template + : undefined emit('update:modelValue', { ...tableValue.value, - [key]: blankCollectionValue( - newObjectKind.value, - Object.values(tableValue.value).filter((_, index) => index < 50), - ), + [key]: template + ? blankFromStructure(template) + : blankCollectionValue( + newObjectKind.value, + Object.values(tableValue.value).filter((_, index) => index < 50), + ), }) newObjectKey.value = '' } @@ -484,7 +546,7 @@ function mapEntryStructure( {{ key }} @@ -797,6 +858,10 @@ function mapEntryStructure( grid-template-columns: 76px minmax(90px, 1fr) 82px auto; } +.config-structured-editor__add-field.is-mutable-table { + grid-template-columns: minmax(100px, 1fr) auto; +} + .config-structured-editor__add-field input { min-width: 0; height: 25px; diff --git a/frontend/src/components/AdminPanel.contract.test.ts b/frontend/src/components/AdminPanel.contract.test.ts index a1310e5..892747a 100644 --- a/frontend/src/components/AdminPanel.contract.test.ts +++ b/frontend/src/components/AdminPanel.contract.test.ts @@ -262,6 +262,9 @@ describe('standalone admin panel contracts', () => { expect(configuratorServer).toContain('validate_structured_value') expect(configuratorServer).toContain('validate_locked_structure') expect(configuratorServer).toContain('build_structure(default_value') + expect(configuratorServer).toContain('path == "Companies.Definitions"') + expect(configuratorServer).toContain('flatten_company_fields') + expect(configuratorServer).toContain('structure.mutableKeys') expect(configuratorServer).toContain('field.type == "stringOrFalse"') expect(configuratorServer).not.toContain('Config.Media = client_payload') expect(configuratorClient).toContain( @@ -280,8 +283,10 @@ describe('standalone admin panel contracts', () => { expect(configuratorValueEditor).toContain('function updateMapKey(') expect(configuratorValueEditor).toContain('mapEntryStructure(current)') expect(configuratorValueEditor).toContain('listStructure?.items[index]') - expect(configuratorValueEditor).toContain('tableStructure?.fields[key]') + expect(configuratorValueEditor).toContain('function tableFieldStructure(') + expect(configuratorValueEditor).toContain('function isFixedTableField(') expect(configuratorValueEditor).toContain('function blankCollectionValue(') + expect(configuratorValueEditor).toContain('function blankFromStructure(') expect(configuratorValueEditor).not.toContain(' kind: 'table' + mutableKeys?: boolean + template?: AdminConfiguratorStructure } | { entries: Array<{ diff --git a/frontend/testserver/configurator-fixture.cjs b/frontend/testserver/configurator-fixture.cjs index 736581f..25b011a 100644 --- a/frontend/testserver/configurator-fixture.cjs +++ b/frontend/testserver/configurator-fixture.cjs @@ -404,6 +404,27 @@ function buildStructure(value, scope, path) { if (scope === 'config' && path === 'Phone.Keybind') { return { kind: 'optionalString' } } + if ( + scope === 'config' && + path === 'Companies.Definitions' && + value !== null && + typeof value === 'object' && + !Array.isArray(value) + ) { + const keys = Object.keys(value).sort() + const fields = Object.fromEntries( + keys.map((key) => [ + key, + buildStructure(value[key], scope, `${path}.${key}`), + ]), + ) + return { + fields, + kind: 'table', + mutableKeys: true, + template: keys[0] ? fields[keys[0]] : undefined, + } + } if ( scope === 'config' && path === 'Garage.VehicleImages.ModelNames' && @@ -452,7 +473,10 @@ function addField(fields, scope, path, value) { const sensitive = typeof value === 'string' && sensitivePath(path) fields.push({ configured: sensitive ? value !== '' : undefined, - label: humanize(path.split('.').at(-1)), + label: + scope === 'config' && path === 'Companies.Definitions' + ? 'Jobs' + : humanize(path.split('.').at(-1)), path, scope, sensitive, @@ -470,12 +494,44 @@ function addField(fields, scope, path, value) { }) } +function flattenCompanyFields(fields, path, value) { + if ( + path === 'Companies.Definitions' || + value === null || + typeof value !== 'object' || + Array.isArray(value) || + value.__skyType || + Object.keys(value).length === 0 + ) { + addField(fields, 'config', path, value) + return + } + + for (const key of Object.keys(value).sort()) { + flattenCompanyFields(fields, `${path}.${key}`, value[key]) + } +} + function buildSections(scope, payload) { const sections = [] const generalFields = [] for (const key of Object.keys(payload).sort()) { const value = payload[key] - if ( + if (scope === 'config' && key === 'Companies') { + const fields = [] + flattenCompanyFields(fields, 'Companies', value) + fields.sort((left, right) => { + if (left.path === 'Companies.Definitions') return 1 + if (right.path === 'Companies.Definitions') return -1 + return left.path.localeCompare(right.path) + }) + sections.push({ + fields, + id: 'config:Companies', + label: humanize(key), + scope, + }) + } else if ( value !== null && typeof value === 'object' && !Array.isArray(value) && diff --git a/frontend/testserver/configurator-fixture.test.ts b/frontend/testserver/configurator-fixture.test.ts index c52e628..6e81b2d 100644 --- a/frontend/testserver/configurator-fixture.test.ts +++ b/frontend/testserver/configurator-fixture.test.ts @@ -4,6 +4,7 @@ import { createRequire } from 'node:module' import { describe, expect, it } from 'vitest' type ConfiguratorField = { + label: string path: string structure?: ConfiguratorStructure type: string @@ -15,6 +16,8 @@ type ConfiguratorStructure = { fields?: Record items?: ConfiguratorStructure[] kind: string + mutableKeys?: boolean + template?: ConfiguratorStructure } type ConfiguratorSection = { @@ -94,7 +97,9 @@ describe('admin configurator fixture', () => { ) const darkChat = fields.find((field) => field.path === 'DarkChat') const phone = fields.find((field) => field.path === 'Phone') - const companies = fields.find((field) => field.path === 'Companies') + const companyJobs = fields.find( + (field) => field.path === 'Companies.Definitions', + ) const garage = fields.find((field) => field.path === 'Garage') const timers = (darkChat?.value as Record | undefined) ?.AllowedDisappearTimers @@ -109,7 +114,16 @@ describe('admin configurator fixture', () => { }) expect(darkChat?.structure?.fields?.AllowedDisappearTimers.kind).toBe('map') expect(phone?.structure?.fields?.Keybind.kind).toBe('optionalString') - expect(companies?.structure?.fields?.Definitions.kind).toBe('table') + expect(companyJobs?.label).toBe('Jobs') + expect(companyJobs?.structure).toMatchObject({ + fields: { + ambulance: { kind: 'table' }, + police: { kind: 'table' }, + }, + kind: 'table', + mutableKeys: true, + template: { kind: 'table' }, + }) expect( garage?.structure?.fields?.VehicleImages.fields?.ModelNames.kind, ).toBe('map') diff --git a/sky_phone/source/server/phone_configurator.lua b/sky_phone/source/server/phone_configurator.lua index 4fb3cea..74f2c0e 100644 --- a/sky_phone/source/server/phone_configurator.lua +++ b/sky_phone/source/server/phone_configurator.lua @@ -235,10 +235,14 @@ local function upgrade_legacy_map(defaults, saved) return { __skyType = "map", entries = entries } end -local function merge_values(defaults, saved) +local function merge_values(defaults, saved, path) + path = path or "" if type(defaults) ~= "table" or type(saved) ~= "table" then return copy_value(saved) end + if path == "Companies.Definitions" then + return copy_value(saved) + end if defaults.__skyType == "map" and not saved.__skyType then saved = upgrade_legacy_map(defaults, saved) end @@ -256,7 +260,9 @@ local function merge_values(defaults, saved) entries[#entries + 1] = { key = entry.key, keyType = entry.keyType, - value = saved_entry and merge_values(entry.value, saved_entry.value) or copy_value(entry.value), + value = saved_entry + and merge_values(entry.value, saved_entry.value, path .. "." .. tostring(entry.key)) + or copy_value(entry.value), } included[identity] = true end @@ -274,7 +280,9 @@ local function merge_values(defaults, saved) if is_sequence(defaults) then local merged = copy_value(saved) for index, child in ipairs(defaults) do - merged[index] = saved[index] ~= nil and merge_values(child, saved[index]) or copy_value(child) + merged[index] = saved[index] ~= nil + and merge_values(child, saved[index], path .. "." .. tostring(index)) + or copy_value(child) end return merged end @@ -285,7 +293,8 @@ local function merge_values(defaults, saved) local merged = copy_value(defaults) for key, child in pairs(saved) do if merged[key] ~= nil then - merged[key] = merge_values(merged[key], child) + local child_path = path == "" and tostring(key) or (path .. "." .. tostring(key)) + merged[key] = merge_values(merged[key], child, child_path) else merged[key] = copy_value(child) end @@ -440,6 +449,25 @@ local function build_structure(value, scope, path) if scope == "config" and path == "Phone.Keybind" then return { kind = "optionalString" } end + if scope == "config" and path == "Companies.Definitions" and value_type == "table" then + local keys = {} + for key in pairs(value) do + keys[#keys + 1] = key + end + table.sort(keys, function(left, right) + return tostring(left) < tostring(right) + end) + local fields = {} + for _, key in ipairs(keys) do + fields[key] = build_structure(value[key], scope, path .. "." .. tostring(key)) + end + return { + fields = fields, + kind = "table", + mutableKeys = true, + template = keys[1] and fields[keys[1]] or nil, + } + end if value_type ~= "table" then return { kind = "value", @@ -516,7 +544,9 @@ local function add_field(fields, field_index, scope, path, value, default_value) local sensitive = type(value) == "string" and sensitive_path(path) local field = { configured = sensitive and value ~= "" or nil, - label = humanize(path:match("([^.]+)$") or path), + label = scope == "config" and path == "Companies.Definitions" + and "Jobs" + or humanize(path:match("([^.]+)$") or path), path = path, scope = scope, sensitive = sensitive, @@ -528,6 +558,35 @@ local function add_field(fields, field_index, scope, path, value, default_value) field_index[scope .. ":" .. path] = field end +local function flatten_company_fields(fields, field_index, path, value, default_value) + if path == "Companies.Definitions" + or type(value) ~= "table" + or value.__skyType + or is_sequence(value) + or next(value) == nil + then + add_field(fields, field_index, "config", path, value, default_value) + return + end + + local keys = {} + for key in pairs(value) do + keys[#keys + 1] = key + end + table.sort(keys, function(left, right) + return tostring(left) < tostring(right) + end) + for _, key in ipairs(keys) do + flatten_company_fields( + fields, + field_index, + path .. "." .. tostring(key), + value[key], + type(default_value) == "table" and default_value[key] or nil + ) + end +end + local function build_sections(scope, payload, defaults, sections, field_index) local general_fields = {} local keys = {} @@ -540,7 +599,25 @@ local function build_sections(scope, payload, defaults, sections, field_index) for _, key in ipairs(keys) do local value = payload[key] - if type(value) == "table" and not value.__skyType and not is_sequence(value) and next(value) ~= nil then + if scope == "config" and key == "Companies" then + local fields = {} + flatten_company_fields(fields, field_index, "Companies", value, defaults[key]) + table.sort(fields, function(left, right) + if left.path == "Companies.Definitions" then + return false + end + if right.path == "Companies.Definitions" then + return true + end + return left.path < right.path + end) + sections[#sections + 1] = { + fields = fields, + id = "config:Companies", + label = humanize(key), + scope = scope, + } + elseif type(value) == "table" and not value.__skyType and not is_sequence(value) and next(value) ~= nil then local fields = {} add_field(fields, field_index, scope, tostring(key), value, defaults[key]) sections[#sections + 1] = { @@ -728,9 +805,22 @@ local function validate_locked_structure(structure, value) if type(value) ~= "table" or value.__skyType or is_sequence(value) then return false end - for key, field_structure in pairs(structure.fields or {}) do - if value[key] == nil or not validate_locked_structure(field_structure, value[key]) then - return false + if not structure.mutableKeys then + for key, field_structure in pairs(structure.fields or {}) do + if value[key] == nil or not validate_locked_structure(field_structure, value[key]) then + return false + end + end + end + if structure.mutableKeys and structure.template then + for key, child in pairs(value) do + if type(key) ~= "string" + or #key > 64 + or not key:match("^[a-z0-9_-]+$") + or not validate_locked_structure(structure.template, child) + then + return false + end end end return true @@ -832,8 +922,8 @@ if not row then error("[sky_phone] Phone configurator could not load its SQL row after initialization.") end -stored_config = merge_values(default_config, decode_payload(row.config_payload, "config")) -stored_media = merge_values(default_media, decode_payload(row.media_payload, "media")) +stored_config = merge_values(default_config, decode_payload(row.config_payload, "config"), "") +stored_media = merge_values(default_media, decode_payload(row.media_payload, "media"), "") revision = tonumber(row.revision) or 1 updated_at = row.updated_at updated_by_name = row.updated_by_name