Add T6 Zombies fleet support with Plutainer configuration
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
# Filesystem paths — inside the Komodo container, these mirror the host layout
|
# Filesystem paths — inside the Komodo container, these mirror the host layout
|
||||||
SERVERS_ROOT=/root/zombies/t5
|
SERVERS_ROOT=/root/zombies/t5
|
||||||
|
# T6 is separate; defaults to a sibling t6 directory beside SERVERS_ROOT.
|
||||||
|
T6_SERVERS_ROOT=/root/zombies/t6
|
||||||
REL_DEST=runtime/plutonium/storage/t5/raw/scripts/sp
|
REL_DEST=runtime/plutonium/storage/t5/raw/scripts/sp
|
||||||
# On-disk archive of active beta files (cleared per-beta on promote/rollback)
|
# On-disk archive of active beta files (cleared per-beta on promote/rollback)
|
||||||
BETA_ARCHIVE_DIR=/root/zombies/beta-archive
|
BETA_ARCHIVE_DIR=/root/zombies/beta-archive
|
||||||
@@ -27,6 +29,9 @@ KOMODO_TARGET_SERVER=
|
|||||||
# PLUTAINER_IMAGE=ghcr.io/ayymoss/plutainer:latest
|
# PLUTAINER_IMAGE=ghcr.io/ayymoss/plutainer:latest
|
||||||
# PLUTAINER_NETWORK=komodo_default
|
# PLUTAINER_NETWORK=komodo_default
|
||||||
# PLUTAINER_GAMEFILES_VOLUME=t5_game_files
|
# PLUTAINER_GAMEFILES_VOLUME=t5_game_files
|
||||||
|
# PLUTAINER_T6_GAMEFILES_VOLUME=t6_game_files
|
||||||
|
# T6_REL_DEST_SCRIPT=runtime/plutonium/storage/t6/scripts/zm
|
||||||
|
# T6_CONFIG_TARGET_FILE=dedicated_zm.cfg
|
||||||
# Port range EC picks from when auto-assigning a game_port to a new server.
|
# Port range EC picks from when auto-assigning a game_port to a new server.
|
||||||
# Must correspond to what's actually forwarded on the Komodo host firewall.
|
# Must correspond to what's actually forwarded on the Komodo host firewall.
|
||||||
# SERVER_PORT_MIN=28960
|
# SERVER_PORT_MIN=28960
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Electric Cherry
|
# Electric Cherry
|
||||||
|
|
||||||
Web app that manages a fleet of T5 Plutonium servers running under Komodo.
|
Web app that manages a fleet of T5 and T6 Plutonium Zombies servers running under Komodo.
|
||||||
|
|
||||||
Fleet membership lives in SQLite. EC registers servers during creation and removes
|
Fleet membership lives in SQLite. EC registers servers during creation and removes
|
||||||
them during destruction; startup does not discover or import server folders.
|
them during destruction; startup does not discover or import server folders.
|
||||||
@@ -122,6 +122,54 @@ reconciliation; this change does not immediately rewrite or restart them.
|
|||||||
Servers with an unknown map retain their existing config behavior. Rollback
|
Servers with an unknown map retain their existing config behavior. Rollback
|
||||||
restores the exact prior config bytes.
|
restores the exact prior config bytes.
|
||||||
|
|
||||||
|
## T6 Zombies (Black Ops II)
|
||||||
|
|
||||||
|
Choose **Black Ops II (T6)** in Deploy new server. T6 uses `t6_zombie_<slug>_<lobby>`
|
||||||
|
IDs, its own saved Plutonium keys, `PLUTAINER_GAME=t6zm`, and
|
||||||
|
`configs/dedicated_zm.cfg`. Existing T5 deployments and environment settings
|
||||||
|
keep their current paths. API clients can send `game: "t6"`; omitting it selects T5.
|
||||||
|
|
||||||
|
Before the first deployment, populate the Docker volume `t6_game_files` on the
|
||||||
|
Komodo host with your Black Ops II game files, including the DLC maps you intend
|
||||||
|
to host. Set `PLUTAINER_T6_GAMEFILES_VOLUME` to use another volume or an absolute
|
||||||
|
host directory. It is mounted read-only at `/home/plutainer/gamefiles`.
|
||||||
|
Plutainer supplies its runtime and config seeds, not the base game files.
|
||||||
|
Use a Plutonium server key created for **T6 Zombies**.
|
||||||
|
|
||||||
|
Server state defaults to `/root/zombies/t6`, or the sibling `t6` directory beside
|
||||||
|
`SERVERS_ROOT`; override with `T6_SERVERS_ROOT`. Electric Cherry and the Komodo
|
||||||
|
host must see this directory at the same absolute path. The supplied Compose
|
||||||
|
mount already covers both games. GSC files go to
|
||||||
|
`runtime/plutonium/storage/t6/scripts/zm` (no `raw/`), and game logs use
|
||||||
|
`logs/games_zm.log`. Optional overrides are `T6_REL_DEST_SCRIPT` and
|
||||||
|
`T6_CONFIG_TARGET_FILE`; a custom config filename needs a promoted baseline or
|
||||||
|
a file supplied at that name, since Plutainer seeds `dedicated_zm.cfg`.
|
||||||
|
|
||||||
|
The catalog includes the classic maps, Survival, Grief, and Turned selections.
|
||||||
|
Rotations use `execgts <ruleset>.cfg map <engine-map>`; Plutainer seeds the required
|
||||||
|
`storage/t6/gamesettings` files. Keep seeding enabled, including with a promoted
|
||||||
|
baseline. Town, Farm, and Bus Depot share `zm_transit`, so EC keeps their selection
|
||||||
|
in `map_slug` through creation, beta, promotion, and drift repair. Map-scoped
|
||||||
|
artifacts target the engine map and therefore apply to all its variants.
|
||||||
|
Creation health checks verify the engine map reported by RCON; this does not
|
||||||
|
independently verify the start location within that map.
|
||||||
|
|
||||||
|
Both games share the fleet's port allocator (`SERVER_PORT_MIN`/`SERVER_PORT_MAX`)
|
||||||
|
to prevent collisions. EC explicitly sets `PLUTAINER_PORT`, so T6 need not use
|
||||||
|
its upstream default of 4976. Forward the allocated UDP ports. Use a current
|
||||||
|
Plutainer v2 image: EC relies on `PLUTAINER_RCON_PASSWORD`,
|
||||||
|
`PLUTAINER_MAP_ROTATE=false`, and extra arguments loaded after the main config.
|
||||||
|
Current Plutainer automatically whitelists the Docker gateway for T5/T6 RCON;
|
||||||
|
an admin client on another host needs its address allowed in the server config.
|
||||||
|
|
||||||
|
Implementation references (checked September 2026):
|
||||||
|
|
||||||
|
- [Plutainer game requirements](https://github.com/Ayymoss/Plutainer/blob/main/docs/games.md)
|
||||||
|
- [Plutainer launch, seeding, and RCON implementation](https://github.com/Ayymoss/Plutainer/blob/main/scripts/lib/cod.sh)
|
||||||
|
- [Plutainer configuration](https://github.com/Ayymoss/Plutainer/blob/main/docs/configuration.md)
|
||||||
|
- [T6 map/ruleset combinations](https://github.com/xerxes-at/T6ServerConfigs/blob/master/localappdata/Plutonium/storage/t6/dedicated_zm.cfg)
|
||||||
|
- [Plutonium GSC paths](https://plutonium.pw/docs/modding/loading-mods/)
|
||||||
|
|
||||||
## Round telemetry experiment
|
## Round telemetry experiment
|
||||||
|
|
||||||
Send `POST /api/telemetry` with `Content-Type: application/json` to the Electric
|
Send `POST /api/telemetry` with `Content-Type: application/json` to the Electric
|
||||||
@@ -174,7 +222,7 @@ RCON parsing (fake UDP socket).
|
|||||||
|
|
||||||
```
|
```
|
||||||
KOMODO_ADDRESS, KOMODO_API_KEY, KOMODO_API_SECRET
|
KOMODO_ADDRESS, KOMODO_API_KEY, KOMODO_API_SECRET
|
||||||
RCON_PASSWORD # single shared password across every T5 server
|
RCON_PASSWORD # single shared password across every T5/T6 server
|
||||||
RCON_HOST # optional; defaults to host.docker.internal
|
RCON_HOST # optional; defaults to host.docker.internal
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ services:
|
|||||||
# replace with a concrete byte cap (e.g. "128M") instead.
|
# replace with a concrete byte cap (e.g. "128M") instead.
|
||||||
BODY_SIZE_LIMIT: "Infinity"
|
BODY_SIZE_LIMIT: "Infinity"
|
||||||
SERVERS_ROOT: /root/zombies/t5
|
SERVERS_ROOT: /root/zombies/t5
|
||||||
|
T6_SERVERS_ROOT: /root/zombies/t6
|
||||||
REL_DEST: runtime/plutonium/storage/t5/raw/scripts/sp
|
REL_DEST: runtime/plutonium/storage/t5/raw/scripts/sp
|
||||||
BETA_ARCHIVE_DIR: /root/zombies/beta-archive
|
BETA_ARCHIVE_DIR: /root/zombies/beta-archive
|
||||||
DB_PATH: /data/cherry.db
|
DB_PATH: /data/cherry.db
|
||||||
@@ -36,6 +37,7 @@ services:
|
|||||||
KOMODO_API_KEY: ${KOMODO_API_KEY}
|
KOMODO_API_KEY: ${KOMODO_API_KEY}
|
||||||
KOMODO_API_SECRET: ${KOMODO_API_SECRET}
|
KOMODO_API_SECRET: ${KOMODO_API_SECRET}
|
||||||
KOMODO_TARGET_SERVER: ${KOMODO_TARGET_SERVER}
|
KOMODO_TARGET_SERVER: ${KOMODO_TARGET_SERVER}
|
||||||
|
PLUTAINER_T6_GAMEFILES_VOLUME: ${PLUTAINER_T6_GAMEFILES_VOLUME:-t6_game_files}
|
||||||
RCON_PASSWORD: ${RCON_PASSWORD}
|
RCON_PASSWORD: ${RCON_PASSWORD}
|
||||||
RCON_HOST: ${RCON_HOST:-host.docker.internal}
|
RCON_HOST: ${RCON_HOST:-host.docker.internal}
|
||||||
RCON_TIMEOUT_MS: ${RCON_TIMEOUT_MS:-3000}
|
RCON_TIMEOUT_MS: ${RCON_TIMEOUT_MS:-3000}
|
||||||
|
|||||||
+4
-2
@@ -1,5 +1,5 @@
|
|||||||
# Komodo stack definition. Deploys electric-cherry as a managed container on
|
# Komodo stack definition. Deploys electric-cherry as a managed container on
|
||||||
# the same host running the T5 Plutonium servers. Mounts /root/zombies so the
|
# the same host running the T5/T6 Plutonium servers. Mounts /root/zombies so the
|
||||||
# app can push scripts to the same paths push_scripts.sh used, and dedicates
|
# app can push scripts to the same paths push_scripts.sh used, and dedicates
|
||||||
# a small subdir for its SQLite state.
|
# a small subdir for its SQLite state.
|
||||||
|
|
||||||
@@ -22,6 +22,8 @@ extra_hosts = ["host.docker.internal:host-gateway"]
|
|||||||
|
|
||||||
[stack.services.web.env]
|
[stack.services.web.env]
|
||||||
SERVERS_ROOT = "/root/zombies/t5"
|
SERVERS_ROOT = "/root/zombies/t5"
|
||||||
|
T6_SERVERS_ROOT = "/root/zombies/t6"
|
||||||
|
PLUTAINER_T6_GAMEFILES_VOLUME = "t6_game_files"
|
||||||
REL_DEST = "runtime/plutonium/storage/t5/raw/scripts/sp"
|
REL_DEST = "runtime/plutonium/storage/t5/raw/scripts/sp"
|
||||||
BETA_ARCHIVE_DIR = "/root/zombies/beta-archive"
|
BETA_ARCHIVE_DIR = "/root/zombies/beta-archive"
|
||||||
DB_PATH = "/data/cherry.db"
|
DB_PATH = "/data/cherry.db"
|
||||||
@@ -35,7 +37,7 @@ KOMODO_API_SECRET = ""
|
|||||||
# Host new deployments land on — must match the name shown in Komodo's UI.
|
# Host new deployments land on — must match the name shown in Komodo's UI.
|
||||||
KOMODO_TARGET_SERVER = ""
|
KOMODO_TARGET_SERVER = ""
|
||||||
|
|
||||||
# RCON — shared password across every T5 Plutonium server in the fleet.
|
# RCON — shared password across every Plutonium server in the fleet.
|
||||||
RCON_PASSWORD = ""
|
RCON_PASSWORD = ""
|
||||||
RCON_HOST = "host.docker.internal"
|
RCON_HOST = "host.docker.internal"
|
||||||
RCON_TIMEOUT_MS = "3000"
|
RCON_TIMEOUT_MS = "3000"
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "electric-cherry",
|
"name": "electric-cherry",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"description": "T5 Plutonium fleet manager — replaces push_scripts.sh with a live web UI",
|
"description": "T5/T6 Plutonium Zombies fleet manager — replaces push_scripts.sh with a live web UI",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+57
-9
@@ -1,20 +1,25 @@
|
|||||||
// T5 (Black Ops 1) zombies map catalog. Server names encode `slug` so we can
|
// T5/T6 zombies map catalogs. Server names encode `slug` so we can
|
||||||
// derive game, mode, and map from the folder name (t5_zombie_cosmodrome_1 →
|
// derive game, mode, and map from the folder name (t5_zombie_cosmodrome_1 →
|
||||||
// game 't5', mode 'zombie', slug 'cosmodrome', canonical 'zombie_cosmodrome').
|
// game 't5', mode 'zombie', slug 'cosmodrome', canonical 'zombie_cosmodrome').
|
||||||
//
|
//
|
||||||
// Fields:
|
// Fields:
|
||||||
// friendly — human name shown in the deploy UI dropdown
|
// friendly — human name shown in the deploy UI dropdown
|
||||||
// slug — segment used in server name (t5_zombie_<slug>_<n>)
|
// slug — segment used in server name (t5_zombie_<slug>_<n>)
|
||||||
// canonical — actual mapname Pluto/RCON emits (matches T5 script scoping)
|
// canonical — actual mapname Pluto/RCON emits (used for script scoping)
|
||||||
//
|
//
|
||||||
// Add rows when new maps land; keep slugs short so paths stay readable.
|
// Add rows when new maps land; keep slugs short so paths stay readable.
|
||||||
|
|
||||||
export interface T5MapEntry {
|
export interface ZombieMapEntry {
|
||||||
friendly: string;
|
friendly: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
canonical: string;
|
canonical: string;
|
||||||
|
/** T6 selects a ruleset/location before loading the engine map. */
|
||||||
|
gameSettings?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type T5MapEntry = ZombieMapEntry;
|
||||||
|
export type ZombieGame = 't5' | 't6';
|
||||||
|
|
||||||
export const T5_ZOMBIE_MAPS: readonly T5MapEntry[] = Object.freeze([
|
export const T5_ZOMBIE_MAPS: readonly T5MapEntry[] = Object.freeze([
|
||||||
{ friendly: 'Kino Der Toten', slug: 'theater', canonical: 'zombie_theater' },
|
{ friendly: 'Kino Der Toten', slug: 'theater', canonical: 'zombie_theater' },
|
||||||
{ friendly: 'Ascension', slug: 'cosmodrome', canonical: 'zombie_cosmodrome' },
|
{ friendly: 'Ascension', slug: 'cosmodrome', canonical: 'zombie_cosmodrome' },
|
||||||
@@ -29,14 +34,52 @@ export const T5_ZOMBIE_MAPS: readonly T5MapEntry[] = Object.freeze([
|
|||||||
{ friendly: 'Der Riese', slug: 'factory', canonical: 'zombie_cod5_factory' }
|
{ friendly: 'Der Riese', slug: 'factory', canonical: 'zombie_cod5_factory' }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Rulesets from xerxes-at/T6ServerConfigs, also seeded by Plutainer.
|
||||||
|
// Several selections share an engine map; slug preserves the selected variant.
|
||||||
|
export const T6_ZOMBIE_MAPS: readonly ZombieMapEntry[] = Object.freeze([
|
||||||
|
{ friendly: 'TranZit', slug: 'transit', canonical: 'zm_transit', gameSettings: 'zm_classic_transit.cfg' },
|
||||||
|
{ friendly: 'Town Survival', slug: 'town', canonical: 'zm_transit', gameSettings: 'zm_standard_town.cfg' },
|
||||||
|
{ friendly: 'Farm Survival', slug: 'farm', canonical: 'zm_transit', gameSettings: 'zm_standard_farm.cfg' },
|
||||||
|
{ friendly: 'Bus Depot Survival', slug: 'busdepot', canonical: 'zm_transit', gameSettings: 'zm_standard_transit.cfg' },
|
||||||
|
{ friendly: 'Nuketown', slug: 'nuked', canonical: 'zm_nuked', gameSettings: 'zm_standard_nuked.cfg' },
|
||||||
|
{ friendly: 'Die Rise', slug: 'highrise', canonical: 'zm_highrise', gameSettings: 'zm_classic_rooftop.cfg' },
|
||||||
|
{ friendly: 'Mob of the Dead', slug: 'prison', canonical: 'zm_prison', gameSettings: 'zm_classic_prison.cfg' },
|
||||||
|
{ friendly: 'Buried', slug: 'buried', canonical: 'zm_buried', gameSettings: 'zm_classic_processing.cfg' },
|
||||||
|
{ friendly: 'Origins', slug: 'tomb', canonical: 'zm_tomb', gameSettings: 'zm_classic_tomb.cfg' },
|
||||||
|
{ friendly: 'Town Grief', slug: 'towngrief', canonical: 'zm_transit', gameSettings: 'zm_grief_town.cfg' },
|
||||||
|
{ friendly: 'Farm Grief', slug: 'farmgrief', canonical: 'zm_transit', gameSettings: 'zm_grief_farm.cfg' },
|
||||||
|
{ friendly: 'Bus Depot Grief', slug: 'busdepotgrief', canonical: 'zm_transit', gameSettings: 'zm_grief_transit.cfg' },
|
||||||
|
{ friendly: 'Cell Block Grief', slug: 'cellblock', canonical: 'zm_prison', gameSettings: 'zm_grief_cellblock.cfg' },
|
||||||
|
{ friendly: 'Buried Grief', slug: 'buriedgrief', canonical: 'zm_buried', gameSettings: 'zm_grief_street.cfg' },
|
||||||
|
{ friendly: 'Diner Turned', slug: 'diner', canonical: 'zm_transit_dr', gameSettings: 'zm_cleansed_diner.cfg' },
|
||||||
|
{ friendly: 'Buried Turned', slug: 'buriedturned', canonical: 'zm_buried', gameSettings: 'zm_cleansed_street.cfg' }
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function zombieMaps(game: string | null): readonly ZombieMapEntry[] {
|
||||||
|
return game === 't5' ? T5_ZOMBIE_MAPS : game === 't6' ? T6_ZOMBIE_MAPS : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function zombieMapBySlug(game: string, slug: string): ZombieMapEntry | undefined {
|
||||||
|
return zombieMaps(game).find((entry) => entry.slug === slug.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectedZombieMap(game: string | null, map: string | null, slug?: string | null): ZombieMapEntry | undefined {
|
||||||
|
const maps = zombieMaps(game);
|
||||||
|
// T5 maps are unambiguous, including legacy friendly slugs. T6 variants
|
||||||
|
// require the stored selection; never fall back from an unknown variant.
|
||||||
|
return maps.find((entry) => entry.canonical === map && (
|
||||||
|
game !== 't6' || !slug || entry.slug === slug
|
||||||
|
|| entry.friendly.toLowerCase().replace(/[^a-z0-9]/g, '') === slug
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapRotation(entry: ZombieMapEntry): string {
|
||||||
|
return `${entry.gameSettings ? `execgts ${entry.gameSettings} ` : ''}map ${entry.canonical}`;
|
||||||
|
}
|
||||||
|
|
||||||
const BY_SLUG: ReadonlyMap<string, T5MapEntry> = new Map(
|
const BY_SLUG: ReadonlyMap<string, T5MapEntry> = new Map(
|
||||||
T5_ZOMBIE_MAPS.map((m) => [m.slug, m])
|
T5_ZOMBIE_MAPS.map((m) => [m.slug, m])
|
||||||
);
|
);
|
||||||
// Older folder names used friendly names (shi_no_numa) instead of engine
|
|
||||||
// slugs (sumpf). Keep deriving their canonical map from the same catalog.
|
|
||||||
const BY_FRIENDLY_SLUG: ReadonlyMap<string, T5MapEntry> = new Map(
|
|
||||||
T5_ZOMBIE_MAPS.map((m) => [m.friendly.toLowerCase().replace(/[^a-z0-9]/g, ''), m])
|
|
||||||
);
|
|
||||||
|
|
||||||
export function t5ZombieMapBySlug(slug: string): T5MapEntry | undefined {
|
export function t5ZombieMapBySlug(slug: string): T5MapEntry | undefined {
|
||||||
return BY_SLUG.get(slug.toLowerCase());
|
return BY_SLUG.get(slug.toLowerCase());
|
||||||
@@ -86,6 +129,11 @@ export function parseServerName(name: string): ParsedServerName | null {
|
|||||||
if (slugParts.length === 0) return null;
|
if (slugParts.length === 0) return null;
|
||||||
|
|
||||||
const mapSlug = slugParts.join('').toLowerCase();
|
const mapSlug = slugParts.join('').toLowerCase();
|
||||||
const mapCanonical = (BY_SLUG.get(mapSlug) ?? BY_FRIENDLY_SLUG.get(mapSlug))?.canonical ?? null;
|
const catalog = zombieMaps(game.toLowerCase());
|
||||||
|
const entry = gameMode === null || gameMode === 'zombie'
|
||||||
|
? catalog.find((m) => m.slug === mapSlug)
|
||||||
|
?? catalog.find((m) => m.friendly.toLowerCase().replace(/[^a-z0-9]/g, '') === mapSlug)
|
||||||
|
: undefined;
|
||||||
|
const mapCanonical = entry?.canonical ?? null;
|
||||||
return { game, gameMode, mapSlug, mapCanonical, lobby };
|
return { game, gameMode, mapSlug, mapCanonical, lobby };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { env } from 'node:process';
|
import { env } from 'node:process';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
function req(name: string, fallback?: string): string {
|
function req(name: string, fallback?: string): string {
|
||||||
const v = env[name] ?? fallback;
|
const v = env[name] ?? fallback;
|
||||||
@@ -22,13 +23,16 @@ function positiveInt(name: string, fallback: number): number {
|
|||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
serversRoot: req('SERVERS_ROOT', '/root/zombies/t5'),
|
serversRoot: req('SERVERS_ROOT', '/root/zombies/t5'),
|
||||||
|
t6ServersRoot: env.T6_SERVERS_ROOT ?? join(dirname(req('SERVERS_ROOT', '/root/zombies/t5')), 't6'),
|
||||||
|
t6RelDestScript: env.T6_REL_DEST_SCRIPT ?? 'runtime/plutonium/storage/t6/scripts/zm',
|
||||||
|
t6ConfigTargetFile: env.T6_CONFIG_TARGET_FILE ?? 'dedicated_zm.cfg',
|
||||||
relDest: req('REL_DEST', 'runtime/plutonium/storage/t5/raw/scripts/sp'),
|
relDest: req('REL_DEST', 'runtime/plutonium/storage/t5/raw/scripts/sp'),
|
||||||
// Per-kind destination sub-paths under each server's directory. `relDest`
|
// Per-kind destination sub-paths under each server's directory. `relDest`
|
||||||
// above stays as the script fallback so existing deploys keep working.
|
// above stays as the script fallback so existing deploys keep working.
|
||||||
relDestScript: env.REL_DEST_SCRIPT ?? env.REL_DEST ?? 'runtime/plutonium/storage/t5/raw/scripts/sp',
|
relDestScript: env.REL_DEST_SCRIPT ?? env.REL_DEST ?? 'runtime/plutonium/storage/t5/raw/scripts/sp',
|
||||||
relDestConfig: env.REL_DEST_CONFIG ?? 'configs',
|
relDestConfig: env.REL_DEST_CONFIG ?? 'configs',
|
||||||
relDestPlugin: env.REL_DEST_PLUGIN ?? 'runtime/plutonium/plugins',
|
relDestPlugin: env.REL_DEST_PLUGIN ?? 'runtime/plutonium/plugins',
|
||||||
// Filename the baseline+overlays compose into. Same for every scope today.
|
// Legacy T5 config filename. T6 has its own target above.
|
||||||
configTargetFile: env.CONFIG_TARGET_FILE ?? 'dedicated_sp.cfg',
|
configTargetFile: env.CONFIG_TARGET_FILE ?? 'dedicated_sp.cfg',
|
||||||
// Where on-disk copies of active beta files live for operator visibility.
|
// Where on-disk copies of active beta files live for operator visibility.
|
||||||
// Cleared per-beta on promote/rollback.
|
// Cleared per-beta on promote/rollback.
|
||||||
@@ -45,12 +49,13 @@ export const config = {
|
|||||||
// deploy flow — Komodo needs to know which machine to schedule the
|
// deploy flow — Komodo needs to know which machine to schedule the
|
||||||
// container onto. Look up the exact name in Komodo's UI (Servers page).
|
// container onto. Look up the exact name in Komodo's UI (Servers page).
|
||||||
targetServer: env.KOMODO_TARGET_SERVER ?? '',
|
targetServer: env.KOMODO_TARGET_SERVER ?? '',
|
||||||
// Default image + config for freshly-provisioned t5 zombie servers.
|
// Default image for freshly-provisioned T5 and T6 zombie servers.
|
||||||
// Overridable per-deploy in the UI later if we ever need to.
|
// Overridable per-deploy in the UI later if we ever need to.
|
||||||
defaultImage: env.PLUTAINER_IMAGE ?? 'ghcr.io/ayymoss/plutainer:latest',
|
defaultImage: env.PLUTAINER_IMAGE ?? 'ghcr.io/ayymoss/plutainer:latest',
|
||||||
defaultNetwork: env.PLUTAINER_NETWORK ?? 'komodo_default',
|
defaultNetwork: env.PLUTAINER_NETWORK ?? 'komodo_default',
|
||||||
// Named docker volume that holds the read-only game files layer.
|
// Named docker volume that holds the read-only game files layer.
|
||||||
gameFilesVolume: env.PLUTAINER_GAMEFILES_VOLUME ?? 't5_game_files'
|
gameFilesVolume: env.PLUTAINER_GAMEFILES_VOLUME ?? 't5_game_files',
|
||||||
|
t6GameFilesVolume: env.PLUTAINER_T6_GAMEFILES_VOLUME ?? 't6_game_files'
|
||||||
},
|
},
|
||||||
// Shared across every newly-provisioned server. Kept in sync with the
|
// Shared across every newly-provisioned server. Kept in sync with the
|
||||||
// fleet's RCON_PASSWORD (rcon.password) so freshly-created servers respond
|
// fleet's RCON_PASSWORD (rcon.password) so freshly-created servers respond
|
||||||
@@ -83,17 +88,24 @@ export function rconConfigured(): boolean {
|
|||||||
|
|
||||||
export type ArtifactKind = 'script' | 'config' | 'plugin' | 'baseline';
|
export type ArtifactKind = 'script' | 'config' | 'plugin' | 'baseline';
|
||||||
|
|
||||||
export function relDestForKind(kind: ArtifactKind): string {
|
export function relDestForKind(kind: ArtifactKind, game = 't5'): string {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
case 'script': return config.relDestScript;
|
case 'script': return game === 't6' ? config.t6RelDestScript : config.relDestScript;
|
||||||
case 'config': return config.relDestConfig;
|
case 'config': return config.relDestConfig;
|
||||||
case 'baseline': return config.relDestConfig;
|
case 'baseline': return config.relDestConfig;
|
||||||
case 'plugin': return config.relDestPlugin;
|
case 'plugin': return config.relDestPlugin;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Server IDs keep the game prefix even while creation is queued and no registry
|
||||||
|
// row exists yet. Legacy IDs retain the original T5 filesystem defaults.
|
||||||
|
export const gameOfServer = (server: string) => server.startsWith('t6_') ? 't6' : 't5';
|
||||||
|
export const rootOfServer = (server: string) => gameOfServer(server) === 't6' ? config.t6ServersRoot : config.serversRoot;
|
||||||
|
export const serverDir = (server: string) => `${rootOfServer(server)}/${server}`;
|
||||||
|
export const configFileForGame = (game: string | null) => game === 't6' ? config.t6ConfigTargetFile : config.configTargetFile;
|
||||||
|
export const configFileOf = (server: string) => configFileForGame(gameOfServer(server));
|
||||||
export const destOfKind = (server: string, kind: ArtifactKind) =>
|
export const destOfKind = (server: string, kind: ArtifactKind) =>
|
||||||
`${config.serversRoot}/${server}/${relDestForKind(kind)}`;
|
`${serverDir(server)}/${relDestForKind(kind, gameOfServer(server))}`;
|
||||||
|
|
||||||
// Back-compat: existing call sites written before the multi-kind refactor
|
// Back-compat: existing call sites written before the multi-kind refactor
|
||||||
// resolve to the script destination.
|
// resolve to the script destination.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import { dirname, join, posix } from 'node:path';
|
import { dirname, join, posix } from 'node:path';
|
||||||
import { betaArchiveOf, config, destOf, destOfKind, type ArtifactKind } from '../config.js';
|
import { betaArchiveOf, configFileOf, destOfKind, type ArtifactKind } from '../config.js';
|
||||||
import { db } from '../db/index.js';
|
import { db } from '../db/index.js';
|
||||||
import { sha256Buffer } from './files.js';
|
import { sha256Buffer } from './files.js';
|
||||||
import { ensureDest, getServer, heldByBeta } from './servers.js';
|
import { ensureDest, getServer, heldByBeta } from './servers.js';
|
||||||
@@ -29,7 +29,7 @@ function isCollapsingKind(kind: ArtifactKind): boolean {
|
|||||||
|
|
||||||
// The path we should write to for the composed cfg on this server.
|
// The path we should write to for the composed cfg on this server.
|
||||||
function composedCfgPath(server: string): string {
|
function composedCfgPath(server: string): string {
|
||||||
return join(destOfKind(server, 'config'), config.configTargetFile);
|
return join(destOfKind(server, 'config'), configFileOf(server));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compose the effective dedicated_sp.cfg for a given beta+server, taking
|
// Compose the effective dedicated_sp.cfg for a given beta+server, taking
|
||||||
@@ -112,7 +112,7 @@ function composeCfgForServer(
|
|||||||
.filter(({ script }) => scopeAppliesToServer(script, { game: serverGame, map: serverMap }))
|
.filter(({ script }) => scopeAppliesToServer(script, { game: serverGame, map: serverMap }))
|
||||||
.map(({ script, version }) => ({ id: script.id, text: version.content.toString('utf8') }));
|
.map(({ script, version }) => ({ id: script.id, text: version.content.toString('utf8') }));
|
||||||
|
|
||||||
const composed = composeServerCfg(baselineText, [...promotedOverlays, ...inBetaScopedOverlays], serverGame, serverMap);
|
const composed = composeServerCfg(baselineText, [...promotedOverlays, ...inBetaScopedOverlays], serverGame, serverMap, getServer(serverName)?.map_slug);
|
||||||
return { text: composed, baselineScriptId };
|
return { text: composed, baselineScriptId };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -480,7 +480,7 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
|||||||
}
|
}
|
||||||
const dest = destOfKind(name, 'config');
|
const dest = destOfKind(name, 'config');
|
||||||
await mkdir(dest, { recursive: true });
|
await mkdir(dest, { recursive: true });
|
||||||
const targetRel = config.configTargetFile;
|
const targetRel = configFileOf(name);
|
||||||
const targetPath = join(dest, targetRel);
|
const targetPath = join(dest, targetRel);
|
||||||
if (existsSync(targetPath)) {
|
if (existsSync(targetPath)) {
|
||||||
const priorBytes = await readFile(targetPath);
|
const priorBytes = await readFile(targetPath);
|
||||||
@@ -504,7 +504,7 @@ export async function openBeta(input: OpenBetaInput): Promise<OpenBetaResult> {
|
|||||||
`beta:${betaId}`
|
`beta:${betaId}`
|
||||||
);
|
);
|
||||||
// Archive the composed preview for operator visibility.
|
// Archive the composed preview for operator visibility.
|
||||||
await writeFile(join(archive, `${name}.${config.configTargetFile}`), composedBuf);
|
await writeFile(join(archive, `${name}.${configFileOf(name)}`), composedBuf);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
firstErr = `configs: ${(err as Error).message}`;
|
firstErr = `configs: ${(err as Error).message}`;
|
||||||
}
|
}
|
||||||
@@ -603,7 +603,7 @@ export async function rollbackBeta(betaId: number): Promise<RollbackResult> {
|
|||||||
// Collapsing config layer: single restore for the shared cfg file.
|
// Collapsing config layer: single restore for the shared cfg file.
|
||||||
if (hasCollapsing) {
|
if (hasCollapsing) {
|
||||||
const dest = destOfKind(bs.server, 'config');
|
const dest = destOfKind(bs.server, 'config');
|
||||||
const targetRel = config.configTargetFile;
|
const targetRel = configFileOf(bs.server);
|
||||||
const targetPath = join(dest, targetRel);
|
const targetPath = join(dest, targetRel);
|
||||||
const snap = getSnapshot.get(betaId, bs.server, targetRel) as
|
const snap = getSnapshot.get(betaId, bs.server, targetRel) as
|
||||||
| { prior_content: Buffer; prior_sha256: string; prior_size: number }
|
| { prior_content: Buffer; prior_sha256: string; prior_size: number }
|
||||||
@@ -828,7 +828,7 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
|||||||
const composedBuf = Buffer.from(composedResult.text, 'utf8');
|
const composedBuf = Buffer.from(composedResult.text, 'utf8');
|
||||||
const composedSha = sha256Buffer(composedBuf);
|
const composedSha = sha256Buffer(composedBuf);
|
||||||
const dest = destOfKind(name, 'config');
|
const dest = destOfKind(name, 'config');
|
||||||
const targetRel = config.configTargetFile;
|
const targetRel = configFileOf(name);
|
||||||
const targetPath = join(dest, targetRel);
|
const targetPath = join(dest, targetRel);
|
||||||
|
|
||||||
let priorContent: Buffer | null = null;
|
let priorContent: Buffer | null = null;
|
||||||
@@ -862,7 +862,7 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
|||||||
try {
|
try {
|
||||||
const dest = destOfKind(cp.server, 'config');
|
const dest = destOfKind(cp.server, 'config');
|
||||||
await mkdir(dest, { recursive: true });
|
await mkdir(dest, { recursive: true });
|
||||||
const targetRel = config.configTargetFile;
|
const targetRel = configFileOf(cp.server);
|
||||||
const targetPath = join(dest, targetRel);
|
const targetPath = join(dest, targetRel);
|
||||||
// Beta server already has composed content at targetPath; overwrite
|
// Beta server already has composed content at targetPath; overwrite
|
||||||
// idempotently. Scope-expansion server: write in place.
|
// idempotently. Scope-expansion server: write in place.
|
||||||
@@ -942,7 +942,7 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
|||||||
depId,
|
depId,
|
||||||
cp.server,
|
cp.server,
|
||||||
verb,
|
verb,
|
||||||
config.configTargetFile,
|
configFileOf(cp.server),
|
||||||
cp.priorSha,
|
cp.priorSha,
|
||||||
cp.composedSha,
|
cp.composedSha,
|
||||||
cp.priorContent,
|
cp.priorContent,
|
||||||
@@ -950,7 +950,7 @@ export async function promoteBeta(betaId: number): Promise<PromoteResult> {
|
|||||||
);
|
);
|
||||||
recordScript(
|
recordScript(
|
||||||
cp.server,
|
cp.server,
|
||||||
config.configTargetFile,
|
configFileOf(cp.server),
|
||||||
null,
|
null,
|
||||||
cp.composedSha,
|
cp.composedSha,
|
||||||
cp.composedContent.byteLength,
|
cp.composedContent.byteLength,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
// - Line endings default to \n for the composed output. Baseline CRLF is
|
// - Line endings default to \n for the composed output. Baseline CRLF is
|
||||||
// preserved if the baseline uses CRLF throughout.
|
// preserved if the baseline uses CRLF throughout.
|
||||||
|
|
||||||
import { T5_ZOMBIE_MAPS } from './map-aliases.js';
|
import { selectedZombieMap, mapRotation } from './map-aliases.js';
|
||||||
|
|
||||||
interface CfgLine {
|
interface CfgLine {
|
||||||
kind: 'set' | 'raw';
|
kind: 'set' | 'raw';
|
||||||
@@ -130,17 +130,18 @@ export function composeServerCfg(
|
|||||||
baselineText: string,
|
baselineText: string,
|
||||||
overlays: OverlayInput[],
|
overlays: OverlayInput[],
|
||||||
game: string | null,
|
game: string | null,
|
||||||
map: string | null
|
map: string | null,
|
||||||
|
mapSlug?: string | null
|
||||||
): string {
|
): string {
|
||||||
const composed = composeCfg(baselineText, overlays);
|
const composed = composeCfg(baselineText, overlays);
|
||||||
const selected = game === 't5' ? T5_ZOMBIE_MAPS.find((entry) => entry.canonical === map) : undefined;
|
const selected = selectedZombieMap(game, map, mapSlug);
|
||||||
// Legacy/unrecognized servers retain their existing config semantics.
|
// Legacy/unrecognized servers retain their existing config semantics.
|
||||||
if (!selected) return composed;
|
if (!selected) return composed;
|
||||||
|
|
||||||
const eol = parseCfg(composed).eol;
|
const eol = parseCfg(composed).eol;
|
||||||
const lines = composed.split(/\r?\n/);
|
const lines = composed.split(/\r?\n/);
|
||||||
lines.pop(); // composeCfg always emits a trailing newline.
|
lines.pop(); // composeCfg always emits a trailing newline.
|
||||||
const rotation = `set sv_maprotation "map ${selected.canonical}"`;
|
const rotation = `set sv_maprotation "${mapRotation(selected)}"`;
|
||||||
// Dvars are case-insensitive. Remove duplicate assignments, including
|
// Dvars are case-insensitive. Remove duplicate assignments, including
|
||||||
// seta and bare forms, so old map-choice overlays cannot undo selection.
|
// seta and bare forms, so old map-choice overlays cannot undo selection.
|
||||||
const isRotation = /^\s*(?:(?:set|seta)\s+)?sv_maprotation\s+/i;
|
const isRotation = /^\s*(?:(?:set|seta)\s+)?sv_maprotation\s+/i;
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import { rconGetPlayerCount, rconStatusResult } from '../integrations/rcon.js';
|
|||||||
import { beginDeployment, deploymentStage, finishDeployment } from './deployments.js';
|
import { beginDeployment, deploymentStage, finishDeployment } from './deployments.js';
|
||||||
import { mkdir, chmod, rm, writeFile } from 'node:fs/promises';
|
import { mkdir, chmod, rm, writeFile } from 'node:fs/promises';
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { config, destOfKind } from '../config.js';
|
import { config, destOfKind, serverDir, gameOfServer, configFileForGame } from '../config.js';
|
||||||
import { db } from '../db/index.js';
|
import { db } from '../db/index.js';
|
||||||
import { getServerKey, saveServerKey } from './server-keys.js';
|
import { getServerKey, saveServerKey } from './server-keys.js';
|
||||||
import { getServer } from './servers.js';
|
import { getServer } from './servers.js';
|
||||||
import { t5ZombieMapBySlug, type GameMode, type T5MapEntry } from './map-aliases.js';
|
import { zombieMapBySlug, type GameMode, type ZombieMapEntry, type ZombieGame } from './map-aliases.js';
|
||||||
import {
|
import {
|
||||||
komodoCreateDeployment,
|
komodoCreateDeployment,
|
||||||
komodoDeploy,
|
komodoDeploy,
|
||||||
@@ -24,11 +24,6 @@ import { recordScript } from './inventory.js';
|
|||||||
import { sha256Buffer } from './files.js';
|
import { sha256Buffer } from './files.js';
|
||||||
import { promotedArtifacts } from './promoted-artifacts.js';
|
import { promotedArtifacts } from './promoted-artifacts.js';
|
||||||
|
|
||||||
// Currently every t5 zombie server uses the same config file + game id. If we
|
|
||||||
// ever add MP or SP, split these based on mode.
|
|
||||||
const T5_ZOMBIE_CONFIG_FILE = config.configTargetFile;
|
|
||||||
const T5_ZOMBIE_GAME_ID = 't5sp';
|
|
||||||
|
|
||||||
export type CreateServerFailure =
|
export type CreateServerFailure =
|
||||||
| 'bad-request'
|
| 'bad-request'
|
||||||
| 'unconfigured' // Komodo creds / target server missing
|
| 'unconfigured' // Komodo creds / target server missing
|
||||||
@@ -48,6 +43,7 @@ export type DestroyServerResult =
|
|||||||
| { ok: false; kind: 'not-found' | 'komodo' | 'fs' | 'db'; message: string; warnings: string[] };
|
| { ok: false; kind: 'not-found' | 'komodo' | 'fs' | 'db'; message: string; warnings: string[] };
|
||||||
|
|
||||||
export interface CreateServerInput {
|
export interface CreateServerInput {
|
||||||
|
game?: ZombieGame; // omitted by older clients: T5
|
||||||
gameMode: GameMode; // 'zombie' — only value supported today
|
gameMode: GameMode; // 'zombie' — only value supported today
|
||||||
mapSlug: string; // 'cosmodrome'
|
mapSlug: string; // 'cosmodrome'
|
||||||
plutoKey?: string; // saved for this server ID when supplied
|
plutoKey?: string; // saved for this server ID when supplied
|
||||||
@@ -98,6 +94,7 @@ function nextAvailablePort(): number | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildEnv(args: {
|
function buildEnv(args: {
|
||||||
|
game: ZombieGame;
|
||||||
name: string;
|
name: string;
|
||||||
port: number;
|
port: number;
|
||||||
plutoKey: string;
|
plutoKey: string;
|
||||||
@@ -105,8 +102,8 @@ function buildEnv(args: {
|
|||||||
return [
|
return [
|
||||||
{ variable: 'PLUTAINER_SERVER_NAME', value: args.name },
|
{ variable: 'PLUTAINER_SERVER_NAME', value: args.name },
|
||||||
{ variable: 'PLUTAINER_PORT', value: String(args.port) },
|
{ variable: 'PLUTAINER_PORT', value: String(args.port) },
|
||||||
{ variable: 'PLUTAINER_GAME', value: T5_ZOMBIE_GAME_ID },
|
{ variable: 'PLUTAINER_GAME', value: args.game === 't6' ? 't6zm' : 't5sp' },
|
||||||
{ variable: 'PLUTAINER_CONFIG_FILE', value: T5_ZOMBIE_CONFIG_FILE },
|
{ variable: 'PLUTAINER_CONFIG_FILE', value: configFileForGame(args.game) },
|
||||||
// Plutainer seeds/loads the main config first. Load our generated map
|
// Plutainer seeds/loads the main config first. Load our generated map
|
||||||
// config afterward, then rotate. A separate exec avoids quoted values
|
// config afterward, then rotate. A separate exec avoids quoted values
|
||||||
// in EXTRA_ARGS, which Plutainer splits on whitespace.
|
// in EXTRA_ARGS, which Plutainer splits on whitespace.
|
||||||
@@ -119,6 +116,7 @@ function buildEnv(args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildSpec(args: {
|
function buildSpec(args: {
|
||||||
|
game: ZombieGame;
|
||||||
name: string;
|
name: string;
|
||||||
port: number;
|
port: number;
|
||||||
hostDir: string;
|
hostDir: string;
|
||||||
@@ -137,7 +135,7 @@ function buildSpec(args: {
|
|||||||
],
|
],
|
||||||
volumes: [
|
volumes: [
|
||||||
`${args.hostDir}:/home/plutainer/app`,
|
`${args.hostDir}:/home/plutainer/app`,
|
||||||
`${config.komodo.gameFilesVolume}:/home/plutainer/gamefiles:ro`
|
`${args.game === 't6' ? config.komodo.t6GameFilesVolume : config.komodo.gameFilesVolume}:/home/plutainer/gamefiles:ro`
|
||||||
],
|
],
|
||||||
environment: buildEnv(args)
|
environment: buildEnv(args)
|
||||||
};
|
};
|
||||||
@@ -145,7 +143,7 @@ function buildSpec(args: {
|
|||||||
|
|
||||||
let creating = false;
|
let creating = false;
|
||||||
interface PreparedCreation {
|
interface PreparedCreation {
|
||||||
ok: true; name: string; port: number; plutoKey: string; mapEntry: T5MapEntry;
|
ok: true; name: string; port: number; plutoKey: string; mapEntry: ZombieMapEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function queueCreateServer(input: CreateServerInput): CreateServerResult {
|
export function queueCreateServer(input: CreateServerInput): CreateServerResult {
|
||||||
@@ -170,6 +168,10 @@ export async function createServer(input: CreateServerInput): Promise<CreateServ
|
|||||||
}
|
}
|
||||||
|
|
||||||
function prepareCreation(input: CreateServerInput): PreparedCreation | Extract<CreateServerResult, { ok: false }> {
|
function prepareCreation(input: CreateServerInput): PreparedCreation | Extract<CreateServerResult, { ok: false }> {
|
||||||
|
const game = input.game ?? 't5';
|
||||||
|
if (game !== 't5' && game !== 't6') {
|
||||||
|
return { ok: false, kind: 'bad-request', message: 'Supported games are t5 and t6' };
|
||||||
|
}
|
||||||
if (input.gameMode !== 'zombie') {
|
if (input.gameMode !== 'zombie') {
|
||||||
return { ok: false, kind: 'unknown-map', message: `unsupported game mode: ${input.gameMode}` };
|
return { ok: false, kind: 'unknown-map', message: `unsupported game mode: ${input.gameMode}` };
|
||||||
}
|
}
|
||||||
@@ -180,12 +182,12 @@ function prepareCreation(input: CreateServerInput): PreparedCreation | Extract<C
|
|||||||
return { ok: false, kind: 'unconfigured', message: 'KOMODO_TARGET_SERVER must name the Komodo host that runs new deployments' };
|
return { ok: false, kind: 'unconfigured', message: 'KOMODO_TARGET_SERVER must name the Komodo host that runs new deployments' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapEntry = t5ZombieMapBySlug(input.mapSlug);
|
const mapEntry = zombieMapBySlug(game, input.mapSlug);
|
||||||
if (!mapEntry) {
|
if (!mapEntry) {
|
||||||
return { ok: false, kind: 'unknown-map', message: `no t5 zombie map with slug '${input.mapSlug}'` };
|
return { ok: false, kind: 'unknown-map', message: `no ${game} zombie map with slug '${input.mapSlug}'` };
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseName = `t5_${input.gameMode}_${mapEntry.slug}`;
|
const baseName = `${game}_${input.gameMode}_${mapEntry.slug}`;
|
||||||
const lobby = nextLobbyNumber(baseName);
|
const lobby = nextLobbyNumber(baseName);
|
||||||
if (lobby === null) {
|
if (lobby === null) {
|
||||||
return { ok: false, kind: 'no-slot', message: `all lobby slots (1..20) for ${baseName} are taken` };
|
return { ok: false, kind: 'no-slot', message: `all lobby slots (1..20) for ${baseName} are taken` };
|
||||||
@@ -215,11 +217,13 @@ function prepareCreation(input: CreateServerInput): PreparedCreation | Extract<C
|
|||||||
|
|
||||||
async function createServerUnlocked(input: CreateServerInput, prepared: PreparedCreation, operation: string): Promise<CreateServerResult> {
|
async function createServerUnlocked(input: CreateServerInput, prepared: PreparedCreation, operation: string): Promise<CreateServerResult> {
|
||||||
const { name, port, plutoKey, mapEntry } = prepared;
|
const { name, port, plutoKey, mapEntry } = prepared;
|
||||||
|
const game = gameOfServer(name);
|
||||||
|
const configFile = configFileForGame(game);
|
||||||
deploymentStage(operation, 'Preparing server files');
|
deploymentStage(operation, 'Preparing server files');
|
||||||
const hostDir = `${config.serversRoot}/${name}`;
|
const hostDir = serverDir(name);
|
||||||
const initialConfig = getPromotedServerConfig('t5', mapEntry.canonical);
|
const initialConfig = getPromotedServerConfig(game, mapEntry.canonical, mapEntry.slug);
|
||||||
const mapConfig = getServerMapConfig('t5', mapEntry.canonical)!;
|
const mapConfig = getServerMapConfig(game, mapEntry.canonical, mapEntry.slug)!;
|
||||||
const initialArtifacts = promotedArtifacts('t5', mapEntry.canonical);
|
const initialArtifacts = promotedArtifacts(game, mapEntry.canonical);
|
||||||
|
|
||||||
// Step 1: filesystem. mkdir mode is masked by umask, so chmod explicitly
|
// Step 1: filesystem. mkdir mode is masked by umask, so chmod explicitly
|
||||||
// after — plutainer expects to be able to write to /home/plutainer/app.
|
// after — plutainer expects to be able to write to /home/plutainer/app.
|
||||||
@@ -244,7 +248,7 @@ async function createServerUnlocked(input: CreateServerInput, prepared: Prepared
|
|||||||
await writeFile(mapConfigPath, mapConfig);
|
await writeFile(mapConfigPath, mapConfig);
|
||||||
await chmod(mapConfigPath, 0o666);
|
await chmod(mapConfigPath, 0o666);
|
||||||
if (initialConfig) {
|
if (initialConfig) {
|
||||||
const configPath = join(destOfKind(name, 'config'), T5_ZOMBIE_CONFIG_FILE);
|
const configPath = join(destOfKind(name, 'config'), configFile);
|
||||||
await mkdir(dirname(configPath), { recursive: true, mode: 0o777 });
|
await mkdir(dirname(configPath), { recursive: true, mode: 0o777 });
|
||||||
await chmod(dirname(configPath), 0o777);
|
await chmod(dirname(configPath), 0o777);
|
||||||
await writeFile(configPath, initialConfig);
|
await writeFile(configPath, initialConfig);
|
||||||
@@ -256,7 +260,7 @@ async function createServerUnlocked(input: CreateServerInput, prepared: Prepared
|
|||||||
|
|
||||||
// Step 2: Komodo CreateDeployment.
|
// Step 2: Komodo CreateDeployment.
|
||||||
deploymentStage(operation, 'Creating Komodo deployment');
|
deploymentStage(operation, 'Creating Komodo deployment');
|
||||||
const spec = buildSpec({ name, port, hostDir, plutoKey });
|
const spec = buildSpec({ game, name, port, hostDir, plutoKey });
|
||||||
const createRes = await komodoCreateDeployment(spec);
|
const createRes = await komodoCreateDeployment(spec);
|
||||||
if (!createRes.ok) {
|
if (!createRes.ok) {
|
||||||
await rm(hostDir, { recursive: true, force: true }).catch(() => {});
|
await rm(hostDir, { recursive: true, force: true }).catch(() => {});
|
||||||
@@ -273,17 +277,17 @@ async function createServerUnlocked(input: CreateServerInput, prepared: Prepared
|
|||||||
game_mode, map_slug, host_dir, config_file, game_id, image,
|
game_mode, map_slug, host_dir, config_file, game_id, image,
|
||||||
game_port, created_by, container_state)
|
game_port, created_by, container_state)
|
||||||
VALUES
|
VALUES
|
||||||
(?, 'deployment', ?, 't5', ?,
|
(?, 'deployment', ?, ?, ?,
|
||||||
?, ?, ?, ?, ?, ?,
|
?, ?, ?, ?, ?, ?,
|
||||||
?, ?, 'starting')`
|
?, ?, 'starting')`
|
||||||
).run(
|
).run(
|
||||||
name, name, mapEntry.canonical,
|
name, name, game, mapEntry.canonical,
|
||||||
input.gameMode, mapEntry.slug, hostDir,
|
input.gameMode, mapEntry.slug, hostDir,
|
||||||
T5_ZOMBIE_CONFIG_FILE, T5_ZOMBIE_GAME_ID, config.komodo.defaultImage,
|
configFile, game === 't6' ? 't6zm' : 't5sp', config.komodo.defaultImage,
|
||||||
port, input.createdBy ?? 'ui'
|
port, input.createdBy ?? 'ui'
|
||||||
);
|
);
|
||||||
if (initialConfig) {
|
if (initialConfig) {
|
||||||
recordScript(name, T5_ZOMBIE_CONFIG_FILE, null, sha256Buffer(initialConfig), initialConfig.byteLength, 'create');
|
recordScript(name, configFile, null, sha256Buffer(initialConfig), initialConfig.byteLength, 'create');
|
||||||
}
|
}
|
||||||
recordScript(name, MAP_CONFIG_FILE, null, sha256Buffer(mapConfig), mapConfig.byteLength, 'create-map');
|
recordScript(name, MAP_CONFIG_FILE, null, sha256Buffer(mapConfig), mapConfig.byteLength, 'create-map');
|
||||||
for (const { script, version } of initialArtifacts) {
|
for (const { script, version } of initialArtifacts) {
|
||||||
@@ -303,7 +307,7 @@ async function createServerUnlocked(input: CreateServerInput, prepared: Prepared
|
|||||||
// by game/map. Best-effort — a tag failure doesn't roll back the server;
|
// by game/map. Best-effort — a tag failure doesn't roll back the server;
|
||||||
// the deployment is fully functional without cosmetic tags. Fires in
|
// the deployment is fully functional without cosmetic tags. Fires in
|
||||||
// parallel to keep the create latency down.
|
// parallel to keep the create latency down.
|
||||||
void applyDeploymentTags(name, 't5', mapEntry.canonical);
|
void applyDeploymentTags(name, game, mapEntry.canonical);
|
||||||
|
|
||||||
// Step 5: execute/Deploy — actually start the container.
|
// Step 5: execute/Deploy — actually start the container.
|
||||||
deploymentStage(operation, 'Starting container');
|
deploymentStage(operation, 'Starting container');
|
||||||
@@ -440,7 +444,7 @@ async function destroyServerUnlocked(name: string, operation: string): Promise<D
|
|||||||
// 3: host directory. Fall back to derived path if host_dir wasn't set on
|
// 3: host directory. Fall back to derived path if host_dir wasn't set on
|
||||||
// legacy rows.
|
// legacy rows.
|
||||||
deploymentStage(operation, 'Removing server files and registry');
|
deploymentStage(operation, 'Removing server files and registry');
|
||||||
const hostDir = row.host_dir ?? `${config.serversRoot}/${name}`;
|
const hostDir = row.host_dir ?? serverDir(name);
|
||||||
try {
|
try {
|
||||||
await rm(hostDir, { recursive: true, force: true });
|
await rm(hostDir, { recursive: true, force: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -481,7 +485,7 @@ async function provisionCreation(id: string): Promise<CreateServerResult> {
|
|||||||
creating = true;
|
creating = true;
|
||||||
db().prepare("UPDATE server_deployments SET state = 'running' WHERE id = ?").run(id);
|
db().prepare("UPDATE server_deployments SET state = 'running' WHERE id = ?").run(id);
|
||||||
try {
|
try {
|
||||||
const mapEntry = t5ZombieMapBySlug(job.map_slug);
|
const mapEntry = zombieMapBySlug(gameOfServer(job.server), job.map_slug);
|
||||||
const plutoKey = getServerKey(job.server);
|
const plutoKey = getServerKey(job.server);
|
||||||
if (!mapEntry || !plutoKey) {
|
if (!mapEntry || !plutoKey) {
|
||||||
const result = { ok: false as const, kind: 'bad-request' as const, message: 'Saved server key or selected map is no longer available' };
|
const result = { ok: false as const, kind: 'bad-request' as const, message: 'Saved server key or selected map is no longer available' };
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import { dirname, join, posix, relative, sep } from 'node:path';
|
import { dirname, join, posix, relative, sep } from 'node:path';
|
||||||
import { config, destOfKind, type ArtifactKind } from '../config.js';
|
import { configFileOf, destOfKind, type ArtifactKind } from '../config.js';
|
||||||
import { db } from '../db/index.js';
|
import { db } from '../db/index.js';
|
||||||
import { emit } from '../pipeline/bus.js';
|
import { emit } from '../pipeline/bus.js';
|
||||||
import { sha256Buffer } from './files.js';
|
import { sha256Buffer } from './files.js';
|
||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
} from './inventory.js';
|
} from './inventory.js';
|
||||||
import { getBaselineForScope, getVersion } from './scripts.js';
|
import { getBaselineForScope, getVersion } from './scripts.js';
|
||||||
import { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } from './server-config.js';
|
import { getPromotedServerConfig, getServerMapConfig, MAP_CONFIG_FILE } from './server-config.js';
|
||||||
import { T5_ZOMBIE_MAPS } from './map-aliases.js';
|
import { selectedZombieMap } from './map-aliases.js';
|
||||||
import { serverDirectories, getServer, listServers } from './servers.js';
|
import { serverDirectories, getServer, listServers } from './servers.js';
|
||||||
import { promotedArtifacts } from './promoted-artifacts.js';
|
import { promotedArtifacts } from './promoted-artifacts.js';
|
||||||
|
|
||||||
@@ -92,8 +92,8 @@ export async function scanServerDrift(server: string): Promise<DriftEntry[]> {
|
|||||||
const srv = getServer(server);
|
const srv = getServer(server);
|
||||||
// Without a promoted baseline, Plutainer owns the seeded config. Keep it
|
// Without a promoted baseline, Plutainer owns the seeded config. Keep it
|
||||||
// until a baseline is promoted; startup arguments handle map selection.
|
// until a baseline is promoted; startup arguments handle map selection.
|
||||||
const keepDefaultConfig = srv?.game === 't5'
|
const keepDefaultConfig = srv?.game
|
||||||
&& T5_ZOMBIE_MAPS.some((entry) => entry.canonical === srv.map)
|
&& selectedZombieMap(srv.game, srv.map, srv.map_slug)
|
||||||
&& !getBaselineForScope('game', srv.game)?.current_version_id;
|
&& !getBaselineForScope('game', srv.game)?.current_version_id;
|
||||||
|
|
||||||
// Scan for untracked files only after resolving managed targets.
|
// Scan for untracked files only after resolving managed targets.
|
||||||
@@ -104,9 +104,9 @@ export async function scanServerDrift(server: string): Promise<DriftEntry[]> {
|
|||||||
|
|
||||||
for (const relPath of onDisk) {
|
for (const relPath of onDisk) {
|
||||||
if (tracked.has(relPath) || managed.has(`${kind}:${relPath}`)) continue;
|
if (tracked.has(relPath) || managed.has(`${kind}:${relPath}`)) continue;
|
||||||
if (keepDefaultConfig && kind === 'config' && relPath === config.configTargetFile) continue;
|
if (keepDefaultConfig && kind === 'config' && relPath === configFileOf(server)) continue;
|
||||||
if (kind === 'config' && relPath === MAP_CONFIG_FILE && srv?.created_by
|
if (kind === 'config' && relPath === MAP_CONFIG_FILE && srv?.created_by
|
||||||
&& srv.game_mode === 'zombie' && getServerMapConfig(srv.game, srv.map)) continue;
|
&& srv.game_mode === 'zombie' && getServerMapConfig(srv.game, srv.map, srv.map_slug)) continue;
|
||||||
const abs = join(dest, relPath);
|
const abs = join(dest, relPath);
|
||||||
try {
|
try {
|
||||||
const st = await stat(abs);
|
const st = await stat(abs);
|
||||||
@@ -138,7 +138,7 @@ async function scanManagedForServer(server: string): Promise<DriftEntry[]> {
|
|||||||
// Provisioned servers load this generated file after their main cfg. It
|
// Provisioned servers load this generated file after their main cfg. It
|
||||||
// remains managed even when there is no promoted baseline yet.
|
// remains managed even when there is no promoted baseline yet.
|
||||||
const mapConfig = srv.created_by && srv.game_mode === 'zombie'
|
const mapConfig = srv.created_by && srv.game_mode === 'zombie'
|
||||||
? getServerMapConfig(srv.game, srv.map) : null;
|
? getServerMapConfig(srv.game, srv.map, srv.map_slug) : null;
|
||||||
if (mapConfig) {
|
if (mapConfig) {
|
||||||
const abs = join(destOfKind(server, 'config'), MAP_CONFIG_FILE);
|
const abs = join(destOfKind(server, 'config'), MAP_CONFIG_FILE);
|
||||||
const content = await readFile(abs).catch(() => null);
|
const content = await readFile(abs).catch(() => null);
|
||||||
@@ -168,10 +168,10 @@ async function scanManagedForServer(server: string): Promise<DriftEntry[]> {
|
|||||||
if (srv.game) {
|
if (srv.game) {
|
||||||
const baseline = getBaselineForScope('game', srv.game);
|
const baseline = getBaselineForScope('game', srv.game);
|
||||||
if (baseline?.current_version_id) {
|
if (baseline?.current_version_id) {
|
||||||
const composed = composeExpectedCfg(srv.game, srv.map);
|
const composed = composeExpectedCfg(srv.game, srv.map, srv.map_slug);
|
||||||
if (composed) {
|
if (composed) {
|
||||||
const dest = destOfKind(server, 'config');
|
const dest = destOfKind(server, 'config');
|
||||||
const targetRel = config.configTargetFile;
|
const targetRel = configFileOf(server);
|
||||||
const abs = join(dest, targetRel);
|
const abs = join(dest, targetRel);
|
||||||
const inv = getServerScript(server, targetRel);
|
const inv = getServerScript(server, targetRel);
|
||||||
const onDiskMatches = existsSync(abs) && inv?.sha256 === composed.sha256;
|
const onDiskMatches = existsSync(abs) && inv?.sha256 === composed.sha256;
|
||||||
@@ -203,8 +203,8 @@ async function resolveManagedEntry(server: string, entry: DriftEntry): Promise<B
|
|||||||
if (entry.kind === 'config') {
|
if (entry.kind === 'config') {
|
||||||
const srv = getServer(server);
|
const srv = getServer(server);
|
||||||
if (!srv?.game) return null;
|
if (!srv?.game) return null;
|
||||||
if (entry.relPath === MAP_CONFIG_FILE) return getServerMapConfig(srv.game, srv.map);
|
if (entry.relPath === MAP_CONFIG_FILE) return getServerMapConfig(srv.game, srv.map, srv.map_slug);
|
||||||
const composed = composeExpectedCfg(srv.game, srv.map);
|
const composed = composeExpectedCfg(srv.game, srv.map, srv.map_slug);
|
||||||
return composed?.content ?? null;
|
return composed?.content ?? null;
|
||||||
}
|
}
|
||||||
if (!entry.scriptVersionId) return null;
|
if (!entry.scriptVersionId) return null;
|
||||||
@@ -214,9 +214,10 @@ async function resolveManagedEntry(server: string, entry: DriftEntry): Promise<B
|
|||||||
|
|
||||||
function composeExpectedCfg(
|
function composeExpectedCfg(
|
||||||
game: string | null,
|
game: string | null,
|
||||||
map: string | null
|
map: string | null,
|
||||||
|
mapSlug?: string | null
|
||||||
): { content: Buffer; sha256: string } | null {
|
): { content: Buffer; sha256: string } | null {
|
||||||
const content = getPromotedServerConfig(game, map);
|
const content = getPromotedServerConfig(game, map, mapSlug);
|
||||||
if (!content) return null;
|
if (!content) return null;
|
||||||
return { content, sha256: sha256Buffer(content) };
|
return { content, sha256: sha256Buffer(content) };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
import { open, stat } from 'node:fs/promises';
|
import { open, stat } from 'node:fs/promises';
|
||||||
import { resolve, sep } from 'node:path';
|
import { resolve, sep } from 'node:path';
|
||||||
import { config } from '../config.js';
|
import { rootOfServer, gameOfServer } from '../config.js';
|
||||||
|
|
||||||
// Whitelisted log files exposed to the UI, mapped to their path relative
|
// Whitelisted log files exposed to the UI, mapped to their path relative
|
||||||
// to each server's root directory. Adding a new supported log = one entry
|
// to each server's root directory. Adding a new supported log = one entry
|
||||||
// here + one tab in LogPanel.svelte.
|
// here + one tab in LogPanel.svelte.
|
||||||
export const KNOWN_LOGS = {
|
export const KNOWN_LOGS = {
|
||||||
console: 'logs/console.log',
|
console: 'logs/console.log',
|
||||||
games_sp: 'logs/games_sp.log'
|
games_sp: 'logs/games_sp.log',
|
||||||
|
games_zm: 'logs/games_zm.log'
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type LogKey = keyof typeof KNOWN_LOGS;
|
export type LogKey = keyof typeof KNOWN_LOGS;
|
||||||
|
export const gameLogKey = (server: string): LogKey => gameOfServer(server) === 't6' ? 'games_zm' : 'games_sp';
|
||||||
|
|
||||||
export function isLogKey(v: string): v is LogKey {
|
export function isLogKey(v: string): v is LogKey {
|
||||||
return Object.prototype.hasOwnProperty.call(KNOWN_LOGS, v);
|
return Object.prototype.hasOwnProperty.call(KNOWN_LOGS, v);
|
||||||
@@ -26,7 +28,7 @@ export function isLogKey(v: string): v is LogKey {
|
|||||||
* Returns null on any escape.
|
* Returns null on any escape.
|
||||||
*/
|
*/
|
||||||
export function resolveLogPath(serverName: string, key: LogKey): string | null {
|
export function resolveLogPath(serverName: string, key: LogKey): string | null {
|
||||||
const serversRoot = resolve(config.serversRoot);
|
const serversRoot = resolve(rootOfServer(serverName));
|
||||||
const serverDir = resolve(serversRoot, serverName);
|
const serverDir = resolve(serversRoot, serverName);
|
||||||
if (serverDir === serversRoot || !serverDir.startsWith(serversRoot + sep)) {
|
if (serverDir === serversRoot || !serverDir.startsWith(serversRoot + sep)) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { resolveLogPath, tailLog } from './logs.js';
|
import { resolveLogPath, tailLog, gameLogKey } from './logs.js';
|
||||||
|
|
||||||
// Match markers for T5 Plutonium's `logs/games_sp.log`. Verified against a live
|
// Match markers for T5 Plutonium's `logs/games_sp.log`. Verified against a live
|
||||||
// sample: each game session is bracketed by `InitGame:` and `ShutdownGame:`.
|
// sample: each game session is bracketed by `InitGame:` and `ShutdownGame:`.
|
||||||
@@ -26,7 +26,7 @@ export async function scanForMatchEnd(
|
|||||||
serverName: string,
|
serverName: string,
|
||||||
sinceIso: string
|
sinceIso: string
|
||||||
): Promise<{ ended: boolean; started: boolean; reason?: string }> {
|
): Promise<{ ended: boolean; started: boolean; reason?: string }> {
|
||||||
const path = resolveLogPath(serverName, 'games_sp');
|
const path = resolveLogPath(serverName, gameLogKey(serverName));
|
||||||
if (!path) return { ended: false, started: false, reason: 'log path resolution failed' };
|
if (!path) return { ended: false, started: false, reason: 'log path resolution failed' };
|
||||||
|
|
||||||
const tail = await tailLog(path, 2000);
|
const tail = await tailLog(path, 2000);
|
||||||
@@ -68,7 +68,7 @@ export interface MatchScanResult {
|
|||||||
* - `no-signal`: log missing / unreadable / no InitGame in tail.
|
* - `no-signal`: log missing / unreadable / no InitGame in tail.
|
||||||
*/
|
*/
|
||||||
export async function scanMatchState(serverName: string): Promise<MatchScanResult> {
|
export async function scanMatchState(serverName: string): Promise<MatchScanResult> {
|
||||||
const path = resolveLogPath(serverName, 'games_sp');
|
const path = resolveLogPath(serverName, gameLogKey(serverName));
|
||||||
if (!path) return { kind: 'no-signal' };
|
if (!path) return { kind: 'no-signal' };
|
||||||
|
|
||||||
const tail = await tailLog(path, 2000);
|
const tail = await tailLog(path, 2000);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { db } from '../db/index.js';
|
import { db } from '../db/index.js';
|
||||||
import { config, type ArtifactKind } from '../config.js';
|
import { configFileForGame, type ArtifactKind } from '../config.js';
|
||||||
|
|
||||||
export type { ArtifactKind };
|
export type { ArtifactKind };
|
||||||
|
|
||||||
@@ -172,7 +172,8 @@ export function createScript(input: CreateScriptInput): { script: ScriptRow; ver
|
|||||||
// the same on-disk cfg file, so the operator never picks it.
|
// the same on-disk cfg file, so the operator never picks it.
|
||||||
let targetRelPath = input.targetRelPath?.trim() ?? '';
|
let targetRelPath = input.targetRelPath?.trim() ?? '';
|
||||||
if (!targetRelPath && (kind === 'config' || kind === 'baseline')) {
|
if (!targetRelPath && (kind === 'config' || kind === 'baseline')) {
|
||||||
targetRelPath = config.configTargetFile;
|
targetRelPath = configFileForGame(input.scopeType === 'game' ? input.scopeValue ?? null
|
||||||
|
: input.scopeValue?.startsWith('zm_') ? 't6' : 't5');
|
||||||
}
|
}
|
||||||
if (!targetRelPath) throw new ScriptError('target rel path is required', 'path-empty');
|
if (!targetRelPath) throw new ScriptError('target rel path is required', 'path-empty');
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { composeServerCfg } from './config-compose.js';
|
import { composeServerCfg } from './config-compose.js';
|
||||||
import { getBaselineForScope, getVersion, listPromotedOverlaysForScope } from './scripts.js';
|
import { getBaselineForScope, getVersion, listPromotedOverlaysForScope } from './scripts.js';
|
||||||
import { T5_ZOMBIE_MAPS } from './map-aliases.js';
|
import { selectedZombieMap, mapRotation } from './map-aliases.js';
|
||||||
|
|
||||||
export const MAP_CONFIG_FILE = 'electric_cherry_map.cfg';
|
export const MAP_CONFIG_FILE = 'electric_cherry_map.cfg';
|
||||||
|
|
||||||
/** Loaded after Plutainer's default/main config and before map_rotate. */
|
/** Loaded after Plutainer's default/main config and before map_rotate. */
|
||||||
export function getServerMapConfig(game: string | null, map: string | null): Buffer | null {
|
export function getServerMapConfig(game: string | null, map: string | null, mapSlug?: string | null): Buffer | null {
|
||||||
const entry = game === 't5' ? T5_ZOMBIE_MAPS.find((item) => item.canonical === map) : undefined;
|
const entry = selectedZombieMap(game, map, mapSlug);
|
||||||
return entry ? Buffer.from(`// Generated by Electric Cherry from the selected map.\nset sv_maprotation "map ${entry.canonical}"\n`) : null;
|
return entry ? Buffer.from(`// Generated by Electric Cherry from the selected map.\nset sv_maprotation "${mapRotation(entry)}"\n`) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The promoted config used for first boot and drift reconciliation. */
|
/** The promoted config used for first boot and drift reconciliation. */
|
||||||
export function getPromotedServerConfig(game: string | null, map: string | null): Buffer | null {
|
export function getPromotedServerConfig(game: string | null, map: string | null, mapSlug?: string | null): Buffer | null {
|
||||||
if (!game) return null;
|
if (!game) return null;
|
||||||
const baseline = getBaselineForScope('game', game);
|
const baseline = getBaselineForScope('game', game);
|
||||||
if (!baseline?.current_version_id) return null;
|
if (!baseline?.current_version_id) return null;
|
||||||
@@ -25,5 +25,5 @@ export function getPromotedServerConfig(game: string | null, map: string | null)
|
|||||||
const current = overlay.current_version_id ? getVersion(overlay.current_version_id) : undefined;
|
const current = overlay.current_version_id ? getVersion(overlay.current_version_id) : undefined;
|
||||||
return current ? [{ id: overlay.id, text: current.content.toString('utf8') }] : [];
|
return current ? [{ id: overlay.id, text: current.content.toString('utf8') }] : [];
|
||||||
});
|
});
|
||||||
return Buffer.from(composeServerCfg(version.content.toString('utf8'), overlays, game, map), 'utf8');
|
return Buffer.from(composeServerCfg(version.content.toString('utf8'), overlays, game, map, mapSlug), 'utf8');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { db } from '../db/index.js';
|
import { db } from '../db/index.js';
|
||||||
import { t5ZombieMapBySlug } from './map-aliases.js';
|
import { zombieMapBySlug } from './map-aliases.js';
|
||||||
|
|
||||||
export function validServerId(value: unknown): value is string {
|
export function validServerId(value: unknown): value is string {
|
||||||
if (typeof value !== 'string') return false;
|
if (typeof value !== 'string') return false;
|
||||||
const match = /^t5_zombie_([a-z0-9]+)_([1-9]|1[0-9]|20)$/.exec(value);
|
const match = /^(t5|t6)_zombie_([a-z0-9]+)_([1-9]|1[0-9]|20)$/.exec(value);
|
||||||
return !!match && !!t5ZombieMapBySlug(match[1]);
|
return !!match && !!zombieMapBySlug(match[1], match[2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listServerKeys() {
|
export function listServerKeys() {
|
||||||
@@ -21,7 +21,7 @@ export function getServerKey(serverId: string): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function saveServerKey(serverId: unknown, key: unknown): void {
|
export function saveServerKey(serverId: unknown, key: unknown): void {
|
||||||
if (!validServerId(serverId)) throw new Error('Choose a valid T5 zombie server ID with a lobby number from 1 to 20');
|
if (!validServerId(serverId)) throw new Error('Choose a valid T5 or T6 zombie server ID with a lobby number from 1 to 20');
|
||||||
if (typeof key !== 'string' || !key.trim() || key.trim().length > 512 || /\s/.test(key.trim())) {
|
if (typeof key !== 'string' || !key.trim() || key.trim().length > 512 || /\s/.test(key.trim())) {
|
||||||
throw new Error('Enter a server key without whitespace (maximum 512 characters)');
|
throw new Error('Enter a server key without whitespace (maximum 512 characters)');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,22 @@
|
|||||||
import { readdir, mkdir, stat } from 'node:fs/promises';
|
import { readdir, mkdir, stat } from 'node:fs/promises';
|
||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import { config, destOf } from '../config.js';
|
import { config, destOf, serverDir, rootOfServer } from '../config.js';
|
||||||
import { db } from '../db/index.js';
|
import { db } from '../db/index.js';
|
||||||
import { parseServerName } from './map-aliases.js';
|
import { parseServerName } from './map-aliases.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All server folders under SERVERS_ROOT. Used by seedServerRegistry to backfill
|
* All server folders under the game roots. Used by seedServerRegistry to backfill
|
||||||
* the servers table for any on-disk server directory that isn't yet tracked.
|
* the servers table for any on-disk server directory that isn't yet tracked.
|
||||||
*/
|
*/
|
||||||
export async function serverDirectories(): Promise<string[]> {
|
export async function serverDirectories(): Promise<string[]> {
|
||||||
let entries;
|
const names = new Set<string>();
|
||||||
try {
|
for (const root of new Set([config.serversRoot, config.t6ServersRoot])) {
|
||||||
entries = await readdir(config.serversRoot, { withFileTypes: true });
|
const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
|
||||||
} catch {
|
for (const entry of entries) {
|
||||||
return [];
|
if (entry.isDirectory() && rootOfServer(entry.name) === root) names.add(entry.name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return entries
|
return [...names].sort();
|
||||||
.filter((e) => e.isDirectory())
|
|
||||||
.map((e) => e.name)
|
|
||||||
.sort();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fleet membership is owned by the database, independent of mounted files. */
|
/** Fleet membership is owned by the database, independent of mounted files. */
|
||||||
@@ -41,7 +39,7 @@ export function matchServers(all: string[], patterns: string[]): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure a server has an sp/ destination. Creates it when create=true.
|
* Ensure a server has a script destination. Creates it when create=true.
|
||||||
*/
|
*/
|
||||||
export async function ensureDest(
|
export async function ensureDest(
|
||||||
server: string,
|
server: string,
|
||||||
@@ -57,7 +55,7 @@ export async function ensureDest(
|
|||||||
}
|
}
|
||||||
return { ok: true, path: dest };
|
return { ok: true, path: dest };
|
||||||
}
|
}
|
||||||
if (!create) return { ok: false, reason: 'no sp folder — use create=true' };
|
if (!create) return { ok: false, reason: 'no script folder — use create=true' };
|
||||||
await mkdir(dest, { recursive: true });
|
await mkdir(dest, { recursive: true });
|
||||||
return { ok: true, path: dest };
|
return { ok: true, path: dest };
|
||||||
}
|
}
|
||||||
@@ -127,7 +125,7 @@ export async function seedServerRegistry(): Promise<number> {
|
|||||||
const map = parsed?.mapCanonical ?? null;
|
const map = parsed?.mapCanonical ?? null;
|
||||||
const gameMode = parsed?.gameMode ?? null;
|
const gameMode = parsed?.gameMode ?? null;
|
||||||
const mapSlug = parsed?.mapSlug ?? null;
|
const mapSlug = parsed?.mapSlug ?? null;
|
||||||
const hostDir = `${config.serversRoot}/${name}`;
|
const hostDir = serverDir(name);
|
||||||
const res = insert.run(name, name, game, map, gameMode, mapSlug, hostDir);
|
const res = insert.run(name, name, game, map, gameMode, mapSlug, hostDir);
|
||||||
if (res.changes > 0) {
|
if (res.changes > 0) {
|
||||||
n++;
|
n++;
|
||||||
@@ -136,7 +134,7 @@ export async function seedServerRegistry(): Promise<number> {
|
|||||||
}
|
}
|
||||||
if (parsed && !parsed.mapCanonical) {
|
if (parsed && !parsed.mapCanonical) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[servers] unknown map slug '${parsed.mapSlug}' for ${name} — add to T5_ZOMBIE_MAPS`
|
`[servers] unknown map slug '${parsed.mapSlug}' for ${name} — add to the game map catalog`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,16 +6,14 @@
|
|||||||
import Input from './primitives/Input.svelte';
|
import Input from './primitives/Input.svelte';
|
||||||
import Select from './primitives/Select.svelte';
|
import Select from './primitives/Select.svelte';
|
||||||
|
|
||||||
interface T5MapEntry {
|
import type { ZombieMapEntry, ZombieGame } from '$lib/map-aliases.js';
|
||||||
friendly: string;
|
|
||||||
slug: string;
|
|
||||||
canonical: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { open = $bindable(false) }: { open?: boolean } = $props();
|
let { open = $bindable(false) }: { open?: boolean } = $props();
|
||||||
|
|
||||||
let dialog: HTMLDialogElement | undefined = $state();
|
let dialog: HTMLDialogElement | undefined = $state();
|
||||||
let maps = $state<T5MapEntry[]>([]);
|
let game = $state<ZombieGame>('t5');
|
||||||
|
let catalogs = $state<Record<ZombieGame, ZombieMapEntry[]>>({ t5: [], t6: [] });
|
||||||
|
let maps = $derived(catalogs[game]);
|
||||||
let mapsError = $state<string | null>(null);
|
let mapsError = $state<string | null>(null);
|
||||||
let mapsLoaded = $state(false);
|
let mapsLoaded = $state(false);
|
||||||
|
|
||||||
@@ -31,8 +29,9 @@
|
|||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/maps');
|
const res = await fetch('/api/maps');
|
||||||
const body = (await res.json()) as { t5Zombie: T5MapEntry[] };
|
if (!res.ok) throw new Error('Could not load map catalog');
|
||||||
maps = body.t5Zombie;
|
const body = (await res.json()) as { t5Zombie: ZombieMapEntry[]; t6Zombie: ZombieMapEntry[] };
|
||||||
|
catalogs = { t5: body.t5Zombie, t6: body.t6Zombie };
|
||||||
if (maps.length > 0) mapSlug = maps[0].slug;
|
if (maps.length > 0) mapSlug = maps[0].slug;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
mapsError = (err as Error).message;
|
mapsError = (err as Error).message;
|
||||||
@@ -78,12 +77,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
let lobby = $derived(Array.from({ length: 20 }, (_, i) => i + 1)
|
let lobby = $derived(Array.from({ length: 20 }, (_, i) => i + 1)
|
||||||
.find(n => !deployedIds.includes(`t5_${gameMode}_${mapSlug}_${n}`)) ?? null);
|
.find(n => !deployedIds.includes(`${game}_${gameMode}_${mapSlug}_${n}`)) ?? null);
|
||||||
let selectedMap = $derived(maps.find((m) => m.slug === mapSlug));
|
let selectedMap = $derived(maps.find((m) => m.slug === mapSlug));
|
||||||
let previewName = $derived(selectedMap && keysLoaded && lobby !== null ? `t5_${gameMode}_${selectedMap.slug}_${lobby}` : '');
|
let previewName = $derived(selectedMap && keysLoaded && lobby !== null ? `${game}_${gameMode}_${selectedMap.slug}_${lobby}` : '');
|
||||||
|
|
||||||
let hasSavedKey = $derived(savedIds.includes(previewName));
|
let hasSavedKey = $derived(savedIds.includes(previewName));
|
||||||
$effect(() => { mapSlug; lobby; plutoKey = ''; });
|
$effect(() => { game; mapSlug; lobby; plutoKey = ''; });
|
||||||
|
$effect(() => {
|
||||||
|
if (maps.length && !maps.some((entry) => entry.slug === mapSlug)) mapSlug = maps[0].slug;
|
||||||
|
});
|
||||||
|
|
||||||
let modeOptions = [{ value: 'zombie', label: 'Zombies' }];
|
let modeOptions = [{ value: 'zombie', label: 'Zombies' }];
|
||||||
let mapOptions = $derived(maps.map((m) => ({ value: m.slug, label: m.friendly })));
|
let mapOptions = $derived(maps.map((m) => ({ value: m.slug, label: m.friendly })));
|
||||||
@@ -99,7 +101,7 @@
|
|||||||
const res = await fetch('/api/servers', {
|
const res = await fetch('/api/servers', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ gameMode, mapSlug: selectedMap.slug, expectedName: previewName, plutoKey: plutoKey.trim() || undefined })
|
body: JSON.stringify({ game, gameMode, mapSlug: selectedMap.slug, expectedName: previewName, plutoKey: plutoKey.trim() || undefined })
|
||||||
});
|
});
|
||||||
const body = (await res.json()) as { ok: boolean; name?: string; port?: number; deploymentId?: string; error?: string; kind?: string };
|
const body = (await res.json()) as { ok: boolean; name?: string; port?: number; deploymentId?: string; error?: string; kind?: string };
|
||||||
if (!res.ok || !body.ok || !body.deploymentId) {
|
if (!res.ok || !body.ok || !body.deploymentId) {
|
||||||
@@ -131,7 +133,7 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<div class="fields">
|
<div class="fields">
|
||||||
<Field label="Game">
|
<Field label="Game">
|
||||||
<div class="readonly">Black Ops 1 (t5)</div>
|
<Select bind:value={game} options={[{ value: 't5', label: 'Black Ops 1 (T5)' }, { value: 't6', label: 'Black Ops II (T6)' }]} disabled={deploying} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label="Mode">
|
<Field label="Mode">
|
||||||
@@ -243,16 +245,6 @@
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.readonly {
|
|
||||||
padding: 9px 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: var(--text-sm);
|
|
||||||
background: var(--surface-sunken);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.preview {
|
.preview {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { parseServerName, T5_ZOMBIE_MAPS } from '../map-aliases.js';
|
import { parseServerName, T5_ZOMBIE_MAPS, T6_ZOMBIE_MAPS, zombieMaps } from '../map-aliases.js';
|
||||||
|
|
||||||
export function mapTitle(map: string | null | undefined): string {
|
export function mapTitle(map: string | null | undefined): string {
|
||||||
return T5_ZOMBIE_MAPS.find((entry) => entry.canonical === map?.toLowerCase())?.friendly ?? map ?? '—';
|
return [...T5_ZOMBIE_MAPS, ...T6_ZOMBIE_MAPS].find((entry) => entry.canonical === map?.toLowerCase())?.friendly ?? map ?? '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function serverTitle(name: string): string {
|
export function serverTitle(name: string): string {
|
||||||
const parsed = parseServerName(name);
|
const parsed = parseServerName(name);
|
||||||
if (!parsed || parsed.game.toLowerCase() !== 't5' ||
|
if (!parsed || !['t5', 't6'].includes(parsed.game.toLowerCase()) ||
|
||||||
(parsed.gameMode !== null && parsed.gameMode !== 'zombie') || !parsed.mapCanonical) {
|
(parsed.gameMode !== null && parsed.gameMode !== 'zombie') || !parsed.mapCanonical) {
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
const title = mapTitle(parsed.mapCanonical);
|
const title = zombieMaps(parsed.game).find((entry) => entry.slug === parsed.mapSlug)?.friendly ?? mapTitle(parsed.mapCanonical);
|
||||||
return parsed.lobby === null ? title : `${title} · Lobby ${parsed.lobby}`;
|
return parsed.lobby === null ? title : `${title} · Lobby ${parsed.lobby}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { T5_ZOMBIE_MAPS } from '$lib/server/fleet/map-aliases.js';
|
import { T5_ZOMBIE_MAPS, T6_ZOMBIE_MAPS } from '$lib/server/fleet/map-aliases.js';
|
||||||
|
|
||||||
export function GET() {
|
export function GET() {
|
||||||
return json({ t5Zombie: T5_ZOMBIE_MAPS });
|
return json({ t5Zombie: T5_ZOMBIE_MAPS, t6Zombie: T6_ZOMBIE_MAPS });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { classifyHealth } from '$lib/server/fleet/health.js';
|
|||||||
import { queueCreateServer } from '$lib/server/fleet/create.js';
|
import { queueCreateServer } from '$lib/server/fleet/create.js';
|
||||||
import { config, komodoConfigured, rconConfigured } from '$lib/server/config.js';
|
import { config, komodoConfigured, rconConfigured } from '$lib/server/config.js';
|
||||||
import type { RconPlayer } from '$lib/server/integrations/rcon.js';
|
import type { RconPlayer } from '$lib/server/integrations/rcon.js';
|
||||||
import type { GameMode } from '$lib/server/fleet/map-aliases.js';
|
import type { GameMode, ZombieGame } from '$lib/server/fleet/map-aliases.js';
|
||||||
|
|
||||||
function safeJson<T>(raw: string | null): T | null {
|
function safeJson<T>(raw: string | null): T | null {
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
@@ -52,18 +52,20 @@ export async function GET() {
|
|||||||
|
|
||||||
export async function POST({ request }: { request: Request }) {
|
export async function POST({ request }: { request: Request }) {
|
||||||
const body = (await request.json().catch(() => ({}))) as {
|
const body = (await request.json().catch(() => ({}))) as {
|
||||||
|
game?: string;
|
||||||
gameMode?: string;
|
gameMode?: string;
|
||||||
mapSlug?: string;
|
mapSlug?: string;
|
||||||
plutoKey?: string;
|
plutoKey?: string;
|
||||||
expectedName?: string;
|
expectedName?: string;
|
||||||
};
|
};
|
||||||
if (!body || typeof body.gameMode !== 'string' || typeof body.mapSlug !== 'string' || (body.plutoKey !== undefined && typeof body.plutoKey !== 'string') || (body.expectedName !== undefined && typeof body.expectedName !== 'string')) {
|
if (!body || (body.game !== undefined && body.game !== 't5' && body.game !== 't6') || typeof body.gameMode !== 'string' || typeof body.mapSlug !== 'string' || (body.plutoKey !== undefined && typeof body.plutoKey !== 'string') || (body.expectedName !== undefined && typeof body.expectedName !== 'string')) {
|
||||||
return json(
|
return json(
|
||||||
{ ok: false, error: 'gameMode and mapSlug are required strings; plutoKey and expectedName are optional strings', kind: 'bad-request' },
|
{ ok: false, error: 'game must be t5 or t6; gameMode and mapSlug are required strings; plutoKey and expectedName are optional strings', kind: 'bad-request' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const result = queueCreateServer({
|
const result = queueCreateServer({
|
||||||
|
game: body.game as ZombieGame | undefined,
|
||||||
gameMode: body.gameMode as GameMode,
|
gameMode: body.gameMode as GameMode,
|
||||||
mapSlug: body.mapSlug,
|
mapSlug: body.mapSlug,
|
||||||
plutoKey: body.plutoKey,
|
plutoKey: body.plutoKey,
|
||||||
|
|||||||
@@ -196,15 +196,15 @@
|
|||||||
<h2 class="panel-title">Upload a baseline <span class="dim">(one per game)</span></h2>
|
<h2 class="panel-title">Upload a baseline <span class="dim">(one per game)</span></h2>
|
||||||
<form onsubmit={createBaseline} class="upload-form">
|
<form onsubmit={createBaseline} class="upload-form">
|
||||||
<Field label="Name">
|
<Field label="Name">
|
||||||
<Input bind:value={baselineName} placeholder="t5 baseline" disabled={uploading} />
|
<Input bind:value={baselineName} placeholder="Game baseline" disabled={uploading} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Game">
|
<Field label="Game">
|
||||||
<Select bind:value={baselineScopeGame} options={gameOptions} disabled={uploading} />
|
<Select bind:value={baselineScopeGame} options={gameOptions} disabled={uploading} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Notes" hint="Optional. Describe what this baseline covers.">
|
<Field label="Notes" hint="Optional. Describe what this baseline covers.">
|
||||||
<Input bind:value={baselineNotes} placeholder="dedicated_sp.cfg baseline" disabled={uploading} />
|
<Input bind:value={baselineNotes} placeholder="Server config baseline" disabled={uploading} />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Cfg file (dedicated_sp.cfg)">
|
<Field label="Server cfg file">
|
||||||
<Input type="file" accept=".cfg,text/plain" onchange={pickBaselineFile} disabled={uploading} />
|
<Input type="file" accept=".cfg,text/plain" onchange={pickBaselineFile} disabled={uploading} />
|
||||||
</Field>
|
</Field>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
<p>Save a Plutonium key for each server ID, even when the server is not deployed. Destroying a server keeps its saved key.</p>
|
<p>Save a Plutonium key for each server ID, even when the server is not deployed. Destroying a server keeps its saved key.</p>
|
||||||
<form onsubmit={save}>
|
<form onsubmit={save}>
|
||||||
<h2>{editing ? 'Update key' : 'Add key'}</h2>
|
<h2>{editing ? 'Update key' : 'Add key'}</h2>
|
||||||
<Field label="Server ID" hint="Use a map slug and lobby number from 1 to 20, e.g. t5_zombie_theater_2.">
|
<Field label="Server ID" hint="Use a T5 or T6 map slug and lobby number from 1 to 20, e.g. t5_zombie_theater_2 or t6_zombie_tomb_1.">
|
||||||
<Input bind:value={serverId} placeholder="t5_zombie_theater_2" required readonly={editing} disabled={busy} mono />
|
<Input bind:value={serverId} placeholder="t5_zombie_theater_2" required readonly={editing} disabled={busy} mono />
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Plutonium server key" hint="Changes apply the next time this server ID is deployed.">
|
<Field label="Plutonium server key" hint="Changes apply the next time this server ID is deployed.">
|
||||||
|
|||||||
@@ -296,7 +296,7 @@
|
|||||||
server={server.name}
|
server={server.name}
|
||||||
tabs={[
|
tabs={[
|
||||||
{ key: 'console', label: 'console.log' },
|
{ key: 'console', label: 'console.log' },
|
||||||
{ key: 'games_sp', label: 'games_sp.log' }
|
server.game === 't6' ? { key: 'games_zm', label: 'games_zm.log' } : { key: 'games_sp', label: 'games_sp.log' }
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { join } from 'node:path';
|
|||||||
export interface Fixture {
|
export interface Fixture {
|
||||||
root: string;
|
root: string;
|
||||||
t5: string;
|
t5: string;
|
||||||
|
t6: string;
|
||||||
betaArchive: string;
|
betaArchive: string;
|
||||||
dbPath: string;
|
dbPath: string;
|
||||||
cleanup: () => Promise<void>;
|
cleanup: () => Promise<void>;
|
||||||
@@ -18,16 +19,20 @@ export interface Fixture {
|
|||||||
export async function newFixture(servers: string[] = ['t5_zombie_theater_1']): Promise<Fixture> {
|
export async function newFixture(servers: string[] = ['t5_zombie_theater_1']): Promise<Fixture> {
|
||||||
const root = await mkdtemp(join(tmpdir(), 'cherry-'));
|
const root = await mkdtemp(join(tmpdir(), 'cherry-'));
|
||||||
const t5 = join(root, 't5');
|
const t5 = join(root, 't5');
|
||||||
|
const t6 = join(root, 't6');
|
||||||
const betaArchive = join(root, 'beta-archive');
|
const betaArchive = join(root, 'beta-archive');
|
||||||
const dbPath = join(root, 'cherry.db');
|
const dbPath = join(root, 'cherry.db');
|
||||||
await mkdir(t5, { recursive: true });
|
await mkdir(t5, { recursive: true });
|
||||||
|
await mkdir(t6, { recursive: true });
|
||||||
await mkdir(betaArchive, { recursive: true });
|
await mkdir(betaArchive, { recursive: true });
|
||||||
|
|
||||||
for (const s of servers) {
|
for (const s of servers) {
|
||||||
await mkdir(join(t5, s, 'runtime/plutonium/storage/t5/raw/scripts/sp'), { recursive: true });
|
await mkdir(s.startsWith('t6_') ? join(t6, s, 'runtime/plutonium/storage/t6/scripts/zm')
|
||||||
|
: join(t5, s, 'runtime/plutonium/storage/t5/raw/scripts/sp'), { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
process.env.SERVERS_ROOT = t5;
|
process.env.SERVERS_ROOT = t5;
|
||||||
|
process.env.T6_SERVERS_ROOT = t6;
|
||||||
process.env.REL_DEST = 'runtime/plutonium/storage/t5/raw/scripts/sp';
|
process.env.REL_DEST = 'runtime/plutonium/storage/t5/raw/scripts/sp';
|
||||||
process.env.BETA_ARCHIVE_DIR = betaArchive;
|
process.env.BETA_ARCHIVE_DIR = betaArchive;
|
||||||
process.env.DB_PATH = dbPath;
|
process.env.DB_PATH = dbPath;
|
||||||
@@ -37,6 +42,7 @@ export async function newFixture(servers: string[] = ['t5_zombie_theater_1']): P
|
|||||||
return {
|
return {
|
||||||
root,
|
root,
|
||||||
t5,
|
t5,
|
||||||
|
t6,
|
||||||
betaArchive,
|
betaArchive,
|
||||||
dbPath,
|
dbPath,
|
||||||
cleanup: async () => {
|
cleanup: async () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { existsSync } from 'node:fs';
|
|||||||
import { readFile, rm } from 'node:fs/promises';
|
import { readFile, rm } from 'node:fs/promises';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
import { T6_ZOMBIE_MAPS } from '../../src/lib/map-aliases.js';
|
||||||
|
|
||||||
vi.mock('../../src/lib/server/integrations/komodo.js', () => ({
|
vi.mock('../../src/lib/server/integrations/komodo.js', () => ({
|
||||||
komodoCreateDeployment: vi.fn(async () => ({ ok: true })),
|
komodoCreateDeployment: vi.fn(async () => ({ ok: true })),
|
||||||
@@ -57,6 +58,120 @@ async function promotedConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe('T6 Zombies provisioning', () => {
|
||||||
|
it('keeps T6 storage and config overrides separate from T5 settings', async () => {
|
||||||
|
vi.stubEnv('T6_SERVERS_ROOT', join(fixture.root, 'custom-t6'));
|
||||||
|
vi.stubEnv('T6_CONFIG_TARGET_FILE', 'custom_zm.cfg');
|
||||||
|
vi.stubEnv('T6_REL_DEST_SCRIPT', 'custom/scripts/zm');
|
||||||
|
vi.stubEnv('PLUTAINER_T6_GAMEFILES_VOLUME', 'bo2-files');
|
||||||
|
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const baseline = createScript({ name: 'T6 baseline', kind: 'baseline', scopeType: 'game', scopeValue: 't6', content: Buffer.from('sv_voice 1\n') });
|
||||||
|
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(baseline.version.id, baseline.script.id);
|
||||||
|
const { createServer } = await import('../../src/lib/server/fleet/create.js');
|
||||||
|
const { komodoCreateDeployment } = await import('../../src/lib/server/integrations/komodo.js');
|
||||||
|
const { destOf, configFileOf } = await import('../../src/lib/server/config.js');
|
||||||
|
const created = await createServer({ ...input, game: 't6', mapSlug: 'town' });
|
||||||
|
if (!created.ok) throw new Error(created.message);
|
||||||
|
const spec = vi.mocked(komodoCreateDeployment).mock.calls[0][0];
|
||||||
|
expect(spec.volumes).toContain('bo2-files:/home/plutainer/gamefiles:ro');
|
||||||
|
expect(spec.environment).toContainEqual({ variable: 'PLUTAINER_CONFIG_FILE', value: 'custom_zm.cfg' });
|
||||||
|
expect(existsSync(join(fixture.root, 'custom-t6', created.name, 'configs/custom_zm.cfg'))).toBe(true);
|
||||||
|
expect(destOf(created.name)).toBe(`${join(fixture.root, 'custom-t6')}/${created.name}/custom/scripts/zm`);
|
||||||
|
expect(configFileOf(serverName)).toBe('dedicated_sp.cfg');
|
||||||
|
expect(destOf(serverName)).toBe(`${fixture.t5}/${serverName}/runtime/plutonium/storage/t5/raw/scripts/sp`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(T6_ZOMBIE_MAPS)('provisions $friendly with the T6 mount, config, and ruleset', async (map) => {
|
||||||
|
const { createServer } = await import('../../src/lib/server/fleet/create.js');
|
||||||
|
const { getServer } = await import('../../src/lib/server/fleet/servers.js');
|
||||||
|
const { komodoCreateDeployment } = await import('../../src/lib/server/integrations/komodo.js');
|
||||||
|
const name = `t6_zombie_${map.slug}_1`;
|
||||||
|
expect(await createServer({ ...input, game: 't6', mapSlug: map.slug })).toMatchObject({ ok: true, name });
|
||||||
|
const spec = vi.mocked(komodoCreateDeployment).mock.calls[0][0];
|
||||||
|
const env = Object.fromEntries(spec.environment.map(({ variable, value }) => [variable, value]));
|
||||||
|
expect(env).toMatchObject({ PLUTAINER_GAME: 't6zm', PLUTAINER_CONFIG_FILE: 'dedicated_zm.cfg',
|
||||||
|
PLUTAINER_RCON_PASSWORD: 'test-rcon', PLUTAINER_MAP_ROTATE: 'false',
|
||||||
|
PLUTAINER_EXTRA_ARGS: `+exec electric_cherry_map.cfg +set tws_server_id ${name} +map_rotate` });
|
||||||
|
expect(env.PLUTAINER_SKIP_SEED).toBeUndefined();
|
||||||
|
expect(spec.volumes).toEqual([`${fixture.t6}/${name}:/home/plutainer/app`, 't6_game_files:/home/plutainer/gamefiles:ro']);
|
||||||
|
expect(getServer(name)).toMatchObject({ game: 't6', game_id: 't6zm', map: map.canonical,
|
||||||
|
map_slug: map.slug, config_file: 'dedicated_zm.cfg' });
|
||||||
|
expect(await readFile(join(fixture.t6, name, 'configs/electric_cherry_map.cfg'), 'utf8'))
|
||||||
|
.toContain(`set sv_maprotation "execgts ${map.gameSettings} map ${map.canonical}"`);
|
||||||
|
expect(existsSync(join(fixture.t5, name))).toBe(false);
|
||||||
|
expect(existsSync(join(fixture.t6, name, 'configs/dedicated_zm.cfg'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists T6 queued work, reuses its key, and allocates ports across both games', async () => {
|
||||||
|
const { queueCreateServer, processCreations } = await import('../../src/lib/server/fleet/create.js');
|
||||||
|
const { saveServerKey, validServerId } = await import('../../src/lib/server/fleet/server-keys.js');
|
||||||
|
const { closeDb } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const { komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js');
|
||||||
|
const { rconStatusResult } = await import('../../src/lib/server/integrations/rcon.js');
|
||||||
|
const { getDeployment } = await import('../../src/lib/server/fleet/deployments.js');
|
||||||
|
saveServerKey('t6_zombie_town_1', 't6-key');
|
||||||
|
expect(validServerId('t6_zombie_town_20')).toBe(true);
|
||||||
|
expect(validServerId('t5_zombie_town_1')).toBe(false);
|
||||||
|
expect(validServerId('t6_zombie_theater_1')).toBe(false);
|
||||||
|
const t5 = queueCreateServer(input);
|
||||||
|
const t6 = queueCreateServer({ game: 't6', gameMode: 'zombie', mapSlug: 'town' });
|
||||||
|
if (!t5.ok || !t6.ok) throw new Error('queue failed');
|
||||||
|
expect(t6.port).not.toBe(t5.port);
|
||||||
|
closeDb();
|
||||||
|
vi.mocked(komodoInspectHealthy).mockResolvedValue({ ok: true, state: 'running' });
|
||||||
|
vi.mocked(rconStatusResult).mockImplementation(async (server) => ({ ok: true, endpoint: { host: 'localhost', port: 28960 }, rawReply: '',
|
||||||
|
status: { players: 0, map: server.startsWith('t6_') ? 'zm_transit' : 'zombie_cosmodrome', gametype: null, roster: [] } }));
|
||||||
|
await processCreations();
|
||||||
|
expect(getDeployment(t6.deploymentId)?.deployment).toMatchObject({ state: 'done' });
|
||||||
|
const content = await readFile(join(fixture.t6, t6.name, 'configs/electric_cherry_map.cfg'), 'utf8');
|
||||||
|
expect(content).toContain('execgts zm_standard_town.cfg map zm_transit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('installs only matching promoted artifacts into T6 paths and removes only its directory on destruction', async () => {
|
||||||
|
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
for (const game of ['t5', 't6']) {
|
||||||
|
for (const kind of ['script', 'baseline'] as const) {
|
||||||
|
const { script, version } = createScript({ name: `${game}-${kind}`, kind, scopeType: 'game', scopeValue: game,
|
||||||
|
targetRelPath: kind === 'script' ? `${game}.gsc` : undefined,
|
||||||
|
content: Buffer.from(kind === 'script' ? 'main(){}' : 'sv_voice 1\nsv_maprotation "map wrong"\n') });
|
||||||
|
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(version.id, script.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { createServer, destroyServer, processDestructions } = await import('../../src/lib/server/fleet/create.js');
|
||||||
|
const { getServerKey } = await import('../../src/lib/server/fleet/server-keys.js');
|
||||||
|
const { scanServerDrift } = await import('../../src/lib/server/fleet/drift.js');
|
||||||
|
await createServer(input);
|
||||||
|
const t6 = await createServer({ ...input, game: 't6', mapSlug: 'tomb' });
|
||||||
|
if (!t6.ok) throw new Error(t6.message);
|
||||||
|
expect(existsSync(join(fixture.t6, t6.name, 'runtime/plutonium/storage/t6/scripts/zm/t6.gsc'))).toBe(true);
|
||||||
|
expect(existsSync(join(fixture.t6, t6.name, 'runtime/plutonium/storage/t6/scripts/zm/t5.gsc'))).toBe(false);
|
||||||
|
expect(await readFile(join(fixture.t6, t6.name, 'configs/dedicated_zm.cfg'), 'utf8'))
|
||||||
|
.toContain('execgts zm_classic_tomb.cfg map zm_tomb');
|
||||||
|
expect(await scanServerDrift(t6.name)).toEqual([]);
|
||||||
|
expect(destroyServer(t6.name).ok).toBe(true);
|
||||||
|
const { komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js');
|
||||||
|
vi.mocked(komodoInspectHealthy).mockResolvedValueOnce({ ok: false, reason: 'gone' });
|
||||||
|
await processDestructions();
|
||||||
|
expect(existsSync(join(fixture.t6, t6.name))).toBe(false);
|
||||||
|
expect(existsSync(join(fixture.t5, serverName))).toBe(true);
|
||||||
|
expect(getServerKey(t6.name)).toBe('test-key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates game/map combinations at the API boundary', async () => {
|
||||||
|
const { POST } = await import('../../src/routes/api/servers/+server.js');
|
||||||
|
for (const game of ['t7', null, 6]) {
|
||||||
|
const res = await POST({ request: new Request('http://localhost/api/servers', {
|
||||||
|
method: 'POST', body: JSON.stringify({ ...input, game })
|
||||||
|
}) });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
}
|
||||||
|
const { queueCreateServer } = await import('../../src/lib/server/fleet/create.js');
|
||||||
|
expect(queueCreateServer({ ...input, game: 't6' })).toMatchObject({ ok: false, kind: 'unknown-map' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('server creation map selection', () => {
|
describe('server creation map selection', () => {
|
||||||
it('returns a queued creation before provisioning and reserves its name and port', async () => {
|
it('returns a queued creation before provisioning and reserves its name and port', async () => {
|
||||||
const { POST } = await import('../../src/routes/api/servers/+server.js');
|
const { POST } = await import('../../src/routes/api/servers/+server.js');
|
||||||
|
|||||||
@@ -2,8 +2,24 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
parseServerName,
|
parseServerName,
|
||||||
t5ZombieMapBySlug,
|
t5ZombieMapBySlug,
|
||||||
T5_ZOMBIE_MAPS
|
T5_ZOMBIE_MAPS,
|
||||||
|
T6_ZOMBIE_MAPS
|
||||||
} from '../../src/lib/server/fleet/map-aliases.js';
|
} from '../../src/lib/server/fleet/map-aliases.js';
|
||||||
|
import { serverTitle } from '../../src/lib/ui/server-labels.js';
|
||||||
|
|
||||||
|
describe('T6 map identity', () => {
|
||||||
|
it.each(T6_ZOMBIE_MAPS)('parses and labels $friendly without losing its variant', (map) => {
|
||||||
|
const name = `t6_zombie_${map.slug}_2`;
|
||||||
|
expect(parseServerName(name)).toMatchObject({ game: 't6', gameMode: 'zombie', mapCanonical: map.canonical, mapSlug: map.slug, lobby: 2 });
|
||||||
|
expect(serverTitle(name)).toBe(`${map.friendly} · Lobby 2`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not interpret another game or mode through the T5 catalog', () => {
|
||||||
|
for (const name of ['t6_zombie_theater_1', 't5_zombie_tomb_1', 't5_mp_theater_1', 'iw5_theater_1']) {
|
||||||
|
expect(parseServerName(name)?.mapCanonical).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('parseServerName (new naming: t5_<mode>_<slug>_<n>)', () => {
|
describe('parseServerName (new naming: t5_<mode>_<slug>_<n>)', () => {
|
||||||
it('extracts game, mode, slug, canonical, and lobby from a T5 zombie name', () => {
|
it('extracts game, mode, slug, canonical, and lobby from a T5 zombie name', () => {
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
|
||||||
|
let fixture: Fixture;
|
||||||
|
const town = 't6_zombie_town_1';
|
||||||
|
const farm = 't6_zombie_farm_1';
|
||||||
|
const t5 = 't5_zombie_theater_1';
|
||||||
|
const cfgPath = (server: string) => join(fixture.t6, server, 'configs/dedicated_zm.cfg');
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
fixture = await newFixture([town, farm, t5]);
|
||||||
|
vi.stubEnv('KOMODO_ADDRESS', '');
|
||||||
|
vi.stubEnv('RCON_PASSWORD', '');
|
||||||
|
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||||
|
await seedServerRegistry();
|
||||||
|
for (const name of [town, farm]) await mkdir(join(fixture.t6, name, 'configs'), { recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await fixture.cleanup();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('T6 config lifecycle in a mixed fleet', () => {
|
||||||
|
it('preserves variants through beta, scope promotion, drift repair, and campaign rollback', async () => {
|
||||||
|
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
|
||||||
|
const { openBeta, promoteBeta } = await import('../../src/lib/server/fleet/beta.js');
|
||||||
|
const { scanServerDrift, runDriftDeployment } = await import('../../src/lib/server/fleet/drift.js');
|
||||||
|
const { rollbackPromoteDeployment } = await import('../../src/lib/server/fleet/rollback.js');
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const previous = '// prior operator settings\r\nsv_voice 0\r\n';
|
||||||
|
await writeFile(cfgPath(town), previous);
|
||||||
|
const { script, version } = createScript({ name: 'T6 baseline', kind: 'baseline', scopeType: 'game', scopeValue: 't6',
|
||||||
|
content: Buffer.from('sv_voice 1\nsv_maprotation "map zm_buried"\n') });
|
||||||
|
expect(script.target_rel_path).toBe('dedicated_zm.cfg');
|
||||||
|
const { betaId } = await openBeta({ scripts: [{ scriptId: script.id, scriptVersionId: version.id }],
|
||||||
|
servers: [town], waitForHealth: false, waitForMatch: false });
|
||||||
|
expect(await readFile(cfgPath(town), 'utf8')).toContain('execgts zm_standard_town.cfg map zm_transit');
|
||||||
|
db().prepare("UPDATE beta_servers SET stage = 'ready', restarted_at = ? WHERE beta_id = ?").run(new Date().toISOString(), betaId);
|
||||||
|
db().prepare("UPDATE betas SET state = 'ready-promote' WHERE id = ?").run(betaId);
|
||||||
|
const promoted = await promoteBeta(betaId);
|
||||||
|
expect(new Set(promoted.landed)).toEqual(new Set([town, farm]));
|
||||||
|
const farmConfig = await readFile(cfgPath(farm), 'utf8');
|
||||||
|
expect(farmConfig).toContain('execgts zm_standard_farm.cfg map zm_transit');
|
||||||
|
expect(existsSync(join(fixture.t5, t5, 'configs/dedicated_sp.cfg'))).toBe(false);
|
||||||
|
expect(existsSync(join(fixture.t6, farm, 'configs/dedicated_sp.cfg'))).toBe(false);
|
||||||
|
expect(await scanServerDrift(farm)).toEqual([]);
|
||||||
|
await rm(cfgPath(farm));
|
||||||
|
expect((await runDriftDeployment({ servers: [farm] })).totalRestored).toBe(1);
|
||||||
|
expect(await readFile(cfgPath(farm), 'utf8')).toBe(farmConfig);
|
||||||
|
await rollbackPromoteDeployment(promoted.deploymentId);
|
||||||
|
expect(await readFile(cfgPath(town), 'utf8')).toBe(previous);
|
||||||
|
expect(existsSync(cfgPath(farm))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores the exact pre-beta T6 config and preserves seeded rulesets during drift repair', async () => {
|
||||||
|
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
|
||||||
|
const { openBeta, rollbackBeta } = await import('../../src/lib/server/fleet/beta.js');
|
||||||
|
const { scanServerDrift, runDriftDeployment } = await import('../../src/lib/server/fleet/drift.js');
|
||||||
|
const previous = 'sv_voice 1\n';
|
||||||
|
await writeFile(cfgPath(town), previous);
|
||||||
|
const settings = join(fixture.t6, town, 'runtime/plutonium/storage/t6/gamesettings');
|
||||||
|
await mkdir(settings, { recursive: true });
|
||||||
|
await writeFile(join(settings, 'zm_standard_town.cfg'), 'ui_zm_mapstartlocation town\n');
|
||||||
|
await writeFile(join(fixture.t6, town, 'configs/dedicated.cfg'), 'sv_voice 1\n');
|
||||||
|
const { script, version } = createScript({ name: 'T6 baseline', kind: 'baseline', scopeType: 'game', scopeValue: 't6',
|
||||||
|
content: Buffer.from('sv_voice 0\n') });
|
||||||
|
const { betaId } = await openBeta({ scripts: [{ scriptId: script.id, scriptVersionId: version.id }],
|
||||||
|
servers: [town], waitForHealth: false, waitForMatch: false });
|
||||||
|
await rollbackBeta(betaId);
|
||||||
|
expect(await readFile(cfgPath(town), 'utf8')).toBe(previous);
|
||||||
|
expect(await scanServerDrift(town)).toEqual([]);
|
||||||
|
await runDriftDeployment({ servers: [town] });
|
||||||
|
expect(await readFile(join(settings, 'zm_standard_town.cfg'), 'utf8')).toBe('ui_zm_mapstartlocation town\n');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads the T6 game log and keeps log path traversal checks', async () => {
|
||||||
|
const { resolveLogPath, gameLogKey } = await import('../../src/lib/server/fleet/logs.js');
|
||||||
|
const { scanMatchState } = await import('../../src/lib/server/fleet/match-log.js');
|
||||||
|
const logs = join(fixture.t6, town, 'logs');
|
||||||
|
await mkdir(logs, { recursive: true });
|
||||||
|
await writeFile(join(logs, 'games_zm.log'), '0:00 InitGame:\n0:01 J;123;0;Player\n');
|
||||||
|
expect(resolveLogPath(town, gameLogKey(town))).toBe(resolve(logs, 'games_zm.log'));
|
||||||
|
expect(await scanMatchState(town)).toMatchObject({ kind: 'in-progress' });
|
||||||
|
expect(resolveLogPath('t6_/../../escape', 'games_zm')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user