mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
Revert "ADD - add in-game phone administration and configuration (#21)"
This reverts commit edd9677f43.
This commit is contained in:
@@ -1,668 +0,0 @@
|
||||
const { readFileSync } = require('node:fs')
|
||||
const { resolve } = require('node:path')
|
||||
|
||||
class LuaTable {
|
||||
constructor() {
|
||||
this.entries = []
|
||||
this.nextArrayIndex = 1
|
||||
}
|
||||
|
||||
get(key) {
|
||||
return this.entries.find((entry) => entry.key === key)?.value
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
const entry = this.entries.find((candidate) => candidate.key === key)
|
||||
if (entry) entry.value = value
|
||||
else this.entries.push({ key, value })
|
||||
}
|
||||
}
|
||||
|
||||
function tokenize(source) {
|
||||
const tokens = []
|
||||
let index = 0
|
||||
|
||||
while (index < source.length) {
|
||||
const character = source[index]
|
||||
if (/\s/.test(character)) {
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
if (source.startsWith('--[[', index)) {
|
||||
const end = source.indexOf(']]', index + 4)
|
||||
index = end < 0 ? source.length : end + 2
|
||||
continue
|
||||
}
|
||||
if (source.startsWith('--', index)) {
|
||||
const end = source.indexOf('\n', index + 2)
|
||||
index = end < 0 ? source.length : end + 1
|
||||
continue
|
||||
}
|
||||
if (character === '"' || character === "'") {
|
||||
const quote = character
|
||||
let value = ''
|
||||
index += 1
|
||||
while (index < source.length && source[index] !== quote) {
|
||||
if (source[index] !== '\\') {
|
||||
value += source[index]
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
index += 1
|
||||
const escaped = source[index]
|
||||
const escapeValues = {
|
||||
a: '\u0007',
|
||||
b: '\b',
|
||||
f: '\f',
|
||||
n: '\n',
|
||||
r: '\r',
|
||||
t: '\t',
|
||||
v: '\u000b',
|
||||
}
|
||||
value += escapeValues[escaped] ?? escaped
|
||||
index += 1
|
||||
}
|
||||
if (source[index] !== quote)
|
||||
throw new Error('Unterminated Lua string in configurator fixture.')
|
||||
index += 1
|
||||
tokens.push({ type: 'string', value })
|
||||
continue
|
||||
}
|
||||
if (/[A-Za-z_]/.test(character)) {
|
||||
const match = source.slice(index).match(/^[A-Za-z_][A-Za-z0-9_]*/)
|
||||
tokens.push({ type: 'name', value: match[0] })
|
||||
index += match[0].length
|
||||
continue
|
||||
}
|
||||
if (
|
||||
/\d/.test(character) ||
|
||||
(character === '.' && /\d/.test(source[index + 1]))
|
||||
) {
|
||||
const match = source
|
||||
.slice(index)
|
||||
.match(/^(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/)
|
||||
tokens.push({ type: 'number', value: Number(match[0]) })
|
||||
index += match[0].length
|
||||
continue
|
||||
}
|
||||
|
||||
tokens.push({ type: 'symbol', value: character })
|
||||
index += 1
|
||||
}
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
class LuaConfigParser {
|
||||
constructor(source) {
|
||||
this.tokens = tokenize(source)
|
||||
this.index = 0
|
||||
this.config = new LuaTable()
|
||||
}
|
||||
|
||||
current(offset = 0) {
|
||||
return this.tokens[this.index + offset]
|
||||
}
|
||||
|
||||
matches(value, offset = 0) {
|
||||
return this.current(offset)?.value === value
|
||||
}
|
||||
|
||||
take(value) {
|
||||
const token = this.current()
|
||||
if (!token || (value !== undefined && token.value !== value)) {
|
||||
throw new Error(
|
||||
`Expected '${value}', received '${token?.value ?? 'end of file'}' in configurator fixture.`,
|
||||
)
|
||||
}
|
||||
this.index += 1
|
||||
return token
|
||||
}
|
||||
|
||||
parse() {
|
||||
while (this.current()) {
|
||||
if (this.matches('Config')) {
|
||||
const start = this.index
|
||||
const path = this.parsePath()
|
||||
if (path.length && this.matches('=')) {
|
||||
this.take('=')
|
||||
this.setPath(path, this.parseExpression())
|
||||
continue
|
||||
}
|
||||
this.index = start + 1
|
||||
continue
|
||||
}
|
||||
this.index += 1
|
||||
}
|
||||
return this.config
|
||||
}
|
||||
|
||||
parsePath() {
|
||||
this.take('Config')
|
||||
const path = []
|
||||
while (this.matches('.') && this.current(1)?.type === 'name') {
|
||||
this.take('.')
|
||||
path.push(this.take().value)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
getPath(path) {
|
||||
let value = this.config
|
||||
for (const key of path) {
|
||||
if (!(value instanceof LuaTable)) return undefined
|
||||
value = value.get(key)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
setPath(path, value) {
|
||||
let parent = this.config
|
||||
for (let index = 0; index < path.length - 1; index += 1) {
|
||||
const key = path[index]
|
||||
let child = parent.get(key)
|
||||
if (!(child instanceof LuaTable)) {
|
||||
child = new LuaTable()
|
||||
parent.set(key, child)
|
||||
}
|
||||
parent = child
|
||||
}
|
||||
parent.set(path.at(-1), value)
|
||||
}
|
||||
|
||||
parseExpression() {
|
||||
return this.parseOr()
|
||||
}
|
||||
|
||||
parseOr() {
|
||||
let value = this.parseAdditive()
|
||||
while (this.matches('or')) {
|
||||
this.take('or')
|
||||
const fallback = this.parseAdditive()
|
||||
value =
|
||||
value !== false && value !== null && value !== undefined
|
||||
? value
|
||||
: fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
parseAdditive() {
|
||||
let value = this.parseMultiplicative()
|
||||
while (this.matches('+') || this.matches('-')) {
|
||||
const operator = this.take().value
|
||||
const right = this.parseMultiplicative()
|
||||
value =
|
||||
operator === '+'
|
||||
? Number(value) + Number(right)
|
||||
: Number(value) - Number(right)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
parseMultiplicative() {
|
||||
let value = this.parseUnary()
|
||||
while (this.matches('*') || this.matches('/')) {
|
||||
const operator = this.take().value
|
||||
const right = this.parseUnary()
|
||||
value =
|
||||
operator === '*'
|
||||
? Number(value) * Number(right)
|
||||
: Number(value) / Number(right)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
parseUnary() {
|
||||
if (this.matches('-')) {
|
||||
this.take('-')
|
||||
return -Number(this.parseUnary())
|
||||
}
|
||||
return this.parsePrimary()
|
||||
}
|
||||
|
||||
parsePrimary() {
|
||||
const token = this.current()
|
||||
if (!token)
|
||||
throw new Error(
|
||||
'Unexpected end of Lua configuration in configurator fixture.',
|
||||
)
|
||||
if (token.type === 'number' || token.type === 'string') {
|
||||
this.index += 1
|
||||
return token.value
|
||||
}
|
||||
if (this.matches('true') || this.matches('false') || this.matches('nil')) {
|
||||
this.index += 1
|
||||
return token.value === 'true'
|
||||
? true
|
||||
: token.value === 'false'
|
||||
? false
|
||||
: null
|
||||
}
|
||||
if (this.matches('{')) return this.parseTable()
|
||||
if (this.matches('(')) {
|
||||
this.take('(')
|
||||
const value = this.parseExpression()
|
||||
this.take(')')
|
||||
return value
|
||||
}
|
||||
if (this.matches('Config')) return this.getPath(this.parsePath())
|
||||
if (token.type === 'name' && this.current(1)?.value === '(') {
|
||||
const name = this.take().value
|
||||
this.take('(')
|
||||
const argumentsList = []
|
||||
while (!this.matches(')')) {
|
||||
argumentsList.push(this.parseExpression())
|
||||
if (!this.matches(',')) break
|
||||
this.take(',')
|
||||
}
|
||||
this.take(')')
|
||||
if (/^vector[234]$/.test(name)) {
|
||||
const axes = ['x', 'y', 'z', 'w']
|
||||
return Object.fromEntries([
|
||||
['__skyType', name],
|
||||
...argumentsList.map((value, index) => [axes[index], value]),
|
||||
])
|
||||
}
|
||||
throw new Error(`Unsupported Lua call '${name}' in configurator fixture.`)
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Unsupported Lua token '${token.value}' in configurator fixture.`,
|
||||
)
|
||||
}
|
||||
|
||||
parseTable() {
|
||||
const table = new LuaTable()
|
||||
this.take('{')
|
||||
while (!this.matches('}')) {
|
||||
if (this.matches('[')) {
|
||||
this.take('[')
|
||||
const key = this.parseExpression()
|
||||
this.take(']')
|
||||
this.take('=')
|
||||
table.set(key, this.parseExpression())
|
||||
} else if (
|
||||
this.current()?.type === 'name' &&
|
||||
this.current(1)?.value === '='
|
||||
) {
|
||||
const key = this.take().value
|
||||
this.take('=')
|
||||
table.set(key, this.parseExpression())
|
||||
} else {
|
||||
table.set(table.nextArrayIndex, this.parseExpression())
|
||||
table.nextArrayIndex += 1
|
||||
}
|
||||
|
||||
if (this.matches(',') || this.matches(';')) this.index += 1
|
||||
else if (!this.matches('}')) {
|
||||
throw new Error(
|
||||
`Expected a Lua table separator, received '${this.current()?.value ?? 'end of file'}'.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
this.take('}')
|
||||
return table
|
||||
}
|
||||
}
|
||||
|
||||
function serializeLuaValue(value) {
|
||||
if (!(value instanceof LuaTable)) {
|
||||
if (Array.isArray(value)) return value.map(serializeLuaValue)
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, child]) => [
|
||||
key,
|
||||
serializeLuaValue(child),
|
||||
]),
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const numericKeys = value.entries
|
||||
.filter((entry) => typeof entry.key === 'number')
|
||||
.map((entry) => entry.key)
|
||||
.sort((left, right) => left - right)
|
||||
const isSequence =
|
||||
numericKeys.length === value.entries.length &&
|
||||
numericKeys.every((key, index) => key === index + 1)
|
||||
if (isSequence) {
|
||||
return [...value.entries]
|
||||
.sort((left, right) => left.key - right.key)
|
||||
.map((entry) => serializeLuaValue(entry.value))
|
||||
}
|
||||
if (value.entries.every((entry) => typeof entry.key === 'string')) {
|
||||
return Object.fromEntries(
|
||||
value.entries.map((entry) => [entry.key, serializeLuaValue(entry.value)]),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
__skyType: 'map',
|
||||
entries: value.entries.map((entry) => ({
|
||||
key: entry.key,
|
||||
keyType: typeof entry.key,
|
||||
value: serializeLuaValue(entry.value),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
function humanize(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
||||
.replace(/([A-Za-z])(\d)/g, '$1 $2')
|
||||
.replace(/^./, (character) => character.toUpperCase())
|
||||
}
|
||||
|
||||
function sensitivePath(path) {
|
||||
const leaf = path.split('.').at(-1) ?? path
|
||||
const normalized = leaf.toLowerCase().replace(/[^a-z0-9]/g, '')
|
||||
return (
|
||||
normalized.includes('apikey') ||
|
||||
normalized.includes('secret') ||
|
||||
normalized.includes('pepper') ||
|
||||
[
|
||||
'password',
|
||||
'token',
|
||||
'authorization',
|
||||
'credential',
|
||||
'connectionstring',
|
||||
].includes(normalized)
|
||||
)
|
||||
}
|
||||
|
||||
function maskValue(value, path) {
|
||||
if (Array.isArray(value))
|
||||
return value.map((child, index) => maskValue(child, `${path}.${index + 1}`))
|
||||
if (value?.__skyType === 'map' && Array.isArray(value.entries)) {
|
||||
return {
|
||||
__skyType: 'map',
|
||||
entries: value.entries.map((entry) => ({
|
||||
key: entry.key,
|
||||
keyType: entry.keyType,
|
||||
value: maskValue(entry.value, `${path}.${entry.key}`),
|
||||
})),
|
||||
}
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, child]) => [
|
||||
key,
|
||||
maskValue(child, `${path}.${key}`),
|
||||
]),
|
||||
)
|
||||
}
|
||||
return typeof value === 'string' && sensitivePath(path)
|
||||
? '***REDACTED***'
|
||||
: value
|
||||
}
|
||||
|
||||
function emptyStructure(scope, path) {
|
||||
if (scope !== 'config') return undefined
|
||||
if (path === 'Garage.VehicleImages.ModelNames') {
|
||||
return {
|
||||
entries: [],
|
||||
keyType: 'number',
|
||||
kind: 'map',
|
||||
template: { kind: 'value', valueType: 'string' },
|
||||
}
|
||||
}
|
||||
if (
|
||||
path === 'CrewLink.ExternalPingResources' ||
|
||||
path === 'CustomApps.TrustedAdapters'
|
||||
) {
|
||||
return {
|
||||
fields: {},
|
||||
kind: 'table',
|
||||
mutableKeys: true,
|
||||
template: { kind: 'value', valueType: 'boolean' },
|
||||
}
|
||||
}
|
||||
if (path === 'FlipTok.MusicTracks') {
|
||||
return {
|
||||
items: [],
|
||||
kind: 'list',
|
||||
template: {
|
||||
fields: {
|
||||
Artist: { kind: 'value', valueType: 'string' },
|
||||
Id: { kind: 'value', valueType: 'string' },
|
||||
Title: { kind: 'value', valueType: 'string' },
|
||||
Url: { kind: 'value', valueType: 'string' },
|
||||
},
|
||||
kind: 'table',
|
||||
},
|
||||
}
|
||||
}
|
||||
if (path === 'Music.Tracks') {
|
||||
return {
|
||||
items: [],
|
||||
kind: 'list',
|
||||
template: {
|
||||
fields: {
|
||||
Artist: { kind: 'value', valueType: 'string' },
|
||||
Id: { kind: 'value', valueType: 'string' },
|
||||
Title: { kind: 'value', valueType: 'string' },
|
||||
},
|
||||
kind: 'table',
|
||||
},
|
||||
}
|
||||
}
|
||||
if (path === 'Payphones.CustomLocations') {
|
||||
return {
|
||||
items: [],
|
||||
kind: 'list',
|
||||
template: { kind: 'vector', vectorType: 'vector4' },
|
||||
}
|
||||
}
|
||||
if (/^Companies\.Definitions\.[^.]+\.Services$/.test(path)) {
|
||||
return {
|
||||
items: [],
|
||||
kind: 'list',
|
||||
template: {
|
||||
fields: {
|
||||
Description: { kind: 'value', valueType: 'string' },
|
||||
Id: { kind: 'value', valueType: 'string' },
|
||||
Price: { kind: 'value', valueType: 'string' },
|
||||
RequestsEnabled: { kind: 'value', valueType: 'boolean' },
|
||||
Title: { kind: 'value', valueType: 'string' },
|
||||
},
|
||||
kind: 'table',
|
||||
},
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
const configuredEmptyStructure =
|
||||
Array.isArray(value) && value.length === 0
|
||||
? emptyStructure(scope, path)
|
||||
: undefined
|
||||
if (configuredEmptyStructure) {
|
||||
return configuredEmptyStructure
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const items = value.map((child, index) =>
|
||||
buildStructure(child, scope, `${path}.${index + 1}`),
|
||||
)
|
||||
return {
|
||||
kind: 'list',
|
||||
items,
|
||||
template: items[0],
|
||||
}
|
||||
}
|
||||
if (value?.__skyType === 'map' && Array.isArray(value.entries)) {
|
||||
const entries = value.entries.map((entry) => ({
|
||||
key: entry.key,
|
||||
keyType: entry.keyType,
|
||||
structure: buildStructure(entry.value, scope, `${path}.${entry.key}`),
|
||||
}))
|
||||
const keyTypes = new Set(entries.map((entry) => entry.keyType))
|
||||
return {
|
||||
kind: 'map',
|
||||
entries,
|
||||
keyType: keyTypes.size === 1 ? entries[0]?.keyType : undefined,
|
||||
template: entries[0]?.structure,
|
||||
}
|
||||
}
|
||||
if (/^vector[234]$/.test(value?.__skyType ?? '')) {
|
||||
return { kind: 'vector', vectorType: value.__skyType }
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
return {
|
||||
kind: 'table',
|
||||
fields: Object.fromEntries(
|
||||
Object.entries(value).map(([key, child]) => [
|
||||
key,
|
||||
buildStructure(child, scope, `${path}.${key}`),
|
||||
]),
|
||||
),
|
||||
}
|
||||
}
|
||||
return { kind: 'value', valueType: typeof value }
|
||||
}
|
||||
|
||||
function addField(fields, scope, path, value) {
|
||||
const valueType = Array.isArray(value) ? 'json' : typeof value
|
||||
const sensitive = typeof value === 'string' && sensitivePath(path)
|
||||
fields.push({
|
||||
configured: sensitive ? value !== '' : undefined,
|
||||
label:
|
||||
scope === 'config' && path === 'Companies.Definitions'
|
||||
? 'Jobs'
|
||||
: humanize(path.split('.').at(-1)),
|
||||
path,
|
||||
scope,
|
||||
sensitive,
|
||||
structure:
|
||||
value !== null && typeof value === 'object'
|
||||
? buildStructure(value, scope, path)
|
||||
: undefined,
|
||||
type:
|
||||
scope === 'config' && path === 'Phone.Keybind'
|
||||
? 'stringOrFalse'
|
||||
: value !== null && typeof value === 'object'
|
||||
? 'json'
|
||||
: valueType,
|
||||
value: sensitive ? '' : maskValue(value, path),
|
||||
})
|
||||
}
|
||||
|
||||
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 (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) &&
|
||||
!value.__skyType &&
|
||||
Object.keys(value).length > 0
|
||||
) {
|
||||
const fields = []
|
||||
addField(fields, scope, key, value)
|
||||
sections.push({
|
||||
fields,
|
||||
id: `${scope}:${key}`,
|
||||
label: humanize(key),
|
||||
scope,
|
||||
})
|
||||
} else {
|
||||
addField(generalFields, scope, key, value)
|
||||
}
|
||||
}
|
||||
if (generalFields.length) {
|
||||
const general = {
|
||||
fields: generalFields,
|
||||
id: `${scope}:general`,
|
||||
label: 'General',
|
||||
scope,
|
||||
}
|
||||
if (scope === 'config') sections.unshift(general)
|
||||
else sections.push(general)
|
||||
}
|
||||
return sections
|
||||
}
|
||||
|
||||
function loadConfiguratorSections() {
|
||||
const resourceRoot = resolve(__dirname, '../..')
|
||||
const config = serializeLuaValue(
|
||||
new LuaConfigParser(
|
||||
readFileSync(
|
||||
resolve(resourceRoot, 'sky_phone/config/config.lua'),
|
||||
'utf8',
|
||||
),
|
||||
).parse(),
|
||||
)
|
||||
const mediaRoot = serializeLuaValue(
|
||||
new LuaConfigParser(
|
||||
readFileSync(resolve(resourceRoot, 'sky_phone/config/media.lua'), 'utf8'),
|
||||
).parse(),
|
||||
)
|
||||
const media = mediaRoot.Media
|
||||
delete config.PhoneConfigurator
|
||||
delete config.Media
|
||||
return [...buildSections('config', config), ...buildSections('media', media)]
|
||||
}
|
||||
|
||||
module.exports = { loadConfiguratorSections }
|
||||
@@ -1,194 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
type ConfiguratorField = {
|
||||
label: string
|
||||
path: string
|
||||
structure?: ConfiguratorStructure
|
||||
type: string
|
||||
value: unknown
|
||||
}
|
||||
|
||||
type ConfiguratorStructure = {
|
||||
entries?: Array<{ structure: ConfiguratorStructure }>
|
||||
fields?: Record<string, ConfiguratorStructure>
|
||||
items?: ConfiguratorStructure[]
|
||||
keyType?: 'number' | 'string'
|
||||
kind: string
|
||||
mutableKeys?: boolean
|
||||
template?: ConfiguratorStructure
|
||||
}
|
||||
|
||||
type ConfiguratorSection = {
|
||||
fields: ConfiguratorField[]
|
||||
id: string
|
||||
scope: 'config' | 'media'
|
||||
}
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { loadConfiguratorSections } = require('./configurator-fixture.cjs') as {
|
||||
loadConfiguratorSections: () => ConfiguratorSection[]
|
||||
}
|
||||
|
||||
const configSource = readFileSync(
|
||||
new URL('../../sky_phone/config/config.lua', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
function countStructure(structure: ConfiguratorStructure | undefined): number {
|
||||
if (!structure) return 1
|
||||
if (structure.fields) {
|
||||
return Object.values(structure.fields).reduce(
|
||||
(total, field) => total + countStructure(field),
|
||||
0,
|
||||
)
|
||||
}
|
||||
if (structure.items) {
|
||||
return structure.items.reduce(
|
||||
(total, item) => total + countStructure(item),
|
||||
0,
|
||||
)
|
||||
}
|
||||
if (structure.entries) {
|
||||
return structure.entries.reduce(
|
||||
(total, entry) => total + countStructure(entry.structure),
|
||||
0,
|
||||
)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
describe('admin configurator fixture', () => {
|
||||
it('exposes every config.lua root through the full live preview', () => {
|
||||
const sections = loadConfiguratorSections()
|
||||
const fields = sections.flatMap((section) => section.fields)
|
||||
const roots = [
|
||||
...configSource.matchAll(/^\s{0,4}Config\.([A-Za-z0-9_]+)\s*=/gm),
|
||||
]
|
||||
.map((match) => match[1])
|
||||
.filter((root) => root !== 'Media' && root !== 'PhoneConfigurator')
|
||||
|
||||
expect(sections).toHaveLength(45)
|
||||
expect(
|
||||
fields.reduce(
|
||||
(total, field) => total + countStructure(field.structure),
|
||||
0,
|
||||
),
|
||||
).toBeGreaterThan(700)
|
||||
for (const root of new Set(roots)) {
|
||||
expect(
|
||||
sections.some(
|
||||
(section) =>
|
||||
section.id === `config:${root}` ||
|
||||
section.fields.some(
|
||||
(field) =>
|
||||
field.path === root || field.path.startsWith(`${root}.`),
|
||||
),
|
||||
),
|
||||
`Missing Config.${root}`,
|
||||
).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves numeric Lua map keys and the false keybind option', () => {
|
||||
const fields = loadConfiguratorSections().flatMap(
|
||||
(section) => section.fields,
|
||||
)
|
||||
const darkChat = fields.find((field) => field.path === 'DarkChat')
|
||||
const phone = fields.find((field) => field.path === 'Phone')
|
||||
const companyJobs = fields.find(
|
||||
(field) => field.path === 'Companies.Definitions',
|
||||
)
|
||||
const garage = fields.find((field) => field.path === 'Garage')
|
||||
const timers = (darkChat?.value as Record<string, unknown> | undefined)
|
||||
?.AllowedDisappearTimers
|
||||
|
||||
expect(timers).toMatchObject({
|
||||
__skyType: 'map',
|
||||
entries: expect.arrayContaining([
|
||||
{ key: -1, keyType: 'number', value: true },
|
||||
{ key: 0, keyType: 'number', value: true },
|
||||
{ key: 604800, keyType: 'number', value: true },
|
||||
]),
|
||||
})
|
||||
expect(darkChat?.structure?.fields?.AllowedDisappearTimers.kind).toBe('map')
|
||||
expect(phone?.structure?.fields?.Keybind.kind).toBe('optionalString')
|
||||
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')
|
||||
})
|
||||
|
||||
it('publishes fixed schemas for every empty configurable collection', () => {
|
||||
const fields = loadConfiguratorSections().flatMap(
|
||||
(section) => section.fields,
|
||||
)
|
||||
const root = (path: string) =>
|
||||
fields.find((field) => field.path === path)?.structure
|
||||
|
||||
expect(root('Music')?.fields?.Tracks).toMatchObject({
|
||||
items: [],
|
||||
kind: 'list',
|
||||
template: {
|
||||
fields: {
|
||||
Artist: { kind: 'value', valueType: 'string' },
|
||||
Id: { kind: 'value', valueType: 'string' },
|
||||
Title: { kind: 'value', valueType: 'string' },
|
||||
},
|
||||
kind: 'table',
|
||||
},
|
||||
})
|
||||
expect(root('FlipTok')?.fields?.MusicTracks.template).toMatchObject({
|
||||
fields: { Url: { kind: 'value', valueType: 'string' } },
|
||||
kind: 'table',
|
||||
})
|
||||
expect(root('Payphones')?.fields?.CustomLocations.template).toEqual({
|
||||
kind: 'vector',
|
||||
vectorType: 'vector4',
|
||||
})
|
||||
expect(root('CrewLink')?.fields?.ExternalPingResources).toMatchObject({
|
||||
fields: {},
|
||||
kind: 'table',
|
||||
mutableKeys: true,
|
||||
template: { kind: 'value', valueType: 'boolean' },
|
||||
})
|
||||
expect(root('CustomApps')?.fields?.TrustedAdapters).toMatchObject({
|
||||
fields: {},
|
||||
kind: 'table',
|
||||
mutableKeys: true,
|
||||
template: { kind: 'value', valueType: 'boolean' },
|
||||
})
|
||||
expect(
|
||||
root('Garage')?.fields?.VehicleImages.fields?.ModelNames,
|
||||
).toMatchObject({
|
||||
entries: [],
|
||||
keyType: 'number',
|
||||
kind: 'map',
|
||||
template: { kind: 'value', valueType: 'string' },
|
||||
})
|
||||
expect(
|
||||
root('Companies.Definitions')?.fields?.police.fields?.Services,
|
||||
).toMatchObject({
|
||||
items: [],
|
||||
kind: 'list',
|
||||
template: {
|
||||
fields: {
|
||||
Id: { kind: 'value', valueType: 'string' },
|
||||
RequestsEnabled: { kind: 'value', valueType: 'boolean' },
|
||||
},
|
||||
kind: 'table',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,8 +3,6 @@ const { randomUUID } = require('node:crypto')
|
||||
const cors = require('cors')
|
||||
const express = require('express')
|
||||
|
||||
const { loadConfiguratorSections } = require('./configurator-fixture.cjs')
|
||||
|
||||
const app = express()
|
||||
const port = Number(process.argv[2]) || 3001
|
||||
|
||||
@@ -3100,7 +3098,9 @@ const deviceData = {
|
||||
ringtoneVolume: 80,
|
||||
streamerMode: false,
|
||||
wallpaper: 'custom',
|
||||
wallpaperHistory: [{ imageUrl: demoWallpaperUrl, wallpaper: 'custom' }],
|
||||
wallpaperHistory: [
|
||||
{ imageUrl: demoWallpaperUrl, wallpaper: 'custom' },
|
||||
],
|
||||
wallpaperImageUrl: demoWallpaperUrl,
|
||||
},
|
||||
version: 1,
|
||||
@@ -4650,303 +4650,11 @@ function companyWorkContext(testScenario = '') {
|
||||
}
|
||||
}
|
||||
|
||||
const adminMockApps = {
|
||||
claimed: ['citymarkt', 'darkchat', 'feather', 'local-pages'],
|
||||
revision: 3,
|
||||
uninstalled: ['crypto', 'skyride'],
|
||||
}
|
||||
|
||||
const adminMockDevices = {
|
||||
1: { account: true, number: '555-0101', security: true },
|
||||
2: { account: true, number: '555-0102', security: true },
|
||||
}
|
||||
|
||||
function adminMockPlayerDetail(source = 1) {
|
||||
const primary = source === 1
|
||||
const deviceState = adminMockDevices[source] ?? adminMockDevices[1]
|
||||
return {
|
||||
birthdate: primary ? '1994-04-16' : '1998-11-03',
|
||||
devices: [
|
||||
{
|
||||
account: deviceState.account
|
||||
? {
|
||||
email: primary ? 'demo@ifruit.com' : 'jordan@ifruit.com',
|
||||
id: primary ? 1 : 2,
|
||||
passwordAvailable: true,
|
||||
}
|
||||
: null,
|
||||
apps: { ...adminMockApps },
|
||||
createdAt: '2026-08-15 18:42:00',
|
||||
imei: primary ? '356938035643809' : '356938035643810',
|
||||
name: primary ? 'Personal iFruit Phone' : 'Service iFruit Phone',
|
||||
number: deviceState.number,
|
||||
security: {
|
||||
enabled: deviceState.security,
|
||||
failedAttempts: 0,
|
||||
length: deviceState.security ? 6 : null,
|
||||
lockedUntil: 0,
|
||||
},
|
||||
simRegistered: true,
|
||||
simType: 'standard',
|
||||
updatedAt: '2026-08-20 19:04:00',
|
||||
},
|
||||
],
|
||||
firstName: primary ? 'Alex' : 'Jordan',
|
||||
identifier: primary ? 'char1:demo' : 'char1:jordan',
|
||||
job: {
|
||||
grade: primary ? 4 : 1,
|
||||
gradeLabel: primary ? 'Chief' : 'Officer',
|
||||
label: 'Los Santos Police Department',
|
||||
name: 'police',
|
||||
onDuty: true,
|
||||
},
|
||||
lastName: primary ? 'Morgan' : 'Blake',
|
||||
money: {
|
||||
bank: primary ? 182450 : 28450,
|
||||
cash: primary ? 2740 : 950,
|
||||
currency: '$',
|
||||
},
|
||||
name: primary ? 'Alex Morgan' : 'Jordan Blake',
|
||||
serverName: primary ? 'Skyline' : 'JordanB',
|
||||
source,
|
||||
}
|
||||
}
|
||||
|
||||
function adminMockBootstrap() {
|
||||
return {
|
||||
audit: [
|
||||
{
|
||||
action: 'grant_app',
|
||||
actorName: 'Skyline',
|
||||
createdAt: '2026-08-20 19:04:00',
|
||||
details: { appId: 'darkchat' },
|
||||
deviceImei: '356938035643810',
|
||||
id: 1,
|
||||
targetIdentifier: 'char1:jordan',
|
||||
targetSource: 2,
|
||||
},
|
||||
],
|
||||
players: [1, 2].map((source) => {
|
||||
const player = adminMockPlayerDetail(source)
|
||||
return {
|
||||
deviceCount: player.devices.length,
|
||||
grade: player.job.grade,
|
||||
identifier: player.identifier,
|
||||
job: player.job.name,
|
||||
name: player.name,
|
||||
onDuty: player.job.onDuty,
|
||||
phoneNumber: player.devices[0]?.number ?? null,
|
||||
serverName: player.serverName,
|
||||
source,
|
||||
}
|
||||
}),
|
||||
stats: { accounts: 24, devices: 31, online: 2 },
|
||||
}
|
||||
}
|
||||
|
||||
const adminMockConfiguratorBase = {
|
||||
enabled: true,
|
||||
revision: 4,
|
||||
sections: [
|
||||
{
|
||||
id: 'config:Bridge',
|
||||
label: 'Bridge',
|
||||
scope: 'config',
|
||||
fields: [
|
||||
{
|
||||
label: 'Framework',
|
||||
path: 'Bridge.Framework',
|
||||
scope: 'config',
|
||||
sensitive: false,
|
||||
type: 'string',
|
||||
value: 'auto',
|
||||
},
|
||||
{
|
||||
label: 'Inventory',
|
||||
path: 'Bridge.Inventory',
|
||||
scope: 'config',
|
||||
sensitive: false,
|
||||
type: 'string',
|
||||
value: 'auto',
|
||||
},
|
||||
{
|
||||
label: 'Locale',
|
||||
path: 'Bridge.Locale',
|
||||
scope: 'config',
|
||||
sensitive: false,
|
||||
type: 'string',
|
||||
value: 'de',
|
||||
},
|
||||
{
|
||||
label: 'Callback Timeout',
|
||||
path: 'Bridge.CallbackTimeout',
|
||||
scope: 'config',
|
||||
sensitive: false,
|
||||
type: 'number',
|
||||
value: 15000,
|
||||
},
|
||||
{
|
||||
label: 'Debug',
|
||||
path: 'Bridge.Debug',
|
||||
scope: 'config',
|
||||
sensitive: false,
|
||||
type: 'boolean',
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'config:Phone',
|
||||
label: 'Phone',
|
||||
scope: 'config',
|
||||
fields: [
|
||||
{
|
||||
label: 'Item',
|
||||
path: 'Phone.Item',
|
||||
scope: 'config',
|
||||
sensitive: false,
|
||||
type: 'string',
|
||||
value: 'phone',
|
||||
},
|
||||
{
|
||||
label: 'Unique',
|
||||
path: 'Phone.Unique',
|
||||
scope: 'config',
|
||||
sensitive: false,
|
||||
type: 'boolean',
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
label: 'Keybind',
|
||||
path: 'Phone.Keybind',
|
||||
scope: 'config',
|
||||
sensitive: false,
|
||||
type: 'string',
|
||||
value: 'F1',
|
||||
},
|
||||
{
|
||||
label: 'Device Name',
|
||||
path: 'Phone.DeviceName',
|
||||
scope: 'config',
|
||||
sensitive: false,
|
||||
type: 'string',
|
||||
value: 'iFruit Phone',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'config:Companies',
|
||||
label: 'Companies',
|
||||
scope: 'config',
|
||||
fields: [
|
||||
{
|
||||
label: 'Enabled',
|
||||
path: 'Companies.Enabled',
|
||||
scope: 'config',
|
||||
sensitive: false,
|
||||
type: 'boolean',
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
label: 'Categories',
|
||||
path: 'Companies.Categories',
|
||||
scope: 'config',
|
||||
sensitive: false,
|
||||
type: 'json',
|
||||
value: ['public_services', 'vehicles', 'transport'],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'media:FiveManage',
|
||||
label: 'Five Manage',
|
||||
scope: 'media',
|
||||
fields: [
|
||||
{
|
||||
configured: true,
|
||||
label: 'Api Key',
|
||||
path: 'FiveManage.ApiKey',
|
||||
scope: 'media',
|
||||
sensitive: true,
|
||||
type: 'string',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
label: 'Base Url',
|
||||
path: 'FiveManage.BaseUrl',
|
||||
scope: 'media',
|
||||
sensitive: false,
|
||||
type: 'string',
|
||||
value: 'https://api.fivemanage.com/api/v3/file',
|
||||
},
|
||||
{
|
||||
label: 'Upload Timeout Ms',
|
||||
path: 'FiveManage.UploadTimeoutMs',
|
||||
scope: 'media',
|
||||
sensitive: false,
|
||||
type: 'number',
|
||||
value: 25000,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'media:Import',
|
||||
label: 'Import',
|
||||
scope: 'media',
|
||||
fields: [
|
||||
{
|
||||
label: 'Enabled',
|
||||
path: 'Import.Enabled',
|
||||
scope: 'media',
|
||||
sensitive: false,
|
||||
type: 'boolean',
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
label: 'Websites',
|
||||
path: 'Import.Websites',
|
||||
scope: 'media',
|
||||
sensitive: false,
|
||||
type: 'json',
|
||||
value: [
|
||||
{
|
||||
Adapter: 'fivemanage',
|
||||
Enabled: true,
|
||||
Id: 'fivemanage',
|
||||
Label: 'FiveManage',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
updatedAt: '2026-08-20 20:15:00',
|
||||
updatedBy: 'Alex Morgan',
|
||||
}
|
||||
|
||||
const adminMockConfigurator = {
|
||||
...adminMockConfiguratorBase,
|
||||
sections: loadConfiguratorSections(),
|
||||
}
|
||||
|
||||
app.post('/api/:endpoint', async (request, response, next) => {
|
||||
const endpoint = request.params.endpoint
|
||||
const loggedBody = { ...request.body }
|
||||
if (typeof loggedBody.password === 'string')
|
||||
loggedBody.password = '<redacted>'
|
||||
if (
|
||||
endpoint === 'admin:save-configurator' &&
|
||||
Array.isArray(loggedBody.changes)
|
||||
) {
|
||||
loggedBody.changes = loggedBody.changes.map((change) => ({
|
||||
...change,
|
||||
value: /api.?key|pepper|secret|token|password/i.test(
|
||||
String(change.path ?? ''),
|
||||
)
|
||||
? '<redacted>'
|
||||
: change.value,
|
||||
}))
|
||||
}
|
||||
if (endpoint === 'memos:devCapture') {
|
||||
loggedBody.audioDataUrl = `<${String(request.body.audioDataUrl ?? '').length} characters>`
|
||||
}
|
||||
@@ -4955,153 +4663,6 @@ app.post('/api/:endpoint', async (request, response, next) => {
|
||||
response.json({ success: true, data: musicBootstrap() })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'admin:bootstrap') {
|
||||
response.json({ success: true, data: adminMockBootstrap() })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'admin:configurator') {
|
||||
response.json({ success: true, data: adminMockConfigurator })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'admin:save-configurator') {
|
||||
const changes = Array.isArray(request.body.changes)
|
||||
? request.body.changes
|
||||
: []
|
||||
for (const change of changes) {
|
||||
for (const section of adminMockConfigurator.sections) {
|
||||
const field = section.fields.find(
|
||||
(candidate) =>
|
||||
candidate.scope === change.scope && candidate.path === change.path,
|
||||
)
|
||||
if (!field) continue
|
||||
if (field.sensitive)
|
||||
field.configured = String(change.value ?? '') !== ''
|
||||
else field.value = change.value
|
||||
}
|
||||
}
|
||||
adminMockConfigurator.revision += 1
|
||||
adminMockConfigurator.updatedAt = new Date().toISOString()
|
||||
response.json({ success: true, data: adminMockConfigurator })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'admin:player') {
|
||||
response.json({
|
||||
success: true,
|
||||
data: adminMockPlayerDetail(Number(request.body.source) || 1),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'admin:save-apps') {
|
||||
const changes = Array.isArray(request.body.changes)
|
||||
? request.body.changes
|
||||
: []
|
||||
for (const change of changes) {
|
||||
const appId = String(change.appId ?? '')
|
||||
const installed = change.installed === true
|
||||
adminMockApps.claimed = adminMockApps.claimed.filter((id) => id !== appId)
|
||||
adminMockApps.uninstalled = adminMockApps.uninstalled.filter(
|
||||
(id) => id !== appId,
|
||||
)
|
||||
if (installed) adminMockApps.claimed.push(appId)
|
||||
else adminMockApps.uninstalled.push(appId)
|
||||
}
|
||||
adminMockApps.revision += 1
|
||||
response.json({
|
||||
success: true,
|
||||
data: adminMockPlayerDetail(Number(request.body.source) || 1),
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'admin:close') {
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'admin:reveal-password') {
|
||||
response.json({
|
||||
success: true,
|
||||
data: { email: 'demo@ifruit.com', password: 'mock-only-password' },
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'admin:activity') {
|
||||
if (request.body.kind === 'messages') {
|
||||
response.json({
|
||||
success: true,
|
||||
data: {
|
||||
kind: 'messages',
|
||||
entries: [
|
||||
{
|
||||
body: 'Meet at Mission Row in ten minutes.',
|
||||
createdAt: '2026-08-20 19:03:00',
|
||||
direction: 'outgoing',
|
||||
id: 'admin-message-1',
|
||||
messageType: 'text',
|
||||
otherNumber: '555-0144',
|
||||
readAt: '2026-08-20 19:03:30',
|
||||
},
|
||||
{
|
||||
body: 'Copy, I am on my way.',
|
||||
createdAt: '2026-08-20 18:58:00',
|
||||
direction: 'incoming',
|
||||
id: 'admin-message-2',
|
||||
messageType: 'text',
|
||||
otherNumber: '555-0199',
|
||||
readAt: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
response.json({
|
||||
success: true,
|
||||
data: {
|
||||
kind: 'calls',
|
||||
entries: [
|
||||
{
|
||||
answeredAt: '2026-08-20 18:49:05',
|
||||
direction: 'incoming',
|
||||
durationSeconds: 184,
|
||||
endedAt: '2026-08-20 18:52:09',
|
||||
id: 'admin-call-1',
|
||||
otherNumber: '555-0177',
|
||||
startedAt: '2026-08-20 18:49:00',
|
||||
status: 'completed',
|
||||
},
|
||||
{
|
||||
answeredAt: null,
|
||||
direction: 'outgoing',
|
||||
durationSeconds: 0,
|
||||
endedAt: '2026-08-20 17:13:18',
|
||||
id: 'admin-call-2',
|
||||
otherNumber: '555-0112',
|
||||
startedAt: '2026-08-20 17:13:00',
|
||||
status: 'missed',
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (endpoint === 'admin:reset-passcode') {
|
||||
const source = Number(request.body.source) || 1
|
||||
adminMockDevices[source].security = false
|
||||
response.json({ success: true, data: adminMockPlayerDetail(source) })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'admin:change-number') {
|
||||
const source = Number(request.body.source) || 1
|
||||
adminMockDevices[source].number = String(request.body.phoneNumber ?? '')
|
||||
response.json({ success: true, data: adminMockPlayerDetail(source) })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'admin:factory-reset') {
|
||||
const source = Number(request.body.source) || 1
|
||||
adminMockDevices[source].account = false
|
||||
adminMockDevices[source].security = false
|
||||
response.json({ success: true, data: adminMockPlayerDetail(source) })
|
||||
return
|
||||
}
|
||||
if (endpoint === 'music:add-youtube') {
|
||||
const value = String(request.body.url ?? '')
|
||||
const customTitle = String(request.body.title ?? '').trim()
|
||||
|
||||
Reference in New Issue
Block a user