diff --git a/README.md b/README.md index 9d214fb..8c14817 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ shared config-format parsers. ## Supported games -38 of the 41 games in GameAP's built-in catalog, plus seven added manually. +39 of the 41 games in GameAP's built-in catalog, plus seven added manually. `game_id` is what the plugin matches on (`server.game_id`); the server app id is the Steam dedicated-server app from GameAP's own catalog, handy when adding a game to the panel. @@ -97,10 +97,15 @@ one. #### Other | Game | `game_id` | Server app id | Config path | |---|---|---|---| +| Ground Branch | `476400` | `476400` | `/GroundBranch/ServerConfig/{Vote,Admin,Server,TeamKill,Ban,MapList}.ini` | | TeamSpeak 3 | `teamspeak3` | - | `/ts3server.ini` | | GTA: San-Andreas Multiplayer | `samp` | - | `/server.cfg` | | GTA: Multi Theft Auto | `mta` | - | `/mods/deathmatch/mtaserver.conf` | +Ground Branch's `Server.ini` stores `GameRules` as a tuple list inside one INI +value. The editor exposes each rule as a boolean field and changes only that +tuple member, while repeated entries in the other files remain round-trippable. + Some paths are conventions rather than guarantees: Arma loads whatever `-config` names (and nothing if the argument is absent), the idTech engines resolve `server.cfg` against their base directory, and The Forest honours diff --git a/frontend/src/formats/groundbranch.ts b/frontend/src/formats/groundbranch.ts new file mode 100644 index 0000000..1449ac0 --- /dev/null +++ b/frontend/src/formats/groundbranch.ts @@ -0,0 +1,66 @@ +/** Ground Branch's GameRules value is a tuple list nested inside one INI key. */ +import type { ConfigDoc, Format } from './types'; +import { addr, addrKey, addrSection } from './shared'; + +const GAME_RULES_KEY = 'GameRules'; +const RULE = /\("([^"\\]*(?:\\.[^"\\]*)*)"\s*,\s*(True|False)\)/g; + +function ruleKey(section: string, name: string): string { + return addr(section, `${GAME_RULES_KEY}.${name}`); +} + +function parseRules(raw: string): Map { + const rules = new Map(); + for (const match of raw.matchAll(RULE)) rules.set(match[1], match[2] === 'True'); + return rules; +} + +export function makeGroundBranchFormat(base: Format): Format { + return { + id: 'groundbranch-ini', + codec: base.codec, + parse(text): ConfigDoc | null { + const baseDoc = base.parse(text); + if (!baseDoc) return null; + const gameRulesAddress = baseDoc.keys().find((key) => addrKey(key) === GAME_RULES_KEY); + if (!gameRulesAddress) return baseDoc; + const section = addrSection(gameRulesAddress); + const rules = parseRules(baseDoc.getRaw(gameRulesAddress) ?? ''); + const ruleAddresses = [...rules.keys()].map((name) => ruleKey(section, name)); + + return { + keys: () => baseDoc.keys().filter((key) => key !== gameRulesAddress).concat(ruleAddresses), + has: (address) => + address === gameRulesAddress ? false : + address.startsWith(`${gameRulesAddress}.`) ? rules.has(addrKey(address).slice(GAME_RULES_KEY.length + 1)) : + baseDoc.has(address), + getRaw: (address) => { + if (!address.startsWith(`${gameRulesAddress}.`)) return baseDoc.getRaw(address); + const name = addrKey(address).slice(GAME_RULES_KEY.length + 1); + const value = rules.get(name); + return value === undefined ? undefined : value ? 'True' : 'False'; + }, + setRaw: (address, value) => { + if (!address.startsWith(`${gameRulesAddress}.`)) return baseDoc.setRaw(address, value); + const name = addrKey(address).slice(GAME_RULES_KEY.length + 1); + if (!rules.has(name)) return false; + const raw = baseDoc.getRaw(gameRulesAddress) ?? ''; + const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const updated = raw.replace( + new RegExp(`(\\("${escapedName}"\\s*,\\s*)(True|False)(\\))`), + `$1${value === 'True' ? 'True' : 'False'}$3`, + ); + return baseDoc.setRaw(gameRulesAddress, updated); + }, + remove: (address) => baseDoc.remove(address), + sectionOf: (address) => baseDoc.sectionOf(address), + labelOf: (address) => { + if (address.startsWith(`${gameRulesAddress}.`)) return addrKey(address).split('.').pop() ?? address; + return baseDoc.labelOf(address); + }, + normKey: (address) => baseDoc.normKey?.(address) ?? address, + serialize: () => baseDoc.serialize(), + }; + }, + }; +} \ No newline at end of file diff --git a/frontend/src/games/registry.test.ts b/frontend/src/games/registry.test.ts index f20f070..13106e9 100644 --- a/frontend/src/games/registry.test.ts +++ b/frontend/src/games/registry.test.ts @@ -727,6 +727,14 @@ describe('gamesFor', () => { 'allowlist.json', 'permissions.json', ]); + expect(gamesFor('476400').map((g) => g.fileName)).toEqual([ + 'Vote.ini', + 'Admin.ini', + 'Server.ini', + 'TeamKill.ini', + 'Ban.ini', + 'MapList.ini', + ]); }); it('returns nothing for an unknown or missing game', () => { @@ -736,6 +744,61 @@ describe('gamesFor', () => { }); }); +describe('Ground Branch', () => { + it('resolves all server configuration files from ServerConfig', () => { + for (const fileName of ['Vote.ini', 'Admin.ini', 'Server.ini', 'TeamKill.ini', 'Ban.ini', 'MapList.ini']) { + const game = resolve('476400', fileName)!; + expect(game).toBeDefined(); + expect(configDir(game)).toBe('/GroundBranch/ServerConfig'); + expect(game.format.id).toBe('groundbranch-ini'); + } + }); + + it('round-trips repeated Unreal INI entries', () => { + const sample = [ + '[/Script/RBZooKeeper.ZKVote]', + 'VoteDuration=30', + 'VoteSucceededTimeout=60', + 'VoteFailedTimeout=180', + 'VotingCommands=changemap', + 'VotingCommands=nextmap', + 'VotingCommands=kick', + '', + '[/Script/RBZooKeeper.ZKServer]', + 'ServerName=SAS Proving Ground 10 (EU)', + 'MaxPlayers=8', + 'GameRules=(("AllowCheats", False))', + 'ReadyCountdownTime=45', + ].join('\n'); + const game = resolve('476400', 'Vote.ini')!; + const doc = game.format.parse(sample)!; + expect(doc.serialize()).toBe(sample); + expect(doc.getRaw('/Script/RBZooKeeper.ZKVote\0VoteDuration')).toBe('30'); + expect(doc.getRaw('/Script/RBZooKeeper.ZKVote\0VotingCommands')).toBe('kick'); + }); + + it('exposes GameRules tuple members as editable booleans', () => { + const sample = [ + '[/Script/RBZooKeeper.ZKServer]', + 'GameRules=(("AllowCheats", False),("AllowDeadChat", True),("BalanceTeams", True))', + 'ReadyCountdownTime=45', + ].join('\n'); + const game = resolve('476400', 'Server.ini')!; + const doc = game.format.parse(sample)!; + const cheats = '/Script/RBZooKeeper.ZKServer\0GameRules.AllowCheats'; + const deadChat = '/Script/RBZooKeeper.ZKServer\0GameRules.AllowDeadChat'; + const balance = '/Script/RBZooKeeper.ZKServer\0GameRules.BalanceTeams'; + + expect(doc.getRaw(cheats)).toBe('False'); + expect(doc.getRaw(deadChat)).toBe('True'); + expect(doc.getRaw(balance)).toBe('True'); + expect(doc.setRaw(cheats, 'True')).toBe(true); + expect(doc.serialize()).toContain('("AllowCheats", True)'); + expect(doc.serialize()).toContain('("AllowDeadChat", True)'); + expect(doc.serialize()).toContain('ReadyCountdownTime=45'); + }); +}); + describe('path helpers', () => { it('joins the directory and file name', () => { expect(configPath(resolve('cs2', 'server.cfg')!)).toBe('/game/csgo/cfg/server.cfg'); diff --git a/frontend/src/games/registry.ts b/frontend/src/games/registry.ts index 4a37eba..f65dea4 100644 --- a/frontend/src/games/registry.ts +++ b/frontend/src/games/registry.ts @@ -33,10 +33,18 @@ import { mtaSchema } from './schemas/mta'; import { factorioSchema } from './schemas/factorio'; import { enshroudedSchema } from './schemas/enshrouded'; import { dragonwildsSchema } from './schemas/dragonwilds'; +import { + groundBranchAdminSchema, + groundBranchBanSchema, + groundBranchServerSchema, + groundBranchTeamKillSchema, + groundBranchVoteSchema, +} from './schemas/groundbranch'; import { sourceGames } from './source'; import { goldSourceGames } from './goldsource'; import { idTechGames } from './idtech'; import { armaGames } from './arma'; +import { makeGroundBranchFormat } from '../formats/groundbranch'; // ARK/Unreal INI keys are case-insensitive - match them that way so a schema // field and a differently-cased file key don't produce a duplicate. @@ -56,6 +64,8 @@ const dragonwildsIni = makeIniFormat('dragonwilds-ini', { codec: { isTruthy: (r) => /^(1|true|yes|on)$/i.test(r.trim()) }, }); +const groundBranchIni = makeGroundBranchFormat(makeIniFormat('groundbranch-base-ini', { caseInsensitive: true })); + // TeamSpeak's ini is flat key=value like server.properties, but its booleans are // 1/0 rather than true/false. const ts3Ini = makeKeyValueFormat('ts3-ini', { codec: { boolTrue: '1', boolFalse: '0' } }); @@ -428,6 +438,53 @@ export const games: GameConfig[] = [ schema: ts3Schema, loadHint: TS3_LOAD_HINT, }, + { + gameId: '476400', + gameName: 'Ground Branch (Vote)', + fileName: 'Vote.ini', + dir: '/GroundBranch/ServerConfig', + format: groundBranchIni, + schema: groundBranchVoteSchema, + }, + { + gameId: '476400', + gameName: 'Ground Branch (Admin)', + fileName: 'Admin.ini', + dir: '/GroundBranch/ServerConfig', + format: groundBranchIni, + schema: groundBranchAdminSchema, + }, + { + gameId: '476400', + gameName: 'Ground Branch', + fileName: 'Server.ini', + dir: '/GroundBranch/ServerConfig', + format: groundBranchIni, + schema: groundBranchServerSchema, + }, + { + gameId: '476400', + gameName: 'Ground Branch (Team Kill)', + fileName: 'TeamKill.ini', + dir: '/GroundBranch/ServerConfig', + format: groundBranchIni, + schema: groundBranchTeamKillSchema, + }, + { + gameId: '476400', + gameName: 'Ground Branch (Ban)', + fileName: 'Ban.ini', + dir: '/GroundBranch/ServerConfig', + format: groundBranchIni, + schema: groundBranchBanSchema, + }, + { + gameId: '476400', + gameName: 'Ground Branch (Map List)', + fileName: 'MapList.ini', + dir: '/GroundBranch/ServerConfig', + format: groundBranchIni, + }, { gameId: 'samp', gameName: 'GTA: San-Andreas Multiplayer', diff --git a/frontend/src/games/schemas/groundbranch.ts b/frontend/src/games/schemas/groundbranch.ts new file mode 100644 index 0000000..b792d30 --- /dev/null +++ b/frontend/src/games/schemas/groundbranch.ts @@ -0,0 +1,113 @@ +/** Ground Branch server configuration files under GroundBranch/ServerConfig. */ +import type { Schema } from '../../formats/types'; +import { b, raw, section } from '../fields'; +import { addr } from '../../formats/shared'; + +const vote = section('/Script/RBZooKeeper.ZKVote'); +const server = section('/Script/RBZooKeeper.ZKServer'); +const teamKill = section('/Script/RBZooKeeper.ZKTeamKill'); +const ban = section('/Script/RBZooKeeper.ZKBan'); +const adminSection = '/Script/RBZooKeeper.ZKAdmin'; +const rulesSection = '/Script/RBZooKeeper.ZKServer'; +const rule = (key: string, label: string) => b(addr(rulesSection, `GameRules.${key}`), label); + +export const groundBranchVoteSchema: Schema = [ + { + id: 'voting', + title: 'Voting', + icon: 'people-group', + fields: [ + vote.n('VoteDuration', 'Vote duration (s)'), + vote.n('VoteSucceededTimeout', 'Successful-vote timeout (s)'), + vote.n('VoteFailedTimeout', 'Failed-vote timeout (s)'), + ], + }, +]; + +export const groundBranchServerSchema: Schema = [ + { + id: 'identity', + title: 'Server / Identity', + icon: 'id-card', + fields: [ + server.t('ServerName', 'Server name (browser)'), + server.t('ServerMOTD', 'Server MOTD (HTML)'), + server.t('ServerPassword', 'Join password (blank = none)'), + server.t('SpectatorOnlyPassword', 'Spectator-only password (blank = none)'), + server.n('MaxPlayers', 'Max players'), + server.t('ServerWebBanner', 'Web banner URL'), + server.n('MaxSpectators', 'Max spectators'), + ], + }, + { + id: 'match', + title: 'Match Rules', + icon: 'sliders', + fields: [ + rule('AllowCheats', 'Allow cheats'), + rule('AllowDeadChat', 'Allow dead chat'), + rule('AllowUnrestrictedRadio', 'Allow unrestricted radio'), + rule('AllowUnrestrictedVoice', 'Allow unrestricted voice'), + rule('SpectateEnemies', 'Allow spectating enemies'), + rule('SpectateForceFirstPerson', 'Force first-person spectating'), + rule('SpectateFreeCam', 'Allow free-camera spectating'), + rule('UseTeamRestrictions', 'Use team restrictions'), + rule('RestrictFiringRange', 'Restrict firing range'), + rule('UseFriendlyNameTags', 'Use friendly name tags'), + rule('AllowEnemyNPCMinimapBlips', 'Show enemy NPC minimap blips'), + rule('BalanceTeams', 'Balance teams'), + server.t('PVEMatchType', 'PvE match type'), + server.n('PVERoundLimit', 'PvE round limit'), + server.t('PVPMatchType', 'PvP match type'), + server.n('PVPRoundLimit', 'PvP round limit'), + server.t('PVPFFAMatchType', 'PvP FFA match type'), + server.n('PVPFFARoundLimit', 'PvP FFA round limit'), + server.n('ReadyCountdownTime', 'Ready countdown (s)'), + ], + }, + { + id: 'shutdown', + title: 'Scheduled Shutdown', + icon: 'stopwatch', + fields: [ + server.n('ServerShutdownType', 'Shutdown type'), + server.n('ServerShutdownHour', 'Shutdown hour'), + server.n('ServerShutdownTimeLimit', 'Shutdown time limit (h)'), + server.n('ServerShutdownGracePeriod', 'Shutdown grace period (min)'), + ], + }, +]; + +export const groundBranchTeamKillSchema: Schema = [ + { + id: 'team-killing', + title: 'Team Killing', + icon: 'shield-halved', + fields: [ + teamKill.n('MaxTeamKills', 'Maximum team kills'), + teamKill.n('BanTime', 'Ban duration (min)'), + teamKill.n('TeamKillExpireTime', 'Team-kill expiry (s)'), + ], + }, +]; + +export const groundBranchBanSchema: Schema = [ + { + id: 'bans', + title: 'Ban Defaults', + icon: 'gavel', + fields: [ban.n('DefaultBanDuration', 'Default ban duration (min)')], + }, +]; + +export const groundBranchAdminSchema: Schema = [ + { + id: 'administration', + title: 'Administration', + icon: 'user-shield', + fields: [ + raw(addr(adminSection, 'AdminGroups'), 'Admin groups (last repeated entry, raw Unreal value)'), + raw(addr(adminSection, 'Admins'), 'Admins (last repeated entry, raw Unreal value)'), + ], + }, +]; \ No newline at end of file