feat: patch cs2 gameinfo after metamod install

This commit is contained in:
hibna 2026-02-26 21:21:27 +00:00
parent 2a3ad5e78f
commit c7d1627e18
5 changed files with 174 additions and 15 deletions

View File

@ -9,6 +9,7 @@ import type {
ServerAutomationAction,
ServerAutomationGitHubReleaseExtractAction,
ServerAutomationHttpDirectoryExtractAction,
ServerAutomationInsertBeforeLineAction,
} from '@source/shared';
import {
daemonReadFile,
@ -20,6 +21,21 @@ import {
const DEFAULT_RELEASE_MAX_BYTES = 256 * 1024 * 1024;
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 120_000;
const AUTOMATION_MARKER_ROOT = '/.gamepanel/automation';
const CS2_GAMEINFO_PATH = '/game/csgo/gameinfo.gi';
const CS2_GAMEINFO_METAMOD_LINE = '\t\t\tGame csgo/addons/metamod';
const CS2_GAMEINFO_INSERT_BEFORE_PATTERN = '^\\s*Game\\s+csgo\\s*$';
const CS2_GAMEINFO_EXISTS_PATTERN = '^\\s*Game\\s+csgo/addons/metamod\\s*$';
const CS2_GAMEINFO_INSERT_ACTION_ID = 'ensure-cs2-metamod-gameinfo-entry';
const DEFAULT_CS2_GAMEINFO_INSERT_ACTION: ServerAutomationInsertBeforeLineAction = {
id: CS2_GAMEINFO_INSERT_ACTION_ID,
type: 'insert_before_line',
path: CS2_GAMEINFO_PATH,
line: CS2_GAMEINFO_METAMOD_LINE,
beforePattern: CS2_GAMEINFO_INSERT_BEFORE_PATTERN,
existsPattern: CS2_GAMEINFO_EXISTS_PATTERN,
skipIfExists: true,
};
const DEFAULT_GAME_AUTOMATION_RULES: Record<string, GameAutomationRule[]> = {
cs2: [
@ -39,6 +55,7 @@ const DEFAULT_GAME_AUTOMATION_RULES: Record<string, GameAutomationRule[]> = {
stripComponents: 0,
maxBytes: DEFAULT_RELEASE_MAX_BYTES,
},
{ ...DEFAULT_CS2_GAMEINFO_INSERT_ACTION },
],
},
{
@ -129,8 +146,8 @@ function normalizeWorkflow(
workflow: GameAutomationRule,
): GameAutomationRule {
if (gameSlug.toLowerCase() !== 'cs2') return workflow;
if (workflow.id !== 'cs2-install-latest-counterstrikesharp-runtime') return workflow;
if (workflow.id === 'cs2-install-latest-counterstrikesharp-runtime') {
const normalizedActions = workflow.actions.map((action) => {
if (action.type !== 'github_release_extract') return action;
if (action.id !== 'install-cs2-runtime') return action;
@ -148,6 +165,24 @@ function normalizeWorkflow(
...workflow,
actions: normalizedActions,
};
}
if (workflow.id === 'cs2-install-latest-metamod') {
const hasGameInfoAction = workflow.actions.some(
(action) =>
action.type === 'insert_before_line' &&
(action.id === CS2_GAMEINFO_INSERT_ACTION_ID || action.path === CS2_GAMEINFO_PATH),
);
if (hasGameInfoAction) return workflow;
return {
...workflow,
actions: [...workflow.actions, { ...DEFAULT_CS2_GAMEINFO_INSERT_ACTION }],
};
}
return workflow;
}
function asAutomationRules(raw: unknown, gameSlug: string): GameAutomationRule[] {
@ -609,6 +644,76 @@ async function executeHttpDirectoryExtract(
);
}
async function executeInsertBeforeLine(
app: FastifyInstance,
context: ServerAutomationContext,
action: ServerAutomationInsertBeforeLineAction,
): Promise<void> {
const file = await daemonReadFile(context.node, context.serverUuid, action.path);
const content = file.data.toString('utf8');
const eol = content.includes('\r\n') ? '\r\n' : '\n';
const hasTrailingEol = content.endsWith('\n');
const lines = content.split(/\r?\n/);
if (hasTrailingEol && lines[lines.length - 1] === '') {
lines.pop();
}
const skipIfExists = action.skipIfExists !== false;
if (skipIfExists) {
const existsRegex = action.existsPattern
? new RegExp(action.existsPattern, 'i')
: null;
const alreadyExists = lines.some((line) =>
existsRegex ? existsRegex.test(line) : line === action.line,
);
if (alreadyExists) {
app.log.info(
{
serverId: context.serverId,
serverUuid: context.serverUuid,
event: context.event,
actionId: action.id,
path: action.path,
},
'Automation action skipped: line already present',
);
return;
}
}
let beforeRegex: RegExp;
try {
beforeRegex = new RegExp(action.beforePattern);
} catch {
throw new Error(`Invalid beforePattern regex for action ${action.id}`);
}
const insertIndex = lines.findIndex((line) => beforeRegex.test(line));
if (insertIndex < 0) {
throw new Error(
`Could not find insertion point in ${action.path} with pattern: ${action.beforePattern}`,
);
}
const updated = [...lines.slice(0, insertIndex), action.line, ...lines.slice(insertIndex)];
const output = `${updated.join(eol)}${hasTrailingEol ? eol : ''}`;
await daemonWriteFile(context.node, context.serverUuid, action.path, output);
app.log.info(
{
serverId: context.serverId,
serverUuid: context.serverUuid,
event: context.event,
actionId: action.id,
path: action.path,
},
'Automation action completed: insert_before_line',
);
}
async function executeAction(
app: FastifyInstance,
context: ServerAutomationContext,
@ -625,6 +730,11 @@ async function executeAction(
return;
}
case 'insert_before_line': {
await executeInsertBeforeLine(app, context, action);
return;
}
case 'write_file': {
const payload =
action.encoding === 'base64'

View File

@ -1,7 +1,7 @@
import type { FastifyInstance } from 'fastify';
import { Type } from '@sinclair/typebox';
import { and, eq } from 'drizzle-orm';
import { nodes, servers } from '@source/database';
import { games, nodes, servers } from '@source/database';
import { AppError } from '../../lib/errors.js';
import { requirePermission } from '../../lib/permissions.js';
import {
@ -19,6 +19,17 @@ const FileParamSchema = {
}),
};
function shouldHideFileForGame(gameSlug: string, fileName: string, isDirectory: boolean): boolean {
if (gameSlug !== 'cs2') return false;
if (isDirectory) return false;
const normalizedName = fileName.trim().toLowerCase();
if (normalizedName.endsWith('.vpk')) return true;
if (/^backup_round.*\.txt$/.test(normalizedName)) return true;
return false;
}
function decodeBase64Payload(data: string): Buffer {
const normalized = data.trim();
if (!normalized) return Buffer.alloc(0);
@ -56,7 +67,11 @@ export default async function fileRoutes(app: FastifyInstance) {
path?.trim() || '/',
);
return { files };
const filteredFiles = files.filter(
(file) => !shouldHideFileForGame(serverContext.gameSlug, file.name, file.isDirectory),
);
return { files: filteredFiles };
},
);
@ -151,17 +166,20 @@ export default async function fileRoutes(app: FastifyInstance) {
async function getServerContext(app: FastifyInstance, orgId: string, serverId: string): Promise<{
serverUuid: string;
gameSlug: string;
node: DaemonNodeConnection;
}> {
const [server] = await app.db
.select({
uuid: servers.uuid,
gameSlug: games.slug,
nodeFqdn: nodes.fqdn,
nodeGrpcPort: nodes.grpcPort,
nodeDaemonToken: nodes.daemonToken,
})
.from(servers)
.innerJoin(nodes, eq(servers.nodeId, nodes.id))
.innerJoin(games, eq(servers.gameId, games.id))
.where(and(eq(servers.id, serverId), eq(servers.organizationId, orgId)));
if (!server) {
@ -170,6 +188,7 @@ async function getServerContext(app: FastifyInstance, orgId: string, serverId: s
return {
serverUuid: server.uuid,
gameSlug: server.gameSlug,
node: {
fqdn: server.nodeFqdn,
grpcPort: server.nodeGrpcPort,

View File

@ -15,6 +15,15 @@ WITH metamod_rule AS (
"destination": "/game/csgo",
"stripComponents": 0,
"maxBytes": 268435456
},
{
"id": "ensure-cs2-metamod-gameinfo-entry",
"type": "insert_before_line",
"path": "/game/csgo/gameinfo.gi",
"line": "\\t\\t\\tGame csgo/addons/metamod",
"beforePattern": "^\\\\s*Game\\\\s+csgo\\\\s*$",
"existsPattern": "^\\\\s*Game\\\\s+csgo/addons/metamod\\\\s*$",
"skipIfExists": true
}
]
}

View File

@ -127,6 +127,15 @@ async function seed() {
stripComponents: 0,
maxBytes: 256 * 1024 * 1024,
},
{
id: 'ensure-cs2-metamod-gameinfo-entry',
type: 'insert_before_line',
path: '/game/csgo/gameinfo.gi',
line: '\t\t\tGame csgo/addons/metamod',
beforePattern: '^\\s*Game\\s+csgo\\s*$',
existsPattern: '^\\s*Game\\s+csgo/addons/metamod\\s*$',
skipIfExists: true,
},
],
},
{

View File

@ -19,6 +19,7 @@ export type ServerAutomationEvent =
export type ServerAutomationActionType =
| 'github_release_extract'
| 'http_directory_extract'
| 'insert_before_line'
| 'write_file'
| 'send_command';
@ -51,6 +52,16 @@ export interface ServerAutomationHttpDirectoryExtractAction {
maxBytes?: number;
}
export interface ServerAutomationInsertBeforeLineAction {
id: string;
type: 'insert_before_line';
path: string;
line: string;
beforePattern: string;
existsPattern?: string;
skipIfExists?: boolean;
}
export interface ServerAutomationSendCommandAction {
id: string;
type: 'send_command';
@ -60,6 +71,7 @@ export interface ServerAutomationSendCommandAction {
export type ServerAutomationAction =
| ServerAutomationGitHubReleaseExtractAction
| ServerAutomationHttpDirectoryExtractAction
| ServerAutomationInsertBeforeLineAction
| ServerAutomationWriteFileAction
| ServerAutomationSendCommandAction;