Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
66 changes: 66 additions & 0 deletions frontend/src/formats/groundbranch.ts
Original file line number Diff line number Diff line change
@@ -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<string, boolean> {
const rules = new Map<string, boolean>();
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(),
};
},
};
}
63 changes: 63 additions & 0 deletions frontend/src/games/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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');
Expand Down
57 changes: 57 additions & 0 deletions frontend/src/games/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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' } });
Expand Down Expand Up @@ -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',
Expand Down
113 changes: 113 additions & 0 deletions frontend/src/games/schemas/groundbranch.ts
Original file line number Diff line number Diff line change
@@ -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)'),
],
},
];