{/if}
diff --git a/src/routes/(app)/server/[id]/players/[steamId]/+page.svelte b/src/routes/(app)/server/[id]/players/[steamId]/+page.svelte
index e5fa694c..887d262b 100644
--- a/src/routes/(app)/server/[id]/players/[steamId]/+page.svelte
+++ b/src/routes/(app)/server/[id]/players/[steamId]/+page.svelte
@@ -2,7 +2,8 @@
import { invalidateAll } from '$app/navigation';
import { api, errorMessage, rconPost } from '$lib/api';
import { MAX_CHAT } from '$lib/chat';
- import { fmtNum, fmtTime } from '$lib/format';
+ import { fmtCash } from '$lib/cash';
+ import { fmtDuration, fmtNum, fmtTime } from '$lib/format';
import { causeLabel } from '$lib/causes';
import { can } from '$lib/capabilities';
import { toast } from '$lib/toast.svelte';
@@ -196,8 +197,8 @@
-
- {#each [['Sessions', fmtNum(d.summary.sessions), 'A session is one stay on a server, from joining to leaving.'], ['Playtime', d.summary.sessions ? minutes(d.summary.minutes) : '—', ''], ['Kills', fmtNum(d.summary.kills), SCOREBOARD_NOTE], ['Deaths', fmtNum(d.summary.deaths), SCOREBOARD_NOTE], ['K/D', kd(d.summary.kills, d.summary.deaths), SCOREBOARD_NOTE], ['First seen', d.summary.firstSeen ? fmtTime(d.summary.firstSeen) : '—', '']] as [label, value, note] (label)}
+
+ {#each [['Sessions', fmtNum(d.summary.sessions), 'A session is one stay on a server, from joining to leaving.'], ['Playtime', d.summary.sessions ? minutes(d.summary.minutes) : '—', ''], ['Seed progress', d.summary.seedReward ? `${fmtNum(d.summary.seedReward.minutes)} / ${fmtNum(d.summary.seedReward.requiredMinutes)} min` : '—', 'Unspent seeding time toward this server’s next reward. It resets when the reward is granted.'], ['Cash earned', fmtCash(d.summary.cash), 'Total cash earned across the servers visible to you.'], ['Kills', fmtNum(d.summary.kills), SCOREBOARD_NOTE], ['Deaths', fmtNum(d.summary.deaths), SCOREBOARD_NOTE], ['Longest alive', fmtDuration(d.summary.longestAliveSeconds), 'Longest recorded interval between joining, a death, a match start, and departure. Available for sessions tracked by the event feed.'], ['K/D', kd(d.summary.kills, d.summary.deaths), SCOREBOARD_NOTE], ['First seen', d.summary.firstSeen ? fmtTime(d.summary.firstSeen) : '—', '']] as [label, value, note] (label)}
{label}
{value}
diff --git a/src/test/view.test.ts b/src/test/view.test.ts
index 5d573c0e..31cc06de 100644
--- a/src/test/view.test.ts
+++ b/src/test/view.test.ts
@@ -6,15 +6,18 @@ import { join } from 'node:path';
import type { Env } from '$lib/server/env';
import { listOf } from '$lib/server/lists';
import {
+ kills,
listEntries,
matches,
matchPlayers,
playerMarks,
playerNotes,
playerSessions,
- serverBans
+ serverBans,
+ triggers
} from '$lib/server/db/schema';
import { newId } from '$lib/server/http';
+import { validateConfig } from '$lib/server/trigger-rules';
import { hasTestDb, testEnv } from './db';
import { callApi, callLoad, stubGateway } from './call';
import { seedWorld, type PrincipalName, type World } from './world';
@@ -207,9 +210,41 @@ describe.skipIf(!hasTestDb)('what View shows', () => {
cash: 90_000
}
]);
+ await env.db.insert(kills).values({
+ ts: new Date(joinedAt.getTime() + 45_000),
+ serverId: w.server.id,
+ eventId: newId(),
+ instanceId: 'test-instance',
+ matchId: 'test-match',
+ eventTime: 45,
+ map: 'Test',
+ killerSteamId: '76561198000000043',
+ killerName: 'killer',
+ victimSteamId: PLAYER,
+ victimName: 'someone',
+ cause: 'Id.Item.AK74M',
+ distanceM: 10,
+ tags: []
+ });
+ await env.db.insert(triggers).values({
+ id: newId(),
+ serverId: w.server.id,
+ orgId: w.org.id,
+ kind: 'seed_reward',
+ name: 'Seed reward',
+ enabled: true,
+ config: validateConfig('seed_reward', { minutes: 60 }),
+ state: {
+ players: {
+ [PLAYER]: { observed: 2_100, balance: 2_100, seenAt: Date.now() }
+ }
+ }
+ });
const viewer = (await get('viewer', 'api/servers/[id]/players/[steamId]')).dossier;
expect(viewer.summary.cash).toBe(1_234);
+ expect(viewer.summary.longestAliveSeconds).toBe(45);
+ expect(viewer.summary.seedReward).toEqual({ minutes: 35, requiredMinutes: 60 });
expect(
viewer.perServer.map((s: { serverId: string; cash: number }) => [s.serverId, s.cash])
).toEqual([[w.server.id, 1_234]]);
From 10a9f0bbce527e30e819b2efbedbcd5f43ea6b39 Mon Sep 17 00:00:00 2001
From: Seebomega
Date: Fri, 25 Sep 2026 11:27:15 +0200
Subject: [PATCH 003/102] add seed avoid collision with paid reserved slot
---
src/lib/server/feed.ts | 54 ++++-
src/lib/server/lists.ts | 64 +++++-
src/lib/server/outbox.ts | 13 +-
src/lib/server/seed-progress.ts | 41 ++++
src/lib/server/trigger-rules.ts | 15 ++
src/lib/server/triggers.test.ts | 12 +
src/lib/server/triggers.ts | 36 ++-
src/lib/types.ts | 14 ++
src/routes/(app)/server/[id]/+layout.svelte | 1 +
.../(app)/server/[id]/automation/+page.svelte | 26 ++-
.../(app)/server/[id]/events/+page.svelte | 173 +++++++++++++++
src/routes/api/servers/[id]/events/+server.ts | 31 +++
src/test/api-matrix.test.ts | 1 +
src/test/feed-ingest.test.ts | 26 ++-
src/test/seed-reward.test.ts | 207 ++++++++++++++++++
15 files changed, 693 insertions(+), 21 deletions(-)
create mode 100644 src/lib/server/seed-progress.ts
create mode 100644 src/routes/(app)/server/[id]/events/+page.svelte
create mode 100644 src/routes/api/servers/[id]/events/+server.ts
create mode 100644 src/test/seed-reward.test.ts
diff --git a/src/lib/server/feed.ts b/src/lib/server/feed.ts
index aac9025d..71d9474f 100644
--- a/src/lib/server/feed.ts
+++ b/src/lib/server/feed.ts
@@ -32,7 +32,7 @@ import {
type KillRow,
type ServerRow
} from './db/schema';
-import type { KillView } from '$lib/types';
+import type { FeedEventView, KillView } from '$lib/types';
import { VEHICLE_TAGS, type KillFilter } from '$lib/kills';
import type { SessionUser } from './access';
import { FEED_TOKEN_PREFIX, isTeamKill, parseBatch, type ParsedEvent } from './feed-core';
@@ -172,6 +172,20 @@ export function killView(r: KillRow): KillView {
};
}
+export function feedEventView(r: KillRow): FeedEventView {
+ return {
+ eventId: r.eventId,
+ ts: r.ts.toISOString(),
+ eventType: r.eventType,
+ parsedKill: r.parsedKill,
+ instanceId: r.instanceId,
+ matchId: r.matchId,
+ eventTime: r.eventTime,
+ map: r.map,
+ rawEvent: r.rawEvent
+ };
+}
+
/**
* Writes one batch: parse, drop what is already stored, add the open match and both factions
* from the sessions the worker keeps, insert. `now` is the receipt time.
@@ -429,3 +443,41 @@ export async function countKills(
.where(killWhere(serverId, null, filter, match));
return row?.n ?? 0;
}
+
+export interface EventsBefore {
+ ts: Date;
+ eventId: string;
+}
+
+const eventWhere = (serverId: string, before: EventsBefore | null, eventType: string): SQL =>
+ and(
+ eq(kills.serverId, serverId),
+ eventType ? eq(kills.eventType, eventType) : undefined,
+ before ? sql`(${kills.ts}, ${kills.eventId}) < (${before.ts}, ${before.eventId})` : undefined
+ )!;
+
+/** Every feed event, including unknown types and incomplete kills, newest first. */
+export async function recentFeedEvents(
+ env: Env,
+ serverId: string,
+ before: EventsBefore | null,
+ limit: number,
+ eventType = ''
+): Promise {
+ const rows = await env.db
+ .select()
+ .from(kills)
+ .where(eventWhere(serverId, before, eventType))
+ .orderBy(desc(kills.ts), desc(kills.eventId))
+ .limit(limit);
+ return rows.map(feedEventView);
+}
+
+/** How many stored feed events match the optional exact event type. */
+export async function countFeedEvents(env: Env, serverId: string, eventType = ''): Promise {
+ const [row] = await env.db
+ .select({ n: count() })
+ .from(kills)
+ .where(eventWhere(serverId, null, eventType));
+ return row?.n ?? 0;
+}
diff --git a/src/lib/server/lists.ts b/src/lib/server/lists.ts
index 3a0d4b62..6bb21bd5 100644
--- a/src/lib/server/lists.ts
+++ b/src/lib/server/lists.ts
@@ -36,6 +36,7 @@ import {
type ListRow
} from './db/schema';
import { DEFAULT_BAN_MESSAGE } from '$lib/ban-message';
+import { noteContainsProtectedWord } from './trigger-rules';
import { requireSteamId } from './steam';
import { desiredFor, memberSlots, summaryOf } from './lists-sync';
import { latestNames } from './sessions';
@@ -441,8 +442,14 @@ async function insertEntry(
env: Env,
list: ListRow,
entry: NewEntry,
- opts: { replaceExisting?: boolean } = {}
-): Promise<{ id: string; added: boolean; replaced: boolean; lengthened: boolean }> {
+ opts: { replaceExisting?: boolean; protectedNoteWord?: string } = {}
+): Promise<{
+ id: string;
+ added: boolean;
+ replaced: boolean;
+ lengthened: boolean;
+ protected: boolean;
+}> {
return env.db.transaction((tx) => insertInto(tx, list, entry, opts));
}
@@ -458,14 +465,20 @@ async function insertInto(
tx: Tx,
list: ListRow,
entry: NewEntry,
- opts: { lengthen?: boolean; replaceExisting?: boolean } = {}
-): Promise<{ id: string; added: boolean; replaced: boolean; lengthened: boolean }> {
+ opts: { lengthen?: boolean; replaceExisting?: boolean; protectedNoteWord?: string } = {}
+): Promise<{
+ id: string;
+ added: boolean;
+ replaced: boolean;
+ lengthened: boolean;
+ protected: boolean;
+}> {
const id = newId();
// Serialise adds to one list so two writers cannot race past the duplicate check; the
// partial unique index on (list_id, steam_id) where removed_at is null is the backstop.
await tx.execute(sql`SELECT 1 FROM ${lists} WHERE ${lists.id} = ${list.id} FOR UPDATE`);
const found = tx
- .select({ id: listEntries.id, expiresAt: listEntries.expiresAt })
+ .select({ id: listEntries.id, expiresAt: listEntries.expiresAt, reason: listEntries.reason })
.from(listEntries)
.where(
and(
@@ -478,6 +491,14 @@ async function insertInto(
// Lengthening decides from the entry's expiry: an edit of it by hand is seen, or waits.
const [dup] = opts.lengthen || opts.replaceExisting ? await found.for('update') : await found;
if (dup) {
+ if (opts.replaceExisting && noteContainsProtectedWord(dup.reason, opts.protectedNoteWord ?? ''))
+ return {
+ id: dup.id,
+ added: false,
+ replaced: false,
+ lengthened: false,
+ protected: true
+ };
if (opts.replaceExisting) {
await tx
.update(listEntries)
@@ -495,18 +516,30 @@ async function insertInto(
(entry.expiresAt === null ||
entry.expiresAt.getTime() - dup.expiresAt.getTime() >= LENGTHEN_MIN_MS);
if (!opts.lengthen || !longer)
- return { id: dup.id, added: false, replaced: false, lengthened: false };
+ return {
+ id: dup.id,
+ added: false,
+ replaced: false,
+ lengthened: false,
+ protected: false
+ };
await tx
.update(listEntries)
.set({ expiresAt: entry.expiresAt })
.where(eq(listEntries.id, dup.id));
await touch(tx, list.id);
- return { id: dup.id, added: false, replaced: false, lengthened: true };
+ return {
+ id: dup.id,
+ added: false,
+ replaced: false,
+ lengthened: true,
+ protected: false
+ };
}
}
await tx.insert(listEntries).values({ id, listId: list.id, ...entry });
await touch(tx, list.id);
- return { id, added: true, replaced: !!dup, lengthened: false };
+ return { id, added: true, replaced: !!dup, lengthened: false, protected: false };
}
/**
@@ -520,8 +553,19 @@ export async function grantEntry(
env: Env,
list: ListRow,
entry: { steamId: string; reason: string; expiresAt: Date | null; addedByName: string },
- opts: { tx?: Tx; lengthen?: boolean; replaceExisting?: boolean } = {}
-): Promise<{ id: string; added: boolean; replaced: boolean; lengthened: boolean }> {
+ opts: {
+ tx?: Tx;
+ lengthen?: boolean;
+ replaceExisting?: boolean;
+ protectedNoteWord?: string;
+ } = {}
+): Promise<{
+ id: string;
+ added: boolean;
+ replaced: boolean;
+ lengthened: boolean;
+ protected: boolean;
+}> {
const row = { ...entry, addedBy: null };
return opts.tx
? insertInto(opts.tx, list, row, opts)
diff --git a/src/lib/server/outbox.ts b/src/lib/server/outbox.ts
index d8b5da43..f1e8b227 100644
--- a/src/lib/server/outbox.ts
+++ b/src/lib/server/outbox.ts
@@ -574,6 +574,7 @@ async function deliverSeedReward(env: Env, row: OutboxRow): Promise {
/** missing on rows from before the rule had a scope: those went org-wide */
scope?: 'server' | 'org';
replaceExisting?: boolean;
+ protectedNoteWord?: string;
};
stats.inFlight++;
try {
@@ -590,7 +591,8 @@ async function deliverSeedReward(env: Env, row: OutboxRow): Promise {
const {
id: entryId,
added,
- replaced
+ replaced,
+ protected: protectedEntry
} = await grantEntry(
env,
list,
@@ -600,7 +602,10 @@ async function deliverSeedReward(env: Env, row: OutboxRow): Promise {
expiresAt,
addedByName: `trigger: ${row.triggerName}`
},
- { replaceExisting: !!p.replaceExisting }
+ {
+ replaceExisting: !!p.replaceExisting,
+ protectedNoteWord: p.protectedNoteWord
+ }
);
// The next sync puts the slot on the server; remember it now so the rule does not grant
// it again before the next snapshot.
@@ -610,7 +615,9 @@ async function deliverSeedReward(env: Env, row: OutboxRow): Promise {
env,
row,
'skipped',
- `${p.steamId} already has a reserved slot ${here ? `on ${server.name}` : `in ${org.name}`}.`
+ protectedEntry
+ ? `${p.steamId}'s existing reserved slot was kept because its note contains the protected word “${p.protectedNoteWord}”.`
+ : `${p.steamId} already has a reserved slot ${here ? `on ${server.name}` : `in ${org.name}`}.`
);
if (m) {
m.syncAt = 0;
diff --git a/src/lib/server/seed-progress.ts b/src/lib/server/seed-progress.ts
new file mode 100644
index 00000000..3273040c
--- /dev/null
+++ b/src/lib/server/seed-progress.ts
@@ -0,0 +1,41 @@
+export interface SeedProgressEntry {
+ /** all-time seed seconds seen when this balance was last updated */
+ observed: number;
+ /** unspent seed seconds toward the next reward */
+ balance: number;
+ seenAt: number;
+}
+
+export interface SeedProgressState {
+ players: Record;
+}
+
+/** Reads persisted seeding reward progress defensively; old and malformed trigger state is empty. */
+export function seedProgress(raw: unknown): SeedProgressState {
+ const input =
+ raw && typeof raw === 'object' && (raw as { players?: unknown }).players
+ ? ((raw as { players: unknown }).players as Record)
+ : {};
+ const players: Record = {};
+ for (const [steamId, value] of Object.entries(input)) {
+ if (!value || typeof value !== 'object') continue;
+ const v = value as Record;
+ if (
+ typeof v.observed === 'number' &&
+ Number.isFinite(v.observed) &&
+ typeof v.balance === 'number' &&
+ Number.isFinite(v.balance) &&
+ typeof v.seenAt === 'number' &&
+ Number.isFinite(v.seenAt)
+ )
+ players[steamId] = {
+ observed: Math.max(0, Math.floor(v.observed)),
+ balance: Math.max(0, Math.floor(v.balance)),
+ seenAt: v.seenAt
+ };
+ }
+ return { players };
+}
+
+export const seedBalanceSeconds = (raw: unknown, steamId: string): number =>
+ seedProgress(raw).players[steamId]?.balance ?? 0;
diff --git a/src/lib/server/trigger-rules.ts b/src/lib/server/trigger-rules.ts
index 13a61385..e9f3c183 100644
--- a/src/lib/server/trigger-rules.ts
+++ b/src/lib/server/trigger-rules.ts
@@ -171,6 +171,8 @@ export interface SeedRewardConfig {
scope?: SeedScope;
/** replace an active entry in the selected reserved-slot list instead of skipping the reward */
replaceExisting: boolean;
+ /** keep an existing entry when its note contains this word; blank protects nothing */
+ protectedNoteWord: string;
lowAt: number;
/** count seed time only once the server has filled with the player still on */
untilFull: boolean;
@@ -181,6 +183,18 @@ export interface SeedRewardConfig {
slotDays: number;
message: string;
}
+
+/** Case-insensitive whole-word/marker match, so `paid` does not accidentally match `unpaid`. */
+export function noteContainsProtectedWord(note: string, word: string): boolean {
+ const needle = word.trim();
+ if (!needle) return false;
+ const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const chars = Array.from(needle);
+ const wordChar = /[\p{L}\p{N}_]/u;
+ const left = wordChar.test(chars[0] ?? '') ? '(^|[^\\p{L}\\p{N}_])' : '';
+ const right = wordChar.test(chars.at(-1) ?? '') ? '(?=$|[^\\p{L}\\p{N}_])' : '';
+ return new RegExp(`${left}${escaped}${right}`, 'iu').test(note);
+}
/**
* Announces a match ending and the next one starting. A match ends when the map changes or the
* faction scores fall back (a faction reached the cap, or an admin ended the round); the live
@@ -371,6 +385,7 @@ export function validateConfig(kind: TriggerKind, raw: unknown): TriggerConfig {
return {
scope: c.scope === 'server' ? 'server' : 'org',
replaceExisting: !!c.replaceExisting,
+ protectedNoteWord: str(c.protectedNoteWord, 50),
lowAt,
untilFull: c.untilFull === undefined ? true : !!c.untilFull,
fullAt,
diff --git a/src/lib/server/triggers.test.ts b/src/lib/server/triggers.test.ts
index 41990852..93d3f243 100644
--- a/src/lib/server/triggers.test.ts
+++ b/src/lib/server/triggers.test.ts
@@ -10,6 +10,7 @@ import {
matchBoundary,
matchBroadcastMessages,
matchReplay,
+ noteContainsProtectedWord,
pingKickStep,
riskKickVerdict,
seedRule,
@@ -148,6 +149,7 @@ describe('validateConfig', () => {
expect(validateConfig('seed_reward', { minutes: '45' })).toEqual({
scope: 'org',
replaceExisting: false,
+ protectedNoteWord: '',
lowAt: 20,
untilFull: true,
fullAt: null,
@@ -168,6 +170,7 @@ describe('validateConfig', () => {
).toEqual({
scope: 'org',
replaceExisting: false,
+ protectedNoteWord: '',
lowAt: 1,
untilFull: false,
fullAt: null,
@@ -190,6 +193,15 @@ describe('validateConfig', () => {
expect(validateConfig('seed_reward', { minutes: 60, replaceExisting: true })).toMatchObject({
replaceExisting: true
});
+ expect(
+ validateConfig('seed_reward', { minutes: 60, protectedNoteWord: ' paid ' })
+ ).toMatchObject({ protectedNoteWord: 'paid' });
+ });
+ test('seed reward note protection matches a whole word without case sensitivity', () => {
+ expect(noteContainsProtectedWord('Annual PAID membership', 'paid')).toBe(true);
+ expect(noteContainsProtectedWord('paid-member', 'PAID')).toBe(true);
+ expect(noteContainsProtectedWord('currently unpaid', 'paid')).toBe(false);
+ expect(noteContainsProtectedWord('Annual membership', '')).toBe(false);
});
});
diff --git a/src/lib/server/triggers.ts b/src/lib/server/triggers.ts
index 362865c1..c7df727d 100644
--- a/src/lib/server/triggers.ts
+++ b/src/lib/server/triggers.ts
@@ -42,6 +42,8 @@ import { writeAudit } from './audit';
import { emit } from './events';
import {
kills,
+ listEntries,
+ lists,
outbox,
playerSessions,
samples,
@@ -87,6 +89,7 @@ import {
type HeldMatchEnd,
type MatchLineVars,
matchReplay,
+ noteContainsProtectedWord,
seedReplay,
type MatchBroadcastConfig,
type MatchEnd,
@@ -1542,6 +1545,29 @@ async function evalSeedReward(
out.updates.push({ id: row.id, state: progress });
return;
}
+ const protectedPlayers = new Set();
+ if (cfg.replaceExisting && cfg.protectedNoteWord) {
+ const protectedRows = await env.db
+ .select({ steamId: listEntries.steamId, reason: listEntries.reason })
+ .from(listEntries)
+ .innerJoin(lists, eq(lists.id, listEntries.listId))
+ .where(
+ and(
+ eq(lists.kind, 'reserve'),
+ isNull(listEntries.removedAt),
+ inArray(
+ listEntries.steamId,
+ ctx.players.map((p) => p.steamId)
+ ),
+ cfg.scope === 'server'
+ ? eq(lists.serverId, ctx.server.id)
+ : and(eq(lists.orgId, ctx.server.orgId), isNull(lists.serverId))
+ )
+ );
+ for (const entry of protectedRows)
+ if (noteContainsProtectedWord(entry.reason, cfg.protectedNoteWord))
+ protectedPlayers.add(entry.steamId);
+ }
const closed = await env.db
.select({
steamId: playerSessions.steamId,
@@ -1573,7 +1599,8 @@ async function evalSeedReward(
? previous.balance + Math.max(0, total - previous.observed)
: Number(historyRow?.windowSeconds ?? 0) + openSeconds;
const qualified = balance >= cfg.minutes * 60;
- const eligible = !ctx.reserved.has(p.steamId) || cfg.replaceExisting;
+ const eligible =
+ !protectedPlayers.has(p.steamId) && (!ctx.reserved.has(p.steamId) || cfg.replaceExisting);
progress.players[p.steamId] = {
observed: total,
balance: qualified && eligible ? 0 : balance,
@@ -1599,7 +1626,8 @@ async function evalSeedReward(
reason,
slotDays: cfg.slotDays,
scope: cfg.scope === 'server' ? 'server' : 'org',
- replaceExisting: cfg.replaceExisting
+ replaceExisting: cfg.replaceExisting,
+ protectedNoteWord: cfg.protectedNoteWord
},
target: p.steamId,
okMessage: `Reserved a slot for ${p.name}.`,
@@ -2349,6 +2377,10 @@ export async function dryRun(
result.notes.push(
'The replay shows raw seed time and cannot subtract time already consumed by a live reward; live progress resets whenever a reward is queued.'
);
+ if (c.replaceExisting && c.protectedNoteWord)
+ result.notes.push(
+ `The replay cannot read reserved-slot notes; live delivery keeps entries whose note contains “${c.protectedNoteWord}”.`
+ );
return result;
}
if (kind === 'name_filter') {
diff --git a/src/lib/types.ts b/src/lib/types.ts
index c8707df9..51ce7787 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -171,6 +171,20 @@ export interface KillView {
teamKill: boolean;
tags: string[];
}
+/** One raw event accepted by the game feed, whether or not it was a complete kill. */
+export interface FeedEventView {
+ eventId: string;
+ /** when Warcon received it */
+ ts: string;
+ eventType: string;
+ parsedKill: boolean;
+ instanceId: string;
+ matchId: string;
+ /** seconds on the match clock, when supplied */
+ eventTime: number | null;
+ map: string;
+ rawEvent: unknown;
+}
/** One trigger action and what became of it. */
export interface OutboxView {
id: number;
diff --git a/src/routes/(app)/server/[id]/+layout.svelte b/src/routes/(app)/server/[id]/+layout.svelte
index 28b781e7..31df5052 100644
--- a/src/routes/(app)/server/[id]/+layout.svelte
+++ b/src/routes/(app)/server/[id]/+layout.svelte
@@ -18,6 +18,7 @@
['', 'Overview'],
['/players', 'Players'],
['/kills', 'Kills'],
+ ['/events', 'Events'],
['/matches', 'Matches'],
['/bans', 'Bans'],
['/slots', 'Reserved slots'],
diff --git a/src/routes/(app)/server/[id]/automation/+page.svelte b/src/routes/(app)/server/[id]/automation/+page.svelte
index 089c8576..e35a7e65 100644
--- a/src/routes/(app)/server/[id]/automation/+page.svelte
+++ b/src/routes/(app)/server/[id]/automation/+page.svelte
@@ -435,6 +435,7 @@
slotDays: number;
slotScope: 'server' | 'org';
replaceExisting: boolean;
+ protectedNoteWord: string;
characters: 'off' | 'latin' | 'ascii';
extraScripts: string[];
allowSymbols: boolean;
@@ -616,6 +617,7 @@
windowDays: n('windowDays', 7),
slotDays: n('slotDays', 7),
replaceExisting: b('replaceExisting', false),
+ protectedNoteWord: s('protectedNoteWord', ''),
// a rule saved before the scope existed hands out org-wide slots; a new one, this server's
slotScope: c.scope === 'server' ? 'server' : t ? 'org' : canSlotHere ? 'server' : 'org',
characters: c.characters === 'ascii' || c.characters === 'off' ? c.characters : 'latin',
@@ -805,6 +807,7 @@
slotDays: Number(f.slotDays),
scope: f.slotScope,
replaceExisting: f.replaceExisting,
+ protectedNoteWord: f.protectedNoteWord,
message: f.message
};
}
@@ -1008,7 +1011,7 @@
case 'afk_protection':
return `kill everyone every ${c.everyMinutes} min while fewer than ${c.stopAt} are on and no side has scored · then off until the server empties or restarts${c.message ? ' · with a broadcast' : ''}${c.doneMessage ? ' · thanks at the start' : ''}`;
case 'seed_reward':
- return `${c.minutes} min with ${c.lowAt} or fewer on${c.untilFull === false ? '' : `, staying until ${typeof c.fullAt === 'number' ? `${c.fullAt}+ on` : 'it fills'}`}, within ${c.windowDays} day${c.windowDays === 1 ? '' : 's'} · slot ${c.scope === 'server' ? 'here' : 'on every server'} for ${c.slotDays} day${c.slotDays === 1 ? '' : 's'}${c.replaceExisting ? ' · replaces an existing slot' : ''}${c.message ? ' · with a whisper' : ''}`;
+ return `${c.minutes} min with ${c.lowAt} or fewer on${c.untilFull === false ? '' : `, staying until ${typeof c.fullAt === 'number' ? `${c.fullAt}+ on` : 'it fills'}`}, within ${c.windowDays} day${c.windowDays === 1 ? '' : 's'} · slot ${c.scope === 'server' ? 'here' : 'on every server'} for ${c.slotDays} day${c.slotDays === 1 ? '' : 's'}${c.replaceExisting ? ` · replaces an existing slot${c.protectedNoteWord ? ` except notes containing “${c.protectedNoteWord}”` : ''}` : ''}${c.message ? ' · with a whisper' : ''}`;
}
}
@@ -2356,6 +2359,20 @@
Replace an existing reserved
slot with the newly earned one
+ {#if f.replaceExisting}
+
+ {/if}
@@ -1910,8 +1909,7 @@
move nobody
- Moves go out a few at a time as the player list refreshes; each kills the player so they
- respawn on the new side.
+ Moves go out a few at a time as the player list refreshes, without killing the player.
{#if f.balance}
A player who switches onto the bigger side is put back every time. A player asked to
move three times in ten minutes for any other reason, or put back three times without
diff --git a/src/test/two-teams-delivery.test.ts b/src/test/two-teams-delivery.test.ts
index 75474cd7..fa24c2b0 100644
--- a/src/test/two-teams-delivery.test.ts
+++ b/src/test/two-teams-delivery.test.ts
@@ -1,5 +1,5 @@
// Two-team mode at delivery: a second move for a player never goes out on the player list the first
-// was checked against (moving, and killing, someone twice is not harmless), and a rule that changes
+// was checked against (moving someone twice is not harmless), and a rule that changes
// drops the moves it queued under its old settings, including ones that reach the outbox after it.
import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from 'bun:test';
import { join } from 'node:path';
@@ -18,7 +18,7 @@ import {
} from '$lib/server/triggers';
import type { TriggerRow } from '$lib/server/db/schema';
import { twoTeamsSettingsKey, validateTwoTeams } from '$lib/server/two-teams';
-import { GameError, WardogsClient } from '$lib/server/rcon';
+import { WardogsClient } from '$lib/server/rcon';
import type { Player } from '$lib/types';
import { subscribe } from '$lib/server/events';
import { hasTestDb, testEnv } from './db';
@@ -49,10 +49,6 @@ describe.skipIf(!hasTestDb)('Two-team mode at delivery', () => {
let spy: ReturnType;
let renewing: ReturnType;
const requests: string[] = [];
- /** when each request reached the stand-in game */
- const times: number[] = [];
- /** players whose kill the stand-in game refuses for sending too fast */
- const refuseKillOf = new Set();
/** the server's Two-team rule, closing Lonestar, whose moves these rows are */
let ruleId: string;
@@ -81,17 +77,6 @@ describe.skipIf(!hasTestDb)('Two-team mode at delivery', () => {
({
json: async (method: string, path: string) => {
requests.push(`${method} ${path}`);
- times.push(Date.now());
- const killOf = /^\/v1\/players\/(\d+)\/kill$/.exec(path)?.[1];
- if (method === 'POST' && killOf && refuseKillOf.has(killOf)) {
- const err = new GameError(
- 429,
- 'The game server is rate limiting this panel (Request rate exceeded); retry in 2 s.',
- 'rate_limited'
- );
- err.retryAfterMs = 2000;
- throw err;
- }
return { ok: true };
}
}) as unknown as WardogsClient
@@ -117,7 +102,7 @@ describe.skipIf(!hasTestDb)('Two-team mode at delivery', () => {
triggerName: 'Two teams',
triggerKind: 'two_teams',
action: 'changeTeam',
- params: { steamId, faction: 'Valkyra', from: 'Lonestar', rule: DECIDED },
+ params: { steamId, faction: 'Valkyra', kill: false, from: 'Lonestar', rule: DECIDED },
target: steamId,
steamId,
okMessage: 'Moved.',
@@ -159,12 +144,7 @@ describe.skipIf(!hasTestDb)('Two-team mode at delivery', () => {
'pending',
'pending'
]);
- expect(requests).toEqual([
- `PATCH /v1/players/${X}`,
- `POST /v1/players/${X}/kill`,
- `PATCH /v1/players/${Y}`,
- `POST /v1/players/${Y}/kill`
- ]);
+ expect(requests).toEqual([`PATCH /v1/players/${X}`, `PATCH /v1/players/${Y}`]);
// The next list: X landed, Y did not.
m.players = [on(X, 'Valkyra'), on(Y, 'Lonestar')];
m.playersAt = Date.now();
@@ -172,9 +152,9 @@ describe.skipIf(!hasTestDb)('Two-team mode at delivery', () => {
const rows = await rowsOf(ids);
expect(rows.slice(2).map((r) => [r.state, r.outcome])).toEqual([
['skipped', 'Already off Lonestar.'],
- ['delivered', 'Moved to Valkyra and killed, so they respawn on the new side.']
+ ['delivered', 'Moved to Valkyra.']
]);
- expect(requests.slice(4)).toEqual([`PATCH /v1/players/${Y}`, `POST /v1/players/${Y}/kill`]);
+ expect(requests.slice(2)).toEqual([`PATCH /v1/players/${Y}`]);
await stopDelivery();
}, 30_000);
@@ -257,14 +237,10 @@ describe.skipIf(!hasTestDb)('Two-team mode at delivery', () => {
expect((await rowsOf(ids)).map((r) => [r.state, r.outcome])).toEqual([
['skipped', 'No longer wanted by the rule.'],
// A is on Valkyra in these lists, so B goes to Manticore
- ['delivered', 'Moved to Manticore and killed, so they respawn on the new side.']
- ]);
- expect(requests.slice(from)).toEqual([
- `PATCH /v1/players/${A}`,
- `POST /v1/players/${A}/kill`,
- `PATCH /v1/players/${B}`,
- `POST /v1/players/${B}/kill`
+ ['delivered', 'Moved to Manticore.']
]);
+ // the rule's moves go out alone, without the kill
+ expect(requests.slice(from)).toEqual([`PATCH /v1/players/${A}`, `PATCH /v1/players/${B}`]);
} finally {
await stopDelivery();
forgetRuleMemory();
@@ -293,54 +269,14 @@ describe.skipIf(!hasTestDb)('Two-team mode at delivery', () => {
await until(async () => (await rowsOf(ids)).every((r) => r.doneAt !== null));
expect((await rowsOf(ids)).map((r) => [r.state, r.outcome])).toEqual([
['skipped', 'The rule was changed before this was sent.'],
- ['delivered', 'Moved to Valkyra and killed, so they respawn on the new side.']
- ]);
- expect(requests.slice(from)).toEqual([
- `PATCH /v1/players/${D}`,
- `POST /v1/players/${D}/kill`
+ ['delivered', 'Moved to Valkyra.']
]);
+ expect(requests.slice(from)).toEqual([`PATCH /v1/players/${D}`]);
} finally {
await stopDelivery();
}
}, 30_000);
- test('a move whose kill is refused for sending too fast says so, and holds the server', async () => {
- const m = memoryOf(w.server.id)!;
- m.players = [on(X, 'Lonestar'), on(Y, 'Lonestar')];
- m.playersAt = Date.now();
- refuseKillOf.add(X);
- const from = requests.length;
- try {
- const ids = (
- await env.db
- .insert(outbox)
- .values([move(X), move(Y)])
- .returning({ id: outbox.id })
- ).map((r) => r.id);
- startDelivery(env);
- await until(async () => (await rowsOf(ids)).every((r) => r.doneAt !== null));
- const rows = await rowsOf(ids);
- expect(rows.map((r) => [r.state, r.outcome])).toEqual([
- [
- 'delivered',
- 'Moved to Valkyra. The game refused the kill for sending too fast, so they stay where they are until they next die.'
- ],
- ['delivered', 'Moved to Valkyra and killed, so they respawn on the new side.']
- ]);
- expect(requests.slice(from)).toEqual([
- `PATCH /v1/players/${X}`,
- `POST /v1/players/${X}/kill`,
- `PATCH /v1/players/${Y}`,
- `POST /v1/players/${Y}/kill`
- ]);
- // Y's move waited out the two seconds the game asked for.
- expect(times[from + 2] - times[from + 1]).toBeGreaterThanOrEqual(1900);
- } finally {
- refuseKillOf.clear();
- await stopDelivery();
- }
- }, 30_000);
-
test('changing, switching off or deleting the rule drops its queued moves; a new name does not', async () => {
const api = async (route: string, input: Omit): Promise => {
const [method, path] = route.split(' ');
@@ -503,10 +439,7 @@ describe.skipIf(!hasTestDb)('Two-team mode at delivery', () => {
await until(async () => (await rowsOf([id]))[0].doneAt !== null);
const [row] = await rowsOf([id]);
expect(failed).toBe(true);
- expect([row.state, row.outcome]).toEqual([
- 'delivered',
- 'Moved to Valkyra and killed, so they respawn on the new side.'
- ]);
+ expect([row.state, row.outcome]).toEqual(['delivered', 'Moved to Valkyra.']);
expect(row.attempts).toBeGreaterThan(1);
const trail = await env.db
.select({ message: auditLog.message })
diff --git a/src/test/two-teams-discord.test.ts b/src/test/two-teams-discord.test.ts
index e155003f..1f50c640 100644
--- a/src/test/two-teams-discord.test.ts
+++ b/src/test/two-teams-discord.test.ts
@@ -53,8 +53,7 @@ describe.skipIf(!hasTestDb)('Two-team mode on Discord', () => {
test('moves that land stay off Discord; one that fails, and other rules, are posted', async () => {
posts.length = 0;
- for (let i = 0; i < 20; i++)
- await delivery('trigger.two_teams', 'ok', 'Moved to Valkyra and killed.');
+ for (let i = 0; i < 20; i++) await delivery('trigger.two_teams', 'ok', 'Moved to Valkyra.');
await delivery('trigger.two_teams', 'error', 'The player is not on the server.');
await delivery('trigger.welcome', 'ok', 'Whispered.');
await new Promise((r) => setTimeout(r, 2000));
From e1fc789feb3aa248f8a1d897456439a395ad7acf Mon Sep 17 00:00:00 2001
From: James Causon
Date: Sun, 4 Oct 2026 10:26:19 +0100
Subject: [PATCH 010/102] Boards: a page of a board is read at most once a
minute for the same servers and query, and the requests that ask for it
during the read wait for that read; keyed by the server ids the caller's own
check settled on, so a board over servers someone was not given is never
theirs; a failed read is not kept and a stats purge forgets the server's
boards (leaderboard pages were 1,204 reads at about 1 s each in 6.5 hours on
the hosted panel)
---
README.md | 2 +
src/lib/server/leaderboards.ts | 53 ++++++++--
src/lib/server/stats.ts | 3 +
src/test/board-cache.test.ts | 184 +++++++++++++++++++++++++++++++++
4 files changed, 236 insertions(+), 6 deletions(-)
create mode 100644 src/test/board-cache.test.ts
diff --git a/README.md b/README.md
index aab6356a..2b43d2a0 100644
--- a/README.md
+++ b/README.md
@@ -732,6 +732,8 @@ result (win, loss, draw) is read from the match's winner and final scores agains
player played; a match with no winner and nobody scoring, or one abandoned by a restart, has no
result. Playtime and seed time come from player sessions; kills per hour leaves seed time out;
cash is summed over sessions, each banked across its matches like kills. Names link to the dossier.
+A page of a board is read at most once a minute for the same servers and settings, so a match
+that just ended can take up to a minute to appear (a stats purge shows at once).
**Export CSV** on the tab downloads the board as it is set (scope, range, sort, playtime floor),
from the top and every page of it, up to 10,000 players: rank, SteamID, name, playtime and seed
diff --git a/src/lib/server/leaderboards.ts b/src/lib/server/leaderboards.ts
index 4165df74..fa756dd5 100644
--- a/src/lib/server/leaderboards.ts
+++ b/src/lib/server/leaderboards.ts
@@ -2,12 +2,12 @@
// of every match (match_players: the game's own kills and deaths, the feed's headshots, team
// kills, suicides, vehicle kills and streaks, time on and the side played) joined to its match
// for the result, and the sessions for playtime, seed time, the last name and cash.
-// Nothing is precomputed. The queries ride the existing indexes: matches (server_id,
-// started_at), match_players (match_id, steam_id) and (steam_id, match_id), player_sessions
-// (server_id, last_seen) and (steam_id, joined_at). Only matches that have ended count, and a
-// match is in a range by when it ended; the match in progress is on the live page. The result
-// of a match for a player (win, loss, draw, none) is the rule in $lib/leaderboard, written out
-// again in SQL below for the aggregates.
+// Nothing is precomputed; a page of a board is kept for a minute (loadBoard). The queries ride
+// the existing indexes: matches (server_id, started_at), match_players (match_id, steam_id) and
+// (steam_id, match_id), player_sessions (server_id, last_seen) and (steam_id, joined_at). Only
+// matches that have ended count, and a match is in a range by when it ended; the match in
+// progress is on the live page. The result of a match for a player (win, loss, draw, none) is the
+// rule in $lib/leaderboard, written out again in SQL below for the aggregates.
import { sql } from 'drizzle-orm';
import type { Env } from './env';
import { servers } from './db/schema';
@@ -241,9 +241,50 @@ async function boardSlice(
FROM page r`)) as BaseRow[];
}
+/**
+ * A page of a board is the same for everyone who may see these servers, and each one is an
+ * aggregate over its whole range: each (servers, query) is read once a minute per web process,
+ * and the requests that ask for it while it is being read wait for that read. The key is the
+ * server ids the caller's own check settled on, so a board over servers someone was not given
+ * is never theirs. A minute behind is the price: a match that just ended may not be on it yet.
+ */
+const BOARD_TTL_MS = 60_000;
+/** The most pages kept: fifty rows each, a few megabytes at most. */
+const BOARD_CACHE_MAX = 500;
+const boardCache = new Map }>();
+
+/** Forgets the boards kept over this server (its stats were purged), or every board. */
+export function forgetBoards(serverId?: string): void {
+ for (const [key, hit] of boardCache)
+ if (serverId === undefined || hit.ids.includes(serverId)) boardCache.delete(key);
+}
+
export async function loadBoard(env: Env, ids: string[], q: BoardQuery): Promise {
const empty: BoardView = { query: q, rows: [], total: 0, pageSize: BOARD_PAGE, hasFeed: false };
if (!ids.length) return empty;
+ const sorted = [...new Set(ids)].sort();
+ const key = JSON.stringify([sorted, q.scope, q.range, q.sort, q.dir, q.page, q.minMinutes]);
+ const now = Date.now();
+ const hit = boardCache.get(key);
+ if (hit && hit.until > now) return hit.view;
+ const entry = { ids: sorted, until: now + BOARD_TTL_MS, view: readBoard(env, sorted, q) };
+ boardCache.delete(key);
+ boardCache.set(key, entry);
+ // A failed read is not kept: the next request reads again.
+ entry.view.catch(() => {
+ if (boardCache.get(key) === entry) boardCache.delete(key);
+ });
+ if (boardCache.size > BOARD_CACHE_MAX) {
+ for (const [k, e] of boardCache) if (e.until <= now) boardCache.delete(k);
+ for (const k of boardCache.keys()) {
+ if (boardCache.size <= BOARD_CACHE_MAX) break;
+ boardCache.delete(k);
+ }
+ }
+ return entry.view;
+}
+
+async function readBoard(env: Env, ids: string[], q: BoardQuery): Promise {
const offset = (q.page - 1) * BOARD_PAGE;
const [rows, hasFeed] = await Promise.all([
boardSlice(env, ids, q, BOARD_PAGE, offset),
diff --git a/src/lib/server/stats.ts b/src/lib/server/stats.ts
index e01cfd49..3c41b81e 100644
--- a/src/lib/server/stats.ts
+++ b/src/lib/server/stats.ts
@@ -8,6 +8,7 @@ import { sql } from 'drizzle-orm';
import type { Env } from './env';
import type { ServerRow, SessionUser } from './access';
import { writeAudit } from './audit';
+import { forgetBoards } from './leaderboards';
export interface PurgeCounts {
kills: number;
@@ -33,6 +34,8 @@ export async function purgeServerStats(
const matches = await del('matches');
return { kills, matches, matchPlayers };
});
+ // The boards this web process keeps for a minute would show the purged numbers until then.
+ forgetBoards(server.id);
await writeAudit(env, req, {
actor,
server: { id: server.id, name: server.name },
diff --git a/src/test/board-cache.test.ts b/src/test/board-cache.test.ts
new file mode 100644
index 00000000..53f44696
--- /dev/null
+++ b/src/test/board-cache.test.ts
@@ -0,0 +1,184 @@
+// A page of a board is kept for a minute per (servers, query): a second look inside the minute is
+// not read again, requests that arrive during a read share it, a failed read is not kept, a purge
+// forgets it, and nobody is handed a board over servers other than the ones their own check gave
+// them (an organisation board read by its owner first, then asked for by a member given one of
+// its servers, or by anyone on the public board).
+import { afterEach, beforeAll, describe, expect, setSystemTime, test } from 'bun:test';
+import { eq } from 'drizzle-orm';
+import type { Env } from '$lib/server/env';
+import { organizations, playerSessions, servers } from '$lib/server/db/schema';
+import { forgetBoards, loadBoard } from '$lib/server/leaderboards';
+import { parseBoardQuery } from '$lib/leaderboard';
+import { hasTestDb, testEnv } from './db';
+import { callApi, stubGateway } from './call';
+import { seedWorld, type World } from './world';
+import { GET as panelBoard } from '../routes/api/servers/[id]/leaderboard/+server';
+import { GET as publicBoard } from '../routes/api/public/servers/[id]/leaderboard/+server';
+import { POST as purge } from '../routes/api/servers/[id]/stats/purge/+server';
+
+const HOUR = 3_600_000;
+const HERE = '76561198000000301';
+const THERE = '76561198000000302';
+const LATER = '76561198000000303';
+const q = parseBoardQuery(new URLSearchParams('minMinutes=0'));
+
+describe.skipIf(!hasTestDb)('board cache', () => {
+ let env: Env;
+ let w: World;
+
+ const played = (serverId: string, steamId: string) =>
+ env.db.insert(playerSessions).values({
+ serverId,
+ steamId,
+ name: steamId.slice(-3),
+ joinedAt: new Date(Date.now() - 2 * HOUR),
+ lastSeen: new Date(Date.now() - HOUR),
+ leftAt: new Date(Date.now() - HOUR)
+ });
+ const ids = (view: { rows: { steamId: string }[] }) => view.rows.map((r) => r.steamId).sort();
+ /** The env with its raw statements counted (the board's aggregate is one), or failing. */
+ const counted = (fail = false) => {
+ const n = { execute: 0 };
+ const db = new Proxy(env.db, {
+ get(target, prop, receiver) {
+ if (prop !== 'execute') return Reflect.get(target, prop, receiver);
+ return (...args: unknown[]) => {
+ n.execute++;
+ if (fail) return Promise.reject(new Error('down'));
+ return (target.execute as (...a: unknown[]) => unknown)(...args);
+ };
+ }
+ });
+ return { env: { ...env, db } as Env, n };
+ };
+
+ beforeAll(async () => {
+ env = await testEnv();
+ stubGateway();
+ w = await seedWorld(env);
+ await played(w.server.id, HERE);
+ await played(w.otherServer.id, THERE);
+ });
+
+ afterEach(() => {
+ setSystemTime();
+ forgetBoards();
+ });
+
+ test('a second look inside the minute is the first one; after it, the board is read again', async () => {
+ const first = await loadBoard(env, [w.server.id], q);
+ expect(ids(first)).toEqual([HERE]);
+ await played(w.server.id, LATER);
+ expect(ids(await loadBoard(env, [w.server.id], q))).toEqual([HERE]);
+ setSystemTime(new Date(Date.now() + 61_000));
+ expect(ids(await loadBoard(env, [w.server.id], q))).toEqual([HERE, LATER]);
+ await env.db.delete(playerSessions).where(eq(playerSessions.steamId, LATER));
+ });
+
+ test('the requests that arrive during a read wait for it; a failed read is not kept', async () => {
+ const down = counted(true);
+ await expect(loadBoard(down.env, [w.server.id], q)).rejects.toThrow('down');
+ const up = counted();
+ const [a, b] = await Promise.all([
+ loadBoard(up.env, [w.server.id], q),
+ loadBoard(up.env, [w.server.id], q)
+ ]);
+ expect(up.n.execute).toBe(1);
+ expect(ids(a)).toEqual([HERE]);
+ expect(b).toBe(a);
+ });
+
+ test('every part of the query is its own board, and so is every set of servers', async () => {
+ const up = counted();
+ await loadBoard(up.env, [w.server.id, w.otherServer.id], q);
+ // the same servers in another order are the same board
+ await loadBoard(up.env, [w.otherServer.id, w.server.id], q);
+ expect(up.n.execute).toBe(1);
+ await loadBoard(up.env, [w.server.id], q);
+ await loadBoard(up.env, [w.server.id, w.otherServer.id], { ...q, page: 2 });
+ await loadBoard(up.env, [w.server.id, w.otherServer.id], { ...q, sort: 'deaths' });
+ await loadBoard(up.env, [w.server.id, w.otherServer.id], { ...q, minMinutes: 1 });
+ await loadBoard(up.env, [w.server.id, w.otherServer.id], { ...q, range: 'all' });
+ await loadBoard(up.env, [w.server.id, w.otherServer.id], { ...q, dir: 'asc' });
+ const org = await loadBoard(up.env, [w.server.id, w.otherServer.id], { ...q, scope: 'org' });
+ expect(up.n.execute).toBe(8);
+ expect(org.query.scope).toBe('org');
+ });
+
+ test("an organisation board read by its owner is not a member's who was given one server", async () => {
+ const query = 'scope=org&minMinutes=0';
+ const owner = await callApi(panelBoard, w.users.owner, {
+ params: { id: w.server.id },
+ query
+ });
+ expect(owner.status).toBe(200);
+ expect(ids(owner.body as { rows: { steamId: string }[] })).toEqual([HERE, THERE]);
+ const member = await callApi(panelBoard, w.users.elsewhere, {
+ params: { id: w.otherServer.id },
+ query
+ });
+ expect(member.status).toBe(200);
+ expect(ids(member.body as { rows: { steamId: string }[] })).toEqual([THERE]);
+ const key = await callApi(panelBoard, w.users.keyElsewhere, {
+ params: { id: w.otherServer.id },
+ query
+ });
+ expect(ids(key.body as { rows: { steamId: string }[] })).toEqual([THERE]);
+ // and a server the caller cannot open is still refused, board kept or not
+ const outsider = await callApi(panelBoard, w.users.outsider, {
+ params: { id: w.server.id },
+ query
+ });
+ expect(outsider.status).toBe(404);
+ });
+
+ test('the public organisation board covers only the public servers, whatever was read before', async () => {
+ await env.db
+ .update(organizations)
+ .set({ allowPublicLeaderboards: true })
+ .where(eq(organizations.id, w.org.id));
+ await env.db
+ .update(servers)
+ .set({ publicLeaderboards: true })
+ .where(eq(servers.id, w.otherServer.id));
+ const query = 'scope=org&minMinutes=0';
+ const owner = await callApi(panelBoard, w.users.owner, {
+ params: { id: w.otherServer.id },
+ query
+ });
+ expect(ids(owner.body as { rows: { steamId: string }[] })).toEqual([HERE, THERE]);
+ const anon = await callApi(publicBoard, null, { params: { id: w.otherServer.id }, query });
+ expect(anon.status).toBe(200);
+ expect(ids(anon.body as { rows: { steamId: string }[] })).toEqual([THERE]);
+ // the server whose board is not public answers nothing, kept board or not
+ const closed = await callApi(publicBoard, null, { params: { id: w.server.id }, query });
+ expect(closed.status).toBe(404);
+ await env.db
+ .update(servers)
+ .set({ publicLeaderboards: false })
+ .where(eq(servers.id, w.otherServer.id));
+ });
+
+ test("a purge forgets the server's boards at once", async () => {
+ const [{ name }] = await env.db
+ .select({ name: servers.name })
+ .from(servers)
+ .where(eq(servers.id, w.server.id));
+ const before = await loadBoard(env, [w.server.id, w.otherServer.id], q);
+ expect(ids(before)).toEqual([HERE, THERE]);
+ await played(w.server.id, LATER);
+ const done = await callApi(purge, w.users.owner, {
+ method: 'POST',
+ params: { id: w.server.id },
+ body: { name }
+ });
+ expect(done.status).toBe(200);
+ // sessions stay through a purge, so the board read again has the newcomer
+ expect(ids(await loadBoard(env, [w.server.id, w.otherServer.id], q))).toEqual([
+ HERE,
+ THERE,
+ LATER
+ ]);
+ await env.db.delete(playerSessions).where(eq(playerSessions.steamId, LATER));
+ });
+});
From 159347dc4bf711033745173053776d3b4390a92d Mon Sep 17 00:00:00 2001
From: James Causon
Date: Sun, 4 Oct 2026 10:26:19 +0100
Subject: [PATCH 011/102] Player names preload their page on tap, not on hover:
a dossier or career reads the all-time boards for its ranks, so moving the
cursor down a table of names started one of those per row
---
src/lib/components/CombatSummary.svelte | 12 ++++++++++--
src/lib/components/LeaderboardTable.svelte | 6 +++++-
src/lib/components/MatchPanel.svelte | 7 ++++++-
src/routes/(app)/orgs/[id]/BanListPage.svelte | 6 ++++--
src/routes/(app)/orgs/[id]/ReservedPage.svelte | 1 +
src/routes/(app)/orgs/[id]/players/+page.svelte | 1 +
src/routes/(app)/server/[id]/+page.svelte | 2 ++
src/routes/(app)/server/[id]/analytics/+page.svelte | 1 +
src/routes/(app)/server/[id]/automation/+page.svelte | 6 ++++--
src/routes/(app)/server/[id]/bans/+page.svelte | 1 +
src/routes/(app)/server/[id]/kills/+page.svelte | 12 ++++++++----
.../(app)/server/[id]/matches/[matchId]/+page.svelte | 12 ++++++++----
src/routes/(app)/server/[id]/players/+page.svelte | 1 +
.../(app)/server/[id]/players/PastPlayers.svelte | 1 +
.../(app)/server/[id]/players/[steamId]/+page.svelte | 1 +
src/routes/(app)/server/[id]/slots/+page.svelte | 1 +
src/routes/(public)/s/[id]/+page.svelte | 1 +
17 files changed, 56 insertions(+), 16 deletions(-)
diff --git a/src/lib/components/CombatSummary.svelte b/src/lib/components/CombatSummary.svelte
index 16c5bcb9..90b08bc1 100644
--- a/src/lib/components/CombatSummary.svelte
+++ b/src/lib/components/CombatSummary.svelte
@@ -54,7 +54,11 @@
Most killed
{#each combat.victims as v (v.steamId)}
From 66291dc0d072fb1be5ed2e3b2d78209c03900a94 Mon Sep 17 00:00:00 2001
From: James Causon
Date: Mon, 5 Oct 2026 22:18:11 +0100
Subject: [PATCH 018/102] Kill distance watch: a kill that comes within a
minute of the killer's own death is not counted, live or in the dry run. The
game credits a vehicle's shell that lands after the vehicle was destroyed to
the gunner's carried weapon, from wherever the gunner is by then: on the
hosted feed, 294 of the 301 carried-weapon kills from 1,000 m or more in 14
days came within 35 s of the killer's own death, and a rule on M4 kills from
1,300 m banned two artillery gunners for them in one evening. The worker
keeps each player's last death per server for servers with the rule on,
forgotten with the counts; the dry run reads the counted killers' deaths and
says how many kills it left out
---
src/lib/server/feed-events.ts | 34 ++++++++-
src/lib/server/kill-distance.test.ts | 89 ++++++++++++++++++++++-
src/lib/server/kill-distance.ts | 67 ++++++++++++++++-
src/lib/server/triggers.ts | 54 +++++++++++++-
src/test/kill-distance-rule.test.ts | 105 ++++++++++++++++++++++++++-
5 files changed, 331 insertions(+), 18 deletions(-)
diff --git a/src/lib/server/feed-events.ts b/src/lib/server/feed-events.ts
index 0f60ed78..f9e697e9 100644
--- a/src/lib/server/feed-events.ts
+++ b/src/lib/server/feed-events.ts
@@ -38,10 +38,12 @@ import {
countsForDistance,
killDistanceSettingsKey,
killDistanceStep,
+ killsAfterDeath,
matchKey,
type DistanceTrack,
type DistanceTracks,
- type KillDistanceConfig
+ type KillDistanceConfig,
+ type LastDeaths
} from './kill-distance';
import { applyTriggerUpdates, enqueueIntents, wakeDelivery } from './outbox';
import { LostOwnership, withOwnedTransaction } from './leadership';
@@ -172,11 +174,21 @@ interface DistanceMemory {
tracks: DistanceTracks;
}
const distanceMemory = new Map>();
+/**
+ * Each server's last deaths per player, so a kill just after the killer's own death is not counted
+ * (killsAfterDeath) when the death came in an earlier batch. Kept, and forgotten, with the counts.
+ */
+const lastDeaths = new Map();
/** Forgets the Kill distance counts of one server (removed from the worker), or of every server. */
export function forgetKillDistance(serverId?: string): void {
- if (serverId) distanceMemory.delete(serverId);
- else distanceMemory.clear();
+ if (serverId) {
+ distanceMemory.delete(serverId);
+ lastDeaths.delete(serverId);
+ } else {
+ distanceMemory.clear();
+ lastDeaths.clear();
+ }
}
/**
@@ -211,13 +223,26 @@ async function actOnKillDistance(
let mine = distanceMemory.get(serverId);
if (!rows.length) {
if (mine) distanceMemory.delete(serverId);
+ lastDeaths.delete(serverId);
return;
}
const live = new Set(rows.map((r) => r.id));
if (mine) for (const id of mine.keys()) if (!live.has(id)) mine.delete(id);
// The kills each rule counts, in the order the game played them. Most batches have none, and
- // then nothing is read.
+ // then nothing is read; every batch's deaths are noted all the same.
const inOrder = [...batch].sort((a, b) => a.eventTime - b.eventTime);
+ let deaths = lastDeaths.get(serverId);
+ if (!deaths) lastDeaths.set(serverId, (deaths = new Map()));
+ const afterDeath = killsAfterDeath(
+ deaths,
+ inOrder.map((k) => ({
+ eventId: k.eventId,
+ eventTime: k.eventTime,
+ killer: k.killer?.steamId,
+ victim: k.victim.steamId
+ })),
+ Date.parse(batch[0].ts)
+ );
const counted = rows
.map((row) => {
const cfg = row.config as KillDistanceConfig;
@@ -225,6 +250,7 @@ async function actOnKillDistance(
countsForDistance(cfg, {
killer: k.killer?.steamId,
suicide: k.suicide,
+ afterOwnDeath: afterDeath.has(k.eventId),
cause: k.cause,
distanceM: k.distanceM
})
diff --git a/src/lib/server/kill-distance.test.ts b/src/lib/server/kill-distance.test.ts
index bffb9edf..95a662e3 100644
--- a/src/lib/server/kill-distance.test.ts
+++ b/src/lib/server/kill-distance.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, test } from 'bun:test';
import {
ACT_AGAIN_MS,
+ AFTER_DEATH_S,
countsForDistance,
killDistanceAction,
killDistanceBanScope,
@@ -8,10 +9,13 @@ import {
killDistanceSettingsKey,
killDistanceStep,
killDistanceVerdict,
+ killsAfterDeath,
+ killsAfterDeathReplay,
matchKey,
validateKillDistance,
type DistanceTracks,
- type KillDistanceConfig
+ type KillDistanceConfig,
+ type LastDeaths
} from './kill-distance';
const DEFIB = 'Id.Item.Defibrillator.Standard';
@@ -29,6 +33,7 @@ const cfg = (c: Partial = {}): KillDistanceConfig => ({
const MIN = 60_000;
const A = '76561198000000001';
const B = '76561198000000002';
+const C = '76561198000000003';
describe('validateKillDistance', () => {
test('needs a weapon, each a kill feed tag', () => {
@@ -122,7 +127,14 @@ describe('validateKillDistance', () => {
describe('countsForDistance', () => {
const c = cfg();
const k = (over: Partial[1]> = {}) =>
- countsForDistance(c, { killer: A, suicide: false, cause: DEFIB, distanceM: 4057, ...over });
+ countsForDistance(c, {
+ killer: A,
+ suicide: false,
+ afterOwnDeath: false,
+ cause: DEFIB,
+ distanceM: 4057,
+ ...over
+ });
test('a player’s kill with a chosen weapon from the distance on, in any case', () => {
expect(k()).toBe(true);
expect(k({ distanceM: 100 })).toBe(true);
@@ -138,12 +150,21 @@ describe('countsForDistance', () => {
expect(k({ killer: null })).toBe(false);
expect(k({ suicide: true })).toBe(false);
});
+ test('not one made just after the killer’s own death', () => {
+ expect(k({ afterOwnDeath: true })).toBe(false);
+ });
test('from 0 m every kill with a chosen weapon or vehicle, one sent without a distance too', () => {
const HUMVEE_M249 = 'Id.Vehicle.WeaponExtension.WHL_05.RingTurret';
const HUMVEE = 'Vehicle.Variant.Land.Wheeled.Humvee.MachineGun';
const any = cfg({ minDistanceM: 0, causes: [HUMVEE_M249, HUMVEE] });
const at = (over: Partial[1]>) =>
- countsForDistance(any, { killer: A, suicide: false, cause: HUMVEE_M249, ...over } as never);
+ countsForDistance(any, {
+ killer: A,
+ suicide: false,
+ afterOwnDeath: false,
+ cause: HUMVEE_M249,
+ ...over
+ } as never);
expect(at({ distanceM: 35 })).toBe(true);
expect(at({ distanceM: 0 })).toBe(true);
expect(at({ distanceM: null })).toBe(true);
@@ -157,6 +178,7 @@ describe('countsForDistance', () => {
countsForDistance(cfg({ minDistanceM: 1, causes: [HUMVEE] }), {
killer: A,
suicide: false,
+ afterOwnDeath: false,
cause: HUMVEE,
distanceM: null
})
@@ -164,6 +186,67 @@ describe('countsForDistance', () => {
});
});
+describe('killsAfterDeath', () => {
+ /** One kill of the feed: `killer` killed `victim` at `t` on the match clock. */
+ let n = 0;
+ const kill = (killer: string | null, victim: string, t: number) => ({
+ eventId: `e${++n}`,
+ eventTime: t,
+ killer,
+ victim
+ });
+ test('a kill within a minute after the killer’s own death: a shell that landed after their vehicle was destroyed', () => {
+ const deaths: LastDeaths = new Map();
+ const before = kill(A, C, 3380);
+ const died = kill(B, A, 3383.97);
+ // the same instant twice: one shell, two victims
+ const shell = [kill(A, B, 3388.23), kill(A, C, 3388.23)];
+ const out = killsAfterDeath(deaths, [before, died, ...shell], 0);
+ // not the kill A made before dying, nor the one that killed A
+ expect([...out]).toEqual(shell.map((k) => k.eventId));
+ });
+ test('after the death, up to the window and not after it, on the match clock', () => {
+ const at = (gap: number) => {
+ const deaths: LastDeaths = new Map();
+ const k = kill(A, B, 1000 + gap);
+ return killsAfterDeath(deaths, [kill(C, A, 1000), k], 0).has(k.eventId);
+ };
+ // two who kill each other at the same instant both count
+ expect(at(0)).toBe(false);
+ expect(at(0.01)).toBe(true);
+ expect(at(AFTER_DEATH_S)).toBe(true);
+ expect(at(AFTER_DEATH_S + 0.01)).toBe(false);
+ });
+ test('a death in an earlier batch counts; a death later on the clock is from the match before', () => {
+ const deaths: LastDeaths = new Map();
+ killsAfterDeath(deaths, [kill(C, A, 3383.97)], 0);
+ const next = kill(A, B, 3388.23);
+ expect([...killsAfterDeath(deaths, [next], 4000)]).toEqual([next.eventId]);
+ // a new match: the clock started again, and A has not died in it
+ const first = kill(A, B, 30);
+ expect(killsAfterDeath(deaths, [first], 30_000).size).toBe(0);
+ });
+ test('a death is forgotten two minutes after it came in; the environment’s kills are deaths too', () => {
+ const deaths: LastDeaths = new Map();
+ killsAfterDeath(deaths, [kill(null, A, 100)], 0);
+ const soon = kill(A, B, 130);
+ expect(
+ killsAfterDeath(new Map(deaths), [soon], 2 * AFTER_DEATH_S * 1000).has(soon.eventId)
+ ).toBe(true);
+ expect(killsAfterDeath(deaths, [soon], 2 * AFTER_DEATH_S * 1000 + 1).size).toBe(0);
+ expect(deaths.has(A)).toBe(false);
+ });
+ test('replayed batch by batch, as they came in', () => {
+ const at = (e: ReturnType, ms: number) => ({ ...e, at: ms });
+ const died = kill(C, A, 500);
+ const shell = kill(A, B, 505);
+ // C killed A and lives on
+ const alive = kill(C, '76561198000000004', 506);
+ const out = killsAfterDeathReplay([at(died, 0), at(shell, 6000), at(alive, 6000)]);
+ expect([...out]).toEqual([shell.eventId]);
+ });
+});
+
describe('matchKey', () => {
test('the stamped match, or the hour a kill came in when none was open', () => {
expect(matchKey(41, 0)).toBe('m41');
diff --git a/src/lib/server/kill-distance.ts b/src/lib/server/kill-distance.ts
index 36591e4f..177bba26 100644
--- a/src/lib/server/kill-distance.ts
+++ b/src/lib/server/kill-distance.ts
@@ -5,7 +5,9 @@
// kill with a weapon or a vehicle it does not allow. Kills are counted per match, as the Team kill
// limit counts them, rather than within minutes: the feed gives no kill a time the panel can trust
// across batches (a batch the game sends again arrives late, with only the match clock), and a
-// match is what the game itself numbers. No database, no game server; the live path
+// match is what the game itself numbers. A kill that comes just after the killer's own death is not
+// counted: the player is dead, and the game credits a vehicle's shell that lands after the vehicle
+// was destroyed to the gunner's own weapon. No database, no game server; the live path
// (feed-events.ts) and the dry run (triggers.ts) run the same step.
import { ApiError, int, str } from './http';
import { causeTags } from './cause-tags';
@@ -91,15 +93,16 @@ function causeSet(cfg: KillDistanceConfig): Set {
}
/**
- * A kill the rule counts: a player's, not a suicide, with one of its weapons, from at least its
- * distance. From 0 m a kill the feed sends without a distance counts too: a vehicle blown up with
- * its crew inside, a roadkill.
+ * A kill the rule counts: a player's, not a suicide, not one made just after the killer's own death
+ * (killsAfterDeath), with one of its weapons, from at least its distance. From 0 m a kill the feed
+ * sends without a distance counts too: a vehicle blown up with its crew inside, a roadkill.
*/
export function countsForDistance(
cfg: KillDistanceConfig,
k: {
killer: string | null | undefined;
suicide: boolean;
+ afterOwnDeath: boolean;
cause: string | null;
distanceM: number | null;
}
@@ -107,6 +110,7 @@ export function countsForDistance(
return (
!!k.killer &&
!k.suicide &&
+ !k.afterOwnDeath &&
(cfg.minDistanceM <= 0 ||
(typeof k.distanceM === 'number' && k.distanceM >= cfg.minDistanceM)) &&
!!k.cause &&
@@ -114,6 +118,61 @@ export function countsForDistance(
);
}
+/**
+ * How long after a player's own death a kill the feed credits to them is not counted, in seconds of
+ * the match clock. A dead player is not firing: the kill is a round or a charge already on its way,
+ * and its cause cannot be trusted. A vehicle's shell that lands after the vehicle was destroyed
+ * comes credited to the gunner's own weapon, from wherever the gunner is by then (an M4 kill from
+ * 2,300 m). On the hosted feed every such kill came within 35 s of the death.
+ */
+export const AFTER_DEATH_S = 60;
+/** How long a death is kept on the panel's clock: past the window, with room for a late batch. */
+const DEATH_KEPT_MS = 2 * AFTER_DEATH_S * 1000;
+
+/** Each player's last death the feed told of: on the match clock (s), and when it came in (ms). */
+export type LastDeaths = Map;
+
+export interface FeedDeath {
+ eventId: string;
+ /** seconds on the match clock */
+ eventTime: number;
+ killer: string | null | undefined;
+ victim: string;
+}
+
+/**
+ * Takes in a batch of kills that came in at `at` (ms), in the order the game played them: notes
+ * each victim's death, and returns the kills made within AFTER_DEATH_S after the killer's own last
+ * death, by event id. Two players who kill each other at the same instant both count. The match
+ * clock starts again at a new match, so a death later on it than the kill is not the one before it.
+ */
+export function killsAfterDeath(deaths: LastDeaths, inOrder: FeedDeath[], at: number): Set {
+ for (const [steamId, d] of deaths) if (at - d.at > DEATH_KEPT_MS) deaths.delete(steamId);
+ const out = new Set();
+ for (const k of inOrder) {
+ const d = k.killer ? deaths.get(k.killer) : undefined;
+ if (d && k.eventTime > d.clock && k.eventTime - d.clock <= AFTER_DEATH_S) out.add(k.eventId);
+ deaths.set(k.victim, { clock: k.eventTime, at });
+ }
+ return out;
+}
+
+/**
+ * killsAfterDeath over a window, batch by batch: `events` in the order they came in (`at`, then
+ * the match clock), the kills of one batch sharing their `at`.
+ */
+export function killsAfterDeathReplay(events: (FeedDeath & { at: number })[]): Set {
+ const deaths: LastDeaths = new Map();
+ const out = new Set();
+ for (let i = 0; i < events.length;) {
+ let j = i;
+ while (j < events.length && events[j].at === events[i].at) j++;
+ for (const id of killsAfterDeath(deaths, events.slice(i, j), events[i].at)) out.add(id);
+ i = j;
+ }
+ return out;
+}
+
/**
* Which match a kill is counted in: the match it was stamped with on arrival (feed.ts), or, for a
* kill that came in while no match was open, the hour it came in.
diff --git a/src/lib/server/triggers.ts b/src/lib/server/triggers.ts
index ba2d86e7..02298eaa 100644
--- a/src/lib/server/triggers.ts
+++ b/src/lib/server/triggers.ts
@@ -118,6 +118,7 @@ import {
killDistanceReplay,
killDistanceSettingsKey,
killDistanceVerdict,
+ killsAfterDeathReplay,
matchKey,
type DistanceKill,
type KillDistanceConfig
@@ -2163,26 +2164,67 @@ export async function dryRun(
const far = c.minDistanceM > 0 ? sql`AND distance_m >= ${c.minDistanceM}` : sql``;
const rows = await env.db.execute<{
ts: Date;
+ eventId: string;
+ eventTime: number;
steamId: string;
name: string | null;
+ victim: string;
cause: string | null;
distanceM: number | null;
matchRow: number | null;
}>(sql`
- SELECT ts, killer_steam_id AS "steamId", killer_name AS name, cause,
- distance_m AS "distanceM", match_row AS "matchRow"
+ SELECT ts, event_id AS "eventId", event_time AS "eventTime", killer_steam_id AS "steamId",
+ killer_name AS name, victim_steam_id AS victim, cause, distance_m AS "distanceM",
+ match_row AS "matchRow"
FROM kills
WHERE event_type = 'killed' AND parsed_kill
AND server_id = ${server.id} AND ts >= ${since}
AND killer_steam_id IS NOT NULL AND NOT suicide ${far}
AND lower(cause) IN (SELECT jsonb_array_elements_text(${JSON.stringify(c.causes.map((x) => x.toLowerCase()))}::text::jsonb))
ORDER BY ts ASC, event_time ASC LIMIT ${KILL_RATE_REPLAY_MAX}`);
+ // Those killers' own deaths from a little before, so a kill just after one is left out as
+ // the live rule leaves it out.
+ const killers = new Set();
+ for (const r of rows) killers.add(r.steamId);
+ const deaths = killers.size
+ ? await env.db.execute<{ ts: Date; eventId: string; eventTime: number; victim: string }>(sql`
+ SELECT ts, event_id AS "eventId", event_time AS "eventTime", victim_steam_id AS victim
+ FROM kills
+ WHERE server_id = ${server.id} AND ts >= ${new Date(since.getTime() - 120_000)}
+ AND victim_steam_id IN (SELECT jsonb_array_elements_text(${JSON.stringify([...killers])}::text::jsonb))
+ ORDER BY ts ASC, event_time ASC LIMIT ${KILL_RATE_REPLAY_MAX}`)
+ : [];
+ const event = (
+ ts: Date,
+ eventId: string,
+ eventTime: number,
+ killer: string | null,
+ victim: string
+ ) => ({ eventId, eventTime: Number(eventTime), killer, victim, at: new Date(ts).getTime() });
+ const events: ReturnType[] = [];
+ for (const r of rows) events.push(event(r.ts, r.eventId, r.eventTime, r.steamId, r.victim));
+ for (const d of deaths) events.push(event(d.ts, d.eventId, d.eventTime, null, d.victim));
+ const afterDeath = killsAfterDeathReplay(
+ events.sort((a, b) => a.at - b.at || a.eventTime - b.eventTime)
+ );
const counted: DistanceKill[] = [];
+ let afterDeathInWindow = 0;
for (const r of rows) {
+ const at = new Date(r.ts).getTime();
const distanceM = r.distanceM === null ? null : Number(r.distanceM);
- if (!countsForDistance(c, { killer: r.steamId, suicide: false, cause: r.cause, distanceM }))
+ const afterOwnDeath = afterDeath.has(r.eventId);
+ if (
+ !countsForDistance(c, {
+ killer: r.steamId,
+ suicide: false,
+ afterOwnDeath,
+ cause: r.cause,
+ distanceM
+ })
+ ) {
+ if (afterOwnDeath && at >= from.getTime()) afterDeathInWindow++;
continue;
- const at = new Date(r.ts).getTime();
+ }
counted.push({
at,
match: matchKey(r.matchRow === null ? null : Number(r.matchRow), at),
@@ -2207,6 +2249,10 @@ export async function dryRun(
result.notes.push(
`${inWindow} kill${inWindow === 1 ? '' : 's'} with ${c.causes.length === 1 ? causeLabel(c.causes[0]) : 'the chosen weapons'} ${c.minDistanceM > 0 ? `from ${c.minDistanceM} m or more` : 'at any distance'} in the window, counted per match.`
);
+ if (afterDeathInWindow)
+ result.notes.push(
+ `${afterDeathInWindow} more came within a minute of the killer’s own death and ${afterDeathInWindow === 1 ? 'is' : 'are'} not counted: a vehicle’s shell that lands after the vehicle was destroyed comes credited to the gunner’s own weapon.`
+ );
if (rows.length >= KILL_RATE_REPLAY_MAX)
result.notes.push(
`Replayed the first ${KILL_RATE_REPLAY_MAX.toLocaleString('en')} such kills only.`
diff --git a/src/test/kill-distance-rule.test.ts b/src/test/kill-distance-rule.test.ts
index 5b778663..e501acd7 100644
--- a/src/test/kill-distance-rule.test.ts
+++ b/src/test/kill-distance-rule.test.ts
@@ -47,12 +47,23 @@ const SNIPER = '76561198000000803';
const VICTIM = '76561198000000804';
/** a player the server does not list */
const GONE = '76561198000000805';
+/** a gunner whose vehicle is destroyed with a shell in the air, and the enemy who destroys it */
+const GUNNER = '76561198000000806';
+const ENEMY = '76561198000000807';
+const M4 = 'Id.Item.M4';
+const ENEMY_GUN = 'Id.Vehicle.WeaponExtension.STN_05.MainBarrel';
const REASON = 'Impossible kill: Defibrillator from 4057 m.';
/** One `killed` event as the game posts it; distance in metres here, centimetres on the wire. */
const ev = (
killer: string | null,
- o: { cause?: string; distanceM?: number | null; eventTime?: number; suicide?: boolean } = {}
+ o: {
+ cause?: string;
+ distanceM?: number | null;
+ eventTime?: number;
+ suicide?: boolean;
+ victim?: string;
+ } = {}
) => ({
eventId: newId(),
type: 'killed',
@@ -60,8 +71,8 @@ const ev = (
matchId: 'boot',
mapName: 'Kavkazi',
...(killer ? { killerName: `p${killer.slice(-3)}`, killerSteamId: killer } : {}),
- victimName: 'victim',
- victimSteamId: VICTIM,
+ victimName: o.victim ? `p${o.victim.slice(-3)}` : 'victim',
+ victimSteamId: o.victim ?? VICTIM,
cause: o.cause ?? DEFIB,
...(o.distanceM === null ? {} : { distance: (o.distanceM ?? 4057) * 100 }),
contextTags: o.suicide ? ['Meta.Progression.Context.Player.KillContext.Suicide'] : []
@@ -272,6 +283,39 @@ describe.skipIf(!hasTestDb)('Kill distance rule, live', () => {
expect(await rowsOf(id)).toHaveLength(0);
});
+ test('a kill just after the killer’s own death is not counted: a shell that landed after their vehicle was destroyed', async () => {
+ const w = await seedWorld(env);
+ await openMatch(w.server.id);
+ const id = await rule(w, w.server.id, {
+ causes: [M4],
+ minDistanceM: 1300,
+ count: 1,
+ action: 'kick'
+ });
+ // the gunner's vehicle destroyed with them in it, then their shell landing on two players
+ // at once, credited to the rifle they carry
+ await post(w.server.id, [
+ ev(ENEMY, { cause: ENEMY_GUN, distanceM: 2297, eventTime: 3383.97, victim: GUNNER })
+ ]);
+ await post(w.server.id, [
+ ev(GUNNER, { cause: M4, distanceM: 2295, eventTime: 3388.23, victim: ENEMY }),
+ ev(GUNNER, { cause: M4, distanceM: 2280, eventTime: 3388.23 })
+ ]);
+ // another gunner's death and their shell in one batch
+ await post(w.server.id, [
+ ev(ENEMY, { cause: ENEMY_GUN, distanceM: 2400, eventTime: 3390, victim: SNIPER }),
+ ev(SNIPER, { cause: M4, distanceM: 2410, eventTime: 3396 })
+ ]);
+ expect(await rowsOf(id)).toHaveLength(0);
+ // a living player's kill from as far counts, and so does the gunner's a minute on
+ await post(w.server.id, [ev(CHEAT, { cause: M4, distanceM: 2300, eventTime: 3400 })]);
+ await post(w.server.id, [ev(GUNNER, { cause: M4, distanceM: 2300, eventTime: 3445 })]);
+ expect((await rowsOf(id)).map((r) => [r.steamId, r.okMessage])).toEqual([
+ [CHEAT, 'Kicked p801: M4 kill from 2300 m'],
+ [GUNNER, 'Kicked p806: M4 kill from 2300 m']
+ ]);
+ });
+
test('a warning on a Humvee’s guns from 0 m: whispered once in the cooldown, a kill without a distance too, in the panel’s words', async () => {
const w = await seedWorld(env);
const [server] = await env.db.select().from(servers).where(eq(servers.id, w.server.id));
@@ -865,6 +909,61 @@ describe.skipIf(!hasTestDb)('Kill distance rule, live', () => {
);
});
+ test('the dry run leaves out a kill just after the killer’s own death, and says so', async () => {
+ const w = await seedWorld(env);
+ const at = Date.now() - 600_000;
+ const row = (
+ killer: string,
+ victim: string,
+ cause: string,
+ distanceM: number,
+ eventTime: number,
+ receivedMs: number
+ ) => ({
+ ts: new Date(at + receivedMs),
+ serverId: w.server.id,
+ eventId: newId(),
+ instanceId: 'i',
+ matchId: 'm',
+ matchRow: 9201,
+ eventTime,
+ map: 'Kavkazi',
+ killerSteamId: killer,
+ killerName: `p${killer.slice(-3)}`,
+ victimSteamId: victim,
+ victimName: `p${victim.slice(-3)}`,
+ cause,
+ distanceM,
+ tags: []
+ });
+ await env.db
+ .insert(kills)
+ .values([
+ row(ENEMY, GUNNER, ENEMY_GUN, 2297, 3383.97, 0),
+ row(GUNNER, ENEMY, M4, 2295, 3388.23, 4000),
+ row(GUNNER, VICTIM, M4, 2280, 3388.23, 4000),
+ row(CHEAT, VICTIM, M4, 2300, 3394, 10_000),
+ row(GUNNER, VICTIM, M4, 2300, 3445, 62_000)
+ ]);
+ const server = { ...w.server, name: 'one' } as Parameters[1];
+ const r = await dryRun(env, server, 'kill_distance', {
+ causes: [M4],
+ minDistanceM: 1300,
+ count: 1,
+ action: 'kick'
+ });
+ expect(r.items.map((i) => i.text)).toEqual([
+ `kick p801 (${CHEAT}): M4 kill from 2300 m`,
+ `kick p806 (${GUNNER}): M4 kill from 2300 m`
+ ]);
+ expect(r.notes).toContain(
+ '2 kills with M4 from 1300 m or more in the window, counted per match.'
+ );
+ expect(r.notes).toContain(
+ '2 more came within a minute of the killer’s own death and are not counted: a vehicle’s shell that lands after the vehicle was destroyed comes credited to the gunner’s own weapon.'
+ );
+ });
+
test('nothing a person or a key can call runs a rule’s ban, flag or kill', async () => {
const w = await seedWorld(env);
const mod = await import(
From ce95bc040aea5bfaefa6fd03fb390aefee0a3a9e Mon Sep 17 00:00:00 2001
From: James Causon
Date: Mon, 5 Oct 2026 23:08:46 +0100
Subject: [PATCH 019/102] Kill distance watch: each kill a rule leaves out for
coming just after the killer's own death is noted in the audit trail ("Not
counted: M4 kill from 2295 m by ..., 4.3 s after they died"), so staff can
see why the rule let it be. The note is a panel action like the flag, sends
nothing to the game, is kept off Discord, and reads "not counted" in the
Automation tab's list
---
src/lib/server/feed-events.ts | 50 +++++++++++++---
src/lib/server/kill-distance.test.ts | 16 ++++-
src/lib/server/kill-distance.ts | 40 +++++++++++--
src/lib/server/outbox.ts | 13 ++--
src/lib/server/webhook-delivery.ts | 9 ++-
.../(app)/server/[id]/automation/+page.svelte | 4 +-
src/test/kill-distance-rule.test.ts | 59 ++++++++++++++++---
src/test/two-teams-discord.test.ts | 33 ++++++++++-
8 files changed, 187 insertions(+), 37 deletions(-)
diff --git a/src/lib/server/feed-events.ts b/src/lib/server/feed-events.ts
index f9e697e9..6a2144c9 100644
--- a/src/lib/server/feed-events.ts
+++ b/src/lib/server/feed-events.ts
@@ -36,10 +36,12 @@ import {
} from './kill-rate';
import {
countsForDistance,
+ KILL_DISTANCE_SKIP,
killDistanceSettingsKey,
killDistanceStep,
killsAfterDeath,
matchKey,
+ notCountedMessage,
type DistanceTrack,
type DistanceTracks,
type KillDistanceConfig,
@@ -246,27 +248,57 @@ async function actOnKillDistance(
const counted = rows
.map((row) => {
const cfg = row.config as KillDistanceConfig;
- const hits = inOrder.filter((k) =>
- countsForDistance(cfg, {
+ const hits: KillView[] = [];
+ // the kills it would have counted but for the killer's own death just before
+ const leftOut: { k: KillView; afterS: number }[] = [];
+ for (const k of inOrder) {
+ const kill = {
killer: k.killer?.steamId,
suicide: k.suicide,
- afterOwnDeath: afterDeath.has(k.eventId),
cause: k.cause,
distanceM: k.distanceM
- })
- );
- return { row, cfg, hits };
+ };
+ const afterS = afterDeath.get(k.eventId);
+ if (countsForDistance(cfg, { ...kill, afterOwnDeath: afterS !== undefined })) hits.push(k);
+ else if (afterS !== undefined && countsForDistance(cfg, { ...kill, afterOwnDeath: false }))
+ leftOut.push({ k, afterS });
+ }
+ return { row, cfg, hits, leftOut };
})
- .filter((r) => r.hits.length);
+ .filter((r) => r.hits.length || r.leftOut.length);
if (!counted.length) return;
const now = Date.now();
- const match = matchKey(await stampedMatch(env, serverId, batch), Date.parse(batch[0].ts));
+ const match = counted.some((r) => r.hits.length)
+ ? matchKey(await stampedMatch(env, serverId, batch), Date.parse(batch[0].ts))
+ : '';
if (!mine) distanceMemory.set(serverId, (mine = new Map()));
const out: Evaluation = { intents: [], updates: [] };
// Acting starts the player's hold; if the action cannot be queued, neither does the hold.
const acted: { track: DistanceTrack; before: number | null }[] = [];
- for (const { row, cfg, hits } of counted) {
+ for (const { row, cfg, hits, leftOut } of counted) {
const settings = killDistanceSettingsKey(cfg);
+ // Each left-out kill is noted in the audit trail, so staff can see why the rule let it be.
+ for (const { k, afterS } of leftOut) {
+ const steamId = k.killer!.steamId;
+ const name = k.killer!.name || steamId;
+ out.intents.push({
+ trigger: row,
+ action: KILL_DISTANCE_SKIP,
+ params: { rule: settings },
+ target: steamId,
+ okMessage: notCountedMessage(name, k.cause, k.distanceM, afterS),
+ detail: {
+ name,
+ cause: k.cause,
+ distanceM: k.distanceM,
+ afterDeathS: Math.round(afterS * 10) / 10,
+ eventId: k.eventId
+ },
+ steamId: null,
+ dedupeKey: [row.id, steamId, k.eventId, 'skip'].join(':')
+ });
+ }
+ if (!hits.length) continue;
let entry = mine.get(row.id);
if (!entry || entry.match !== match || entry.settings !== settings) {
entry = { settings, match, tracks: new Map() };
diff --git a/src/lib/server/kill-distance.test.ts b/src/lib/server/kill-distance.test.ts
index 95a662e3..b7857666 100644
--- a/src/lib/server/kill-distance.test.ts
+++ b/src/lib/server/kill-distance.test.ts
@@ -12,6 +12,7 @@ import {
killsAfterDeath,
killsAfterDeathReplay,
matchKey,
+ notCountedMessage,
validateKillDistance,
type DistanceTracks,
type KillDistanceConfig,
@@ -202,8 +203,9 @@ describe('killsAfterDeath', () => {
// the same instant twice: one shell, two victims
const shell = [kill(A, B, 3388.23), kill(A, C, 3388.23)];
const out = killsAfterDeath(deaths, [before, died, ...shell], 0);
- // not the kill A made before dying, nor the one that killed A
- expect([...out]).toEqual(shell.map((k) => k.eventId));
+ // not the kill A made before dying, nor the one that killed A; each with how long after
+ expect([...out.keys()]).toEqual(shell.map((k) => k.eventId));
+ expect([...out.values()].map((s) => s.toFixed(2))).toEqual(['4.26', '4.26']);
});
test('after the death, up to the window and not after it, on the match clock', () => {
const at = (gap: number) => {
@@ -221,7 +223,7 @@ describe('killsAfterDeath', () => {
const deaths: LastDeaths = new Map();
killsAfterDeath(deaths, [kill(C, A, 3383.97)], 0);
const next = kill(A, B, 3388.23);
- expect([...killsAfterDeath(deaths, [next], 4000)]).toEqual([next.eventId]);
+ expect([...killsAfterDeath(deaths, [next], 4000).keys()]).toEqual([next.eventId]);
// a new match: the clock started again, and A has not died in it
const first = kill(A, B, 30);
expect(killsAfterDeath(deaths, [first], 30_000).size).toBe(0);
@@ -236,6 +238,14 @@ describe('killsAfterDeath', () => {
expect(killsAfterDeath(deaths, [soon], 2 * AFTER_DEATH_S * 1000 + 1).size).toBe(0);
expect(deaths.has(A)).toBe(false);
});
+ test('what the audit trail says of a kill left out', () => {
+ expect(notCountedMessage('Gunner', 'Id.Item.M4', 2295.25, 4.26)).toBe(
+ 'Not counted: M4 kill from 2295 m by Gunner, 4.3 s after they died'
+ );
+ expect(
+ notCountedMessage('Gunner', 'Vehicle.Variant.Land.Wheeled.Humvee.Default', null, 12)
+ ).toBe('Not counted: Humvee kill by Gunner, 12.0 s after they died');
+ });
test('replayed batch by batch, as they came in', () => {
const at = (e: ReturnType, ms: number) => ({ ...e, at: ms });
const died = kill(C, A, 500);
diff --git a/src/lib/server/kill-distance.ts b/src/lib/server/kill-distance.ts
index 177bba26..afa5f3a0 100644
--- a/src/lib/server/kill-distance.ts
+++ b/src/lib/server/kill-distance.ts
@@ -17,6 +17,11 @@ import type { BanScope } from './rule-ban';
/** The outbox action of a Kill distance flag: a panel action, nothing is sent to the game. */
export const KILL_DISTANCE_FLAG = 'kill_distance_flag';
+/**
+ * The outbox action that notes a kill a rule left out because it came just after the killer's own
+ * death (killsAfterDeath): a panel action whose audit row is the whole delivery, kept off Discord.
+ */
+export const KILL_DISTANCE_SKIP = 'kill_distance_skip';
export type KillDistanceAction = 'flag' | 'warn' | 'kill' | 'kick' | 'ban';
@@ -143,20 +148,43 @@ export interface FeedDeath {
/**
* Takes in a batch of kills that came in at `at` (ms), in the order the game played them: notes
* each victim's death, and returns the kills made within AFTER_DEATH_S after the killer's own last
- * death, by event id. Two players who kill each other at the same instant both count. The match
- * clock starts again at a new match, so a death later on it than the kill is not the one before it.
+ * death, by event id, with how many seconds after it. Two players who kill each other at the same
+ * instant both count. The match clock starts again at a new match, so a death later on it than the
+ * kill is not the one before it.
*/
-export function killsAfterDeath(deaths: LastDeaths, inOrder: FeedDeath[], at: number): Set {
+export function killsAfterDeath(
+ deaths: LastDeaths,
+ inOrder: FeedDeath[],
+ at: number
+): Map {
for (const [steamId, d] of deaths) if (at - d.at > DEATH_KEPT_MS) deaths.delete(steamId);
- const out = new Set();
+ const out = new Map();
for (const k of inOrder) {
const d = k.killer ? deaths.get(k.killer) : undefined;
- if (d && k.eventTime > d.clock && k.eventTime - d.clock <= AFTER_DEATH_S) out.add(k.eventId);
+ if (d && k.eventTime > d.clock && k.eventTime - d.clock <= AFTER_DEATH_S)
+ out.set(k.eventId, k.eventTime - d.clock);
deaths.set(k.victim, { clock: k.eventTime, at });
}
return out;
}
+/**
+ * What the audit trail says of a kill a rule left out because it came just after the killer's own
+ * death: the kill, by whom, and how long after they died.
+ */
+export function notCountedMessage(
+ name: string,
+ cause: string | null,
+ distanceM: number | null,
+ afterS: number
+): string {
+ const what =
+ distanceM === null
+ ? `${causeLabel(cause)} kill`
+ : `${causeLabel(cause)} kill from ${Math.round(distanceM)} m`;
+ return `Not counted: ${what} by ${name}, ${afterS.toFixed(1)} s after they died`;
+}
+
/**
* killsAfterDeath over a window, batch by batch: `events` in the order they came in (`at`, then
* the match clock), the kills of one batch sharing their `at`.
@@ -167,7 +195,7 @@ export function killsAfterDeathReplay(events: (FeedDeath & { at: number })[]): S
for (let i = 0; i < events.length;) {
let j = i;
while (j < events.length && events[j].at === events[i].at) j++;
- for (const id of killsAfterDeath(deaths, events.slice(i, j), events[i].at)) out.add(id);
+ for (const id of killsAfterDeath(deaths, events.slice(i, j), events[i].at).keys()) out.add(id);
i = j;
}
return out;
diff --git a/src/lib/server/outbox.ts b/src/lib/server/outbox.ts
index 3528e614..84665f6a 100644
--- a/src/lib/server/outbox.ts
+++ b/src/lib/server/outbox.ts
@@ -32,7 +32,7 @@ import { gateway } from './gateway';
import { deliveries } from './metrics';
import { NAME_FLAG } from './name-filter';
import { KILL_RATE_FLAG } from './kill-rate';
-import { KILL_DISTANCE_FLAG } from './kill-distance';
+import { KILL_DISTANCE_FLAG, KILL_DISTANCE_SKIP } from './kill-distance';
import { writeAudit } from './audit';
import { PANEL_BAN, type PanelBanParams } from './rule-ban';
import { killAndTell, RULE_KILL, RULE_KILL_MAX_AGE_MS } from './rule-kill';
@@ -53,7 +53,8 @@ const PANEL_ACTIONS = new Set([
PANEL_BAN,
NAME_FLAG,
KILL_RATE_FLAG,
- KILL_DISTANCE_FLAG
+ KILL_DISTANCE_FLAG,
+ KILL_DISTANCE_SKIP
]);
/** The most rows one claim takes, oldest first. */
@@ -470,12 +471,14 @@ async function release(env: Env, row: OutboxRow): Promise {
async function deliverOne(env: Env, row: OutboxRow): Promise<'held' | 'refused' | void> {
if (row.action === 'seed_reward') return deliverSeedReward(env, row);
if (row.action === PANEL_BAN) return deliverPanelBan(env, row);
- // An alert-only Name filter match or a Kill rate or Kill distance flag: the audit row (and its
- // Discord card) is the whole delivery.
+ // An alert-only Name filter match, a Kill rate or Kill distance flag, or a Kill distance rule's
+ // note of a kill it left out: the audit row (and, but for the note, its Discord card) is the
+ // whole delivery.
if (
row.action === NAME_FLAG ||
row.action === KILL_RATE_FLAG ||
- row.action === KILL_DISTANCE_FLAG
+ row.action === KILL_DISTANCE_FLAG ||
+ row.action === KILL_DISTANCE_SKIP
) {
const holds = await stillHolds(env, row);
if (holds === null) return release(env, row);
diff --git a/src/lib/server/webhook-delivery.ts b/src/lib/server/webhook-delivery.ts
index 66fe7daf..5975254b 100644
--- a/src/lib/server/webhook-delivery.ts
+++ b/src/lib/server/webhook-delivery.ts
@@ -8,6 +8,7 @@ import { decryptSecret } from './crypto';
import { playerMarks, servers, webhooks, type AuditRow, type WebhookRow } from './db/schema';
import { OWNERS_ROWS } from './audit-rows';
import { escapeMarkdown } from './webhook-status-core';
+import { KILL_DISTANCE_SKIP } from './kill-distance';
import { causeLabel } from '$lib/causes';
import type { KillView } from '$lib/types';
@@ -468,13 +469,19 @@ export async function recordResult(env: Env, id: string, result: PostResult): Pr
* rule's card out of the webhook's queue.
*/
const QUIET_WHEN_OK = new Set(['trigger.two_teams', 'trigger.afk_protection']);
+/** A delivery that only notes what a rule saw and let be: the audit trail keeps it, Discord does not. */
+const NOTES = new Set([KILL_DISTANCE_SKIP]);
+const isNote = (row: AuditRow): boolean => {
+ const action = (row.detail as { rconAction?: unknown } | null)?.rconAction;
+ return typeof action === 'string' && NOTES.has(action);
+};
/** Fans one audit row out to the org's webhooks that want its event class. Never throws. */
export async function notifyWebhooks(env: Env, row: AuditRow): Promise {
try {
const event = classify(row);
if (!event) return;
- if (row.outcome === 'ok' && QUIET_WHEN_OK.has(row.action)) return;
+ if (row.outcome === 'ok' && (QUIET_WHEN_OK.has(row.action) || isNote(row))) return;
const orgId = row.orgId ?? (row.serverId ? await orgOfServer(env, row.serverId) : null);
if (!orgId) return;
const hooks = await enabledWebhooks(env, orgId);
diff --git a/src/routes/(app)/server/[id]/automation/+page.svelte b/src/routes/(app)/server/[id]/automation/+page.svelte
index 7024c069..b65a9909 100644
--- a/src/routes/(app)/server/[id]/automation/+page.svelte
+++ b/src/routes/(app)/server/[id]/automation/+page.svelte
@@ -220,7 +220,9 @@
? 'kill everyone'
: action === 'rule_kill'
? 'kill'
- : action;
+ : action === 'kill_distance_skip'
+ ? 'not counted'
+ : action;
const label = (kind: TriggerKind) => KINDS.find((k) => k.kind === kind)?.label ?? kind;
const blurb = (kind: TriggerKind) => KINDS.find((k) => k.kind === kind)?.blurb ?? '';
/** Why a kind cannot run on this server yet, or '' when it can. */
diff --git a/src/test/kill-distance-rule.test.ts b/src/test/kill-distance-rule.test.ts
index e501acd7..ee9fd8e5 100644
--- a/src/test/kill-distance-rule.test.ts
+++ b/src/test/kill-distance-rule.test.ts
@@ -28,7 +28,7 @@ import { forgetMemory, memoryFor, observeServer } from '$lib/server/observe';
import { startDelivery, stopDelivery } from '$lib/server/outbox';
import { WardogsClient } from '$lib/server/rcon';
import { dryRun, invalidateTriggers, validateConfig } from '$lib/server/triggers';
-import { KILL_DISTANCE_FLAG } from '$lib/server/kill-distance';
+import { KILL_DISTANCE_FLAG, KILL_DISTANCE_SKIP } from '$lib/server/kill-distance';
import { PANEL_BAN } from '$lib/server/rule-ban';
import { RULE_KILL } from '$lib/server/rule-kill';
import { newId } from '$lib/server/http';
@@ -283,14 +283,14 @@ describe.skipIf(!hasTestDb)('Kill distance rule, live', () => {
expect(await rowsOf(id)).toHaveLength(0);
});
- test('a kill just after the killer’s own death is not counted: a shell that landed after their vehicle was destroyed', async () => {
+ test('a kill just after the killer’s own death is not counted, and the audit trail says so: a shell that landed after their vehicle was destroyed', async () => {
const w = await seedWorld(env);
await openMatch(w.server.id);
const id = await rule(w, w.server.id, {
causes: [M4],
minDistanceM: 1300,
count: 1,
- action: 'kick'
+ action: 'flag'
});
// the gunner's vehicle destroyed with them in it, then their shell landing on two players
// at once, credited to the rifle they carry
@@ -306,14 +306,55 @@ describe.skipIf(!hasTestDb)('Kill distance rule, live', () => {
ev(ENEMY, { cause: ENEMY_GUN, distanceM: 2400, eventTime: 3390, victim: SNIPER }),
ev(SNIPER, { cause: M4, distanceM: 2410, eventTime: 3396 })
]);
- expect(await rowsOf(id)).toHaveLength(0);
+ // nothing done to anyone; each kill left out is a note, sent nothing and asked of no one
+ const notes = await rowsOf(id);
+ expect(notes.map((r) => [r.action, r.target, r.steamId, r.okMessage])).toEqual([
+ [
+ KILL_DISTANCE_SKIP,
+ GUNNER,
+ null,
+ 'Not counted: M4 kill from 2295 m by p806, 4.3 s after they died'
+ ],
+ [
+ KILL_DISTANCE_SKIP,
+ GUNNER,
+ null,
+ 'Not counted: M4 kill from 2280 m by p806, 4.3 s after they died'
+ ],
+ [
+ KILL_DISTANCE_SKIP,
+ SNIPER,
+ null,
+ 'Not counted: M4 kill from 2410 m by p803, 6.0 s after they died'
+ ]
+ ]);
+ // written while the rule holds the settings it was decided under, like a flag
+ expect((notes[0].params as { rule?: string }).rule).toMatch(/^[A-Za-z0-9_-]{16}$/);
+ const done = await deliver(...notes.map((r) => r.id));
+ expect(done.map((d) => [d.state, d.outcome])).toEqual(
+ notes.map((r) => ['delivered', r.okMessage])
+ );
+ const audited = await auditSoon(w.server.id, 'trigger.kill_distance', SNIPER);
+ expect(audited.map((a) => [a.outcome, a.category, a.message])).toEqual([
+ ['ok', 'trigger', 'Not counted: M4 kill from 2410 m by p803, 6.0 s after they died']
+ ]);
+ expect(audited[0].detail).toMatchObject({
+ rconAction: KILL_DISTANCE_SKIP,
+ cause: M4,
+ distanceM: 2410,
+ afterDeathS: 6
+ });
// a living player's kill from as far counts, and so does the gunner's a minute on
await post(w.server.id, [ev(CHEAT, { cause: M4, distanceM: 2300, eventTime: 3400 })]);
await post(w.server.id, [ev(GUNNER, { cause: M4, distanceM: 2300, eventTime: 3445 })]);
- expect((await rowsOf(id)).map((r) => [r.steamId, r.okMessage])).toEqual([
- [CHEAT, 'Kicked p801: M4 kill from 2300 m'],
- [GUNNER, 'Kicked p806: M4 kill from 2300 m']
+ const acted = (await rowsOf(id)).filter((r) => r.action !== KILL_DISTANCE_SKIP);
+ expect(acted.map((r) => [r.action, r.steamId, r.okMessage])).toEqual([
+ [KILL_DISTANCE_FLAG, CHEAT, 'Flagged p801: M4 kill from 2300 m'],
+ [KILL_DISTANCE_FLAG, GUNNER, 'Flagged p806: M4 kill from 2300 m']
]);
+ // the notes and flags asked nothing of the game
+ expect(sent.filter(([s]) => s === w.server.id)).toEqual([]);
+ expect(kicked.filter(([s]) => s === w.server.id)).toEqual([]);
});
test('a warning on a Humvee’s guns from 0 m: whispered once in the cooldown, a kill without a distance too, in the panel’s words', async () => {
@@ -964,7 +1005,7 @@ describe.skipIf(!hasTestDb)('Kill distance rule, live', () => {
);
});
- test('nothing a person or a key can call runs a rule’s ban, flag or kill', async () => {
+ test('nothing a person or a key can call runs a rule’s ban, flag, note or kill', async () => {
const w = await seedWorld(env);
const mod = await import(
join(
@@ -979,7 +1020,7 @@ describe.skipIf(!hasTestDb)('Kill distance rule, live', () => {
'+server.ts'
)
);
- for (const action of [PANEL_BAN, KILL_DISTANCE_FLAG, RULE_KILL]) {
+ for (const action of [PANEL_BAN, KILL_DISTANCE_FLAG, KILL_DISTANCE_SKIP, RULE_KILL]) {
const r = await callApi(mod.POST, w.users.owner, {
method: 'POST',
params: { id: w.server.id, action },
diff --git a/src/test/two-teams-discord.test.ts b/src/test/two-teams-discord.test.ts
index 1f50c640..736ceeed 100644
--- a/src/test/two-teams-discord.test.ts
+++ b/src/test/two-teams-discord.test.ts
@@ -1,5 +1,6 @@
// A Two-team mode sort is a hundred moves at every match start: the audit trail keeps each, and the
-// org's Automation webhook hears only of the ones that fail. Other rules post as before.
+// org's Automation webhook hears only of the ones that fail. A Kill distance rule's note of a kill it
+// left out stays in the audit trail. Other rules post as before.
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { and, eq } from 'drizzle-orm';
import type { Env } from '$lib/server/env';
@@ -39,7 +40,12 @@ describe.skipIf(!hasTestDb)('Two-team mode on Discord', () => {
resetWebhookQueues();
});
- const delivery = (action: string, outcome: 'ok' | 'error', message: string) =>
+ const delivery = (
+ action: string,
+ outcome: 'ok' | 'error',
+ message: string,
+ detail?: Record
+ ) =>
writeAudit(env, null, {
actorName: 'trigger: rule',
server: { id: w.server.id, name: 'Server' },
@@ -48,7 +54,8 @@ describe.skipIf(!hasTestDb)('Two-team mode on Discord', () => {
action,
target: '76561198000000951',
outcome,
- message
+ message,
+ detail
});
test('moves that land stay off Discord; one that fails, and other rules, are posted', async () => {
@@ -79,4 +86,24 @@ describe.skipIf(!hasTestDb)('Two-team mode on Discord', () => {
);
expect(titles).toEqual(['Trigger · AFK protection']);
});
+
+ test('a Kill distance rule’s note of a kill it left out stays off Discord; its flag is posted', async () => {
+ posts.length = 0;
+ await delivery(
+ 'trigger.kill_distance',
+ 'ok',
+ 'Not counted: M4 kill from 2295 m by p806, 4.3 s after they died',
+ {
+ rconAction: 'kill_distance_skip'
+ }
+ );
+ await delivery('trigger.kill_distance', 'ok', 'Flagged p801: M4 kill from 2300 m', {
+ rconAction: 'kill_distance_flag'
+ });
+ await new Promise((r) => setTimeout(r, 2000));
+ const titles = posts.flatMap((body) =>
+ (JSON.parse(body) as { embeds: { title: string }[] }).embeds.map((e) => e.title)
+ );
+ expect(titles).toEqual(['Trigger · kill distance watch']);
+ });
});
From ed7890ee0f295f70656440e7ddcbaa5a722451d9 Mon Sep 17 00:00:00 2001
From: James Causon
Date: Mon, 5 Oct 2026 23:08:46 +0100
Subject: [PATCH 020/102] README: the Kill distance watch does not count a kill
that comes within a minute of the killer's own death, and notes each one in
the audit trail
---
README.md | 30 +++++++++++++++---------------
1 file changed, 15 insertions(+), 15 deletions(-)
diff --git a/README.md b/README.md
index 2655659c..5338fadd 100644
--- a/README.md
+++ b/README.md
@@ -533,21 +533,21 @@ ban list_ (on every server), and for the Seeding reward _Reserved slots_ or _Org
its row). A custom role or API key with _Automation_ alone can read the rules and
delete them.
-| Trigger | Does |
-| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Welcome whisper | Whispers a message to each joiner (optionally only on their first visit), which can tell them their all-time stats, on the server or across the organisation (see [Placeholders](#placeholders)). |
-| Scheduled broadcast | Rotates through a list of messages every N minutes while at least M players are on, and optionally only until a ceiling, so a fill-the-server message stops once it has. |
-| Empty-server map reset | After the server has been empty for N minutes on a different map or mode, sets the chosen map as next and ends the match (or requests it directly when there is no rotation). |
-| Kick on connect risk | Kicks players who match rules: VAC ban or game ban (optionally only within the last N days), Steam account younger than N days (optionally private profiles too), banned on another server in the org, or on the watchlist; or whose advisory risk score is at or above a set number from 1 to 100 (medium starts at 20, high at 50; the players table badges 20 and up). Joiners and players reconnecting are judged at once, and everyone on is judged again every minute (a player whose kick did not land, every five). Nobody is judged until the server's reserved slots have been read. Reserved-slot players can be spared. |
-| Name filter | Kicks joiners whose name breaks the rule, or with _Alert only_ just records them (audit trail and Discord). A character policy: any, Latin letters (keeps José and Müller, optionally with Cyrillic, Greek, Arabic, Hebrew, Thai, Devanagari, Chinese, Japanese or Korean beside them) or ASCII only; digits, spaces and keyboard punctuation always pass, emoji and symbols only when allowed, and a name can be required to hold N letters. Blocked words: a built-in English list of slurs and hate terms, your own words (up to 200) and exceptions for names that would match but are fine. Words are caught through case, leetspeak, look-alike letters, stretching and spelling out (`n.a.z.i`). The kick reason's `{why}` says what kind of fault it was, never the word. Reserved-slot players can be spared. Checked at the join and again whenever the name changes, clan tag included: the game can show the tag a moment after the player is in. A kick rule also checks a player who reconnects. Its dry run checks everyone who has played on the server, under each name they used. |
-| High ping kick | Kicks a player whose reported ping remains above a configurable limit for a configurable number of seconds. Normal or unavailable ping, leaving, or interrupted player-list polling resets the timer. Historical ping is not stored, so this rule cannot be replayed in a dry run. |
-| Team kill limit | Whispers a player from N team kills in one match, and kicks them at M; leaving and rejoining does not start the count over. Team kills by what the rule leaves out are not counted and still show as team kills everywhere else: barbed wire unless unticked (a player who runs into a teammate's wire is reported as killed by whoever built it), and any other buildable or placed charge ticked. Needs the [kill feed](#kill-feed); acted on as each kill arrives, not per poll. |
-| Kill rate watch | Flags a player, never kicks, when their kills with hand-held weapons in the last N minutes reach a count, or their share of headshots over those kills reaches a percentage once they have at least M; vehicles, their guns and buildables are not counted. The flag goes to the audit trail and the Discord mirror (the post opens the player's page in the panel), and the same player is flagged again only after a cooldown. Needs the [kill feed](#kill-feed), which has no position or aim, so a flag is a reason to look, not proof. The windows are kept in the worker's memory and start over when it restarts. |
-| Kill distance watch | Acts on a player whose kills with the chosen weapons come from at least N metres away, M of them in one match: a defibrillator or a fist reaches a few metres, so the same kill from across the map is a player the game did not stop. The weapons are offered in three lists, as the kill feed names them: hand-held (a new rule starts with the defibrillator; charges that are placed and set off from anywhere are not offered), the vehicles' guns (a Humvee's M249 or minigun turret), and the vehicles themselves (a roadkill, or a vehicle blown up with its crew inside). From 0 m every kill with them counts, one the feed sends without a distance too, so a server can act on each kill with a weapon or a vehicle it does not allow: an infantry-only server can act on whoever kills from a Humvee's turret. It flags them (audit trail and the Discord mirror, whose post opens the player's page), warns them (the rule's text whispered to them, mirrored the same way; a blank text says the weapon is not allowed on this server), kills them and whispers them the rule's text (so a server can answer each Humvee kill with the killer's death: the feed only reports kills, so a player who drives one without killing anybody is not seen), kicks them, or bans them for N days or for good on this server's own ban list or the organisation's, like a ban by hand: every server the ban reaches takes its lists again at once and removes them at its next look, and the ban is lifted from the list. A shorter ban already on that list is made to last as long. A killed, kicked or banned player is left alone for a minute while that lands, so a roadkill of three is one kill; one who does it again after that, that match, is acted on at their next such kill. A flag or a warning comes again in the same match only after the rule's cooldown, and at their first such kill in the next. A warning, a kill and a kick need the player still on when they are sent, and a kill goes within half a minute or not at all (one sent later would land on them doing something else, or on the next map). A kill the game refuses (no living character, presumably) still sends the whisper; with that refused too, the action fails. Two rules that kill on the same weapon each kill. To warn first and kick after, make two rules on the same weapons, the kick's count higher. Needs the [kill feed](#kill-feed), whose distance is between killer and victim; acted on as each kill arrives, and counted per match as the Team kill limit counts. Editing, switching off or deleting the rule starts the counts over and drops what it had queued. The counts are kept in the worker's memory and start over when it restarts. The rule believes the feed: whoever holds a server's feed token can make it act, so keep bans on every server for rules on servers whose host you trust. |
-| Team balance | Keeps the open sides within a set number of players of each other (3 by default), closes one faction, or both. Nobody who is playing is moved mid-match: balancing places a player when they arrive (on the side they picked while that keeps within the gap, else on the lighter one), puts back a player who switches onto a side more than the gap bigger than the one they left, and evens everyone up when a new match starts (a map change or the scores starting over). Whoever is on when the rule is switched on or changed, after the worker restarts, when the server comes back from being out of reach, or after two minutes (three looks, at a slower player list cadence) without a look at the player list, counts as placed until the next match, so it never reshuffles the match it walks in on; starting over also forgets who was told and the asks. The side a player was placed on is kept for ten minutes away, so leaving and rejoining on the bigger side is put back too. Optional: keep clan tags together (a short tag in brackets at the front of the name; a preference that yields to the gap; when a new match is evened up, players whose clan is mostly on the other side move first, then those with the fewest clanmates beside them), and SteamIDs it never moves (staff who switch sides themselves). A move made from the Players tab or the API is kept, not put back; one made anywhere else (the game's own console, another balancer) is put back like a switch, so run no other balancer beside it. Right after a match end, placing waits up to 30 seconds from the players' return (the list is empty while the next map loads) while a quarter or more of them have not picked a side, and whom it moves is not decided by list order. Everyone on the closed faction is moved to the smaller other side (or their clan's, within the gap). Every move is the game's own move, sent without the kill the Players tab's Move adds. Moves go out a few at a time as the player list refreshes, so a full server's sort takes about half a minute; moves in flight count toward their side, and one not seen landed within 30 seconds is decided again; a queued move goes out only while the rule still holds that decision, so a retry, a new match or a person moving the player drops it. A player who switches onto the bigger side is put back every time; a player asked to move three times in ten minutes for any other reason, or put back three times without it taking, is left where they are until the ten minutes pass. Watch only lists each move it would make under Actions, as skipped, and moves nobody. Optional names for the sides (e.g. Red and Green) and a whisper, `{team}`, sent once to each player it moves (again after two hours away). One rule per server; changing or switching it off drops its queued moves. Its moves reach Discord only when they fail. What it remembers is the worker's: a restart forgets the moves in flight, who was told, the asks and the sides. A rule saved before balancing existed only closes its faction until balancing is ticked. Nothing to dry-run: faction moves are not in the session history; use Watch only. |
-| Match broadcast | Announces the result when a match ends and the map as the next one starts, either message optional, with at least N players on (the game shows nobody on while the next map loads, so the announcement waits up to three minutes for the players to be back). A match ends when the map changes or the faction scores fall back to zero (a faction reached the cap, or an admin ended the round; live builds send no score cap or match clock, so Warcon assumes the game's default of 100), so `{faction}` is whoever led at that moment, tied factions named together. Besides the server's placeholders, `{score}` and `{scores}` of the match that ended, `{previous}` (its map), and from the players' lines of it `{mvp}` (the most kills, tied players named together) and `{top}` (the top three with their kills). Sent one poll after the round ends. |
-| Seeding reward | Time a player spends on with at most N players counts as seed time, by default banked only once the server has filled (a count the rule sets, else the limit the server reports) with the player still on, so staying until the threshold and leaving, or a few minutes on an empty server, earns nothing (a switch on the rule counts every low minute instead); M minutes of it over the sessions that ended in the last D days earns a reserved slot for E days, with an optional whisper: on this server only (its own reserved-slot list, which needs the Reserved slots capability) or on every server in the organisation (the org list, which needs Org reserved slots), chosen on the rule. The seeded server applies it at once and, for an org-wide slot, the other servers at their next sync; it lapses on its own and can be earned again; players who already hold a slot here are skipped. Seed time is kept on each session, so the dossier history, the leaderboard's Seed time column and the dry run show it. |
-| AFK protection | While the server seeds (someone on, fewer than the rule's count, 20 by default, and no side has scored), kills everyone on every N minutes (3 by default, 2 to 10), so the game's idle kick, which also runs while a server waits for its players, does not remove the seeders. The panel cannot tell who is idle, so players are killed too. Set the count no higher than the players the server's match starts at. A side scoring or the count being reached turns it off until the server has sat empty for 10 minutes or the game restarts, so a dip below the count mid-match, or the empty list of a map change, never starts it again; switched off and on, it starts from what the server shows then, so switching it on during a match nobody has scored in yet takes that match for seeding until the first score. Switching it off drops a round not yet sent. Its row says whether it is active or paused, and why. Optional broadcasts after each round and once when the match it kept the seeders for goes live (`{goal}` is the count it turns off at). A round is one action in the trail, kept off Discord unless it fails; it goes only within 30 seconds of being decided, while the last look still showed the server seeding, and players who left since are passed over. One rule per server. |
+| Trigger | Does |
+| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
+| Welcome whisper | Whispers a message to each joiner (optionally only on their first visit), which can tell them their all-time stats, on the server or across the organisation (see [Placeholders](#placeholders)). |
+| Scheduled broadcast | Rotates through a list of messages every N minutes while at least M players are on, and optionally only until a ceiling, so a fill-the-server message stops once it has. |
+| Empty-server map reset | After the server has been empty for N minutes on a different map or mode, sets the chosen map as next and ends the match (or requests it directly when there is no rotation). |
+| Kick on connect risk | Kicks players who match rules: VAC ban or game ban (optionally only within the last N days), Steam account younger than N days (optionally private profiles too), banned on another server in the org, or on the watchlist; or whose advisory risk score is at or above a set number from 1 to 100 (medium starts at 20, high at 50; the players table badges 20 and up). Joiners and players reconnecting are judged at once, and everyone on is judged again every minute (a player whose kick did not land, every five). Nobody is judged until the server's reserved slots have been read. Reserved-slot players can be spared. |
+| Name filter | Kicks joiners whose name breaks the rule, or with _Alert only_ just records them (audit trail and Discord). A character policy: any, Latin letters (keeps José and Müller, optionally with Cyrillic, Greek, Arabic, Hebrew, Thai, Devanagari, Chinese, Japanese or Korean beside them) or ASCII only; digits, spaces and keyboard punctuation always pass, emoji and symbols only when allowed, and a name can be required to hold N letters. Blocked words: a built-in English list of slurs and hate terms, your own words (up to 200) and exceptions for names that would match but are fine. Words are caught through case, leetspeak, look-alike letters, stretching and spelling out (`n.a.z.i`). The kick reason's `{why}` says what kind of fault it was, never the word. Reserved-slot players can be spared. Checked at the join and again whenever the name changes, clan tag included: the game can show the tag a moment after the player is in. A kick rule also checks a player who reconnects. Its dry run checks everyone who has played on the server, under each name they used. |
+| High ping kick | Kicks a player whose reported ping remains above a configurable limit for a configurable number of seconds. Normal or unavailable ping, leaving, or interrupted player-list polling resets the timer. Historical ping is not stored, so this rule cannot be replayed in a dry run. |
+| Team kill limit | Whispers a player from N team kills in one match, and kicks them at M; leaving and rejoining does not start the count over. Team kills by what the rule leaves out are not counted and still show as team kills everywhere else: barbed wire unless unticked (a player who runs into a teammate's wire is reported as killed by whoever built it), and any other buildable or placed charge ticked. Needs the [kill feed](#kill-feed); acted on as each kill arrives, not per poll. |
+| Kill rate watch | Flags a player, never kicks, when their kills with hand-held weapons in the last N minutes reach a count, or their share of headshots over those kills reaches a percentage once they have at least M; vehicles, their guns and buildables are not counted. The flag goes to the audit trail and the Discord mirror (the post opens the player's page in the panel), and the same player is flagged again only after a cooldown. Needs the [kill feed](#kill-feed), which has no position or aim, so a flag is a reason to look, not proof. The windows are kept in the worker's memory and start over when it restarts. |
+| Kill distance watch | Acts on a player whose kills with the chosen weapons come from at least N metres away, M of them in one match: a defibrillator or a fist reaches a few metres, so the same kill from across the map is a player the game did not stop. The weapons are offered in three lists, as the kill feed names them: hand-held (a new rule starts with the defibrillator; charges that are placed and set off from anywhere are not offered), the vehicles' guns (a Humvee's M249 or minigun turret), and the vehicles themselves (a roadkill, or a vehicle blown up with its crew inside). From 0 m every kill with them counts, one the feed sends without a distance too, so a server can act on each kill with a weapon or a vehicle it does not allow: an infantry-only server can act on whoever kills from a Humvee's turret. It flags them (audit trail and the Discord mirror, whose post opens the player's page), warns them (the rule's text whispered to them, mirrored the same way; a blank text says the weapon is not allowed on this server), kills them and whispers them the rule's text (so a server can answer each Humvee kill with the killer's death: the feed only reports kills, so a player who drives one without killing anybody is not seen), kicks them, or bans them for N days or for good on this server's own ban list or the organisation's, like a ban by hand: every server the ban reaches takes its lists again at once and removes them at its next look, and the ban is lifted from the list. A shorter ban already on that list is made to last as long. A killed, kicked or banned player is left alone for a minute while that lands, so a roadkill of three is one kill; one who does it again after that, that match, is acted on at their next such kill. A flag or a warning comes again in the same match only after the rule's cooldown, and at their first such kill in the next. A warning, a kill and a kick need the player still on when they are sent, and a kill goes within half a minute or not at all (one sent later would land on them doing something else, or on the next map). A kill the game refuses (no living character, presumably) still sends the whisper; with that refused too, the action fails. Two rules that kill on the same weapon each kill. To warn first and kick after, make two rules on the same weapons, the kick's count higher. Needs the [kill feed](#kill-feed), whose distance is between killer and victim; acted on as each kill arrives, and counted per match as the Team kill limit counts. A kill that comes within a minute of the killer's own death is not counted: the player is dead, so it is a round already on its way, and the game credits a vehicle's shell that lands after the vehicle was destroyed to the gunner's own weapon, from wherever the gunner is by then (an M4 kill from 2,300 m). The audit trail notes each kill left out this way, with how long after the death it came; Discord is not told. Editing, switching off or deleting the rule starts the counts over and drops what it had queued. The counts are kept in the worker's memory and start over when it restarts. The rule believes the feed: whoever holds a server's feed token can make it act, so keep bans on every server for rules on servers whose host you trust. |
+| Team balance | Keeps the open sides within a set number of players of each other (3 by default), closes one faction, or both. Nobody who is playing is moved mid-match: balancing places a player when they arrive (on the side they picked while that keeps within the gap, else on the lighter one), puts back a player who switches onto a side more than the gap bigger than the one they left, and evens everyone up when a new match starts (a map change or the scores starting over). Whoever is on when the rule is switched on or changed, after the worker restarts, when the server comes back from being out of reach, or after two minutes (three looks, at a slower player list cadence) without a look at the player list, counts as placed until the next match, so it never reshuffles the match it walks in on; starting over also forgets who was told and the asks. The side a player was placed on is kept for ten minutes away, so leaving and rejoining on the bigger side is put back too. Optional: keep clan tags together (a short tag in brackets at the front of the name; a preference that yields to the gap; when a new match is evened up, players whose clan is mostly on the other side move first, then those with the fewest clanmates beside them), and SteamIDs it never moves (staff who switch sides themselves). A move made from the Players tab or the API is kept, not put back; one made anywhere else (the game's own console, another balancer) is put back like a switch, so run no other balancer beside it. Right after a match end, placing waits up to 30 seconds from the players' return (the list is empty while the next map loads) while a quarter or more of them have not picked a side, and whom it moves is not decided by list order. Everyone on the closed faction is moved to the smaller other side (or their clan's, within the gap). Every move is the game's own move, sent without the kill the Players tab's Move adds. Moves go out a few at a time as the player list refreshes, so a full server's sort takes about half a minute; moves in flight count toward their side, and one not seen landed within 30 seconds is decided again; a queued move goes out only while the rule still holds that decision, so a retry, a new match or a person moving the player drops it. A player who switches onto the bigger side is put back every time; a player asked to move three times in ten minutes for any other reason, or put back three times without it taking, is left where they are until the ten minutes pass. Watch only lists each move it would make under Actions, as skipped, and moves nobody. Optional names for the sides (e.g. Red and Green) and a whisper, `{team}`, sent once to each player it moves (again after two hours away). One rule per server; changing or switching it off drops its queued moves. Its moves reach Discord only when they fail. What it remembers is the worker's: a restart forgets the moves in flight, who was told, the asks and the sides. A rule saved before balancing existed only closes its faction until balancing is ticked. Nothing to dry-run: faction moves are not in the session history; use Watch only. |
+| Match broadcast | Announces the result when a match ends and the map as the next one starts, either message optional, with at least N players on (the game shows nobody on while the next map loads, so the announcement waits up to three minutes for the players to be back). A match ends when the map changes or the faction scores fall back to zero (a faction reached the cap, or an admin ended the round; live builds send no score cap or match clock, so Warcon assumes the game's default of 100), so `{faction}` is whoever led at that moment, tied factions named together. Besides the server's placeholders, `{score}` and `{scores}` of the match that ended, `{previous}` (its map), and from the players' lines of it `{mvp}` (the most kills, tied players named together) and `{top}` (the top three with their kills). Sent one poll after the round ends. |
+| Seeding reward | Time a player spends on with at most N players counts as seed time, by default banked only once the server has filled (a count the rule sets, else the limit the server reports) with the player still on, so staying until the threshold and leaving, or a few minutes on an empty server, earns nothing (a switch on the rule counts every low minute instead); M minutes of it over the sessions that ended in the last D days earns a reserved slot for E days, with an optional whisper: on this server only (its own reserved-slot list, which needs the Reserved slots capability) or on every server in the organisation (the org list, which needs Org reserved slots), chosen on the rule. The seeded server applies it at once and, for an org-wide slot, the other servers at their next sync; it lapses on its own and can be earned again; players who already hold a slot here are skipped. Seed time is kept on each session, so the dossier history, the leaderboard's Seed time column and the dry run show it. |
+| AFK protection | While the server seeds (someone on, fewer than the rule's count, 20 by default, and no side has scored), kills everyone on every N minutes (3 by default, 2 to 10), so the game's idle kick, which also runs while a server waits for its players, does not remove the seeders. The panel cannot tell who is idle, so players are killed too. Set the count no higher than the players the server's match starts at. A side scoring or the count being reached turns it off until the server has sat empty for 10 minutes or the game restarts, so a dip below the count mid-match, or the empty list of a map change, never starts it again; switched off and on, it starts from what the server shows then, so switching it on during a match nobody has scored in yet takes that match for seeding until the first score. Switching it off drops a round not yet sent. Its row says whether it is active or paused, and why. Optional broadcasts after each round and once when the match it kept the seeders for goes live (`{goal}` is the count it turns off at). A round is one action in the trail, kept off Discord unless it fails; it goes only within 30 seconds of being decided, while the last look still showed the server seeding, and players who left since are passed over. One rule per server. |
**Dry run** replays the last 24 hours of the server's own history (joins, player counts, empty
stretches, cached Steam data) against a rule and lists what it would have done, so you can tune a
From 318ae9072f72dfe75769dc32b8cd76d469491d68 Mon Sep 17 00:00:00 2001
From: James Causon
Date: Tue, 6 Oct 2026 16:08:09 +0100
Subject: [PATCH 021/102] Match page: the scoreboard is one table per side, the
winner first, then the players the game never put on a side, each headed by
the side's colour, result and player count, as the live page groups its
roster. One sort orders every table, their columns line up on a wide screen,
and on a phone a long name is cut short so the numbers stay in view. The
Faction column goes; the side is the table's heading
---
src/lib/components/MatchPanel.svelte | 168 ++++++++++++++++-----------
src/lib/matches.test.ts | 69 ++++++++++-
src/lib/matches.ts | 34 ++++++
3 files changed, 201 insertions(+), 70 deletions(-)
diff --git a/src/lib/components/MatchPanel.svelte b/src/lib/components/MatchPanel.svelte
index b2075c63..e163de68 100644
--- a/src/lib/components/MatchPanel.svelte
+++ b/src/lib/components/MatchPanel.svelte
@@ -1,16 +1,23 @@
@@ -139,69 +145,93 @@
{/if}
-
-
-
-
- Player
- Faction
- Time
- K
- D
- K/D
- K/min
- Cash
+
@@ -56,6 +96,58 @@
Kills, deaths and the rate per minute are the game's own counters over the matches that ended; the headshot
rate and best streak come from the kill feed.
+{#if career.seasons.length}
+ Seasons
+
+
+
+
+
Season
Kind
K
D
K/D
Matches
W-L-D
Places won
+
+
+
+ {#each career.seasons as s (s.season.key)}
+
+ {#each s.places as p (p.category)}{CATEGORY[p.category]}{:else}—{/each}
+
+
+ {/each}
+
+
All time
—
+
{fmtNum(career.kills)}
{fmtNum(career.deaths)}
+
{kd(career.kills, career.deaths)}
{fmtNum(career.matches)}
+
{career.wins}-{career.losses}-{career.draws}
+
—
+
+
+
+
+
+ A season counts the matches that ended in it, over the same servers as the rest of this page.
+ Places are on this server's board when the season finished: the top three on kills, K/D (at
+ least {WINNER_MIN_MATCHES} matches), playtime and wins.
+
{#if !board || loading}Loading…{:else if board.total === 0 && query.minMinutes > 0}Nobody
- has {fmtMinutes(query.minMinutes)} of playtime in this range yet.{:else}No players in
- this range yet.{/if}
+ has {fmtMinutes(query.minMinutes)} of playtime in this {what}{final
+ ? ''
+ : ' yet'}.{:else}No players in this {what}{final ? '' : ' yet'}.{/if}
+ {#if final}{season.name} ended when {next?.name ?? 'the next season'} started; its board counts the
+ matches that ended before then and no longer changes, so Last seen is left off. A player needs at
+ least {WINNER_MIN_MATCHES} matches in the season to win on K/D.{:else}{season.name}
+ counts the matches that ended since it started; all time and the other ranges are in the picker.{/if}
+
+{/if}
{#if board && !board.hasFeed}
{query.scope === 'org' ? 'None of these servers has' : 'This server has no'} kill feed, so headshots,
diff --git a/src/lib/components/Place.svelte b/src/lib/components/Place.svelte
new file mode 100644
index 00000000..4bab35a0
--- /dev/null
+++ b/src/lib/components/Place.svelte
@@ -0,0 +1,11 @@
+
+
+{n}
diff --git a/src/lib/components/SeasonPicker.svelte b/src/lib/components/SeasonPicker.svelte
new file mode 100644
index 00000000..9daefdfc
--- /dev/null
+++ b/src/lib/components/SeasonPicker.svelte
@@ -0,0 +1,105 @@
+
+
+ {
+ if (!wrap.contains(e.target as Node)) open = false;
+ }}
+ onkeydown={(e) => e.key === 'Escape' && (open = false)}
+/>
+
+
+ Showing
+
+ {#if open}
+
+ {#each groups as g (g.label)}
+ {g.label}
+ {#each g.list as s (s.key)}
+ {@const on = when.range === `s:${s.key}`}
+
+ {/each}
+
+ {/each}
+ Ranges
+ {#each BOARD_RANGES as r (r.key)}
+ {@const on = when.range === r.key}
+
+ {/each}
+
+ For {data.org.name}'s leaderboards, in the panel and on the public pages. Anyone looking can
+ still pick another season or range on the board.
+
+
+
+
+ Official seasons
+
+
+
Season
Dates
+
+ {#each official as s (s.key)}
+
+
{s.name}
+
{seasonSpan(s, now)}
+
{status(s)}
+
+ {:else}
+
None yet.
+ {/each}
+
+
+
+
+ Bulkhead's seasons, the same for every community, each from the moment it launched. Warcon
+ adds each one when it is announced; they can't be changed here.
+
+
+
+
+ {data.org.name} seasons
+
+
+
Name
Starts
+
+ {#each own as s (s.key)}
+
+
{s.name}
+
{seasonDay(s.startsAt)}
+
{status(s)}
+
+
+ {#if !isStarted(s, now)}
+
+ {/if}
+
+
+ {:else}
+
+
No seasons of your own yet.
+
+ {/each}
+
+
+
+
+
+ A season runs until your next one starts, from midnight UTC. Once it has started its start
+ date is fixed, so finished standings stay put. To end one early, start the next.
+
+
+
+
+ Winners
+
+ When a season ends, its top three on kills, K/D (at least {WINNER_MIN_MATCHES} matches), playtime
+ and wins show on its board and on those players' pages, for official seasons and yours alike.
+
+ The buttons under Reason when anyone in {org.name} places a ban. A button fills in its reason and,
+ when it has a length, sets Expires; with no length set, Expires stays as the admin has it. Everyone
+ who can open a server sees a ban's reason, so write it as something the player could be told.
+ {#if !owner}Only an owner of the organisation can change them.{/if}
+
@@ -560,6 +561,7 @@
server={{ id, name: data.server.name }}
canOrg={listState?.canEditOrgBans ?? false}
banMessage={listState?.banMessage}
+ reasons={listState?.banReasons ?? []}
onclose={() => (banning = null)}
ondone={refreshPlayers}
/>
diff --git a/src/routes/(app)/server/[id]/players/PastPlayers.svelte b/src/routes/(app)/server/[id]/players/PastPlayers.svelte
index 2f77415e..aa6946cd 100644
--- a/src/routes/(app)/server/[id]/players/PastPlayers.svelte
+++ b/src/routes/(app)/server/[id]/players/PastPlayers.svelte
@@ -10,12 +10,14 @@
import SortHeader from '$lib/components/SortHeader.svelte';
import type { SortLike } from '$lib/table.svelte';
import type { SeenPlayer, SeenSort } from '$lib/server/seen';
+ import type { BanReason } from '$lib/ban-reasons';
let {
server,
canBan,
canWatch,
- canOrg
+ canOrg,
+ reasons
}: {
server: { id: string; name: string; orgId: string; orgName: string };
/** holds Bans here: the row's Ban places it on this server's list or the org's */
@@ -24,6 +26,8 @@
canWatch: boolean;
/** may edit the org's ban list: the Ban dialog then offers every server */
canOrg: boolean;
+ /** the org's quick reasons for the Ban dialog */
+ reasons: BanReason[];
} = $props();
const PAGE = 50;
@@ -247,6 +251,7 @@
name={banning.name}
server={{ id: server.id, name: server.name }}
{canOrg}
+ {reasons}
onclose={() => (banning = null)}
ondone={() => load()}
/>
diff --git a/src/routes/(app)/server/[id]/players/[steamId]/+page.svelte b/src/routes/(app)/server/[id]/players/[steamId]/+page.svelte
index ed5e4a9e..0462eef3 100644
--- a/src/routes/(app)/server/[id]/players/[steamId]/+page.svelte
+++ b/src/routes/(app)/server/[id]/players/[steamId]/+page.svelte
@@ -713,6 +713,7 @@
steamId={d.steamId}
name={d.name}
canOrg
+ reasons={d.orgLists.banReasons ?? []}
onclose={() => (banning = false)}
ondone={() => invalidateAll()}
/>
From 9124d213ed72a5c1610399b198d32f3652048049 Mon Sep 17 00:00:00 2001
From: James Causon
Date: Tue, 6 Oct 2026 23:22:19 +0100
Subject: [PATCH 029/102] README: the ban dialog's quick reasons, set by an
org's owners on the Ban list tab, with their short names, reasons and
lengths, the built-in six and who reads them; the banReasons body of PATCH
/api/orgs/:id
---
README.md | 15 +++++++++++++--
1 file changed, 13 insertions(+), 2 deletions(-)
diff --git a/README.md b/README.md
index f50b0852..6f136d64 100644
--- a/README.md
+++ b/README.md
@@ -468,6 +468,17 @@ server's own list, from the moment it is saved; the list keeps the bare reason,
on a server keeps the text it was placed with, also when its reason or expiry is edited later.
The default, `{reason}`, sends the reason alone.
+An org owner can also set the ban dialog's **quick reasons** on the Ban list tab: the buttons under
+Reason when anyone in the organisation places a ban, from any page. Each has a short name for the
+button, the reason it fills in and, if you like, a length (permanent, 1, 3, 7, 14 or 30 days) that
+it sets in Expires, shown beside its name (`7d`, `Perm`); one with no length leaves Expires as the
+admin has it. An organisation keeps up to 20, in the order the buttons show. Until its owners save
+a list of their own it offers the six built in (Cheating, Team killing, Toxic behaviour, Racism /
+hate speech, Ban evasion, Griefing), and **Reset to the built-in six** puts those back. The quick
+reasons reach only people who can place a ban there (_Bans_ on the server, or the org's ban list),
+and ban list editors who are not owners see them on the Ban list tab without changing them. Bans
+already placed keep their reasons.
+
Each entry shows where it stands on every server: **applied** by the panel, **pending** the next
sync, **failed** (hover for the server's answer), or **local**. Local means the player was already
banned (or reserved) on that server by someone working outside the panel. The panel never removes
@@ -1468,7 +1479,7 @@ which call Better Auth server-side behind the login lockout and the audit trail.
own `/api/auth/*` routes only the OAuth callback is reachable over HTTP; everything else answers 404.
```
-GET/POST /api/orgs PATCH/DELETE /api/orgs/:id PATCH {name} | {discordInviteUrl} | {boardOpens: official|custom|30d|all} | {membersReserved} | {banMessage} | site owner: {serverLimit, suspended, reason, allowPublicStatus, allowPublicLeaderboards}
+GET/POST /api/orgs PATCH/DELETE /api/orgs/:id PATCH {name} | {discordInviteUrl} | {boardOpens: official|custom|30d|all} | {membersReserved} | {banMessage} | {banReasons: [{label,reason,days}] | null} | site owner: {serverLimit, suspended, reason, allowPublicStatus, allowPublicLeaderboards}
GET/POST /api/orgs/:id/seasons {name,startsAt} PATCH/DELETE .../:seasonId {name?,startsAt?} the official seasons and the organisation's own, and what its boards open on (owners; a start is still to come, and a started season's stays put)
GET /api/orgs/:id/members PATCH/DELETE /api/orgs/:id/members/:userId {role} PUT .../:userId/grants {grants:[{serverId,roleId}]}
GET/POST /api/orgs/:id/roles {name,capabilities[]} PATCH/DELETE .../:roleId {name?,capabilities?} POST .../:roleId/reset PUT .../order {ids[]} (every role once, else 409 stale)
@@ -1497,7 +1508,7 @@ POST /api/servers/:id/players/:steamId/notes {body} DELETE .../notes/:noteId
GET/POST /api/servers/:id/triggers {kind,name,enabled,config} PATCH/DELETE .../:triggerId POST .../dry-run {kind,config}
GET/POST /api/orgs/:id/webhooks {label,url,events,serverIds,enabled,statusEnabled,statusStyle,statusIntervalS,linkStatus,linkLeaderboard,linkPanel} PATCH/DELETE .../:webhookId POST .../:webhookId/test
GET /api/public/servers/:id .../leaderboard (same query as above, page 20 at most) .../players/:steamId the public pages' JSON: no session, 404 while the page is off, limited per address
-GET /api/orgs/:id/lists the org lists the caller edits (kinds), with counts, and the caller's role on them
+GET /api/orgs/:id/lists the org lists the caller edits (kinds), with counts, and the caller's role on them; for ban list editors, the ban message and quick reasons
GET/POST /api/orgs/:id/lists/:kind/entries {steamId,reason,expiresAt} PATCH {reason,expiresAt} / DELETE .../entries/:steamId (kind = ban | reserve, needing Org ban list or Org reserved slots; ?includeRemoved=1)
POST /api/orgs/:id/lists/sync push the lists to every org server now
GET /api/orgs/:id/lists/import server entries not on the org list POST {entries:[{kind,steamId,reason}]} adopts them (owner)
From 738f94090e5542b2fba8ac9fb99ed631395e9d34 Mon Sep 17 00:00:00 2001
From: James Causon
Date: Mon, 5 Oct 2026 19:24:01 +0100
Subject: [PATCH 030/102] Name change watch trigger: flag or kick a player the
kill feed shows under a name other than the one the server lists them under.
The game's player list keeps the name a player joined with (a clan tag
changed mid-game aside) while the feed names both players of every kill as
the others see them, so each name in a batch is held against the player list
of that moment. Names are compared as they read on screen, with and without a
clan tag: their own name with a tag put on, taken off or swapped is no
change, and a change counts when the name reads as another listed player's
or, unless the rule counts only those, whatever it is. A count of changes
within a window of up to two hours can be asked for; an alert flags a player
once a window, a kick acts at every count, and a kicked player who comes back
under the taken name goes again; an edit starts the counts over. The kick
reason takes the message placeholders, {previous} for the name they are
listed under; a flag or kick goes out only while the rule holds the settings
it was decided under, so switching it off stops what it queued; saving it
needs Kick. The dry run holds the day's kills against the names of the
sessions open at each
---
src/lib/placeholders.ts | 4 +-
src/lib/server/db/schema.ts | 3 +-
src/lib/server/feed-events.ts | 191 +++++++++++--
src/lib/server/message-vars.test.ts | 3 +-
src/lib/server/name-change.test.ts | 297 ++++++++++++++++++++
src/lib/server/name-change.ts | 243 ++++++++++++++++
src/lib/server/outbox.ts | 6 +-
src/lib/server/poller.ts | 4 +-
src/lib/server/trigger-rules.ts | 12 +-
src/lib/server/triggers.ts | 121 +++++++-
src/lib/server/webhook-delivery.ts | 10 +-
src/lib/types.ts | 3 +-
src/test/access.test.ts | 57 ++++
src/test/name-change-rule.test.ts | 420 ++++++++++++++++++++++++++++
src/test/player-actions.test.ts | 2 +
15 files changed, 1338 insertions(+), 38 deletions(-)
create mode 100644 src/lib/server/name-change.test.ts
create mode 100644 src/lib/server/name-change.ts
create mode 100644 src/test/name-change-rule.test.ts
diff --git a/src/lib/placeholders.ts b/src/lib/placeholders.ts
index 710fd84a..4f51d5ed 100644
--- a/src/lib/placeholders.ts
+++ b/src/lib/placeholders.ts
@@ -90,7 +90,9 @@ export const MESSAGE_RULES: Readonly>>
kept: true
},
seed_reward: { fields: ['message'], own: ['minutes', 'until', 'days'], player: true },
- afk_protection: { fields: ['message', 'doneMessage'], own: ['goal'], player: false }
+ afk_protection: { fields: ['message', 'doneMessage'], own: ['goal'], player: false },
+ // the name the player had before the change
+ name_change: { fields: ['reason'], own: ['previous'], player: true }
};
export type PlaceholderGroupKey = 'own' | 'player' | 'stats' | 'org' | 'server';
diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts
index 2d4e9f7e..41668e35 100644
--- a/src/lib/server/db/schema.ts
+++ b/src/lib/server/db/schema.ts
@@ -813,7 +813,8 @@ export const triggers = pgTable(
'kill_rate',
'two_teams',
'kill_distance',
- 'afk_protection'
+ 'afk_protection',
+ 'name_change'
]
}).notNull(),
name: text('name').notNull(),
diff --git a/src/lib/server/feed-events.ts b/src/lib/server/feed-events.ts
index 6a2144c9..72e6ddf5 100644
--- a/src/lib/server/feed-events.ts
+++ b/src/lib/server/feed-events.ts
@@ -2,8 +2,8 @@
// event bus (the SSE route fans them to browsers; in the split roles every web process gets them
// through the relay stream), and the team-kill rules get their turn. Their intents go through
// the same outbox as every other trigger, so delivery, audit and the Discord mirror are shared.
-// The Kill rate and Kill distance rules see every batch; the rest of the work is for batches with
-// team kills.
+// The Kill rate, Kill distance and Name change rules see every batch; the rest of the work is for
+// batches with team kills.
import { and, eq, gte, inArray, isNull, lte, sql } from 'drizzle-orm';
import type { Env } from './env';
import { emit } from './events';
@@ -47,6 +47,14 @@ import {
type KillDistanceConfig,
type LastDeaths
} from './kill-distance';
+import {
+ nameChangeSettingsKey,
+ nameChangeStep,
+ pruneNameTracks,
+ type NameChangeConfig,
+ type NameTracks
+} from './name-change';
+import { NAME_FLAG } from './name-filter';
import { applyTriggerUpdates, enqueueIntents, wakeDelivery } from './outbox';
import { LostOwnership, withOwnedTransaction } from './leadership';
import { memoryOf } from './observe';
@@ -79,6 +87,12 @@ export async function onKillsIngested(
if (!(err instanceof LostOwnership))
console.warn(`[warcon] kill-distance rules on ${serverId}:`, publicMessage(err));
}
+ try {
+ await actOnNameChange(env, serverId, kills, vars);
+ } catch (err) {
+ if (!(err instanceof LostOwnership))
+ console.warn(`[warcon] name-change rules on ${serverId}:`, publicMessage(err));
+ }
const teamKills = kills.filter((k) => k.teamKill && k.killer);
if (!teamKills.length) return;
const m = memoryOf(serverId);
@@ -356,10 +370,136 @@ async function actOnKillDistance(
}
/**
- * The placeholders of a message about a kill's killer: the server as the worker last saw it, the
- * killer (their side as the kill has it, their ping from the last player list) and their stats,
- * read once per player for the whole batch, whichever rules ask. Nothing is looked at until a rule
- * acts.
+ * Each Name change rule's tracks in this process's memory, by rule: the players the feed has shown
+ * under a name not their own, with their changes within the window, and the settings they were
+ * counted under (an edit starts them over). A restart or a handover forgets them, so a change across
+ * one is missed, never made up. Kept only for servers with the rule on.
+ */
+const nameTracks = new Map<
+ string,
+ { serverId: string; settings: string; prunedAt: number; tracks: NameTracks }
+>();
+/** How often a rule's tracks are swept of players not named within its window. */
+const NAME_PRUNE_MS = 60_000;
+
+/** Forgets the Name change tracks of one server (removed from the worker), or of every server. */
+export function forgetNameChange(serverId?: string): void {
+ if (!serverId) return nameTracks.clear();
+ for (const [id, t] of nameTracks) if (t.serverId === serverId) nameTracks.delete(id);
+}
+
+async function actOnNameChange(
+ env: Env,
+ serverId: string,
+ batch: KillView[],
+ vars: KillerVars
+): Promise {
+ const rows = (await enabledTriggers(env, serverId)).filter((r) => r.kind === 'name_change');
+ const live = new Set(rows.map((r) => r.id));
+ for (const [id, t] of nameTracks)
+ if (t.serverId === serverId && !live.has(id)) nameTracks.delete(id);
+ if (!rows.length) return;
+ // The names the server lists its players under now: nothing to hold the feed against while the
+ // list is empty (a map loading) or not read yet.
+ const m = memoryOf(serverId);
+ const listed = new Map((m?.players ?? []).map((p) => [p.steamId, p.name]));
+ if (!listed.size) return;
+ // Both players of every kill as the feed named them, in the order the game played them; a
+ // suicide names its player once.
+ const shown = [...batch]
+ .sort((a, b) => a.eventTime - b.eventTime)
+ .flatMap((k) => [
+ ...(k.killer?.steamId ? [{ ...k.killer, k }] : []),
+ ...(k.victim.steamId && k.victim.steamId !== k.killer?.steamId ? [{ ...k.victim, k }] : [])
+ ]);
+ if (!shown.length) return;
+ const now = Date.now();
+ const out: Evaluation = { intents: [], updates: [] };
+ // Each rule's tracks move on as it reads; if its actions cannot be queued, they move back.
+ const undos: (() => void)[] = [];
+ try {
+ for (const row of rows) {
+ const cfg = row.config as NameChangeConfig;
+ // the settings it decides under: delivery sends its rows only while the rule still holds them
+ const settings = nameChangeSettingsKey(cfg);
+ let entry = nameTracks.get(row.id);
+ if (!entry || entry.settings !== settings)
+ nameTracks.set(row.id, (entry = { serverId, settings, prunedAt: now, tracks: new Map() }));
+ if (now - entry.prunedAt >= NAME_PRUNE_MS) {
+ pruneNameTracks(cfg, entry.tracks, now);
+ entry.prunedAt = now;
+ }
+ // A kick rule that spares reserved slots flags such a player instead, and flags everyone
+ // until the worker has read the reserved list.
+ const kicks = (steamId: string) =>
+ cfg.action === 'kick' &&
+ !(cfg.spareReserved && (!m?.reservedAt || m.reserved.has(steamId)));
+ const { hits, undo } = nameChangeStep(cfg, entry.tracks, shown, listed, now, {
+ kicked: kicks
+ });
+ undos.push(undo);
+ if (!hits.length) continue;
+ const stats = await vars.stats(
+ hits
+ .filter((h) => kicks(h.player.steamId))
+ .map((h) => ({ steamId: h.player.steamId, text: cfg.reason }))
+ );
+ for (const h of hits) {
+ const p = h.player;
+ const kick = kicks(p.steamId);
+ const spared = cfg.action === 'kick' && !kick;
+ out.intents.push({
+ trigger: row,
+ action: kick ? 'kick' : NAME_FLAG,
+ params: kick
+ ? {
+ steamId: p.steamId,
+ reason: renderTemplate(
+ cfg.reason,
+ vars.about(p, stats, { previous: h.listed }),
+ MAX_REASON
+ ),
+ rule: settings
+ }
+ : { rule: settings },
+ target: p.steamId,
+ okMessage: `${kick ? 'Kicked' : 'Flagged'} ${h.listed}${spared ? ' (reserved slot)' : ''}: ${h.verdict}`,
+ detail: {
+ name: h.listed,
+ shown: p.name,
+ holder: h.holder,
+ changes: h.count,
+ verdict: h.verdict,
+ eventId: p.k.eventId
+ },
+ steamId: p.steamId,
+ dedupeKey: [row.id, p.steamId, p.k.eventId].join(':')
+ });
+ out.updates.push({
+ id: row.id,
+ lastFiredAt: new Date(),
+ lastResult: `${kick ? 'Kicking' : 'Flagging'} ${h.listed}: ${h.verdict}`
+ });
+ }
+ }
+ if (!out.intents.length) return;
+ let queued = 0;
+ await withOwnedTransaction(env, async (tx) => {
+ queued = await enqueueIntents(tx, serverId, out.intents);
+ await applyTriggerUpdates(tx, out.updates);
+ });
+ if (queued) wakeDelivery();
+ } catch (err) {
+ for (const undo of undos.reverse()) undo();
+ throw err;
+ }
+}
+
+/**
+ * The placeholders of a message about a kill's killer (or either of its players): the server as the
+ * worker last saw it, the player (their side as the kill has it, their ping from the last player
+ * list) and their stats, read once per player for the whole batch, whichever rules ask. Nothing is
+ * looked at until a rule acts.
*/
function killerVars(env: Env, serverId: string) {
let seen: { server: MessageVars; live: Map } | null = null;
@@ -382,23 +522,30 @@ function killerVars(env: Env, serverId: string) {
/** what these killers' texts tell them of their stats; nothing is read for none */
stats: (wants: { steamId: string; text: string }[], sides?: { org: boolean }) =>
statsFor(read, wants, sides),
- of: (k: KillView, stats: StatsRead, own: MessageVars): MessageVars => {
- const { server, live } = look();
- const steamId = k.killer!.steamId;
- const p = live.get(steamId);
- return messageVars(
- server,
- {
- name: k.killer!.name || steamId,
- steamId,
- faction: k.killer!.faction ?? p?.faction ?? null,
- ping: p?.ping
- },
- statsOf(stats, steamId),
- own
- );
- }
+ of: (k: KillView, stats: StatsRead, own: MessageVars): MessageVars =>
+ about(k.killer!, stats, own),
+ /** the same for either player of a kill, as the feed named them */
+ about
};
+ function about(
+ who: { steamId: string; name: string; faction: string | null },
+ stats: StatsRead,
+ own: MessageVars
+ ): MessageVars {
+ const { server, live } = look();
+ const p = live.get(who.steamId);
+ return messageVars(
+ server,
+ {
+ name: who.name || who.steamId,
+ steamId: who.steamId,
+ faction: who.faction ?? p?.faction ?? null,
+ ping: p?.ping
+ },
+ statsOf(stats, who.steamId),
+ own
+ );
+ }
}
type KillerVars = ReturnType;
diff --git a/src/lib/server/message-vars.test.ts b/src/lib/server/message-vars.test.ts
index 2e4508ba..9461d99e 100644
--- a/src/lib/server/message-vars.test.ts
+++ b/src/lib/server/message-vars.test.ts
@@ -249,7 +249,8 @@ describe('the catalogue and the rules’ settings', () => {
two_teams: { closedFaction: 'Lonestar', message: 'a' },
kill_distance: { causes: ['Id.Item.Defibrillator.Standard'], action: 'kick', reason: 'a' },
seed_reward: { minutes: 30, message: 'a' },
- afk_protection: { message: 'a', doneMessage: 'b' }
+ afk_protection: { message: 'a', doneMessage: 'b' },
+ name_change: { action: 'kick', reason: 'a' }
};
test('every message field the catalogue names is a text the rule keeps', () => {
for (const [kind, rule] of Object.entries(MESSAGE_RULES)) {
diff --git a/src/lib/server/name-change.test.ts b/src/lib/server/name-change.test.ts
new file mode 100644
index 00000000..59005ed8
--- /dev/null
+++ b/src/lib/server/name-change.test.ts
@@ -0,0 +1,297 @@
+import { describe, expect, test } from 'bun:test';
+import {
+ nameChangeStep,
+ nameKey,
+ pruneNameTracks,
+ readName,
+ validateNameChange,
+ type NameTracks
+} from './name-change';
+
+const MIN = 60_000;
+const p = (steamId: string, name: string) => ({ steamId, name });
+/** The server's player list: SteamID to the name it lists them under. */
+const list = (...players: [string, string][]) => new Map(players);
+
+/**
+ * Kill batches in turn, each the names the feed showed and the list of that moment, a minute
+ * apart unless a batch says when; returns every batch's hits as [SteamID, verdict].
+ */
+function replay(
+ config: Record,
+ batches: {
+ shown: { steamId: string; name: string }[];
+ listed: Map;
+ at?: number;
+ }[]
+) {
+ const cfg = validateNameChange(config);
+ const tracks: NameTracks = new Map();
+ return batches.map((b, i) =>
+ nameChangeStep(cfg, tracks, b.shown, b.listed, b.at ?? i * MIN).hits.map((h) => [
+ h.player.steamId,
+ h.verdict
+ ])
+ );
+}
+
+describe('nameKey', () => {
+ test('reads a name as it shows: case, spaces, invisible characters, look-alikes, I for l', () => {
+ const key = nameKey('Silver Fox');
+ expect(nameKey('silver fox')).toBe(key);
+ expect(nameKey('Silver\u200BFox')).toBe(key);
+ // a Cyrillic o (U+043E) and a capital I passing for l
+ expect(nameKey('SiIver F\u043Ex')).toBe(key);
+ expect(nameKey('Silver Fox')).toBe(key);
+ // punctuation added, a 1 or a | for the l, a 0 for an O, a filler that shows as nothing
+ expect(nameKey('Silver_Fox.')).toBe(key);
+ expect(nameKey('Si1ver Fox')).toBe(key);
+ expect(nameKey('Si|ver Fox')).toBe(key);
+ expect(nameKey('Silver Fox\u3164')).toBe(key);
+ // symbols and emoji, and a dotless i
+ expect(nameKey('~Silver Fox~')).toBe(key);
+ expect(nameKey('Silver Fox \u{1F525}')).toBe(key);
+ expect(nameKey('S\u0131lver Fox')).toBe(key);
+ expect(nameKey('T0XIC')).toBe(nameKey('TOXIC'));
+ expect(nameKey('Silver Fox2')).not.toBe(key);
+ // a name of nothing but punctuation still reads as itself
+ expect(nameKey('...')).toBe('...');
+ expect(nameKey('...')).not.toBe(nameKey('___'));
+ });
+ test('reads a name without a clan tag in brackets, at the front or the back', () => {
+ const core = readName('Silver Fox').core;
+ for (const tagged of [
+ '[ABC] Silver Fox',
+ 'Silver Fox [ABC]',
+ '(ABC)Silver Fox',
+ '【ABC】Silver Fox',
+ '[ABC] Silver Fox'
+ ])
+ expect(readName(tagged).core).toBe(core);
+ expect(readName('[ABC] Silver Fox').key).not.toBe(nameKey('Silver Fox'));
+ // a name that is nothing but a tag reads as itself
+ expect(readName('[ABC]').core).toBe(readName('[ABC]').key);
+ });
+});
+
+describe('the name change step', () => {
+ const listed = list(['1', 'Night Owl'], ['2', '[ABC] Wolfpack'], ['3', 'Ghostpepper']);
+
+ test('a player shown under their own name, or their own with another clan tag, is no change', () => {
+ const tracks: NameTracks = new Map();
+ const { hits } = nameChangeStep(
+ validateNameChange({}),
+ tracks,
+ [p('1', '[XYZ] Night Owl'), p('2', 'wolfpack'), p('2', '[DEF]Wolfpack'), p('1', 'Night Owl')],
+ listed,
+ 0
+ );
+ expect(hits).toEqual([]);
+ // and nothing is kept for them
+ expect(tracks.size).toBe(0);
+ });
+
+ test('a player shown under another name has changed it; with only taken names counting, a free one does nothing', () => {
+ const batch = [{ shown: [p('3', 'Charlie')], listed }];
+ expect(replay({}, batch)).toEqual([[['3', 'shown as Charlie']]]);
+ expect(replay({ takenOnly: true }, batch)).toEqual([[]]);
+ });
+
+ test("taking a listed player's name counts, look-alikes and a dropped clan tag no disguise", () => {
+ const server = list(
+ ['1', 'Ghostpepper'],
+ ['2', '[R-14] ✪ Silver Fox'],
+ ['3', 'Tango'],
+ ['4', '[ABC] Golden Hawk']
+ );
+ expect(
+ replay({ takenOnly: true }, [
+ {
+ shown: [p('1', '✪ SiIver Fox'), p('3', '[XYZ] Golden Hawk')],
+ listed: server
+ }
+ ])
+ ).toEqual([
+ [
+ // a tag swapped for another is another player's name, not this one's
+ ['1', 'shown as ✪ SiIver Fox, the name of 2']
+ ]
+ ]);
+ });
+
+ test('a short name matched only without the tags is too common to be a copy; the exact name is one', () => {
+ const server = list(['1', 'Bob'], ['2', '[XYZ] Alex'], ['3', 'Sam']);
+ expect(
+ replay({ takenOnly: true }, [
+ { shown: [p('1', 'Alex'), p('3', '[XYZ] Alex')], listed: server }
+ ])
+ ).toEqual([[['3', 'shown as [XYZ] Alex, the name of 2']]]);
+ });
+
+ test('showing the same other name again is no new change, nor is going back to your own', () => {
+ const at = (shown: string) => ({ shown: [p('3', shown)], listed });
+ expect(
+ replay({ changes: 2 }, [at('Charlie'), at('Charlie'), at('Ghostpepper'), at('Delta')])
+ ).toEqual([[], [], [], [['3', 'shown as Delta (2 changes in 10 min)']]]);
+ });
+
+ test('at N changes within the window the rule acts, then counts from nothing again', () => {
+ const at = (shown: string) => ({ shown: [p('3', shown)], listed });
+ expect(
+ replay({ changes: 3, action: 'kick' }, [
+ at('Alpha'),
+ at('Bravo'),
+ at('Charlie'),
+ at('Delta'),
+ at('Echo'),
+ at('Foxtrot')
+ ])
+ ).toEqual([
+ [],
+ [],
+ [['3', 'shown as Charlie (3 changes in 10 min)']],
+ [],
+ [],
+ [['3', 'shown as Foxtrot (3 changes in 10 min)']]
+ ]);
+ });
+
+ test('changes older than the window fall out of the count', () => {
+ const at = (shown: string, minute: number) => ({
+ shown: [p('3', shown)],
+ listed,
+ at: minute * MIN
+ });
+ expect(replay({ changes: 2 }, [at('Alpha', 0), at('Bravo', 11), at('Charlie', 12)])).toEqual([
+ [],
+ [],
+ [['3', 'shown as Charlie (2 changes in 10 min)']]
+ ]);
+ });
+
+ test('an alert rule tells staff about a player once a window; a kick rule acts at every count', () => {
+ const at = (shown: string, minute: number) => ({
+ shown: [p('3', shown)],
+ listed,
+ at: minute * MIN
+ });
+ const batches = [at('Alpha', 0), at('Bravo', 1), at('Charlie', 11)];
+ // the change it did not tell staff about still counts while it is inside the window
+ expect(replay({}, batches)).toEqual([
+ [['3', 'shown as Alpha']],
+ [],
+ [['3', 'shown as Charlie (2 changes in 10 min)']]
+ ]);
+ expect(replay({ action: 'kick' }, batches)).toEqual([
+ [['3', 'shown as Alpha']],
+ [['3', 'shown as Bravo']],
+ [['3', 'shown as Charlie']]
+ ]);
+ });
+
+ test('a kicked player shown under the taken name again is a change again; one only flagged is not', () => {
+ const cfg = validateNameChange({ action: 'kick', takenOnly: true });
+ const server = list(['1', 'Ghostpepper'], ['2', 'Silver Fox']);
+ const run = (kicked: (steamId: string) => boolean) => {
+ const tracks: NameTracks = new Map();
+ // kicked; still on (the kick has not landed) or back after it, under the taken name; then
+ // back under their own and taking it again
+ return ['Silver Fox', 'Silver Fox', 'Ghostpepper', 'Silver Fox'].map((name, i) =>
+ nameChangeStep(cfg, tracks, [p('1', name)], server, i * MIN, { kicked }).hits.map(
+ (h) => h.verdict
+ )
+ );
+ };
+ const taken = ['shown as Silver Fox, the name of 2'];
+ expect(run(() => true)).toEqual([taken, taken, [], taken]);
+ // a reserved slot a kick rule flags instead is flagged once for the name, again only after
+ // going back to their own
+ expect(run(() => false)).toEqual([taken, [], [], taken]);
+ });
+
+ test('two listed players with the same name, each shown under it, have not changed it', () => {
+ const server = list(['1', 'Alex'], ['2', 'Alex'], ['3', '[ABC] Alex']);
+ const shown = [p('1', 'Alex'), p('2', 'Alex'), p('3', 'Alex')];
+ expect(replay({}, [{ shown, listed: server }])).toEqual([[]]);
+ expect(replay({ takenOnly: true }, [{ shown, listed: server }])).toEqual([[]]);
+ });
+
+ test('a player off the list, or shown under a blank name, is not judged', () => {
+ const tracks: NameTracks = new Map();
+ const { hits } = nameChangeStep(
+ validateNameChange({}),
+ tracks,
+ [p('9', 'Night Owl'), p('3', ' ')],
+ listed,
+ 0
+ );
+ expect(hits).toEqual([]);
+ expect(tracks.size).toBe(0);
+ });
+
+ test('the hit names who the player is listed as and who has the name they show', () => {
+ const tracks: NameTracks = new Map();
+ const { hits } = nameChangeStep(
+ validateNameChange({}),
+ tracks,
+ [p('3', 'Night 0wl')],
+ listed,
+ 0
+ );
+ expect(hits).toEqual([
+ {
+ player: p('3', 'Night 0wl'),
+ listed: 'Ghostpepper',
+ holder: '1',
+ count: 1,
+ verdict: 'shown as Night 0wl, the name of 1'
+ }
+ ]);
+ });
+
+ test('undo puts the tracks back as they were before the batch', () => {
+ const cfg = validateNameChange({ changes: 2 });
+ const tracks: NameTracks = new Map();
+ nameChangeStep(cfg, tracks, [p('3', 'Alpha')], listed, 0);
+ const kept = structuredClone([...tracks]);
+ const { hits, undo } = nameChangeStep(
+ cfg,
+ tracks,
+ [p('3', 'Bravo'), p('1', 'Charlie')],
+ listed,
+ MIN
+ );
+ expect(hits.map((h) => h.player.steamId)).toEqual(['3']);
+ undo();
+ expect([...tracks]).toEqual(kept);
+ // so the same batch, read again, acts again
+ expect(
+ nameChangeStep(cfg, tracks, [p('3', 'Bravo')], listed, MIN).hits.map((h) => h.verdict)
+ ).toEqual(['shown as Bravo (2 changes in 10 min)']);
+ });
+
+ test('pruning forgets the players the feed has not named within the window', () => {
+ const cfg = validateNameChange({ windowMinutes: 5 });
+ const tracks: NameTracks = new Map();
+ nameChangeStep(cfg, tracks, [p('3', 'Alpha'), p('1', 'Bravo')], listed, 0);
+ nameChangeStep(cfg, tracks, [p('1', 'Charlie')], listed, 4 * MIN);
+ pruneNameTracks(cfg, tracks, 6 * MIN);
+ expect([...tracks.keys()]).toEqual(['1']);
+ });
+});
+
+describe('validateNameChange', () => {
+ test('alert by default, counts clamped, a reason always set', () => {
+ expect(validateNameChange({})).toEqual({
+ takenOnly: false,
+ changes: 1,
+ windowMinutes: 10,
+ action: 'alert',
+ spareReserved: true,
+ reason: 'Changing your name mid-game is not allowed here.'
+ });
+ expect(
+ validateNameChange({ changes: 0, windowMinutes: 99999, action: 'ban', takenOnly: 1 })
+ ).toMatchObject({ changes: 1, windowMinutes: 120, action: 'alert', takenOnly: true });
+ });
+});
diff --git a/src/lib/server/name-change.ts b/src/lib/server/name-change.ts
new file mode 100644
index 00000000..eb2f5961
--- /dev/null
+++ b/src/lib/server/name-change.ts
@@ -0,0 +1,243 @@
+// The Name change watch rule's pure part: names read as they show on screen, and when the name a
+// player shows calls for the rule. The game's player list holds the name a player joined with (a
+// clan tag changed mid-game is the one change it follows); a name changed mid-game shows only in
+// the kill feed, which names both players of every kill as the others see them. So a player the
+// feed shows under a name that does not read as the one the list holds for them has changed it,
+// and one whose shown name reads as another listed player's is taking it. Live, each kill batch is
+// held against the player list of that moment (feed-events.ts); the dry run holds the day's kills
+// against the sessions open at each (triggers.ts). No database, no game server; both go through
+// nameChangeStep.
+import { int, str } from './http';
+import { fold } from './name-fold';
+import { settingsFingerprint } from './fingerprint';
+
+export interface NameChangeConfig {
+ /** count a change only when the name shown is another listed player's; otherwise every change
+ * counts */
+ takenOnly: boolean;
+ /** act once one player's counted changes within the window reach this many; 1 is every change */
+ changes: number;
+ /** also how long the rule remembers a player, so it bounds the worker's memory */
+ windowMinutes: number;
+ /** `alert` writes the audit row (and so the Discord card) and leaves the player on */
+ action: 'alert' | 'kick';
+ /** a kick rule flags a player with a reserved slot instead of kicking them */
+ spareReserved: boolean;
+ reason: string;
+}
+
+export function validateNameChange(c: Record): NameChangeConfig {
+ return {
+ takenOnly: !!c.takenOnly,
+ changes: int(c.changes, 1, 1, 20),
+ windowMinutes: int(c.windowMinutes, 10, 1, 120),
+ action: c.action === 'kick' ? 'kick' : 'alert',
+ spareReserved: c.spareReserved === undefined ? true : !!c.spareReserved,
+ reason: str(c.reason, 200) || 'Changing your name mid-game is not allowed here.'
+ };
+}
+
+/**
+ * The settings a rule's flags and kicks were decided under: delivery sends them only while the rule
+ * still holds these (outbox.ts), so switching off a rule that is kicking the wrong players stops
+ * what it has queued.
+ */
+export const nameChangeSettingsKey = (cfg: NameChangeConfig): string => settingsFingerprint(cfg);
+
+/** What changes nothing on screen: spaces, format characters, the fillers that pass for a blank,
+ * punctuation and symbols (a dot, a tilde or an emoji added is no new name). */
+const UNSEEN = /[\s\p{Cf}\p{P}\p{S}ᅟᅠㅤᅠ⠀]+/gu;
+/** Letters that pass for Latin ones and that name-fold.ts leaves as they are. */
+const MORE_LOOKALIKES: Record = {
+ ı: 'i',
+ ɩ: 'i',
+ ӏ: 'i',
+ ɑ: 'a',
+ օ: 'o',
+ ս: 'u',
+ ո: 'n',
+ հ: 'h',
+ һ: 'h',
+ ց: 'g',
+ ԛ: 'q',
+ ԝ: 'w',
+ ѡ: 'w',
+ ү: 'y'
+};
+const MORE = new RegExp(`[${Object.keys(MORE_LOOKALIKES).join('')}]`, 'gu');
+/** A clan tag in brackets at the front or the back of a name (read after NFKC, so fullwidth
+ * brackets are plain ones). */
+const TAG =
+ /^\s*[[({<【「『〔][^\])}>】」』〕]{1,12}[\])}>】」』〕]|[[({<【「『〔][^\])}>】」』〕]{1,12}[\])}>】」』〕]\s*$/gu;
+/** A name that matches another only once a clan tag is taken off is a copy from this length on;
+ * shorter ones ("Alex", "[ABC] Alex") are too common to be. */
+const MIN_CORE = 5;
+
+// | is a symbol, so it is read as i before the symbols go.
+const reading = (folded: string): string =>
+ folded
+ .replace(MORE, (c) => MORE_LOOKALIKES[c])
+ .replace(/\|/g, 'i')
+ .replace(UNSEEN, '')
+ .replace(/[l1]/g, 'i')
+ .replace(/0/g, 'o');
+
+/** How a name reads on screen (`key`), and without a clan tag (`core`). */
+export interface NameReading {
+ key: string;
+ core: string;
+}
+
+/**
+ * A name as it reads on screen: case, accents, compatibility forms and look-alike letters from
+ * other alphabets folded (name-fold.ts and MORE_LOOKALIKES), what changes nothing on screen gone,
+ * and the characters that pass for one another read as one (I, l, 1 and | as i; 0 as o). A name
+ * made of nothing else reads as itself.
+ */
+export function readName(name: string): NameReading {
+ const folded = fold(name);
+ const key = reading(folded) || name.trim();
+ return { key, core: reading(folded.replace(TAG, '')) || key };
+}
+export const nameKey = (name: string): string => readName(name).key;
+
+/**
+ * Two names another player would take for one: they read the same, or one is the other with a
+ * clan tag put on or taken off (and long enough not to be a coincidence). A tag swapped for
+ * another is another player.
+ */
+const sameName = (a: NameReading, b: NameReading): boolean =>
+ a.key === b.key ||
+ (a.core === b.core && a.core.length >= MIN_CORE && (a.core === a.key || b.core === b.key));
+
+/** Their own name: it reads the same, or the same with a clan tag put on, taken off or swapped. */
+const ownName = (shown: NameReading, listed: NameReading): boolean =>
+ shown.key === listed.key || shown.core === listed.core;
+
+/** One player as the kill feed named them at one kill. */
+export interface ShownName {
+ steamId: string;
+ name: string;
+}
+
+/**
+ * What the rule remembers of a player the feed has shown under a name not their own: how the name
+ * it last showed reads ('' once it is their own again), their counted changes still inside the
+ * window (ms, oldest first), when the rule last acted on them, and when the feed last named them.
+ * A player always shown under their own name has none.
+ */
+export interface NameTrack {
+ shown: string;
+ changes: readonly number[];
+ actedAt: number | null;
+ seenAt: number;
+}
+export type NameTracks = Map;
+
+const NONE: readonly never[] = Object.freeze([]);
+
+export interface NameChangeHit
{
+ player: P;
+ /** the name the server lists them under */
+ listed: string;
+ /** the SteamID of the listed player whose name they show, when it is someone else's */
+ holder: string | null;
+ /** counted changes in the window, this one included */
+ count: number;
+ verdict: string;
+}
+
+/**
+ * The names a batch of kills showed, in the order they were played, held against the names the
+ * server lists its players under (`listed`). A player not on the list is not judged: they have
+ * left, or the list is between maps. A player shown under a name that does not read as their listed
+ * one has changed it; the change counts when the name reads as another listed player's, or, unless
+ * the rule counts only those, whatever it is. Showing the same name again is no new change, nor is
+ * going back to one's own.
+ *
+ * The rule acts once a player's counted changes within the window reach its count, and counts from
+ * nothing again: an alert rule tells staff about a player once a window, a kick rule acts at every
+ * count it reaches. A player it kicks (`kicked`; one with a reserved slot may be flagged instead) is
+ * remembered under their own name, so showing the taken name again after coming back is a change
+ * again: a kick is no answer if the player can reconnect and carry on.
+ *
+ * The tracks move on at once, so a batch read while another is being written sees its changes;
+ * `undo` puts them back for a batch whose actions could not be written. `readings` keeps the names
+ * read across calls (the dry run's whole replay).
+ */
+export function nameChangeStep
[]; undo: () => void } {
+ const from = now - cfg.windowMinutes * 60_000;
+ const readings = opts.readings ?? new Map();
+ const read = (name: string): NameReading => {
+ let r = readings.get(name);
+ if (!r) readings.set(name, (r = readName(name)));
+ return r;
+ };
+ const before = new Map();
+ const move = (steamId: string, t: NameTrack) => {
+ if (!before.has(steamId)) before.set(steamId, tracks.get(steamId));
+ tracks.set(steamId, t);
+ };
+ const hits: NameChangeHit
[] = [];
+ for (const p of shown) {
+ const own = listed.get(p.steamId);
+ if (own === undefined || !p.name.trim()) continue;
+ const r = p.name === own ? null : read(p.name);
+ const key = !r || ownName(r, read(own)) ? '' : r.key;
+ const was = tracks.get(p.steamId);
+ const t: NameTrack =
+ was && was.seenAt >= from ? was : { shown: '', changes: NONE, actedAt: null, seenAt: now };
+ if (key === t.shown) {
+ if (t === was) move(p.steamId, { ...t, seenAt: now });
+ continue;
+ }
+ const holder = r && key ? holderOf(listed, p.steamId, r, read) : null;
+ const counts = !!key && (holder !== null || !cfg.takenOnly);
+ const times = t.changes.filter((at) => at >= from);
+ if (counts) times.push(now);
+ // an alert rule has told staff about this player within the window already
+ const told = cfg.action !== 'kick' && t.actedAt !== null && t.actedAt >= from;
+ const act = counts && !told && times.length >= cfg.changes;
+ move(p.steamId, {
+ shown: act && opts.kicked?.(p.steamId) ? '' : key,
+ changes: act ? NONE : times,
+ actedAt: act ? now : t.actedAt,
+ seenAt: now
+ });
+ if (!act) continue;
+ const verdict = `shown as ${p.name}${holder ? `, the name of ${holder}` : ''}${times.length > 1 ? ` (${times.length} changes in ${cfg.windowMinutes} min)` : ''}`;
+ hits.push({ player: p, listed: own, holder, count: times.length, verdict });
+ }
+ return {
+ hits,
+ undo: () => {
+ for (const [id, t] of before)
+ if (t) tracks.set(id, t);
+ else tracks.delete(id);
+ }
+ };
+}
+
+/** The listed player, other than this one, whose name the shown one reads as; null for none. */
+function holderOf(
+ listed: ReadonlyMap,
+ steamId: string,
+ r: NameReading,
+ read: (name: string) => NameReading
+): string | null {
+ for (const [id, name] of listed) if (id !== steamId && sameName(read(name), r)) return id;
+ return null;
+}
+
+/** Forgets players the feed has not named within the window: their next sighting starts afresh. */
+export function pruneNameTracks(cfg: NameChangeConfig, tracks: NameTracks, now: number): void {
+ const from = now - cfg.windowMinutes * 60_000;
+ for (const [id, t] of tracks) if (t.seenAt < from) tracks.delete(id);
+}
diff --git a/src/lib/server/outbox.ts b/src/lib/server/outbox.ts
index 84665f6a..77a672e7 100644
--- a/src/lib/server/outbox.ts
+++ b/src/lib/server/outbox.ts
@@ -471,9 +471,9 @@ async function release(env: Env, row: OutboxRow): Promise {
async function deliverOne(env: Env, row: OutboxRow): Promise<'held' | 'refused' | void> {
if (row.action === 'seed_reward') return deliverSeedReward(env, row);
if (row.action === PANEL_BAN) return deliverPanelBan(env, row);
- // An alert-only Name filter match, a Kill rate or Kill distance flag, or a Kill distance rule's
- // note of a kill it left out: the audit row (and, but for the note, its Discord card) is the
- // whole delivery.
+ // An alert-only Name filter match, a Name change, Kill rate or Kill distance flag, or a Kill
+ // distance rule's note of a kill it left out: the audit row (and, but for the note, its Discord
+ // card) is the whole delivery.
if (
row.action === NAME_FLAG ||
row.action === KILL_RATE_FLAG ||
diff --git a/src/lib/server/poller.ts b/src/lib/server/poller.ts
index e28becab..675df380 100644
--- a/src/lib/server/poller.ts
+++ b/src/lib/server/poller.ts
@@ -38,7 +38,7 @@ import {
import { phaseOffset, pickDue, withHold } from './poller-schedule';
import { rollupSamples } from './rollups';
import { liveView } from './live';
-import { feedDemoKills, forgetKillDistance } from './feed-events';
+import { feedDemoKills, forgetKillDistance, forgetNameChange } from './feed-events';
import { publicMessage } from './http';
import * as metrics from './metrics';
import type { LiveView } from '$lib/types';
@@ -236,6 +236,7 @@ async function beat(env: Env): Promise {
s.epoch = period;
forgetRemembered();
forgetKillDistance();
+ forgetNameChange();
}
if (now - s.settingsAt >= SETTINGS_MS) {
s.settingsAt = now;
@@ -284,6 +285,7 @@ async function refreshRoster(env: Env, s: Scheduler, now: number): Promise
if (!present.has(m.server.id) && m.inFlight === null) {
forgetMemory(m.server.id);
forgetKillDistance(m.server.id);
+ forgetNameChange(m.server.id);
}
if (now - s.expiryAt >= EXPIRY_MS) {
s.expiryAt = now;
diff --git a/src/lib/server/trigger-rules.ts b/src/lib/server/trigger-rules.ts
index e9f3c183..87dd08a5 100644
--- a/src/lib/server/trigger-rules.ts
+++ b/src/lib/server/trigger-rules.ts
@@ -8,6 +8,7 @@ import { validateKillRate, type KillRateConfig } from './kill-rate';
import { validateKillDistance, type KillDistanceConfig } from './kill-distance';
import { validateTwoTeams, type TwoTeamsConfig } from './two-teams';
import { validateAfkProtection, type AfkProtectionConfig } from './afk-protection';
+import { validateNameChange, type NameChangeConfig } from './name-change';
import { causeTags } from './cause-tags';
import { RESTART_AFTER_HOURS, restartWindow } from '$lib/uptime';
import { MAX_CHAT } from '$lib/chat';
@@ -32,7 +33,8 @@ export const TRIGGER_KINDS: TriggerKind[] = [
'kill_rate',
'two_teams',
'kill_distance',
- 'afk_protection'
+ 'afk_protection',
+ 'name_change'
];
export const TRIGGER_LABELS: Record = {
welcome: 'Welcome whisper',
@@ -49,7 +51,8 @@ export const TRIGGER_LABELS: Record = {
kill_rate: 'Kill rate watch',
two_teams: 'Team balance',
kill_distance: 'Kill distance watch',
- afk_protection: 'AFK protection'
+ afk_protection: 'AFK protection',
+ name_change: 'Name change watch'
};
export interface WelcomeConfig {
@@ -222,7 +225,8 @@ export type TriggerConfig =
| KillRateConfig
| TwoTeamsConfig
| KillDistanceConfig
- | AfkProtectionConfig;
+ | AfkProtectionConfig
+ | NameChangeConfig;
/** A kick reason: not chat, so not held to the game's chat cap. */
export const MAX_REASON = 200;
@@ -415,6 +419,8 @@ export function validateConfig(kind: TriggerKind, raw: unknown): TriggerConfig {
return validateKillDistance(c);
case 'afk_protection':
return validateAfkProtection(c);
+ case 'name_change':
+ return validateNameChange(c);
}
}
diff --git a/src/lib/server/triggers.ts b/src/lib/server/triggers.ts
index 02298eaa..ef103427 100644
--- a/src/lib/server/triggers.ts
+++ b/src/lib/server/triggers.ts
@@ -9,6 +9,9 @@
// team_kill whisper or kick a player over team kills the kill feed reports (feed-events.ts)
// kill_rate flag a player whose kills in a short window are too many or too many headshots
// (kill-rate.ts, acted on in feed-events.ts)
+// name_change flag or kick a player the kill feed shows under a name not the one the server lists
+// them under, or under another listed player's (name-change.ts, acted on in
+// feed-events.ts)
// seed_reward hand players who stay through a low population a reserved slot, on this server
// or across the org
// two_teams close one faction and move its players to the smaller of the other two
@@ -103,6 +106,14 @@ import {
} from './trigger-rules';
import { MAX_CHAT } from '$lib/chat';
import { NAME_FLAG, nameFilterTargets, nameVerdict, type NameFilterConfig } from './name-filter';
+import {
+ nameChangeSettingsKey,
+ nameChangeStep,
+ pruneNameTracks,
+ type NameChangeConfig,
+ type NameReading,
+ type NameTracks
+} from './name-change';
import {
countsForRate,
killRateReplay,
@@ -174,6 +185,10 @@ export * from './trigger-rules';
const WINDOW_MS = 24 * 3600_000;
/** The most kills one Kill rate dry run reads, whatever the server did that day. */
const KILL_RATE_REPLAY_MAX = 200_000;
+/** The most kills one Name change dry run reads names from. */
+const NAME_CHANGE_REPLAY_MAX = 200_000;
+/** The most sessions it reads the listed names from (a busy server has a couple of thousand a day). */
+const NAME_SESSIONS_MAX = 20_000;
// ---- records ------------------------------------------------------------------------------------
@@ -236,7 +251,8 @@ const RULE_NEEDS: Record<
team_kill: ['players.kick', 'kicks players'],
kill_rate: ['players.kick', 'flags players'],
two_teams: ['players.move', 'moves players between teams'],
- afk_protection: ['players.kill', 'kills players']
+ afk_protection: ['players.kill', 'kills players'],
+ name_change: ['players.kick', 'flags or kicks players']
};
/**
@@ -440,12 +456,13 @@ export async function deleteTrigger(
* delivery): a Team balance move decided under the old closed faction or gap could take a player
* to the wrong side, and a Kill distance kick or ban decided under settings an admin has just corrected, or by
* a rule just switched off, is not to be sent; nor is an AFK protection round once the rule is off,
- * which is how it is stopped in a hurry.
+ * which is how it is stopped in a hurry, nor a Name change flag or kick.
*/
export const SETTINGS_KEYS: Partial string>> = {
two_teams: (c) => twoTeamsSettingsKey(c as TwoTeamsConfig),
kill_distance: (c) => killDistanceSettingsKey(c as KillDistanceConfig),
- afk_protection: (c) => afkSettingsKey(c as AfkProtectionConfig)
+ afk_protection: (c) => afkSettingsKey(c as AfkProtectionConfig),
+ name_change: (c) => nameChangeSettingsKey(c as NameChangeConfig)
};
/**
@@ -726,6 +743,7 @@ export async function evaluateTriggers(
case 'team_kill':
case 'kill_rate':
case 'kill_distance':
+ case 'name_change':
// Acted on as kills arrive (feed-events.ts), not per observation.
break;
case 'seed_reward':
@@ -2259,6 +2277,103 @@ export async function dryRun(
);
return result;
}
+ if (kind === 'name_change') {
+ const c = cfg as NameChangeConfig;
+ // The live rule holds each kill's names against the player list of that moment; the replay
+ // holds the window's kills against the sessions open at each, whose names are the list's (but
+ // for a clan tag changed mid-session, which is no change anyway).
+ const sessions = await env.db.execute<{
+ steamId: string;
+ name: string;
+ joinedAt: Date;
+ leftAt: Date | null;
+ }>(sql`
+ SELECT steam_id AS "steamId", name, joined_at AS "joinedAt", left_at AS "leftAt"
+ FROM player_sessions
+ WHERE server_id = ${server.id} AND (left_at IS NULL OR left_at >= ${from})
+ ORDER BY joined_at ASC LIMIT ${NAME_SESSIONS_MAX}`);
+ const rows = await env.db.execute<{
+ ts: Date;
+ killer: string | null;
+ killerName: string | null;
+ victim: string | null;
+ victimName: string | null;
+ }>(sql`
+ SELECT ts, killer_steam_id AS killer, killer_name AS "killerName",
+ victim_steam_id AS victim, victim_name AS "victimName"
+ FROM kills
+ WHERE server_id = ${server.id} AND ts >= ${from}
+ ORDER BY ts ASC, event_time ASC LIMIT ${NAME_CHANGE_REPLAY_MAX}`);
+ let reserved = new Set();
+ if (c.action === 'kick' && c.spareReserved && readReserved) {
+ try {
+ reserved = new Set(await readReserved());
+ } catch {
+ result.notes.push('Could not read the reserved slots; nobody was spared for one.');
+ }
+ }
+ // The list at the kill being replayed: sessions join it in order and leave it once they have
+ // ended, a few seconds' slack for a kill received just after its player left.
+ const listed = new Map();
+ const holding = new Map();
+ const ends = sessions
+ .flatMap((s, i) => (s.leftAt ? [{ i, until: new Date(s.leftAt).getTime() + 10_000 }] : []))
+ .sort((a, b) => a.until - b.until);
+ let joins = 0;
+ let leaves = 0;
+ const tracks: NameTracks = new Map();
+ const readings = new Map();
+ const kicked = (steamId: string) =>
+ c.action === 'kick' && !(c.spareReserved && reserved.has(steamId));
+ for (let i = 0; i < rows.length; i++) {
+ const r = rows[i];
+ const at = new Date(r.ts).getTime();
+ for (
+ ;
+ joins < sessions.length && new Date(sessions[joins].joinedAt).getTime() <= at;
+ joins++
+ ) {
+ listed.set(sessions[joins].steamId, sessions[joins].name);
+ holding.set(sessions[joins].steamId, joins);
+ }
+ for (; leaves < ends.length && ends[leaves].until < at; leaves++) {
+ const s = sessions[ends[leaves].i];
+ if (holding.get(s.steamId) !== ends[leaves].i) continue;
+ listed.delete(s.steamId);
+ holding.delete(s.steamId);
+ }
+ if (i % 1000 === 0) pruneNameTracks(c, tracks, at);
+ const shown: { steamId: string; name: string }[] = [];
+ if (r.killer && r.killerName !== null) shown.push({ steamId: r.killer, name: r.killerName });
+ if (r.victim && r.victim !== r.killer && r.victimName !== null)
+ shown.push({ steamId: r.victim, name: r.victimName });
+ for (const h of nameChangeStep(c, tracks, shown, listed, at, { kicked, readings }).hits)
+ push(
+ new Date(at),
+ `${kicked(h.player.steamId) ? 'kick' : 'flag'} ${h.listed} (${h.player.steamId}): ${h.verdict}`
+ );
+ }
+ const [feed] = await env.db
+ .select({ configured: sql`feed_token_hash IS NOT NULL` })
+ .from(servers)
+ .where(eq(servers.id, server.id));
+ if (!feed?.configured)
+ result.notes.push(
+ 'This server has no kill feed set up (Config tab), so the rule cannot see any names.'
+ );
+ result.notes.push(
+ `${rows.length} kill${rows.length === 1 ? '' : 's'} in the window, each player's name held against the one their session had: a name is checked each time its player kills or dies.`
+ );
+ if (rows.length >= NAME_CHANGE_REPLAY_MAX)
+ result.notes.push(
+ `Replayed the first ${NAME_CHANGE_REPLAY_MAX.toLocaleString('en')} kills of the window only.`
+ );
+ if (sessions.length >= NAME_SESSIONS_MAX)
+ result.notes.push(
+ `Read the first ${NAME_SESSIONS_MAX.toLocaleString('en')} sessions of the window only; players of later ones were not judged.`
+ );
+ return result;
+ }
if (kind === 'restart_notice') {
const c = cfg as RestartNoticeConfig;
const [live] = await env.db
diff --git a/src/lib/server/webhook-delivery.ts b/src/lib/server/webhook-delivery.ts
index 5975254b..abecf63d 100644
--- a/src/lib/server/webhook-delivery.ts
+++ b/src/lib/server/webhook-delivery.ts
@@ -151,6 +151,7 @@ const ACTION_TITLES: Record = {
'trigger.two_teams': 'Trigger · two-team mode',
'trigger.kill_distance': 'Trigger · kill distance watch',
'trigger.afk_protection': 'Trigger · AFK protection',
+ 'trigger.name_change': 'Trigger · name change watch',
'player.note': 'Player note',
'player.watch': 'Watchlist',
'list.add': 'Org list · added',
@@ -198,8 +199,13 @@ export function buildEmbed(appName: string, row: AuditRow): Embed {
};
}
-/** A Kill rate or Kill distance post is a prompt to go and look: it opens the player's page. */
-const DOSSIER_LINKED = new Set(['trigger.kill_rate', 'trigger.kill_distance']);
+/** A Kill rate, Kill distance or Name change post is a prompt to go and look: it opens the player's
+ * page. */
+const DOSSIER_LINKED = new Set([
+ 'trigger.kill_rate',
+ 'trigger.kill_distance',
+ 'trigger.name_change'
+]);
function withDossierLink(env: Env, row: AuditRow, embed: Embed): Embed {
if (!DOSSIER_LINKED.has(row.action) || !row.serverId || !row.target) return embed;
const url = dossierUrl(env.ORIGIN, row.serverId, row.target);
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 23e2bd47..7df0d4da 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -513,7 +513,8 @@ export type TriggerKind =
| 'kill_rate'
| 'two_teams'
| 'kill_distance'
- | 'afk_protection';
+ | 'afk_protection'
+ | 'name_change';
export interface TriggerView {
id: string;
diff --git a/src/test/access.test.ts b/src/test/access.test.ts
index adcf7085..3bf50150 100644
--- a/src/test/access.test.ts
+++ b/src/test/access.test.ts
@@ -529,6 +529,8 @@ describe.skipIf(!hasTestDb)('access', () => {
['kill_distance', { causes: [DEFIB], action: 'ban', banScope: 'org' }, 'lists.ban'],
['two_teams', { closedFaction: 'Lonestar' }, 'players.move'],
['afk_protection', {}, 'players.kill'],
+ ['name_change', { takenOnly: true }, 'players.kick'],
+ ['name_change', { action: 'kick' }, 'players.kick'],
['seed_reward', { minutes: 60, scope: 'server' }, 'slots.manage'],
['seed_reward', { minutes: 60, scope: 'org' }, 'lists.reserve']
];
@@ -771,6 +773,61 @@ describe.skipIf(!hasTestDb)('access', () => {
expect(moved.status).toBe(404);
});
+ test('a Name change rule: who may save, dry-run and switch it on', async () => {
+ const w = await seedWorld(env);
+ const body = { kind: 'name_change', config: { takenOnly: true } };
+ const made = await api(w, 'owner', 'POST api/servers/[id]/triggers', {
+ params: { id: w.server.id },
+ body
+ });
+ expect(made.status).toBe(201);
+ const triggerId = (made.body as { trigger: { id: string } }).trigger.id;
+ // Automation without Kick: an alert-only rule is still not theirs to make, replay or enable.
+ await env.db
+ .update(orgRoles)
+ .set({ capabilities: ['server.view', 'automation.manage'] })
+ .where(eq(orgRoles.id, w.roles.viewer));
+ const expected: Record = {
+ anon: 401,
+ stranger: 404,
+ outsider: 404,
+ member: 404,
+ viewer: 403,
+ operator: 403,
+ admin: 200,
+ elsewhere: 404,
+ orgBans: 403,
+ orgSlots: 403,
+ owner: 200,
+ site: 200,
+ keyView: 403,
+ keyAll: 200,
+ keyElsewhere: 404,
+ keyBans: 403
+ };
+ for (const [who, status] of Object.entries(expected) as [PrincipalName, number][]) {
+ const got = [
+ await api(w, who, 'POST api/servers/[id]/triggers/dry-run', {
+ params: { id: w.server.id },
+ body
+ }),
+ await api(w, who, 'PATCH api/servers/[id]/triggers/[triggerId]', {
+ params: { id: w.server.id, triggerId },
+ body: { enabled: true }
+ }),
+ await api(w, who, 'POST api/servers/[id]/triggers', { params: { id: w.server.id }, body })
+ ].map((r) => r.status);
+ // a create that gets through answers 201
+ expect([who, ...got]).toEqual([who, status, status, status === 200 ? 201 : status]);
+ }
+ // The rule's id under another server's path is not found, even for its org's owner.
+ const moved = await api(w, 'owner', 'PATCH api/servers/[id]/triggers/[triggerId]', {
+ params: { id: w.otherServer.id, triggerId },
+ body: { enabled: false }
+ });
+ expect(moved.status).toBe(404);
+ });
+
test('a Kill distance rule that bans: who may save, dry-run and switch it on, for each list', async () => {
const w = await seedWorld(env);
// Automation and Kick, but neither ban list: a ban rule is not theirs.
diff --git a/src/test/name-change-rule.test.ts b/src/test/name-change-rule.test.ts
new file mode 100644
index 00000000..bc1f5ad2
--- /dev/null
+++ b/src/test/name-change-rule.test.ts
@@ -0,0 +1,420 @@
+// The Name change watch as the worker runs it, end to end against the database and the mock game:
+// kills come in through the real ingest and each player's name in the feed is held against the
+// name the server's player list has for them. A player shown under another listed player's name is
+// flagged (or kicked) at that kill, on that server only; their own name with another clan tag, or
+// a player off the list, is not judged; switching the rule off or editing it stops what it had
+// queued; and the dry run finds the same in the day's kills against the sessions open at each.
+import { afterAll, beforeAll, describe, expect, spyOn, test } from 'bun:test';
+import { and, eq } from 'drizzle-orm';
+import { join } from 'node:path';
+import type { Env } from '$lib/server/env';
+import {
+ kills,
+ organizations,
+ outbox,
+ playerSessions,
+ servers,
+ triggers
+} from '$lib/server/db/schema';
+import { newId } from '$lib/server/http';
+import { ingestBatch } from '$lib/server/feed';
+import { onKillsIngested } from '$lib/server/feed-events';
+import { acquireOrRenew, releaseOwnership } from '$lib/server/leadership';
+import { forgetMemory, memoryFor, observeServer } from '$lib/server/observe';
+import { startDelivery, stopDelivery } from '$lib/server/outbox';
+import { WardogsClient } from '$lib/server/rcon';
+import { validateConfig } from '$lib/server/trigger-rules';
+import { dryRun, invalidateTriggers } from '$lib/server/triggers';
+import { NAME_FLAG } from '$lib/server/name-filter';
+import { nameChangeSettingsKey, type NameChangeConfig } from '$lib/server/name-change';
+import { callApi, stubGateway } from './call';
+import { hasTestDb, testEnv } from './db';
+import { seedWorld, type World } from './world';
+
+const COPIER = '76561198000000701';
+const ORIGINAL = '76561198000000702';
+const STEADY = '76561198000000703';
+const STRANGER = '76561198000000704';
+
+type Named = [steamId: string, name: string];
+
+/** One `killed` event as the game posts it, the players named as the feed shows them; no killer
+ * is the environment. */
+const ev = (killer: Named | null, victim: Named, eventTime = 100) => ({
+ eventId: newId(),
+ type: 'killed',
+ eventTime,
+ matchId: 'boot',
+ mapName: 'Kavkazi',
+ ...(killer ? { killerName: killer[1], killerSteamId: killer[0] } : {}),
+ victimName: victim[1],
+ victimSteamId: victim[0],
+ cause: 'Id.Item.AK74M',
+ distance: 5000,
+ contextTags: []
+});
+
+describe.skipIf(!hasTestDb)('the name change rule on the kill feed', () => {
+ let env: Env;
+ /** who the stand-in game lists, per server, under which name */
+ const lists = new Map();
+ const used: string[] = [];
+ let spy: ReturnType;
+
+ beforeAll(async () => {
+ env = { ...(await testEnv()), STEAM_API_KEY: '' };
+ stubGateway();
+ expect(await acquireOrRenew(env, 'name-change-live')).toBe(true);
+ // The mock game answers everything but the player list, which the test scripts per server.
+ spy = spyOn(WardogsClient, 'forServer').mockImplementation(async (_env, server) => {
+ const client = new WardogsClient(
+ env,
+ { id: server.id, host: 'demo', port: 1, scheme: 'http' },
+ 'demo',
+ `name-change-${server.id}`
+ );
+ const raw = client.raw.bind(client);
+ client.raw = async (method, path, body, headers) =>
+ method === 'GET' && path === '/v1/players'
+ ? {
+ status: 200,
+ statusText: 'OK',
+ headers: { 'content-type': 'application/json' },
+ text: JSON.stringify({
+ players: (lists.get(server.id) ?? []).map(([steamId, name]) => ({
+ steamId,
+ name,
+ faction: null,
+ kills: 0
+ }))
+ })
+ }
+ : raw(method, path, body, headers);
+ return client;
+ });
+ });
+
+ afterAll(async () => {
+ await stopDelivery();
+ spy.mockRestore();
+ for (const id of used) forgetMemory(id);
+ await releaseOwnership(env);
+ });
+
+ const rule = async (w: World, serverId: string, config: Record) => {
+ const id = newId();
+ await env.db.insert(triggers).values({
+ id,
+ serverId,
+ orgId: w.org.id,
+ kind: 'name_change',
+ name: 'Name change watch',
+ enabled: true,
+ config: validateConfig('name_change', config)
+ });
+ invalidateTriggers(serverId);
+ return id;
+ };
+ /** The server's player list, as the worker reads it at a look. */
+ const listing = async (w: World, serverId: string, ...players: Named[]) => {
+ const [server] = await env.db.select().from(servers).where(eq(servers.id, serverId));
+ const [org] = await env.db.select().from(organizations).where(eq(organizations.id, w.org.id));
+ const m = memoryFor(server, org);
+ used.push(serverId);
+ lists.set(serverId, players);
+ m.playersIntervalMs = 1000;
+ await observeServer(env, m, { status: true, players: true });
+ return m;
+ };
+ /** A batch through the real ingest, then the rules. */
+ const post = async (serverId: string, ...events: ReturnType[]) => {
+ const r = await ingestBatch(env, serverId, { serverId: 'i', serverName: 'x', events });
+ await onKillsIngested(env, serverId, r.kills);
+ };
+ const rowsOf = (triggerId: string) =>
+ env.db.select().from(outbox).where(eq(outbox.triggerId, triggerId)).orderBy(outbox.id);
+ const rowOf = async (id: number) =>
+ (await env.db.select().from(outbox).where(eq(outbox.id, id)))[0];
+ /** Runs the delivery loop until the rows are done. */
+ const deliver = async (...ids: number[]) => {
+ startDelivery(env);
+ for (let i = 0; i < 100; i++) {
+ const rows = await Promise.all(ids.map(rowOf));
+ if (rows.every((r) => r.state !== 'pending' && r.state !== 'sending')) break;
+ await Bun.sleep(50);
+ }
+ await stopDelivery();
+ return Promise.all(ids.map(rowOf));
+ };
+
+ test("a player the feed shows under another listed player's name is flagged at that kill, as the name they are listed under, on that server only", async () => {
+ const w = await seedWorld(env);
+ const here = await rule(w, w.server.id, { takenOnly: true });
+ const there = await rule(w, w.otherServer.id, { takenOnly: true });
+ await listing(
+ w,
+ w.server.id,
+ [COPIER, 'Ghostpepper'],
+ [ORIGINAL, 'Silver Fox'],
+ [STEADY, 'Steady']
+ );
+ await listing(w, w.otherServer.id, [COPIER, 'Ghostpepper'], [ORIGINAL, 'Silver Fox']);
+ await post(w.server.id, ev([COPIER, 'Ghostpepper'], [STEADY, 'Steady']));
+ expect(await rowsOf(here)).toHaveLength(0);
+
+ await post(w.server.id, ev([COPIER, 'Silver Fox'], [STEADY, 'Steady']));
+ const rows = await rowsOf(here);
+ expect(rows.map((r) => [r.action, r.target, r.steamId])).toEqual([[NAME_FLAG, COPIER, COPIER]]);
+ expect(rows[0].okMessage).toBe(
+ `Flagged Ghostpepper: shown as Silver Fox, the name of ${ORIGINAL}`
+ );
+ expect(rows[0].detail).toMatchObject({
+ name: 'Ghostpepper',
+ shown: 'Silver Fox',
+ holder: ORIGINAL,
+ changes: 1
+ });
+ expect(rows[0].params).toEqual({
+ rule: nameChangeSettingsKey(
+ validateConfig('name_change', { takenOnly: true }) as NameChangeConfig
+ )
+ });
+ // shown under the same name again, as killer or victim: nothing more
+ await post(
+ w.server.id,
+ ev([COPIER, 'Silver Fox'], [STEADY, 'Steady']),
+ ev([STEADY, 'Steady'], [COPIER, 'Silver Fox'], 101)
+ );
+ expect(await rowsOf(here)).toHaveLength(1);
+ // the other server's rule never saw the kills
+ expect(await rowsOf(there)).toHaveLength(0);
+ const [fired] = await env.db
+ .select({ lastResult: triggers.lastResult })
+ .from(triggers)
+ .where(eq(triggers.id, here));
+ expect(fired.lastResult).toBe(
+ `Flagging Ghostpepper: shown as Silver Fox, the name of ${ORIGINAL}`
+ );
+ });
+
+ test('their own name with another clan tag is no change, a player off the list is not judged, and a victim is judged as a killer is', async () => {
+ const w = await seedWorld(env);
+ const id = await rule(w, w.server.id, {});
+ await listing(w, w.server.id, [COPIER, 'Ghostpepper'], [STEADY, '[ABC] Steady']);
+ await post(
+ w.server.id,
+ ev([STEADY, '[XYZ] Steady'], [COPIER, 'Ghostpepper']),
+ ev([STRANGER, 'Silver Fox'], [STEADY, 'Steady'], 101)
+ );
+ expect(await rowsOf(id)).toHaveLength(0);
+ // a change of name shows when the player dies too
+ await post(w.server.id, ev([STEADY, '[ABC] Steady'], [COPIER, 'Tango']));
+ expect((await rowsOf(id)).map((r) => [r.action, r.target, r.okMessage])).toEqual([
+ [NAME_FLAG, COPIER, 'Flagged Ghostpepper: shown as Tango']
+ ]);
+ });
+
+ test('a kick rule asks the game to kick the player, with the reason filled in', async () => {
+ const w = await seedWorld(env);
+ const config = {
+ action: 'kick',
+ spareReserved: false,
+ reason: 'Not as {name} (was {previous}), {kills} kills here.'
+ };
+ const id = await rule(w, w.otherServer.id, config);
+ await listing(w, w.otherServer.id, [COPIER, 'Ghostpepper'], [STEADY, 'Steady']);
+ await post(w.otherServer.id, ev([COPIER, 'Steady'], [STEADY, 'Steady']));
+ const [kick] = await env.db
+ .select()
+ .from(outbox)
+ .where(and(eq(outbox.triggerId, id), eq(outbox.action, 'kick')));
+ // the name they show, the rule's {previous} (the name they are listed under), their stats here
+ expect(kick.params).toEqual({
+ steamId: COPIER,
+ reason: 'Not as Steady (was Ghostpepper), 0 kills here.',
+ rule: nameChangeSettingsKey(validateConfig('name_change', config) as NameChangeConfig)
+ });
+ expect(kick.steamId).toBe(COPIER);
+ // a kick rule acts at every change: still at it, kicked again; and shown under a name it was
+ // kicked for before (back after the kick, or the kick not landed yet), again
+ await post(w.otherServer.id, ev([COPIER, 'Silver Fox'], [STEADY, 'Steady'], 101));
+ await post(w.otherServer.id, ev([COPIER, 'Silver Fox'], [STEADY, 'Steady'], 102));
+ expect(
+ (await rowsOf(id)).map((r) => [r.action, (r.detail as { shown: string }).shown])
+ ).toEqual([
+ ['kick', 'Steady'],
+ ['kick', 'Silver Fox'],
+ ['kick', 'Silver Fox']
+ ]);
+ });
+
+ test('an edit starts the count over: a player an alert rule flagged is kicked once it kicks', async () => {
+ const w = await seedWorld(env);
+ const id = await rule(w, w.server.id, {});
+ await listing(w, w.server.id, [COPIER, 'Ghostpepper'], [STEADY, 'Steady']);
+ await post(w.server.id, ev([COPIER, 'Steady'], [STEADY, 'Steady']));
+ await env.db
+ .update(triggers)
+ .set({ config: validateConfig('name_change', { action: 'kick', spareReserved: false }) })
+ .where(eq(triggers.id, id));
+ invalidateTriggers(w.server.id);
+ await post(w.server.id, ev([COPIER, 'Steady'], [STEADY, 'Steady'], 101));
+ expect((await rowsOf(id)).map((r) => r.action)).toEqual([NAME_FLAG, 'kick']);
+ });
+
+ test('a suicide names its player once, an environment kill its victim alone, and nothing is judged while the list is empty', async () => {
+ const w = await seedWorld(env);
+ const id = await rule(w, w.server.id, {});
+ await listing(w, w.server.id, [COPIER, 'Ghostpepper'], [STEADY, 'Steady']);
+ await post(
+ w.server.id,
+ ev([COPIER, 'Tango'], [COPIER, 'Tango']),
+ ev(null, [STEADY, 'Bravo'], 101)
+ );
+ expect((await rowsOf(id)).map((r) => [r.target, r.okMessage])).toEqual([
+ [COPIER, 'Flagged Ghostpepper: shown as Tango'],
+ [STEADY, 'Flagged Steady: shown as Bravo']
+ ]);
+ // the next map loading: nobody listed, nothing to hold the feed against
+ const kicking = await rule(w, w.server.id, { action: 'kick', spareReserved: false });
+ await listing(w, w.server.id);
+ await post(w.server.id, ev([COPIER, 'Zulu'], [STEADY, 'Steady'], 102));
+ expect(await rowsOf(kicking)).toHaveLength(0);
+ await listing(w, w.server.id, [COPIER, 'Ghostpepper'], [STEADY, 'Steady']);
+ await post(w.server.id, ev([COPIER, 'Zulu'], [STEADY, 'Steady'], 103));
+ expect((await rowsOf(kicking)).map((r) => r.action)).toEqual(['kick']);
+ });
+
+ test('a kick rule flags a reserved slot instead, and everyone until the reserved list is read', async () => {
+ const w = await seedWorld(env);
+ const id = await rule(w, w.server.id, { action: 'kick' });
+ const m = await listing(w, w.server.id, [COPIER, 'Ghostpepper'], [STEADY, 'Steady']);
+ m.reserved = new Set([COPIER]);
+ m.reservedAt = Date.now();
+ await post(w.server.id, ev([COPIER, 'Alpha'], [STEADY, 'Bravo']));
+ expect((await rowsOf(id)).map((r) => [r.target, r.action, r.okMessage])).toEqual([
+ [COPIER, NAME_FLAG, 'Flagged Ghostpepper (reserved slot): shown as Alpha'],
+ [STEADY, 'kick', 'Kicked Steady: shown as Bravo']
+ ]);
+ m.reservedAt = 0;
+ await post(w.server.id, ev([STEADY, 'Charlie'], [COPIER, 'Ghostpepper'], 101));
+ expect((await rowsOf(id)).map((r) => r.action)).toEqual([NAME_FLAG, 'kick', NAME_FLAG]);
+ });
+
+ test('switching the rule off drops the kick it had queued, and a flag decided under old settings is not delivered', async () => {
+ const w = await seedWorld(env);
+ const kicking = await rule(w, w.server.id, { action: 'kick', spareReserved: false });
+ await listing(w, w.server.id, [COPIER, 'Ghostpepper'], [STEADY, 'Steady']);
+ await post(w.server.id, ev([COPIER, 'Juliet'], [STEADY, 'Steady']));
+ const [queued] = await rowsOf(kicking);
+ expect([queued.action, queued.state]).toEqual(['kick', 'pending']);
+ const mod = await import(
+ join(
+ import.meta.dir,
+ '..',
+ 'routes',
+ 'api',
+ 'servers',
+ '[id]',
+ 'triggers',
+ '[triggerId]',
+ '+server.ts'
+ )
+ );
+ const off = await callApi(mod.PATCH, w.users.owner, {
+ method: 'PATCH',
+ params: { id: w.server.id, triggerId: kicking },
+ body: { enabled: false }
+ });
+ expect(off.status).toBe(200);
+ expect(await rowOf(queued.id)).toMatchObject({
+ state: 'skipped',
+ outcome: 'The rule was changed before this was sent.'
+ });
+
+ // a flag under the settings its rule still holds is delivered; one an edit overtook is not
+ const flagging = await rule(w, w.server.id, {});
+ await post(w.server.id, ev([COPIER, 'Kilo'], [STEADY, 'Steady'], 101));
+ const [held] = await rowsOf(flagging);
+ expect(held.action).toBe(NAME_FLAG);
+ expect((await deliver(held.id)).map((r) => r.state)).toEqual(['delivered']);
+ // an alert rule tells staff once a window: a quick change after it is not flagged again
+ await post(w.server.id, ev([COPIER, 'Lima'], [STEADY, 'Steady'], 102));
+ expect(await rowsOf(flagging)).toHaveLength(1);
+ const fresh = await rule(w, w.server.id, { windowMinutes: 5 });
+ await post(w.server.id, ev([COPIER, 'Mike'], [STEADY, 'Steady'], 103));
+ const [late] = await rowsOf(fresh);
+ await env.db
+ .update(triggers)
+ .set({ config: validateConfig('name_change', { windowMinutes: 30 }) })
+ .where(eq(triggers.id, fresh));
+ const [done] = await deliver(late.id);
+ expect([done.state, done.outcome]).toEqual([
+ 'skipped',
+ 'The rule was changed before this was sent.'
+ ]);
+ });
+
+ test("the dry run finds the same in the day's kills, against the names of the sessions open at each", async () => {
+ const w = await seedWorld(env);
+ const server = { ...w.server, name: 'one' } as Parameters[1];
+ const at = Date.now() - 3600_000;
+ await env.db.insert(playerSessions).values(
+ (
+ [
+ [COPIER, 'Ghostpepper'],
+ [ORIGINAL, '[R-14] Silver Fox'],
+ [STEADY, 'Steady']
+ ] as Named[]
+ ).map(([steamId, name]) => ({
+ serverId: w.server.id,
+ steamId,
+ name,
+ joinedAt: new Date(at - 60_000),
+ lastSeen: new Date(at - 60_000)
+ }))
+ );
+ // a player who left before the kills that name them is not on the list then
+ await env.db.insert(playerSessions).values({
+ serverId: w.server.id,
+ steamId: STRANGER,
+ name: 'Tango',
+ joinedAt: new Date(at - 120_000),
+ lastSeen: new Date(at - 90_000),
+ leftAt: new Date(at - 90_000)
+ });
+ const kill = (i: number, killer: Named, victim: Named) => ({
+ ts: new Date(at + i * 60_000),
+ serverId: w.server.id,
+ eventId: newId(),
+ instanceId: 'i',
+ matchId: 'm',
+ eventTime: i * 60,
+ map: 'Kavkazi',
+ killerSteamId: killer[0],
+ killerName: killer[1],
+ victimSteamId: victim[0],
+ victimName: victim[1],
+ cause: 'Id.Item.AK74M',
+ tags: []
+ });
+ await env.db
+ .insert(kills)
+ .values([
+ kill(0, [COPIER, 'Ghostpepper'], [ORIGINAL, '[R-14] Silver Fox']),
+ kill(1, [COPIER, 'Silver Fox'], [STEADY, 'Steady']),
+ kill(2, [STEADY, '[ABC] Steady'], [COPIER, 'Tango']),
+ kill(3, [STRANGER, 'Steady'], [ORIGINAL, '[R-14] Silver Fox'])
+ ]);
+ const taken = await dryRun(env, server, 'name_change', { takenOnly: true });
+ expect(taken.items.map((i) => i.text)).toEqual([
+ `flag Ghostpepper (${COPIER}): shown as Silver Fox, the name of ${ORIGINAL}`
+ ]);
+ const every = await dryRun(env, server, 'name_change', { action: 'kick' });
+ expect(every.items.map((i) => i.text)).toEqual([
+ `kick Ghostpepper (${COPIER}): shown as Silver Fox, the name of ${ORIGINAL}`,
+ `kick Ghostpepper (${COPIER}): shown as Tango`
+ ]);
+ expect(every.notes.join(' ')).toContain('4 kills in the window');
+ });
+});
diff --git a/src/test/player-actions.test.ts b/src/test/player-actions.test.ts
index f26ce2c5..ea215f58 100644
--- a/src/test/player-actions.test.ts
+++ b/src/test/player-actions.test.ts
@@ -117,6 +117,8 @@ describe.skipIf(!hasTestDb)('Kick, Kill and Move apart', () => {
['kill_rate', { maxKills: 20 }, 'players.kick'],
['kill_distance', { causes: [DEFIB], action: 'flag' }, 'players.kick'],
['kill_distance', { causes: [DEFIB], action: 'kick' }, 'players.kick'],
+ ['name_change', { takenOnly: true }, 'players.kick'],
+ ['name_change', { action: 'kick' }, 'players.kick'],
['two_teams', { closedFaction: 'Lonestar' }, 'players.move']
];
const routes = ['servers/[id]/triggers', 'servers/[id]/triggers/dry-run'];
From 8637160f727cfce88bd779cd84cca85ff26713c8 Mon Sep 17 00:00:00 2001
From: James Causon
Date: Mon, 28 Sep 2026 20:43:59 +0100
Subject: [PATCH 031/102] Automation tab: the Name change watch rule, and the
Name filter's note says names are checked whenever they change
# Conflicts:
# src/routes/(app)/server/[id]/automation/+page.svelte
---
.../(app)/server/[id]/automation/+page.svelte | 122 ++++++++++++++++--
1 file changed, 112 insertions(+), 10 deletions(-)
diff --git a/src/routes/(app)/server/[id]/automation/+page.svelte b/src/routes/(app)/server/[id]/automation/+page.svelte
index b65a9909..8d515e3d 100644
--- a/src/routes/(app)/server/[id]/automation/+page.svelte
+++ b/src/routes/(app)/server/[id]/automation/+page.svelte
@@ -159,6 +159,12 @@
label: 'Name filter',
blurb: 'Kick or flag joiners whose name uses characters or words this server does not allow.'
},
+ {
+ kind: 'name_change',
+ group: 'Players',
+ label: 'Name change watch',
+ blurb: "Flag or kick players who change their name mid-game, or take another player's."
+ },
{
kind: 'ping_kick',
group: 'Players',
@@ -231,6 +237,7 @@
case 'team_kill':
case 'kill_rate':
case 'kill_distance':
+ case 'name_change':
return data.feed
? ''
: 'Needs the kill feed, which is off on this server. Turn it on under Config.';
@@ -323,7 +330,10 @@
list.some((c) => c.toLowerCase() === cause.toLowerCase());
/** A kind that lacks what it needs stays in the menu, greyed, with the reason in a few words. */
const short = (kind: TriggerKind): string =>
- kind === 'team_kill' || kind === 'kill_rate' || kind === 'kill_distance'
+ kind === 'team_kill' ||
+ kind === 'kill_rate' ||
+ kind === 'kill_distance' ||
+ kind === 'name_change'
? 'needs the kill feed'
: kind === 'risk_kick'
? 'needs a Steam key'
@@ -446,6 +456,8 @@
blocked: string;
allowed: string;
nameAction: 'kick' | 'alert';
+ takenOnly: boolean;
+ changes: number;
windowMinutes: number;
maxKills: number;
headshotPct: number;
@@ -591,11 +603,13 @@
'reason',
kind === 'name_filter'
? 'Your name is not allowed on this server: {why}.'
- : kind === 'ping_kick'
- ? 'Ping too high for too long.'
- : kind === 'kill_distance'
- ? distanceText(distanceAction)
- : 'Your account does not meet this server’s requirements.'
+ : kind === 'name_change'
+ ? 'Changing your name mid-game is not allowed here.'
+ : kind === 'ping_kick'
+ ? 'Ping too high for too long.'
+ : kind === 'kill_distance'
+ ? distanceText(distanceAction)
+ : 'Your account does not meet this server’s requirements.'
),
leadMinutes: n('leadMinutes', 30),
leadMessage: s(
@@ -629,8 +643,16 @@
builtinWords: b('builtinWords', !t),
blocked: Array.isArray(c.blocked) ? (c.blocked as string[]).join('\n') : '',
allowed: Array.isArray(c.allowed) ? (c.allowed as string[]).join('\n') : '',
- nameAction: c.action === 'alert' ? 'alert' : 'kick',
- windowMinutes: n('windowMinutes', 5),
+ // a new Name change rule only alerts until it is told to kick
+ nameAction:
+ c.action === 'alert' || c.action === 'kick'
+ ? c.action
+ : kind === 'name_change'
+ ? 'alert'
+ : 'kick',
+ takenOnly: b('takenOnly', false),
+ changes: n('changes', 1),
+ windowMinutes: n('windowMinutes', kind === 'name_change' ? 10 : 5),
maxKills: n('maxKills', 25),
headshotPct: n('headshotPct', 70),
headshotMinKills: n('headshotMinKills', 15),
@@ -730,6 +752,15 @@
spareReserved: f.spareReserved,
reason: f.reason
};
+ case 'name_change':
+ return {
+ takenOnly: f.takenOnly,
+ changes: Number(f.changes),
+ windowMinutes: Number(f.windowMinutes),
+ action: f.nameAction,
+ spareReserved: f.spareReserved,
+ reason: f.reason
+ };
case 'ping_kick':
return {
maxPingMs: Number(f.maxPingMs),
@@ -937,6 +968,16 @@
.filter(Boolean)
.join(' · ');
}
+ case 'name_change':
+ return [
+ c.takenOnly ? "taking another player's name" : 'any name change',
+ Number(c.changes) > 1 ? `${c.changes} changes in ${c.windowMinutes} min` : '',
+ c.action === 'kick'
+ ? `kick${c.spareReserved ? ', flag reserved slots' : ''}`
+ : 'alert only'
+ ]
+ .filter(Boolean)
+ .join(' · ');
case 'ping_kick':
return `ping over ${c.maxPingMs} ms for ${c.durationSeconds} s`;
case 'restart_notice':
@@ -1773,8 +1814,69 @@
{/if}
- Names are checked as players join; a player who renames mid-session is caught on their
- next join. Run the dry run before turning a word list loose.
+ Names are checked as players join and whenever they change. Run the dry run before
+ turning a word list loose.
+
+ {:else if f.kind === 'name_change'}
+
+
+ Every name change
+ Only a change to another player's
+ name
+
+
+ or more changes within
+
+ minutes
+
+
+
+
+ Alert only
+ (audit trail and Discord, nobody is kicked)
+ Kick the player
+ {#if f.nameAction === 'kick'}
+ Flag players with a reserved
+ slot instead
+ {/if}
+
+ Names come from the kill feed: each time a player kills or dies, the name it shows is
+ held against the one the server lists them under. A clan tag put on, taken off or
+ swapped is not a change.
{:else if f.kind === 'ping_kick'}
From deeac50119f962d554e16e67d3815b81c7ae8cab Mon Sep 17 00:00:00 2001
From: James Causon
Date: Mon, 28 Sep 2026 20:43:59 +0100
Subject: [PATCH 032/102] Document the Name change watch
---
README.md | 19 ++++++++++---------
1 file changed, 10 insertions(+), 9 deletions(-)
diff --git a/README.md b/README.md
index 6f136d64..95b707d8 100644
--- a/README.md
+++ b/README.md
@@ -536,8 +536,8 @@ create them; every action they take is in the audit trail under the `trigger` ca
rule that fired, and can be mirrored to Discord. A rule acts with nobody at the controls, so
saving or dry-running one needs, besides _Automation_, the capability for what it does: _Chat_ for
the rules that message players (a Kill distance watch that warns among them), _Match control_ for
-the map reset, _Kick_ for the rules that kick, the Kill rate watch and a Kill distance watch that
-flags or kicks, _Move_ for Team balance (with
+the map reset, _Kick_ for the rules that kick, the Kill rate watch, the Name change watch and a
+Kill distance watch that flags or kicks, _Move_ for Team balance (with
_Chat_ as well when it whispers), _Kill_ for AFK protection (with _Chat_ as well when it
broadcasts) and for a Kill distance watch that kills (with _Chat_ as well, for the whisper that
comes with it), for a Kill distance watch that bans _Bans_ (on this server) or _Org
@@ -552,6 +552,7 @@ delete them.
| Empty-server map reset | After the server has been empty for N minutes on a different map or mode, sets the chosen map as next and ends the match (or requests it directly when there is no rotation). |
| Kick on connect risk | Kicks players who match rules: VAC ban or game ban (optionally only within the last N days), Steam account younger than N days (optionally private profiles too), banned on another server in the org, or on the watchlist; or whose advisory risk score is at or above a set number from 1 to 100 (medium starts at 20, high at 50; the players table badges 20 and up). Joiners and players reconnecting are judged at once, and everyone on is judged again every minute (a player whose kick did not land, every five). Nobody is judged until the server's reserved slots have been read. Reserved-slot players can be spared. |
| Name filter | Kicks joiners whose name breaks the rule, or with _Alert only_ just records them (audit trail and Discord). A character policy: any, Latin letters (keeps José and Müller, optionally with Cyrillic, Greek, Arabic, Hebrew, Thai, Devanagari, Chinese, Japanese or Korean beside them) or ASCII only; digits, spaces and keyboard punctuation always pass, emoji and symbols only when allowed, and a name can be required to hold N letters. Blocked words: a built-in English list of slurs and hate terms, your own words (up to 200) and exceptions for names that would match but are fine. Words are caught through case, leetspeak, look-alike letters, stretching and spelling out (`n.a.z.i`). The kick reason's `{why}` says what kind of fault it was, never the word. Reserved-slot players can be spared. Checked at the join and again whenever the name changes, clan tag included: the game can show the tag a moment after the player is in. A kick rule also checks a player who reconnects. Its dry run checks everyone who has played on the server, under each name they used. |
+| Name change watch | Flags a player who changes their name mid-game, or with _Kick_ kicks them (a reserved slot can be flagged instead). The game's player list keeps the name a player joined with (a clan tag changed mid-game aside), while the [kill feed](#kill-feed) names both players of every kill as the others see them: each time a player kills or dies, the rule holds the name the feed shows against the one the list has for them. Counts every change, or only a change to the name of another player on the server. Names are compared as they read: case, spacing, punctuation, symbols, invisible characters, look-alike letters, I/l/1 and 0/O are no disguise, nor is a clan tag in brackets dropped or added to a copied name (names of five letters or more); a player's own name with a clan tag put on, taken off or swapped is not a change. Acts at N changes within M minutes (up to two hours): an alert flags a player once a window, a kick acts at every count, and a kicked player who comes back under the taken name goes again. In the kick reason `{name}` is the name the feed showed and `{previous}` the one they are listed under. Flags go to the audit trail and Discord with the name they are listed under and their SteamID (the post opens the player's page). Changing or switching it off drops what it had queued. Needs the kill feed; the worker's memory of names starts over when it restarts. The dry run holds the day's kills against the names of the sessions open at each. |
| High ping kick | Kicks a player whose reported ping remains above a configurable limit for a configurable number of seconds. Normal or unavailable ping, leaving, or interrupted player-list polling resets the timer. Historical ping is not stored, so this rule cannot be replayed in a dry run. |
| Team kill limit | Whispers a player from N team kills in one match, and kicks them at M; leaving and rejoining does not start the count over. Team kills by what the rule leaves out are not counted and still show as team kills everywhere else: barbed wire unless unticked (a player who runs into a teammate's wire is reported as killed by whoever built it), and any other buildable or placed charge ticked. Needs the [kill feed](#kill-feed); acted on as each kill arrives, not per poll. |
| Kill rate watch | Flags a player, never kicks, when their kills with hand-held weapons in the last N minutes reach a count, or their share of headshots over those kills reaches a percentage once they have at least M; vehicles, their guns and buildables are not counted. The flag goes to the audit trail and the Discord mirror (the post opens the player's page in the panel), and the same player is flagged again only after a cooldown. Needs the [kill feed](#kill-feed), which has no position or aim, so a flag is a reason to look, not proof. The windows are kept in the worker's memory and start over when it restarts. |
@@ -578,13 +579,13 @@ Team kill whisper, the Team balance and Seeding reward whispers, and every kick
the player's and their stats as well. A placeholder with nothing to fill in a message (a player's in
a broadcast) is left empty; anything else in braces is sent as typed.
-| Placeholders | What they say |
-| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| Server | `{server}`, `{map}` (as players know it: Zestafona, not NorthAmerica), `{players}` on, `{max}` players, `{scores}` (`Valkyra 45 · Lonestar 30`), `{cap}` (the score cap, 100 on today's builds), `{uptime}` (since the game started) |
-| Player | `{name}` (or `{player}`), `{faction}`, `{steamid}`, `{ping}` (ms) |
-| Here | `{kills}`, `{deaths}`, `{kd}` (or `{kdr}`), `{playtime}`, `{matches}`, `{wins}`, `{winrate}`, `{seeded}` (seed time): the player's line on this server's all-time leaderboard, from the matches that have ended and their sessions here; 0 for a player new to it |
-| Org | `{org_kills}`, `{org_deaths}`, `{org_kd}` (or `{org_kdr}`), `{org_playtime}`, `{org_matches}`, `{org_wins}`, `{org_winrate}`, `{org_seeded}`: the same over every server of the organisation, never another organisation's. Not in a Kill distance rule's text, which can be a ban reason that staff of other servers read |
-| A rule's own | Faction change `{previous}` (the side they left); Restart notice `{minutes}` (to the restart window); Match broadcast, see its row; Name filter `{why}`; Team kill `{victim}`, `{count}` (this match); Team balance `{team}`; Kill distance `{weapon}`, `{distance}` (… when the feed sent none), `{count}`; Seeding reward `{minutes}` (seeded), `{until}`, `{days}`; AFK protection `{goal}` |
+| Placeholders | What they say |
+| ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Server | `{server}`, `{map}` (as players know it: Zestafona, not NorthAmerica), `{players}` on, `{max}` players, `{scores}` (`Valkyra 45 · Lonestar 30`), `{cap}` (the score cap, 100 on today's builds), `{uptime}` (since the game started) |
+| Player | `{name}` (or `{player}`), `{faction}`, `{steamid}`, `{ping}` (ms) |
+| Here | `{kills}`, `{deaths}`, `{kd}` (or `{kdr}`), `{playtime}`, `{matches}`, `{wins}`, `{winrate}`, `{seeded}` (seed time): the player's line on this server's all-time leaderboard, from the matches that have ended and their sessions here; 0 for a player new to it |
+| Org | `{org_kills}`, `{org_deaths}`, `{org_kd}` (or `{org_kdr}`), `{org_playtime}`, `{org_matches}`, `{org_wins}`, `{org_winrate}`, `{org_seeded}`: the same over every server of the organisation, never another organisation's. Not in a Kill distance rule's text, which can be a ban reason that staff of other servers read |
+| A rule's own | Faction change `{previous}` (the side they left); Restart notice `{minutes}` (to the restart window); Match broadcast, see its row; Name filter `{why}`; Name change `{previous}` (the name they are listed under; `{name}` is the one the feed showed); Team kill `{victim}`, `{count}` (this match); Team balance `{team}`; Kill distance `{weapon}`, `{distance}` (… when the feed sent none), `{count}`; Seeding reward `{minutes}` (seeded), `{until}`, `{days}`; AFK protection `{goal}` |
So `Welcome {player}: {kills} kills here, {org_kills} on all our servers` reaches a regular as
`Welcome [ABC] Night Owl: 1234 kills here, 2000 on all our servers`. The stats are read only for a
From 01dd82ed4c143cc9622ae1b4e7bb108bc44b28f3 Mon Sep 17 00:00:00 2001
From: James Causon
Date: Wed, 7 Oct 2026 00:00:15 +0100
Subject: [PATCH 033/102] Kill distance live test: the Humvee roadkill test
gets 30 s, as the other tests that run deliveries do; its four deliveries and
two audit waits take about 5 s, Bun's default limit
---
src/test/kill-distance-rule.test.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/src/test/kill-distance-rule.test.ts b/src/test/kill-distance-rule.test.ts
index ee9fd8e5..69b7d3c1 100644
--- a/src/test/kill-distance-rule.test.ts
+++ b/src/test/kill-distance-rule.test.ts
@@ -526,7 +526,7 @@ describe.skipIf(!hasTestDb)('Kill distance rule, live', () => {
dead.clear();
forgetMemory(server.id);
}
- });
+ }, 30_000);
test('a kick rule kicks once for a burst, with its reason, while the kick lands', async () => {
const w = await seedWorld(env);
From b9bc751d083591143f85a163283ae733f6d35cea Mon Sep 17 00:00:00 2001
From: James Causon
Date: Wed, 7 Oct 2026 01:07:21 +0100
Subject: [PATCH 034/102] Kill feed names: the names the kill feed shows for a
player that are not the one the server lists them under are kept per server,
player and name (player_aliases, migration 0042), with when each was first
and last shown and the listed player whose name it read as; their own name
with a clan tag put on, taken off or swapped is the same name. They are
recorded as each batch comes in, on every server with the feed whatever its
rules, and filled once from the kill history in the background, a day at a
time with a pause between days, by whichever process owns the worker; a
server that fails leaves the fill unfinished and it is tried again ten
minutes later. Rows are written in key order and a thousand to a statement,
and the holder is the newer sighting's. The dossier answers them for the
servers the viewer can open, newest first (the 200 newest, with the count of
all), leaving out a name the player also had as their own there, and the
Players searches match them and answer them per player. The Name change
watch's dry run now reads its sessions through the same replay
---
drizzle/0044_player_aliases.sql | 11 +
drizzle/meta/0044_snapshot.json | 5449 ++++++++++++++++++++++++++++
drizzle/meta/_journal.json | 7 +
src/lib/server/aliases.ts | 248 ++
src/lib/server/db/schema.ts | 26 +
src/lib/server/feed-events.ts | 75 +-
src/lib/server/name-change.test.ts | 52 +
src/lib/server/name-change.ts | 82 +
src/lib/server/players.ts | 9 +-
src/lib/server/poller.ts | 4 +
src/lib/server/seen.ts | 34 +-
src/lib/server/triggers.ts | 35 +-
src/lib/types.ts | 6 +
src/test/kill-feed-names.test.ts | 443 +++
14 files changed, 6433 insertions(+), 48 deletions(-)
create mode 100644 drizzle/0044_player_aliases.sql
create mode 100644 drizzle/meta/0044_snapshot.json
create mode 100644 src/lib/server/aliases.ts
create mode 100644 src/test/kill-feed-names.test.ts
diff --git a/drizzle/0044_player_aliases.sql b/drizzle/0044_player_aliases.sql
new file mode 100644
index 00000000..643078bc
--- /dev/null
+++ b/drizzle/0044_player_aliases.sql
@@ -0,0 +1,11 @@
+CREATE TABLE "player_aliases" (
+ "server_id" text NOT NULL,
+ "steam_id" text NOT NULL,
+ "name" text NOT NULL,
+ "holder" text,
+ "first_seen" timestamp with time zone NOT NULL,
+ "last_seen" timestamp with time zone NOT NULL,
+ CONSTRAINT "player_aliases_server_id_steam_id_name_pk" PRIMARY KEY("server_id","steam_id","name")
+);
+--> statement-breakpoint
+CREATE INDEX "player_aliases_steam_idx" ON "player_aliases" USING btree ("steam_id");
\ No newline at end of file
diff --git a/drizzle/meta/0044_snapshot.json b/drizzle/meta/0044_snapshot.json
new file mode 100644
index 00000000..316b1555
--- /dev/null
+++ b/drizzle/meta/0044_snapshot.json
@@ -0,0 +1,5449 @@
+{
+ "id": "73540293-9ed7-4796-836e-6ccbc1ca0dff",
+ "prevId": "9f724321-72db-4ee1-8d18-40c24a1293d5",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "issuer": {
+ "name": "issuer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ }
+ },
+ "indexes": {
+ "account_user_id_idx": {
+ "name": "account_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "account_issuer_account_id_uidx": {
+ "name": "account_issuer_account_id_uidx",
+ "columns": [
+ {
+ "expression": "issuer",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "account_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.api_keys": {
+ "name": "api_keys",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key_hash": {
+ "name": "key_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "hint": {
+ "name": "hint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "capabilities": {
+ "name": "capabilities",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "server_ids": {
+ "name": "server_ids",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "last_used_at": {
+ "name": "last_used_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "api_keys_org_idx": {
+ "name": "api_keys_org_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "api_keys_org_id_organizations_id_fk": {
+ "name": "api_keys_org_id_organizations_id_fk",
+ "tableFrom": "api_keys",
+ "tableTo": "organizations",
+ "columnsFrom": [
+ "org_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "api_keys_created_by_user_id_fk": {
+ "name": "api_keys_created_by_user_id_fk",
+ "tableFrom": "api_keys",
+ "tableTo": "user",
+ "columnsFrom": [
+ "created_by"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "api_keys_key_hash_unique": {
+ "name": "api_keys_key_hash_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "key_hash"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.audit_log": {
+ "name": "audit_log",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "ts": {
+ "name": "ts",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "actor_id": {
+ "name": "actor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "actor_name": {
+ "name": "actor_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "server_name": {
+ "name": "server_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target": {
+ "name": "target",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "detail": {
+ "name": "detail",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "outcome": {
+ "name": "outcome",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "message": {
+ "name": "message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "duration_ms": {
+ "name": "duration_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "audit_ts_idx": {
+ "name": "audit_ts_idx",
+ "columns": [
+ {
+ "expression": "ts",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_server_idx": {
+ "name": "audit_server_idx",
+ "columns": [
+ {
+ "expression": "server_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_org_idx": {
+ "name": "audit_org_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_actor_idx": {
+ "name": "audit_actor_idx",
+ "columns": [
+ {
+ "expression": "actor_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_action_idx": {
+ "name": "audit_action_idx",
+ "columns": [
+ {
+ "expression": "category",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "action",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_target_idx": {
+ "name": "audit_target_idx",
+ "columns": [
+ {
+ "expression": "target",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"audit_log\".\"target\" <> ''",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.json_webhook_posts": {
+ "name": "json_webhook_posts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "webhook_id": {
+ "name": "webhook_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "event_id": {
+ "name": "event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "state": {
+ "name": "state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "not_before": {
+ "name": "not_before",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "lease_until": {
+ "name": "lease_until",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "outcome": {
+ "name": "outcome",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "done_at": {
+ "name": "done_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "json_webhook_posts_event_idx": {
+ "name": "json_webhook_posts_event_idx",
+ "columns": [
+ {
+ "expression": "webhook_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "event_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "json_webhook_posts_next_idx": {
+ "name": "json_webhook_posts_next_idx",
+ "columns": [
+ {
+ "expression": "webhook_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"json_webhook_posts\".\"state\" in ('pending', 'sending')",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "json_webhook_posts_open_idx": {
+ "name": "json_webhook_posts_open_idx",
+ "columns": [
+ {
+ "expression": "state",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"json_webhook_posts\".\"state\" in ('pending', 'sending')",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "json_webhook_posts_webhook_id_json_webhooks_id_fk": {
+ "name": "json_webhook_posts_webhook_id_json_webhooks_id_fk",
+ "tableFrom": "json_webhook_posts",
+ "tableTo": "json_webhooks",
+ "columnsFrom": [
+ "webhook_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.json_webhooks": {
+ "name": "json_webhooks",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "url_enc": {
+ "name": "url_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url_hint": {
+ "name": "url_hint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "secret_enc": {
+ "name": "secret_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "events": {
+ "name": "events",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "server_ids": {
+ "name": "server_ids",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "allow_private": {
+ "name": "allow_private",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "last_sent_at": {
+ "name": "last_sent_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_status": {
+ "name": "last_status",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "json_webhooks_org_idx": {
+ "name": "json_webhooks_org_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "json_webhooks_org_id_organizations_id_fk": {
+ "name": "json_webhooks_org_id_organizations_id_fk",
+ "tableFrom": "json_webhooks",
+ "tableTo": "organizations",
+ "columnsFrom": [
+ "org_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.kills": {
+ "name": "kills",
+ "schema": "",
+ "columns": {
+ "ts": {
+ "name": "ts",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "event_id": {
+ "name": "event_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "event_type": {
+ "name": "event_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'killed'"
+ },
+ "parsed_kill": {
+ "name": "parsed_kill",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "raw_event": {
+ "name": "raw_event",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "instance_id": {
+ "name": "instance_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "match_id": {
+ "name": "match_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "match_row": {
+ "name": "match_row",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "event_time": {
+ "name": "event_time",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "map": {
+ "name": "map",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "killer_steam_id": {
+ "name": "killer_steam_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "killer_name": {
+ "name": "killer_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "killer_faction": {
+ "name": "killer_faction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "victim_steam_id": {
+ "name": "victim_steam_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "victim_name": {
+ "name": "victim_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "victim_faction": {
+ "name": "victim_faction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cause": {
+ "name": "cause",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "distance_m": {
+ "name": "distance_m",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "headshot": {
+ "name": "headshot",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "suicide": {
+ "name": "suicide",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "team_kill": {
+ "name": "team_kill",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "tags": {
+ "name": "tags",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "kills_event_idx": {
+ "name": "kills_event_idx",
+ "columns": [
+ {
+ "expression": "event_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "kills_server_ts_idx": {
+ "name": "kills_server_ts_idx",
+ "columns": [
+ {
+ "expression": "server_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "ts",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "kills_killer_idx": {
+ "name": "kills_killer_idx",
+ "columns": [
+ {
+ "expression": "killer_steam_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "ts",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "kills_victim_idx": {
+ "name": "kills_victim_idx",
+ "columns": [
+ {
+ "expression": "victim_steam_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "ts",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.list_entries": {
+ "name": "list_entries",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "list_id": {
+ "name": "list_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "steam_id": {
+ "name": "steam_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "added_by": {
+ "name": "added_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "added_by_name": {
+ "name": "added_by_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "added_at": {
+ "name": "added_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "removed_at": {
+ "name": "removed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "removed_by": {
+ "name": "removed_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "removed_by_name": {
+ "name": "removed_by_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "removal": {
+ "name": "removal",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "list_entries_active_uidx": {
+ "name": "list_entries_active_uidx",
+ "columns": [
+ {
+ "expression": "list_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "steam_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"list_entries\".\"removed_at\" is null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "list_entries_list_idx": {
+ "name": "list_entries_list_idx",
+ "columns": [
+ {
+ "expression": "list_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "removed_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "list_entries_steam_idx": {
+ "name": "list_entries_steam_idx",
+ "columns": [
+ {
+ "expression": "steam_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "list_entries_expiry_idx": {
+ "name": "list_entries_expiry_idx",
+ "columns": [
+ {
+ "expression": "expires_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"list_entries\".\"removed_at\" is null and \"list_entries\".\"expires_at\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "list_entries_list_id_lists_id_fk": {
+ "name": "list_entries_list_id_lists_id_fk",
+ "tableFrom": "list_entries",
+ "tableTo": "lists",
+ "columnsFrom": [
+ "list_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.lists": {
+ "name": "lists",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'Default'"
+ },
+ "share_token": {
+ "name": "share_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "lists_org_kind_name_uidx": {
+ "name": "lists_org_kind_name_uidx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "name",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"lists\".\"server_id\" is null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "lists_server_kind_uidx": {
+ "name": "lists_server_kind_uidx",
+ "columns": [
+ {
+ "expression": "server_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"lists\".\"server_id\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "lists_org_id_organizations_id_fk": {
+ "name": "lists_org_id_organizations_id_fk",
+ "tableFrom": "lists",
+ "tableTo": "organizations",
+ "columnsFrom": [
+ "org_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "lists_server_id_servers_id_fk": {
+ "name": "lists_server_id_servers_id_fk",
+ "tableFrom": "lists",
+ "tableTo": "servers",
+ "columnsFrom": [
+ "server_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "lists_share_token_unique": {
+ "name": "lists_share_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "share_token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.login_attempts": {
+ "name": "login_attempts",
+ "schema": "",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "count": {
+ "name": "count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "first_at": {
+ "name": "first_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "locked_until": {
+ "name": "locked_until",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.match_players": {
+ "name": "match_players",
+ "schema": "",
+ "columns": {
+ "match_id": {
+ "name": "match_id",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "steam_id": {
+ "name": "steam_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "faction": {
+ "name": "faction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "seconds": {
+ "name": "seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "kills": {
+ "name": "kills",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "deaths": {
+ "name": "deaths",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "cash_delta": {
+ "name": "cash_delta",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "headshots": {
+ "name": "headshots",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "team_kills": {
+ "name": "team_kills",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "suicides": {
+ "name": "suicides",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "vehicle_kills": {
+ "name": "vehicle_kills",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "longest_m": {
+ "name": "longest_m",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "kill_streak": {
+ "name": "kill_streak",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "death_streak": {
+ "name": "death_streak",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "match_players_server_idx": {
+ "name": "match_players_server_idx",
+ "columns": [
+ {
+ "expression": "server_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "match_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "match_players_steam_idx": {
+ "name": "match_players_steam_idx",
+ "columns": [
+ {
+ "expression": "steam_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "match_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "match_players_match_id_steam_id_pk": {
+ "name": "match_players_match_id_steam_id_pk",
+ "columns": [
+ "match_id",
+ "steam_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.matches": {
+ "name": "matches",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ended_at": {
+ "name": "ended_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "map": {
+ "name": "map",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "experiences": {
+ "name": "experiences",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lighting": {
+ "name": "lighting",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "peak_players": {
+ "name": "peak_players",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "final_scores": {
+ "name": "final_scores",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "winner": {
+ "name": "winner",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "matches_server_idx": {
+ "name": "matches_server_idx",
+ "columns": [
+ {
+ "expression": "server_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "matches_open_idx": {
+ "name": "matches_open_idx",
+ "columns": [
+ {
+ "expression": "server_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"matches\".\"ended_at\" is null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "matches_server_ended_idx": {
+ "name": "matches_server_ended_idx",
+ "columns": [
+ {
+ "expression": "server_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "ended_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.org_ban_reasons": {
+ "name": "org_ban_reasons",
+ "schema": "",
+ "columns": {
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "reasons": {
+ "name": "reasons",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "org_ban_reasons_org_id_organizations_id_fk": {
+ "name": "org_ban_reasons_org_id_organizations_id_fk",
+ "tableFrom": "org_ban_reasons",
+ "tableTo": "organizations",
+ "columnsFrom": [
+ "org_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.org_invites": {
+ "name": "org_invites",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "org_role": {
+ "name": "org_role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'member'"
+ },
+ "server_role_id": {
+ "name": "server_role_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "max_uses": {
+ "name": "max_uses",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "uses": {
+ "name": "uses",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "org_invites_org_idx": {
+ "name": "org_invites_org_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "org_invites_org_id_organizations_id_fk": {
+ "name": "org_invites_org_id_organizations_id_fk",
+ "tableFrom": "org_invites",
+ "tableTo": "organizations",
+ "columnsFrom": [
+ "org_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "org_invites_server_role_id_org_roles_id_fk": {
+ "name": "org_invites_server_role_id_org_roles_id_fk",
+ "tableFrom": "org_invites",
+ "tableTo": "org_roles",
+ "columnsFrom": [
+ "server_role_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "org_invites_token_unique": {
+ "name": "org_invites_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.org_members": {
+ "name": "org_members",
+ "schema": "",
+ "columns": {
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "invite_id": {
+ "name": "invite_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "org_members_user_idx": {
+ "name": "org_members_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "org_members_org_id_organizations_id_fk": {
+ "name": "org_members_org_id_organizations_id_fk",
+ "tableFrom": "org_members",
+ "tableTo": "organizations",
+ "columnsFrom": [
+ "org_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "org_members_user_id_user_id_fk": {
+ "name": "org_members_user_id_user_id_fk",
+ "tableFrom": "org_members",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "org_members_org_id_user_id_pk": {
+ "name": "org_members_org_id_user_id_pk",
+ "columns": [
+ "org_id",
+ "user_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.org_roles": {
+ "name": "org_roles",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "capabilities": {
+ "name": "capabilities",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "builtin": {
+ "name": "builtin",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "org_roles_builtin_uidx": {
+ "name": "org_roles_builtin_uidx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "builtin",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"org_roles\".\"builtin\" is not null",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "org_roles_name_uidx": {
+ "name": "org_roles_name_uidx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "lower(\"name\")",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "org_roles_org_id_organizations_id_fk": {
+ "name": "org_roles_org_id_organizations_id_fk",
+ "tableFrom": "org_roles",
+ "tableTo": "organizations",
+ "columnsFrom": [
+ "org_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.organizations": {
+ "name": "organizations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "server_limit": {
+ "name": "server_limit",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "suspended_at": {
+ "name": "suspended_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "suspended_reason": {
+ "name": "suspended_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "members_reserved": {
+ "name": "members_reserved",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "allow_public_status": {
+ "name": "allow_public_status",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "allow_public_leaderboards": {
+ "name": "allow_public_leaderboards",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "discord_invite_url": {
+ "name": "discord_invite_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "ban_message": {
+ "name": "ban_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{reason}'"
+ },
+ "board_opens": {
+ "name": "board_opens",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'official'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "organizations_slug_unique": {
+ "name": "organizations_slug_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "slug"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.outbox": {
+ "name": "outbox",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "trigger_id": {
+ "name": "trigger_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "trigger_name": {
+ "name": "trigger_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "trigger_kind": {
+ "name": "trigger_kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "action": {
+ "name": "action",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "params": {
+ "name": "params",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "target": {
+ "name": "target",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "detail": {
+ "name": "detail",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "steam_id": {
+ "name": "steam_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ok_message": {
+ "name": "ok_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "dedupe_key": {
+ "name": "dedupe_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "state": {
+ "name": "state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "not_before": {
+ "name": "not_before",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "lease_until": {
+ "name": "lease_until",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "outcome": {
+ "name": "outcome",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "done_at": {
+ "name": "done_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "outbox_dedupe_idx": {
+ "name": "outbox_dedupe_idx",
+ "columns": [
+ {
+ "expression": "dedupe_key",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "outbox_pending_idx": {
+ "name": "outbox_pending_idx",
+ "columns": [
+ {
+ "expression": "state",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "not_before",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "where": "\"outbox\".\"state\" in ('pending', 'sending')",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "outbox_server_idx": {
+ "name": "outbox_server_idx",
+ "columns": [
+ {
+ "expression": "server_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "outbox_server_id_servers_id_fk": {
+ "name": "outbox_server_id_servers_id_fk",
+ "tableFrom": "outbox",
+ "tableTo": "servers",
+ "columnsFrom": [
+ "server_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.passkey": {
+ "name": "passkey",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "public_key": {
+ "name": "public_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "credential_id": {
+ "name": "credential_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "counter": {
+ "name": "counter",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "device_type": {
+ "name": "device_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "backed_up": {
+ "name": "backed_up",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "transports": {
+ "name": "transports",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "aaguid": {
+ "name": "aaguid",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "passkey_user_id_idx": {
+ "name": "passkey_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "passkey_credential_id_idx": {
+ "name": "passkey_credential_id_idx",
+ "columns": [
+ {
+ "expression": "credential_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "passkey_user_id_user_id_fk": {
+ "name": "passkey_user_id_user_id_fk",
+ "tableFrom": "passkey",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.player_aliases": {
+ "name": "player_aliases",
+ "schema": "",
+ "columns": {
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "steam_id": {
+ "name": "steam_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "holder": {
+ "name": "holder",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "first_seen": {
+ "name": "first_seen",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_seen": {
+ "name": "last_seen",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "player_aliases_steam_idx": {
+ "name": "player_aliases_steam_idx",
+ "columns": [
+ {
+ "expression": "steam_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "player_aliases_server_id_steam_id_name_pk": {
+ "name": "player_aliases_server_id_steam_id_name_pk",
+ "columns": [
+ "server_id",
+ "steam_id",
+ "name"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.player_days": {
+ "name": "player_days",
+ "schema": "",
+ "columns": {
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "day": {
+ "name": "day",
+ "type": "date",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "steam_id": {
+ "name": "steam_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "seconds": {
+ "name": "seconds",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'0'"
+ },
+ "crossings": {
+ "name": "crossings",
+ "type": "timestamp with time zone[]",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sessions": {
+ "name": "sessions",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "seed_seconds": {
+ "name": "seed_seconds",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "cash": {
+ "name": "cash",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "last_seen": {
+ "name": "last_seen",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "matches": {
+ "name": "matches",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "kills": {
+ "name": "kills",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "deaths": {
+ "name": "deaths",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "headshots": {
+ "name": "headshots",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "team_kills": {
+ "name": "team_kills",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "suicides": {
+ "name": "suicides",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "vehicle_kills": {
+ "name": "vehicle_kills",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "kill_streak": {
+ "name": "kill_streak",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "death_streak": {
+ "name": "death_streak",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "wins": {
+ "name": "wins",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "losses": {
+ "name": "losses",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "draws": {
+ "name": "draws",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "player_days_server_id_day_steam_id_pk": {
+ "name": "player_days_server_id_day_steam_id_pk",
+ "columns": [
+ "server_id",
+ "day",
+ "steam_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.player_marks": {
+ "name": "player_marks",
+ "schema": "",
+ "columns": {
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "steam_id": {
+ "name": "steam_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "watched": {
+ "name": "watched",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "updated_by": {
+ "name": "updated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_by_name": {
+ "name": "updated_by_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "player_marks_org_id_organizations_id_fk": {
+ "name": "player_marks_org_id_organizations_id_fk",
+ "tableFrom": "player_marks",
+ "tableTo": "organizations",
+ "columnsFrom": [
+ "org_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "player_marks_org_id_steam_id_pk": {
+ "name": "player_marks_org_id_steam_id_pk",
+ "columns": [
+ "org_id",
+ "steam_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.player_notes": {
+ "name": "player_notes",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "steam_id": {
+ "name": "steam_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "author_name": {
+ "name": "author_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "player_notes_idx": {
+ "name": "player_notes_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "steam_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "player_notes_org_id_organizations_id_fk": {
+ "name": "player_notes_org_id_organizations_id_fk",
+ "tableFrom": "player_notes",
+ "tableTo": "organizations",
+ "columnsFrom": [
+ "org_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.player_sessions": {
+ "name": "player_sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "bigserial",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "steam_id": {
+ "name": "steam_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "faction": {
+ "name": "faction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "joined_at": {
+ "name": "joined_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_seen": {
+ "name": "last_seen",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "left_at": {
+ "name": "left_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "kills": {
+ "name": "kills",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "deaths": {
+ "name": "deaths",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "cash": {
+ "name": "cash",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "seed_seconds": {
+ "name": "seed_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "player_sessions_open_idx": {
+ "name": "player_sessions_open_idx",
+ "columns": [
+ {
+ "expression": "server_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "left_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "player_sessions_steam_idx": {
+ "name": "player_sessions_steam_idx",
+ "columns": [
+ {
+ "expression": "steam_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "joined_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.player_totals": {
+ "name": "player_totals",
+ "schema": "",
+ "columns": {
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "steam_id": {
+ "name": "steam_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sessions": {
+ "name": "sessions",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "seconds": {
+ "name": "seconds",
+ "type": "numeric",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'0'"
+ },
+ "seed_seconds": {
+ "name": "seed_seconds",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "cash": {
+ "name": "cash",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "last_seen": {
+ "name": "last_seen",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "matches": {
+ "name": "matches",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "kills": {
+ "name": "kills",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "deaths": {
+ "name": "deaths",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "headshots": {
+ "name": "headshots",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "team_kills": {
+ "name": "team_kills",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "suicides": {
+ "name": "suicides",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "vehicle_kills": {
+ "name": "vehicle_kills",
+ "type": "bigint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "kill_streak": {
+ "name": "kill_streak",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "death_streak": {
+ "name": "death_streak",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "wins": {
+ "name": "wins",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "losses": {
+ "name": "losses",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "draws": {
+ "name": "draws",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "player_totals_server_id_steam_id_pk": {
+ "name": "player_totals_server_id_steam_id_pk",
+ "columns": [
+ "server_id",
+ "steam_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sample_map_rollups": {
+ "name": "sample_map_rollups",
+ "schema": "",
+ "columns": {
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "bucket": {
+ "name": "bucket",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "map": {
+ "name": "map",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "secs": {
+ "name": "secs",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "sample_map_rollups_server_id_bucket_map_pk": {
+ "name": "sample_map_rollups_server_id_bucket_map_pk",
+ "columns": [
+ "server_id",
+ "bucket",
+ "map"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sample_rollups": {
+ "name": "sample_rollups",
+ "schema": "",
+ "columns": {
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "bucket": {
+ "name": "bucket",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "samples": {
+ "name": "samples",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "ok_samples": {
+ "name": "ok_samples",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "up_s": {
+ "name": "up_s",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "down_s": {
+ "name": "down_s",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "player_s": {
+ "name": "player_s",
+ "type": "real",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "max_players": {
+ "name": "max_players",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "max_cap": {
+ "name": "max_cap",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "sample_rollups_server_id_bucket_pk": {
+ "name": "sample_rollups_server_id_bucket_pk",
+ "columns": [
+ "server_id",
+ "bucket"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.samples": {
+ "name": "samples",
+ "schema": "",
+ "columns": {
+ "ts": {
+ "name": "ts",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ok": {
+ "name": "ok",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "player_count": {
+ "name": "player_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "max_players": {
+ "name": "max_players",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "map": {
+ "name": "map",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "experiences": {
+ "name": "experiences",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lighting": {
+ "name": "lighting",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "match_seconds": {
+ "name": "match_seconds",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scores": {
+ "name": "scores",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cash": {
+ "name": "cash",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "latency_ms": {
+ "name": "latency_ms",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "samples_server_ts_idx": {
+ "name": "samples_server_ts_idx",
+ "columns": [
+ {
+ "expression": "server_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "ts",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.seasons": {
+ "name": "seasons",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "starts_at": {
+ "name": "starts_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "seasons_org_start_uidx": {
+ "name": "seasons_org_start_uidx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "starts_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "seasons_org_id_organizations_id_fk": {
+ "name": "seasons_org_id_organizations_id_fk",
+ "tableFrom": "seasons",
+ "tableTo": "organizations",
+ "columnsFrom": [
+ "org_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.server_bans": {
+ "name": "server_bans",
+ "schema": "",
+ "columns": {
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "steam_id": {
+ "name": "steam_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "banned_by": {
+ "name": "banned_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "banned_at_utc": {
+ "name": "banned_at_utc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "seen_at": {
+ "name": "seen_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "server_bans_steam_idx": {
+ "name": "server_bans_steam_idx",
+ "columns": [
+ {
+ "expression": "steam_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "server_bans_server_id_servers_id_fk": {
+ "name": "server_bans_server_id_servers_id_fk",
+ "tableFrom": "server_bans",
+ "tableTo": "servers",
+ "columnsFrom": [
+ "server_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "server_bans_server_id_steam_id_pk": {
+ "name": "server_bans_server_id_steam_id_pk",
+ "columns": [
+ "server_id",
+ "steam_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.server_grants": {
+ "name": "server_grants",
+ "schema": "",
+ "columns": {
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role_id": {
+ "name": "role_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "granted_by": {
+ "name": "granted_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "server_grants_user_idx": {
+ "name": "server_grants_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "server_grants_role_idx": {
+ "name": "server_grants_role_idx",
+ "columns": [
+ {
+ "expression": "role_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "server_grants_server_id_servers_id_fk": {
+ "name": "server_grants_server_id_servers_id_fk",
+ "tableFrom": "server_grants",
+ "tableTo": "servers",
+ "columnsFrom": [
+ "server_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "server_grants_user_id_user_id_fk": {
+ "name": "server_grants_user_id_user_id_fk",
+ "tableFrom": "server_grants",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "server_grants_role_id_org_roles_id_fk": {
+ "name": "server_grants_role_id_org_roles_id_fk",
+ "tableFrom": "server_grants",
+ "tableTo": "org_roles",
+ "columnsFrom": [
+ "role_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "server_grants_server_id_user_id_pk": {
+ "name": "server_grants_server_id_user_id_pk",
+ "columns": [
+ "server_id",
+ "user_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.server_list_state": {
+ "name": "server_list_state",
+ "schema": "",
+ "columns": {
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "steam_id": {
+ "name": "steam_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_list_id": {
+ "name": "source_list_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "state": {
+ "name": "state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "attempted_at": {
+ "name": "attempted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "server_list_state_source_idx": {
+ "name": "server_list_state_source_idx",
+ "columns": [
+ {
+ "expression": "source_list_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "server_list_state_server_id_servers_id_fk": {
+ "name": "server_list_state_server_id_servers_id_fk",
+ "tableFrom": "server_list_state",
+ "tableTo": "servers",
+ "columnsFrom": [
+ "server_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "server_list_state_source_list_id_lists_id_fk": {
+ "name": "server_list_state_source_list_id_lists_id_fk",
+ "tableFrom": "server_list_state",
+ "tableTo": "lists",
+ "columnsFrom": [
+ "source_list_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "server_list_state_server_id_kind_steam_id_pk": {
+ "name": "server_list_state_server_id_kind_steam_id_pk",
+ "columns": [
+ "server_id",
+ "kind",
+ "steam_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.server_list_sync": {
+ "name": "server_list_sync",
+ "schema": "",
+ "columns": {
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "synced_at": {
+ "name": "synced_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "server_list_sync_server_id_servers_id_fk": {
+ "name": "server_list_sync_server_id_servers_id_fk",
+ "tableFrom": "server_list_sync",
+ "tableTo": "servers",
+ "columnsFrom": [
+ "server_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.server_lists": {
+ "name": "server_lists",
+ "schema": "",
+ "columns": {
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "list_id": {
+ "name": "list_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "server_lists_list_idx": {
+ "name": "server_lists_list_idx",
+ "columns": [
+ {
+ "expression": "list_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "server_lists_server_id_servers_id_fk": {
+ "name": "server_lists_server_id_servers_id_fk",
+ "tableFrom": "server_lists",
+ "tableTo": "servers",
+ "columnsFrom": [
+ "server_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "server_lists_list_id_lists_id_fk": {
+ "name": "server_lists_list_id_lists_id_fk",
+ "tableFrom": "server_lists",
+ "tableTo": "lists",
+ "columnsFrom": [
+ "list_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "server_lists_server_id_list_id_pk": {
+ "name": "server_lists_server_id_list_id_pk",
+ "columns": [
+ "server_id",
+ "list_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.server_live": {
+ "name": "server_live",
+ "schema": "",
+ "columns": {
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "ok": {
+ "name": "ok",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "tier": {
+ "name": "tier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'idle'"
+ },
+ "build": {
+ "name": "build",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "game_server_id": {
+ "name": "game_server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reserved_slots": {
+ "name": "reserved_slots",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "players": {
+ "name": "players",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "player_count": {
+ "name": "player_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "status_at": {
+ "name": "status_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "players_at": {
+ "name": "players_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "observed_at": {
+ "name": "observed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "feed_at": {
+ "name": "feed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "server_live_server_id_servers_id_fk": {
+ "name": "server_live_server_id_servers_id_fk",
+ "tableFrom": "server_live",
+ "tableTo": "servers",
+ "columnsFrom": [
+ "server_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.server_reserved": {
+ "name": "server_reserved",
+ "schema": "",
+ "columns": {
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "steam_id": {
+ "name": "steam_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "seen_at": {
+ "name": "seen_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "server_reserved_server_id_servers_id_fk": {
+ "name": "server_reserved_server_id_servers_id_fk",
+ "tableFrom": "server_reserved",
+ "tableTo": "servers",
+ "columnsFrom": [
+ "server_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "server_reserved_server_id_steam_id_pk": {
+ "name": "server_reserved_server_id_steam_id_pk",
+ "columns": [
+ "server_id",
+ "steam_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.servers": {
+ "name": "servers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "host": {
+ "name": "host",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "port": {
+ "name": "port",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scheme": {
+ "name": "scheme",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'http'"
+ },
+ "password_enc": {
+ "name": "password_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "allow_private": {
+ "name": "allow_private",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "feed_token_enc": {
+ "name": "feed_token_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "feed_token_hash": {
+ "name": "feed_token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "public_status": {
+ "name": "public_status",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "public_leaderboards": {
+ "name": "public_leaderboards",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "public_kills": {
+ "name": "public_kills",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "servers_org_id_organizations_id_fk": {
+ "name": "servers_org_id_organizations_id_fk",
+ "tableFrom": "servers",
+ "tableTo": "organizations",
+ "columnsFrom": [
+ "org_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "servers_feed_token_hash_unique": {
+ "name": "servers_feed_token_hash_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "feed_token_hash"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "impersonated_by": {
+ "name": "impersonated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "session_user_id_idx": {
+ "name": "session_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "session_token_unique": {
+ "name": "session_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.site_settings": {
+ "name": "site_settings",
+ "schema": "",
+ "columns": {
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_by": {
+ "name": "updated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.steam_profiles": {
+ "name": "steam_profiles",
+ "schema": "",
+ "columns": {
+ "steam_id": {
+ "name": "steam_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "persona": {
+ "name": "persona",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "avatar": {
+ "name": "avatar",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "profile_url": {
+ "name": "profile_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "public": {
+ "name": "public",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "account_created_at": {
+ "name": "account_created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "vac_bans": {
+ "name": "vac_bans",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "game_bans": {
+ "name": "game_bans",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "days_since_last_ban": {
+ "name": "days_since_last_ban",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "community_banned": {
+ "name": "community_banned",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "economy_ban": {
+ "name": "economy_ban",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'none'"
+ },
+ "friends_state": {
+ "name": "friends_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'unknown'"
+ },
+ "friends_total": {
+ "name": "friends_total",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "friends_checked": {
+ "name": "friends_checked",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "banned_friends": {
+ "name": "banned_friends",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "friends_checked_at": {
+ "name": "friends_checked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "fetched_at": {
+ "name": "fetched_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.triggers": {
+ "name": "triggers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "config": {
+ "name": "config",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "state": {
+ "name": "state",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_fired_at": {
+ "name": "last_fired_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_result": {
+ "name": "last_result",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "fire_count": {
+ "name": "fire_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "triggers_server_idx": {
+ "name": "triggers_server_idx",
+ "columns": [
+ {
+ "expression": "server_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "triggers_server_id_servers_id_fk": {
+ "name": "triggers_server_id_servers_id_fk",
+ "tableFrom": "triggers",
+ "tableTo": "servers",
+ "columnsFrom": [
+ "server_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.two_factor": {
+ "name": "two_factor",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "secret": {
+ "name": "secret",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "backup_codes": {
+ "name": "backup_codes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "verified": {
+ "name": "verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": true
+ },
+ "failed_verification_count": {
+ "name": "failed_verification_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "default": 0
+ },
+ "locked_until": {
+ "name": "locked_until",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "two_factor_user_id_idx": {
+ "name": "two_factor_user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "two_factor_secret_idx": {
+ "name": "two_factor_secret_idx",
+ "columns": [
+ {
+ "expression": "secret",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "two_factor_user_id_user_id_fk": {
+ "name": "two_factor_user_id_user_id_fk",
+ "tableFrom": "two_factor",
+ "tableTo": "user",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "username": {
+ "name": "username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "display_username": {
+ "name": "display_username",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "banned": {
+ "name": "banned",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "ban_reason": {
+ "name": "ban_reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ban_expires": {
+ "name": "ban_expires",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "must_change_password": {
+ "name": "must_change_password",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "steam_id": {
+ "name": "steam_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "default_org_id": {
+ "name": "default_org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "two_factor_enabled": {
+ "name": "two_factor_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": false,
+ "default": false
+ },
+ "auth_complete": {
+ "name": "auth_complete",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "auth_grace_started_at": {
+ "name": "auth_grace_started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "recovery_key_hash": {
+ "name": "recovery_key_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "recovery_key_at": {
+ "name": "recovery_key_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_default_org_id_organizations_id_fk": {
+ "name": "user_default_org_id_organizations_id_fk",
+ "tableFrom": "user",
+ "tableTo": "organizations",
+ "columnsFrom": [
+ "default_org_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_email_unique": {
+ "name": "user_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ },
+ "user_username_unique": {
+ "name": "user_username_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "username"
+ ]
+ },
+ "user_steam_id_unique": {
+ "name": "user_steam_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "steam_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verification": {
+ "name": "verification",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "verification_identifier_idx": {
+ "name": "verification_identifier_idx",
+ "columns": [
+ {
+ "expression": "identifier",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.webhooks": {
+ "name": "webhooks",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "org_id": {
+ "name": "org_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "url_enc": {
+ "name": "url_enc",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url_hint": {
+ "name": "url_hint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "events": {
+ "name": "events",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "server_ids": {
+ "name": "server_ids",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "status_enabled": {
+ "name": "status_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "status_style": {
+ "name": "status_style",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'banner'"
+ },
+ "status_interval_s": {
+ "name": "status_interval_s",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 60
+ },
+ "link_status": {
+ "name": "link_status",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "link_leaderboard": {
+ "name": "link_leaderboard",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "link_panel": {
+ "name": "link_panel",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "status_messages": {
+ "name": "status_messages",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status_sent_at": {
+ "name": "status_sent_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_sent_at": {
+ "name": "last_sent_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_status": {
+ "name": "last_status",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "created_by": {
+ "name": "created_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "webhooks_org_idx": {
+ "name": "webhooks_org_idx",
+ "columns": [
+ {
+ "expression": "org_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "webhooks_org_id_organizations_id_fk": {
+ "name": "webhooks_org_id_organizations_id_fk",
+ "tableFrom": "webhooks",
+ "tableTo": "organizations",
+ "columnsFrom": [
+ "org_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.worker_ownership": {
+ "name": "worker_ownership",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "integer",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "label": {
+ "name": "label",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "acquired_at": {
+ "name": "acquired_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "lease_until": {
+ "name": "lease_until",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json
index 1ce022bd..3ea97944 100644
--- a/drizzle/meta/_journal.json
+++ b/drizzle/meta/_journal.json
@@ -309,6 +309,13 @@
"when": 1791741155124,
"tag": "0043_org_ban_reasons",
"breakpoints": true
+ },
+ {
+ "idx": 44,
+ "version": "7",
+ "when": 1791741216189,
+ "tag": "0044_player_aliases",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/src/lib/server/aliases.ts b/src/lib/server/aliases.ts
new file mode 100644
index 00000000..7330de41
--- /dev/null
+++ b/src/lib/server/aliases.ts
@@ -0,0 +1,248 @@
+// The names the kill feed showed for players that were not the ones the server listed them under
+// (player_aliases): what staff see as "in the kill feed as" on the dossier, and what the Players
+// searches also match. Recorded as kill batches come in (feed-events.ts), filled once from the
+// history, and read only for the servers the viewer can open.
+import { sql } from 'drizzle-orm';
+import type { Env } from './env';
+import type { DbOrTx } from './db';
+import { playerAliases, servers, siteSettings } from './db/schema';
+import { forLog } from './http';
+import { isOwner, LostOwnership, withOwnedTransaction } from './leadership';
+import { namesShownElse, sessionSweep, type NameReading, type ShownElse } from './name-change';
+
+export interface AliasRow extends ShownElse {
+ firstSeen: Date;
+ lastSeen: Date;
+}
+
+/** The most rows one statement writes: each binds six of the 65,535 parameters a statement may. */
+const ROWS_PER_STATEMENT = 1000;
+
+const byKey = (a: AliasRow, b: AliasRow) =>
+ a.steamId < b.steamId
+ ? -1
+ : a.steamId > b.steamId
+ ? 1
+ : a.name < b.name
+ ? -1
+ : a.name > b.name
+ ? 1
+ : 0;
+
+/**
+ * Adds names to a server's record, one row per player and name: a name shown again widens its
+ * first and last sighting, and the holder is the newer sighting's when it has one. Written in key
+ * order, so two writers that touch the same rows (overlapping batches, the fill beside a batch)
+ * lock them in the same order. Rows must not repeat a player and name.
+ */
+export async function recordAliases(
+ db: DbOrTx,
+ serverId: string,
+ rows: readonly AliasRow[]
+): Promise {
+ const sorted = [...rows].sort(byKey);
+ for (let i = 0; i < sorted.length; i += ROWS_PER_STATEMENT)
+ await db
+ .insert(playerAliases)
+ .values(sorted.slice(i, i + ROWS_PER_STATEMENT).map((r) => ({ serverId, ...r })))
+ .onConflictDoUpdate({
+ target: [playerAliases.serverId, playerAliases.steamId, playerAliases.name],
+ set: {
+ firstSeen: sql`LEAST(${playerAliases.firstSeen}, excluded.first_seen)`,
+ lastSeen: sql`GREATEST(${playerAliases.lastSeen}, excluded.last_seen)`,
+ holder: sql`CASE WHEN excluded.last_seen >= ${playerAliases.lastSeen}
+ THEN COALESCE(excluded.holder, ${playerAliases.holder})
+ ELSE COALESCE(${playerAliases.holder}, excluded.holder) END`
+ }
+ });
+}
+
+/** One name the feed showed for a player, over the servers asked for. */
+export interface FeedName {
+ name: string;
+ /** another listed player whose name it read as, when it was someone else's */
+ holder: string | null;
+ lastSeen: string;
+}
+
+/** The most names a dossier lists, newest first. */
+const FEED_NAMES_MAX = 200;
+
+/**
+ * A player's names in the kill feed on these servers (the ones the viewer can open), newest first,
+ * leaving out a name they also played under as their own there; `total` counts them all.
+ */
+export async function feedNamesOf(
+ env: Env,
+ serverIds: readonly string[],
+ steamId: string
+): Promise<{ names: FeedName[]; total: number }> {
+ if (!serverIds.length) return { names: [], total: 0 };
+ const rows = await env.db.execute<{
+ name: string;
+ holder: string | null;
+ lastSeen: Date;
+ total: number;
+ }>(sql`
+ SELECT a.name,
+ (array_agg(a.holder ORDER BY a.last_seen DESC) FILTER (WHERE a.holder IS NOT NULL))[1] AS holder,
+ MAX(a.last_seen) AS "lastSeen",
+ COUNT(*) OVER ()::int AS total
+ FROM player_aliases a
+ WHERE a.steam_id = ${steamId} AND a.server_id IN ${serverIds}
+ AND NOT EXISTS (SELECT 1 FROM player_sessions s
+ WHERE s.steam_id = a.steam_id AND s.server_id IN ${serverIds}
+ AND s.name = a.name)
+ GROUP BY a.name
+ ORDER BY MAX(a.last_seen) DESC
+ LIMIT ${FEED_NAMES_MAX}`);
+ return {
+ names: rows.map((r) => ({
+ name: r.name,
+ holder: r.holder,
+ lastSeen: new Date(r.lastSeen).toISOString()
+ })),
+ total: rows[0]?.total ?? 0
+ };
+}
+
+// ---- the history ---------------------------------------------------------------------------------
+
+/** The site_settings key that says the history has been filled, and up to when. */
+const FILLED = 'kill_feed_aliases_filled';
+const DAY_MS = 86_400_000;
+
+/**
+ * Fills the record from the kill feed's history, once: each server's kills, a day at a time, held
+ * against the sessions open at each (the same replay as the Name change watch's dry run), with a
+ * pause between days so the database is never busy with it. Live recording meanwhile only merges
+ * with it. Stops as soon as this process no longer owns the worker; a server that fails is left for
+ * the next try. Either way the next try starts over, which writes nothing twice. Marks itself done
+ * once every server is.
+ */
+export async function fillAliasHistory(
+ env: Env,
+ opts: { pauseMs?: number } = {}
+): Promise<'done' | 'already' | 'stopped' | 'incomplete'> {
+ const [mark] = await env.db.execute<{ key: string }>(
+ sql`SELECT key FROM site_settings WHERE key = ${FILLED}`
+ );
+ if (mark) return 'already';
+ const until = new Date();
+ const list = await env.db.select({ id: servers.id }).from(servers).orderBy(servers.id);
+ // One server's failure does not stop the others; the fill is done only once every one is.
+ let complete = true;
+ for (const s of list) {
+ if (!isOwner()) return 'stopped';
+ try {
+ if (!(await fillServer(env, s.id, until.getTime(), opts.pauseMs ?? 50))) return 'stopped';
+ } catch (err) {
+ if (err instanceof LostOwnership) return 'stopped';
+ console.warn(`[warcon] filling the kill feed names of ${s.id}:`, forLog(err));
+ complete = false;
+ }
+ }
+ if (!complete) return 'incomplete';
+ if (!isOwner()) return 'stopped';
+ await withOwnedTransaction(env, (tx) =>
+ tx
+ .insert(siteSettings)
+ .values({ key: FILLED, value: { through: until.toISOString() } })
+ .onConflictDoNothing()
+ );
+ return 'done';
+}
+
+/** One server's history; false when this process stopped owning the worker on the way. */
+async function fillServer(
+ env: Env,
+ serverId: string,
+ until: number,
+ pauseMs: number
+): Promise {
+ const sessions = await env.db.execute<{
+ steamId: string;
+ name: string;
+ joinedAt: Date;
+ leftAt: Date | null;
+ }>(sql`
+ SELECT steam_id AS "steamId", name, joined_at AS "joinedAt", left_at AS "leftAt"
+ FROM player_sessions WHERE server_id = ${serverId}
+ ORDER BY joined_at`);
+ if (!sessions.length) return true;
+ const listedAt = sessionSweep(
+ sessions.map((s) => ({
+ steamId: s.steamId,
+ name: s.name,
+ joinedAt: s.joinedAt,
+ leftAt: s.leftAt
+ }))
+ );
+ const readings = new Map();
+ const found = new Map();
+ const start = new Date(sessions[0].joinedAt).getTime();
+ for (let from = start - (start % DAY_MS); from < until; from += DAY_MS) {
+ const to = Math.min(from + DAY_MS, until);
+ const kills = await env.db.execute<{
+ ts: Date;
+ killer: string | null;
+ killerName: string | null;
+ victim: string;
+ victimName: string;
+ }>(sql`
+ SELECT ts, killer_steam_id AS killer, killer_name AS "killerName",
+ victim_steam_id AS victim, victim_name AS "victimName"
+ FROM kills
+ WHERE server_id = ${serverId} AND ts >= ${new Date(from)} AND ts < ${new Date(to)}
+ ORDER BY ts, event_time`);
+ for (const k of kills) {
+ const at = new Date(k.ts).getTime();
+ const shown = [
+ ...(k.killer && k.killerName !== null ? [{ steamId: k.killer, name: k.killerName }] : []),
+ ...(k.victim !== k.killer ? [{ steamId: k.victim, name: k.victimName }] : [])
+ ];
+ for (const a of namesShownElse(shown, listedAt(at), readings)) {
+ const key = `${a.steamId}\n${a.name}`;
+ const was = found.get(key);
+ found.set(key, {
+ ...a,
+ holder: a.holder ?? was?.holder ?? null,
+ firstSeen: was?.firstSeen ?? new Date(at),
+ lastSeen: new Date(at)
+ });
+ }
+ }
+ if (pauseMs && kills.length) await new Promise((r) => setTimeout(r, pauseMs));
+ if (!isOwner()) return false;
+ }
+ if (found.size)
+ await withOwnedTransaction(env, (tx) => recordAliases(tx, serverId, [...found.values()]));
+ return true;
+}
+
+/** How long after a fill that did not finish it is tried again. */
+const FILL_RETRY_MS = 10 * 60_000;
+let filling: Promise | null = null;
+let filled = false;
+let retryAt = 0;
+
+/**
+ * Starts filling the history in the background (called at every beat while this process owns the
+ * worker), unless it is running, done, or waiting to try again after a fill that did not finish.
+ */
+export function startAliasFill(env: Env): void {
+ if (filled || filling || Date.now() < retryAt) return;
+ filling = fillAliasHistory(env)
+ .then((r) => {
+ if (r === 'done' || r === 'already') filled = true;
+ else retryAt = Date.now() + FILL_RETRY_MS;
+ })
+ .catch((err) => {
+ if (!(err instanceof LostOwnership))
+ console.warn('[warcon] filling the kill feed names:', forLog(err));
+ retryAt = Date.now() + FILL_RETRY_MS;
+ })
+ .finally(() => {
+ filling = null;
+ });
+}
diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts
index 41668e35..ae421031 100644
--- a/src/lib/server/db/schema.ts
+++ b/src/lib/server/db/schema.ts
@@ -585,6 +585,32 @@ export const kills = pgTable(
);
export type KillRow = typeof kills.$inferSelect;
+/**
+ * The names the kill feed showed for a player that were not the one the server's player list held
+ * for them then (a clan tag put on, taken off or swapped is the same name): one row per server,
+ * player and name, with when it was first and last shown and, when it read as another listed
+ * player's name, that player. Staff see them on the dossier as "in the kill feed as", and the
+ * Players searches match them. Written by the worker as kill batches come in (feed-events.ts) and,
+ * once, from the history (aliases.ts); kept for good. A name that differs is rare, so rows are few.
+ */
+export const playerAliases = pgTable(
+ 'player_aliases',
+ {
+ serverId: text('server_id').notNull(),
+ steamId: text('steam_id').notNull(),
+ /** as the feed showed it */
+ name: text('name').notNull(),
+ /** the listed player whose name it read as, the last time it was someone else's */
+ holder: text('holder'),
+ firstSeen: ts('first_seen').notNull(),
+ lastSeen: ts('last_seen').notNull()
+ },
+ (t) => [
+ primaryKey({ columns: [t.serverId, t.steamId, t.name] }),
+ index('player_aliases_steam_idx').on(t.steamId)
+ ]
+);
+
/**
* One row per player per match: the game's own scoreboard counters over the match (kills, deaths,
* the change in cash) with the player's time on and side, and, on servers with a kill feed, what
diff --git a/src/lib/server/feed-events.ts b/src/lib/server/feed-events.ts
index 72e6ddf5..da00b3da 100644
--- a/src/lib/server/feed-events.ts
+++ b/src/lib/server/feed-events.ts
@@ -50,10 +50,12 @@ import {
import {
nameChangeSettingsKey,
nameChangeStep,
+ namesShownElse,
pruneNameTracks,
type NameChangeConfig,
type NameTracks
} from './name-change';
+import { recordAliases } from './aliases';
import { NAME_FLAG } from './name-filter';
import { applyTriggerUpdates, enqueueIntents, wakeDelivery } from './outbox';
import { LostOwnership, withOwnedTransaction } from './leadership';
@@ -73,6 +75,13 @@ export async function onKillsIngested(
): Promise {
if (!kills.length) return;
emit({ type: 'kills', serverId, kills });
+ const named = namedIn(kills);
+ try {
+ await recordFeedNames(env, serverId, kills, named);
+ } catch (err) {
+ if (!(err instanceof LostOwnership))
+ console.warn(`[warcon] kill feed names on ${serverId}:`, publicMessage(err));
+ }
try {
await actOnKillRate(env, serverId, kills);
} catch (err) {
@@ -88,7 +97,7 @@ export async function onKillsIngested(
console.warn(`[warcon] kill-distance rules on ${serverId}:`, publicMessage(err));
}
try {
- await actOnNameChange(env, serverId, kills, vars);
+ await actOnNameChange(env, serverId, named, vars);
} catch (err) {
if (!(err instanceof LostOwnership))
console.warn(`[warcon] name-change rules on ${serverId}:`, publicMessage(err));
@@ -105,6 +114,55 @@ export async function onKillsIngested(
}
}
+/** Both players of every kill as the feed named them, in the order the game played them; a suicide
+ * names its player once, and the environment no one. */
+function namedIn(batch: KillView[]) {
+ return [...batch]
+ .sort((a, b) => a.eventTime - b.eventTime)
+ .flatMap((k) => [
+ ...(k.killer?.steamId ? [{ ...k.killer, k }] : []),
+ ...(k.victim.steamId && k.victim.steamId !== k.killer?.steamId ? [{ ...k.victim, k }] : [])
+ ]);
+}
+type Named = ReturnType;
+
+/** The names the server's player list holds, by SteamID, from the worker's last look at it (read
+ * once a look, however many batches come in meanwhile). */
+const listedCache = new WeakMap>();
+function listedOf(serverId: string): ReadonlyMap {
+ const players = memoryOf(serverId)?.players;
+ if (!players) return new Map();
+ let listed = listedCache.get(players);
+ if (!listed)
+ listedCache.set(players, (listed = new Map(players.map((p) => [p.steamId, p.name]))));
+ return listed;
+}
+
+/**
+ * Keeps, for staff, the names this batch showed that were not the ones the list holds for their
+ * players (aliases.ts), on every server with the feed whatever its rules: a name the same as the
+ * list's, nearly every one, costs a lookup and a compare.
+ */
+async function recordFeedNames(
+ env: Env,
+ serverId: string,
+ batch: KillView[],
+ named: Named
+): Promise {
+ const listed = listedOf(serverId);
+ if (!listed.size) return;
+ const found = namesShownElse(named, listed);
+ if (!found.length) return;
+ const at = new Date(Math.max(...batch.map((k) => Date.parse(k.ts))));
+ await withOwnedTransaction(env, (tx) =>
+ recordAliases(
+ tx,
+ serverId,
+ found.map((a) => ({ ...a, firstSeen: at, lastSeen: at }))
+ )
+ );
+}
+
/**
* Each Kill rate rule's window of recent kills per player, in this process's memory: kills are
* acted on only in the worker, and a restart starting the windows over costs a flag, not data
@@ -391,7 +449,7 @@ export function forgetNameChange(serverId?: string): void {
async function actOnNameChange(
env: Env,
serverId: string,
- batch: KillView[],
+ shown: Named,
vars: KillerVars
): Promise {
const rows = (await enabledTriggers(env, serverId)).filter((r) => r.kind === 'name_change');
@@ -402,17 +460,8 @@ async function actOnNameChange(
// The names the server lists its players under now: nothing to hold the feed against while the
// list is empty (a map loading) or not read yet.
const m = memoryOf(serverId);
- const listed = new Map((m?.players ?? []).map((p) => [p.steamId, p.name]));
- if (!listed.size) return;
- // Both players of every kill as the feed named them, in the order the game played them; a
- // suicide names its player once.
- const shown = [...batch]
- .sort((a, b) => a.eventTime - b.eventTime)
- .flatMap((k) => [
- ...(k.killer?.steamId ? [{ ...k.killer, k }] : []),
- ...(k.victim.steamId && k.victim.steamId !== k.killer?.steamId ? [{ ...k.victim, k }] : [])
- ]);
- if (!shown.length) return;
+ const listed = listedOf(serverId);
+ if (!listed.size || !shown.length) return;
const now = Date.now();
const out: Evaluation = { intents: [], updates: [] };
// Each rule's tracks move on as it reads; if its actions cannot be queued, they move back.
diff --git a/src/lib/server/name-change.test.ts b/src/lib/server/name-change.test.ts
index 59005ed8..b5663460 100644
--- a/src/lib/server/name-change.test.ts
+++ b/src/lib/server/name-change.test.ts
@@ -2,8 +2,10 @@ import { describe, expect, test } from 'bun:test';
import {
nameChangeStep,
nameKey,
+ namesShownElse,
pruneNameTracks,
readName,
+ sessionSweep,
validateNameChange,
type NameTracks
} from './name-change';
@@ -295,3 +297,53 @@ describe('validateNameChange', () => {
).toMatchObject({ changes: 1, windowMinutes: 120, action: 'alert', takenOnly: true });
});
});
+
+describe('the names a batch showed that were not the listed ones', () => {
+ const server = list(['1', 'Ghostpepper'], ['2', '[R-14] ✪ Silver Fox'], ['3', '[ABC] Steady']);
+
+ test("each player's other name once, with the listed player whose name it read as", () => {
+ expect(
+ namesShownElse(
+ [
+ p('1', '✪ SiIver Fox'),
+ p('1', '✪ SiIver Fox'),
+ p('1', 'Tango'),
+ // their own name with another clan tag, a player off the list, a blank name
+ p('3', '[XYZ] Steady'),
+ p('9', 'Silver Fox'),
+ p('2', ' ')
+ ],
+ server
+ )
+ ).toEqual([
+ { steamId: '1', name: '✪ SiIver Fox', holder: '2' },
+ { steamId: '1', name: 'Tango', holder: null }
+ ]);
+ });
+});
+
+describe('the list as the sessions give it', () => {
+ test('sessions join in order and leave some seconds after they end; a later session takes over', () => {
+ const at = (s: number) => new Date(s * 1000);
+ const listedAt = sessionSweep(
+ [
+ { steamId: '1', name: 'Alpha', joinedAt: at(0), leftAt: at(100) },
+ { steamId: '2', name: 'Bravo', joinedAt: at(10), leftAt: null },
+ { steamId: '1', name: 'Alpha2', joinedAt: at(105), leftAt: at(200) }
+ ],
+ 10_000
+ );
+ expect([...listedAt(5_000)]).toEqual([['1', 'Alpha']]);
+ expect([...listedAt(50_000)]).toEqual([
+ ['1', 'Alpha'],
+ ['2', 'Bravo']
+ ]);
+ // ended at 100 s, still listed for the slack, then the next session holds the name
+ expect(listedAt(104_000).get('1')).toBe('Alpha');
+ expect(listedAt(106_000).get('1')).toBe('Alpha2');
+ // the first session's end does not take the second one off
+ expect(listedAt(150_000).get('1')).toBe('Alpha2');
+ expect(listedAt(211_000).has('1')).toBe(false);
+ expect(listedAt(211_000).get('2')).toBe('Bravo');
+ });
+});
diff --git a/src/lib/server/name-change.ts b/src/lib/server/name-change.ts
index eb2f5961..cfb44d26 100644
--- a/src/lib/server/name-change.ts
+++ b/src/lib/server/name-change.ts
@@ -241,3 +241,85 @@ export function pruneNameTracks(cfg: NameChangeConfig, tracks: NameTracks, now:
const from = now - cfg.windowMinutes * 60_000;
for (const [id, t] of tracks) if (t.seenAt < from) tracks.delete(id);
}
+
+/** A name the feed showed for a player that was not the one the list held for them. */
+export interface ShownElse {
+ steamId: string;
+ name: string;
+ /** the listed player whose name it read as, when it was someone else's */
+ holder: string | null;
+}
+
+/**
+ * The names some kills showed that were not the ones the list held for their players (their own
+ * name with a clan tag put on, taken off or swapped is the same name), each player's name once,
+ * with the listed player whose name it read as. A player not on the list, or shown under a blank
+ * name, is not judged. `readings` keeps the names read across calls.
+ */
+export function namesShownElse(
+ shown: readonly ShownName[],
+ listed: ReadonlyMap,
+ readings: Map = new Map()
+): ShownElse[] {
+ const read = (name: string): NameReading => {
+ let r = readings.get(name);
+ if (!r) readings.set(name, (r = readName(name)));
+ return r;
+ };
+ const out = new Map();
+ for (const p of shown) {
+ const own = listed.get(p.steamId);
+ if (own === undefined || p.name === own || !p.name.trim()) continue;
+ const key = `${p.steamId}\n${p.name}`;
+ if (out.has(key)) continue;
+ const r = read(p.name);
+ if (ownName(r, read(own))) continue;
+ out.set(key, {
+ steamId: p.steamId,
+ name: p.name,
+ holder: holderOf(listed, p.steamId, r, read)
+ });
+ }
+ return [...out.values()];
+}
+
+/** One session as a replay reads it: who was listed under which name, from when until when. */
+export interface ListedSession {
+ steamId: string;
+ name: string;
+ joinedAt: Date | string;
+ leftAt: Date | string | null;
+}
+
+/**
+ * The player list as a server's sessions give it at a moment, for a replay that moves forward in
+ * time (`at` never goes back): sessions, given in join order, join it as they start and leave it
+ * `slackMs` after they end (a kill is received a second or two after it happened); a player's later
+ * session takes over their name. The name is the session's last, which is the list's but for a clan
+ * tag changed during it.
+ */
+export function sessionSweep(
+ sessions: readonly ListedSession[],
+ slackMs = 10_000
+): (at: number) => ReadonlyMap {
+ const listed = new Map();
+ const holding = new Map();
+ const ends = sessions
+ .flatMap((s, i) => (s.leftAt ? [{ i, until: new Date(s.leftAt).getTime() + slackMs }] : []))
+ .sort((a, b) => a.until - b.until);
+ let joins = 0;
+ let leaves = 0;
+ return (at) => {
+ for (; joins < sessions.length && new Date(sessions[joins].joinedAt).getTime() <= at; joins++) {
+ listed.set(sessions[joins].steamId, sessions[joins].name);
+ holding.set(sessions[joins].steamId, joins);
+ }
+ for (; leaves < ends.length && ends[leaves].until < at; leaves++) {
+ const s = sessions[ends[leaves].i];
+ if (holding.get(s.steamId) !== ends[leaves].i) continue;
+ listed.delete(s.steamId);
+ holding.delete(s.steamId);
+ }
+ return listed;
+ };
+}
diff --git a/src/lib/server/players.ts b/src/lib/server/players.ts
index 180faad4..1c7bab45 100644
--- a/src/lib/server/players.ts
+++ b/src/lib/server/players.ts
@@ -39,6 +39,7 @@ import type {
} from '$lib/types';
import { killView } from './feed';
import { latestNames } from './sessions';
+import { feedNamesOf } from './aliases';
export { requireSteamId } from './steam';
@@ -342,7 +343,8 @@ export async function dossier(
allOrgServers,
combat,
performance,
- seedRows
+ seedRows,
+ feedNames
] = await Promise.all([
getProfiles(env, [steamId], {
refresh: !!opts.refreshSteam,
@@ -383,7 +385,8 @@ export async function dossier(
eq(triggers.enabled, true)
)
)
- .limit(1)
+ .limit(1),
+ feedNamesOf(env, ids, steamId)
]);
const seedRow = seedRows[0];
const seedConfig = seedRow?.config as Partial | undefined;
@@ -405,6 +408,8 @@ export async function dossier(
steamId,
name,
names: names.map((n) => n.name),
+ feedNames: feedNames.names,
+ feedNamesTotal: feedNames.total,
online: online
? { serverId: online.serverId, serverName: nameOf.get(online.serverId) || '' }
: null,
diff --git a/src/lib/server/poller.ts b/src/lib/server/poller.ts
index 675df380..c560ae85 100644
--- a/src/lib/server/poller.ts
+++ b/src/lib/server/poller.ts
@@ -39,6 +39,7 @@ import { phaseOffset, pickDue, withHold } from './poller-schedule';
import { rollupSamples } from './rollups';
import { liveView } from './live';
import { feedDemoKills, forgetKillDistance, forgetNameChange } from './feed-events';
+import { startAliasFill } from './aliases';
import { publicMessage } from './http';
import * as metrics from './metrics';
import type { LiveView } from '$lib/types';
@@ -238,6 +239,9 @@ async function beat(env: Env): Promise {
forgetKillDistance();
forgetNameChange();
}
+ // Once, in the background: the names the kill feed showed before they were recorded (a fill
+ // that did not finish is tried again a while later).
+ startAliasFill(env);
if (now - s.settingsAt >= SETTINGS_MS) {
s.settingsAt = now;
await loadSettings(env).catch((err) => console.error('[warcon] settings', err));
diff --git a/src/lib/server/seen.ts b/src/lib/server/seen.ts
index 64e21942..fb7db207 100644
--- a/src/lib/server/seen.ts
+++ b/src/lib/server/seen.ts
@@ -1,6 +1,7 @@
// Everyone an organisation has seen: one row per SteamID built from player_sessions on the org's
-// servers the viewer can open, with every name they played under. A player who never joined one
-// of those servers cannot appear here, whatever is searched for.
+// servers the viewer can open, with every name they played under and the names the kill feed showed
+// for them there that were not those (player_aliases). A player who never joined one of those
+// servers cannot appear here, whatever is searched for.
import { sql, type SQL } from 'drizzle-orm';
import type { Env } from './env';
import { getProfiles } from './steam';
@@ -18,7 +19,7 @@ export const SEEN_SORTS = [
export type SeenSort = (typeof SEEN_SORTS)[number];
export interface SeenFilters {
- /** matches any name used (contains) or the SteamID (prefix) */
+ /** matches any name used or shown in the kill feed (contains), or the SteamID (prefix) */
q: string;
/** seen in the last N days; null for ever */
since: number | null;
@@ -53,6 +54,9 @@ export interface SeenPlayer {
name: string;
/** every other name seen, most recent first */
aliases: string[];
+ /** the names the kill feed showed for them that were not the ones the server listed them under,
+ * most recent first */
+ feedNames: string[];
firstSeen: string;
lastSeen: string;
sessions: number;
@@ -74,6 +78,7 @@ interface Row extends Record {
steamId: string;
name: string;
names: string[];
+ feedNames: string[] | null;
lastServerId: string;
lastServerName: string | null;
firstSeen: Date;
@@ -124,11 +129,10 @@ export async function seenPlayers(
const conds: SQL[] = [];
if (f.q) {
const q = f.q;
- conds.push(
- /^\d+$/.test(q)
- ? sql`(steam_id LIKE ${q + '%'} OR EXISTS (SELECT 1 FROM unnest(names) n WHERE n ILIKE ${'%' + likeEscape(q) + '%'}))`
- : sql`EXISTS (SELECT 1 FROM unnest(names) n WHERE n ILIKE ${'%' + likeEscape(q) + '%'})`
- );
+ const like = '%' + likeEscape(q) + '%';
+ const named = sql`(EXISTS (SELECT 1 FROM unnest(names) n WHERE n ILIKE ${like})
+ OR EXISTS (SELECT 1 FROM unnest(feed_names) n WHERE n ILIKE ${like}))`;
+ conds.push(/^\d+$/.test(q) ? sql`(steam_id LIKE ${q + '%'} OR ${named})` : named);
}
if (f.since) conds.push(sql`last_seen >= now() - (${f.since} * interval '1 day')`);
if (f.serverId && ids.includes(f.serverId)) conds.push(sql`on_server`);
@@ -168,8 +172,14 @@ export async function seenPlayers(
FROM player_sessions
WHERE server_id IN ${ids} ${one} ${recent}
GROUP BY steam_id
+ ), fed AS (
+ SELECT steam_id, array_agg(name ORDER BY seen DESC) AS feed_names
+ FROM (SELECT steam_id, name, MAX(last_seen) AS seen
+ FROM player_aliases WHERE server_id IN ${ids}
+ GROUP BY steam_id, name) n
+ GROUP BY steam_id
), flagged AS (
- SELECT a.*, s.name AS last_server_name,
+ SELECT a.*, s.name AS last_server_name, f.feed_names,
EXISTS (SELECT 1 FROM list_entries e JOIN lists l ON l.id = e.list_id
WHERE l.org_id = ${orgId} AND l.server_id IS NULL AND l.kind = 'ban'
AND e.removed_at IS NULL
@@ -186,8 +196,10 @@ export async function seenPlayers(
EXISTS (SELECT 1 FROM player_marks m
WHERE m.org_id = ${orgId} AND m.watched AND m.steam_id = a.steam_id) AS watched
FROM agg a LEFT JOIN servers s ON s.id = a.last_server_id
+ LEFT JOIN fed f ON f.steam_id = a.steam_id
)
- SELECT steam_id AS "steamId", name, names, last_server_id AS "lastServerId",
+ SELECT steam_id AS "steamId", name, names, feed_names AS "feedNames",
+ last_server_id AS "lastServerId",
last_server_name AS "lastServerName", first_seen AS "firstSeen", last_seen AS "lastSeen",
sessions, minutes, kills, deaths, servers, online,
org_banned AS "orgBanned", server_banned AS "serverBanned", watched,
@@ -208,6 +220,8 @@ export async function seenPlayers(
steamId: r.steamId,
name: r.name,
aliases: (r.names || []).filter((n: string) => n !== r.name),
+ // a name the feed showed that they also played under as their own is no other name
+ feedNames: (r.feedNames || []).filter((n: string) => !(r.names || []).includes(n)),
firstSeen: r.firstSeen.toISOString(),
lastSeen: r.lastSeen.toISOString(),
sessions: r.sessions,
diff --git a/src/lib/server/triggers.ts b/src/lib/server/triggers.ts
index ef103427..cbc14aae 100644
--- a/src/lib/server/triggers.ts
+++ b/src/lib/server/triggers.ts
@@ -110,6 +110,7 @@ import {
nameChangeSettingsKey,
nameChangeStep,
pruneNameTracks,
+ sessionSweep,
type NameChangeConfig,
type NameReading,
type NameTracks
@@ -2312,15 +2313,16 @@ export async function dryRun(
result.notes.push('Could not read the reserved slots; nobody was spared for one.');
}
}
- // The list at the kill being replayed: sessions join it in order and leave it once they have
- // ended, a few seconds' slack for a kill received just after its player left.
- const listed = new Map();
- const holding = new Map();
- const ends = sessions
- .flatMap((s, i) => (s.leftAt ? [{ i, until: new Date(s.leftAt).getTime() + 10_000 }] : []))
- .sort((a, b) => a.until - b.until);
- let joins = 0;
- let leaves = 0;
+ // The list at the kill being replayed, a few seconds' slack for a kill received just after its
+ // player left.
+ const listedAt = sessionSweep(
+ sessions.map((s) => ({
+ steamId: s.steamId,
+ name: s.name,
+ joinedAt: s.joinedAt,
+ leftAt: s.leftAt
+ }))
+ );
const tracks: NameTracks = new Map();
const readings = new Map();
const kicked = (steamId: string) =>
@@ -2328,20 +2330,7 @@ export async function dryRun(
for (let i = 0; i < rows.length; i++) {
const r = rows[i];
const at = new Date(r.ts).getTime();
- for (
- ;
- joins < sessions.length && new Date(sessions[joins].joinedAt).getTime() <= at;
- joins++
- ) {
- listed.set(sessions[joins].steamId, sessions[joins].name);
- holding.set(sessions[joins].steamId, joins);
- }
- for (; leaves < ends.length && ends[leaves].until < at; leaves++) {
- const s = sessions[ends[leaves].i];
- if (holding.get(s.steamId) !== ends[leaves].i) continue;
- listed.delete(s.steamId);
- holding.delete(s.steamId);
- }
+ const listed = listedAt(at);
if (i % 1000 === 0) pruneNameTracks(c, tracks, at);
const shown: { steamId: string; name: string }[] = [];
if (r.killer && r.killerName !== null) shown.push({ steamId: r.killer, name: r.killerName });
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 7df0d4da..8a1c2c72 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -436,6 +436,12 @@ export interface DossierView {
steamId: string;
name: string;
names: string[];
+ /** the names the kill feed showed for them that were not the ones the server listed them under,
+ * newest first, on the servers the viewer can open; `holder` is another listed player whose name
+ * it read as */
+ feedNames: { name: string; holder: string | null; lastSeen: string }[];
+ /** how many there are in all: the list holds the newest 200 at most */
+ feedNamesTotal: number;
online: { serverId: string; serverName: string } | null;
steamEnabled: boolean;
steam: SteamView | null;
diff --git a/src/test/kill-feed-names.test.ts b/src/test/kill-feed-names.test.ts
new file mode 100644
index 00000000..e2b31376
--- /dev/null
+++ b/src/test/kill-feed-names.test.ts
@@ -0,0 +1,443 @@
+// The names the kill feed showed for players that were not the ones the server listed them under:
+// recorded as kill batches come through the real ingest, on any server with the feed, one row per
+// player and name; shown on the dossier and found by the Players searches only for the servers the
+// caller can open, never another organisation's; and filled once from the history.
+import { afterAll, beforeAll, describe, expect, spyOn, test } from 'bun:test';
+import { and, eq, inArray } from 'drizzle-orm';
+import { join } from 'node:path';
+import type { Env } from '$lib/server/env';
+import {
+ kills,
+ organizations,
+ playerAliases,
+ playerSessions,
+ servers,
+ siteSettings
+} from '$lib/server/db/schema';
+import { newId } from '$lib/server/http';
+import { ingestBatch } from '$lib/server/feed';
+import { onKillsIngested } from '$lib/server/feed-events';
+import { acquireOrRenew, releaseOwnership } from '$lib/server/leadership';
+import { forgetMemory, memoryFor, observeServer } from '$lib/server/observe';
+import { WardogsClient } from '$lib/server/rcon';
+import { fillAliasHistory, recordAliases } from '$lib/server/aliases';
+import { callApi } from './call';
+import { hasTestDb, testEnv } from './db';
+import { seedWorld, type PrincipalName, type World } from './world';
+
+const ROUTES = join(import.meta.dir, '..', 'routes', 'api');
+const COPIER = '76561198000000901';
+const ORIGINAL = '76561198000000902';
+const STEADY = '76561198000000903';
+const STRANGER = '76561198000000904';
+
+type Named = [steamId: string, name: string];
+
+/** One `killed` event as the game posts it, the players named as the feed shows them. */
+const ev = (killer: Named, victim: Named, eventTime = 100) => ({
+ eventId: newId(),
+ type: 'killed',
+ eventTime,
+ matchId: 'boot',
+ mapName: 'Kavkazi',
+ killerName: killer[1],
+ killerSteamId: killer[0],
+ victimName: victim[1],
+ victimSteamId: victim[0],
+ cause: 'Id.Item.AK74M',
+ distance: 5000,
+ contextTags: []
+});
+
+describe.skipIf(!hasTestDb)('the names the kill feed showed', () => {
+ let env: Env;
+ const lists = new Map();
+ const used: string[] = [];
+ let spy: ReturnType;
+
+ beforeAll(async () => {
+ env = { ...(await testEnv()), STEAM_API_KEY: '' };
+ expect(await acquireOrRenew(env, 'kill-feed-names')).toBe(true);
+ // The mock game answers everything but the player list, which the test scripts per server.
+ spy = spyOn(WardogsClient, 'forServer').mockImplementation(async (_env, server) => {
+ const client = new WardogsClient(
+ env,
+ { id: server.id, host: 'demo', port: 1, scheme: 'http' },
+ 'demo',
+ `feed-names-${server.id}`
+ );
+ const raw = client.raw.bind(client);
+ client.raw = async (method, path, body, headers) =>
+ method === 'GET' && path === '/v1/players'
+ ? {
+ status: 200,
+ statusText: 'OK',
+ headers: { 'content-type': 'application/json' },
+ text: JSON.stringify({
+ players: (lists.get(server.id) ?? []).map(([steamId, name]) => ({
+ steamId,
+ name,
+ faction: null,
+ kills: 0
+ }))
+ })
+ }
+ : raw(method, path, body, headers);
+ return client;
+ });
+ });
+
+ afterAll(async () => {
+ spy.mockRestore();
+ for (const id of used) forgetMemory(id);
+ await releaseOwnership(env);
+ });
+
+ /** The server's player list, as the worker reads it at a look. */
+ const listing = async (w: World, serverId: string, ...players: Named[]) => {
+ const [server] = await env.db.select().from(servers).where(eq(servers.id, serverId));
+ const [org] = await env.db.select().from(organizations).where(eq(organizations.id, w.org.id));
+ const m = memoryFor(server, org);
+ used.push(serverId);
+ lists.set(serverId, players);
+ m.playersIntervalMs = 1000;
+ await observeServer(env, m, { status: true, players: true });
+ };
+ const post = async (serverId: string, at: Date, ...events: ReturnType[]) => {
+ const r = await ingestBatch(env, serverId, { serverId: 'i', serverName: 'x', events }, at);
+ await onKillsIngested(env, serverId, r.kills);
+ };
+ const aliasesOf = (serverId: string) =>
+ env.db
+ .select()
+ .from(playerAliases)
+ .where(eq(playerAliases.serverId, serverId))
+ .orderBy(playerAliases.name);
+ const dossier = async (w: World, who: PrincipalName, serverId: string, steamId: string) => {
+ const mod = await import(join(ROUTES, 'servers/[id]/players/[steamId]', '+server.ts'));
+ return callApi(mod.GET, w.users[who], { params: { id: serverId, steamId } });
+ };
+ const feedNamesIn = (answer: { status: number; body: unknown }) =>
+ answer.status === 200
+ ? (answer.body as { dossier: { feedNames: { name: string; holder: string | null }[] } })
+ .dossier.feedNames
+ : answer.status;
+ const seen = async (w: World, who: PrincipalName, serverId: string, q: string) => {
+ const { GET } = await import(join(ROUTES, 'servers/[id]/players/seen', '+server.ts'));
+ return callApi(GET, w.users[who], {
+ params: { id: serverId },
+ query: `q=${encodeURIComponent(q)}`
+ });
+ };
+ const orgSeen = async (w: World, who: PrincipalName, q: string) => {
+ const { GET } = await import(join(ROUTES, 'orgs/[id]/players', '+server.ts'));
+ return callApi(GET, w.users[who], {
+ params: { id: w.org.id },
+ query: `q=${encodeURIComponent(q)}`
+ });
+ };
+ const found = (answer: { body: unknown }) =>
+ (answer.body as { players: { steamId: string; feedNames: string[] }[] }).players.map((p) => [
+ p.steamId,
+ p.feedNames
+ ]);
+ const session = (serverId: string, steamId: string, name: string, joinedAt: Date) => ({
+ serverId,
+ steamId,
+ name,
+ joinedAt,
+ lastSeen: joinedAt
+ });
+
+ test('a batch records the names its players showed that were not their listed ones, once per player and name, whatever the rules', async () => {
+ const w = await seedWorld(env);
+ await listing(
+ w,
+ w.server.id,
+ [COPIER, 'Ghostpepper'],
+ [ORIGINAL, 'Silver Fox'],
+ [STEADY, '[ABC] Steady']
+ );
+ const first = new Date(Date.now() - 60_000);
+ await post(
+ w.server.id,
+ first,
+ ev([COPIER, 'Silver Fox'], [STEADY, '[XYZ] Steady']),
+ ev([STRANGER, 'Silver Fox'], [COPIER, 'Tango'], 101),
+ ev([COPIER, 'Silver Fox'], [ORIGINAL, 'Silver Fox'], 102)
+ );
+ expect(
+ (await aliasesOf(w.server.id)).map((r) => [
+ r.steamId,
+ r.name,
+ r.holder,
+ r.firstSeen.getTime()
+ ])
+ ).toEqual([
+ [COPIER, 'Silver Fox', ORIGINAL, first.getTime()],
+ [COPIER, 'Tango', null, first.getTime()]
+ ]);
+ // shown again later: the same row, its last sighting moved
+ const later = new Date();
+ await post(w.server.id, later, ev([COPIER, 'Silver Fox'], [STEADY, '[ABC] Steady'], 130));
+ const [again] = (await aliasesOf(w.server.id)).filter((r) => r.name === 'Silver Fox');
+ expect([again.firstSeen.getTime(), again.lastSeen.getTime(), again.holder]).toEqual([
+ first.getTime(),
+ later.getTime(),
+ ORIGINAL
+ ]);
+ });
+
+ test('the dossier lists them for the servers the viewer can open, newest first, leaving out a name the player also had as their own', async () => {
+ const w = await seedWorld(env);
+ const now = Date.now();
+ const at = (m: number) => new Date(now - m * 60_000);
+ await env.db
+ .insert(playerSessions)
+ .values([
+ session(w.server.id, COPIER, 'Ghostpepper', at(120)),
+ session(w.otherServer.id, COPIER, 'Ghostpepper', at(110)),
+ session(w.otherServer.id, COPIER, 'Kestrel', at(100))
+ ]);
+ await recordAliases(env.db, w.server.id, [
+ { steamId: COPIER, name: 'Silver Fox', holder: ORIGINAL, firstSeen: at(60), lastSeen: at(50) }
+ ]);
+ await recordAliases(env.db, w.otherServer.id, [
+ { steamId: COPIER, name: 'Cinder', holder: null, firstSeen: at(40), lastSeen: at(30) },
+ { steamId: COPIER, name: 'Kestrel', holder: null, firstSeen: at(20), lastSeen: at(10) }
+ ]);
+ await recordAliases(env.db, w.otherOrgServer.id, [
+ { steamId: COPIER, name: 'Zulu', holder: null, firstSeen: at(5), lastSeen: at(1) }
+ ]);
+ const names = (list: unknown) =>
+ Array.isArray(list)
+ ? list.map((f: { name: string; holder: string | null }) => [f.name, f.holder])
+ : list;
+ // both servers for the owner, newest first; Kestrel was their own name on one of them
+ expect(names(feedNamesIn(await dossier(w, 'owner', w.server.id, COPIER)))).toEqual([
+ ['Cinder', null],
+ ['Silver Fox', ORIGINAL]
+ ]);
+ // a viewer of the first server only, and a member of the second only
+ expect(names(feedNamesIn(await dossier(w, 'viewer', w.server.id, COPIER)))).toEqual([
+ ['Silver Fox', ORIGINAL]
+ ]);
+ expect(names(feedNamesIn(await dossier(w, 'elsewhere', w.otherServer.id, COPIER)))).toEqual([
+ ['Cinder', null]
+ ]);
+ // a key held to the second server reads that server's names, and gets no dossier on the first
+ expect(names(feedNamesIn(await dossier(w, 'keyElsewhere', w.otherServer.id, COPIER)))).toEqual([
+ ['Cinder', null]
+ ]);
+ expect(feedNamesIn(await dossier(w, 'keyElsewhere', w.server.id, COPIER))).toBe(404);
+ // the other organisation's server is never read, and its members get no dossier here
+ expect(feedNamesIn(await dossier(w, 'elsewhere', w.server.id, COPIER))).toBe(404);
+ expect(feedNamesIn(await dossier(w, 'stranger', w.server.id, COPIER))).toBe(404);
+ expect(feedNamesIn(await dossier(w, 'anon', w.server.id, COPIER))).toBe(401);
+ });
+
+ test('the searches find a player by a name the feed showed, only on the servers the caller can open', async () => {
+ const w = await seedWorld(env);
+ const now = Date.now();
+ const at = (m: number) => new Date(now - m * 60_000);
+ await env.db
+ .insert(playerSessions)
+ .values([
+ session(w.server.id, COPIER, 'Ghostpepper', at(120)),
+ session(w.server.id, ORIGINAL, 'Silver Fox', at(120)),
+ session(w.otherServer.id, COPIER, 'Ghostpepper', at(110)),
+ session(w.otherOrgServer.id, COPIER, 'Ghostpepper', at(110))
+ ]);
+ await recordAliases(env.db, w.server.id, [
+ { steamId: COPIER, name: 'Silver Fox', holder: ORIGINAL, firstSeen: at(60), lastSeen: at(50) }
+ ]);
+ await recordAliases(env.db, w.otherServer.id, [
+ { steamId: COPIER, name: 'Cinder', holder: null, firstSeen: at(40), lastSeen: at(30) }
+ ]);
+ await recordAliases(env.db, w.otherOrgServer.id, [
+ { steamId: COPIER, name: 'Zulu', holder: null, firstSeen: at(5), lastSeen: at(1) }
+ ]);
+ // the real one by their name, the copier by the name the feed showed for them
+ expect(found(await seen(w, 'viewer', w.server.id, 'silver')).sort()).toEqual(
+ [
+ [COPIER, ['Silver Fox']],
+ [ORIGINAL, []]
+ ].sort()
+ );
+ // a name shown on the other server is not this server's to search
+ expect(found(await seen(w, 'viewer', w.server.id, 'cinder'))).toEqual([]);
+ expect(found(await orgSeen(w, 'owner', 'cinder'))).toEqual([
+ [COPIER, ['Cinder', 'Silver Fox']]
+ ]);
+ // nor is a name shown on another organisation's server, for anyone here
+ expect(found(await orgSeen(w, 'owner', 'zulu'))).toEqual([]);
+ // a member of the second server only searches the second server's names
+ const theirs = await orgSeen(w, 'elsewhere', 'cinder');
+ expect(theirs.status).toBe(200);
+ expect(found(theirs)).toEqual([[COPIER, ['Cinder']]]);
+ expect(found(await orgSeen(w, 'elsewhere', 'silver'))).toEqual([]);
+ });
+
+ test("a name recorded again keeps the newer sighting's holder, and a long list goes in several statements", async () => {
+ const w = await seedWorld(env);
+ const now = Date.now();
+ const at = (m: number) => new Date(now - m * 60_000);
+ const silver = (holder: string | null, from: number, to: number) => [
+ { steamId: COPIER, name: 'Silver Fox', holder, firstSeen: at(from), lastSeen: at(to) }
+ ];
+ await recordAliases(env.db, w.server.id, silver(ORIGINAL, 10, 5));
+ // an older sighting with another holder (the history arriving after the live row) leaves it
+ await recordAliases(env.db, w.server.id, silver(STEADY, 60, 50));
+ // a newer one with nobody's name keeps the holder known
+ await recordAliases(env.db, w.server.id, silver(null, 2, 1));
+ const [row] = await aliasesOf(w.server.id);
+ expect([row.holder, row.firstSeen.getTime(), row.lastSeen.getTime()]).toEqual([
+ ORIGINAL,
+ at(60).getTime(),
+ at(1).getTime()
+ ]);
+ // a newer one with another holder replaces it
+ await recordAliases(env.db, w.server.id, silver(STEADY, 0.5, 0));
+ expect((await aliasesOf(w.server.id))[0].holder).toBe(STEADY);
+ // more rows than one statement can bind
+ await recordAliases(
+ env.db,
+ w.otherServer.id,
+ Array.from({ length: 2500 }, (_, i) => ({
+ steamId: COPIER,
+ name: `Name ${i}`,
+ holder: null,
+ firstSeen: at(1),
+ lastSeen: at(1)
+ }))
+ );
+ expect(await aliasesOf(w.otherServer.id)).toHaveLength(2500);
+ });
+
+ test('a server whose fill fails leaves the others filled and the history unmarked, for the next try', async () => {
+ const w = await seedWorld(env);
+ const now = Date.now();
+ const at = (m: number) => new Date(now - m * 60_000);
+ const kill = (serverId: string, minutesAgo: number, killer: Named, victim: Named) => ({
+ ts: at(minutesAgo),
+ serverId,
+ eventId: newId(),
+ instanceId: 'i',
+ matchId: 'm',
+ eventTime: 1000 - minutesAgo,
+ map: 'Kavkazi',
+ killerSteamId: killer[0],
+ killerName: killer[1],
+ victimSteamId: victim[0],
+ victimName: victim[1],
+ cause: 'Id.Item.AK74M',
+ tags: []
+ });
+ for (const serverId of [w.server.id, w.otherServer.id]) {
+ await env.db
+ .insert(playerSessions)
+ .values([
+ session(serverId, COPIER, 'Ghostpepper', at(120)),
+ session(serverId, STEADY, 'Steady', at(120))
+ ]);
+ await env.db
+ .insert(kills)
+ .values(kill(serverId, 60, [COPIER, 'Juniper'], [STEADY, 'Steady']));
+ }
+ await env.db.delete(siteSettings).where(eq(siteSettings.key, 'kill_feed_aliases_filled'));
+ // the second server's reads fail, as a dropped connection would
+ const dialect = (
+ env.db as unknown as { dialect: { sqlToQuery(q: unknown): { params: unknown[] } } }
+ ).dialect;
+ const execute = env.db.execute.bind(env.db);
+ const broken = spyOn(env.db, 'execute').mockImplementation(((q: unknown) =>
+ dialect.sqlToQuery(q).params.includes(w.otherServer.id)
+ ? Promise.reject(new Error('connection lost'))
+ : execute(q as Parameters[0])) as typeof env.db.execute);
+ try {
+ expect(await fillAliasHistory(env, { pauseMs: 0 })).toBe('incomplete');
+ } finally {
+ broken.mockRestore();
+ }
+ expect((await aliasesOf(w.server.id)).map((r) => r.name)).toEqual(['Juniper']);
+ expect(await aliasesOf(w.otherServer.id)).toEqual([]);
+ const [mark] = await env.db
+ .select()
+ .from(siteSettings)
+ .where(eq(siteSettings.key, 'kill_feed_aliases_filled'));
+ expect(mark).toBeUndefined();
+ // the next try fills what was left
+ expect(await fillAliasHistory(env, { pauseMs: 0 })).toBe('done');
+ expect((await aliasesOf(w.otherServer.id)).map((r) => r.name)).toEqual(['Juniper']);
+ });
+
+ test('the history is filled once, from the kills against the sessions open at each, merging with what was recorded live', async () => {
+ const w = await seedWorld(env);
+ const now = Date.now();
+ const at = (m: number) => new Date(now - m * 60_000);
+ await env.db.insert(playerSessions).values([
+ session(w.server.id, COPIER, 'Ghostpepper', at(48 * 60)),
+ session(w.server.id, ORIGINAL, '[R-14] Silver Fox', at(48 * 60)),
+ // left before the kills: not listed at them
+ { ...session(w.server.id, STRANGER, 'Tango', at(200)), lastSeen: at(190), leftAt: at(190) }
+ ]);
+ const kill = (minutesAgo: number, killer: Named, victim: Named) => ({
+ ts: at(minutesAgo),
+ serverId: w.server.id,
+ eventId: newId(),
+ instanceId: 'i',
+ matchId: 'm',
+ eventTime: 1000 - minutesAgo,
+ map: 'Kavkazi',
+ killerSteamId: killer[0],
+ killerName: killer[1],
+ victimSteamId: victim[0],
+ victimName: victim[1],
+ cause: 'Id.Item.AK74M',
+ tags: []
+ });
+ await env.db.insert(kills).values([
+ // more than a day before the others: another UTC day of the fill
+ kill(30 * 60, [COPIER, 'Halcyon'], [ORIGINAL, '[R-14] Silver Fox']),
+ kill(90, [COPIER, 'Ghostpepper'], [ORIGINAL, '[R-14] Silver Fox']),
+ kill(80, [COPIER, 'Silver Fox'], [ORIGINAL, '[R-14] Silver Fox']),
+ kill(70, [ORIGINAL, '[R-14] Silver Fox'], [COPIER, 'Tango']),
+ kill(60, [STRANGER, 'Nightjar'], [COPIER, 'Ghostpepper'])
+ ]);
+ // recorded live meanwhile, later than the history
+ await recordAliases(env.db, w.server.id, [
+ { steamId: COPIER, name: 'Silver Fox', holder: ORIGINAL, firstSeen: at(2), lastSeen: at(1) }
+ ]);
+ await env.db.delete(siteSettings).where(eq(siteSettings.key, 'kill_feed_aliases_filled'));
+ // a process that no longer owns the worker leaves it to the next owner, unmarked
+ await releaseOwnership(env);
+ expect(await fillAliasHistory(env, { pauseMs: 0 })).toBe('stopped');
+ expect(await acquireOrRenew(env, 'kill-feed-names')).toBe(true);
+ expect(await fillAliasHistory(env, { pauseMs: 0 })).toBe('done');
+ expect(
+ (await aliasesOf(w.server.id)).map((r) => [
+ r.steamId,
+ r.name,
+ r.holder,
+ r.firstSeen.getTime(),
+ r.lastSeen.getTime()
+ ])
+ ).toEqual([
+ [COPIER, 'Halcyon', null, at(30 * 60).getTime(), at(30 * 60).getTime()],
+ [COPIER, 'Silver Fox', ORIGINAL, at(80).getTime(), at(1).getTime()],
+ [COPIER, 'Tango', null, at(70).getTime(), at(70).getTime()]
+ ]);
+ expect(await fillAliasHistory(env, { pauseMs: 0 })).toBe('already');
+ // nothing else came of the fill for this world's players
+ const others = await env.db
+ .select()
+ .from(playerAliases)
+ .where(
+ and(
+ eq(playerAliases.serverId, w.server.id),
+ inArray(playerAliases.steamId, [STRANGER, ORIGINAL])
+ )
+ );
+ expect(others).toEqual([]);
+ });
+});
From 8c712fd507918dae07bc117747a949635e693b93 Mon Sep 17 00:00:00 2001
From: James Causon
Date: Wed, 7 Oct 2026 00:50:47 +0100
Subject: [PATCH 035/102] Dossier and Players searches show the kill feed
names: one line under the dossier's SteamID with the five newest, the rest a
press away, another player's name in brass and opening their page, and a link
to the player's kills; a line on a Past players or org Players row with up to
three, the one the search found first and in brass
---
src/lib/feed-names.test.ts | 29 +++++++++
src/lib/feed-names.ts | 18 ++++++
.../(app)/orgs/[id]/players/+page.svelte | 11 ++++
.../server/[id]/players/PastPlayers.svelte | 11 ++++
.../[id]/players/[steamId]/+page.svelte | 59 +++++++++++++++++++
5 files changed, 128 insertions(+)
create mode 100644 src/lib/feed-names.test.ts
create mode 100644 src/lib/feed-names.ts
diff --git a/src/lib/feed-names.test.ts b/src/lib/feed-names.test.ts
new file mode 100644
index 00000000..6a0254ee
--- /dev/null
+++ b/src/lib/feed-names.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, test } from 'bun:test';
+import { feedNamesLine } from './feed-names';
+
+describe('a search row’s kill-feed names', () => {
+ const names = ['Halcyon', 'Silver Fox', 'Orchid', 'Silverback', 'Tango'];
+
+ test('the ones the search found first, then the newest, and how many more', () => {
+ expect(feedNamesLine(names, 'silver')).toEqual({
+ names: [
+ { name: 'Silver Fox', hit: true },
+ { name: 'Silverback', hit: true },
+ { name: 'Halcyon', hit: false }
+ ],
+ more: 2
+ });
+ });
+
+ test('without a search, the newest few; a short list, all of it', () => {
+ expect(feedNamesLine(names, ' ').names.map((n) => n.name)).toEqual([
+ 'Halcyon',
+ 'Silver Fox',
+ 'Orchid'
+ ]);
+ expect(feedNamesLine(['Tango'], 'x')).toEqual({
+ names: [{ name: 'Tango', hit: false }],
+ more: 0
+ });
+ });
+});
diff --git a/src/lib/feed-names.ts b/src/lib/feed-names.ts
new file mode 100644
index 00000000..6c2e81d4
--- /dev/null
+++ b/src/lib/feed-names.ts
@@ -0,0 +1,18 @@
+// How a Players search row shows the names the kill feed showed for a player that were not the
+// ones the server listed them under: a few of them, the ones the search found first, then how many
+// more. Pure and client-safe.
+
+/** Up to `n` of a player's kill-feed names, those containing the search text first (`hit`). */
+export function feedNamesLine(
+ names: readonly string[],
+ q: string,
+ n = 3
+): { names: { name: string; hit: boolean }[]; more: number } {
+ const needle = q.trim().toLowerCase();
+ const hit = (name: string) => !!needle && name.toLowerCase().includes(needle);
+ const ordered = [...names.filter(hit), ...names.filter((name) => !hit(name))];
+ return {
+ names: ordered.slice(0, n).map((name) => ({ name, hit: hit(name) })),
+ more: Math.max(0, names.length - n)
+ };
+}
diff --git a/src/routes/(app)/orgs/[id]/players/+page.svelte b/src/routes/(app)/orgs/[id]/players/+page.svelte
index f2fc09ff..eab21d1c 100644
--- a/src/routes/(app)/orgs/[id]/players/+page.svelte
+++ b/src/routes/(app)/orgs/[id]/players/+page.svelte
@@ -13,6 +13,7 @@
import SortHeader from '$lib/components/SortHeader.svelte';
import type { SortLike } from '$lib/table.svelte';
import type { SeenPlayer, SeenSort } from '$lib/server/seen';
+ import { feedNamesLine } from '$lib/feed-names';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
@@ -252,6 +253,16 @@
>{/if}
{#if p.watched}watch{/if}
+ {#if p.feedNames.length}
+ {@const line = feedNamesLine(p.feedNames, f.q)}
+
+ in the kill feed as {#each line.names as n, i (n.name)}{n.name}{i < line.names.length - 1
+ ? ', '
+ : ''}{/each}{#if line.more}{` and ${line.more} more`}{/if}
+
+ {/if}
{#if p.aliases.length}
also {p.aliases.slice(0, 4).join(', ')}{#if p.aliases.length > 4}
diff --git a/src/routes/(app)/server/[id]/players/PastPlayers.svelte b/src/routes/(app)/server/[id]/players/PastPlayers.svelte
index aa6946cd..f6a853bb 100644
--- a/src/routes/(app)/server/[id]/players/PastPlayers.svelte
+++ b/src/routes/(app)/server/[id]/players/PastPlayers.svelte
@@ -11,6 +11,7 @@
import type { SortLike } from '$lib/table.svelte';
import type { SeenPlayer, SeenSort } from '$lib/server/seen';
import type { BanReason } from '$lib/ban-reasons';
+ import { feedNamesLine } from '$lib/feed-names';
let {
server,
@@ -183,6 +184,16 @@
>
{/if}
+ {#if p.feedNames.length}
+ {@const line = feedNamesLine(p.feedNames, q)}
+
+ in the kill feed as {#each line.names as f, i (f.name)}{f.name}{i < line.names.length - 1
+ ? ', '
+ : ''}{/each}{#if line.more}{` and ${line.more} more`}{/if}
+
+ {/if}
{p.steamId}
{fmtAgo(p.lastSeen)}
diff --git a/src/routes/(app)/server/[id]/players/[steamId]/+page.svelte b/src/routes/(app)/server/[id]/players/[steamId]/+page.svelte
index 0462eef3..9ddeb058 100644
--- a/src/routes/(app)/server/[id]/players/[steamId]/+page.svelte
+++ b/src/routes/(app)/server/[id]/players/[steamId]/+page.svelte
@@ -21,6 +21,29 @@
let { data }: PageProps = $props();
let d = $derived(data.dossier);
let id = $derived(data.server.id);
+
+ // The names the kill feed showed for the player: the newest few, the rest a press away.
+ const FEED_SHOWN = 5;
+ let feedOpenFor = $state(null);
+ let feedShown = $derived(
+ feedOpenFor === d.steamId ? d.feedNames : d.feedNames.slice(0, FEED_SHOWN)
+ );
+ let feedTaken = $derived(d.feedNames.filter((f) => f.holder).length);
+ // a player with more names than the list holds: what it says is of the newest
+ let feedOf = $derived(
+ d.feedNamesTotal > d.feedNames.length
+ ? `the ${d.feedNames.length} newest of ${d.feedNamesTotal}`
+ : `the ${d.feedNames.length}`
+ );
+ let feedTakenText = $derived(
+ feedTaken < d.feedNames.length
+ ? `${feedTaken} of ${feedOf} ${feedTaken === 1 ? 'was' : 'were'} another player's name on the server`
+ : d.feedNames.length === 1
+ ? "another player's name on the server"
+ : d.feedNamesTotal > d.feedNames.length
+ ? `each of ${feedOf} another player's name on the server`
+ : "each another player's name on the server"
+ );
let canKick = $derived(can(data.server.caps, 'players.kick'));
let chat = $derived(can(data.server.caps, 'chat.send'));
let bans = $derived(can(data.server.caps, 'bans.manage'));
@@ -194,6 +217,42 @@
>· also seen as {d.names.slice(1, 6).join(', ')}{d.names.length > 6 ? '…' : ''}{/if}
+ {#if d.feedNames.length}
+
+ In the kill feed as
+ {#each feedShown as f, i (f.name)}{#if f.holder}{f.name}{:else}{f.name}{/if}{i < feedShown.length - 1 ? ', ' : ''}{/each}
+ {#if d.feedNames.length > FEED_SHOWN}
+ {#if feedOpenFor === d.steamId}
+ ·
+ {:else}
+ and
+ {/if}
+ {/if}
+ {#if feedTaken}· {feedTakenText}{/if}
+ ·
+ Kills →
+
+ {/if}
From f86e82d984ab7d0bb99975b0b847fa2fa6bd1cdb Mon Sep 17 00:00:00 2001
From: James Causon
Date: Wed, 7 Oct 2026 00:50:47 +0100
Subject: [PATCH 036/102] Document the kill feed names
---
README.md | 27 ++++++++++++++++++++-------
1 file changed, 20 insertions(+), 7 deletions(-)
diff --git a/README.md b/README.md
index 95b707d8..83652ee4 100644
--- a/README.md
+++ b/README.md
@@ -44,9 +44,10 @@ What is in the box:
minute when it is empty. Pages get each observation as it happens over an event stream, and a
command you send shows its effect on the next look. Browsers never talk to a game server.
- **Past players**: the Players tab switches between who is on now and everyone who has played on
- that server, searched by name, alias or SteamID64, with when they were last on, their sessions
- and playtime. Anyone who can open the server can look; a row's Ban (for people who hold _Bans_)
- lands the moment the player next joins, and Watch needs _Notes_.
+ that server, searched by name, alias, a name the kill feed showed for them or SteamID64, with
+ when they were last on, their sessions and playtime. Anyone who can open the server can look; a
+ row's Ban (for people who hold _Bans_) lands the moment the player next joins, and Watch needs
+ _Notes_.
- **Analytics**: the worker keeps what the game does not: players online over time, players per
day and how many of them were new, how long sessions last, cash in play per faction, uptime,
time per map, wins per team, busiest hours, player playtime and sessions, match history
@@ -410,6 +411,17 @@ watchlist_ can write them, and a note can be deleted by its author or a role wit
A [Discord webhook](#discord-webhooks) ticked for _Watched players joining_ posts each time a
watched player joins one of the organisation's servers.
+On servers with the [kill feed](#kill-feed), the dossier also lists the names the feed showed for
+the player that were not the one the server listed them under, newest first: _In the kill feed as_.
+The game's player list keeps the name a player joined with (a clan tag changed mid-game aside), so a
+player who renames mid-game to hide, often as another player, shows only there. A name that was
+another player's on the server at the time is in brass and opens that player's page; the five
+newest show, the rest a press away, with a link to the player's kills. A name they also played under
+as their own there is left out. Past players and the
+organisation's Players find a player by those names too, and show which one the search found. Only
+the servers the reader can open are read. The names are recorded as kills come in, kept for good,
+and filled once from the kill history, in the background, after the upgrade that adds them.
+
With a Steam key, the Players tab shows a player's Steam name under an in-game name that does not
already hold it (a streamer's hidden name, say), and its filter finds players by that name too.
@@ -615,7 +627,8 @@ history (filter by killer, victim, either side, weapon or vehicle, kind of kill
distance, with a count, older pages and new kills arriving live; the filter lives in the URL, so a
view can be shared), a **Combat** section on Analytics (kills per bucket, weapons, longest kills,
top killers with headshot share and team kills), a Combat card on every player dossier (weapons,
-most-killed, nemeses, recent kills and deaths), and the team-kill trigger. Team kills are
+most-killed, nemeses, recent kills and deaths), the names players showed in it that were not their
+own (on their dossiers and in the Players searches), and the team-kill trigger. Team kills are
inferred: the feed carries no factions, so Warcon uses the factions it observed for both players
at that moment. Feed events are history and are
never pruned (a TimescaleDB hypertable with compression where the extension is installed). The
@@ -1196,8 +1209,8 @@ These need _View_ on the server unless the table says otherwise.
| Route | Answers |
| -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `GET /api/servers/:id/players/seen` | everyone who has played on the server: `q` (name, alias or SteamID), `since` (days), `flag` (`banned`, `watched` or `online`), `sort` (`lastSeen`, `firstSeen`, `minutes`, `sessions`, `kills`, `deaths`, `name`), `dir`, `offset`, `limit` (up to 100) |
-| `GET /api/servers/:id/players/:steamId` | the dossier: names, sessions, totals per server, bans, risk, the kill feed's summary; notes and why a player is watched only with _Notes & watchlist_ |
+| `GET /api/servers/:id/players/seen` | everyone who has played on the server: `q` (name, alias, a name the kill feed showed for them, or SteamID; each player's `feedNames` are those names), `since` (days), `flag` (`banned`, `watched` or `online`), `sort` (`lastSeen`, `firstSeen`, `minutes`, `sessions`, `kills`, `deaths`, `name`), `dir`, `offset`, `limit` (up to 100) |
+| `GET /api/servers/:id/players/:steamId` | the dossier: names, the names the kill feed showed that were not theirs (`feedNames`), sessions, totals per server, bans, risk, the kill feed's summary; notes and why a player is watched only with _Notes & watchlist_ |
| `GET /api/servers/:id/players/:steamId/career` | rank, streak, results by map and faction, the last ten matches, the organisation's last ten seasons with the places won |
| `GET /api/servers/:id/players/marks?ids=a,b` | watched, first visit, risk score and Steam name (`steamName`) for up to 200 SteamIDs |
| `GET /api/servers/:id/kills` | the stored kill feed, newest first (see [Kill feed](#kill-feed)): `killer`, `victim`, `player` (a SteamID, or part of a name), `kind` (`headshot`, `teamKill`, `suicide`, `vehicle`, `environment`), `cause`, `minM` (metres), `match`, `limit` (up to 200); `count=1` adds the total. For the next page, send the last kill's `ts` as `before` and its `eventTime` as `beforeTime` |
@@ -1513,7 +1526,7 @@ GET /api/orgs/:id/lists the org lists the calle
GET/POST /api/orgs/:id/lists/:kind/entries {steamId,reason,expiresAt} PATCH {reason,expiresAt} / DELETE .../entries/:steamId (kind = ban | reserve, needing Org ban list or Org reserved slots; ?includeRemoved=1)
POST /api/orgs/:id/lists/sync push the lists to every org server now
GET /api/orgs/:id/lists/import server entries not on the org list POST {entries:[{kind,steamId,reason}]} adopts them (owner)
-GET /api/servers/:id/players/seen?q=&since=&flag=&sort=&dir=&offset=&limit= everyone who has played on this server, by name, alias or SteamID (View; 60 a minute)
+GET /api/servers/:id/players/seen?q=&since=&flag=&sort=&dir=&offset=&limit= everyone who has played on this server, by name, alias, kill-feed name or SteamID (View; 60 a minute)
GET /api/servers/:id/lists/state which bans / reserved slots here come from the org lists or this server's own POST .../lists/sync
POST /api/servers/:id/lists/ban/entries {steamId,reason,expiresAt} ban on this server only, placed on sight if the player is away (Bans) PATCH {reason,expiresAt} / DELETE .../entries/:steamId
POST /api/servers/:id/lists/reserve/entries {steamId,reason,expiresAt} reserve on this server only (Reserved slots) DELETE .../entries/:steamId
From 083ef90fe3ee3500acd4c5680f2654003e365c70 Mon Sep 17 00:00:00 2001
From: James Causon
Date: Wed, 7 Oct 2026 01:23:33 +0100
Subject: [PATCH 037/102] Cash per minute on the boards: a $/min column after
Cash, the cash a player made per minute of the time it was made in, seed time
left out as kills per hour leaves it, sortable (sort=cashPerMin) and in the
CSV export as cash_per_min. A board counts a session's cash where the session
ended, so the time it divides by is the whole of those sessions (cashMinutes
on each row): a session that began before a range or season counts all its
time as it does all its cash, and one still going when a season ended counts
in neither, worked out from the rows the boards already read and held against
a plain read of every session by the exactness test. The panel's Last seen
may wrap after its date, so the sixteen columns fit at 1440 beside the
SteamIDs, and the panel board's note no longer says cash is as last seen: it
has been added up match by match since 22 September
---
src/lib/components/LeaderboardTable.svelte | 16 ++-
src/lib/leaderboard.test.ts | 15 +++
src/lib/leaderboard.ts | 21 +++-
src/lib/server/leaderboards.ts | 72 +++++++-----
.../server/[id]/leaderboard/+page.svelte | 7 +-
.../(public)/s/[id]/leaderboard/+page.svelte | 5 +-
.../[id]/leaderboard/export/+server.ts | 10 +-
src/test/board-export.test.ts | 109 +++++++++++++++++-
src/test/player-totals.test.ts | 1 +
src/test/totals-oracle.ts | 39 ++++---
10 files changed, 242 insertions(+), 53 deletions(-)
diff --git a/src/lib/components/LeaderboardTable.svelte b/src/lib/components/LeaderboardTable.svelte
index 1147202b..83b2d101 100644
--- a/src/lib/components/LeaderboardTable.svelte
+++ b/src/lib/components/LeaderboardTable.svelte
@@ -13,6 +13,7 @@
BOARD_RANGES,
kdRatio,
perHour,
+ perMinute,
winRate,
type BoardMetric,
type BoardQuery,
@@ -196,6 +197,13 @@
W-L-DWin %Cash
+ $/min
{#if !final}
{#if !board || loading}Loading…{:else if board.total === 0 && query.minMinutes > 0}Nobody
has {fmtMinutes(query.minMinutes)} of playtime in this {what}{final
? ''
diff --git a/src/lib/leaderboard.test.ts b/src/lib/leaderboard.test.ts
index 605575d2..b1d62aa8 100644
--- a/src/lib/leaderboard.test.ts
+++ b/src/lib/leaderboard.test.ts
@@ -10,6 +10,7 @@ import {
metricValue,
parseBoardQuery,
perHour,
+ perMinute,
rangeStart,
streak,
winRate,
@@ -80,6 +81,13 @@ describe('ratios with zero denominators', () => {
expect(perHour(30, 90, 30)).toBe(30);
expect(perHour(30, 90, 90)).toBeNull();
});
+ test('cash per minute needs playtime, and seed time is not playtime', () => {
+ expect(perMinute(900, 90)).toBe(10);
+ expect(perMinute(900, 0)).toBeNull();
+ expect(perMinute(0, 60)).toBe(0);
+ expect(perMinute(900, 90, 30)).toBe(15);
+ expect(perMinute(900, 90, 90)).toBeNull();
+ });
test('win rate needs a match with a result', () => {
expect(winRate(3, 1, 0)).toBe(0.75);
expect(winRate(1, 1, 2)).toBe(0.25);
@@ -113,6 +121,7 @@ describe('board query', () => {
expect(
parseBoardQuery(new URLSearchParams('scope=x&range=1y&sort=luck&dir=up&page=0&minMinutes=-5'))
).toEqual({ ...DEFAULT_BOARD_QUERY, page: 1, minMinutes: 0 });
+ expect(parseBoardQuery(new URLSearchParams('sort=cashPerMin')).sort).toBe('cashPerMin');
});
test('round-trips through its parameters, defaults left out', () => {
expect(boardQueryParams(DEFAULT_BOARD_QUERY)).toEqual({});
@@ -133,6 +142,7 @@ describe('metricValue', () => {
steamId: '76561198000000001',
name: 'Nomad',
minutes: 120,
+ cashMinutes: 120,
seedMinutes: 45,
kills: 40,
deaths: 0,
@@ -155,9 +165,14 @@ describe('metricValue', () => {
expect(metricValue(row, 'perHour')).toBe(32);
expect(metricValue(row, 'winRate')).toBe(0.5);
expect(metricValue(row, 'cash')).toBe(900);
+ // $900 over the same 75 minutes
+ expect(metricValue(row, 'cashPerMin')).toBe(12);
+ // over the whole time of the sessions it came from, when one began before the range
+ expect(metricValue({ ...row, cashMinutes: 195 }, 'cashPerMin')).toBe(6);
expect(metricValue(row, 'seeded')).toBe(45);
expect(metricValue({ ...row, minutes: 0 }, 'perHour')).toBeNull();
expect(metricValue({ ...row, minutes: 45 }, 'perHour')).toBeNull();
+ expect(metricValue({ ...row, cashMinutes: 45 }, 'cashPerMin')).toBeNull();
});
});
diff --git a/src/lib/leaderboard.ts b/src/lib/leaderboard.ts
index e11f1d07..31c2ef8e 100644
--- a/src/lib/leaderboard.ts
+++ b/src/lib/leaderboard.ts
@@ -22,7 +22,8 @@ export type BoardMetric =
| 'matches'
| 'wins'
| 'winRate'
- | 'cash';
+ | 'cash'
+ | 'cashPerMin';
export type SortDir = 'asc' | 'desc';
export const BOARD_RANGES: { key: FixedRange; label: string; ms: number | null }[] = [
@@ -41,7 +42,8 @@ export const BOARD_METRICS: { key: BoardMetric; label: string }[] = [
{ key: 'matches', label: 'Matches' },
{ key: 'wins', label: 'Wins' },
{ key: 'winRate', label: 'Win rate' },
- { key: 'cash', label: 'Cash' }
+ { key: 'cash', label: 'Cash' },
+ { key: 'cashPerMin', label: 'Cash per minute' }
];
const METRIC_KEYS = new Set(BOARD_METRICS.map((m) => m.key));
const RANGE_KEYS = new Set(BOARD_RANGES.map((r) => r.key));
@@ -91,6 +93,12 @@ export interface BoardRow {
/** the name last seen with, or the id when no session has one */
name: string;
minutes: number;
+ /**
+ * the whole time of the sessions the cash came from, what cash per minute divides by: a session
+ * that began before the range or season counts all of its cash, so all of its minutes too, and
+ * one still going when a season ended counts in neither; the playtime itself on all time
+ */
+ cashMinutes: number;
/** minutes on with the server low, as the Seeding reward counts them (0 without a rule) */
seedMinutes: number;
kills: number;
@@ -187,6 +195,13 @@ export const kdRatio = (kills: number, deaths: number): number | null =>
export const perHour = (kills: number, minutes: number, seedMinutes = 0): number | null =>
minutes - seedMinutes > 0 ? kills / ((minutes - seedMinutes) / 60) : null;
+/**
+ * Cash per minute of the time it was made in (a board row's cashMinutes), seed time left out as
+ * for kills per hour; nothing without such time.
+ */
+export const perMinute = (cash: number, minutes: number, seedMinutes = 0): number | null =>
+ minutes - seedMinutes > 0 ? cash / (minutes - seedMinutes) : null;
+
/** Wins over the matches that had a result; nothing without one. */
export const winRate = (wins: number, losses: number, draws: number): number | null => {
const played = wins + losses + draws;
@@ -276,6 +291,8 @@ export function metricValue(row: BoardRow, metric: BoardMetric): number | null {
return winRate(row.wins, row.losses, row.draws);
case 'cash':
return row.cash;
+ case 'cashPerMin':
+ return perMinute(row.cash, row.cashMinutes, row.seedMinutes);
}
}
diff --git a/src/lib/server/leaderboards.ts b/src/lib/server/leaderboards.ts
index 58b86770..192aab69 100644
--- a/src/lib/server/leaderboards.ts
+++ b/src/lib/server/leaderboards.ts
@@ -81,30 +81,34 @@ const lines = (ids: string[], from: Date, steamId: string | string[] | null) =>
* over them, then the columns of the reads before player_days (base in src/test/totals-oracle.ts),
* word for word: a player's session side only when they have a session in range, the line side
* only with a line in range, else the zero the missing side had. Seconds are exact numeric sums,
- * so the minutes are the same digits for any `from`. Also read by the exactness test.
+ * so the minutes are the same digits for any `from`. `cash_minutes` is the whole time of the
+ * sessions the cash came from, what cash per minute divides by: a session that began before
+ * `from` counts all of its cash, so all of its time too (from its join, not from `from`). Also
+ * read by the exactness test.
*/
export const rangeBase = (ids: string[], from: Date) => sql`
edge AS (
SELECT ${from}::timestamptz AS f, (${from}::timestamptz AT TIME ZONE 'UTC')::date + 1 AS d1,
((${from}::timestamptz AT TIME ZONE 'UTC')::date + 1)::timestamp AT TIME ZONE 'UTC' AS m1),
parts AS (
- SELECT d.steam_id, d.sessions AS n, d.seconds AS sec, d.seed_seconds AS seed, d.cash, d.last_seen AS ls,
- d.matches AS m, d.kills, d.deaths, d.headshots, d.team_kills, d.suicides, d.vehicle_kills,
- d.kill_streak, d.death_streak, d.wins, d.losses, d.draws
+ SELECT d.steam_id, d.sessions AS n, d.seconds AS sec, d.seconds AS cs, d.seed_seconds AS seed, d.cash,
+ d.last_seen AS ls, d.matches AS m, d.kills, d.deaths, d.headshots, d.team_kills, d.suicides,
+ d.vehicle_kills, d.kill_streak, d.death_streak, d.wins, d.losses, d.draws
FROM player_days d, edge WHERE d.server_id IN ${ids} AND d.day >= edge.d1
UNION ALL
- SELECT d.steam_id, 0, EXTRACT(EPOCH FROM (edge.m1 - GREATEST(j, edge.f))), 0, 0, NULL,
- 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
+ SELECT d.steam_id, 0, EXTRACT(EPOCH FROM (edge.m1 - GREATEST(j, edge.f))), EXTRACT(EPOCH FROM (edge.m1 - j)),
+ 0, 0, NULL, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
FROM player_days d CROSS JOIN edge CROSS JOIN LATERAL unnest(d.crossings) j
WHERE d.server_id IN ${ids} AND d.day = edge.d1 AND d.crossings IS NOT NULL
UNION ALL
SELECT s.steam_id, 1, EXTRACT(EPOCH FROM (COALESCE(s.left_at, now()) - GREATEST(s.joined_at, edge.f))),
+ EXTRACT(EPOCH FROM (COALESCE(s.left_at, now()) - s.joined_at)),
s.seed_seconds, s.cash, s.last_seen, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
FROM player_sessions s, edge
WHERE s.server_id IN ${ids} AND s.last_seen >= edge.f
AND (s.left_at IS NULL OR (s.left_at >= edge.f AND s.left_at < edge.m1))
UNION ALL
- SELECT p.steam_id, 0, 0, 0, 0, NULL, 1, p.kills, p.deaths, p.headshots, p.team_kills, p.suicides,
+ SELECT p.steam_id, 0, 0, 0, 0, 0, NULL, 1, p.kills, p.deaths, p.headshots, p.team_kills, p.suicides,
p.vehicle_kills, p.kill_streak, p.death_streak, CASE WHEN x.r = 'win' THEN 1 ELSE 0 END,
CASE WHEN x.r = 'loss' THEN 1 ELSE 0 END, CASE WHEN x.r = 'draw' THEN 1 ELSE 0 END
FROM edge, matches mt
@@ -112,16 +116,17 @@ export const rangeBase = (ids: string[], from: Date) => sql`
CROSS JOIN LATERAL (SELECT match_result(mt.final_scores, mt.winner, p.faction) AS r) x
WHERE mt.server_id IN ${ids} AND mt.ended_at >= edge.f AND mt.ended_at < edge.m1),
player AS (
- SELECT steam_id, SUM(n) AS n, SUM(sec) AS sec, SUM(seed) AS seed, MAX(ls) AS ls, SUM(cash) AS cash,
- SUM(m) AS m, SUM(kills) AS kills, SUM(deaths) AS deaths, SUM(headshots) AS headshots,
- SUM(team_kills) AS team_kills, SUM(suicides) AS suicides, SUM(vehicle_kills) AS vehicle_kills,
- MAX(kill_streak) FILTER (WHERE m > 0) AS kill_streak,
+ SELECT steam_id, SUM(n) AS n, SUM(sec) AS sec, SUM(cs) AS cs, SUM(seed) AS seed, MAX(ls) AS ls,
+ SUM(cash) AS cash, SUM(m) AS m, SUM(kills) AS kills, SUM(deaths) AS deaths,
+ SUM(headshots) AS headshots, SUM(team_kills) AS team_kills, SUM(suicides) AS suicides,
+ SUM(vehicle_kills) AS vehicle_kills, MAX(kill_streak) FILTER (WHERE m > 0) AS kill_streak,
MAX(death_streak) FILTER (WHERE m > 0) AS death_streak,
SUM(wins) AS wins, SUM(losses) AS losses, SUM(draws) AS draws
FROM parts GROUP BY steam_id HAVING SUM(n) > 0 OR SUM(m) > 0),
base AS (
SELECT steam_id,
CASE WHEN n > 0 THEN sec / 60 ELSE 0 END AS minutes,
+ CASE WHEN n > 0 THEN cs / 60 ELSE 0 END AS cash_minutes,
CASE WHEN n > 0 THEN seed / 60.0 ELSE 0 END AS seed_minutes,
CASE WHEN n > 0 THEN cash ELSE 0 END AS cash, CASE WHEN n > 0 THEN ls END AS last_seen,
CASE WHEN m > 0 THEN kills ELSE 0 END AS kills, CASE WHEN m > 0 THEN deaths ELSE 0 END AS deaths,
@@ -143,9 +148,13 @@ export const rangeBase = (ids: string[], from: Date) => sql`
* before `to`. When `to` falls on from's own day, or at its end, only the first day is read, up
* to `to`. The sessions still open count up to `to`. A session is in the season when it left in
* it or was still going at its end; its seed time and cash go where it left, as the day rows keep
- * them, so one still going at `to` gives the season its time and nothing else. Last seen has no
- * meaning for a finished season and is left empty. Read against oracleSeasonBase by the
- * exactness test.
+ * them, so one still going at `to` gives the season its time and nothing else. `cash_minutes` is
+ * the whole time of the sessions the cash came from, as in rangeBase: the time before `from` of
+ * one that left in the season counts, and none of one still going at `to`. A crossing into D1
+ * adds its time from its join to that midnight once `to` is past it; one into the day after Dt,
+ * or one that left on Dt after `to`, takes back what D1's crossing and the day rows gave it, its
+ * time from its join, or D1, to Dt's midnight. Last seen has no meaning for a finished season
+ * and is left empty. Read against oracleSeasonBase by the exactness test.
*/
export const seasonBase = (ids: string[], from: Date, to: Date) => sql`
edge AS (
@@ -156,25 +165,28 @@ export const seasonBase = (ids: string[], from: Date, to: Date) => sql`
(${from}::timestamptz AT TIME ZONE 'UTC')::date + 1 AS d1,
(${to}::timestamptz AT TIME ZONE 'UTC')::date AS dt) a) b),
parts AS (
- SELECT d.steam_id, d.sessions AS n, d.seconds AS sec, d.seed_seconds AS seed, d.cash,
+ SELECT d.steam_id, d.sessions AS n, d.seconds AS sec, d.seconds AS cs, d.seed_seconds AS seed, d.cash,
d.matches AS m, d.kills, d.deaths, d.headshots, d.team_kills, d.suicides, d.vehicle_kills,
d.kill_streak, d.death_streak, d.wins, d.losses, d.draws
FROM player_days d, edge WHERE d.server_id IN ${ids} AND d.day >= edge.d1 AND d.day < edge.dt
UNION ALL
- SELECT d.steam_id, 0, EXTRACT(EPOCH FROM (LEAST(edge.m1, edge.t) - GREATEST(j, edge.f))), 0, 0,
+ SELECT d.steam_id, 0, EXTRACT(EPOCH FROM (LEAST(edge.m1, edge.t) - GREATEST(j, edge.f))),
+ CASE WHEN edge.late THEN EXTRACT(EPOCH FROM (edge.m1 - j)) ELSE 0 END, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
FROM player_days d CROSS JOIN edge CROSS JOIN LATERAL unnest(d.crossings) j
WHERE d.server_id IN ${ids} AND d.day = edge.d1 AND d.crossings IS NOT NULL AND j < edge.t
UNION ALL
SELECT s.steam_id, CASE WHEN s.left_at < edge.t THEN 1 ELSE 0 END,
EXTRACT(EPOCH FROM (LEAST(s.left_at, edge.t) - GREATEST(s.joined_at, edge.f))),
+ CASE WHEN s.left_at < edge.t THEN EXTRACT(EPOCH FROM (s.left_at - s.joined_at)) ELSE 0 END,
CASE WHEN s.left_at < edge.t THEN s.seed_seconds ELSE 0 END,
CASE WHEN s.left_at < edge.t THEN s.cash ELSE 0 END, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
FROM player_sessions s, edge
WHERE s.server_id IN ${ids} AND s.left_at >= edge.f AND s.left_at < edge.m1
AND (s.left_at < edge.t OR s.joined_at < edge.t)
UNION ALL
- SELECT d.steam_id, 0, EXTRACT(EPOCH FROM (edge.t - GREATEST(j, edge.mt))), 0, 0,
+ SELECT d.steam_id, 0, EXTRACT(EPOCH FROM (edge.t - GREATEST(j, edge.mt))),
+ -GREATEST(EXTRACT(EPOCH FROM (edge.mt - j)), 0), 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
FROM player_days d CROSS JOIN edge CROSS JOIN LATERAL unnest(d.crossings) j
WHERE edge.late AND d.server_id IN ${ids} AND d.day = edge.dt + 1 AND d.crossings IS NOT NULL
@@ -182,6 +194,8 @@ export const seasonBase = (ids: string[], from: Date, to: Date) => sql`
UNION ALL
SELECT s.steam_id, CASE WHEN s.left_at < edge.t THEN 1 ELSE 0 END,
EXTRACT(EPOCH FROM (LEAST(s.left_at, edge.t) - GREATEST(s.joined_at, edge.mt))),
+ CASE WHEN s.left_at < edge.t THEN EXTRACT(EPOCH FROM (s.left_at - GREATEST(s.joined_at, edge.mt)))
+ ELSE -GREATEST(EXTRACT(EPOCH FROM (edge.mt - s.joined_at)), 0) END,
CASE WHEN s.left_at < edge.t THEN s.seed_seconds ELSE 0 END,
CASE WHEN s.left_at < edge.t THEN s.cash ELSE 0 END, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
FROM player_sessions s, edge
@@ -189,12 +203,12 @@ export const seasonBase = (ids: string[], from: Date, to: Date) => sql`
AND (s.left_at < edge.t OR s.joined_at < edge.t)
UNION ALL
SELECT s.steam_id, 1, EXTRACT(EPOCH FROM (LEAST(now(), edge.t) - GREATEST(s.joined_at, edge.f))),
- 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
FROM player_sessions s, edge
WHERE s.server_id IN ${ids} AND s.left_at IS NULL AND s.last_seen >= edge.f
AND s.joined_at < edge.t
UNION ALL
- SELECT p.steam_id, 0, 0, 0, 0, 1, p.kills, p.deaths, p.headshots, p.team_kills, p.suicides,
+ SELECT p.steam_id, 0, 0, 0, 0, 0, 1, p.kills, p.deaths, p.headshots, p.team_kills, p.suicides,
p.vehicle_kills, p.kill_streak, p.death_streak, CASE WHEN x.r = 'win' THEN 1 ELSE 0 END,
CASE WHEN x.r = 'loss' THEN 1 ELSE 0 END, CASE WHEN x.r = 'draw' THEN 1 ELSE 0 END
FROM edge, matches mt
@@ -204,7 +218,7 @@ export const seasonBase = (ids: string[], from: Date, to: Date) => sql`
AND ((mt.ended_at >= edge.f AND mt.ended_at < LEAST(edge.m1, edge.t))
OR (edge.late AND mt.ended_at >= edge.mt AND mt.ended_at < edge.t))),
player AS (
- SELECT steam_id, SUM(n) AS n, SUM(sec) AS sec, SUM(seed) AS seed, SUM(cash) AS cash,
+ SELECT steam_id, SUM(n) AS n, SUM(sec) AS sec, SUM(cs) AS cs, SUM(seed) AS seed, SUM(cash) AS cash,
SUM(m) AS m, SUM(kills) AS kills, SUM(deaths) AS deaths, SUM(headshots) AS headshots,
SUM(team_kills) AS team_kills, SUM(suicides) AS suicides, SUM(vehicle_kills) AS vehicle_kills,
MAX(kill_streak) FILTER (WHERE m > 0) AS kill_streak,
@@ -214,6 +228,7 @@ export const seasonBase = (ids: string[], from: Date, to: Date) => sql`
base AS (
SELECT steam_id,
CASE WHEN n > 0 OR sec <> 0 THEN sec / 60 ELSE 0 END AS minutes,
+ CASE WHEN n > 0 OR sec <> 0 THEN cs / 60 ELSE 0 END AS cash_minutes,
CASE WHEN n > 0 OR sec <> 0 THEN seed / 60.0 ELSE 0 END AS seed_minutes,
CASE WHEN n > 0 OR sec <> 0 THEN cash ELSE 0 END AS cash, NULL::timestamptz AS last_seen,
CASE WHEN m > 0 THEN kills ELSE 0 END AS kills, CASE WHEN m > 0 THEN deaths ELSE 0 END AS deaths,
@@ -231,8 +246,9 @@ export const seasonBase = (ids: string[], from: Date, to: Date) => sql`
* The base rows for all time, from the settled totals: `sess` is the pairs with a closed session
* plus the open sessions, `mt` the pairs with a line of an ended match, then the join of the reads
* before player_totals. The same rows and values as they gave: the seconds are exact numeric sums,
- * and a player with no session at all still gets the join's zeros. Also read by the exactness
- * test (src/test/player-totals.test.ts).
+ * and a player with no session at all still gets the join's zeros. All time has no edge, so the
+ * time behind the cash is the playtime. Also read by the exactness test
+ * (src/test/player-totals.test.ts).
*/
export const totalsBase = (ids: string[], steamIds: string[] | null = null) => sql`
sess AS (
@@ -257,8 +273,8 @@ export const totalsBase = (ids: string[], steamIds: string[] | null = null) => s
GROUP BY steam_id),
base AS (
SELECT steam_id,
- COALESCE(sess.minutes, 0) AS minutes, COALESCE(sess.seed_minutes, 0) AS seed_minutes,
- COALESCE(sess.cash, 0) AS cash, sess.last_seen,
+ COALESCE(sess.minutes, 0) AS minutes, COALESCE(sess.minutes, 0) AS cash_minutes,
+ COALESCE(sess.seed_minutes, 0) AS seed_minutes, COALESCE(sess.cash, 0) AS cash, sess.last_seen,
COALESCE(mt.kills, 0) AS kills, COALESCE(mt.deaths, 0) AS deaths,
COALESCE(mt.headshots, 0) AS headshots, COALESCE(mt.team_kills, 0) AS team_kills,
COALESCE(mt.suicides, 0) AS suicides, COALESCE(mt.vehicle_kills, 0) AS vehicle_kills,
@@ -340,13 +356,15 @@ const METRIC_SQL: Record> = {
matches: sql`matches`,
wins: sql`wins`,
winRate: sql`CASE WHEN wins + losses + draws > 0 THEN wins::float / (wins + losses + draws) ELSE NULL END`,
- cash: sql`cash`
+ cash: sql`cash`,
+ cashPerMin: sql`CASE WHEN cash_minutes - seed_minutes > 0 THEN cash::float / (cash_minutes - seed_minutes) ELSE NULL END`
};
interface BaseRow extends Record {
steamId: string;
name: string | null;
minutes: string;
+ cashMinutes: string;
seedMinutes: string;
cash: string;
lastSeen: Date | null;
@@ -409,7 +427,8 @@ async function boardSlice(
WHERE minutes >= ${q.minMinutes}
ORDER BY ${METRIC_SQL[q.sort]} ${order}, kills DESC, steam_id
LIMIT ${limit} OFFSET ${offset})
- SELECT r.steam_id AS "steamId", r.minutes, r.seed_minutes AS "seedMinutes", r.cash, r.last_seen AS "lastSeen",
+ SELECT r.steam_id AS "steamId", r.minutes, r.cash_minutes AS "cashMinutes", r.seed_minutes AS "seedMinutes",
+ r.cash, r.last_seen AS "lastSeen",
r.kills, r.headshots, r.team_kills AS "teamKills", r.deaths, r.suicides,
r.vehicle_kills AS "vehicleKills", r.kill_streak AS "killStreak", r.death_streak AS "deathStreak",
r.matches, r.wins, r.losses, r.draws, r.total,
@@ -622,6 +641,7 @@ const shapeRow = (r: BaseRow, rank: number): BoardRow => ({
steamId: r.steamId,
name: r.name || r.steamId,
minutes: Math.round(num(r.minutes)),
+ cashMinutes: Math.round(num(r.cashMinutes)),
seedMinutes: Math.round(num(r.seedMinutes)),
kills: num(r.kills),
deaths: num(r.deaths),
diff --git a/src/routes/(app)/server/[id]/leaderboard/+page.svelte b/src/routes/(app)/server/[id]/leaderboard/+page.svelte
index f3747e41..c568c226 100644
--- a/src/routes/(app)/server/[id]/leaderboard/+page.svelte
+++ b/src/routes/(app)/server/[id]/leaderboard/+page.svelte
@@ -66,9 +66,10 @@
/>
Kills and deaths are the game's own scoreboard counters, recorded per match; headshots, team
- kills and streaks come from the kill feed; playtime from player sessions, cash as last seen. A
- match counts once it has ended, with the result read against the side the player played. Kills
- per hour leaves seed time out. Names link to the dossier.
+ kills and streaks come from the kill feed; playtime and cash from player sessions, cash added up
+ match by match. A match counts once it has ended, with the result read against the side the
+ player played. Kills per hour and cash per minute leave seed time out. Names link to the
+ dossier.
{#if publicOn}This board is also public at /s/{id}/leaderboard
Kills and deaths from the game's scoreboard, match by match; headshots, team kills and streaks
- from the kill feed; playtime from time seen on the server. A match counts once it has ended.
- Names open a player's career.
+ from the kill feed; playtime from time seen on the server. K/h is kills per hour of that
+ playtime and $/min cash per minute of the sessions the cash came from, seed time left out of
+ both. A match counts once it has ended. Names open a player's career.
+ For {org.name}'s public boards, each server's and the organisation's. Rank, player and kills
+ always show. The panel's Leaderboards tab and its CSV export keep every column and the board's
+ JSON keeps every number, so leaving a column out tidies the page without hiding anything.
+
- {#each [['Sessions', fmtNum(d.summary.sessions), 'A session is one stay on a server, from joining to leaving.'], ['Playtime', d.summary.sessions ? minutes(d.summary.minutes) : '—', ''], ['Seed progress', d.summary.seedReward ? `${fmtNum(d.summary.seedReward.minutes)} / ${fmtNum(d.summary.seedReward.requiredMinutes)} min` : '—', 'Unspent seeding time toward this server’s next reward. It resets when the reward is granted.'], ['Cash earned', fmtCash(d.summary.cash), 'Total cash earned across the servers visible to you.'], ['Kills', fmtNum(d.summary.kills), SCOREBOARD_NOTE], ['Deaths', fmtNum(d.summary.deaths), SCOREBOARD_NOTE], ['Longest alive', fmtDuration(d.summary.longestAliveSeconds), 'Longest recorded interval between joining, a death, a match start, and departure. Available for sessions tracked by the event feed.'], ['K/D', kd(d.summary.kills, d.summary.deaths), SCOREBOARD_NOTE], ['First seen', d.summary.firstSeen ? fmtTime(d.summary.firstSeen) : '—', '']] as [label, value, note] (label)}
+
+ {#each [['Sessions', fmtNum(d.summary.sessions), 'A session is one stay on a server, from joining to leaving.'], ['Playtime', d.summary.sessions ? minutes(d.summary.minutes) : '—', ''], ['Seeded', d.summary.seedMinutes ? minutes(d.summary.seedMinutes) : '—', 'Time on with the server low, as a Seeding reward counts it.'], ['Seed progress', d.summary.seedReward ? `${fmtNum(d.summary.seedReward.minutes)} / ${fmtNum(d.summary.seedReward.requiredMinutes)} min` : '—', 'Unspent seeding time toward this server’s next reward. It resets when the reward is granted.'], ['Cash earned', fmtCash(d.summary.cash), 'Total cash earned across the servers visible to you.'], ['Kills', fmtNum(d.summary.kills), SCOREBOARD_NOTE], ['Deaths', fmtNum(d.summary.deaths), SCOREBOARD_NOTE], ['Longest alive', fmtDuration(d.summary.longestAliveSeconds), 'Longest recorded interval between joining, a death, a match start, and departure. Available for sessions tracked by the event feed.'], ['K/D', kd(d.summary.kills, d.summary.deaths), SCOREBOARD_NOTE], ['First seen', d.summary.firstSeen ? fmtTime(d.summary.firstSeen) : '—', '']] as [label, value, note] (label)}
{label}
{value}
@@ -267,19 +360,31 @@
- {#if d.bannedOn.length}
+ {#if d.bans.length}
- Banned on {d.bannedOn.length} of {d.orgServerCount} server{d.orgServerCount === 1
- ? ''
- : 's'} in this organisation.
- {#each d.bannedOn as b (b.serverId)}
-
+ {#if orgBan}Banned on every server in this organisation.{:else}Banned on {bannedServers.length}
+ of {d.orgServerCount} server{d.orgServerCount === 1 ? '' : 's'} in this organisation.{/if}
+
+
+ {#each d.bans as b (b.source + (b.serverId ?? ''))}
+
+ {SOURCE_LABEL[b.source]}
+ {b.serverId ? b.serverName : `Every server in ${data.server.orgName}`}{#if b.reason}: {b.reason}{/if}
+ {banMore(b)}
+ {#if b.canUnban}{/if}
+
-
-
+ {Math.floor(r.seconds / 60)} of {r.minutes} min in the last {r.windowDays} day{r.windowDays ===
+ 1
+ ? ''
+ : 's'}
-
+ {pct}%
+
+
{seedLine(r)}
+
+ {#if r.untilFull}Seed time on {data.server.name} with {r.lowAt} or fewer on, banked once the
+ server fills with the player still on, so a seed in progress shows once it is banked.{:else}Seed
+ time on {data.server.name}: every minute with {r.lowAt} or fewer on.{/if}
+
- {#if d.orgLists.ban}
- {@const b = d.orgLists.ban}
- banned org-wide
- {b.reason || 'no reason'} · by {b.addedByName || '—'}{#if b.expiresAt}
- · until {fmtTime(b.expiresAt)}{/if}
-
- {#each b.servers as s (s.serverId)}
- {s.serverName}
- {/each}
-
-
- {:else}
- Not on the organisation's ban list.
-
- {/if}
-
- {/if}
- {#if d.orgLists.canReserve}
-
- {#if d.orgLists.reserve}
- {@const r = d.orgLists.reserve}
- reserved slot
- {r.reason || 'org-wide'}{#if r.member}
- · member{/if}{#if r.expiresAt}
- · until {fmtTime(r.expiresAt)}{/if}
-
- {#each r.servers as s (s.serverId)}
- {s.serverName}
- {/each}
-
- {#if !r.member}
-
- {/if}
- {:else}
- No reserved slot from the organisation.
-
+ {#if d.orgLists.reserve}
+ {@const r = d.orgLists.reserve}
+ reserved slot
+ {r.reason || 'org-wide'}{#if r.member}
+ · member{/if}{#if r.expiresAt}
+ · until {fmtTime(r.expiresAt)}{/if}
+
+ {#each r.servers as s (s.serverId)}
+ {s.serverName}
- {/if}
-
+ {/each}
+
+ {#if !r.member}
+
+ {/if}
+ {:else}
+ No reserved slot from the organisation.
+
{/if}
@@ -771,9 +906,16 @@
orgName={data.server.orgName}
steamId={d.steamId}
name={d.name}
- canOrg
- reasons={d.orgLists.banReasons ?? []}
+ server={bans ? { id, name: data.server.name } : null}
+ canOrg={d.orgLists.canBan}
+ scope="server"
+ reason={reason.trim()}
+ banMessage={d.banDialog?.message ?? null}
+ reasons={d.banDialog?.reasons ?? []}
onclose={() => (banning = false)}
- ondone={() => invalidateAll()}
+ ondone={() => {
+ reason = '';
+ return invalidateAll();
+ }}
/>
{/if}
diff --git a/src/test/ban-reasons.test.ts b/src/test/ban-reasons.test.ts
index 4227b400..96cae2fa 100644
--- a/src/test/ban-reasons.test.ts
+++ b/src/test/ban-reasons.test.ts
@@ -138,9 +138,10 @@ describe.skipIf(!hasTestDb)("an org's quick reasons", () => {
};
const reasonsIn = (answer: { body: unknown }) =>
(answer.body as { banReasons?: BanReason[] | null }).banReasons;
- const dossierReasons = (answer: { body: unknown }) =>
- (answer.body as { dossier: { orgLists: { banReasons: BanReason[] | null } } }).dossier.orgLists
- .banReasons;
+ const dossierDialog = (answer: { body: unknown }) =>
+ (answer.body as { dossier: { banDialog: { reasons: BanReason[]; message: string } | null } })
+ .dossier.banDialog;
+ const dossierReasons = (answer: { body: unknown }) => dossierDialog(answer)?.reasons ?? null;
beforeAll(async () => {
env = await testEnv();
@@ -288,7 +289,7 @@ describe.skipIf(!hasTestDb)("an org's quick reasons", () => {
] as PrincipalName[])
expect({ who, refused: (await orgLists(who)).status >= 400 }).toEqual({ who, refused: true });
- // a dossier, whose Ban org-wide places the ban on the org's list
+ // a dossier, whose ban dialog bans on this server's own list (Bans) or the org's, as the tab
for (const who of ['owner', 'site', 'admin', 'orgBans', 'keyAll', 'keyBans'] as PrincipalName[])
expect({ who, reasons: dossierReasons(await dossier(who)) }).toEqual({ who, reasons: LIST });
for (const who of ['viewer', 'operator', 'orgSlots', 'keyView'] as PrincipalName[])
@@ -310,7 +311,8 @@ describe.skipIf(!hasTestDb)("an org's quick reasons", () => {
expect(reasonsIn(here)).toEqual(LIST);
expect(here.body).toMatchObject({ canEditOrgBans: false, banMessage: '{reason}' });
expect((await orgLists(bansOnly)).status).toBe(404);
- expect(dossierReasons(await dossier(bansOnly))).toBeNull();
+ // and so does the dossier's ban dialog, which bans onto the same list
+ expect(dossierDialog(await dossier(bansOnly))).toEqual({ reasons: LIST, message: '{reason}' });
expect((await patch(bansOnly, [])).status).toBe(403);
expect(await stored()).toEqual(LIST);
});
diff --git a/src/test/view.test.ts b/src/test/view.test.ts
index b9979aab..6f2fa7d4 100644
--- a/src/test/view.test.ts
+++ b/src/test/view.test.ts
@@ -148,13 +148,8 @@ describe.skipIf(!hasTestDb)('what View shows', () => {
const viewer = await dossier('viewer');
expect(viewer.notes).toEqual([]);
expect(viewer.watch).toMatchObject({ watched: true, reason: '', updatedByName: '' });
- expect(viewer.orgLists).toEqual({
- ban: null,
- reserve: null,
- canBan: false,
- canReserve: false,
- banReasons: null
- });
+ expect(viewer.orgLists).toEqual({ reserve: null, canBan: false, canReserve: false });
+ expect(viewer.banDialog).toBeNull();
expect(JSON.stringify(viewer)).not.toContain('suspected alt');
// Who is banned and why is View, as a server's Bans tab says it; who placed it is not.
expect(viewer.bans).toEqual([
@@ -171,23 +166,29 @@ describe.skipIf(!hasTestDb)('what View shows', () => {
'suspected alt of a banned player'
]);
expect(operator.watch.reason).toBe('watch for team kills');
- expect(operator.orgLists.ban).toBeNull();
+ expect(operator.bans.map((b: { by: string }) => b.by)).toEqual(['']);
const admin = await dossier('admin');
- expect(admin.orgLists.ban).toMatchObject({ reason: 'org-wide ban reason' });
+ expect(admin.bans).toEqual([
+ expect.objectContaining({ source: 'org', reason: 'org-wide ban reason', by: 'admin' })
+ ]);
expect(admin.orgLists.reserve).toMatchObject({ reason: 'sponsor, paid until March' });
expect(admin.orgLists).toMatchObject({ canBan: true, canReserve: true });
// one list's editor reads that list's entry and nothing of the other's
const bans = await dossier('orgBans');
expect(bans.orgLists).toMatchObject({ reserve: null, canBan: true, canReserve: false });
- expect(bans.orgLists.ban).toMatchObject({
- reason: 'org-wide ban reason',
- addedByName: 'admin'
- });
+ expect(bans.bans).toEqual([
+ expect.objectContaining({
+ source: 'org',
+ reason: 'org-wide ban reason',
+ by: 'admin',
+ canUnban: true
+ })
+ ]);
expect(JSON.stringify(bans)).not.toContain('sponsor, paid until March');
const slots = await dossier('orgSlots');
- expect(slots.orgLists).toMatchObject({ ban: null, canBan: false, canReserve: true });
+ expect(slots.orgLists).toMatchObject({ canBan: false, canReserve: true });
expect(slots.orgLists.reserve).toMatchObject({ reason: 'sponsor, paid until March' });
// the ban's reason as the Bans tab gives every reader; its author stays with the ban list's
expect(slots.bans).toEqual([
From 76d1b8351c80b5d5f7bdbfdbd7bf40a36c1631bc Mon Sep 17 00:00:00 2001
From: James Causon
Date: Wed, 7 Oct 2026 19:01:52 +0100
Subject: [PATCH 050/102] README: player page bans, history, seed time; risk
kick line
---
README.md | 61 +++++++++++++++++++++++++++++++++++++++----------------
1 file changed, 44 insertions(+), 17 deletions(-)
diff --git a/README.md b/README.md
index 8415f8e8..2b29a967 100644
--- a/README.md
+++ b/README.md
@@ -309,14 +309,14 @@ site owner hold every capability on every server in scope.
| Match control | end/restart match, change map, next map, weather | | ✓ | ✓ |
| Live rotation | add, remove and reorder rotation entries on the running server | | ✓ | ✓ |
| Notes & watchlist | read and add player notes (delete your own), watch and unwatch, the reason a player is watched | | ✓ | ✓ |
-| Bans | ban and unban on the server; a Kill distance watch that bans here | | | ✓ |
+| Bans | ban and unban on the server, from a dossier too, and see who placed its bans; a Kill distance watch that bans here | | | ✓ |
| Reserved slots | reserve and unreserve on this server, with a note and an expiry, and read the notes; a Seeding reward rule that hands out slots here | | | ✓ |
-| Org ban list | the organisation's ban list, enforced on every server; sync; a player's entry on it in the dossier; a Kill distance watch that bans everywhere | | | ✓ |
+| Org ban list | the organisation's ban list, enforced on every server; sync; who placed a ban, and Unban org-wide, in a dossier; a Kill distance watch that bans everywhere | | | ✓ |
| Org reserved slots | the organisation's reserved-slot list, handed out on every server; sync; a player's entry on it in the dossier; a Seeding reward rule that hands out slots everywhere | | | ✓ |
| Others' notes | delete anyone's note | | | ✓ |
| Save rotation | save the rotation, rotation mode on and off | | | ✓ |
| Config & settings | read, validate and apply the config document; score tick, sponsor image, connection test, the game's raw status | | | ✓ |
-| Automation | see the triggers and what they did; create, edit, dry-run and delete them | | | ✓ |
+| Automation | see the triggers and what they did; create, edit, dry-run and delete them; a player's Seeding reward progress | | | ✓ |
| Audit trail | everyone's actions on the server in the audit log, not just your own; the game server's own RCON log | | | ✓ |
| Raw RCON | any /v1 route on the game server directly, except the config document | | | ✓ |
@@ -403,13 +403,35 @@ through `/api/passkeys/*`, and codes, recovery keys and Steam through the panel'
### Player dossiers, risk and the watchlist
-Every player name in the panel links to a dossier: sessions, playtime, kills and deaths on each
-of the organisation's servers, the names they have used, the admin actions taken on them (kicks,
-bans, whispers, trigger actions), notes admins have left, and a watchlist flag with a reason.
-Notes and the watchlist are shared by every server in the organisation; roles with _Notes &
-watchlist_ can write them, and a note can be deleted by its author or a role with _Others' notes_.
-A [Discord webhook](#discord-webhooks) ticked for _Watched players joining_ posts each time a
-watched player joins one of the organisation's servers.
+Every player name in the panel links to a dossier: sessions, playtime, seed time, kills and deaths
+on each of the organisation's servers, the names they have used, every ban that holds them, the
+admin actions taken on them (kicks, bans, whispers, trigger actions), notes admins have left, and a
+watchlist flag with a reason. Notes and the watchlist are shared by every server in the
+organisation; roles with _Notes & watchlist_ can write them, and a note can be deleted by its author
+or a role with _Others' notes_. A [Discord webhook](#discord-webhooks) ticked for _Watched players
+joining_ posts each time a watched player joins one of the organisation's servers.
+
+The **bans** box lists every ban that holds the player on the servers you can open: the
+organisation's list (once: it holds them on every server), each server's own list, and a ban the
+game holds on its own list, with the reason and when it lifts. Who placed a panel ban is shown, as
+on the Bans tab, to people who hold _Bans_ on that server or may edit the org's ban list (a ban on
+the game's own list shows the name the game keeps, as the Bans tab does), and each ban has
+an **Unban** for people who may lift it: _Bans_ there for a server's own list or the game's, _Org ban
+list_ for the organisation's. **Ban…** opens the ban dialog whether or not the player is on: this
+server only with _Bans_, every server with _Org ban list_, with a length and the organisation's
+quick reasons. Whisper and Kick need the player on the server.
+
+**Admin actions on this player** names each action and says what it was about: the reason a kick
+or ban was given, what a whisper said, how long a ban or reserved slot lasts. A rule's row keeps
+its own short line ("Kicked …: 3 team kills this match"), never the text a rule sent. The table
+holds the newest 50 rows you may read in the audit trail; **All in the audit trail** opens them
+all, with the trail's filters by action and by person.
+
+With a [Seeding reward](#automation-triggers) switched on for the server, people with _Automation_
+there see where the player stands with it: the seed time banked on that server over the rule's
+window against the minutes it asks for and what is left, or the reserved slot they hold, which the
+rule passes over them for. With seed time banked only once the server fills, a seed in progress
+shows once it is banked.
On servers with the [kill feed](#kill-feed), the dossier also lists the names the feed showed for
the player that were not the one the server listed them under, newest first: _In the kill feed as_.
@@ -428,13 +450,18 @@ already hold it (a streamer's hidden name, say), and its filter finds players by
With `STEAM_API_KEY` set, the dossier also shows the Steam persona, account age (public profiles
only), VAC and game bans, refreshed daily and on demand, and what its friends list shows, looked
at weekly. The **advisory risk score** is worked out when someone looks, for that reader: a ban on
-another server, or games recorded there, count only if the reader can open that server. The score
+another server, or games recorded there, count only if the reader can open that server. A ban
+counts whether the panel placed it (a server's own list, or the organisation's, which holds the
+player everywhere and so counts only on the dossier) or the game holds it on its own list. The score
is bounded to 0–100. Recent bans weigh more than old ones; multiple banned friends, a private
profile or friends list, local bans, name resemblance, and the watchlist add evidence. Extreme
win rate, K/D, and headshot percentage across recorded games add smaller weights only after
minimum match/kill counts. Headshot percentage uses kill-feed games only; the other totals use the
panel's match and session history. A _Kick on connect risk_ rule that kicks at a risk score judges
-everyone on the server against the whole organisation.
+everyone on the server against the whole organisation. Its line in the audit trail and on Discord
+says _banned on another server of this organisation_ or _Name resembles a banned player_, never
+which server, why, or whom: the server's staff need not be able to open that server, and its
+dossier shows the details to people who can.
At most 200 Steam friends are checked per account, and a partial count is labelled as such;
the friends lookups keep to a fifth of the 100,000 calls a day Steam allows a key.
Steam provides no documented profile-comments read endpoint to this panel, so comments are not
@@ -501,9 +528,9 @@ Bans and reserved slots that your servers already hold show up on the list pages
**import**: an owner reviews them, and importing puts them on the org list, marks them as managed
on the servers that have them, and applies them to the rest. On a server's Bans tab a local ban can be
promoted the same way (owners), or added to the org list while this server's own copy stays local
-(ban list editors). Every dossier shows the player's entry on each org list the reader may edit,
-and lets them ban or unban org-wide, or hand out and withdraw a reserved slot, without leaving the
-page.
+(ban list editors). A dossier shows the player's slot on the org's reserved-slot list to that
+list's editors, who can hand one out or withdraw it there; an org ban shows in its bans box, where
+the ban list's editors can lift it, and its Ban… places one.
A ban or reserved slot with an **expiry** is lifted by the panel when the time comes: the entry
moves to the list's history as expired; an expired ban stops being enforced at once, and an
@@ -571,7 +598,7 @@ delete them.
| Kill distance watch | Acts on a player whose kills with the chosen weapons come from at least N metres away, M of them in one match: a defibrillator or a fist reaches a few metres, so the same kill from across the map is a player the game did not stop. The weapons are offered in three lists, as the kill feed names them: hand-held (a new rule starts with the defibrillator; charges that are placed and set off from anywhere are not offered), the vehicles' guns (a Humvee's M249 or minigun turret), and the vehicles themselves (a roadkill, or a vehicle blown up with its crew inside). From 0 m every kill with them counts, one the feed sends without a distance too, so a server can act on each kill with a weapon or a vehicle it does not allow: an infantry-only server can act on whoever kills from a Humvee's turret. It flags them (audit trail and the Discord mirror, whose post opens the player's page), warns them (the rule's text whispered to them, mirrored the same way; a blank text says the weapon is not allowed on this server), kills them and whispers them the rule's text (so a server can answer each Humvee kill with the killer's death: the feed only reports kills, so a player who drives one without killing anybody is not seen), kicks them, or bans them for N days or for good on this server's own ban list or the organisation's, like a ban by hand: every server the ban reaches takes its lists again at once and removes them at its next look, and the ban is lifted from the list. A shorter ban already on that list is made to last as long. A killed, kicked or banned player is left alone for a minute while that lands, so a roadkill of three is one kill; one who does it again after that, that match, is acted on at their next such kill. A flag or a warning comes again in the same match only after the rule's cooldown, and at their first such kill in the next. A warning, a kill and a kick need the player still on when they are sent, and a kill goes within half a minute or not at all (one sent later would land on them doing something else, or on the next map). A kill the game refuses (no living character, presumably) still sends the whisper; with that refused too, the action fails. Two rules that kill on the same weapon each kill. To warn first and kick after, make two rules on the same weapons, the kick's count higher. Needs the [kill feed](#kill-feed), whose distance is between killer and victim; acted on as each kill arrives, and counted per match as the Team kill limit counts. A kill that comes within a minute of the killer's own death is not counted: the player is dead, so it is a round already on its way, and the game credits a vehicle's shell that lands after the vehicle was destroyed to the gunner's own weapon, from wherever the gunner is by then (an M4 kill from 2,300 m). The audit trail notes each kill left out this way, with how long after the death it came; Discord is not told. Editing, switching off or deleting the rule starts the counts over and drops what it had queued. The counts are kept in the worker's memory and start over when it restarts. The rule believes the feed: whoever holds a server's feed token can make it act, so keep bans on every server for rules on servers whose host you trust. |
| Team balance | Keeps the open sides within a set number of players of each other (3 by default), closes one faction, or both. Nobody who is playing is moved mid-match: balancing places a player when they arrive (on the side they picked while that keeps within the gap, else on the lighter one), puts back a player who switches onto a side more than the gap bigger than the one they left, and evens everyone up when a new match starts (a map change or the scores starting over). Whoever is on when the rule is switched on or changed, after the worker restarts, when the server comes back from being out of reach, or after two minutes (three looks, at a slower player list cadence) without a look at the player list, counts as placed until the next match, so it never reshuffles the match it walks in on; starting over also forgets who was told and the asks. The side a player was placed on is kept for ten minutes away, so leaving and rejoining on the bigger side is put back too. Optional: keep clan tags together (a short tag in brackets at the front of the name; a preference that yields to the gap; when a new match is evened up, players whose clan is mostly on the other side move first, then those with the fewest clanmates beside them), and SteamIDs it never moves (staff who switch sides themselves). A move made from the Players tab or the API is kept, not put back; one made anywhere else (the game's own console, another balancer) is put back like a switch, so run no other balancer beside it. Right after a match end, placing waits up to 30 seconds from the players' return (the list is empty while the next map loads) while a quarter or more of them have not picked a side, and whom it moves is not decided by list order. Everyone on the closed faction is moved to the smaller other side (or their clan's, within the gap). Every move is the game's own move, sent without the kill the Players tab's Move adds. Moves go out a few at a time as the player list refreshes, so a full server's sort takes about half a minute; moves in flight count toward their side, and one not seen landed within 30 seconds is decided again; a queued move goes out only while the rule still holds that decision, so a retry, a new match or a person moving the player drops it. A player who switches onto the bigger side is put back every time; a player asked to move three times in ten minutes for any other reason, or put back three times without it taking, is left where they are until the ten minutes pass. Watch only lists each move it would make under Actions, as skipped, and moves nobody. Optional names for the sides (e.g. Red and Green) and a whisper, `{team}`, sent once to each player it moves (again after two hours away). One rule per server; changing or switching it off drops its queued moves. Its moves reach Discord only when they fail. What it remembers is the worker's: a restart forgets the moves in flight, who was told, the asks and the sides. A rule saved before balancing existed only closes its faction until balancing is ticked. Nothing to dry-run: faction moves are not in the session history; use Watch only. |
| Match broadcast | Announces the result when a match ends and the map as the next one starts, either message optional, with at least N players on (the game shows nobody on while the next map loads, so the announcement waits up to three minutes for the players to be back). A match ends when the map changes or the faction scores fall back to zero (a faction reached the cap, or an admin ended the round; live builds send no score cap or match clock, so Warcon assumes the game's default of 100), so `{faction}` is whoever led at that moment, tied factions named together. Besides the server's placeholders, `{score}` and `{scores}` of the match that ended, `{previous}` (its map), and from the players' lines of it `{mvp}` (the most kills, tied players named together) and `{top}` (the top three with their kills). Sent one poll after the round ends. |
-| Seeding reward | Time a player spends on with at most N players counts as seed time, by default banked only once the server has filled (a count the rule sets, else the limit the server reports) with the player still on, so staying until the threshold and leaving, or a few minutes on an empty server, earns nothing (a switch on the rule counts every low minute instead); M minutes of it over the sessions that ended in the last D days earns a reserved slot for E days, with an optional whisper: on this server only (its own reserved-slot list, which needs the Reserved slots capability) or on every server in the organisation (the org list, which needs Org reserved slots), chosen on the rule. The seeded server applies it at once and, for an org-wide slot, the other servers at their next sync; it lapses on its own and can be earned again; players who already hold a slot here are skipped. Seed time is kept on each session, so the dossier history, the leaderboard's Seed time column and the dry run show it. |
+| Seeding reward | Time a player spends on with at most N players counts as seed time, by default banked only once the server has filled (a count the rule sets, else the limit the server reports) with the player still on, so staying until the threshold and leaving, or a few minutes on an empty server, earns nothing (a switch on the rule counts every low minute instead); M minutes of it over the sessions that ended in the last D days earns a reserved slot for E days, with an optional whisper: on this server only (its own reserved-slot list, which needs the Reserved slots capability) or on every server in the organisation (the org list, which needs Org reserved slots), chosen on the rule. The seeded server applies it at once and, for an org-wide slot, the other servers at their next sync; it lapses on its own and can be earned again; players who already hold a slot here are skipped. Seed time is kept on each session, so the dossier (a total, per server and per session, and the player's progress toward the reward for _Automation_ holders), the leaderboard's Seed time column and the dry run show it. |
| AFK protection | While the server seeds (someone on, fewer than the rule's count, 20 by default, and no side has scored), kills everyone on every N minutes (3 by default, 2 to 10), so the game's idle kick, which also runs while a server waits for its players, does not remove the seeders. The panel cannot tell who is idle, so players are killed too. Set the count no higher than the players the server's match starts at. A side scoring or the count being reached turns it off until the server has sat empty for 10 minutes or the game restarts, so a dip below the count mid-match, or the empty list of a map change, never starts it again; switched off and on, it starts from what the server shows then, so switching it on during a match nobody has scored in yet takes that match for seeding until the first score. Switching it off drops a round not yet sent. Its row says whether it is active or paused, and why. Optional broadcasts after each round and once when the match it kept the seeders for goes live (`{goal}` is the count it turns off at). A round is one action in the trail, kept off Discord unless it fails; it goes only within 30 seconds of being decided, while the last look still showed the server seeding, and players who left since are passed over. One rule per server. |
**Dry run** replays the last 24 hours of the server's own history (joins, player counts, empty
@@ -1229,7 +1256,7 @@ These need _View_ on the server unless the table says otherwise.
| Route | Answers |
| -------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GET /api/servers/:id/players/seen` | everyone who has played on the server: `q` (name, alias, a name the kill feed showed for them, or SteamID; each player's `feedNames` are those names), `since` (days), `flag` (`banned`, `watched` or `online`), `sort` (`lastSeen`, `firstSeen`, `minutes`, `sessions`, `kills`, `deaths`, `name`), `dir`, `offset`, `limit` (up to 100) |
-| `GET /api/servers/:id/players/:steamId` | the dossier: names, the names the kill feed showed that were not theirs (`feedNames`), sessions, totals per server, bans, risk, the kill feed's summary; notes and why a player is watched only with _Notes & watchlist_ |
+| `GET /api/servers/:id/players/:steamId` | the dossier: names, the names the kill feed showed that were not theirs (`feedNames`), sessions, totals per server with seed time, every ban in force (`bans`), risk, the kill feed's summary, the admin actions you may read with a kick's reason and a whisper's text; notes and why a player is watched only with _Notes & watchlist_; the Seeding reward's progress (`seedReward`) only with _Automation_ |
| `GET /api/servers/:id/players/:steamId/career` | rank, streak, results by map and faction, the last ten matches, the organisation's last ten seasons with the places won |
| `GET /api/servers/:id/players/marks?ids=a,b` | watched, first visit, risk score and Steam name (`steamName`) for up to 200 SteamIDs |
| `GET /api/servers/:id/kills` | the stored kill feed, newest first (see [Kill feed](#kill-feed)): `killer`, `victim`, `player` (a SteamID, or part of a name), `kind` (`headshot`, `teamKill`, `suicide`, `vehicle`, `environment`), `cause`, `minM` (metres), `match`, `limit` (up to 200); `count=1` adds the total. For the next page, send the last kill's `ts` as `before` and its `eventTime` as `beforeTime` |
From 4071db45b20acc608ea5c2371f4fb934c5e0fc88 Mon Sep 17 00:00:00 2001
From: James Causon
Date: Wed, 7 Oct 2026 22:41:10 +0100
Subject: [PATCH 051/102] Team plan: deal and even up the sides, clan tags
shared
---
src/lib/clan.test.ts | 11 ++
src/lib/clan.ts | 13 +++
src/lib/server/two-teams.test.ts | 10 --
src/lib/server/two-teams.ts | 13 +--
src/lib/team-plan.test.ts | 155 ++++++++++++++++++++++++++++
src/lib/team-plan.ts | 170 +++++++++++++++++++++++++++++++
6 files changed, 350 insertions(+), 22 deletions(-)
create mode 100644 src/lib/clan.test.ts
create mode 100644 src/lib/clan.ts
create mode 100644 src/lib/team-plan.test.ts
create mode 100644 src/lib/team-plan.ts
diff --git a/src/lib/clan.test.ts b/src/lib/clan.test.ts
new file mode 100644
index 00000000..edb7ec0f
--- /dev/null
+++ b/src/lib/clan.test.ts
@@ -0,0 +1,11 @@
+import { expect, test } from 'bun:test';
+import { clanTag } from './clan';
+
+test('clanTag reads a short bracketed tag at the front, in one case', () => {
+ expect(clanTag('[ABC] Name')).toBe('abc');
+ expect(clanTag(' {Wolf}Name')).toBe('wolf');
+ expect(clanTag('(TAG) x')).toBe('tag');
+ expect(clanTag('Name [ABC]')).toBeNull();
+ expect(clanTag('[] Name')).toBeNull();
+ expect(clanTag('[a tag far too long to be one] Name')).toBeNull();
+});
diff --git a/src/lib/clan.ts b/src/lib/clan.ts
new file mode 100644
index 00000000..5f093b55
--- /dev/null
+++ b/src/lib/clan.ts
@@ -0,0 +1,13 @@
+// Clan tags, as the Team balance rule and the Players tab's Teams view both read them.
+
+/**
+ * A player's clan tag: a short tag in brackets at the front of the name ("[ABC] Name", "{ABC}Name"),
+ * in one case, or null.
+ */
+export function clanTag(name: string): string | null {
+ const m = /^\s*(?:\[([^\]]{1,12})\]|\{([^}]{1,12})\}|\(([^)]{1,12})\)|<([^>]{1,12})>)/u.exec(
+ name
+ );
+ const tag = (m?.[1] ?? m?.[2] ?? m?.[3] ?? m?.[4] ?? '').normalize('NFKC').trim().toLowerCase();
+ return tag || null;
+}
diff --git a/src/lib/server/two-teams.test.ts b/src/lib/server/two-teams.test.ts
index 454f6fb5..f3532903 100644
--- a/src/lib/server/two-teams.test.ts
+++ b/src/lib/server/two-teams.test.ts
@@ -1,6 +1,5 @@
import { describe, expect, test } from 'bun:test';
import {
- clanTag,
emptyTwoTeamsState,
TWO_TEAMS_ASK_WINDOW_MS,
TWO_TEAMS_FORGET_MS,
@@ -510,15 +509,6 @@ describe('twoTeamsStep balancing', () => {
expect(new Set(r.moves.map((m) => m.to)).size).toBe(1);
});
- test('clanTag reads a short bracketed tag at the front, in one case', () => {
- expect(clanTag('[ABC] Name')).toBe('abc');
- expect(clanTag(' {Wolf}Name')).toBe('wolf');
- expect(clanTag('(TAG) x')).toBe('tag');
- expect(clanTag('Name [ABC]')).toBeNull();
- expect(clanTag('[] Name')).toBeNull();
- expect(clanTag('[a tag far too long to be one] Name')).toBeNull();
- });
-
test('a move a person made from the panel is kept, not put back', () => {
const players = [...side('v', 7, V), ...side('m', 5, M)];
const state = seeded(players);
diff --git a/src/lib/server/two-teams.ts b/src/lib/server/two-teams.ts
index 2f7a1282..e8a9f489 100644
--- a/src/lib/server/two-teams.ts
+++ b/src/lib/server/two-teams.ts
@@ -11,6 +11,7 @@
import { settingsFingerprint } from './fingerprint';
import { ApiError, str } from './http';
import { MAX_CHAT } from '$lib/chat';
+import { clanTag } from '$lib/clan';
/** How long a move is waited on before it is asked for again (the player is still on the closed faction). */
export const TWO_TEAMS_RETRY_MS = 30_000;
@@ -121,18 +122,6 @@ export function validateTwoTeams(c: Record): TwoTeamsConfig {
*/
export const twoTeamsSettingsKey = (cfg: TwoTeamsConfig): string => settingsFingerprint(cfg);
-/**
- * A player's clan tag: a short tag in brackets at the front of the name ("[ABC] Name", "{ABC}Name"),
- * in one case, or null.
- */
-export function clanTag(name: string): string | null {
- const m = /^\s*(?:\[([^\]]{1,12})\]|\{([^}]{1,12})\}|\(([^)]{1,12})\)|<([^>]{1,12})>)/u.exec(
- name
- );
- const tag = (m?.[1] ?? m?.[2] ?? m?.[3] ?? m?.[4] ?? '').normalize('NFKC').trim().toLowerCase();
- return tag || null;
-}
-
/** What the rule remembers between player lists (the worker's memory, per rule). */
export interface TwoTeamsState {
/**
diff --git a/src/lib/team-plan.test.ts b/src/lib/team-plan.test.ts
new file mode 100644
index 00000000..37400d1a
--- /dev/null
+++ b/src/lib/team-plan.test.ts
@@ -0,0 +1,155 @@
+import { describe, expect, test } from 'bun:test';
+import { deal, evenUp, recordRating, type PlanPlayer } from './team-plan';
+
+/** mulberry32: the same deals for the same seed */
+function rng(seed: number) {
+ let a = seed >>> 0;
+ return () => {
+ a = (a + 0x6d2b79f5) >>> 0;
+ let t = a;
+ t = Math.imul(t ^ (t >>> 15), t | 1);
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
+ };
+}
+
+const V = 'Valkyra';
+const M = 'Manticore';
+const L = 'Lonestar';
+const on = (id: string, side: string | null, name = `Player ${id}`): PlanPlayer => ({
+ steamId: id,
+ name,
+ side
+});
+const many = (prefix: string, n: number, side: string | null, tag = '') =>
+ Array.from({ length: n }, (_, i) => on(`${prefix}${i}`, side, `${tag}Player ${prefix}${i}`));
+const sizes = (out: Map, sides: string[]) =>
+ sides.map((s) => [...out.values()].filter((v) => v === s).length);
+const spread = (xs: number[]) => Math.max(...xs) - Math.min(...xs);
+const moved = (players: PlanPlayer[], out: Map) =>
+ players.filter((p) => out.get(p.steamId) !== p.side);
+
+describe('deal', () => {
+ test('puts everyone on one of the sides, none more than one ahead, whatever the numbers', () => {
+ const random = rng(1);
+ for (const sides of [
+ [V, M],
+ [V, M, L]
+ ])
+ for (let n = 0; n <= 41; n++) {
+ const players = [...many('v', Math.ceil(n / 2), V), ...many('l', Math.floor(n / 2), L)];
+ for (const clans of [false, true]) {
+ const out = deal(players, sides, { clans, random });
+ expect(out.size).toBe(n);
+ expect([...out.values()].every((s) => sides.includes(s))).toBe(true);
+ if (n) expect(spread(sizes(out, sides))).toBeLessThanOrEqual(1);
+ }
+ }
+ });
+
+ test('takes everyone off a side left out of the shuffle, and those on no side', () => {
+ const players = [
+ ...many('v', 9, V),
+ ...many('m', 6, M),
+ ...many('l', 4, L),
+ ...many('x', 3, null)
+ ];
+ const out = deal(players, [V, M], { clans: false, random: rng(2) });
+ expect(sizes(out, [V, M]).sort()).toEqual([11, 11]);
+ expect([...out.values()].includes(L)).toBe(false);
+ });
+
+ test('keeps a clan on one side while it has room, and only the overflow goes elsewhere', () => {
+ const random = rng(3);
+ const players = [...many('v', 10, V), ...many('m', 6, M), ...many('c', 4, M, '[ABC] ')];
+ for (let i = 0; i < 20; i++) {
+ const out = deal(players, [V, M], { clans: true, random });
+ expect(
+ new Set(players.filter((p) => p.steamId[0] === 'c').map((p) => out.get(p.steamId))).size
+ ).toBe(1);
+ }
+ // twelve of a clan on a twenty-player server: ten together, the other two across
+ const big = [...many('c', 12, V, '[ABC] '), ...many('m', 8, M)];
+ const out = deal(big, [V, M], { clans: true, random });
+ expect(
+ sizes(
+ new Map(
+ big.filter((p) => p.steamId[0] === 'c').map((p) => [p.steamId, out.get(p.steamId)!])
+ ),
+ [V, M]
+ ).sort((a, b) => a - b)
+ ).toEqual([2, 10]);
+ // and with clans not kept, tags mean nothing
+ const loose = deal(players, [V, M], { clans: false, random: rng(4) });
+ expect(spread(sizes(loose, [V, M]))).toBe(0);
+ });
+
+ test('by a rating, the strong are shared out and the sides add up about the same', () => {
+ const players = Array.from({ length: 20 }, (_, i) => on(`p${i}`, i < 14 ? V : M));
+ const rating = (p: PlanPlayer) => Number(p.steamId.slice(1)) + 1;
+ for (let seed = 0; seed < 20; seed++) {
+ const out = deal(players, [V, M], { clans: false, rating, random: rng(seed) });
+ const sum = (s: string) =>
+ players.filter((p) => out.get(p.steamId) === s).reduce((t, p) => t + rating(p), 0);
+ expect(Math.abs(sum(V) - sum(M))).toBeLessThanOrEqual(2);
+ expect(out.get('p19')).not.toBe(out.get('p18'));
+ }
+ });
+
+ test('moves no more players than it must: each team takes the side most of it is on', () => {
+ const random = rng(5);
+ // already dealt by strength: the deal comes out the same, and nobody moves
+ const players = Array.from({ length: 8 }, (_, i) =>
+ on(`p${i}`, [0, 3, 4, 7].includes(i) ? M : V)
+ );
+ const rating = (p: PlanPlayer) => Number(p.steamId.slice(1));
+ expect(moved(players, deal(players, [V, M], { clans: false, rating, random }))).toEqual([]);
+ // at random, at most half of a two-sided server moves
+ const mixed = [...many('v', 13, V), ...many('m', 11, M)];
+ for (let i = 0; i < 50; i++)
+ expect(
+ moved(mixed, deal(mixed, [V, M], { clans: false, random })).length
+ ).toBeLessThanOrEqual(12);
+ });
+});
+
+describe('evenUp', () => {
+ test('moves the fewest players, those off the sides first, and no clan players while others can go', () => {
+ const random = rng(6);
+ const players = [
+ ...many('t', 3, V, '[RDX] '),
+ ...many('v', 11, V),
+ ...many('m', 9, M),
+ on('l0', L)
+ ];
+ for (let i = 0; i < 20; i++) {
+ const out = evenUp(players, [V, M], { clans: true, random });
+ expect(sizes(out, [V, M])).toEqual([12, 12]);
+ const gone = moved(players, out);
+ expect(gone.length).toBe(3);
+ expect(gone.some((p) => p.steamId === 'l0')).toBe(true);
+ expect(gone.some((p) => p.name.startsWith('[RDX]'))).toBe(false);
+ }
+ });
+
+ test('leaves sides within one alone, and places a player on no side', () => {
+ const players = [...many('v', 7, V), ...many('m', 6, M), on('x', null)];
+ const out = evenUp(players, [V, M], { clans: false, random: rng(7) });
+ expect(moved(players, out).map((p) => [p.steamId, out.get(p.steamId)])).toEqual([['x', M]]);
+ });
+
+ test('with clans kept and only clan players on the big side, still evens up', () => {
+ const players = [...many('t', 6, V, '[RDX] '), ...many('m', 2, M)];
+ const out = evenUp(players, [V, M], { clans: true, random: rng(8) });
+ expect(sizes(out, [V, M])).toEqual([4, 4]);
+ });
+});
+
+test('recordRating counts a player with no record as 1 and grows with kills over deaths', () => {
+ expect(recordRating(null)).toBe(1);
+ expect(recordRating({ kills: 0, deaths: 0 })).toBe(1);
+ expect(recordRating({ kills: 30, deaths: 10 })).toBe(2);
+ expect(recordRating({ kills: 3, deaths: 0 })).toBeLessThan(
+ recordRating({ kills: 60, deaths: 20 })
+ );
+});
diff --git a/src/lib/team-plan.ts b/src/lib/team-plan.ts
new file mode 100644
index 00000000..ee61e390
--- /dev/null
+++ b/src/lib/team-plan.ts
@@ -0,0 +1,170 @@
+// The Players tab's Teams view: which side each player goes to when someone shuffles or evens up
+// the teams. Pure: the page keeps the plan, and nobody moves until it is saved, one changeTeam per
+// player.
+import { clanTag } from './clan';
+
+export interface PlanPlayer {
+ steamId: string;
+ name: string;
+ /** the side the player is on now; null when the game has them on none */
+ side: string | null;
+}
+
+/** How Shuffle deals the teams: at random, or spread by each player's K/D on record here. */
+export type ShuffleMode = 'random' | 'record';
+
+/**
+ * The kills and deaths a K/D on record starts from, so a player with little history counts as
+ * about 1 and one good match does not make anyone the strongest on the server.
+ */
+export const RECORD_PRIOR = 10;
+
+/** A player's K/D over the matches they finished on this server, smoothed by RECORD_PRIOR. */
+export function recordRating(record: { kills: number; deaths: number } | null | undefined): number {
+ return ((record?.kills ?? 0) + RECORD_PRIOR) / ((record?.deaths ?? 0) + RECORD_PRIOR);
+}
+
+function shuffled(xs: T[], random: () => number): T[] {
+ const a = xs.slice();
+ for (let i = a.length - 1; i > 0; i--) {
+ const j = Math.floor(random() * (i + 1));
+ [a[i], a[j]] = [a[j], a[i]];
+ }
+ return a;
+}
+
+/** Every order of a few sides (WARDOGS has three, so six). */
+function orders(xs: T[]): T[][] {
+ if (xs.length <= 1) return [xs.slice()];
+ return xs.flatMap((x, i) =>
+ orders([...xs.slice(0, i), ...xs.slice(i + 1)]).map((o) => [x, ...o])
+ );
+}
+
+/**
+ * Everyone dealt onto `sides` in even numbers, no side more than one player ahead of another.
+ * Clanmates (two or more players on one tag) go together while their side has room, biggest clan
+ * first. The strongest player left goes to the side whose players add up weakest so far; without
+ * a rating each player's strength is a random number, which makes the deal random. Each team then
+ * takes the side most of it is on already, so as few players move as the deal allows. Returns
+ * every player's side.
+ */
+export function deal(
+ players: PlanPlayer[],
+ sides: string[],
+ opts: { clans: boolean; rating?: (p: PlanPlayer) => number; random?: () => number }
+): Map {
+ const random = opts.random ?? Math.random;
+ const out = new Map();
+ const k = sides.length;
+ if (!k || !players.length) return out;
+ const strength = new Map(
+ players.map((p) => [p.steamId, opts.rating ? opts.rating(p) : random()])
+ );
+ const tie = new Map(players.map((p) => [p.steamId, random()]));
+ const of = (p: PlanPlayer) => strength.get(p.steamId)!;
+ const stronger = (a: PlanPlayer, b: PlanPlayer) =>
+ of(b) - of(a) || tie.get(a.steamId)! - tie.get(b.steamId)!;
+
+ const byTag = new Map();
+ const units: PlanPlayer[][] = [];
+ for (const p of players) {
+ const tag = opts.clans ? clanTag(p.name) : null;
+ if (!tag) units.push([p]);
+ else if (byTag.has(tag)) byTag.get(tag)!.push(p);
+ else byTag.set(tag, [p]);
+ }
+ for (const members of byTag.values()) units.push(members.sort(stronger));
+ const total = (u: PlanPlayer[]) => u.reduce((s, p) => s + of(p), 0);
+ units.sort((a, b) => b.length - a.length || total(b) - total(a) || stronger(a[0], b[0]));
+
+ // Teams 0..k-1: `base` players each, and `bigger` of them one more.
+ const base = Math.floor(players.length / k);
+ let bigger = players.length % k;
+ const size = new Array(k).fill(0);
+ const sum = new Array(k).fill(0);
+ const room = (t: number) => size[t] < base || (size[t] === base && bigger > 0);
+ const weakest = () => {
+ let best = -1;
+ for (let t = 0; t < k; t++)
+ if (
+ room(t) &&
+ (best < 0 || sum[t] < sum[best] || (sum[t] === sum[best] && size[t] < size[best]))
+ )
+ best = t;
+ return best;
+ };
+ const team = new Map();
+ for (const unit of units) {
+ let t = -1;
+ for (const p of unit) {
+ if (t < 0 || !room(t)) t = weakest();
+ if (size[t] === base) bigger--;
+ size[t]++;
+ sum[t] += of(p);
+ team.set(p.steamId, t);
+ }
+ }
+
+ // Name the teams: of the ways to give each team a side, the one that leaves the most players
+ // where they are (ties at random). More sides than WARDOGS has keep the order they came in.
+ let label = sides.slice();
+ if (k <= 6) {
+ let best = -1;
+ for (const o of shuffled(orders(sides), random)) {
+ let stay = 0;
+ for (const p of players) if (p.side === o[team.get(p.steamId)!]) stay++;
+ if (stay > best) {
+ best = stay;
+ label = o;
+ }
+ }
+ }
+ for (const p of players) out.set(p.steamId, label[team.get(p.steamId)!]);
+ return out;
+}
+
+/**
+ * The fewest moves that bring `sides` within one player of each other. Players on a side that is
+ * not one of `sides`, or on none, go to the smallest; then players leave the biggest side for the
+ * smallest one at a time, players without a clan tag first when clans are kept together. Returns
+ * every player's side.
+ */
+export function evenUp(
+ players: PlanPlayer[],
+ sides: string[],
+ opts: { clans: boolean; random?: () => number }
+): Map {
+ const random = opts.random ?? Math.random;
+ const out = new Map();
+ if (!sides.length) return out;
+ const count = new Map(sides.map((s) => [s, 0]));
+ const order = new Map(sides.map((s) => [s, random()]));
+ const n = (s: string) => count.get(s)!;
+ const smallest = () =>
+ sides.reduce((a, b) =>
+ n(b) < n(a) || (n(b) === n(a) && order.get(b)! < order.get(a)!) ? b : a
+ );
+ const biggest = () =>
+ sides.reduce((a, b) =>
+ n(b) > n(a) || (n(b) === n(a) && order.get(b)! < order.get(a)!) ? b : a
+ );
+ const put = (p: PlanPlayer, to: string) => {
+ const from = out.get(p.steamId);
+ if (from !== undefined) count.set(from, n(from) - 1);
+ out.set(p.steamId, to);
+ count.set(to, n(to) + 1);
+ };
+ const pick = (xs: PlanPlayer[]) => (xs.length ? xs[Math.floor(random() * xs.length)] : undefined);
+
+ for (const p of players) if (p.side !== null && count.has(p.side)) put(p, p.side);
+ for (const p of shuffled(players, random)) if (!out.has(p.steamId)) put(p, smallest());
+ for (;;) {
+ const hi = biggest();
+ const lo = smallest();
+ if (n(hi) - n(lo) <= 1) break;
+ const there = players.filter((p) => out.get(p.steamId) === hi);
+ put((opts.clans && pick(there.filter((p) => !clanTag(p.name)))) || pick(there)!, lo);
+ }
+ return out;
+}
From 1664f9ac5e587e69e5354b42a69e67017d27d404 Mon Sep 17 00:00:00 2001
From: James Causon
Date: Wed, 7 Oct 2026 22:41:10 +0100
Subject: [PATCH 052/102] Player marks carry each player's record on the server
---
src/lib/server/players.ts | 20 ++++++++--
src/lib/types.ts | 2 +
src/test/team-record.test.ts | 76 ++++++++++++++++++++++++++++++++++++
3 files changed, 95 insertions(+), 3 deletions(-)
create mode 100644 src/test/team-record.test.ts
diff --git a/src/lib/server/players.ts b/src/lib/server/players.ts
index 0dc67f57..d40cf16b 100644
--- a/src/lib/server/players.ts
+++ b/src/lib/server/players.ts
@@ -21,6 +21,7 @@ import {
playerMarks,
playerNotes,
playerSessions,
+ playerTotals,
serverBans,
servers,
triggers
@@ -266,7 +267,7 @@ export async function marksFor(
// Bans elsewhere in the org count only where the reader could open them, as in the dossier.
const orgIds = (await accessibleServers(env, user, server.orgId)).map((s) => s.id);
const staff = access.caps.has('players.notes') || access.caps.has('players.notes.manage');
- const [profiles, local, counts, performance] = await Promise.all([
+ const [profiles, local, counts, performance, totals] = await Promise.all([
getProfiles(env, ids),
localSignals(env, server.orgId, orgIds, server.id, players),
env.db
@@ -274,9 +275,21 @@ export async function marksFor(
.from(playerSessions)
.where(and(eq(playerSessions.serverId, server.id), inArray(playerSessions.steamId, ids)))
.groupBy(playerSessions.steamId),
- riskPerformanceFor(env, orgIds, ids)
+ riskPerformanceFor(env, orgIds, ids),
+ // the matches each player finished here, which the Teams view's shuffle can spread by
+ env.db
+ .select({
+ steamId: playerTotals.steamId,
+ kills: playerTotals.kills,
+ deaths: playerTotals.deaths
+ })
+ .from(playerTotals)
+ .where(and(eq(playerTotals.serverId, server.id), inArray(playerTotals.steamId, ids)))
]);
const visits = new Map(counts.map((c) => [c.steamId, num(c.n)]));
+ const records = new Map(
+ totals.map((t) => [t.steamId, { kills: num(t.kills), deaths: num(t.deaths) }])
+ );
return ids.map((steamId) => {
const l = local.get(steamId);
return {
@@ -285,7 +298,8 @@ export async function marksFor(
reason: staff ? (l?.watched?.reason ?? '') : '',
firstVisit: (visits.get(steamId) ?? 0) <= 1,
risk: riskFor(env, profiles.get(steamId), l, performance.get(steamId), staff),
- steamName: profiles.get(steamId)?.persona || null
+ steamName: profiles.get(steamId)?.persona || null,
+ record: records.get(steamId) ?? null
};
});
}
diff --git a/src/lib/types.ts b/src/lib/types.ts
index ee909506..47650349 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -384,6 +384,8 @@ export interface PlayerMark {
risk: RiskView;
/** the Steam profile's name, as the dossier shows it; null without a profile on record */
steamName: string | null;
+ /** kills and deaths over the matches the player finished on this server; null before the first */
+ record: { kills: number; deaths: number } | null;
}
export interface PlayerNoteView {
diff --git a/src/test/team-record.test.ts b/src/test/team-record.test.ts
new file mode 100644
index 00000000..2cb68abf
--- /dev/null
+++ b/src/test/team-record.test.ts
@@ -0,0 +1,76 @@
+// The players table's marks carry each player's kills and deaths over the matches they finished on
+// this server (player_totals), which the Teams view's "Spread by record here" shuffle deals by. The
+// player page already shows the same counts per server to anyone who can open it.
+import { beforeAll, describe, expect, test } from 'bun:test';
+import type { Env } from '$lib/server/env';
+import { matches, matchPlayers } from '$lib/server/db/schema';
+import type { PlayerMark } from '$lib/types';
+import { hasTestDb, testEnv } from './db';
+import { callApi, stubGateway } from './call';
+import { seedWorld, type World } from './world';
+import { GET as marksRoute } from '../routes/api/servers/[id]/players/marks/+server';
+
+const PLAYED = '76561198000000851';
+const NEW = '76561198000000852';
+const HOUR = 3_600_000;
+
+describe.skipIf(!hasTestDb)('records on the players table', () => {
+ let env: Env;
+ let w: World;
+
+ const finished = async (serverId: string, kills: number, deaths: number) => {
+ const [m] = await env.db
+ .insert(matches)
+ .values({
+ serverId,
+ startedAt: new Date(Date.now() - 2 * HOUR),
+ endedAt: new Date(Date.now() - HOUR),
+ map: 'Europe',
+ finalScores: [
+ { name: 'Valkyra', score: 3 },
+ { name: 'Lonestar', score: 1 }
+ ],
+ winner: 'Valkyra'
+ })
+ .returning({ id: matches.id });
+ await env.db.insert(matchPlayers).values({
+ matchId: m.id,
+ serverId,
+ steamId: PLAYED,
+ name: 'Player',
+ faction: 'Valkyra',
+ seconds: 3000,
+ kills,
+ deaths
+ });
+ };
+ const marks = (who: World['users'][keyof World['users']]) =>
+ callApi(marksRoute, who, {
+ params: { id: w.server.id },
+ query: new URLSearchParams({ ids: `${PLAYED},${NEW}`, names: 'Player\nJoiner' }).toString()
+ });
+
+ beforeAll(async () => {
+ env = await testEnv();
+ stubGateway();
+ w = await seedWorld(env);
+ await finished(w.server.id, 20, 8);
+ await finished(w.server.id, 10, 2);
+ // the same player's matches on the org's other server are not this server's record
+ await finished(w.otherServer.id, 99, 1);
+ });
+
+ test("a viewer reads each player's record on this server, and null before their first match", async () => {
+ const answer = await marks(w.users.viewer);
+ expect(answer.status).toBe(200);
+ const got = (answer.body as { marks: PlayerMark[] }).marks;
+ expect(got.find((m) => m.steamId === PLAYED)?.record).toEqual({ kills: 30, deaths: 10 });
+ expect(got.find((m) => m.steamId === NEW)?.record).toBeNull();
+ });
+
+ test('another org, a key held to the other server and nobody signed in get nothing', async () => {
+ expect((await marks(w.users.outsider)).status).toBe(404);
+ expect((await marks(w.users.keyElsewhere)).status).toBe(404);
+ expect((await marks(null)).status).toBe(401);
+ });
+});
From f6b9f8cf378548c870cf3a535e9fd58939ef8af2 Mon Sep 17 00:00:00 2001
From: James Causon
Date: Wed, 7 Oct 2026 22:41:10 +0100
Subject: [PATCH 053/102] Players tab: Teams view to arrange, shuffle and move
Save sends changeTeam per player with kill: false, so each move keeps
Move's check, its audit row and the same-side refusal. No new route.
---
.../(app)/server/[id]/players/+page.svelte | 25 +-
.../server/[id]/players/TeamsView.svelte | 724 ++++++++++++++++++
src/test/player-actions.test.ts | 35 +-
3 files changed, 779 insertions(+), 5 deletions(-)
create mode 100644 src/routes/(app)/server/[id]/players/TeamsView.svelte
diff --git a/src/routes/(app)/server/[id]/players/+page.svelte b/src/routes/(app)/server/[id]/players/+page.svelte
index 62395231..feef3157 100644
--- a/src/routes/(app)/server/[id]/players/+page.svelte
+++ b/src/routes/(app)/server/[id]/players/+page.svelte
@@ -16,6 +16,7 @@
import { TableSort, matches } from '$lib/table.svelte';
import type { LiveView, Player, PlayerMark, ServerListsState, Status } from '$lib/types';
import PastPlayers from './PastPlayers.svelte';
+ import TeamsView from './TeamsView.svelte';
import type { PageProps, Snapshot } from './$types';
let { data }: PageProps = $props();
@@ -27,8 +28,10 @@
let bans = $derived(can(data.server.caps, 'bans.manage'));
let anyAction = $derived(canKick || canKill || canMove || chat || bans);
let notes = $derived(can(data.server.caps, 'players.notes'));
- /** who is on now, or everyone who has played here */
- let view = $state<'online' | 'past'>('online');
+ /** who is on now, the same players by side, or everyone who has played here */
+ let view = $state<'online' | 'teams' | 'past'>('online');
+ /** the Teams view stays mounted once opened, so its plan survives a look at the other views */
+ let teamsOpened = $state(false);
/** may the user write to the organisation's lists? Decides the ban dialog's default scope. */
let listState = $state(null);
let banning = $state(null);
@@ -249,6 +252,15 @@
aria-selected={view === 'online'}
onclick={() => (view = 'online')}>Online now · {all.length}
+
+ {#each c.cards as p (p.steamId)}{@render card(p)}{/each}
+ {#if !c.cards.length}
+
+ {canMove ? 'No one here. Drag players onto this side.' : 'No one on this side.'}
+
+ {/if}
+
+
+ {/each}
+
+{/if}
+
+ {#if canMove}
+ Shuffle deals everyone into even teams on the sides ticked In shuffle, keeping clan tags
+ together when that is ticked; Even up moves the fewest players to get the sides within one.
+ Neither moves anyone: drag players around afterwards, and nothing happens in game until Save. A
+ player who picks the planned side themselves drops out of the plan.
+ {:else}
+ Arranging the teams needs Move on this server.
+ {/if}
+
+
+{#if confirming}
+ (confirming = false)}>
+ {#each groups as g (g.name)}
+
+ To {g.name}
+ {g.who.join(', ')}
+
+ {/each}
+
+ Each player is switched to the new side without a kill, as Team balance switches them. The
+ moves go out one at a time, about three a second; anyone who has left, or is on that side
+ already, is passed over.
+
Players not moved stay in the plan, so Save tries them again.
+ {/if}
+ {#snippet actions()}
+ {#if r.finished}
+
+ {:else}
+
+ {/if}
+ {/snippet}
+
+{/if}
diff --git a/src/test/player-actions.test.ts b/src/test/player-actions.test.ts
index ea215f58..228b20f5 100644
--- a/src/test/player-actions.test.ts
+++ b/src/test/player-actions.test.ts
@@ -174,12 +174,17 @@ describe.skipIf(!hasTestDb)('Kick, Kill and Move apart', () => {
stubGateway();
});
- const move = async (who: SessionUser, steamId: unknown, faction: unknown) => {
+ const move = async (
+ who: SessionUser | null,
+ steamId: unknown,
+ faction: unknown,
+ extra: Record = {}
+ ) => {
const { POST } = await import(join(ROUTES, 'servers/[id]/rcon/[action]', '+server.ts'));
return callApi(POST, who, {
method: 'POST',
params: { id: w.server.id, action: 'changeTeam' },
- body: { steamId, faction }
+ body: { steamId, faction, ...extra }
});
};
@@ -209,6 +214,32 @@ describe.skipIf(!hasTestDb)('Kick, Kill and Move apart', () => {
expect(requests).toEqual([`PATCH /v1/players/${PLAYER}`, `POST /v1/players/${PLAYER}/kill`]);
});
+ test('without the kill, as the Teams view saves, is the move alone, behind the same check', async () => {
+ await holds(['players.move']);
+ requests.length = 0;
+ const got = await move(w.users.viewer!, PLAYER, 'Valkyra', { kill: false });
+ expect([got.status, requests]).toEqual([200, [`PATCH /v1/players/${PLAYER}`]]);
+ expect((got.body as { result: { message: string } }).result.message).toBe(
+ 'Moved to Valkyra.'
+ );
+ // View alone, another org's owner, a key held to the org's other server and nobody signed
+ // in are refused before the game hears of it
+ await holds([]);
+ requests.length = 0;
+ const refused: [string, SessionUser | null, number][] = [
+ ['viewer', w.users.viewer, 403],
+ ['outsider', w.users.outsider, 404],
+ ['keyElsewhere', w.users.keyElsewhere, 404],
+ ['anon', null, 401]
+ ];
+ for (const [who, user, status] of refused)
+ expect([who, (await move(user, PLAYER, 'Valkyra', { kill: false })).status]).toEqual([
+ who,
+ status
+ ]);
+ expect(requests).toEqual([]);
+ });
+
test('of a player the list does not hold goes to the game, which answers for them', async () => {
await holds(['players.move']);
requests.length = 0;
From ed55e83760c0333cbdb5a696496cbf8efcd84fd7 Mon Sep 17 00:00:00 2001
From: James Causon
Date: Wed, 7 Oct 2026 22:41:10 +0100
Subject: [PATCH 054/102] README: Teams view
---
README.md | 54 ++++++++++++++++++++++++++++++++----------------------
1 file changed, 32 insertions(+), 22 deletions(-)
diff --git a/README.md b/README.md
index 2b29a967..c30893c4 100644
--- a/README.md
+++ b/README.md
@@ -48,6 +48,16 @@ What is in the box:
when they were last on, their sessions and playtime. Anyone who can open the server can look; a
row's Ban (for people who hold _Bans_) lands the moment the player next joins, and Watch needs
_Notes_.
+- **Arrange the teams**: the Players tab's Teams view shows who is on each side, one column per
+ faction. With _Move_, drag players between the columns (or send one to the next side with its
+ button, or pick the side from its menu on a phone), or let Shuffle fill them in: _Random_, or
+ _Spread by record here_, which deals players out by their K/D over the matches they finished on
+ that server (smoothed, so a newcomer counts as about 1). Even up moves the fewest players that
+ get the sides within one. Both keep clan tags together when that is ticked, and leave out the
+ sides unticked _In shuffle_ (a closed faction). Nothing moves until Save, which moves the players
+ one at a time, about three a second, without the kill, and says who was moved, who had left and
+ who was on that side already; whoever was not moved stays in the plan. A Team balance rule keeps
+ those moves, and evens the sides up again at the next match start as it always does.
- **Analytics**: the worker keeps what the game does not: players online over time, players per
day and how many of them were new, how long sessions last, cash in play per faction, uptime,
time per map, wins per team, busiest hours, player playtime and sessions, match history
@@ -299,26 +309,26 @@ what it shipped with, and add roles of their own, say a `Trial staff` that may k
which the tab's columns and every role picker follow; a new role goes in last. Org owners and the
site owner hold every capability on every server in scope.
-| Capability | Unlocks | viewer | operator | admin |
-| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | ----- |
-| View | what is happening on the server: status, players, kills, rotation, who is banned and who holds a reserved slot, analytics, leaderboards, player stats. Every role has it. | ✓ | ✓ | ✓ |
-| Chat | broadcast; whisper one player, several, or everyone on a faction | | ✓ | ✓ |
-| Kick | kick a player; the rules that kick or flag players | | ✓ | ✓ |
-| Kill | kill a player's character; AFK protection, and a Kill distance watch that kills | | ✓ | ✓ |
-| Move | move a player to another team, which kills them so they respawn on it (a move to the side they are on is refused); a Team balance rule | | ✓ | ✓ |
-| Match control | end/restart match, change map, next map, weather | | ✓ | ✓ |
-| Live rotation | add, remove and reorder rotation entries on the running server | | ✓ | ✓ |
-| Notes & watchlist | read and add player notes (delete your own), watch and unwatch, the reason a player is watched | | ✓ | ✓ |
-| Bans | ban and unban on the server, from a dossier too, and see who placed its bans; a Kill distance watch that bans here | | | ✓ |
-| Reserved slots | reserve and unreserve on this server, with a note and an expiry, and read the notes; a Seeding reward rule that hands out slots here | | | ✓ |
-| Org ban list | the organisation's ban list, enforced on every server; sync; who placed a ban, and Unban org-wide, in a dossier; a Kill distance watch that bans everywhere | | | ✓ |
-| Org reserved slots | the organisation's reserved-slot list, handed out on every server; sync; a player's entry on it in the dossier; a Seeding reward rule that hands out slots everywhere | | | ✓ |
-| Others' notes | delete anyone's note | | | ✓ |
-| Save rotation | save the rotation, rotation mode on and off | | | ✓ |
-| Config & settings | read, validate and apply the config document; score tick, sponsor image, connection test, the game's raw status | | | ✓ |
-| Automation | see the triggers and what they did; create, edit, dry-run and delete them; a player's Seeding reward progress | | | ✓ |
-| Audit trail | everyone's actions on the server in the audit log, not just your own; the game server's own RCON log | | | ✓ |
-| Raw RCON | any /v1 route on the game server directly, except the config document | | | ✓ |
+| Capability | Unlocks | viewer | operator | admin |
+| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | ----- |
+| View | what is happening on the server: status, players, kills, rotation, who is banned and who holds a reserved slot, analytics, leaderboards, player stats. Every role has it. | ✓ | ✓ | ✓ |
+| Chat | broadcast; whisper one player, several, or everyone on a faction | | ✓ | ✓ |
+| Kick | kick a player; the rules that kick or flag players | | ✓ | ✓ |
+| Kill | kill a player's character; AFK protection, and a Kill distance watch that kills | | ✓ | ✓ |
+| Move | move a player to another team, which kills them so they respawn on it (a move to the side they are on is refused); arrange the teams on the Players tab, which moves without the kill; a Team balance rule | | ✓ | ✓ |
+| Match control | end/restart match, change map, next map, weather | | ✓ | ✓ |
+| Live rotation | add, remove and reorder rotation entries on the running server | | ✓ | ✓ |
+| Notes & watchlist | read and add player notes (delete your own), watch and unwatch, the reason a player is watched | | ✓ | ✓ |
+| Bans | ban and unban on the server, from a dossier too, and see who placed its bans; a Kill distance watch that bans here | | | ✓ |
+| Reserved slots | reserve and unreserve on this server, with a note and an expiry, and read the notes; a Seeding reward rule that hands out slots here | | | ✓ |
+| Org ban list | the organisation's ban list, enforced on every server; sync; who placed a ban, and Unban org-wide, in a dossier; a Kill distance watch that bans everywhere | | | ✓ |
+| Org reserved slots | the organisation's reserved-slot list, handed out on every server; sync; a player's entry on it in the dossier; a Seeding reward rule that hands out slots everywhere | | | ✓ |
+| Others' notes | delete anyone's note | | | ✓ |
+| Save rotation | save the rotation, rotation mode on and off | | | ✓ |
+| Config & settings | read, validate and apply the config document; score tick, sponsor image, connection test, the game's raw status | | | ✓ |
+| Automation | see the triggers and what they did; create, edit, dry-run and delete them; a player's Seeding reward progress | | | ✓ |
+| Audit trail | everyone's actions on the server in the audit log, not just your own; the game server's own RCON log | | | ✓ |
+| Raw RCON | any /v1 route on the game server directly, except the config document | | | ✓ |
View is what is happening on the server and nothing about how it is run. The config document, the
triggers, staff notes on players and the game's RCON log each need the capability that manages
@@ -1258,7 +1268,7 @@ These need _View_ on the server unless the table says otherwise.
| `GET /api/servers/:id/players/seen` | everyone who has played on the server: `q` (name, alias, a name the kill feed showed for them, or SteamID; each player's `feedNames` are those names), `since` (days), `flag` (`banned`, `watched` or `online`), `sort` (`lastSeen`, `firstSeen`, `minutes`, `sessions`, `kills`, `deaths`, `name`), `dir`, `offset`, `limit` (up to 100) |
| `GET /api/servers/:id/players/:steamId` | the dossier: names, the names the kill feed showed that were not theirs (`feedNames`), sessions, totals per server with seed time, every ban in force (`bans`), risk, the kill feed's summary, the admin actions you may read with a kick's reason and a whisper's text; notes and why a player is watched only with _Notes & watchlist_; the Seeding reward's progress (`seedReward`) only with _Automation_ |
| `GET /api/servers/:id/players/:steamId/career` | rank, streak, results by map and faction, the last ten matches, the organisation's last ten seasons with the places won |
-| `GET /api/servers/:id/players/marks?ids=a,b` | watched, first visit, risk score and Steam name (`steamName`) for up to 200 SteamIDs |
+| `GET /api/servers/:id/players/marks?ids=a,b` | watched, first visit, risk score, Steam name (`steamName`) and kills and deaths over the matches finished on this server (`record`, null before the first) for up to 200 SteamIDs |
| `GET /api/servers/:id/kills` | the stored kill feed, newest first (see [Kill feed](#kill-feed)): `killer`, `victim`, `player` (a SteamID, or part of a name), `kind` (`headshot`, `teamKill`, `suicide`, `vehicle`, `environment`), `cause`, `minM` (metres), `match`, `limit` (up to 200); `count=1` adds the total. For the next page, send the last kill's `ts` as `before` and its `eventTime` as `beforeTime` |
| `GET /api/servers/:id/matches?page=` | the match history, fifty a page, newest first; the match in progress has no `endedAt` |
| `GET /api/servers/:id/matches/:matchId` | a match that has ended: each player's line, the score timeline, awards |
@@ -1560,7 +1570,7 @@ POST /api/servers/:id/stats/purge {name} deletes the server's ki
GET/POST/DELETE /api/servers/:id/feed the kill feed setup: token and URL (POST mints or replaces, owners only)
POST /api/ingest/events where the game posts: [WDServerFeed] Url is the origin, the game adds this path (Authorization: Bearer wkf_…); not a panel route
GET /api/servers/:id/cash?since= cash-in-play samples since a moment (24 h at most), seeds the dashboard chart
-GET /api/servers/:id/players/marks?ids=a,b&names=… watchlist / first-visit / risk per connected player
+GET /api/servers/:id/players/marks?ids=a,b&names=… watchlist / first-visit / risk / record here per connected player
GET /api/servers/:id/players/:steamId dossier POST .../steam (refresh Steam data) GET .../career rank, streak, results by map and faction, the last ten matches
GET /api/servers/:id/leaderboard?scope=server|org&range=current|s:|7d|30d|90d|all&sort=kills|deaths|kd|perHour|playtime|matches|wins|winRate|cash|cashPerMin&dir=desc|asc&page=1&minMinutes=60
GET /api/servers/:id/leaderboard/export? the board as CSV, every row from the top, up to 10,000
From 9ca18cf4f8293776a0d5a0c761942f67fed585fb Mon Sep 17 00:00:00 2001
From: James Causon
Date: Thu, 8 Oct 2026 20:31:53 +0100
Subject: [PATCH 055/102] Team kill limit: crashes can be left out of the count
- A vehicle that blows up is reported as its driver's or pilot's kill of
everyone aboard. A rule whose notCounted holds VehicleExplosion leaves
those out; a vehicle blown up by a weapon, and a roadkill, still count.
- Opt-in: the default list is unchanged. No migration, no new route.
---
src/lib/causes.test.ts | 37 ++++++++++++-
src/lib/causes.ts | 13 +++++
src/lib/server/feed-events.ts | 29 +++++-----
src/lib/server/trigger-rules.ts | 18 +++----
src/lib/server/triggers.test.ts | 45 +++++++++++++---
src/lib/server/triggers.ts | 31 +++++++----
src/test/access.test.ts | 6 ++-
src/test/team-kill-rule.test.ts | 93 +++++++++++++++++++++++++++++----
8 files changed, 220 insertions(+), 52 deletions(-)
diff --git a/src/lib/causes.test.ts b/src/lib/causes.test.ts
index 0dba9a8d..3e43a688 100644
--- a/src/lib/causes.test.ts
+++ b/src/lib/causes.test.ts
@@ -1,5 +1,12 @@
import { describe, expect, test } from 'bun:test';
-import { causeKind, causeLabel, knownCauses } from './causes';
+import {
+ causeKind,
+ causeLabel,
+ isVehicleCrash,
+ knownCauses,
+ notCountedLabel,
+ VEHICLE_CRASH
+} from './causes';
describe('causeLabel', () => {
test('named weapons, tools, buildables and vehicles', () => {
@@ -90,3 +97,31 @@ describe('causeKind', () => {
expect(causeKind(null)).toBe('none');
});
});
+
+describe('isVehicleCrash', () => {
+ test("a vehicle's own blast is a crash; a weapon that blows one up, or a roadkill, is not", () => {
+ const heli = 'Vehicle.Variant.Air.Rotary.ROT_04.Default';
+ expect(isVehicleCrash({ cause: heli, tags: ['VehicleExplosion'] })).toBe(true);
+ expect(
+ isVehicleCrash({ cause: heli.toUpperCase(), tags: ['Penetration', 'VehicleExplosion'] })
+ ).toBe(true);
+ expect(isVehicleCrash({ cause: heli, tags: [] })).toBe(false);
+ expect(isVehicleCrash({ cause: heli, tags: ['RoadKill'] })).toBe(false);
+ expect(isVehicleCrash({ cause: 'Id.Item.C4Explosive', tags: ['VehicleExplosion'] })).toBe(
+ false
+ );
+ expect(
+ isVehicleCrash({
+ cause: 'Id.Vehicle.WeaponExtension.TNK_01.Artillery',
+ tags: ['VehicleExplosion']
+ })
+ ).toBe(false);
+ expect(isVehicleCrash({ cause: null, tags: ['VehicleExplosion'] })).toBe(false);
+ });
+
+ test("the Team kill limit's entry for crashes is named in any case, the rest by their cause", () => {
+ expect(notCountedLabel(VEHICLE_CRASH)).toBe('Vehicle crash');
+ expect(notCountedLabel('vehicleexplosion')).toBe('Vehicle crash');
+ expect(notCountedLabel('Id.Buildable.BarbedWire')).toBe('Barbed wire');
+ });
+});
diff --git a/src/lib/causes.ts b/src/lib/causes.ts
index 9dd17c7c..e787cf53 100644
--- a/src/lib/causes.ts
+++ b/src/lib/causes.ts
@@ -118,6 +118,19 @@ const LABELS: Record = {
* runs into a teammate's barbed wire is reported as killed by whoever built it.
*/
export const TEAM_KILL_NOT_COUNTED: readonly string[] = ['Id.Buildable.BarbedWire'];
+/**
+ * The entry of a Team kill limit's list that leaves out crashes, by the feed's own tag: a vehicle
+ * that blows up kills whoever is aboard, and the feed reports each death as a kill by its driver
+ * or pilot, with the vehicle as the cause. A vehicle blown up by a weapon has that weapon as the
+ * cause instead, so a teammate's charge or rocket is not a crash.
+ */
+export const VEHICLE_CRASH = 'VehicleExplosion';
+/** Whether a kill is a crash: a vehicle's own blast, reported as its driver's or pilot's kill. */
+export const isVehicleCrash = (k: { cause: string | null; tags: readonly string[] }): boolean =>
+ causeKind(k.cause) === 'vehicle' && k.tags.includes(VEHICLE_CRASH);
+/** An entry of a Team kill limit's list by name. */
+export const notCountedLabel = (entry: string): string =>
+ entry.toLowerCase() === VEHICLE_CRASH.toLowerCase() ? 'Vehicle crash' : causeLabel(entry);
/** The table by lower-case tag, for a lookup in any case. */
const BY_TAG = new Map(
diff --git a/src/lib/server/feed-events.ts b/src/lib/server/feed-events.ts
index da00b3da..27438ee5 100644
--- a/src/lib/server/feed-events.ts
+++ b/src/lib/server/feed-events.ts
@@ -598,19 +598,20 @@ function killerVars(env: Env, serverId: string) {
}
type KillerVars = ReturnType;
-/** A player's team kills in a match by one cause (null for none), as a rule counts them. */
+/** A player's team kills in a match by one cause (null for none) and tags, as a rule counts them. */
interface CauseCount {
cause: string | null;
+ tags: string[];
n: number;
}
/**
* How many team kills each of these players has in the match the batch arrived in, up to and
- * including the batch, by cause: the rows that carry that match, as its match page counts them.
- * Leaving and joining again does not start the count over, and a batch acted on late does not
- * count the ones that came after it. A batch that came in while no match was open (a server's
- * first seconds, or just after its stats were purged) counts with the other such kills of the
- * hour before.
+ * including the batch, by cause and tags: the rows that carry that match, as its match page
+ * counts them. Leaving and joining again does not start the count over, and a batch acted on late
+ * does not count the ones that came after it. A batch that came in while no match was open (a
+ * server's first seconds, or just after its stats were purged) counts with the other such kills
+ * of the hour before.
*/
async function teamKillsThisMatch(
env: Env,
@@ -639,7 +640,12 @@ async function teamKillsThisMatch(
? killsOfMatch(stamp.row, stamp.startedAt, stamp.endedAt)
: null;
const rows = await env.db
- .select({ steamId: kills.killerSteamId, cause: kills.cause, n: sql`COUNT(*)` })
+ .select({
+ steamId: kills.killerSteamId,
+ cause: kills.cause,
+ tags: kills.tags,
+ n: sql`COUNT(*)`
+ })
.from(kills)
.where(
and(
@@ -653,11 +659,11 @@ async function teamKillsThisMatch(
lte(kills.ts, at)
)
)
- .groupBy(kills.killerSteamId, kills.cause);
+ .groupBy(kills.killerSteamId, kills.cause, kills.tags);
const out = new Map();
for (const r of rows) {
const list = out.get(r.steamId!) ?? out.set(r.steamId!, []).get(r.steamId!)!;
- list.push({ cause: r.cause, n: Number(r.n) });
+ list.push({ cause: r.cause, tags: r.tags as string[], n: Number(r.n) });
}
return out;
}
@@ -675,8 +681,7 @@ async function actOnTeamKills(
.map((row) => {
const cfg = row.config as TeamKillConfig;
const byKiller = new Map();
- for (const k of teamKills)
- if (countsForTeamKill(cfg, k.cause)) byKiller.set(k.killer!.steamId, k);
+ for (const k of teamKills) if (countsForTeamKill(cfg, k)) byKiller.set(k.killer!.steamId, k);
return { row, cfg, byKiller };
})
.filter((r) => r.byKiller.size);
@@ -688,7 +693,7 @@ async function actOnTeamKills(
for (const { row, cfg, byKiller } of rules) {
const acts = [...byKiller].flatMap(([steamId, k]) => {
const count = (counts.get(steamId) ?? []).reduce(
- (n, c) => (countsForTeamKill(cfg, c.cause) ? n + c.n : n),
+ (n, c) => (countsForTeamKill(cfg, c) ? n + c.n : n),
0
);
const stage = teamKillStage(cfg, count);
diff --git a/src/lib/server/trigger-rules.ts b/src/lib/server/trigger-rules.ts
index fbb07001..cdcb1f88 100644
--- a/src/lib/server/trigger-rules.ts
+++ b/src/lib/server/trigger-rules.ts
@@ -12,7 +12,7 @@ import { validateNameChange, type NameChangeConfig } from './name-change';
import { causeTags } from './cause-tags';
import { RESTART_AFTER_HOURS, restartWindow } from '$lib/uptime';
import { MAX_CHAT } from '$lib/chat';
-import { TEAM_KILL_NOT_COUNTED } from '$lib/causes';
+import { isVehicleCrash, TEAM_KILL_NOT_COUNTED, VEHICLE_CRASH } from '$lib/causes';
import { mapName } from '$lib/format';
import { UNKNOWN } from './message-vars';
import type { SteamProfileRow } from './db/schema';
@@ -151,9 +151,9 @@ export interface RestartNoticeConfig {
/**
* Acts on team kills the kill feed reports, counted per killer within each match: a whisper
* from `warnAt` team kills on (0 = never), a kick at `kickAt` (0 = never). Team kills by a cause
- * in `notCounted` (feed tags, any case) are left out of the count and never acted on; they stay
- * team kills everywhere else. A rule saved before the list existed has none and leaves out
- * TEAM_KILL_NOT_COUNTED.
+ * in `notCounted` (feed tags, any case) are left out of the count and never acted on, and so are
+ * crashes when it holds VEHICLE_CRASH; they stay team kills everywhere else. A rule saved before
+ * the list existed has none and leaves out TEAM_KILL_NOT_COUNTED.
*/
export interface TeamKillConfig {
warnAt: number;
@@ -568,14 +568,14 @@ export function broadcastWanted(
export const teamKillNotCounted = (cfg: Pick): readonly string[] =>
Array.isArray(cfg.notCounted) ? cfg.notCounted : TEAM_KILL_NOT_COUNTED;
-/** Whether a team-kill rule counts a team kill by this cause: one with no cause always counts. */
+/** Whether a team-kill rule counts a team kill: one with no cause always counts. */
export function countsForTeamKill(
cfg: Pick,
- cause: string | null | undefined
+ k: { cause: string | null; tags: readonly string[] }
): boolean {
- if (!cause) return true;
- const lower = cause.toLowerCase();
- return !teamKillNotCounted(cfg).some((c) => c.toLowerCase() === lower);
+ const left = teamKillNotCounted(cfg).map((c) => c.toLowerCase());
+ if (left.includes(VEHICLE_CRASH.toLowerCase()) && isVehicleCrash(k)) return false;
+ return !k.cause || !left.includes(k.cause.toLowerCase());
}
/** What a team-kill rule does once the killer's count this match has reached `count`. */
diff --git a/src/lib/server/triggers.test.ts b/src/lib/server/triggers.test.ts
index 54bf9046..891955e8 100644
--- a/src/lib/server/triggers.test.ts
+++ b/src/lib/server/triggers.test.ts
@@ -779,6 +779,10 @@ describe('team_kill', () => {
'Id.Item.ATMine',
'Id.Item.Claymore'
]);
+ expect(notCounted(['Id.Buildable.BarbedWire', 'VehicleExplosion'])).toEqual([
+ 'Id.Buildable.BarbedWire',
+ 'VehicleExplosion'
+ ]);
expect(() => notCounted(['Barbed wire'])).toThrow('kill feed tag');
expect(() => notCounted(Array.from({ length: 41 }, (_, i) => `Id.Item.W${i}`))).toThrow(
'at most 40'
@@ -787,16 +791,41 @@ describe('team_kill', () => {
test('countsForTeamKill: any cause but those left out, in any case; an older rule leaves out barbed wire', () => {
const wire = 'Id.Buildable.BarbedWire';
+ const by = (cause: string | null) => ({ cause, tags: [] });
// saved before the list existed
- expect(countsForTeamKill({}, wire)).toBe(false);
- expect(countsForTeamKill({}, 'ID.BUILDABLE.BARBEDWIRE')).toBe(false);
- expect(countsForTeamKill({}, 'Id.Item.AK74M')).toBe(true);
- expect(countsForTeamKill({}, null)).toBe(true);
+ expect(countsForTeamKill({}, by(wire))).toBe(false);
+ expect(countsForTeamKill({}, by('ID.BUILDABLE.BARBEDWIRE'))).toBe(false);
+ expect(countsForTeamKill({}, by('Id.Item.AK74M'))).toBe(true);
+ expect(countsForTeamKill({}, by(null))).toBe(true);
// a list, even an empty one, is what the rule says
- expect(countsForTeamKill({ notCounted: [] }, wire)).toBe(true);
- expect(countsForTeamKill({ notCounted: ['Id.Item.Claymore'] }, wire)).toBe(true);
- expect(countsForTeamKill({ notCounted: ['Id.Item.Claymore'] }, 'id.item.claymore')).toBe(false);
- expect(countsForTeamKill({ notCounted: [wire] }, null)).toBe(true);
+ expect(countsForTeamKill({ notCounted: [] }, by(wire))).toBe(true);
+ expect(countsForTeamKill({ notCounted: ['Id.Item.Claymore'] }, by(wire))).toBe(true);
+ expect(countsForTeamKill({ notCounted: ['Id.Item.Claymore'] }, by('id.item.claymore'))).toBe(
+ false
+ );
+ expect(countsForTeamKill({ notCounted: [wire] }, by(null))).toBe(true);
+ });
+
+ test('countsForTeamKill: a crash is left out when the list holds it, in any case; a teammate who blows a vehicle up, or runs one over, still counts', () => {
+ const heli = 'Vehicle.Variant.Air.Rotary.Littlebird.Default';
+ const crash = { cause: heli, tags: ['VehicleExplosion'] };
+ const crashes = { notCounted: ['Id.Buildable.BarbedWire', 'VehicleExplosion'] };
+ expect(countsForTeamKill(crashes, crash)).toBe(false);
+ expect(countsForTeamKill({ notCounted: ['vehicleexplosion'] }, crash)).toBe(false);
+ // a rule without it, saved before the list or since
+ expect(countsForTeamKill({}, crash)).toBe(true);
+ expect(countsForTeamKill({ notCounted: ['Id.Buildable.BarbedWire'] }, crash)).toBe(true);
+ expect(
+ countsForTeamKill(crashes, { cause: 'Id.Item.C4Explosive', tags: ['VehicleExplosion'] })
+ ).toBe(true);
+ expect(
+ countsForTeamKill(crashes, {
+ cause: 'Id.Vehicle.WeaponExtension.STN_01.MistralAA',
+ tags: ['VehicleExplosion']
+ })
+ ).toBe(true);
+ expect(countsForTeamKill(crashes, { cause: heli, tags: ['RoadKill'] })).toBe(true);
+ expect(countsForTeamKill(crashes, { cause: null, tags: ['VehicleExplosion'] })).toBe(true);
});
test('teamKillStage: a whisper from warnAt on, a kick from kickAt on', () => {
diff --git a/src/lib/server/triggers.ts b/src/lib/server/triggers.ts
index cbc14aae..be57e9ee 100644
--- a/src/lib/server/triggers.ts
+++ b/src/lib/server/triggers.ts
@@ -137,7 +137,7 @@ import {
} from './kill-distance';
import { banNeeds, PANEL_BAN, type PanelBanParams } from './rule-ban';
import { RULE_KILL, RULE_KILL_NEEDS, type RuleKillParams } from './rule-kill';
-import { causeLabel } from '$lib/causes';
+import { causeLabel, isVehicleCrash, notCountedLabel, VEHICLE_CRASH } from '$lib/causes';
import {
emptyTwoTeamsState,
teamName,
@@ -2031,11 +2031,18 @@ export async function dryRun(
const c = cfg as TeamKillConfig;
// Each team kill in the window, with the killer's running count in the match it arrived in:
// the rows carrying that match from two minutes before it opened (killsOfMatch), else, for
- // a kill that came in with no match open, the other such kills of the hour before. A kill by
- // a cause the rule does not count adds to no count and comes back with none.
- const notCounted = JSON.stringify(teamKillNotCounted(c).map((x) => x.toLowerCase()));
- const counts = (cause: SQL) =>
- sql`(${cause} IS NULL OR lower(${cause}) NOT IN (SELECT jsonb_array_elements_text(${notCounted}::text::jsonb)))`;
+ // a kill that came in with no match open, the other such kills of the hour before. A kill the
+ // rule does not count (by its cause, or a crash) adds to no count and comes back with none.
+ const listed = teamKillNotCounted(c).map((x) => x.toLowerCase());
+ const notCounted = JSON.stringify(listed);
+ const crashes = listed.includes(VEHICLE_CRASH.toLowerCase());
+ // countsForTeamKill over the kill row `k`, with isVehicleCrash in SQL: no cause is no crash
+ const counts = (k: SQL) =>
+ sql`(${k}.cause IS NULL OR lower(${k}.cause) NOT IN (SELECT jsonb_array_elements_text(${notCounted}::text::jsonb)))${
+ crashes
+ ? sql` AND NOT COALESCE(${k}.cause ILIKE 'Vehicle.%' AND ${k}.tags ? ${VEHICLE_CRASH}::text, false)`
+ : sql``
+ }`;
const rows = await env.db.execute<{
ts: Date;
killerName: string;
@@ -2043,15 +2050,16 @@ export async function dryRun(
victimName: string;
map: string | null;
cause: string | null;
+ tags: string[];
n: string | null;
}>(sql`
SELECT k.ts, k.killer_name AS "killerName", k.killer_steam_id AS "killerSteamId",
- k.victim_name AS "victimName", k.map, k.cause,
- CASE WHEN ${counts(sql`k.cause`)} THEN
+ k.victim_name AS "victimName", k.map, k.cause, k.tags,
+ CASE WHEN ${counts(sql`k`)} THEN
(SELECT COUNT(*) FROM kills k2
WHERE k2.event_type = 'killed' AND k2.parsed_kill
AND k2.server_id = k.server_id AND k2.killer_steam_id = k.killer_steam_id
- AND k2.team_kill AND ${counts(sql`k2.cause`)} AND k2.ts <= k.ts
+ AND k2.team_kill AND ${counts(sql`k2`)} AND k2.ts <= k.ts
AND k2.match_row IS NOT DISTINCT FROM k.match_row
AND k2.ts >= COALESCE((SELECT m.started_at - interval '2 minutes' FROM matches m
WHERE m.id = k.match_row AND m.server_id = k.server_id),
@@ -2065,7 +2073,10 @@ export async function dryRun(
const left = new Map();
for (const r of rows) {
if (r.n === null) {
- const label = causeLabel(r.cause);
+ const label =
+ crashes && isVehicleCrash({ cause: r.cause, tags: r.tags })
+ ? notCountedLabel(VEHICLE_CRASH)
+ : causeLabel(r.cause);
left.set(label, (left.get(label) ?? 0) + 1);
continue;
}
diff --git a/src/test/access.test.ts b/src/test/access.test.ts
index 3bf50150..bd29fdf0 100644
--- a/src/test/access.test.ts
+++ b/src/test/access.test.ts
@@ -520,7 +520,11 @@ describe.skipIf(!hasTestDb)('access', () => {
['name_filter', { characters: 'ascii' }, 'players.kick'],
['name_filter', { characters: 'ascii', action: 'alert' }, 'players.kick'],
['team_kill', { kickAt: 3 }, 'players.kick'],
- ['team_kill', { kickAt: 3, notCounted: ['Id.Item.Claymore'] }, 'players.kick'],
+ [
+ 'team_kill',
+ { kickAt: 3, notCounted: ['Id.Item.Claymore', 'VehicleExplosion'] },
+ 'players.kick'
+ ],
['kill_rate', { maxKills: 20 }, 'players.kick'],
['kill_distance', { causes: [DEFIB], action: 'flag' }, 'players.kick'],
['kill_distance', { causes: [DEFIB], action: 'warn' }, 'chat.send'],
diff --git a/src/test/team-kill-rule.test.ts b/src/test/team-kill-rule.test.ts
index 83cdd11e..4a3d16cf 100644
--- a/src/test/team-kill-rule.test.ts
+++ b/src/test/team-kill-rule.test.ts
@@ -3,9 +3,9 @@
// does not start it over. A batch counts up to itself, even when it is acted on after its match
// closed or after later batches came in; one that came in with no match open counts that server's
// such kills of the hour before. Team kills by a cause the rule leaves out (barbed wire, unless its
-// list says otherwise) stay team kills but add to no count and are never acted on. The dry run
-// replays the same count, and rules saved with the old default texts are moved to the new ones by
-// migration 0035.
+// list says otherwise), and crashes when its list holds them, stay team kills but add to no count
+// and are never acted on. The dry run replays the same count, and rules saved with the old default
+// texts are moved to the new ones by migration 0035.
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { randomUUID } from 'node:crypto';
import { and, asc, eq, sql } from 'drizzle-orm';
@@ -27,7 +27,14 @@ const BUILDER = { id: '76561198000000505', name: 'Builder' };
const FENCER = { id: '76561198000000506', name: 'Fencer' };
const SAPPER = { id: '76561198000000507', name: 'Sapper' };
const MIXER = { id: '76561198000000508', name: 'Mixer' };
+const PILOT = { id: '76561198000000509', name: 'Pilot' };
+const BLASTER = { id: '76561198000000510', name: 'Blaster' };
+const DRIVER = { id: '76561198000000511', name: 'Driver' };
+const GUNNER = { id: '76561198000000512', name: 'Gunner' };
const WIRE = 'Id.Buildable.BarbedWire';
+const HELI = 'Vehicle.Variant.Air.Rotary.Littlebird.Default';
+/** the feed's tag on a death in a vehicle's blast, and the list's entry for crashes */
+const EXPLOSION = 'VehicleExplosion';
const MIN = 60_000;
const ago = (ms: number) => new Date(Date.now() - ms);
@@ -35,7 +42,8 @@ const ago = (ms: number) => new Date(Date.now() - ms);
const eventOf = (
killer: typeof STAYER,
victim: typeof STAYER,
- cause: string | null = 'Id.Item.AK74M'
+ cause: string | null = 'Id.Item.AK74M',
+ tags: string[] = []
) => ({
eventId: randomUUID(),
type: 'killed',
@@ -48,13 +56,18 @@ const eventOf = (
victimSteamId: victim.id,
cause,
distance: 3000,
- contextTags: []
+ contextTags: tags.map((t) => `Meta.Progression.Context.Player.KillContext.${t}`)
});
/** One feed batch with one kill, as the game posts it. */
-const batchOf = (killer: typeof STAYER, victim: typeof STAYER, cause?: string | null) => ({
+const batchOf = (
+ killer: typeof STAYER,
+ victim: typeof STAYER,
+ cause?: string | null,
+ tags?: string[]
+) => ({
serverId: randomUUID(),
serverName: 'Test',
- events: [eventOf(killer, victim, cause)]
+ events: [eventOf(killer, victim, cause, tags)]
});
describe.skipIf(!hasTestDb)('Team kill limit, live', () => {
@@ -107,15 +120,20 @@ describe.skipIf(!hasTestDb)('Team kill limit, live', () => {
serverId: string,
killer: typeof STAYER,
now = new Date(),
- cause?: string | null
+ cause?: string | null,
+ tags?: string[]
) => {
- const r = await ingestBatch(env, serverId, batchOf(killer, MATE, cause), now);
+ const r = await ingestBatch(env, serverId, batchOf(killer, MATE, cause, tags), now);
expect(r.kills.map((k) => k.teamKill)).toEqual([true]);
return r.kills;
};
/** ...and handed to the rules at once, as the worker does. */
- const arrives = async (serverId: string, killer: typeof STAYER, cause?: string | null) =>
- onKillsIngested(env, serverId, await receive(serverId, killer, new Date(), cause));
+ const arrives = async (
+ serverId: string,
+ killer: typeof STAYER,
+ cause?: string | null,
+ tags?: string[]
+ ) => onKillsIngested(env, serverId, await receive(serverId, killer, new Date(), cause, tags));
/** A rule as saved; without `extra` it is one saved before the list of causes not counted existed. */
const ruleOn = async (serverId: string, extra: Record = {}) => {
@@ -179,6 +197,10 @@ describe.skipIf(!hasTestDb)('Team kill limit, live', () => {
session(w.server.id, FENCER, 5 * MIN),
session(w.server.id, SAPPER, 5 * MIN),
session(w.server.id, MIXER, 5 * MIN),
+ session(w.server.id, PILOT, 5 * MIN),
+ session(w.server.id, BLASTER, 5 * MIN),
+ session(w.server.id, DRIVER, 5 * MIN),
+ session(w.server.id, GUNNER, 5 * MIN),
session(w.otherServer.id, STAYER, 125 * MIN),
session(w.otherServer.id, MATE, 125 * MIN)
]);
@@ -363,6 +385,55 @@ describe.skipIf(!hasTestDb)('Team kill limit, live', () => {
]);
});
+ test('a rule that leaves out crashes: a pilot who crashes with a teammate aboard is not counted, a teammate who blows a vehicle up or runs one over is', async () => {
+ const crashes = await ruleOn(w.server.id, { notCounted: [WIRE, EXPLOSION] });
+ invalidateTriggers(w.server.id);
+ await arrives(w.server.id, PILOT, HELI, [EXPLOSION]);
+ expect(await actionsOn(crashes, PILOT.id)).toEqual([]);
+ // a rule without it whispers the pilot, as before
+ expect(await actionsOn(here, PILOT.id)).toEqual([['whisper', 1]]);
+ // and the crash adds nothing to the pilot's next team kill
+ await arrives(w.server.id, PILOT);
+ expect(await actionsOn(crashes, PILOT.id)).toEqual([['whisper', 1]]);
+ await arrives(w.server.id, BLASTER, 'Id.Item.C4Explosive', [EXPLOSION]);
+ await arrives(w.server.id, DRIVER, HELI, ['RoadKill']);
+ // a kill in a blast with no cause is no vehicle's own
+ await arrives(w.server.id, GUNNER, null, [EXPLOSION]);
+ expect(await actionsOn(crashes, BLASTER.id)).toEqual([['whisper', 1]]);
+ expect(await actionsOn(crashes, DRIVER.id)).toEqual([['whisper', 1]]);
+ expect(await actionsOn(crashes, GUNNER.id)).toEqual([['whisper', 1]]);
+ await env.db.update(triggers).set({ enabled: false }).where(eq(triggers.id, crashes));
+ invalidateTriggers(w.server.id);
+ });
+
+ test('the dry run leaves out crashes the same way, and says how many', async () => {
+ const server = { ...w.server, name: 'one' } as Parameters[1];
+ const of = (r: Awaited>) =>
+ r.items
+ .map((i) => i.text)
+ .filter((t) => /^(whisper|kick) (Pilot|Blaster|Driver|Gunner)\b/.test(t));
+ const r = await dryRun(env, server, 'team_kill', {
+ warnAt: 1,
+ kickAt: 3,
+ notCounted: [EXPLOSION]
+ });
+ expect(of(r)).toEqual([
+ 'whisper Pilot: Careful, Pilot: that was a team kill (1 this match).',
+ 'whisper Blaster: Careful, Blaster: that was a team kill (1 this match).',
+ 'whisper Driver: Careful, Driver: that was a team kill (1 this match).',
+ 'whisper Gunner: Careful, Gunner: that was a team kill (1 this match).'
+ ]);
+ expect(r.notes).toContain('Not counted: 1 by Vehicle crash.');
+ const all = await dryRun(env, server, 'team_kill', { warnAt: 1, kickAt: 3, notCounted: [] });
+ expect(of(all)).toEqual([
+ 'whisper Pilot: Careful, Pilot: that was a team kill (1 this match).',
+ 'whisper Pilot: Careful, Pilot: that was a team kill (2 this match).',
+ 'whisper Blaster: Careful, Blaster: that was a team kill (1 this match).',
+ 'whisper Driver: Careful, Driver: that was a team kill (1 this match).',
+ 'whisper Gunner: Careful, Gunner: that was a team kill (1 this match).'
+ ]);
+ });
+
test('migration 0035 moves the old default texts to "this match" and leaves written ones', async () => {
const saved = (config: Record) => ({
id: newId(),
From 3cf0e6fbffc1a034d7a0ded72b9da596cb74a4a7 Mon Sep 17 00:00:00 2001
From: James Causon
Date: Thu, 8 Oct 2026 20:31:53 +0100
Subject: [PATCH 056/102] Automation tab: Vehicle crash under Not counted
---
.../(app)/server/[id]/automation/+page.svelte | 24 +++++++++++++++----
1 file changed, 20 insertions(+), 4 deletions(-)
diff --git a/src/routes/(app)/server/[id]/automation/+page.svelte b/src/routes/(app)/server/[id]/automation/+page.svelte
index 05f49bf0..6ab6ef95 100644
--- a/src/routes/(app)/server/[id]/automation/+page.svelte
+++ b/src/routes/(app)/server/[id]/automation/+page.svelte
@@ -5,7 +5,14 @@
import { MAX_CHAT } from '$lib/chat';
import { fmtAgo, fmtSpan, fmtTime, mapLabel } from '$lib/format';
import { can } from '$lib/capabilities';
- import { causeKind, causeLabel, knownCauses, TEAM_KILL_NOT_COUNTED } from '$lib/causes';
+ import {
+ causeKind,
+ causeLabel,
+ knownCauses,
+ notCountedLabel,
+ TEAM_KILL_NOT_COUNTED,
+ VEHICLE_CRASH
+ } from '$lib/causes';
import { placeholdersFor, unfilled, type PlaceholderGroupKey } from '$lib/placeholders';
import { isSteamId } from '$lib/steam-profiles';
import { toast } from '$lib/toast.svelte';
@@ -226,9 +233,18 @@
const openAtFirst = (open: () => boolean) => (node: HTMLDetailsElement) => {
node.open = untrack(open);
};
- /** What a Team kill limit can leave out of its count: things placed that a teammate runs into. */
+ /**
+ * What a Team kill limit can leave out of its count: things placed that a teammate runs into,
+ * and crashes.
+ */
const notCountedChoices = (chosen: string[]) =>
- causeChoices((c) => causeKind(c) === 'buildable' || PLACED.has(c.toLowerCase()), chosen);
+ [
+ ...causeChoices(
+ (c) => causeKind(c) === 'buildable' || PLACED.has(c.toLowerCase()),
+ chosen.filter((c) => !holds([VEHICLE_CRASH], c))
+ ),
+ { cause: VEHICLE_CRASH, label: notCountedLabel(VEHICLE_CRASH) }
+ ].sort((a, b) => a.label.localeCompare(b.label));
const holds = (list: string[], cause: string) =>
list.some((c) => c.toLowerCase() === cause.toLowerCase());
/** A kind that lacks what it needs stays in the menu, greyed, with the reason in a few words. */
@@ -904,7 +920,7 @@
left.length > 3
? `${left.length} causes not counted`
: left.length
- ? `${left.map((x) => causeLabel(x)).join(', ')} not counted`
+ ? `${left.map((x) => notCountedLabel(x)).join(', ')} not counted`
: ''
]
.filter(Boolean)
From 8a885c83795da4dcdb153b6590fdbe951be54e85 Mon Sep 17 00:00:00 2001
From: James Causon
Date: Thu, 8 Oct 2026 20:31:53 +0100
Subject: [PATCH 057/102] README: vehicle crashes in the Team kill limit
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index c30893c4..9f3d993e 100644
--- a/README.md
+++ b/README.md
@@ -603,7 +603,7 @@ delete them.
| Name filter | Kicks joiners whose name breaks the rule, or with _Alert only_ just records them (audit trail and Discord). A character policy: any, Latin letters (keeps José and Müller, optionally with Cyrillic, Greek, Arabic, Hebrew, Thai, Devanagari, Chinese, Japanese or Korean beside them) or ASCII only; digits, spaces and keyboard punctuation always pass, emoji and symbols only when allowed, and a name can be required to hold N letters. Blocked words: a built-in English list of slurs and hate terms, your own words (up to 200) and exceptions for names that would match but are fine. Words are caught through case, leetspeak, look-alike letters, stretching and spelling out (`n.a.z.i`). The kick reason's `{why}` says what kind of fault it was, never the word. Reserved-slot players can be spared. Checked at the join and again whenever the name changes, clan tag included: the game can show the tag a moment after the player is in. A kick rule also checks a player who reconnects. Its dry run checks everyone who has played on the server, under each name they used. |
| Name change watch | Flags a player who changes their name mid-game, or with _Kick_ kicks them (a reserved slot can be flagged instead). The game's player list keeps the name a player joined with (a clan tag changed mid-game aside), while the [kill feed](#kill-feed) names both players of every kill as the others see them: each time a player kills or dies, the rule holds the name the feed shows against the one the list has for them. Counts every change, or only a change to the name of another player on the server. Names are compared as they read: case, spacing, punctuation, symbols, invisible characters, look-alike letters, I/l/1 and 0/O are no disguise, nor is a clan tag in brackets dropped or added to a copied name (names of five letters or more); a player's own name with a clan tag put on, taken off or swapped is not a change. Acts at N changes within M minutes (up to two hours): an alert flags a player once a window, a kick acts at every count, and a kicked player who comes back under the taken name goes again. In the kick reason `{name}` is the name the feed showed and `{previous}` the one they are listed under. Flags go to the audit trail and Discord with the name they are listed under and their SteamID (the post opens the player's page). Changing or switching it off drops what it had queued. Needs the kill feed; the worker's memory of names starts over when it restarts. The dry run holds the day's kills against the names of the sessions open at each. |
| High ping kick | Kicks a player whose reported ping remains above a configurable limit for a configurable number of seconds. Normal or unavailable ping, leaving, or interrupted player-list polling resets the timer. Historical ping is not stored, so this rule cannot be replayed in a dry run. |
-| Team kill limit | Whispers a player from N team kills in one match, and kicks them at M; leaving and rejoining does not start the count over. Team kills by what the rule leaves out are not counted and still show as team kills everywhere else: barbed wire unless unticked (a player who runs into a teammate's wire is reported as killed by whoever built it), and any other buildable or placed charge ticked. Needs the [kill feed](#kill-feed); acted on as each kill arrives, not per poll. |
+| Team kill limit | Whispers a player from N team kills in one match, and kicks them at M; leaving and rejoining does not start the count over. Team kills by what the rule leaves out are not counted and still show as team kills everywhere else: barbed wire unless unticked (a player who runs into a teammate's wire is reported as killed by whoever built it), any other buildable or placed charge ticked, and vehicle crashes when ticked (when a vehicle blows up, a helicopter flown into the ground say, the game reports everyone aboard as killed by its driver or pilot; a teammate who blows a vehicle up with a charge, rocket or gun is still counted). Needs the [kill feed](#kill-feed); acted on as each kill arrives, not per poll. |
| Kill rate watch | Flags a player, never kicks, when their kills with hand-held weapons in the last N minutes reach a count, or their share of headshots over those kills reaches a percentage once they have at least M; vehicles, their guns and buildables are not counted. The flag goes to the audit trail and the Discord mirror (the post opens the player's page in the panel), and the same player is flagged again only after a cooldown. Needs the [kill feed](#kill-feed), which has no position or aim, so a flag is a reason to look, not proof. The windows are kept in the worker's memory and start over when it restarts. |
| Kill distance watch | Acts on a player whose kills with the chosen weapons come from at least N metres away, M of them in one match: a defibrillator or a fist reaches a few metres, so the same kill from across the map is a player the game did not stop. The weapons are offered in three lists, as the kill feed names them: hand-held (a new rule starts with the defibrillator; charges that are placed and set off from anywhere are not offered), the vehicles' guns (a Humvee's M249 or minigun turret), and the vehicles themselves (a roadkill, or a vehicle blown up with its crew inside). From 0 m every kill with them counts, one the feed sends without a distance too, so a server can act on each kill with a weapon or a vehicle it does not allow: an infantry-only server can act on whoever kills from a Humvee's turret. It flags them (audit trail and the Discord mirror, whose post opens the player's page), warns them (the rule's text whispered to them, mirrored the same way; a blank text says the weapon is not allowed on this server), kills them and whispers them the rule's text (so a server can answer each Humvee kill with the killer's death: the feed only reports kills, so a player who drives one without killing anybody is not seen), kicks them, or bans them for N days or for good on this server's own ban list or the organisation's, like a ban by hand: every server the ban reaches takes its lists again at once and removes them at its next look, and the ban is lifted from the list. A shorter ban already on that list is made to last as long. A killed, kicked or banned player is left alone for a minute while that lands, so a roadkill of three is one kill; one who does it again after that, that match, is acted on at their next such kill. A flag or a warning comes again in the same match only after the rule's cooldown, and at their first such kill in the next. A warning, a kill and a kick need the player still on when they are sent, and a kill goes within half a minute or not at all (one sent later would land on them doing something else, or on the next map). A kill the game refuses (no living character, presumably) still sends the whisper; with that refused too, the action fails. Two rules that kill on the same weapon each kill. To warn first and kick after, make two rules on the same weapons, the kick's count higher. Needs the [kill feed](#kill-feed), whose distance is between killer and victim; acted on as each kill arrives, and counted per match as the Team kill limit counts. A kill that comes within a minute of the killer's own death is not counted: the player is dead, so it is a round already on its way, and the game credits a vehicle's shell that lands after the vehicle was destroyed to the gunner's own weapon, from wherever the gunner is by then (an M4 kill from 2,300 m). The audit trail notes each kill left out this way, with how long after the death it came; Discord is not told. Editing, switching off or deleting the rule starts the counts over and drops what it had queued. The counts are kept in the worker's memory and start over when it restarts. The rule believes the feed: whoever holds a server's feed token can make it act, so keep bans on every server for rules on servers whose host you trust. |
| Team balance | Keeps the open sides within a set number of players of each other (3 by default), closes one faction, or both. Nobody who is playing is moved mid-match: balancing places a player when they arrive (on the side they picked while that keeps within the gap, else on the lighter one), puts back a player who switches onto a side more than the gap bigger than the one they left, and evens everyone up when a new match starts (a map change or the scores starting over). Whoever is on when the rule is switched on or changed, after the worker restarts, when the server comes back from being out of reach, or after two minutes (three looks, at a slower player list cadence) without a look at the player list, counts as placed until the next match, so it never reshuffles the match it walks in on; starting over also forgets who was told and the asks. The side a player was placed on is kept for ten minutes away, so leaving and rejoining on the bigger side is put back too. Optional: keep clan tags together (a short tag in brackets at the front of the name; a preference that yields to the gap; when a new match is evened up, players whose clan is mostly on the other side move first, then those with the fewest clanmates beside them), and SteamIDs it never moves (staff who switch sides themselves). A move made from the Players tab or the API is kept, not put back; one made anywhere else (the game's own console, another balancer) is put back like a switch, so run no other balancer beside it. Right after a match end, placing waits up to 30 seconds from the players' return (the list is empty while the next map loads) while a quarter or more of them have not picked a side, and whom it moves is not decided by list order. Everyone on the closed faction is moved to the smaller other side (or their clan's, within the gap). Every move is the game's own move, sent without the kill the Players tab's Move adds. Moves go out a few at a time as the player list refreshes, so a full server's sort takes about half a minute; moves in flight count toward their side, and one not seen landed within 30 seconds is decided again; a queued move goes out only while the rule still holds that decision, so a retry, a new match or a person moving the player drops it. A player who switches onto the bigger side is put back every time; a player asked to move three times in ten minutes for any other reason, or put back three times without it taking, is left where they are until the ten minutes pass. Watch only lists each move it would make under Actions, as skipped, and moves nobody. Optional names for the sides (e.g. Red and Green) and a whisper, `{team}`, sent once to each player it moves (again after two hours away). One rule per server; changing or switching it off drops its queued moves. Its moves reach Discord only when they fail. What it remembers is the worker's: a restart forgets the moves in flight, who was told, the asks and the sides. A rule saved before balancing existed only closes its faction until balancing is ticked. Nothing to dry-run: faction moves are not in the session history; use Watch only. |
From 48792980731c43cfcf6a9d2a093e24003f285c8e Mon Sep 17 00:00:00 2001
From: James Causon
Date: Fri, 9 Oct 2026 00:09:51 +0100
Subject: [PATCH 058/102] Bounty rule: a kill streak puts a bounty on a player
- No new route: saving needs Automation and Chat, plus Reserved slots
or Org reserved slots for a slot reward (requireRuleCaps)
- Bounties webhook class posts player-facing cards, no SteamIDs
- No migration: the open bounty lives in triggers.state
---
src/lib/placeholders.ts | 15 +-
src/lib/rule-kinds.ts | 7 +
src/lib/server/bounty.test.ts | 272 +++++++++++++
src/lib/server/bounty.ts | 334 ++++++++++++++++
src/lib/server/db/schema.ts | 3 +-
src/lib/server/feed-events.ts | 249 +++++++++++-
src/lib/server/observe.ts | 6 +
src/lib/server/outbox.ts | 86 +++-
src/lib/server/poller.ts | 2 +
src/lib/server/trigger-rules.ts | 12 +-
src/lib/server/triggers.ts | 324 ++++++++++++++--
src/lib/server/webhook-delivery.ts | 90 ++++-
src/lib/types.ts | 5 +-
src/test/access.test.ts | 73 +++-
src/test/bounty-rule.test.ts | 604 +++++++++++++++++++++++++++++
15 files changed, 2040 insertions(+), 42 deletions(-)
create mode 100644 src/lib/server/bounty.test.ts
create mode 100644 src/lib/server/bounty.ts
create mode 100644 src/test/bounty-rule.test.ts
diff --git a/src/lib/placeholders.ts b/src/lib/placeholders.ts
index 4f51d5ed..fe3f2d52 100644
--- a/src/lib/placeholders.ts
+++ b/src/lib/placeholders.ts
@@ -61,8 +61,9 @@ interface MessageRule {
fields: readonly string[];
own: readonly string[];
player: boolean;
- /** its text can be kept where staff read it (a Kill distance reason becomes a ban reason), so it
- * has no org-wide stats: staff of this server may not see the organisation's other servers */
+ /** its text can be kept where staff read it (a Kill distance reason becomes a ban reason), or is
+ * said to the whole server (a Bounty's), so it has no org-wide stats: staff of this server may
+ * not see the organisation's other servers, and a player's are told to them alone */
kept?: boolean;
}
@@ -92,7 +93,15 @@ export const MESSAGE_RULES: Readonly>>
seed_reward: { fields: ['message'], own: ['minutes', 'until', 'days'], player: true },
afk_protection: { fields: ['message', 'doneMessage'], own: ['goal'], player: false },
// the name the player had before the change
- name_change: { fields: ['reason'], own: ['previous'], player: true }
+ name_change: { fields: ['reason'], own: ['previous'], player: true },
+ // said to the whole server, so no org-wide stats: `{name}` is the marked player when the bounty is
+ // set and the claimer after, `{target}` always the marked player
+ bounty: {
+ fields: ['setMessage', 'claimMessage', 'whisper'],
+ own: ['target', 'streak', 'reward', 'days', 'until'],
+ player: true,
+ kept: true
+ }
};
export type PlaceholderGroupKey = 'own' | 'player' | 'stats' | 'org' | 'server';
diff --git a/src/lib/rule-kinds.ts b/src/lib/rule-kinds.ts
index e542b1d8..9cc06087 100644
--- a/src/lib/rule-kinds.ts
+++ b/src/lib/rule-kinds.ts
@@ -92,6 +92,13 @@ export const RULE_KINDS: { kind: TriggerKind; group: RuleGroup; label: string; b
label: 'Seeding reward',
blurb: 'Give players who stay while the server is quiet a reserved slot.'
},
+ {
+ kind: 'bounty',
+ group: 'Players',
+ label: 'Bounty',
+ blurb:
+ 'Put a bounty on a player on a kill streak; whoever kills them first gets a reserved slot.'
+ },
{
kind: 'afk_protection',
group: 'Players',
diff --git a/src/lib/server/bounty.test.ts b/src/lib/server/bounty.test.ts
new file mode 100644
index 00000000..256893e6
--- /dev/null
+++ b/src/lib/server/bounty.test.ts
@@ -0,0 +1,272 @@
+import { describe, expect, test } from 'bun:test';
+import {
+ bountyReplay,
+ bountyState,
+ bountyStep,
+ bountyTrack,
+ bountyVersion,
+ claims,
+ forgetBounties,
+ fromEarlierMatch,
+ keepBounties,
+ lapseWords,
+ rewardWords,
+ savedBounty,
+ validateBounty,
+ type BountyKill,
+ type BountyPlayer,
+ type BountyTrack
+} from './bounty';
+
+const RED = 'Valkyra';
+const BLUE = 'Lonestar';
+const p = (n: number, faction: string | null = RED): BountyPlayer => ({
+ steamId: `7656119800000${String(n).padStart(4, '0')}`,
+ name: `Player ${n}`,
+ faction
+});
+const A = p(1, RED);
+const B = p(2, BLUE);
+const C = p(3, BLUE);
+const D = p(4, RED);
+let seq = 0;
+const kill = (
+ killer: BountyPlayer | null,
+ victim: BountyPlayer,
+ o: { suicide?: boolean; teamKill?: boolean } = {}
+): BountyKill => ({
+ eventId: `e${++seq}`,
+ killer,
+ victim,
+ suicide: !!o.suicide,
+ teamKill: !!o.teamKill
+});
+const fresh = (): BountyTrack => ({ runs: new Map(), open: null });
+const step = (track: BountyTrack, kills: BountyKill[], canMark = true, streak = 3) =>
+ bountyStep({ streak }, track, kills, { canMark, now: 1000 });
+
+describe('the Bounty rule, settings', () => {
+ test('defaults, clamps and the announcement a blank one gets', () => {
+ const d = validateBounty({});
+ expect(d).toEqual({
+ streak: 15,
+ minPlayers: 20,
+ reward: 'slot',
+ slotDays: 1,
+ scope: 'server',
+ setMessage: 'BOUNTY on {name}: {streak} kills without dying. Kill them for {reward}.',
+ claimMessage: '',
+ whisper: ''
+ });
+ const c = validateBounty({
+ streak: 1,
+ minPlayers: -5,
+ reward: 'none',
+ slotDays: 9999,
+ scope: 'org',
+ setMessage: ' ',
+ claimMessage: 'x'.repeat(400)
+ });
+ expect([c.streak, c.minPlayers, c.reward, c.slotDays, c.scope]).toEqual([
+ 3,
+ 0,
+ 'none',
+ 365,
+ 'org'
+ ]);
+ expect(c.setMessage).toBe('BOUNTY on {name}: {streak} kills without dying. Who ends the run?');
+ expect(c.claimMessage).toHaveLength(256);
+ // anything but 'none' is a slot, anything but 'org' this server's list
+ expect(validateBounty({ reward: 'role', scope: 'all' })).toMatchObject({
+ reward: 'slot',
+ scope: 'server'
+ });
+ });
+
+ test('the reward in words', () => {
+ expect(rewardWords({ reward: 'slot', slotDays: 1 })).toBe('a reserved slot for 1 day');
+ expect(rewardWords({ reward: 'slot', slotDays: 3 })).toBe('a reserved slot for 3 days');
+ expect(rewardWords({ reward: 'none', slotDays: 3 })).toBe('');
+ });
+});
+
+describe('the Bounty rule, runs and bounties', () => {
+ test('a run of kills without dying puts a bounty on the player at the count', () => {
+ const t = fresh();
+ expect(step(t, [kill(A, B), kill(A, C)])).toEqual([]);
+ expect(t.runs.get(A.steamId)).toBe(2);
+ const [set] = step(t, [kill(A, B)]);
+ expect(set).toMatchObject({ kind: 'set', bounty: { steamId: A.steamId, streak: 3, best: 3 } });
+ expect(t.open?.steamId).toBe(A.steamId);
+ // more kills while marked grow the run the claim will name
+ step(t, [kill(A, C), kill(A, B)]);
+ expect(t.open).toMatchObject({ streak: 3, best: 5 });
+ });
+
+ test('any death ends a run; a team kill or a suicide adds nothing to one', () => {
+ const t = fresh();
+ step(t, [kill(A, B), kill(A, C)]);
+ step(t, [kill(A, D, { teamKill: true })]);
+ expect(t.runs.get(A.steamId)).toBe(2);
+ step(t, [kill(null, A)]);
+ expect(t.runs.has(A.steamId)).toBe(false);
+ step(t, [kill(A, B), kill(A, C), kill(D, A, { teamKill: true })]);
+ expect(t.runs.has(A.steamId)).toBe(false);
+ step(t, [kill(A, B), kill(A, A, { suicide: true })]);
+ expect(t.runs.has(A.steamId)).toBe(false);
+ expect(t.open).toBeNull();
+ });
+
+ test('nobody is marked while too few are on; the run counts all the same', () => {
+ const t = fresh();
+ expect(step(t, [kill(A, B), kill(A, C), kill(A, B)], false)).toEqual([]);
+ expect(t.open).toBeNull();
+ // enough on now: marked at their next kill
+ expect(step(t, [kill(A, C)])[0]).toMatchObject({ kind: 'set', bounty: { streak: 4 } });
+ });
+
+ test('one bounty at a time: the next player on a run is marked at their next kill', () => {
+ const t = fresh();
+ step(t, [kill(A, B), kill(A, C), kill(A, B)]);
+ expect(step(t, [kill(B, D), kill(B, D), kill(B, D)])).toEqual([]);
+ expect(t.open?.steamId).toBe(A.steamId);
+ // C claims A's; B, on a run of 3 already, is marked at their next kill
+ const ended = step(t, [kill(C, A)]);
+ expect(ended.map((e) => e.kind)).toEqual(['claim']);
+ expect(step(t, [kill(B, D)])[0]).toMatchObject({
+ kind: 'set',
+ bounty: { steamId: B.steamId, streak: 4 }
+ });
+ });
+
+ test('only an enemy whose side is known claims it: not a teammate, a suicide or the environment', () => {
+ const t = fresh();
+ step(t, [kill(A, B), kill(A, C), kill(A, B)]);
+ const open = t.open!;
+ expect(step(t, [kill(D, A, { teamKill: true })])).toEqual([]);
+ expect(step(t, [kill(A, A, { suicide: true })])).toEqual([]);
+ expect(step(t, [kill(null, A)])).toEqual([]);
+ // a kill between players whose sides the panel has not seen is no claim
+ expect(step(t, [kill(p(9, null), A)])).toEqual([]);
+ expect(step(t, [kill(B, { ...A, faction: null })])).toEqual([]);
+ // a player the panel takes for an enemy (no team kill) but on the same side is no claim either
+ expect(claims(kill(D, A), open)).toBe(false);
+ expect(t.open).toBe(open);
+ const [claim] = step(t, [kill(B, A)]);
+ expect(claim).toMatchObject({ kind: 'claim', claimer: B, bounty: { steamId: A.steamId } });
+ expect(t.open).toBeNull();
+ });
+
+ test('the kill that claims it can put the next bounty on the claimer', () => {
+ const t = fresh();
+ step(t, [kill(A, B), kill(A, C), kill(A, B), kill(B, D), kill(B, D)]);
+ const out = step(t, [kill(B, A)]);
+ expect(out.map((e) => e.kind)).toEqual(['claim', 'set']);
+ expect(t.open).toMatchObject({ steamId: B.steamId, streak: 3 });
+ });
+});
+
+describe('the Bounty rule, lapses', () => {
+ test('what the panel says of each', () => {
+ const b = { name: 'Player 1', best: 17 };
+ expect(lapseWords(b, 'left')).toBe(
+ 'The bounty on Player 1 lapsed: they left the server (a run of 17).'
+ );
+ expect(lapseWords(b, 'match')).toBe(
+ 'The bounty on Player 1 lapsed: the match ended (a run of 17).'
+ );
+ expect(lapseWords(b, 'lost')).toBe(
+ 'The bounty on Player 1 lapsed: the server was out of reach (a run of 17).'
+ );
+ });
+
+ test('a bounty is from an earlier match only when both are matches the panel opened', () => {
+ expect(fromEarlierMatch('m12', 'm13')).toBe(true);
+ expect(fromEarlierMatch('m12', 'm12')).toBe(false);
+ // set or claimed while no match was open (a server's first seconds): it stands
+ expect(fromEarlierMatch('h480000', 'm13')).toBe(false);
+ expect(fromEarlierMatch('m12', 'h480001')).toBe(false);
+ expect(fromEarlierMatch('', 'm13')).toBe(false);
+ });
+});
+
+describe('the Bounty rule, its memory', () => {
+ test('an open bounty is read back only under the save it was set in', () => {
+ const row = { config: { streak: 15 }, updatedAt: new Date(5000) };
+ const version = bountyVersion(row);
+ const open = { ...A, streak: 15, best: 17, setAt: 4000, match: 'm12' };
+ const state = bountyState(version, open);
+ expect(savedBounty(state, version)).toEqual(open);
+ expect(savedBounty(state, bountyVersion({ ...row, updatedAt: new Date(6000) }))).toBeNull();
+ expect(savedBounty(state, bountyVersion({ ...row, config: { streak: 20 } }))).toBeNull();
+ expect(savedBounty(bountyState(version, null), version)).toBeNull();
+ expect(savedBounty(null, version)).toBeNull();
+ expect(savedBounty({ version, open: { steamId: 7 } }, version)).toBeNull();
+ });
+
+ test("a rule's track comes back from its row once, and starts over after a save", () => {
+ forgetBounties();
+ const row = {
+ id: 'rule-1',
+ serverId: 'server-1',
+ config: { streak: 3 },
+ updatedAt: new Date(1000),
+ state: null as unknown
+ };
+ const open = { ...A, streak: 3, best: 3, setAt: 900, match: 'm3' };
+ row.state = bountyState(bountyVersion(row), open);
+ const first = bountyTrack(row).track;
+ expect(first.open).toEqual(open);
+ first.runs.set(B.steamId, 2);
+ // the same rule: the memory, not the row
+ row.state = null;
+ expect(bountyTrack(row).track).toBe(first);
+ // saved again: started over, the old state not read back
+ row.updatedAt = new Date(2000);
+ row.state = bountyState(bountyVersion({ ...row, updatedAt: new Date(1000) }), open);
+ const after = bountyTrack(row).track;
+ expect(after).not.toBe(first);
+ expect(after.open).toBeNull();
+ expect(after.runs.size).toBe(0);
+ // a rule no longer on is dropped
+ keepBounties('server-1', new Set());
+ row.state = bountyState(bountyVersion(row), open);
+ expect(bountyTrack(row).track.open).toEqual(open);
+ forgetBounties('server-1');
+ });
+});
+
+describe('the Bounty rule, replayed', () => {
+ test('runs start over and an open bounty lapses at a new match; a leave lapses it too', () => {
+ const batch = (at: number, match: string, kills: BountyKill[], players = 50) => ({
+ kind: 'kills' as const,
+ at,
+ match,
+ players,
+ kills
+ });
+ const { outcomes, open } = bountyReplay({ streak: 3, minPlayers: 20 }, [
+ batch(1, 'm1', [kill(A, B), kill(A, C)]),
+ // a new match: A's run of 2 starts over
+ batch(2, 'm2', [kill(A, B), kill(A, C)]),
+ batch(3, 'm2', [kill(A, B)], 10),
+ batch(4, 'm2', [kill(A, C)]),
+ batch(5, 'm3', [kill(B, C)]),
+ batch(6, 'm3', [kill(B, C), kill(B, D), kill(B, D)]),
+ { kind: 'left', at: 7, steamId: B.steamId },
+ batch(8, 'm3', [kill(C, D), kill(C, D), kill(C, D), kill(C, D)]),
+ batch(9, 'm3', [kill(A, C)])
+ ]);
+ expect(outcomes.map((o) => [o.kind, o.at, o.bounty.steamId, 'why' in o ? o.why : ''])).toEqual([
+ // too few on at 3; marked at the next kill
+ ['set', 4, A.steamId, ''],
+ ['lapse', 5, A.steamId, 'match'],
+ ['set', 6, B.steamId, ''],
+ ['lapse', 7, B.steamId, 'left'],
+ ['set', 8, C.steamId, ''],
+ ['claim', 9, C.steamId, '']
+ ]);
+ expect(outcomes[0]).toMatchObject({ bounty: { streak: 4 }, players: 50 });
+ expect(open).toBeNull();
+ });
+});
diff --git a/src/lib/server/bounty.ts b/src/lib/server/bounty.ts
new file mode 100644
index 00000000..94b21561
--- /dev/null
+++ b/src/lib/server/bounty.ts
@@ -0,0 +1,334 @@
+// The Bounty rule's own part: who is on a run, when a run puts a bounty on a player, which kill
+// claims it, and the worker's memory of both. A run is the match page's Best streak (feedRecord): a
+// kill of another player that is not a team kill or a suicide adds to the killer's run, any death
+// ends the victim's. One bounty is open on a server at a time; the first enemy to kill the marked
+// player claims it, and a team kill, a suicide or the environment never does, so nobody clears
+// their own or has a friend do it. It lapses when the player leaves, the match ends, or the panel
+// loses sight of the server (it cannot tell what happened meanwhile). No database
+// and no game server here: the live path (feed-events.ts for kills, triggers.ts for leaves and match
+// ends) and the dry run (triggers.ts) run the same steps.
+import { ApiError, int, str } from './http';
+import { settingsFingerprint } from './fingerprint';
+import { MAX_CHAT } from '$lib/chat';
+
+/**
+ * The outbox action of a claimed bounty's reserved slot: a panel action, the player goes on a
+ * reserved list.
+ */
+export const BOUNTY_REWARD = 'bounty_reward';
+/**
+ * The outbox actions that note a bounty claimed and a bounty lapsing: panel actions whose audit row is
+ * the delivery, so the trail says who claimed it whatever the rule announces.
+ */
+export const BOUNTY_CLAIM = 'bounty_claim';
+export const BOUNTY_LAPSE = 'bounty_lapse';
+
+export type BountyReward = 'slot' | 'none';
+/** Where a claimed slot goes: this server's own reserved list, or the organisation's. */
+export type BountyScope = 'server' | 'org';
+
+export interface BountyConfig {
+ /** kills in a row without dying, in one match, that put a bounty on a player */
+ streak: number;
+ /** nobody is marked while fewer than this many are on */
+ minPlayers: number;
+ reward: BountyReward;
+ /** how long the claimer's reserved slot lasts */
+ slotDays: number;
+ scope: BountyScope;
+ /** broadcast when a bounty is set */
+ setMessage: string;
+ /** broadcast when it is claimed; '' for none */
+ claimMessage: string;
+ /** whispered to the claimer; '' for none */
+ whisper: string;
+}
+
+export const BOUNTY_SET_MESSAGE =
+ 'BOUNTY on {name}: {streak} kills without dying. Kill them for {reward}.';
+export const BOUNTY_SET_MESSAGE_PLAIN =
+ 'BOUNTY on {name}: {streak} kills without dying. Who ends the run?';
+
+/** What a rule's reward needs of whoever saves it: a slot here, or on the organisation's list. */
+export const bountyScope = (c: unknown): BountyScope =>
+ c && typeof c === 'object' && (c as Record).scope === 'org' ? 'org' : 'server';
+export const bountyReward = (c: unknown): BountyReward =>
+ c && typeof c === 'object' && (c as Record).reward === 'none' ? 'none' : 'slot';
+
+export function validateBounty(c: Record): BountyConfig {
+ const reward = bountyReward(c);
+ return {
+ streak: int(c.streak, 15, 3, 200),
+ minPlayers: int(c.minPlayers, 20, 0, 1000),
+ reward,
+ slotDays: int(c.slotDays, 1, 1, 365),
+ scope: bountyScope(c),
+ setMessage:
+ str(c.setMessage, MAX_CHAT) ||
+ (reward === 'slot' ? BOUNTY_SET_MESSAGE : BOUNTY_SET_MESSAGE_PLAIN),
+ claimMessage: str(c.claimMessage, MAX_CHAT),
+ whisper: str(c.whisper, MAX_CHAT)
+ };
+}
+
+const days = (n: number) => `${n} day${n === 1 ? '' : 's'}`;
+
+/** The reward in words, for `{reward}` and the panel's own lines; '' for none. */
+export const rewardWords = (cfg: Pick): string =>
+ cfg.reward === 'slot' ? `a reserved slot for ${days(cfg.slotDays)}` : '';
+
+/** One player of a kill as the rule reads them. */
+export interface BountyPlayer {
+ steamId: string;
+ name: string;
+ faction: string | null;
+}
+
+/** One kill as the rule reads it. */
+export interface BountyKill {
+ eventId: string;
+ killer: BountyPlayer | null;
+ victim: BountyPlayer;
+ suicide: boolean;
+ teamKill: boolean;
+}
+
+/** The bounty open on a server. */
+export interface OpenBounty extends BountyPlayer {
+ /** the run that put the bounty on them */
+ streak: number;
+ /** their longest run since it was set, for the claim (in memory; written with the next change) */
+ best: number;
+ /** when it was set (ms) */
+ setAt: number;
+ /**
+ * the match it was set in (matchKey of the batch), so one read back after a restart is not
+ * claimed in a later match; '' until known
+ */
+ match: string;
+}
+
+/** A rule's runs and its open bounty. */
+export interface BountyTrack {
+ runs: Map;
+ open: OpenBounty | null;
+}
+
+export type BountyEvent =
+ | { kind: 'set'; bounty: OpenBounty; kill: BountyKill }
+ | { kind: 'claim'; bounty: OpenBounty; claimer: BountyPlayer; kill: BountyKill };
+
+/** A kill that adds to the killer's run: of another player, not a team kill or a suicide. */
+const scores = (k: BountyKill): boolean =>
+ !!k.killer && !k.suicide && !k.teamKill && k.killer.steamId !== k.victim.steamId;
+
+/**
+ * A kill that claims the bounty on its victim: one that scores, by a player whose side is known and
+ * not the victim's. A kill between players whose sides the panel has not seen could be a friend's.
+ */
+export const claims = (k: BountyKill, open: Pick): boolean =>
+ k.victim.steamId === open.steamId &&
+ scores(k) &&
+ !!k.killer!.faction &&
+ !!k.victim.faction &&
+ k.killer!.faction !== k.victim.faction;
+
+/**
+ * Takes in kills in the order the game played them: a claim first (the marked player's death),
+ * then the victim's run ends and the killer's grows, and a run that reaches the rule's count while
+ * no bounty is open, with `canMark` (enough players on), puts one on the killer. The claimer can be
+ * marked by the very kill that claims. Moves `track` on and returns what happened.
+ */
+export function bountyStep(
+ cfg: Pick,
+ track: BountyTrack,
+ inOrder: BountyKill[],
+ at: { canMark: boolean; now: number }
+): BountyEvent[] {
+ const out: BountyEvent[] = [];
+ for (const k of inOrder) {
+ if (track.open && claims(k, track.open)) {
+ out.push({ kind: 'claim', bounty: track.open, claimer: k.killer!, kill: k });
+ track.open = null;
+ }
+ track.runs.delete(k.victim.steamId);
+ if (!scores(k)) continue;
+ const killer = k.killer!;
+ const run = (track.runs.get(killer.steamId) ?? 0) + 1;
+ track.runs.set(killer.steamId, run);
+ if (track.open?.steamId === killer.steamId) track.open.best = Math.max(track.open.best, run);
+ else if (!track.open && at.canMark && run >= cfg.streak) {
+ track.open = {
+ steamId: killer.steamId,
+ name: killer.name || killer.steamId,
+ faction: killer.faction,
+ streak: run,
+ best: run,
+ setAt: at.now,
+ match: ''
+ };
+ out.push({ kind: 'set', bounty: track.open, kill: k });
+ }
+ }
+ return out;
+}
+
+/**
+ * Why an open bounty lapses: its player left, the match ended, or the panel lost sight of the server
+ * (out of reach for a few looks, its own restart among them), after which it cannot tell either.
+ */
+export type BountyLapse = 'left' | 'match' | 'lost';
+
+/** What the panel says of a lapse. */
+export const lapseWords = (b: Pick, why: BountyLapse): string =>
+ `The bounty on ${b.name} lapsed: ${
+ why === 'left'
+ ? 'they left the server'
+ : why === 'match'
+ ? 'the match ended'
+ : 'the server was out of reach'
+ } (a run of ${b.best}).`;
+
+/**
+ * Whether a bounty set in match `set` is from before the match `now` (both matchKey): only two
+ * matches the panel opened are told apart, so a bounty set or claimed while none was open stands.
+ */
+export const fromEarlierMatch = (set: string, now: string): boolean =>
+ set.startsWith('m') && now.startsWith('m') && set !== now;
+
+// ---- the worker's memory ------------------------------------------------------------------------
+
+/**
+ * What a rule's open bounty and runs were kept under: its settings and its last save. Any save of the
+ * rule (switching it off and on included) calls off an open bounty and starts the runs over; the
+ * saved state of an older save is not read back.
+ */
+export const bountyVersion = (row: { config: unknown; updatedAt: Date | null }): string =>
+ `${settingsFingerprint(row.config)}:${row.updatedAt?.getTime() ?? 0}`;
+
+/** The rule's state as written to its row: the open bounty, under the version it was set in. */
+export interface BountyState {
+ version: string;
+ open: OpenBounty | null;
+}
+
+/** The open bounty a row's saved state holds for this version, if any. */
+export function savedBounty(state: unknown, version: string): OpenBounty | null {
+ const s = state as Partial | null;
+ if (!s || s.version !== version || !s.open || typeof s.open !== 'object') return null;
+ const o = s.open;
+ return typeof o.steamId === 'string' && typeof o.name === 'string'
+ ? {
+ steamId: o.steamId,
+ name: o.name,
+ faction: typeof o.faction === 'string' ? o.faction : null,
+ streak: Number(o.streak) || 0,
+ best: Number(o.best) || Number(o.streak) || 0,
+ setAt: Number(o.setAt) || 0,
+ match: typeof o.match === 'string' ? o.match : ''
+ }
+ : null;
+}
+
+export const bountyState = (version: string, open: OpenBounty | null): BountyState => ({
+ version,
+ open: open ? { ...open } : null
+});
+
+/**
+ * Each Bounty rule's runs and open bounty in this process's memory, by server and rule. The open
+ * bounty is also written to the rule's row as it changes, so a restart keeps the hunt and forgets
+ * only the runs. Kept only for servers with the rule on.
+ */
+const memory = new Map>();
+
+/** The rule's track, read back from its row's state the first time this process sees it. */
+export function bountyTrack(row: {
+ id: string;
+ serverId: string;
+ config: unknown;
+ state: unknown;
+ updatedAt: Date | null;
+}): { version: string; track: BountyTrack } {
+ const version = bountyVersion(row);
+ let mine = memory.get(row.serverId);
+ if (!mine) memory.set(row.serverId, (mine = new Map()));
+ let m = mine.get(row.id);
+ if (!m || m.version !== version) {
+ m = { version, track: { runs: new Map(), open: savedBounty(row.state, version) } };
+ mine.set(row.id, m);
+ }
+ return m;
+}
+
+/** Drops the tracks of a server's rules that are no longer on. */
+export function keepBounties(serverId: string, live: ReadonlySet): void {
+ const mine = memory.get(serverId);
+ if (!mine) return;
+ for (const id of mine.keys()) if (!live.has(id)) mine.delete(id);
+ if (!mine.size) memory.delete(serverId);
+}
+
+/** Forgets the tracks of one server (removed from the worker), or of every server. */
+export function forgetBounties(serverId?: string): void {
+ if (serverId) memory.delete(serverId);
+ else memory.clear();
+}
+
+// ---- the dry run ----------------------------------------------------------------------------------
+
+/** One thing the replay takes in, in the order it happened. */
+export type BountyReplayEvent =
+ | {
+ kind: 'kills';
+ at: number;
+ /** the match the batch was stamped with (matchKey) */
+ match: string;
+ /** players on when the batch came in */
+ players: number;
+ kills: BountyKill[];
+ }
+ | { kind: 'left'; at: number; steamId: string };
+
+export type BountyOutcome =
+ | { kind: 'set'; at: number; bounty: OpenBounty; players: number }
+ | { kind: 'claim'; at: number; bounty: OpenBounty; claimer: BountyPlayer }
+ | { kind: 'lapse'; at: number; bounty: OpenBounty; why: BountyLapse };
+
+/**
+ * The live rule over a stretch of history: batches through bountyStep, runs started over and the
+ * open bounty lapsed at each new match, and lapsed when its player's session closes.
+ */
+export function bountyReplay(
+ cfg: Pick,
+ events: BountyReplayEvent[]
+): { outcomes: BountyOutcome[]; open: OpenBounty | null } {
+ const track: BountyTrack = { runs: new Map(), open: null };
+ const outcomes: BountyOutcome[] = [];
+ let match: string | null = null;
+ for (const e of events) {
+ if (e.kind === 'left') {
+ if (track.open?.steamId === e.steamId) {
+ outcomes.push({ kind: 'lapse', at: e.at, bounty: track.open, why: 'left' });
+ track.open = null;
+ }
+ continue;
+ }
+ if (match !== null && e.match !== match) {
+ if (track.open) outcomes.push({ kind: 'lapse', at: e.at, bounty: track.open, why: 'match' });
+ track.open = null;
+ track.runs.clear();
+ }
+ match = e.match;
+ for (const x of bountyStep(cfg, track, e.kills, {
+ canMark: e.players >= cfg.minPlayers,
+ now: e.at
+ }))
+ outcomes.push(
+ x.kind === 'set'
+ ? { kind: 'set', at: e.at, bounty: { ...x.bounty }, players: e.players }
+ : { kind: 'claim', at: e.at, bounty: { ...x.bounty }, claimer: x.claimer }
+ );
+ }
+ return { outcomes, open: track.open };
+}
diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts
index d0b0a1e3..661a3d75 100644
--- a/src/lib/server/db/schema.ts
+++ b/src/lib/server/db/schema.ts
@@ -845,7 +845,8 @@ export const triggers = pgTable(
'two_teams',
'kill_distance',
'afk_protection',
- 'name_change'
+ 'name_change',
+ 'bounty'
]
}).notNull(),
name: text('name').notNull(),
diff --git a/src/lib/server/feed-events.ts b/src/lib/server/feed-events.ts
index 27438ee5..72f5f6d4 100644
--- a/src/lib/server/feed-events.ts
+++ b/src/lib/server/feed-events.ts
@@ -2,14 +2,15 @@
// event bus (the SSE route fans them to browsers; in the split roles every web process gets them
// through the relay stream), and the team-kill rules get their turn. Their intents go through
// the same outbox as every other trigger, so delivery, audit and the Discord mirror are shared.
-// The Kill rate, Kill distance and Name change rules see every batch; the rest of the work is for
-// batches with team kills.
+// The Kill rate, Kill distance, Name change and Bounty rules see every batch; the rest of the work is
+// for batches with team kills.
import { and, eq, gte, inArray, isNull, lte, sql } from 'drizzle-orm';
import type { Env } from './env';
import { emit } from './events';
import { kills, matches } from './db/schema';
import { killsOfMatch } from './matches';
import {
+ bountyLapse,
enabledTriggers,
killDistanceAct,
renderTemplate,
@@ -20,7 +21,7 @@ import {
type Evaluation,
type StatsRead
} from './triggers';
-import { messageVars, serverVars, type MessageVars } from './message-vars';
+import { keptVars, messageVars, serverVars, type MessageVars } from './message-vars';
import { mapName } from '$lib/format';
import { countsForTeamKill, MAX_REASON, type TeamKillConfig } from './trigger-rules';
import { MAX_CHAT } from '$lib/chat';
@@ -61,7 +62,20 @@ import { applyTriggerUpdates, enqueueIntents, wakeDelivery } from './outbox';
import { LostOwnership, withOwnedTransaction } from './leadership';
import { memoryOf } from './observe';
import { publicMessage } from './http';
-import { notifyTeamKills } from './webhook-delivery';
+import { notifyBounty, notifyTeamKills, type BountyCard } from './webhook-delivery';
+import {
+ BOUNTY_CLAIM,
+ BOUNTY_REWARD,
+ bountyState,
+ fromEarlierMatch,
+ bountyStep,
+ bountyTrack,
+ keepBounties,
+ rewardWords,
+ type BountyConfig,
+ type BountyKill,
+ type BountyPlayer
+} from './bounty';
import { isDemoServer } from './env';
import { drainMockFeed } from './mockgame';
import { ingestBatch } from './feed';
@@ -102,6 +116,12 @@ export async function onKillsIngested(
if (!(err instanceof LostOwnership))
console.warn(`[warcon] name-change rules on ${serverId}:`, publicMessage(err));
}
+ try {
+ await actOnBounty(env, serverId, kills, vars);
+ } catch (err) {
+ if (!(err instanceof LostOwnership))
+ console.warn(`[warcon] bounty rules on ${serverId}:`, publicMessage(err));
+ }
const teamKills = kills.filter((k) => k.teamKill && k.killer);
if (!teamKills.length) return;
const m = memoryOf(serverId);
@@ -544,6 +564,227 @@ async function actOnNameChange(
}
}
+/** A kill as the Bounty rule reads it. */
+const bountyKill = (k: KillView): BountyKill => ({
+ eventId: k.eventId,
+ killer: k.killer
+ ? { steamId: k.killer.steamId, name: k.killer.name, faction: k.killer.faction }
+ : null,
+ victim: { steamId: k.victim.steamId, name: k.victim.name, faction: k.victim.faction },
+ suicide: k.suicide,
+ teamKill: k.teamKill
+});
+
+const dateOf = (d: Date) => d.toISOString().slice(0, 10);
+
+/**
+ * The Bounty rule (one per server) over a batch: runs grow and end in the order the game played the
+ * kills, a run that reaches the rule's count while enough players are on and no bounty is open sets
+ * one (announced), and the first enemy kill of the marked player claims it (noted, announced when the
+ * rule says, the claimer whispered when it says, and the reserved slot queued). The open bounty is
+ * written to the rule's row with the actions; if they cannot be written, the bounty in memory goes
+ * back to what it was. Players of the Bounties channel get a card once it is written.
+ */
+async function actOnBounty(
+ env: Env,
+ serverId: string,
+ batch: KillView[],
+ vars: KillerVars
+): Promise {
+ const rows = (await enabledTriggers(env, serverId)).filter((r) => r.kind === 'bounty');
+ keepBounties(serverId, new Set(rows.map((r) => r.id)));
+ if (!rows.length) return;
+ const m = memoryOf(serverId);
+ // The most anyone says is on: a status a few seconds old must not hide a full list.
+ const on = Math.max(m?.status?.playerCount || 0, m?.players.length ?? 0);
+ const sorted = [...batch].sort((a, b) => a.eventTime - b.eventTime);
+ const inOrder = sorted.map(bountyKill);
+ const byEvent = new Map(sorted.map((k) => [k.eventId, k]));
+ const serverName = m?.status?.serverName || m?.server.name || '';
+ const now = Date.now();
+ for (const row of rows) {
+ const cfg = row.config as BountyConfig;
+ const { version, track } = bountyTrack(row);
+ const before = track.open;
+ const out: Evaluation = { intents: [], updates: [] };
+ const cards: BountyCard[] = [];
+ // The match this batch was stamped with, read only when a bounty is set or its player dies.
+ let stamped: string | undefined;
+ const batchMatch = async () =>
+ (stamped ??= matchKey(await stampedMatch(env, serverId, batch), Date.parse(batch[0].ts)));
+ // A bounty read back after a restart can be from a match that ended while no worker watched:
+ // the batch that could claim it says which match it is in, and an earlier match's lapses.
+ const open = track.open;
+ if (
+ open?.match.startsWith('m') &&
+ inOrder.some((k) => k.victim.steamId === open.steamId) &&
+ fromEarlierMatch(open.match, await batchMatch()) &&
+ track.open === open
+ ) {
+ track.open = null;
+ track.runs.clear();
+ const lapse = bountyLapse(row, cfg, version, open, 'match', new Date(now), sorted[0].map);
+ out.intents.push(lapse.intent);
+ out.updates.push(lapse.update);
+ cards.push(lapse.card);
+ }
+ const events = bountyStep(cfg, track, inOrder, { canMark: on >= cfg.minPlayers, now });
+ if (!events.length && !out.intents.length) continue;
+ // a bounty set here is of the batch's match
+ for (const e of events) if (e.kind === 'set') e.bounty.match = await batchMatch();
+ const reward = rewardWords(cfg);
+ const slot = cfg.reward === 'slot';
+ const until = slot ? dateOf(new Date(now + cfg.slotDays * 86400_000)) : '';
+ const own = (target: string, streak: number, map: string) => ({
+ target,
+ streak,
+ reward,
+ days: slot ? cfg.slotDays : '',
+ until,
+ ...(map ? { map: mapName(map) } : {})
+ });
+ // said to the whole server, or to the claimer, so no org-wide stats (keptVars)
+ const stats = await vars.stats(
+ events.flatMap((e) =>
+ e.kind === 'set'
+ ? [{ steamId: e.bounty.steamId, text: cfg.setMessage }]
+ : [
+ { steamId: e.claimer.steamId, text: cfg.claimMessage },
+ { steamId: e.claimer.steamId, text: cfg.whisper }
+ ]
+ ),
+ { org: false }
+ );
+ const say = (text: string, who: BountyPlayer, extra: MessageVars, max: number) =>
+ renderTemplate(text, keptVars(vars.about(who, stats, extra)), max);
+ const card = { reward, orgWide: slot && cfg.scope === 'org', at: new Date(now).toISOString() };
+ for (const e of events) {
+ const k = byEvent.get(e.kill.eventId)!;
+ const dedupe = (what: string) => [row.id, what, k.eventId].join(':');
+ if (e.kind === 'set') {
+ const b = e.bounty;
+ const message = say(cfg.setMessage, b, own(b.name, b.streak, k.map), MAX_CHAT);
+ out.intents.push({
+ trigger: row,
+ action: 'broadcast',
+ params: { message },
+ target: b.steamId,
+ okMessage: `Announced the bounty on ${b.name} (${b.streak} kills).`,
+ detail: { name: b.name, streak: b.streak, stage: 'set', eventId: k.eventId },
+ steamId: null,
+ dedupeKey: dedupe('set')
+ });
+ out.updates.push({
+ id: row.id,
+ lastFiredAt: new Date(now),
+ lastResult: `Bounty on ${b.name} (${b.streak} kills)`
+ });
+ cards.push({
+ ...card,
+ kind: 'set',
+ map: k.map || null,
+ name: b.name,
+ faction: b.faction,
+ streak: b.streak
+ });
+ continue;
+ }
+ const b = e.bounty;
+ const c = e.claimer;
+ const claimer = c.name || c.steamId;
+ const claimed = `${claimer} claimed the bounty on ${b.name} (a run of ${b.best}).`;
+ const extra = own(b.name, b.best, k.map);
+ out.intents.push({
+ trigger: row,
+ action: BOUNTY_CLAIM,
+ params: {},
+ target: c.steamId,
+ okMessage: claimed,
+ detail: {
+ name: claimer,
+ target: b.name,
+ targetSteamId: b.steamId,
+ run: b.best,
+ eventId: k.eventId
+ },
+ steamId: null,
+ dedupeKey: dedupe('claim')
+ });
+ if (cfg.claimMessage)
+ out.intents.push({
+ trigger: row,
+ action: 'broadcast',
+ params: { message: say(cfg.claimMessage, c, extra, MAX_CHAT) },
+ target: c.steamId,
+ okMessage: `Announced ${claimer}'s claim on ${b.name}.`,
+ detail: { name: claimer, target: b.name, stage: 'claim', eventId: k.eventId },
+ steamId: null,
+ dedupeKey: dedupe('claimed')
+ });
+ if (slot)
+ out.intents.push({
+ trigger: row,
+ action: BOUNTY_REWARD,
+ params: {
+ steamId: c.steamId,
+ name: claimer,
+ reason: `Bounty on ${b.name}, ${serverName}`.slice(0, 200),
+ slotDays: cfg.slotDays,
+ scope: cfg.scope
+ },
+ target: c.steamId,
+ okMessage: `Reserved a slot for ${claimer}.`,
+ detail: { name: claimer, target: b.name, slotDays: cfg.slotDays },
+ // The slot was won; it is granted even if the claimer leaves before delivery.
+ steamId: null,
+ dedupeKey: dedupe('reward')
+ });
+ if (cfg.whisper)
+ out.intents.push({
+ trigger: row,
+ action: 'whisper',
+ params: { steamId: c.steamId, message: say(cfg.whisper, c, extra, MAX_CHAT) },
+ target: c.steamId,
+ okMessage: `Whispered ${claimer}.`,
+ detail: { name: claimer },
+ steamId: c.steamId,
+ dedupeKey: dedupe('whisper')
+ });
+ out.updates.push({
+ id: row.id,
+ lastFiredAt: new Date(now),
+ lastResult: claimed.slice(0, -1)
+ });
+ cards.push({
+ ...card,
+ kind: 'claim',
+ map: k.map || null,
+ claimer,
+ target: b.name,
+ run: b.best
+ });
+ }
+ // The open bounty as it stands after the batch, on the last update (they are applied in order).
+ const state = bountyState(version, track.open);
+ out.updates[out.updates.length - 1].state = state;
+ const after = track.open;
+ let queued = 0;
+ try {
+ await withOwnedTransaction(env, async (tx) => {
+ queued = await enqueueIntents(tx, serverId, out.intents);
+ await applyTriggerUpdates(tx, out.updates);
+ });
+ } catch (err) {
+ // Not written: the bounty goes back to what it was, unless a look has moved it on since.
+ if (track.open === after) track.open = before;
+ throw err;
+ }
+ row.state = state;
+ if (queued) wakeDelivery();
+ void notifyBounty(env, serverId, serverName, cards);
+ }
+}
+
/**
* The placeholders of a message about a kill's killer (or either of its players): the server as the
* worker last saw it, the player (their side as the kill has it, their ping from the last player
diff --git a/src/lib/server/observe.ts b/src/lib/server/observe.ts
index 4bf7baf3..ef858c59 100644
--- a/src/lib/server/observe.ts
+++ b/src/lib/server/observe.ts
@@ -647,6 +647,12 @@ export async function observeServer(env: Env, m: ServerMemory, kinds: ObserveKin
recovered: wasOffline,
// the lines the match stage will write, for the broadcast's {mvp} and {top}
matchLines: matchEnd ? closeTallies(m.tallies, prevStatusAt).rows : [],
+ // the sessions open after this look: a leaver's, gone past the grace, closes at it
+ // (the map itself follows when the look is written)
+ present: {
+ has: (steamId: string) =>
+ m.presence.open.has(steamId) && !diff.left.some((s) => s.steamId === steamId)
+ },
ts
},
rows
diff --git a/src/lib/server/outbox.ts b/src/lib/server/outbox.ts
index 77a672e7..1cb44b69 100644
--- a/src/lib/server/outbox.ts
+++ b/src/lib/server/outbox.ts
@@ -33,6 +33,7 @@ import { deliveries } from './metrics';
import { NAME_FLAG } from './name-filter';
import { KILL_RATE_FLAG } from './kill-rate';
import { KILL_DISTANCE_FLAG, KILL_DISTANCE_SKIP } from './kill-distance';
+import { BOUNTY_CLAIM, BOUNTY_LAPSE, BOUNTY_REWARD } from './bounty';
import { writeAudit } from './audit';
import { PANEL_BAN, type PanelBanParams } from './rule-ban';
import { killAndTell, RULE_KILL, RULE_KILL_MAX_AGE_MS } from './rule-kill';
@@ -54,7 +55,10 @@ const PANEL_ACTIONS = new Set([
NAME_FLAG,
KILL_RATE_FLAG,
KILL_DISTANCE_FLAG,
- KILL_DISTANCE_SKIP
+ KILL_DISTANCE_SKIP,
+ BOUNTY_REWARD,
+ BOUNTY_CLAIM,
+ BOUNTY_LAPSE
]);
/** The most rows one claim takes, oldest first. */
@@ -470,15 +474,18 @@ async function release(env: Env, row: OutboxRow): Promise {
*/
async function deliverOne(env: Env, row: OutboxRow): Promise<'held' | 'refused' | void> {
if (row.action === 'seed_reward') return deliverSeedReward(env, row);
+ if (row.action === BOUNTY_REWARD) return deliverBountyReward(env, row);
if (row.action === PANEL_BAN) return deliverPanelBan(env, row);
- // An alert-only Name filter match, a Name change, Kill rate or Kill distance flag, or a Kill
- // distance rule's note of a kill it left out: the audit row (and, but for the note, its Discord
- // card) is the whole delivery.
+ // An alert-only Name filter match, a Name change, Kill rate or Kill distance flag, a Kill
+ // distance rule's note of a kill it left out, or a bounty claimed or lapsed: the audit row (and,
+ // but for the Kill distance note, its Discord card) is the whole delivery.
if (
row.action === NAME_FLAG ||
row.action === KILL_RATE_FLAG ||
row.action === KILL_DISTANCE_FLAG ||
- row.action === KILL_DISTANCE_SKIP
+ row.action === KILL_DISTANCE_SKIP ||
+ row.action === BOUNTY_CLAIM ||
+ row.action === BOUNTY_LAPSE
) {
const holds = await stillHolds(env, row);
if (holds === null) return release(env, row);
@@ -663,6 +670,75 @@ async function deliverSeedReward(env: Env, row: OutboxRow): Promise {
}
}
+/**
+ * A claimed bounty's slot, like a Seeding reward's: the claimer goes on this server's reserved list or
+ * the organisation's for the rule's number of days from now, and the server is nudged to sync. A
+ * claimer who already holds a slot there that ends sooner has it made to last as long; one whose slot
+ * lasts as long or longer (or for good) keeps it as it is. Won slots do not go stale.
+ */
+async function deliverBountyReward(env: Env, row: OutboxRow): Promise {
+ const p = row.params as {
+ steamId: string;
+ name: string;
+ reason: string;
+ slotDays: number;
+ scope: 'server' | 'org';
+ };
+ stats.inFlight++;
+ try {
+ if (!isOwner()) throw new LostOwnership();
+ const m = memoryOf(row.serverId);
+ const server = m?.server ?? (await getServer(env, row.serverId));
+ const org = m?.org ?? (server ? await getOrg(env, server.orgId) : null);
+ if (!server || !org) return await finish(env, row, 'skipped', 'Server no longer exists.');
+ const here = p.scope !== 'org';
+ const list = here
+ ? await serverListOf(env, server, 'reserve')
+ : await listOf(env, org.id, 'reserve');
+ const expiresAt = new Date(Date.now() + p.slotDays * 86400_000);
+ const { added, lengthened } = await grantEntry(
+ env,
+ list,
+ {
+ steamId: p.steamId,
+ reason: p.reason,
+ expiresAt,
+ addedByName: `trigger: ${row.triggerName}`
+ },
+ { lengthen: true }
+ );
+ if (!added && !lengthened)
+ return await finish(
+ env,
+ row,
+ 'skipped',
+ `${p.name} already holds a reserved slot ${here ? `on ${server.name}` : `in ${org.name}`} that lasts as long or longer.`
+ );
+ // The next sync puts the slot on the server; this server syncs at its next look.
+ m?.reserved.add(p.steamId);
+ if (m) {
+ m.syncAt = 0;
+ gateway().observeSoon(row.serverId, { lists: true });
+ }
+ const until = expiresAt.toISOString().slice(0, 10);
+ await finish(
+ env,
+ row,
+ 'delivered',
+ added
+ ? `Reserved a slot for ${p.name} until ${until}.`
+ : `${p.name}'s reserved slot now lasts until ${until}.`
+ );
+ } catch (err) {
+ if (err instanceof LostOwnership) return; // the lease sweep marks it unknown
+ // the error goes to the log, a fixed phrase to the row: it may name what it failed on
+ console.error('[warcon] bounty reward', forLog(err));
+ await finish(env, row, 'failed', 'Could not write the reserved slot.');
+ } finally {
+ stats.inFlight--;
+ }
+}
+
/**
* A rule's ban is a panel action, not a game request: the player goes on the server's own ban list,
* or the organisation's, with the rule's reason, for its number of days or for good. It is written
diff --git a/src/lib/server/poller.ts b/src/lib/server/poller.ts
index c560ae85..05617d04 100644
--- a/src/lib/server/poller.ts
+++ b/src/lib/server/poller.ts
@@ -39,6 +39,7 @@ import { phaseOffset, pickDue, withHold } from './poller-schedule';
import { rollupSamples } from './rollups';
import { liveView } from './live';
import { feedDemoKills, forgetKillDistance, forgetNameChange } from './feed-events';
+import { forgetBounties } from './bounty';
import { startAliasFill } from './aliases';
import { publicMessage } from './http';
import * as metrics from './metrics';
@@ -290,6 +291,7 @@ async function refreshRoster(env: Env, s: Scheduler, now: number): Promise
forgetMemory(m.server.id);
forgetKillDistance(m.server.id);
forgetNameChange(m.server.id);
+ forgetBounties(m.server.id);
}
if (now - s.expiryAt >= EXPIRY_MS) {
s.expiryAt = now;
diff --git a/src/lib/server/trigger-rules.ts b/src/lib/server/trigger-rules.ts
index cdcb1f88..2f8b899f 100644
--- a/src/lib/server/trigger-rules.ts
+++ b/src/lib/server/trigger-rules.ts
@@ -9,6 +9,7 @@ import { validateKillDistance, type KillDistanceConfig } from './kill-distance';
import { validateTwoTeams, type TwoTeamsConfig } from './two-teams';
import { validateAfkProtection, type AfkProtectionConfig } from './afk-protection';
import { validateNameChange, type NameChangeConfig } from './name-change';
+import { validateBounty, type BountyConfig } from './bounty';
import { causeTags } from './cause-tags';
import { RESTART_AFTER_HOURS, restartWindow } from '$lib/uptime';
import { MAX_CHAT } from '$lib/chat';
@@ -34,7 +35,8 @@ export const TRIGGER_KINDS: TriggerKind[] = [
'two_teams',
'kill_distance',
'afk_protection',
- 'name_change'
+ 'name_change',
+ 'bounty'
];
export const TRIGGER_LABELS: Record = {
welcome: 'Welcome whisper',
@@ -52,7 +54,8 @@ export const TRIGGER_LABELS: Record = {
two_teams: 'Team balance',
kill_distance: 'Kill distance watch',
afk_protection: 'AFK protection',
- name_change: 'Name change watch'
+ name_change: 'Name change watch',
+ bounty: 'Bounty'
};
export interface WelcomeConfig {
@@ -226,7 +229,8 @@ export type TriggerConfig =
| TwoTeamsConfig
| KillDistanceConfig
| AfkProtectionConfig
- | NameChangeConfig;
+ | NameChangeConfig
+ | BountyConfig;
/** A kick reason: not chat, so not held to the game's chat cap. */
export const MAX_REASON = 200;
@@ -421,6 +425,8 @@ export function validateConfig(kind: TriggerKind, raw: unknown): TriggerConfig {
return validateAfkProtection(c);
case 'name_change':
return validateNameChange(c);
+ case 'bounty':
+ return validateBounty(c);
}
}
diff --git a/src/lib/server/triggers.ts b/src/lib/server/triggers.ts
index be57e9ee..da4de01a 100644
--- a/src/lib/server/triggers.ts
+++ b/src/lib/server/triggers.ts
@@ -105,6 +105,7 @@ import {
type WelcomeConfig
} from './trigger-rules';
import { MAX_CHAT } from '$lib/chat';
+import { notifyBounty, type BountyCard } from './webhook-delivery';
import { NAME_FLAG, nameFilterTargets, nameVerdict, type NameFilterConfig } from './name-filter';
import {
nameChangeSettingsKey,
@@ -161,6 +162,24 @@ import {
afkStep,
type AfkProtectionConfig
} from './afk-protection';
+import {
+ BOUNTY_LAPSE,
+ bountyReplay,
+ bountyReward,
+ bountyScope,
+ bountyState,
+ bountyTrack,
+ forgetBounties,
+ lapseWords,
+ rewardWords,
+ savedBounty,
+ bountyVersion,
+ type BountyConfig,
+ type BountyKill,
+ type BountyLapse,
+ type BountyReplayEvent,
+ type OpenBounty
+} from './bounty';
import { fmtUptime, RESTART_AFTER_HOURS, restartWindow } from '$lib/uptime';
import { DEFAULT_SCORE_CAP } from '$lib/match';
import { mapName } from '$lib/format';
@@ -203,9 +222,18 @@ const shape = (t: TriggerRow): TriggerView => ({
lastResult: t.lastResult,
fireCount: t.fireCount,
createdAt: t.createdAt ? t.createdAt.toISOString() : null,
- ...(t.kind === 'afk_protection' ? { phase: afkPhase(t.state) } : {})
+ ...(t.kind === 'afk_protection' ? { phase: afkPhase(t.state) } : {}),
+ ...(t.kind === 'bounty' ? { bounty: openBounty(t) } : {})
});
+/** A Bounty rule's open bounty, as its row shows it: the one its last save holds, if any. */
+function openBounty(t: TriggerRow): TriggerView['bounty'] {
+ const b = savedBounty(t.state, bountyVersion(t));
+ return b
+ ? { steamId: b.steamId, name: b.name, streak: b.streak, setAt: new Date(b.setAt).toISOString() }
+ : null;
+}
+
/** Where an AFK protection rule stands, as its row shows it; null until the worker has looked. */
function afkPhase(state: unknown): TriggerView['phase'] {
const s = afkState(state);
@@ -237,7 +265,7 @@ async function triggerOf(env: Env, serverId: string, id: string): Promise,
+ Exclude,
[Capability, string]
> = {
welcome: ['chat.send', 'messages players'],
@@ -256,16 +284,27 @@ const RULE_NEEDS: Record<
name_change: ['players.kick', 'flags or kicks players']
};
+/** What a reserved slot a rule hands out needs: one here, or on the organisation's list. */
+const SLOT_NEEDS: Record<'server' | 'org', [Capability, string]> = {
+ org: ['lists.reserve', "edits the organisation's reserved-slot list"],
+ server: ['slots.manage', 'reserves slots on this server']
+};
+
/**
* What one rule needs of whoever saves it. The Seeding reward reserves slots: here, or on the
* organisation's list. A Kill distance rule flags, warns, kills (and whispers), kicks, or bans:
- * here, or on the organisation's ban list.
+ * here, or on the organisation's ban list. A Bounty broadcasts, and reserves the claimer a slot when
+ * it has a reward.
*/
export function ruleNeeds(kind: TriggerKind, config: unknown): [Capability, string] {
if (kind === 'seed_reward')
- return (config as Partial | null)?.scope !== 'server'
- ? ['lists.reserve', "edits the organisation's reserved-slot list"]
- : ['slots.manage', 'reserves slots on this server'];
+ return SLOT_NEEDS[
+ (config as Partial | null)?.scope !== 'server' ? 'org' : 'server'
+ ];
+ if (kind === 'bounty')
+ return bountyReward(config) === 'slot'
+ ? SLOT_NEEDS[bountyScope(config)]
+ : ['chat.send', 'messages players'];
if (kind === 'kill_distance') {
const action = killDistanceAction(config);
if (action === 'ban') return banNeeds(killDistanceBanScope(config));
@@ -282,6 +321,8 @@ export function ruleNeeds(kind: TriggerKind, config: unknown): [Capability, stri
*/
function ruleAlsoNeeds(kind: TriggerKind, config: unknown): [Capability, string] | null {
if (kind === 'kill_distance' && killDistanceAction(config) === 'kill') return RULE_KILL_NEEDS[1];
+ if (kind === 'bounty' && bountyReward(config) === 'slot')
+ return ['chat.send', 'messages players'];
if (kind === 'two_teams' && str((config as Partial | null)?.message))
return ['chat.send', 'whispers players'];
const afk = config as Partial | null;
@@ -323,9 +364,14 @@ export async function createTrigger(
const row = await env.db.transaction(async (tx) => {
// Seed time is one count per server, taken against one threshold, so one rule holds it. Two
// Team balance rules would move a player back and forth, killing them at every move; two AFK
- // protection rules would kill everyone twice a round. Saves to one server take turns, so two at
- // once cannot both find none.
- if (kind === 'seed_reward' || kind === 'two_teams' || kind === 'afk_protection') {
+ // protection rules would kill everyone twice a round; two Bounty rules would mark two players at
+ // once. Saves to one server take turns, so two at once cannot both find none.
+ if (
+ kind === 'seed_reward' ||
+ kind === 'two_teams' ||
+ kind === 'afk_protection' ||
+ kind === 'bounty'
+ ) {
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${`triggers:${server.id}`}))`);
const [other] = await tx
.select({ name: triggers.name })
@@ -384,6 +430,8 @@ export async function updateTrigger(
if (body.config !== undefined) set.config = validateConfig(row.kind, body.config);
if (row.kind === 'ping_kick' && (body.config !== undefined || body.enabled !== undefined))
set.state = null;
+ // Any save of a Bounty rule calls off its open bounty: the worker starts it over (bounty.ts).
+ if (row.kind === 'bounty') set.state = null;
// Switched on again, a Team balance rule starts over in the worker (its memory is keyed on this):
// who switched sides while it was off is unknown, so whoever is on then counts as placed. Whether
// it was off is the row's at the write, not the read above, so a switch-off landing between them
@@ -527,6 +575,9 @@ export interface TickContext {
recovered?: boolean;
/** at a boundary, every player's line of the match that ended, from the worker's tallies */
matchLines: MatchLineVars[];
+ /** the players with an open session: on, or gone for less than the leave grace; absent, nobody
+ * is known to have left */
+ present?: { has(steamId: string): boolean };
ts: Date;
}
@@ -762,6 +813,10 @@ export async function evaluateTriggers(
case 'afk_protection':
evalAfkProtection(ctx, row, row.config as AfkProtectionConfig, out);
break;
+ case 'bounty':
+ // Kills move it on as they arrive (feed-events.ts); a look ends it.
+ evalBounty(env, ctx, row, row.config as BountyConfig, out);
+ break;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@@ -1455,6 +1510,93 @@ function evalAfkProtection(
out.updates.push(update);
}
+/**
+ * A Bounty rule at each look: a match end starts the runs over, and an open bounty lapses when the
+ * match ends, when its player's session closes (gone past the leave grace, so a map change's empty
+ * list is no leave), or at the first look after the server was out of reach (whatever happened
+ * meanwhile, its 24-hour restart among it, is unknown). Kills set and claim bounties as they arrive
+ * (feed-events.ts). The bounty is let go here, before the look is written, so no kill claims it
+ * meanwhile; a look whose write fails leaves it open on the rule's row only, where a restart reads
+ * it back and the next look or claim lapses it (a claim checks the bounty's match).
+ */
+function evalBounty(
+ env: Env,
+ ctx: TickContext,
+ row: TriggerRow,
+ cfg: BountyConfig,
+ out: Evaluation
+) {
+ const { version, track } = bountyTrack(row);
+ if (ctx.matchEnd || ctx.recovered) track.runs.clear();
+ const open = track.open;
+ if (!open) return;
+ const why: BountyLapse | null = ctx.matchEnd
+ ? 'match'
+ : ctx.recovered
+ ? 'lost'
+ : ctx.playersObserved && ctx.present && !ctx.present.has(open.steamId)
+ ? 'left'
+ : null;
+ if (!why) return;
+ track.open = null;
+ const lapse = bountyLapse(
+ row,
+ cfg,
+ version,
+ open,
+ why,
+ ctx.ts,
+ ctx.matchEnd?.map ?? ctx.status.map
+ );
+ out.intents.push(lapse.intent);
+ out.updates.push(lapse.update);
+ const serverName = ctx.status.serverName || ctx.server.name;
+ (out.afterCommit ??= []).push(
+ () => void notifyBounty(env, ctx.server.id, serverName, [lapse.card])
+ );
+}
+
+/**
+ * What a bounty lapsing writes: the note in the trail (Recent actions, the Automation mirror), the
+ * rule's row with no bounty open, and the card for a Bounties channel, posted once it is written.
+ */
+export function bountyLapse(
+ row: TriggerRow,
+ cfg: BountyConfig,
+ version: string,
+ open: OpenBounty,
+ why: BountyLapse,
+ at: Date,
+ map: string | null
+): { intent: Intent; update: TriggerUpdate; card: BountyCard } {
+ const state = bountyState(version, null);
+ row.state = state;
+ const message = lapseWords(open, why);
+ return {
+ intent: {
+ trigger: row,
+ action: BOUNTY_LAPSE,
+ params: {},
+ target: open.steamId,
+ okMessage: message,
+ detail: { name: open.name, streak: open.streak, run: open.best, why },
+ steamId: null,
+ dedupeKey: key(row, 'lapse', open.steamId, open.setAt)
+ },
+ update: { id: row.id, lastFiredAt: at, lastResult: message, state },
+ card: {
+ kind: 'lapse',
+ at: at.toISOString(),
+ map: map || null,
+ reward: rewardWords(cfg),
+ orgWide: cfg.reward === 'slot' && cfg.scope === 'org',
+ name: open.name,
+ run: open.best,
+ why
+ }
+ };
+}
+
// A match boundary is one tick, so the rule keeps no state: the end message then the start
// message, each an outbox row keyed on the tick.
// A match end the rule is holding until the server has its players on again, per rule (in memory
@@ -1468,6 +1610,7 @@ export function forgetRuleMemory(): void {
twoTeamsMemory.clear();
staffMoves.clear();
afkEmpty.clear();
+ forgetBounties();
}
function evalMatchBroadcast(
@@ -1878,6 +2021,26 @@ const REPLAY_ROWS_MAX = 5000;
/** The risk dry run judges at most this many players (the latest on), for its Steam lookups. */
const RISK_REPLAY_MAX = 2000;
+/**
+ * Where a replay of a per-match rule starts: the start of the match already running at `from` (two
+ * minutes before it, as killsOfMatch reads a match), at most six hours back, else `from`.
+ */
+async function matchStartBefore(env: Env, serverId: string, from: Date): Promise {
+ const [open] = await env.db.execute<{ startedAt: Date | null }>(sql`
+ SELECT MIN(started_at) AS "startedAt" FROM matches
+ WHERE server_id = ${serverId} AND started_at < ${from}
+ AND (ended_at IS NULL OR ended_at >= ${from})`);
+ return new Date(
+ Math.max(
+ Math.min(
+ from.getTime(),
+ open?.startedAt ? new Date(open.startedAt).getTime() - 120_000 : Infinity
+ ),
+ from.getTime() - 6 * 3600_000
+ )
+ );
+}
+
/** Replays the last 24 hours of this server's history against a rule. Touches nobody. */
export async function dryRun(
env: Env,
@@ -1899,6 +2062,10 @@ export async function dryRun(
};
const push = (at: Date, text: string) => {
result.fires++;
+ note(at, text);
+ };
+ /** a line of the replay that is not a firing of its own (a bounty claimed or lapsed) */
+ const note = (at: Date, text: string) => {
if (result.items.length < 50) result.items.push({ at: at.toISOString(), text });
};
const joins = (withFaction = false) =>
@@ -2172,24 +2339,137 @@ export async function dryRun(
);
return result;
}
+ if (kind === 'bounty') {
+ const c = cfg as BountyConfig;
+ // Every kill of the window through the live rule's own step, batch by batch as they came in,
+ // from the start of a match already running when the window opens (at most six hours back), so
+ // a run at the window's edge is the live one. Each batch is judged against the player count of
+ // the server's last sample before it, and a marked player's bounty lapses when their session
+ // closed. Only what happened in the window is listed.
+ const since = await matchStartBefore(env, server.id, from);
+ const rows = await env.db.execute<{
+ ts: Date;
+ eventId: string;
+ matchRow: number | null;
+ killer: string | null;
+ killerName: string | null;
+ killerFaction: string | null;
+ victim: string;
+ victimName: string;
+ victimFaction: string | null;
+ suicide: boolean;
+ teamKill: boolean;
+ }>(sql`
+ SELECT ts, event_id AS "eventId", match_row AS "matchRow", killer_steam_id AS killer,
+ killer_name AS "killerName", killer_faction AS "killerFaction",
+ victim_steam_id AS victim, victim_name AS "victimName",
+ victim_faction AS "victimFaction", suicide, team_kill AS "teamKill"
+ FROM kills
+ WHERE server_id = ${server.id} AND ts >= ${since}
+ ORDER BY ts ASC, event_time ASC LIMIT ${KILL_RATE_REPLAY_MAX}`);
+ const counts = await env.db.execute<{ ts: Date; n: number | null }>(sql`
+ SELECT ts, player_count AS n FROM samples
+ WHERE server_id = ${server.id} AND ok AND ts >= ${new Date(since.getTime() - 10 * 60_000)}
+ ORDER BY ts ASC`);
+ const leaves = await env.db.execute<{ steamId: string; leftAt: Date }>(sql`
+ SELECT steam_id AS "steamId", left_at AS "leftAt" FROM player_sessions
+ WHERE server_id = ${server.id} AND left_at >= ${since}
+ ORDER BY left_at ASC LIMIT ${NAME_SESSIONS_MAX}`);
+ const sampled = counts.map((r) => ({ at: new Date(r.ts).getTime(), n: Number(r.n ?? 0) }));
+ let next = 0;
+ let on = 0;
+ const events: BountyReplayEvent[] = [];
+ for (let i = 0; i < rows.length;) {
+ const at = new Date(rows[i].ts).getTime();
+ let j = i;
+ while (j < rows.length && new Date(rows[j].ts).getTime() === at) j++;
+ for (; next < sampled.length && sampled[next].at <= at; next++) on = sampled[next].n;
+ const batch = rows.slice(i, j);
+ events.push({
+ kind: 'kills',
+ at,
+ match: matchKey(batch[0].matchRow === null ? null : Number(batch[0].matchRow), at),
+ players: on,
+ kills: batch.map((r): BountyKill => ({
+ eventId: r.eventId,
+ killer: r.killer
+ ? { steamId: r.killer, name: r.killerName || r.killer, faction: r.killerFaction }
+ : null,
+ victim: { steamId: r.victim, name: r.victimName, faction: r.victimFaction },
+ suicide: !!r.suicide,
+ teamKill: !!r.teamKill
+ }))
+ });
+ i = j;
+ }
+ for (const l of leaves)
+ events.push({ kind: 'left', at: new Date(l.leftAt).getTime(), steamId: l.steamId });
+ // a leave dated to the same moment as a batch comes after it: a player is seen, then gone
+ events.sort((a, b) => a.at - b.at || (a.kind === b.kind ? 0 : a.kind === 'kills' ? -1 : 1));
+ const { outcomes, open } = bountyReplay(c, events);
+ const reward = rewardWords(c);
+ const slot = c.reward === 'slot';
+ const counted = { set: 0, claim: 0, lapse: 0 };
+ for (const o of outcomes) {
+ if (o.at < from.getTime()) continue;
+ counted[o.kind]++;
+ const at = new Date(o.at);
+ const b = o.bounty;
+ if (o.kind === 'set') {
+ const v = keptVars(
+ dryRunVars(
+ server.name,
+ { name: b.name, steamId: b.steamId },
+ {
+ target: b.name,
+ streak: b.streak,
+ reward,
+ days: slot ? c.slotDays : '',
+ until: slot ? dateOf(new Date(o.at + c.slotDays * 86400_000)) : ''
+ }
+ )
+ );
+ push(
+ at,
+ `bounty on ${b.name} (${b.streak} kills, ${o.players} on): ${renderTemplate(c.setMessage, v, MAX_CHAT)}`
+ );
+ } else if (o.kind === 'claim')
+ note(
+ at,
+ `claimed by ${o.claimer.name || o.claimer.steamId}: ${b.name} after a run of ${b.best}${slot ? `, ${reward}` : ''}`
+ );
+ else
+ note(
+ at,
+ o.why === 'left'
+ ? `lapsed: ${b.name} left (a run of ${b.best})`
+ : `lapsed: the match ended, ${b.name} on a run of ${b.best}`
+ );
+ }
+ const [feed] = await env.db
+ .select({ configured: sql`feed_token_hash IS NOT NULL` })
+ .from(servers)
+ .where(eq(servers.id, server.id));
+ if (!feed?.configured)
+ result.notes.push(
+ 'This server has no kill feed set up (Config tab), so the rule cannot see any kills.'
+ );
+ const n = (x: number, one: string, many = `${one}s`) => `${x} ${x === 1 ? one : many}`;
+ result.notes.push(
+ `${n(counted.set, 'bounty', 'bounties')} set, ${counted.claim} claimed, ${counted.lapse} lapsed${open ? ', one still open' : ''}. Runs start over at each match, and nobody is marked while the server's samples show fewer than ${c.minPlayers} on.`
+ );
+ if (rows.length >= KILL_RATE_REPLAY_MAX)
+ result.notes.push(
+ `Replayed the first ${KILL_RATE_REPLAY_MAX.toLocaleString('en')} kills only.`
+ );
+ return result;
+ }
if (kind === 'kill_distance') {
const c = cfg as KillDistanceConfig;
// The kills of the window the rule counts, per match as the live rule counts them, from the
// start of a match already running when the window opens (at most six hours back), so a count
// at the window's edge is the live one. Only what happened in the window is listed.
- const [open] = await env.db.execute<{ startedAt: Date | null }>(sql`
- SELECT MIN(started_at) AS "startedAt" FROM matches
- WHERE server_id = ${server.id} AND started_at < ${from}
- AND (ended_at IS NULL OR ended_at >= ${from})`);
- const since = new Date(
- Math.max(
- Math.min(
- from.getTime(),
- open?.startedAt ? new Date(open.startedAt).getTime() - 120_000 : Infinity
- ),
- from.getTime() - 6 * 3600_000
- )
- );
+ const since = await matchStartBefore(env, server.id, from);
// from 0 m a kill without a distance counts too
const far = c.minDistanceM > 0 ? sql`AND distance_m >= ${c.minDistanceM}` : sql``;
const rows = await env.db.execute<{
diff --git a/src/lib/server/webhook-delivery.ts b/src/lib/server/webhook-delivery.ts
index 504c0e41..002ab580 100644
--- a/src/lib/server/webhook-delivery.ts
+++ b/src/lib/server/webhook-delivery.ts
@@ -10,6 +10,8 @@ import { OWNERS_ROWS } from './audit-rows';
import { escapeMarkdown } from './webhook-status-core';
import { KILL_DISTANCE_SKIP } from './kill-distance';
import { causeLabel } from '$lib/causes';
+import { mapName } from '$lib/format';
+import type { BountyLapse } from './bounty';
import { FAILURES_ONLY } from '$lib/rule-kinds';
import type { KillView } from '$lib/types';
@@ -21,7 +23,8 @@ export const WEBHOOK_EVENTS = [
'management',
'auth',
'teamkills',
- 'watched'
+ 'watched',
+ 'bounties'
] as const;
export type WebhookEvent = (typeof WEBHOOK_EVENTS)[number];
export const WEBHOOK_EVENT_LABELS: Record = {
@@ -32,7 +35,8 @@ export const WEBHOOK_EVENT_LABELS: Record = {
management: 'Servers, members, invite links, accounts',
auth: 'Sign-ins and sign-in failures',
teamkills: 'Team kills (from the kill feed)',
- watched: 'Watched players joining'
+ watched: 'Watched players joining',
+ bounties: "Bounties (set, claimed and lapsed, for a players' channel)"
};
/** Which event class an audit row belongs to. */
@@ -155,6 +159,7 @@ const ACTION_TITLES: Record = {
'trigger.kill_distance': 'Trigger · kill distance watch',
'trigger.afk_protection': 'Trigger · AFK protection',
'trigger.name_change': 'Trigger · name change watch',
+ 'trigger.bounty': 'Trigger · bounty',
'player.note': 'Player note',
'player.watch': 'Watchlist',
'list.add': 'Org list · added',
@@ -243,6 +248,87 @@ export function buildTeamKillEmbed(appName: string, serverName: string, k: KillV
const WATCHED_COLOR = 0x5b8def;
+const BOUNTY_COLORS = { set: 0xd4a843, claim: 0x7bc462, lapse: 0x8a8a90 } as const;
+
+/** A bounty as a Bounties channel shows it: for players, so names only, never a SteamID. */
+export type BountyCard = {
+ /** when it happened (ISO) */
+ at: string;
+ /** the map's id, as the game names it */
+ map: string | null;
+ /** the reward in words; '' for none */
+ reward: string;
+ /** the slot is on every server of the organisation */
+ orgWide: boolean;
+} & (
+ | { kind: 'set'; name: string; faction: string | null; streak: number }
+ | { kind: 'claim'; claimer: string; target: string; run: number }
+ | { kind: 'lapse'; name: string; run: number; why: BountyLapse }
+);
+
+/** One bounty set, claimed or lapsed as an embed: who, the run, the reward, where. */
+export function buildBountyEmbed(appName: string, serverName: string, c: BountyCard): Embed {
+ const reward = c.reward
+ ? `Reward: ${plain(c.reward, 80)}${c.orgWide ? ', on every server' : ''}`
+ : '';
+ const where = `${plain(serverName, 80)}${c.map ? ` · ${plain(mapName(c.map), 40)}` : ''}`;
+ const card =
+ c.kind === 'set'
+ ? {
+ // the name goes in the body, where it is escaped like every other name (titles are not)
+ title: 'Bounty set',
+ lines: [
+ `**${plain(c.name, 60)}**: ${c.streak} kills without dying${c.faction ? `, for ${plain(c.faction, 30)}` : ''}`,
+ reward
+ ]
+ }
+ : c.kind === 'claim'
+ ? {
+ title: 'Bounty claimed',
+ lines: [
+ `**${plain(c.claimer, 60)}** ended **${plain(c.target, 60)}**'s run at ${c.run}`,
+ reward
+ ]
+ }
+ : {
+ title: 'Bounty lapsed',
+ lines: [
+ c.why === 'left'
+ ? `**${plain(c.name, 60)}** left the server on a run of ${c.run}`
+ : `The match ended with **${plain(c.name, 60)}** on a run of ${c.run}`
+ ]
+ };
+ return {
+ title: card.title,
+ description: clip([...card.lines, where].filter(Boolean).join('\n'), 2000),
+ color: BOUNTY_COLORS[c.kind],
+ timestamp: c.at,
+ footer: { text: appName }
+ };
+}
+
+/** Posts a bounty to the org's webhooks with Bounties ticked for the server. Never throws. */
+export async function notifyBounty(
+ env: Env,
+ serverId: string,
+ serverName: string,
+ cards: BountyCard[]
+): Promise {
+ try {
+ if (!cards.length) return;
+ const orgId = await orgOfServer(env, serverId);
+ if (!orgId) return;
+ const hooks = hooksFor(await enabledWebhooks(env, orgId), 'bounties', serverId);
+ if (!hooks.length) return;
+ for (const c of cards) {
+ const embed = buildBountyEmbed(env.APP_NAME || 'Warcon', serverName, c);
+ for (const hook of hooks) enqueue(env, hook, embed);
+ }
+ } catch (err) {
+ console.error('[warcon] webhook bounties', err);
+ }
+}
+
/** The player's page in the panel, for a staff post about one player; none without an origin. */
export function dossierUrl(origin: string | undefined, serverId: string, steamId: string) {
if (!origin || !/^\d{17}$/.test(steamId)) return undefined;
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 47650349..163bade8 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -578,7 +578,8 @@ export type TriggerKind =
| 'two_teams'
| 'kill_distance'
| 'afk_protection'
- | 'name_change';
+ | 'name_change'
+ | 'bounty';
export interface TriggerView {
id: string;
@@ -592,6 +593,8 @@ export interface TriggerView {
createdAt: string | null;
/** an AFK protection rule's standing: acting while the server seeds, or off since a match went live */
phase?: { on: boolean; since: string | null; why: string } | null;
+ /** a Bounty rule's open bounty: on whom, the run that set it, and when */
+ bounty?: { steamId: string; name: string; streak: number; setAt: string } | null;
}
export interface DryRunResult {
diff --git a/src/test/access.test.ts b/src/test/access.test.ts
index bd29fdf0..2a93ce8f 100644
--- a/src/test/access.test.ts
+++ b/src/test/access.test.ts
@@ -536,7 +536,8 @@ describe.skipIf(!hasTestDb)('access', () => {
['name_change', { takenOnly: true }, 'players.kick'],
['name_change', { action: 'kick' }, 'players.kick'],
['seed_reward', { minutes: 60, scope: 'server' }, 'slots.manage'],
- ['seed_reward', { minutes: 60, scope: 'org' }, 'lists.reserve']
+ ['seed_reward', { minutes: 60, scope: 'org' }, 'lists.reserve'],
+ ['bounty', { reward: 'none' }, 'chat.send']
];
for (const [kind, config, cap] of rules) {
await holds([]);
@@ -667,6 +668,76 @@ describe.skipIf(!hasTestDb)('access', () => {
}
});
+ test('a Bounty rule broadcasts, so Chat; a reward needs the reserved list it goes on as well', async () => {
+ const w = await seedWorld(env);
+ const params = { id: w.server.id };
+ const holds = (caps: string[]) =>
+ env.db
+ .update(orgRoles)
+ .set({ capabilities: ['server.view', 'automation.manage', ...caps] })
+ .where(eq(orgRoles.id, w.roles.viewer));
+ const routes = ['POST api/servers/[id]/triggers', 'POST api/servers/[id]/triggers/dry-run'];
+ const slot = (scope: string) => ({ kind: 'bounty', config: { reward: 'slot', scope } });
+ // nobody outside the server reaches the rule at all, with or without a reward
+ for (const [who, status] of [
+ ['anon', 401],
+ ['outsider', 404],
+ ['elsewhere', 404],
+ ['keyView', 403],
+ ['viewer', 403]
+ ] as const)
+ for (const route of routes)
+ for (const body of [slot('server'), { kind: 'bounty', config: { reward: 'none' } }])
+ expect([who, route, (await api(w, who, route, { params, body })).status]).toEqual([
+ who,
+ route,
+ status
+ ]);
+ // a slot here needs Reserved slots and Chat, one on every server Org reserved slots and Chat:
+ // neither alone, nor the other list's
+ for (const [caps, scope] of [
+ [['slots.manage'], 'server'],
+ [['chat.send'], 'server'],
+ [['lists.reserve', 'chat.send'], 'server'],
+ [['lists.reserve'], 'org'],
+ [['chat.send'], 'org'],
+ [['slots.manage', 'chat.send'], 'org']
+ ] as const) {
+ await holds([...caps]);
+ for (const route of routes) {
+ const got = await api(w, 'viewer', route, { params, body: slot(scope) });
+ expect([caps, scope, route, got.status]).toEqual([caps, scope, route, 403]);
+ }
+ }
+ for (const [caps, scope] of [
+ [['slots.manage', 'chat.send'], 'server'],
+ [['lists.reserve', 'chat.send'], 'org']
+ ] as const) {
+ await holds([...caps]);
+ const got = await api(w, 'viewer', routes[1], { params, body: slot(scope) });
+ expect([caps, scope, got.status]).toEqual([caps, scope, 200]);
+ }
+ // an announcement-only rule needs Chat alone, and its author cannot give it a slot by an edit
+ await holds(['chat.send']);
+ const made = await api(w, 'viewer', routes[0], {
+ params,
+ body: { kind: 'bounty', config: { reward: 'none' } }
+ });
+ expect(made.status).toBe(201);
+ const triggerId = (made.body as { trigger: { id: string } }).trigger.id;
+ for (const scope of ['server', 'org']) {
+ const edit = await api(w, 'viewer', 'PATCH api/servers/[id]/triggers/[triggerId]', {
+ params: { id: w.server.id, triggerId },
+ body: { config: { reward: 'slot', scope } }
+ });
+ expect([scope, edit.status]).toEqual([scope, 403]);
+ }
+ // one per server: two would put two bounties up at once
+ await holds(['chat.send', 'slots.manage']);
+ const second = await api(w, 'viewer', routes[0], { params, body: slot('server') });
+ expect(second.status).toBe(409);
+ });
+
test('a Name filter rule: who may save, dry-run and switch it on', async () => {
const w = await seedWorld(env);
const body = { kind: 'name_filter', config: { characters: 'ascii', action: 'alert' } };
diff --git a/src/test/bounty-rule.test.ts b/src/test/bounty-rule.test.ts
new file mode 100644
index 00000000..508b61fd
--- /dev/null
+++ b/src/test/bounty-rule.test.ts
@@ -0,0 +1,604 @@
+// A Bounty rule as the worker runs it, against the database and a stand-in game: kills come in
+// through the real ingest, which takes both players' sides from the sessions the worker's looks
+// wrote; a run of kills without dying puts a bounty on a player while enough are on, announced to
+// the server; a teammate, a suicide or the environment never ends it, an enemy's kill claims it,
+// and the claimer's reserved slot goes on the list the rule names. A leave or a match end lapses
+// it, a worker restart keeps it, any save of the rule calls it off. A Bounties webhook gets a card
+// for each, with names and never a SteamID. The dry run replays the day's kills the same way.
+import { afterAll, beforeAll, describe, expect, spyOn, test } from 'bun:test';
+import { and, eq, isNull } from 'drizzle-orm';
+import { join } from 'node:path';
+import type { Env } from '$lib/server/env';
+import {
+ auditLog,
+ kills,
+ listEntries,
+ lists,
+ matches,
+ organizations,
+ outbox,
+ playerSessions,
+ samples,
+ servers,
+ triggers,
+ webhooks
+} from '$lib/server/db/schema';
+import { acquireOrRenew, releaseOwnership } from '$lib/server/leadership';
+import { onKillsIngested } from '$lib/server/feed-events';
+import { ingestBatch } from '$lib/server/feed';
+import {
+ forgetMemory,
+ memoryFor,
+ observeServer,
+ OFFLINE_AFTER_FAILURES,
+ type ServerMemory
+} from '$lib/server/observe';
+import { startDelivery, stopDelivery } from '$lib/server/outbox';
+import { WardogsClient } from '$lib/server/rcon';
+import { closeAllSessions, LEAVE_GRACE_MS } from '$lib/server/sessions';
+import { dryRun, invalidateTriggers, validateConfig } from '$lib/server/triggers';
+import { BOUNTY_CLAIM, BOUNTY_LAPSE, BOUNTY_REWARD, forgetBounties } from '$lib/server/bounty';
+import { encryptSecret } from '$lib/server/crypto';
+import { newId } from '$lib/server/http';
+import { resetWebhookQueues } from '$lib/server/webhook-delivery';
+import { callApi, stubGateway, type CallInput, type Outcome } from './call';
+import { hasTestDb, testEnv } from './db';
+import { seedWorld, type PrincipalName, type World } from './world';
+
+const ROUTES = join(import.meta.dir, '..', 'routes');
+/** A route as a person calls it: `METHOD path` under src/routes. */
+async function api(
+ w: World,
+ who: PrincipalName,
+ route: string,
+ input: Omit = {}
+): Promise {
+ const [method, path] = route.split(' ');
+ const mod = await import(join(ROUTES, path, '+server.ts'));
+ return callApi(mod[method], w.users[who], { ...input, method });
+}
+
+// The mock game's scoreboard has Valkyra, Lonestar and Manticore.
+const A = '76561198000000901'; // Valkyra
+const B = '76561198000000902'; // Lonestar
+const C = '76561198000000903'; // Lonestar
+const D = '76561198000000904'; // Valkyra, A's teammate
+const SIDE: Record = {
+ [A]: 'Valkyra',
+ [B]: 'Lonestar',
+ [C]: 'Lonestar',
+ [D]: 'Valkyra'
+};
+const name = (steamId: string) => `p${steamId.slice(-3)}`;
+
+let clock = 100;
+/** One `killed` event as the game posts it. */
+const ev = (killer: string | null, victim: string, o: { suicide?: boolean } = {}) => ({
+ eventId: newId(),
+ type: 'killed',
+ eventTime: clock++,
+ matchId: 'boot',
+ mapName: 'Kavkazi',
+ ...(killer ? { killerName: name(killer), killerSteamId: killer } : {}),
+ victimName: name(victim),
+ victimSteamId: victim,
+ cause: 'Id.Item.AK74M',
+ distance: 4000,
+ contextTags: o.suicide ? ['Meta.Progression.Context.Player.KillContext.Suicide'] : []
+});
+
+describe.skipIf(!hasTestDb)('Bounty rule, live', () => {
+ let env: Env;
+ let spy: ReturnType;
+ /** who the stand-in game lists, per server */
+ const listed = new Map();
+ const broadcasts: [string, string][] = [];
+ const whispers: [string, string, string][] = [];
+ /** what reached Discord */
+ const posts: { title: string; description: string }[] = [];
+ const realFetch = globalThis.fetch;
+
+ beforeAll(async () => {
+ env = { ...(await testEnv()), STEAM_API_KEY: '' };
+ stubGateway();
+ expect(await acquireOrRenew(env, 'bounty test')).toBe(true);
+ spy = spyOn(WardogsClient, 'forServer').mockImplementation(async (_env, server) => {
+ const client = new WardogsClient(
+ env,
+ { id: server.id, host: 'demo', port: 1, scheme: 'http' },
+ 'demo',
+ `bounty-${server.id}`
+ );
+ const raw = client.raw.bind(client);
+ const ok = (text: string) => ({
+ status: 200,
+ statusText: 'OK',
+ headers: { 'content-type': 'application/json' },
+ text
+ });
+ client.raw = async (method, path, body, headers) => {
+ if (method === 'GET' && path === '/v1/players')
+ return ok(
+ JSON.stringify({
+ players: (listed.get(server.id) ?? []).map((steamId) => ({
+ name: name(steamId),
+ steamId,
+ faction: SIDE[steamId],
+ kills: 0
+ }))
+ })
+ );
+ if (method === 'POST' && path === '/v1/broadcast') {
+ broadcasts.push([server.id, JSON.parse(body ?? '{}').message]);
+ return ok('{"message":"Broadcast sent."}');
+ }
+ const whisper = /^\/v1\/players\/(\d{17})\/message$/.exec(path);
+ if (method === 'POST' && whisper) {
+ whispers.push([server.id, whisper[1], JSON.parse(body ?? '{}').message]);
+ return ok('{"message":"Message sent."}');
+ }
+ return raw(method, path, body, headers);
+ };
+ return client;
+ });
+ globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
+ if (!String(url).includes('discord.test')) return realFetch(url, init);
+ posts.push(...(JSON.parse(String(init?.body ?? '{}')).embeds ?? []));
+ return new Response('{"id":"1"}', { status: 200 });
+ }) as typeof fetch;
+ });
+ afterAll(async () => {
+ await stopDelivery();
+ spy.mockRestore();
+ globalThis.fetch = realFetch;
+ resetWebhookQueues();
+ await releaseOwnership(env);
+ });
+
+ /** The worker's memory of a server, fresh. */
+ const memoryOf = async (w: World): Promise => {
+ const [server] = await env.db.select().from(servers).where(eq(servers.id, w.server.id));
+ const [org] = await env.db.select().from(organizations).where(eq(organizations.id, w.org.id));
+ forgetMemory(server.id);
+ return memoryFor(server, org);
+ };
+ /** A look at the server with these players on, their sides as SIDE has them. */
+ const look = async (m: ServerMemory, ...on: string[]) => {
+ listed.set(m.server.id, on);
+ m.playersIntervalMs = 1000;
+ await observeServer(env, m, { status: true, players: true });
+ // the stand-in's scoreboard counts its own players; the rule reads the most anyone says is on
+ m.status = { ...m.status!, playerCount: on.length };
+ };
+ const post = async (serverId: string, events: ReturnType[]) => {
+ const r = await ingestBatch(env, serverId, { serverId: 'i', serverName: 'x', events });
+ await onKillsIngested(env, serverId, r.kills);
+ return r;
+ };
+ const rule = async (w: World, config: Record) => {
+ const id = newId();
+ await env.db.insert(triggers).values({
+ id,
+ serverId: w.server.id,
+ orgId: w.org.id,
+ kind: 'bounty',
+ name: 'Bounty',
+ enabled: true,
+ config: validateConfig('bounty', config)
+ });
+ invalidateTriggers(w.server.id);
+ return id;
+ };
+ const rowsOf = (triggerId: string) =>
+ env.db.select().from(outbox).where(eq(outbox.triggerId, triggerId)).orderBy(outbox.id);
+ const stateOf = async (triggerId: string) =>
+ (
+ await env.db
+ .select({ state: triggers.state })
+ .from(triggers)
+ .where(eq(triggers.id, triggerId))
+ )[0].state as { open: { steamId: string; streak: number; match: string } | null } | null;
+ const rowOf = async (id: number) =>
+ (await env.db.select().from(outbox).where(eq(outbox.id, id)))[0];
+ /** Runs the delivery loop until the rows are done. */
+ const deliver = async (...ids: number[]) => {
+ startDelivery(env);
+ for (let i = 0; i < 100; i++) {
+ const rows = await Promise.all(ids.map(rowOf));
+ if (rows.every((r) => r.state !== 'pending' && r.state !== 'sending')) break;
+ await Bun.sleep(50);
+ }
+ await stopDelivery();
+ return Promise.all(ids.map(rowOf));
+ };
+ const slotsOf = (w: World, steamId: string) =>
+ env.db
+ .select({ serverId: lists.serverId, expiresAt: listEntries.expiresAt })
+ .from(listEntries)
+ .innerJoin(lists, eq(lists.id, listEntries.listId))
+ .where(
+ and(
+ eq(lists.orgId, w.org.id),
+ eq(lists.kind, 'reserve'),
+ eq(listEntries.steamId, steamId),
+ isNull(listEntries.removedAt)
+ )
+ );
+ const bountiesHook = (w: World) =>
+ env.db.insert(webhooks).values({
+ id: newId(),
+ orgId: w.org.id,
+ label: 'bounties',
+ urlEnc: encryptSecret(env, 'https://discord.test/api/webhooks/1/bounties'),
+ events: ['bounties'],
+ serverIds: null
+ });
+ /** Discord posts go out in bursts, a second and a half after the first. */
+ const postsSoon = async (n: number) => {
+ for (let i = 0; i < 60 && posts.length < n; i++) await Bun.sleep(100);
+ return posts;
+ };
+
+ test('a run sets a bounty; a teammate, a suicide and the environment do not end it; an enemy claims it and wins a slot here', async () => {
+ const w = await seedWorld(env);
+ await bountiesHook(w);
+ posts.length = 0;
+ const m = await memoryOf(w);
+ await look(m, A, B, C, D);
+ const id = await rule(w, {
+ streak: 3,
+ minPlayers: 2,
+ slotDays: 2,
+ scope: 'server',
+ claimMessage: '{name} took {target} down after {streak}, for {reward}',
+ whisper: 'Yours until {until}, {name}'
+ });
+ await post(w.server.id, [ev(A, B), ev(A, C)]);
+ expect(await rowsOf(id)).toHaveLength(0);
+ await post(w.server.id, [ev(A, B)]);
+ const [set] = await rowsOf(id);
+ expect(set).toMatchObject({
+ action: 'broadcast',
+ target: A,
+ steamId: null,
+ okMessage: 'Announced the bounty on p901 (3 kills).',
+ params: {
+ message: 'BOUNTY on p901: 3 kills without dying. Kill them for a reserved slot for 2 days.'
+ }
+ });
+ expect((await stateOf(id))?.open).toMatchObject({ steamId: A, streak: 3 });
+ // A's teammate, A's own death and the environment: no claim, the bounty stands
+ await post(w.server.id, [ev(D, A)]);
+ await post(w.server.id, [ev(A, A, { suicide: true })]);
+ await post(w.server.id, [ev(null, A)]);
+ expect(await rowsOf(id)).toHaveLength(1);
+ expect((await stateOf(id))?.open?.steamId).toBe(A);
+ // the panel's own view of the rule names the open bounty
+ const view = await api(w, 'owner', 'GET api/servers/[id]/triggers', {
+ params: { id: w.server.id }
+ });
+ const listedRule = (view.body as { triggers: { id: string; bounty: unknown }[] }).triggers.find(
+ (t) => t.id === id
+ );
+ expect(listedRule?.bounty).toMatchObject({ steamId: A, name: 'p901', streak: 3 });
+ // an enemy's kill claims it
+ await post(w.server.id, [ev(B, A)]);
+ const rows = await rowsOf(id);
+ expect(rows.slice(1).map((r) => [r.action, r.target, r.steamId])).toEqual([
+ [BOUNTY_CLAIM, B, null],
+ ['broadcast', B, null],
+ [BOUNTY_REWARD, B, null],
+ ['whisper', B, B]
+ ]);
+ expect(rows[1].okMessage).toBe('p902 claimed the bounty on p901 (a run of 3).');
+ const until = new Date(Date.now() + 2 * 86400_000).toISOString().slice(0, 10);
+ expect((rows[2].params as { message: string }).message).toBe(
+ 'p902 took p901 down after 3, for a reserved slot for 2 days'
+ );
+ expect((rows[4].params as { message: string }).message).toBe(`Yours until ${until}, p902`);
+ expect((await stateOf(id))?.open).toBeNull();
+ // delivered: the broadcasts and the whisper reach the game, the slot goes on this server's list
+ const done = await deliver(...rows.map((r) => r.id));
+ expect(done.map((r) => r.state)).toEqual(Array(5).fill('delivered'));
+ expect(done[3].outcome).toBe(`Reserved a slot for p902 until ${until}.`);
+ expect(broadcasts.filter(([s]) => s === w.server.id).map(([, t]) => t)).toEqual([
+ 'BOUNTY on p901: 3 kills without dying. Kill them for a reserved slot for 2 days.',
+ 'p902 took p901 down after 3, for a reserved slot for 2 days'
+ ]);
+ expect(whispers.filter(([s]) => s === w.server.id)).toEqual([
+ [w.server.id, B, `Yours until ${until}, p902`]
+ ]);
+ const slots = await slotsOf(w, B);
+ expect(slots.map((s) => s.serverId)).toEqual([w.server.id]);
+ expect(Math.abs(slots[0].expiresAt!.getTime() - (Date.now() + 2 * 86400_000))).toBeLessThan(
+ 60_000
+ );
+ // the trail has the claim under the rule
+ const audited = await env.db
+ .select({ message: auditLog.message })
+ .from(auditLog)
+ .where(and(eq(auditLog.serverId, w.server.id), eq(auditLog.action, 'trigger.bounty')));
+ expect(audited.map((a) => a.message)).toContain(
+ 'p902 claimed the bounty on p901 (a run of 3).'
+ );
+ // the Bounties channel: a card for each, names and never a SteamID
+ const cards = await postsSoon(2);
+ expect(cards.map((c) => c.title)).toEqual(['Bounty set', 'Bounty claimed']);
+ expect(cards[0].description).toContain('**p901**: 3 kills without dying, for Valkyra');
+ expect(cards[1].description).toContain("**p902** ended **p901**'s run at 3");
+ expect(cards[1].description).toContain('Reward: a reserved slot for 2 days');
+ for (const c of cards) expect(c.description).not.toMatch(/\d{17}/);
+ });
+
+ test('a claimer whose slot lasts longer keeps it; one whose slot ends sooner has it made to last; the org list when the rule says', async () => {
+ const w = await seedWorld(env);
+ const m = await memoryOf(w);
+ await look(m, A, B, C, D);
+ const id = await rule(w, { streak: 3, minPlayers: 2, slotDays: 3, scope: 'org' });
+ await post(w.server.id, [ev(A, B), ev(A, C), ev(A, B), ev(C, A)]);
+ const reward = (await rowsOf(id)).find((r) => r.action === BOUNTY_REWARD)!;
+ expect(reward.params).toMatchObject({ steamId: C, scope: 'org', slotDays: 3 });
+ const [first] = await deliver(reward.id);
+ expect(first.state).toBe('delivered');
+ expect((await slotsOf(w, C)).map((s) => s.serverId)).toEqual([null]);
+ // claimed again: a slot as long already, kept as it is
+ await post(w.server.id, [ev(A, B), ev(A, C), ev(A, B), ev(C, A)]);
+ const again = (await rowsOf(id)).filter((r) => r.action === BOUNTY_REWARD)[1];
+ const [kept] = await deliver(again.id);
+ expect([kept.state, kept.outcome]).toEqual([
+ 'skipped',
+ `p903 already holds a reserved slot in ${w.org.name} that lasts as long or longer.`
+ ]);
+ // a slot that ends sooner is made to last as long
+ await env.db
+ .update(listEntries)
+ .set({ expiresAt: new Date(Date.now() + 3600_000) })
+ .where(eq(listEntries.steamId, C));
+ await post(w.server.id, [ev(A, B), ev(A, C), ev(A, B), ev(C, A)]);
+ const third = (await rowsOf(id)).filter((r) => r.action === BOUNTY_REWARD)[2];
+ const [longer] = await deliver(third.id);
+ expect(longer.outcome).toMatch(/^p903's reserved slot now lasts until /);
+ const [slot] = await slotsOf(w, C);
+ expect(slot.expiresAt!.getTime()).toBeGreaterThan(Date.now() + 2 * 86400_000);
+ }, 30_000);
+
+ test('nobody is marked with too few on; one bounty at a time; any save of the rule calls it off', async () => {
+ const w = await seedWorld(env);
+ const m = await memoryOf(w);
+ await look(m, A, B, C, D);
+ const id = await rule(w, { streak: 3, minPlayers: 5, reward: 'none' });
+ await post(w.server.id, [ev(A, B), ev(A, C), ev(A, B), ev(A, C)]);
+ expect(await rowsOf(id)).toHaveLength(0);
+ // fewer asked for: a save starts the runs over, so three more kills
+ const patch = (body: Record) =>
+ api(w, 'owner', 'PATCH api/servers/[id]/triggers/[triggerId]', {
+ params: { id: w.server.id, triggerId: id },
+ body
+ });
+ expect((await patch({ config: { streak: 3, minPlayers: 2, reward: 'none' } })).status).toBe(
+ 200
+ );
+ invalidateTriggers(w.server.id);
+ await post(w.server.id, [ev(A, B)]);
+ expect(await rowsOf(id)).toHaveLength(0);
+ await post(w.server.id, [ev(A, C), ev(A, B)]);
+ const [set] = await rowsOf(id);
+ expect((set.params as { message: string }).message).toBe(
+ 'BOUNTY on p901: 3 kills without dying. Who ends the run?'
+ );
+ // B on a run of three too: one bounty at a time
+ await post(w.server.id, [ev(B, D), ev(B, D), ev(B, D)]);
+ expect(await rowsOf(id)).toHaveLength(1);
+ // a rename is a save: the bounty on A is called off
+ expect((await patch({ name: 'Wanted' })).status).toBe(200);
+ expect(await stateOf(id)).toBeNull();
+ invalidateTriggers(w.server.id);
+ await post(w.server.id, [ev(C, A)]);
+ expect(await rowsOf(id)).toHaveLength(1);
+ });
+
+ test('a worker restart keeps the hunt; a leave and a match end lapse it', async () => {
+ const w = await seedWorld(env);
+ await bountiesHook(w);
+ posts.length = 0;
+ const m = await memoryOf(w);
+ await look(m, A, B, C, D);
+ const id = await rule(w, { streak: 3, minPlayers: 2, reward: 'none' });
+ await post(w.server.id, [ev(A, B), ev(A, C), ev(A, B)]);
+ // restarted: the runs are gone, the bounty is read back from the rule's row
+ forgetBounties();
+ invalidateTriggers(w.server.id);
+ await post(w.server.id, [ev(C, A)]);
+ expect((await rowsOf(id)).map((r) => r.action)).toEqual(['broadcast', BOUNTY_CLAIM]);
+ // B marked, then gone: a look while they are within the leave grace keeps it
+ await post(w.server.id, [ev(B, A), ev(B, D), ev(B, A)]);
+ expect((await stateOf(id))?.open?.steamId).toBe(B);
+ await look(m, A, C, D);
+ expect((await stateOf(id))?.open?.steamId).toBe(B);
+ m.presence.open.get(B)!.lastSeen = Date.now() - LEAVE_GRACE_MS - 1000;
+ await look(m, A, C, D);
+ const lapse = (await rowsOf(id)).at(-1)!;
+ expect([lapse.action, lapse.target, lapse.okMessage]).toEqual([
+ BOUNTY_LAPSE,
+ B,
+ 'The bounty on p902 lapsed: they left the server (a run of 3).'
+ ]);
+ expect((await stateOf(id))?.open).toBeNull();
+ // A marked, then the match ends
+ await look(m, A, B, C, D);
+ await post(w.server.id, [ev(A, B), ev(A, C), ev(A, B)]);
+ expect((await stateOf(id))?.open?.steamId).toBe(A);
+ m.lastMatch = { map: 'Elsewhere', scores: [], matchSeconds: null };
+ await look(m, A, B, C, D);
+ expect((await rowsOf(id)).at(-1)!.okMessage).toBe(
+ 'The bounty on p901 lapsed: the match ended (a run of 3).'
+ );
+ // the runs started over with the match: two more kills mark nobody
+ await post(w.server.id, [ev(A, B), ev(A, C)]);
+ expect((await stateOf(id))?.open).toBeNull();
+ const [done] = await deliver(lapse.id);
+ expect(done.state).toBe('delivered');
+ const cards = await postsSoon(6);
+ expect(cards.map((c) => c.title)).toEqual([
+ 'Bounty set',
+ 'Bounty claimed',
+ 'Bounty set',
+ 'Bounty lapsed',
+ 'Bounty set',
+ 'Bounty lapsed'
+ ]);
+ expect(cards[3].description).toContain('**p902** left the server on a run of 3');
+ expect(cards[5].description).toContain('The match ended with **p901** on a run of 3');
+ });
+
+ test('a bounty read back after a restart is not claimed in a later match: it lapses', async () => {
+ const w = await seedWorld(env);
+ const m = await memoryOf(w);
+ // the look opens the match the kills are stamped with
+ await look(m, A, B, C, D);
+ const id = await rule(w, { streak: 3, minPlayers: 2 });
+ await post(w.server.id, [ev(A, B), ev(A, C), ev(A, B)]);
+ const [open] = await env.db
+ .select({ id: matches.id })
+ .from(matches)
+ .where(and(eq(matches.serverId, w.server.id), isNull(matches.endedAt)));
+ expect((await stateOf(id))?.open).toMatchObject({ steamId: A, match: `m${open.id}` });
+ // the worker restarts; meanwhile the match ends and the next one opens
+ forgetBounties();
+ invalidateTriggers(w.server.id);
+ await env.db.update(matches).set({ endedAt: new Date() }).where(eq(matches.id, open.id));
+ await env.db
+ .insert(matches)
+ .values({ serverId: w.server.id, startedAt: new Date(), map: 'Kavkazi' });
+ await post(w.server.id, [ev(B, A)]);
+ const rows = await rowsOf(id);
+ expect(rows.map((r) => r.action)).toEqual(['broadcast', BOUNTY_LAPSE]);
+ expect(rows[1].okMessage).toBe('The bounty on p901 lapsed: the match ended (a run of 3).');
+ expect((await stateOf(id))?.open).toBeNull();
+ // the runs started over with it: B's kill there is one, not a claim's leftover
+ await post(w.server.id, [ev(B, C), ev(B, D)]);
+ expect(await rowsOf(id)).toHaveLength(2);
+ });
+
+ test('the first look after the server was out of reach lapses it as such, not as a leave', async () => {
+ const w = await seedWorld(env);
+ const m = await memoryOf(w);
+ await look(m, A, B, C, D);
+ const id = await rule(w, { streak: 3, minPlayers: 2, reward: 'none' });
+ await post(w.server.id, [ev(A, B), ev(A, C), ev(A, B)]);
+ expect((await stateOf(id))?.open?.steamId).toBe(A);
+ // out of reach for a few looks: the worker closes every session and forgets the match
+ await closeAllSessions(env.db, m.presence);
+ m.failures = OFFLINE_AFTER_FAILURES;
+ m.lastMatch = null;
+ await look(m, A, B, C, D);
+ const lapse = (await rowsOf(id)).at(-1)!;
+ expect([lapse.action, lapse.okMessage]).toEqual([
+ BOUNTY_LAPSE,
+ 'The bounty on p901 lapsed: the server was out of reach (a run of 3).'
+ ]);
+ expect((await stateOf(id))?.open).toBeNull();
+ });
+
+ test("the rule acts on its own server's kills only", async () => {
+ const w = await seedWorld(env);
+ const m = await memoryOf(w);
+ await look(m, A, B, C, D);
+ const id = await rule(w, { streak: 3, minPlayers: 0 });
+ await post(w.otherServer.id, [ev(A, B), ev(A, C), ev(A, B)]);
+ expect(await rowsOf(id)).toHaveLength(0);
+ });
+
+ test('the dry run replays the day: bounties set with enough on, claimed, lapsed at a match end and at a leave', async () => {
+ const w = await seedWorld(env);
+ const now = Date.now();
+ const t = (minutes: number) => new Date(now - 6 * 3600_000 + minutes * 60_000);
+ const [m1] = await env.db
+ .insert(matches)
+ .values({ serverId: w.server.id, startedAt: t(-10), endedAt: t(30), map: 'Kavkazi' })
+ .returning({ id: matches.id });
+ const [m2] = await env.db
+ .insert(matches)
+ .values({ serverId: w.server.id, startedAt: t(30), map: 'Kavkazi' })
+ .returning({ id: matches.id });
+ await env.db.insert(samples).values([
+ { serverId: w.server.id, ts: t(-5), ok: true, playerCount: 4 },
+ { serverId: w.server.id, ts: t(4), ok: true, playerCount: 40 }
+ ]);
+ let n = 0;
+ const row = (
+ minute: number,
+ match: number,
+ killer: string | null,
+ victim: string,
+ teamKill = false
+ ) => ({
+ ts: t(minute),
+ serverId: w.server.id,
+ eventId: `dry-${++n}`,
+ instanceId: 'i',
+ matchId: 'boot',
+ matchRow: match,
+ eventTime: n,
+ map: 'Kavkazi',
+ killerSteamId: killer,
+ killerName: killer ? name(killer) : null,
+ killerFaction: killer ? SIDE[killer] : null,
+ victimSteamId: victim,
+ victimName: name(victim),
+ victimFaction: SIDE[victim],
+ cause: 'Id.Item.AK74M',
+ distanceM: 40,
+ headshot: false,
+ suicide: false,
+ teamKill,
+ tags: []
+ });
+ await env.db.insert(kills).values([
+ // four on: A's run of three marks nobody
+ row(1, m1.id, A, B),
+ row(2, m1.id, A, C),
+ row(3, m1.id, A, B),
+ // forty on: marked at the next kill, a teammate's kill does not claim, B's does
+ row(5, m1.id, A, C),
+ row(6, m1.id, D, A, true),
+ row(7, m1.id, B, A),
+ // C marked, then the match ends
+ row(10, m1.id, C, A),
+ row(11, m1.id, C, D),
+ row(12, m1.id, C, A),
+ row(31, m2.id, A, B),
+ // D marked, then leaves
+ row(40, m2.id, D, B),
+ row(41, m2.id, D, C),
+ row(42, m2.id, D, B)
+ ]);
+ await env.db.insert(playerSessions).values({
+ serverId: w.server.id,
+ steamId: D,
+ name: name(D),
+ joinedAt: t(0),
+ lastSeen: t(43),
+ leftAt: t(43)
+ });
+ const config = { streak: 3, minPlayers: 20, slotDays: 1 };
+ const r = await dryRun(
+ env,
+ (await env.db.select().from(servers).where(eq(servers.id, w.server.id)))[0],
+ 'bounty',
+ config
+ );
+ expect(r.fires).toBe(3);
+ expect(r.items.map((i) => i.text)).toEqual([
+ 'bounty on p901 (4 kills, 40 on): BOUNTY on p901: 4 kills without dying. Kill them for a reserved slot for 1 day.',
+ 'claimed by p902: p901 after a run of 4, a reserved slot for 1 day',
+ 'bounty on p903 (3 kills, 40 on): BOUNTY on p903: 3 kills without dying. Kill them for a reserved slot for 1 day.',
+ 'lapsed: the match ended, p903 on a run of 3',
+ 'bounty on p904 (3 kills, 40 on): BOUNTY on p904: 3 kills without dying. Kill them for a reserved slot for 1 day.',
+ 'lapsed: p904 left (a run of 3)'
+ ]);
+ expect(r.notes).toContain(
+ "3 bounties set, 1 claimed, 2 lapsed. Runs start over at each match, and nobody is marked while the server's samples show fewer than 20 on."
+ );
+ });
+});
From 1620f189c0c45936d3f4844fd84d510d90bb5772 Mon Sep 17 00:00:00 2001
From: James Causon
Date: Fri, 9 Oct 2026 00:09:51 +0100
Subject: [PATCH 059/102] Automation tab: the Bounty rule; org page: the
Bounties box
---
src/routes/(app)/orgs/[id]/+page.svelte | 9 +-
.../server/[id]/automation/+page.server.ts | 4 +-
.../(app)/server/[id]/automation/+page.svelte | 251 +++++++++++++++++-
3 files changed, 249 insertions(+), 15 deletions(-)
diff --git a/src/routes/(app)/orgs/[id]/+page.svelte b/src/routes/(app)/orgs/[id]/+page.svelte
index d2a5ecba..70596098 100644
--- a/src/routes/(app)/orgs/[id]/+page.svelte
+++ b/src/routes/(app)/orgs/[id]/+page.svelte
@@ -748,10 +748,11 @@
A webhook is one Discord channel, and each one carries what you tick for it: the audit trail
- (bans, kicks, trigger actions, sign-ins), team kills from the kill feed, and live status
- cards, one per server, showing the map and who is on. Add one webhook per channel; a
- team-kill channel is simply a webhook with only that box ticked. In Discord, open the
- channel's settings → Integrations → Webhooks, copy the URL and paste it here.
+ (bans, kicks, trigger actions, sign-ins), team kills from the kill feed, bounties set and
+ claimed by a Bounty rule, and live status cards, one per server, showing the map and who is
+ on. Add one webhook per channel; a team-kill or bounty channel is simply a webhook with only
+ that box ticked. In Discord, open the channel's settings → Integrations → Webhooks, copy the
+ URL and paste it here.
{#each data.webhooks as w (w.id)}
diff --git a/src/routes/(app)/server/[id]/automation/+page.server.ts b/src/routes/(app)/server/[id]/automation/+page.server.ts
index 538fe8b6..95a003b3 100644
--- a/src/routes/(app)/server/[id]/automation/+page.server.ts
+++ b/src/routes/(app)/server/[id]/automation/+page.server.ts
@@ -11,8 +11,10 @@ import { listTriggers } from '$lib/server/triggers';
* layouts (SvelteKit's __data.json), so the layout's refusal protects nothing below it. Viewers
* see the rules read-only.
*/
-export const load: PageServerLoad = async ({ locals, params }) => {
+export const load: PageServerLoad = async ({ locals, params, depends }) => {
const env = getEnv();
+ // re-read on its own when a Bounty rule acts (its open bounty is on the row)
+ depends('warcon:triggers');
try {
const { server } = await requireServerCap(env, locals, params.id, 'automation.manage');
// What the kinds need before they can run here, so the Add menu and the editor can say so.
diff --git a/src/routes/(app)/server/[id]/automation/+page.svelte b/src/routes/(app)/server/[id]/automation/+page.svelte
index 6ab6ef95..2f6406a7 100644
--- a/src/routes/(app)/server/[id]/automation/+page.svelte
+++ b/src/routes/(app)/server/[id]/automation/+page.svelte
@@ -1,6 +1,6 @@
+
+
+
+
+
Add a rule
+
Pick what it does; you set it up next.
+
+
+
+
+
+ {#each shown as g (g.group)}
+
+
{g.group}
+
+ {#each g.kinds as k (k.kind)}
+ {@const here = rules.filter((r) => r.kind === k.kind)}
+ {@const only = ONE_PER_SERVER.includes(k.kind) ? here[0] : undefined}
+ {@const why = only ? '' : lacks(k.kind)}
+
+ {/each}
+
+
+ {:else}
+
No rule matches “{query.trim()}”.
+ {/each}
+
diff --git a/src/routes/(app)/server/[id]/automation/+page.svelte b/src/routes/(app)/server/[id]/automation/+page.svelte
index 2f6406a7..c6fbbc8e 100644
--- a/src/routes/(app)/server/[id]/automation/+page.svelte
+++ b/src/routes/(app)/server/[id]/automation/+page.svelte
@@ -24,7 +24,8 @@
import SortHeader from '$lib/components/SortHeader.svelte';
import { TableSort, matches } from '$lib/table.svelte';
import { watchLive } from '$lib/live';
- import { RULE_GROUPS, RULE_KINDS } from '$lib/rule-kinds';
+ import { RULE_KINDS } from '$lib/rule-kinds';
+ import RulePicker from '$lib/components/RulePicker.svelte';
import type {
DryRunResult,
MapSelection,
@@ -126,11 +127,9 @@
? 'warn'
: 'err';
- // The kinds, grouped by what they act on for the Add menu ($lib/rule-kinds). `needs` is what a
- // kind must have before it can run here, shown in the menu and at the top of its editor; '' when
- // it can.
+ // The kinds, as the rule picker groups them ($lib/rule-kinds). `needs` is what a kind must have
+ // before it can run here, shown on its card and at the top of its editor; '' when it can.
const KINDS = RULE_KINDS;
- const GROUPS = RULE_GROUPS;
/** The outbox action as the table shows it: a flag sends nothing to the game, so it reads as one. */
const actionLabel = (action: string) =>
action === 'name_flag' || action === 'kill_rate_flag' || action === 'kill_distance_flag'
@@ -264,19 +263,23 @@
].sort((a, b) => a.label.localeCompare(b.label));
const holds = (list: string[], cause: string) =>
list.some((c) => c.toLowerCase() === cause.toLowerCase());
- /** A kind that lacks what it needs stays in the menu, greyed, with the reason in a few words. */
+ /** A kind that lacks what it needs stays in the picker, greyed, with the reason in a few words. */
const short = (kind: TriggerKind): string =>
- kind === 'team_kill' ||
- kind === 'kill_rate' ||
- kind === 'kill_distance' ||
- kind === 'name_change' ||
- (kind === 'bounty' && !data.feed)
- ? 'needs the kill feed'
- : kind === 'bounty'
- ? 'needs Chat'
- : kind === 'risk_kick'
- ? 'needs a Steam key'
- : '';
+ !needs(kind)
+ ? ''
+ : kind === 'team_kill' ||
+ kind === 'kill_rate' ||
+ kind === 'kill_distance' ||
+ kind === 'name_change' ||
+ (kind === 'bounty' && !data.feed)
+ ? 'Needs the kill feed'
+ : kind === 'bounty'
+ ? 'Needs Chat'
+ : kind === 'risk_kick'
+ ? 'Needs a Steam key'
+ : kind === 'seed_reward'
+ ? 'Needs Reserved slots'
+ : '';
/** A Bounty rule's texts until they are written: a slot is named, an announcement-only rule asks. */
const bountyTexts = (reward: 'slot' | 'none') =>
reward === 'slot'
@@ -297,7 +300,14 @@
for (const k of ['setMessage', 'claimMessage', 'whisper'] as const)
if (f[k] === from[k]) f[k] = to[k];
};
- let addOpen = $state(false);
+ /** The Add rule picker is open; `fromPicker`: the editor came from it, so it offers the way back. */
+ let picking = $state(false);
+ let fromPicker = $state(false);
+ function pick(kind: TriggerKind, t?: TriggerView) {
+ picking = false;
+ open(kind, t);
+ fromPicker = true;
+ }
// The status lines count up on their own: a minute clock, only while the page is open.
let now = $state(Date.now());
@@ -503,6 +513,7 @@
/** The editor for a new rule of a kind, an existing rule, or a copy of one (`copy`). */
function open(kind: TriggerKind, t?: TriggerView, copy = false) {
+ fromPicker = false;
const c = (t?.config ?? {}) as Record;
const s = (k: string, d: string) => (typeof c[k] === 'string' ? (c[k] as string) : d);
const n = (k: string, d: number) => (typeof c[k] === 'number' ? (c[k] as number) : d);
@@ -1055,11 +1066,6 @@
}
- (addOpen = false)}
- onkeydown={(e) => e.key === 'Escape' && (addOpen = false)}
-/>
-
Rules act on {data.server.demo ? 'the demo server' : 'this server'} as things happen: a join, a kill,
a quiet hour. Every action is recorded below and in the audit trail as
@@ -1092,50 +1098,9 @@
{#if admin}
-
-
- {#if addOpen}
-
-
e.stopPropagation()}
- >
- {#each GROUPS as g (g)}
-
{g}
- {#each KINDS.filter((k) => k.group === g) as k (k.kind)}
-
- {/each}
- {/each}
-
- Handed out on every server in {org.name}, now and when one is added later. A slot with an
- expiry is withdrawn by the panel when the time comes. To reserve a slot on one server only,
- use that server's Reserved slots tab.
+ {#if group}
+ While the group is on, a player added here gets the slot at once; while it is off, when it
+ is next switched on. A player with an expiry leaves the group when the time comes.
+ {:else}
+ Handed out on every server in {org.name}, now and when one is added later. A slot with an
+ expiry is withdrawn by the panel when the time comes. To reserve a slot on one server only,
+ use that server's Reserved slots tab.
+ {/if}
- list slots were reserved here and can be withdrawn; member slots follow membership and go when the member leaves or the switch above is turned off. Server
- badges: applied by the panel, pending the next
- sync, failed (hover for why), local already on that server
- but added outside the panel, so the panel never removes it.
-
+ {#if group}
+
+ Server badges as on the organisation's list: applied by the panel,
+ pending the next sync, failed (hover for
+ why), local already on that server but added outside the panel.
+
+ {:else}
+
+ list slots were reserved here and can be withdrawn; member slots follow membership and go when the member leaves or the switch above is turned off. Server
+ badges: applied by the panel, pending the
+ next sync, failed (hover for why), local already on that
+ server but added outside the panel, so the panel never removes it.
+
+ {/if}
+ {:else if group}
+
+ Nobody is in {group.name} yet.
+ Add players above. They skip the queue on {where} while the group is on.
+
{:else}
Nobody holds a reserved slot yet.
diff --git a/src/routes/(app)/orgs/[id]/reserved/[groupId]/+page.server.ts b/src/routes/(app)/orgs/[id]/reserved/[groupId]/+page.server.ts
new file mode 100644
index 00000000..59e81d8c
--- /dev/null
+++ b/src/routes/(app)/orgs/[id]/reserved/[groupId]/+page.server.ts
@@ -0,0 +1,24 @@
+import { error } from '@sveltejs/kit';
+import type { PageServerLoad } from './$types';
+import { getEnv } from '$lib/server/env';
+import { requireListsRole } from '$lib/server/access';
+import { normalizeError } from '$lib/server/http';
+import { groupEntriesView, groupOf, groupsView } from '$lib/server/slot-groups';
+
+/** One of the org's reserved-slot groups: for those who edit its reserved slots, found within it. */
+export const load: PageServerLoad = async ({ locals, params }) => {
+ const env = getEnv();
+ try {
+ const { org } = await requireListsRole(env, locals, params.id, 'reserve');
+ const group = await groupOf(env, org, params.groupId);
+ const [entries, groups] = await Promise.all([
+ groupEntriesView(env, org, group),
+ groupsView(env, org)
+ ]);
+ return { entries, group: groups.find((g) => g.id === group.id)! };
+ } catch (err) {
+ const known = normalizeError(err);
+ if (!known) throw err;
+ error(known.status, known.message);
+ }
+};
diff --git a/src/routes/(app)/orgs/[id]/reserved/[groupId]/+page.svelte b/src/routes/(app)/orgs/[id]/reserved/[groupId]/+page.svelte
new file mode 100644
index 00000000..e016efce
--- /dev/null
+++ b/src/routes/(app)/orgs/[id]/reserved/[groupId]/+page.svelte
@@ -0,0 +1,14 @@
+
+
+
diff --git a/src/routes/(app)/server/[id]/slots/+page.svelte b/src/routes/(app)/server/[id]/slots/+page.svelte
index 294b68ba..cca077be 100644
--- a/src/routes/(app)/server/[id]/slots/+page.svelte
+++ b/src/routes/(app)/server/[id]/slots/+page.svelte
@@ -490,7 +490,12 @@
? ''
: ` · ${s.src.state}`}
- {:else}
+
+ {#if s.src.group}{s.src.group}{/if}
+ {:else if s.pending !== 'leaves'}
+
local
{/if}
{#if s.pending === 'leaves'}
diff --git a/src/test/load-matrix.test.ts b/src/test/load-matrix.test.ts
index 3d8957c9..f13d4511 100644
--- a/src/test/load-matrix.test.ts
+++ b/src/test/load-matrix.test.ts
@@ -40,6 +40,8 @@ const MATRIX: Record = {
'orgs/[id]/seasons/+page.server.ts': 'orgOwner',
'orgs/[id]/bans/+page.server.ts': 'lists:ban',
'orgs/[id]/reserved/+page.server.ts': 'lists:reserve',
+ // "Group not found." is not a hidden answer: a group looked up before the role check fails it
+ 'orgs/[id]/reserved/[groupId]/+page.server.ts': 'lists:reserve',
'orgs/[id]/players/+page.server.ts': 'lists',
'server/[id]/+layout.server.ts': 'cap:server.view',
'server/[id]/automation/+page.server.ts': 'cap:automation.manage',
From 80b0f23fa3fe69bfbf923fcd548a00210fbb037d Mon Sep 17 00:00:00 2001
From: James Causon
Date: Fri, 9 Oct 2026 17:55:25 +0100
Subject: [PATCH 072/102] Rotation: shuffle with the maps, and each map's
zones, in turn
---
src/lib/rotation-doc.test.ts | 192 +++++++++++++++++++++++++++
src/lib/rotation-doc.ts | 250 +++++++++++++++++++++++++++++++++++
2 files changed, 442 insertions(+)
diff --git a/src/lib/rotation-doc.test.ts b/src/lib/rotation-doc.test.ts
index 4a21b970..fe5baee5 100644
--- a/src/lib/rotation-doc.test.ts
+++ b/src/lib/rotation-doc.test.ts
@@ -2,11 +2,18 @@ import { test, expect } from 'bun:test';
import {
formatRotationEntry,
parseRotationEntry,
+ alignRotation,
+ entryKey,
+ keepApart,
+ mapsInOrder,
setModifierOnAll,
+ shuffleFor,
+ shuffleRotation,
rotationFromText,
rotationIntoText
} from './rotation-doc';
import { getArray, parseIni, setArrayInText } from './config-doc';
+import type { MapSelection } from './types';
// A slice of the TLR server's document (live build CL-499480), as GET /v1/config returns it.
const TEXT = [
@@ -140,3 +147,188 @@ test('setModifierOnAll takes a modifier off every entry, in any case, and keeps
skipped: 1
});
});
+
+// A rotation of `counts[m]` entries on map m, with `zones` control zones each, told apart by lighting.
+const MAPS = ['Kavkazi', 'Europe', 'NorthAmerica', 'Mars'];
+const rotationOf = (counts: number[], zones = 1): MapSelection[] =>
+ counts.flatMap((c, m) =>
+ Array.from({ length: c }, (_, i) => ({
+ map: MAPS[m],
+ experiences: [],
+ lighting: `L${Math.floor(i / zones)}`,
+ zoneAlternator: zones > 1 ? `Zone${i % zones}` : ''
+ }))
+ );
+const repeats = (list: MapSelection[]) =>
+ list.filter((e, i) => i > 0 && e.map.toLowerCase() === list[i - 1].map.toLowerCase()).length;
+const initials = (list: MapSelection[]) => list.map((e) => e.map[0]).join('');
+const seeded = (seed: number) => () => (seed = (seed * 1103515245 + 12345) % 2 ** 31) / 2 ** 31;
+
+test('shuffleRotation keeps every entry and the maps apart wherever the counts allow it', () => {
+ const random = seeded(1);
+ const each = (counts: number[]) => {
+ const entries = rotationOf(counts);
+ const n = entries.length;
+ const top = Math.max(...counts);
+ for (const maps of [[], MAPS.slice(0, counts.length).reverse()]) {
+ const out = shuffleRotation(entries, maps, random);
+ expect(new Set(out)).toEqual(new Set(entries));
+ expect(out).toHaveLength(n);
+ // No repeat at all unless one map has more than half; then no more than it must.
+ expect(repeats(out)).toBe(Math.max(0, 2 * top - n - 1));
+ // Coming round again, last and then first are different maps whenever they can be.
+ if (n >= 2 && 2 * top <= n) expect(out[n - 1].map).not.toBe(out[0].map);
+ }
+ };
+ for (let a = 1; a <= 7; a++) {
+ each([a]);
+ for (let b = 1; b <= 7; b++) {
+ each([a, b]);
+ for (let c = 1; c <= 7; c++) each([a, b, c]);
+ }
+ }
+ each([3, 2, 2, 1]);
+ each([5, 1, 1, 1]);
+ expect(shuffleRotation([])).toEqual([]);
+});
+
+test('shuffleRotation plays the maps in the order given, round after round', () => {
+ // Fewer entries on one map: whole rounds until it runs out, then the other two in turn.
+ const out = shuffleRotation(rotationOf([32, 32, 24]), ['Europe', 'Kavkazi', 'NorthAmerica']);
+ expect(initials(out)).toBe('EKN'.repeat(24) + 'EK'.repeat(8));
+ // A map the order leaves out follows those it names; no order is the order the rotation has.
+ expect(initials(shuffleRotation(rotationOf([2, 2, 2]), ['NorthAmerica']))).toBe('NKENKE');
+ expect(initials(shuffleRotation(rotationOf([2, 2, 2]), []))).toBe('KENKEN');
+ // Rounds stop where going on would put a map twice in a row later: the rest is spread instead.
+ const uneven = shuffleRotation(rotationOf([40, 30, 18]), ['Kavkazi', 'Europe', 'NorthAmerica']);
+ expect(initials(uneven).startsWith('KEN'.repeat(8))).toBe(true);
+ expect(initials(uneven).startsWith('KEN'.repeat(9))).toBe(false);
+ expect(repeats(uneven)).toBe(0);
+});
+
+test("shuffleRotation takes each map's control zones in turn, its times of day in a new order", () => {
+ const out = shuffleRotation(rotationOf([32, 32, 24], 4), [], seeded(2));
+ for (const map of MAPS.slice(0, 3)) {
+ const own = out.filter((e) => e.map === map);
+ const zones = own.map((e) => e.zoneAlternator);
+ expect(zones.filter((z, i) => i > 0 && z === zones[i - 1])).toEqual([]);
+ // a whole round of zones before any comes again
+ expect(new Set(zones.slice(0, 4)).size).toBe(4);
+ expect(zones.slice(4, 8)).toEqual(zones.slice(0, 4));
+ }
+ const lightings = out.filter((e) => e.map === 'Kavkazi').map((e) => e.lighting);
+ expect(lightings).not.toEqual([...lightings].sort());
+});
+
+test('shuffleRotation plays a map with over half the entries in even runs between the others', () => {
+ const out = shuffleRotation(rotationOf([10, 2, 2]), [], seeded(3));
+ expect(out.map((e) => (e.map === 'Kavkazi' ? 'K' : '-')).join('')).toBe('KK-KK-KK-KK-KK');
+ expect(repeats(out)).toBe(5);
+});
+
+test('shuffleRotation tells maps apart in any letter case', () => {
+ const entries = [
+ { map: 'Kavkazi', experiences: [], lighting: 'A', zoneAlternator: '' },
+ { map: 'kavkazi', experiences: [], lighting: 'B', zoneAlternator: '' },
+ { map: 'Europe', experiences: [], lighting: 'C', zoneAlternator: '' }
+ ];
+ for (let seed = 1; seed < 20; seed++) {
+ const out = shuffleRotation(entries, ['EUROPE'], seeded(seed));
+ expect(out.map((e) => e.lighting[0] && e.map.toLowerCase())).toEqual([
+ 'kavkazi',
+ 'europe',
+ 'kavkazi'
+ ]);
+ }
+ expect(mapsInOrder(entries)).toEqual(['Kavkazi', 'Europe']);
+});
+
+test('alignRotation puts the map that follows the one on where the server takes its next entry', () => {
+ const list = shuffleRotation(rotationOf([3, 3, 3]), ['Europe', 'Kavkazi', 'NorthAmerica']);
+ expect(initials(list)).toBe('EKNEKNEKN');
+ // On Europe, next from the top of the new order (a restart): Kavkazi first.
+ expect(initials(alignRotation(list, 'Europe', [0]))).toBe('KNEKNEKNE');
+ // From the entry after place 4 (a pointer that keeps its place): Kavkazi there.
+ expect(initials(alignRotation(list, 'Europe', [5]))[5]).toBe('K');
+ // Two places that cannot both hold Kavkazi: the first listed gets it, then the second does as well
+ // as the turn allows (after Kavkazi at the top, Northamerica: at least not Europe).
+ const both = initials(alignRotation(list, 'Europe', [0, 1]));
+ expect(both.slice(0, 2)).toBe('KN');
+ // The other way round, the second place cannot avoid Europe: in turn, it comes just before.
+ expect(initials(alignRotation(list, 'Europe', [1, 0])).slice(0, 2)).toBe('EK');
+ // Turned by less than one round, so the rounds stay whole; nothing is lost or doubled.
+ const turned = alignRotation(list, 'NorthAmerica', [0]);
+ expect(new Set(turned)).toEqual(new Set(list));
+ expect(repeats(turned)).toBe(0);
+ expect(turned[turned.length - 1].map).not.toBe(turned[0].map);
+ // A map not in the rotation: anything but it will do, so the list may stay as it is.
+ expect(initials(alignRotation(list, 'Mars', [0], () => 0))).toBe('EKNEKNEKN');
+ // A list that comes round onto its own map is not turned.
+ const wraps = rotationOf([3, 1, 1]);
+ const odd = shuffleRotation(wraps, [], seeded(4));
+ expect(alignRotation(odd, 'Kavkazi', [0])).toBe(odd);
+});
+
+test('shuffleFor starts on the map after the one on and puts the entry on last', () => {
+ const order = ['Europe', 'Kavkazi', 'NorthAmerica'];
+ for (const [counts, zones] of [
+ [[12, 12, 12], 4],
+ [[32, 32, 24], 4],
+ [[5, 7, 6], 1]
+ ] as [number[], number][]) {
+ const entries = rotationOf(counts, zones);
+ for (const on of [entries[0], entries[counts[0]], entries[entries.length - 1]]) {
+ const out = shuffleFor(entries, order, on, seeded(5));
+ expect(new Set(out)).toEqual(new Set(entries));
+ expect(repeats(out)).toBe(0);
+ expect(out[0].map).not.toBe(out[out.length - 1].map);
+ // a server that stays on its entry, or goes to the top, goes on to the map after it
+ expect(out[out.length - 1]).toBe(on);
+ const turn = order.filter((m) => counts[MAPS.indexOf(m)] > 0);
+ expect(out[0].map).toBe(turn[(turn.indexOf(on.map) + 1) % turn.length]);
+ }
+ }
+ // An entry the rotation does not hold: only the turn starts after its map.
+ const entries = rotationOf([3, 3, 3]);
+ const other = { map: 'Kavkazi', experiences: [], lighting: 'Elsewhere', zoneAlternator: '' };
+ expect(initials(shuffleFor(entries, ['Europe', 'Kavkazi', 'NorthAmerica'], other))).toBe(
+ 'NEKNEKNEK'
+ );
+ expect(initials(shuffleFor(entries, ['Europe', 'Kavkazi', 'NorthAmerica'], null))).toBe(
+ 'EKNEKNEKN'
+ );
+ expect(entryKey(other)).toBe(entryKey({ ...other, map: 'KAVKAZI', zoneAlternator: 'None' }));
+});
+
+test('keepApart ends on another key than the one asked, where the counts allow it', () => {
+ const items = [...'aaaabbbbcccc'].map((k, i) => ({ k, i }));
+ for (let seed = 1; seed < 30; seed++) {
+ const out = keepApart(items, (x) => x.k, {
+ order: ['a', 'b', 'c'],
+ last: 'c',
+ random: seeded(seed)
+ });
+ expect(out.map((x) => x.k).join('')).not.toMatch(/(.)\1/);
+ expect(out[out.length - 1].k).not.toBe('c');
+ }
+ // Twelve of one key and eleven of another can only alternate from it to it: asked to end on
+ // another, it still keeps them apart.
+ const tight = [...('a'.repeat(12) + 'b'.repeat(11))].map((k, i) => ({ k, i }));
+ const out = keepApart(tight, (x) => x.k, { order: ['b', 'a'], last: 'a' });
+ expect(out.map((x) => x.k).join('')).toBe('ab'.repeat(11) + 'a');
+});
+
+test('shuffleFor does not start on the map on when it has half the entries, nor its zone', () => {
+ const order = ['Europe', 'Kavkazi', 'NorthAmerica'];
+ const entries = rotationOf([16, 8, 8], 4);
+ for (let seed = 1; seed < 40; seed++) {
+ const on = entries[(seed * 7) % 16];
+ const out = shuffleFor(entries, order, on, seeded(seed));
+ // Kavkazi has half: after it in the turn comes NorthAmerica
+ expect(out[0].map).toBe('NorthAmerica');
+ expect(out[out.length - 1]).toBe(on);
+ expect(repeats(out)).toBe(0);
+ const next = out.find((e) => e.map === on.map && e !== on)!;
+ expect(next.zoneAlternator).not.toBe(on.zoneAlternator);
+ }
+});
diff --git a/src/lib/rotation-doc.ts b/src/lib/rotation-doc.ts
index 8465c29d..e8cbaa87 100644
--- a/src/lib/rotation-doc.ts
+++ b/src/lib/rotation-doc.ts
@@ -68,6 +68,256 @@ export function setModifierOnAll(
return { entries: next, changed, skipped };
}
+function shuffled(list: T[], random: () => number): T[] {
+ const out = list.slice();
+ for (let i = out.length - 1; i > 0; i--) {
+ const j = Math.floor(random() * (i + 1));
+ [out[i], out[j]] = [out[j], out[i]];
+ }
+ return out;
+}
+
+/**
+ * The piles dealt out one item at a time so that items of one pile stand apart. The piles take
+ * turns in the order given, a whole round at a time, for as many rounds as leave the rest able to
+ * stand apart; the rest follows in a smooth round robin over what is left of each pile, the earlier
+ * pile first on a tie (and the list starts on the first pile that can start it). No pile plays
+ * twice in a row wherever the counts allow it, and the list ends
+ * on another pile than `endNot`, or, without one, than the pile it starts with, so it comes round
+ * cleanly. A pile of more than half the items cannot be kept apart: it goes in runs as even as they
+ * can be, with one other item between each.
+ */
+function deal(piles: T[][], endNot = -1): T[] {
+ const counts = piles.map((p) => p.length);
+ const n = counts.reduce((a, b) => a + b, 0);
+ if (!n) return [];
+ const top = Math.max(...counts);
+ if (2 * top > n + 1) {
+ const big = counts.indexOf(top);
+ const rest = deal(piles.filter((_, i) => i !== big));
+ const runs = rest.length + 1;
+ const out: T[] = [];
+ for (let i = 0, from = 0; i < runs; i++) {
+ const to = Math.round(((i + 1) * top) / runs);
+ out.push(...piles[big].slice(from, to));
+ from = to;
+ if (i < rest.length) out.push(rest[i]);
+ }
+ return out;
+ }
+ const k = piles.length;
+ let left = counts.slice();
+ // With `slots` still to fill after pile `prev`, the last of them not pile `end`: can every pile's
+ // remaining items stay apart?
+ const fits = (slots: number, prev: number, end: number) =>
+ left.every((have, m) => {
+ if (!have) return true;
+ const free = slots - (m === prev ? 1 : 0) - (m === end ? 1 : 0);
+ return free > 0 && have <= Math.ceil(free / 2);
+ });
+ if (endNot >= 0 && !fits(n, -1, endNot)) endNot = -1;
+ // Coming round onto the first pile again only where the counts leave room for it.
+ const round = 2 * top <= n;
+ const endFor = (first: number) => (endNot >= 0 ? endNot : round ? first : -1);
+ let rounds = Math.min(...counts);
+ for (; rounds > 0; rounds--) {
+ left = counts.map((c) => c - rounds);
+ // rounds that use up every item end on the last pile, which must be allowed to end it
+ const rest = n - k * rounds;
+ if (rest ? fits(rest, k - 1, endFor(0)) : k - 1 !== endFor(0)) break;
+ }
+ left = counts.map((c) => c - rounds);
+ const out: T[] = [];
+ for (let r = 0; r < rounds; r++) for (let m = 0; m < k; m++) out.push(piles[m][r]);
+ const rest = n - k * rounds;
+ const weight = left.slice();
+ const credit = piles.map(() => 0);
+ let first = rounds ? 0 : -1;
+ let last = rounds ? k - 1 : -1;
+ for (let t = 0; t < rest; t++) {
+ for (let m = 0; m < k; m++) credit[m] += weight[m];
+ let pick = -1;
+ for (let m = 0; m < k; m++) {
+ if (!left[m] || m === last) continue;
+ left[m]--;
+ const ok = fits(rest - t - 1, m, endFor(first < 0 ? m : first));
+ left[m]++;
+ if (ok && (pick < 0 || (first >= 0 && credit[m] > credit[pick]))) pick = m;
+ }
+ credit[pick] -= rest;
+ out.push(piles[pick][counts[pick] - left[pick]--]);
+ if (first < 0) first = pick;
+ last = pick;
+ }
+ return out;
+}
+
+/**
+ * `items` in an order that keeps those of one key apart (see `deal`), each key's own items in the
+ * order `within` gives them, a shuffle by default. The keys take turns in `order`; a key it leaves
+ * out follows the ones it names, in the order it first appears. Without `order`, a random order.
+ * `last`: a key the order should not end on.
+ */
+export function keepApart(
+ items: T[],
+ keyOf: (item: T) => string,
+ opts: {
+ order?: string[];
+ within?: (items: T[]) => T[];
+ last?: string;
+ random?: () => number;
+ } = {}
+): T[] {
+ const random = opts.random ?? Math.random;
+ const byKey = new Map();
+ for (const item of items) {
+ const k = keyOf(item);
+ const pile = byKey.get(k);
+ if (pile) pile.push(item);
+ else byKey.set(k, [item]);
+ }
+ const keys = opts.order
+ ? [...new Set([...opts.order.filter((k) => byKey.has(k)), ...byKey.keys()])]
+ : shuffled([...byKey.keys()], random);
+ const within = opts.within ?? ((list: T[]) => shuffled(list, random));
+ const piles = keys.map((k) => within(byKey.get(k)!));
+ return deal(piles, opts.last === undefined ? -1 : keys.indexOf(opts.last));
+}
+
+const mapKey = (e: MapSelection) => e.map.toLowerCase();
+const zoneKey = (e: MapSelection) => (e.zoneAlternator || '').toLowerCase();
+
+/** One rotation entry as text, the same whichever of the server and the document gives it. */
+export function entryKey(e: MapSelection): string {
+ const v = (x: string | undefined) => (!x || /^none$/i.test(x) ? '' : x.toLowerCase());
+ const exps = e.experiences.map((x) => x.toLowerCase()).sort();
+ return [v(e.map), exps.join('+'), v(e.lighting), v(e.zoneAlternator)].join('|');
+}
+
+/** The maps of a rotation in the order it first has them, spelled as they first are. */
+export function mapsInOrder(entries: MapSelection[]): string[] {
+ const seen = new Map();
+ for (const e of entries) if (!seen.has(mapKey(e))) seen.set(mapKey(e), e.map);
+ return [...seen.values()];
+}
+
+/**
+ * The maps of a rotation in the order they take turns: those `maps` names first, in its order, then
+ * the rotation's others in the order it first has them; spelled as the rotation has them.
+ */
+export function turnOf(entries: MapSelection[], maps: string[]): string[] {
+ const spelled = new Map(mapsInOrder(entries).map((m) => [m.toLowerCase(), m]));
+ return [...new Set([...maps.map((m) => m.toLowerCase()), ...spelled.keys()])]
+ .filter((m) => spelled.has(m))
+ .map((m) => spelled.get(m)!);
+}
+
+/**
+ * The rotation in a new order. The maps take turns in `maps` (one it leaves out follows, in the
+ * order the rotation first has it), and among each map's own entries its control zones take turns,
+ * its times of day in a new order (`zones` can order a map's entries instead). `endNot`: a map the
+ * order should not end on. Where the counts cannot be kept apart, see `deal`.
+ */
+export function shuffleRotation(
+ entries: MapSelection[],
+ maps: string[] = [],
+ random: () => number = Math.random,
+ opts: { endNot?: string; zones?: (own: MapSelection[]) => MapSelection[] } = {}
+): MapSelection[] {
+ return keepApart(entries, mapKey, {
+ order: maps.map((m) => m.toLowerCase()),
+ within: opts.zones ?? ((own) => keepApart(own, zoneKey, { random })),
+ last: opts.endNot?.toLowerCase(),
+ random
+ });
+}
+
+/** How often a map comes twice in a row: within the list, then once more when it comes round. */
+const repeatsIn = (list: MapSelection[]) => {
+ const within = list.filter((e, i) => i > 0 && mapKey(e) === mapKey(list[i - 1])).length;
+ const round = list.length > 1 && mapKey(list[0]) === mapKey(list[list.length - 1]) ? 1 : 0;
+ return within * 2 + round;
+};
+
+/**
+ * The rotation in a new order (`shuffleRotation`) for a server playing `on` when it is written. The
+ * turn starts with the map after the one on, and the entry on goes last: a server that stays on
+ * the entry it is playing then goes on from the top of the new order, as one that starts from the
+ * top does (a restart), and either way the map after the one on comes next. The map on's control
+ * zones likewise start after the zone on, so its next turn is on another. Where the counts do not
+ * allow that, or the rotation does not hold the entry, the plain shuffle with that turn.
+ */
+export function shuffleFor(
+ entries: MapSelection[],
+ maps: string[],
+ on: MapSelection | null,
+ random: () => number = Math.random
+): MapSelection[] {
+ const turn = turnOf(entries, maps).map((m) => m.toLowerCase());
+ const at = on ? turn.indexOf(mapKey(on)) : -1;
+ const after = at < 0 ? turn : [...turn.slice(at + 1), ...turn.slice(0, at + 1)];
+ // The map on's entries: its zones in turn from the one after the zone on, so its next turn is
+ // on another zone, and ending on another too (the entry on follows them) unless that costs the
+ // next turn, which comes first: the end only shows when the whole list comes round.
+ const zones = (own: MapSelection[]) => {
+ if (!on || mapKey(own[0]) !== mapKey(on)) return keepApart(own, zoneKey, { random });
+ const keys = shuffled([...new Set(own.map(zoneKey))], random);
+ const z = keys.indexOf(zoneKey(on));
+ const order = z < 0 ? keys : [...keys.slice(z + 1), ...keys.slice(0, z + 1)];
+ const both = keepApart(own, zoneKey, { order, last: zoneKey(on), random });
+ if (z < 0 || zoneKey(both[0]) !== zoneKey(on)) return both;
+ const next = keepApart(own, zoneKey, { order, random });
+ return zoneKey(next[0]) !== zoneKey(on) ? next : both;
+ };
+ const plain = shuffleRotation(entries, after, random, { zones });
+ const i = on ? entries.findIndex((e) => entryKey(e) === entryKey(on)) : -1;
+ if (i < 0) return plain;
+ const rest = [...entries.slice(0, i), ...entries.slice(i + 1)];
+ const last = [
+ ...shuffleRotation(rest, after, random, { endNot: entries[i].map, zones }),
+ entries[i]
+ ];
+ return repeatsIn(last) <= repeatsIn(plain) ? last : plain;
+}
+
+/**
+ * A shuffled rotation turned round by fewer places than it has maps, so its whole rounds stay at
+ * the top, to suit the map on when it is written. `after` lists where the server may take its next
+ * entry from once the order changes, the likeliest first: each should hold the map that follows
+ * `playing` in the turn, or at least another map. Among the turns that do best, one at random. A
+ * list that comes round onto its own map is left as it is: turning it would put a map twice in a
+ * row.
+ */
+export function alignRotation(
+ list: MapSelection[],
+ playing: string,
+ after: number[],
+ random: () => number = Math.random
+): MapSelection[] {
+ const n = list.length;
+ if (n < 2 || mapKey(list[0]) === mapKey(list[n - 1])) return list;
+ const turn = [...new Set(list.map(mapKey))];
+ const now = playing.toLowerCase();
+ const at = turn.indexOf(now);
+ const next = at < 0 ? null : turn[(at + 1) % turn.length];
+ const score = (r: number) =>
+ after.map((p) => {
+ const m = mapKey(list[(((p + r) % n) + n) % n]);
+ return m === next ? 2 : m === now ? 0 : 1;
+ });
+ let best: number[] = [];
+ let top: number[] = [];
+ for (let r = 0; r < Math.min(turn.length, n); r++) {
+ const s = score(r);
+ const i = s.findIndex((v, j) => v !== top[j]);
+ const better = !best.length || (i >= 0 && s[i] > top[i]);
+ if (better) [best, top] = [[r], s];
+ else if (i < 0) best.push(r);
+ }
+ const r = best[Math.floor(random() * best.length)];
+ return r ? [...list.slice(r), ...list.slice(0, r)] : list;
+}
+
export function rotationFromText(text: string): RotationDoc {
const doc = parseIni(text);
const enabled = getScalar(doc, S_ROTATION, 'bEnabled');
From c88dbe1c90a5f29f9b80d60a25b259ff24da7114 Mon Sep 17 00:00:00 2001
From: James Causon
Date: Fri, 9 Oct 2026 17:55:25 +0100
Subject: [PATCH 073/102] Rotation shuffle rule: a new rotation order once a
run
- Shuffles when saved or switched on again, in the last round before
the 24-hour restart, and after a restart it did not see coming
- Saving needs Config & settings as well as Automation; no migration,
no new route, action or load
- Refusals are stored as fixed phrases; the mock's rotation pointer is
settable for tests (MOCK_ROTATION_POINTER)
---
docs/wardogs-api.md | 5 +-
src/lib/rule-kinds.ts | 13 +-
src/lib/server/db/schema.ts | 3 +-
src/lib/server/mockgame.ts | 24 +-
src/lib/server/outbox.ts | 11 +-
src/lib/server/rotation-shuffle.test.ts | 457 ++++++++++++++++++++++++
src/lib/server/rotation-shuffle.ts | 222 ++++++++++++
src/lib/server/trigger-rules.ts | 12 +-
src/lib/server/triggers.ts | 98 ++++-
src/lib/server/webhook-delivery.ts | 1 +
src/lib/types.ts | 3 +-
src/test/access.test.ts | 6 +-
src/test/rotation-shuffle.test.ts | 68 ++++
13 files changed, 904 insertions(+), 19 deletions(-)
create mode 100644 src/lib/server/rotation-shuffle.test.ts
create mode 100644 src/lib/server/rotation-shuffle.ts
create mode 100644 src/test/rotation-shuffle.test.ts
diff --git a/docs/wardogs-api.md b/docs/wardogs-api.md
index 99f5194d..8c9fed07 100644
--- a/docs/wardogs-api.md
+++ b/docs/wardogs-api.md
@@ -100,7 +100,10 @@ into `server_live`, so the header, the Discord status cards and the list sync sh
Servers → Test reads them fresh and drops the caches. Set `MOCK_LIVE_BUILD=true` to make the demo
servers behave like this build (routes, pinned `ServerName`/`Port`, the live build string), and
`MOCK_RATE_LIMIT_EVERY=N` to have the demo answer every Nth request with a 429 and `Retry-After: 2`
-so the worker's hold can be watched. The
+so the worker's hold can be watched. Where a live build's rotation pointer goes when the document
+rewrites the rotation mid-match has not been seen: the demo keeps it on the entry being played,
+`MOCK_ROTATION_POINTER=index` keeps its place instead and `=top` goes to the top of the list at the
+next map change (the Rotation shuffle rule reads the rotation back to tell which). The
authoritative list for any server is its own `routes` array; Warcon shows it under Servers, Test,
"Routes this build serves".
diff --git a/src/lib/rule-kinds.ts b/src/lib/rule-kinds.ts
index ef8e559a..d7486a15 100644
--- a/src/lib/rule-kinds.ts
+++ b/src/lib/rule-kinds.ts
@@ -116,20 +116,27 @@ export const RULE_KINDS: { kind: TriggerKind; group: RuleGroup; label: string; b
group: 'Server',
label: 'Empty-server map reset',
blurb: 'Put an empty server back on a chosen map after a while.'
+ },
+ {
+ kind: 'rotation_shuffle',
+ group: 'Server',
+ label: 'Rotation shuffle',
+ blurb: 'A new rotation order every day, the maps in turn so none plays twice in a row.'
}
];
/**
* Kinds a server holds one rule of (createTrigger refuses a second): seed time is one count per
* server, two Team balance rules would move players back and forth, two AFK protection rules would
- * kill everyone twice a round, two Bounty rules would mark two players at once. The picker opens the
- * one a server has.
+ * kill everyone twice a round, two Bounty rules would mark two players at once, two Rotation shuffle
+ * rules would each undo the other's order. The picker opens the one a server has.
*/
export const ONE_PER_SERVER: TriggerKind[] = [
'seed_reward',
'two_teams',
'afk_protection',
- 'bounty'
+ 'bounty',
+ 'rotation_shuffle'
];
/**
diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts
index d914d170..369178ac 100644
--- a/src/lib/server/db/schema.ts
+++ b/src/lib/server/db/schema.ts
@@ -847,7 +847,8 @@ export const triggers = pgTable(
'kill_distance',
'afk_protection',
'name_change',
- 'bounty'
+ 'bounty',
+ 'rotation_shuffle'
]
}).notNull(),
name: text('name').notNull(),
diff --git a/src/lib/server/mockgame.ts b/src/lib/server/mockgame.ts
index 9ab4227c..63e647f3 100644
--- a/src/lib/server/mockgame.ts
+++ b/src/lib/server/mockgame.ts
@@ -145,6 +145,13 @@ const MAX_BODY_BYTES = 65536;
// AFK protection can be watched at work, and lowering the minimum starts the match.
const prematch = () => /^(1|true|yes)$/i.test(process.env.MOCK_PREMATCH || '');
const PREMATCH_MINIMUM = 60;
+// Where the rotation's pointer goes when the document rewrites it, which no live build has been
+// seen doing: by default it stays on the entry now playing; MOCK_ROTATION_POINTER=index keeps the
+// place it had, =top goes to the top of the list at the next map change.
+const rotationPointer = () =>
+ /^(index|top)$/i.test(process.env.MOCK_ROTATION_POINTER || '')
+ ? (process.env.MOCK_ROTATION_POINTER as string).toLowerCase()
+ : 'entry';
const prematchMinimum = (text: string): number => {
const n = Number(
getScalar(
@@ -1159,11 +1166,13 @@ export function mockHandle(
s.sponsorUrl = img[1].trim().replace(/^"|"$/g, '');
}
// Like the live build: the rotation section is "rebuilt immediately; used from the next
- // map change". The entry now playing keeps its place by identity, or the pointer resets.
+ // map change". The entry now playing keeps its place by identity, or the pointer resets
+ // (see MOCK_ROTATION_POINTER for the other ways a build might do it).
const rot = rotationFromText(text);
s.rotation.enabled = rot.enabled;
s.rotation.mode = rot.mode;
- const playing = s.rotation.entries[s.rotation.nowIndex];
+ const was = s.rotation.nowIndex;
+ const playing = s.rotation.entries[was];
s.rotation.entries = rot.entries.map((e) => ({
...e,
denied: e.experiences.some((id) => !EXPERIENCES.some((x) => x.id === id))
@@ -1173,10 +1182,19 @@ export function mockHandle(
(e) =>
e.map === playing.map &&
e.lighting === playing.lighting &&
+ (e.zoneAlternator || '') === (playing.zoneAlternator || '') &&
e.experiences.join('+') === playing.experiences.join('+')
)
: -1;
- s.rotation.nowIndex = keep >= 0 ? keep : 0;
+ const pointer = rotationPointer();
+ s.rotation.nowIndex =
+ pointer === 'top'
+ ? Math.max(0, s.rotation.entries.length - 1)
+ : pointer === 'index'
+ ? Math.min(Math.max(was, 0), Math.max(0, s.rotation.entries.length - 1))
+ : keep >= 0
+ ? keep
+ : 0;
s.rotation.nextIndex = s.rotation.entries.length
? (s.rotation.nowIndex + 1) % s.rotation.entries.length
: -1;
diff --git a/src/lib/server/outbox.ts b/src/lib/server/outbox.ts
index 1cb44b69..69f9574a 100644
--- a/src/lib/server/outbox.ts
+++ b/src/lib/server/outbox.ts
@@ -37,6 +37,7 @@ import { BOUNTY_CLAIM, BOUNTY_LAPSE, BOUNTY_REWARD } from './bounty';
import { writeAudit } from './audit';
import { PANEL_BAN, type PanelBanParams } from './rule-ban';
import { killAndTell, RULE_KILL, RULE_KILL_MAX_AGE_MS } from './rule-kill';
+import { ROTATION_SHUFFLE, shuffleOnServer } from './rotation-shuffle';
import { queueEvent } from './json-webhook-queue';
import { seedRewardGranted } from './json-webhook-events';
import {
@@ -528,7 +529,7 @@ async function deliverOne(env: Env, row: OutboxRow): Promise<'held' | 'refused'
env,
row,
'delivered',
- ((row.action === RULE_KILL || !OWN_WORDS.has(row.action)) && messageOf(result)) ||
+ ((OWN_RESULT.has(row.action) || !OWN_WORDS.has(row.action)) && messageOf(result)) ||
row.okMessage
);
// Done, but a later step was refused for sending too fast (a rule kill's whisper): hold the
@@ -845,9 +846,12 @@ const retryAfterOf = (r: unknown): number =>
* A rule's whisper, kick or kill carries what it tells one player, their stats across the
* organisation among it: what became of it is told in the panel's own words, never the game's,
* which staff of this server read (Recent actions, the audit trail, Discord) and which might repeat
- * the text. A kill says in its own fixed words whether the player was killed and told.
+ * the text. A kill says in its own fixed words whether the player was killed and told. A rotation
+ * shuffle writes the config document, whose refusals can quote the file.
*/
-const OWN_WORDS = new Set(['whisper', 'kick', RULE_KILL]);
+const OWN_WORDS = new Set(['whisper', 'kick', RULE_KILL, ROTATION_SHUFFLE]);
+/** Of those, the ones whose result is the panel's own account (names, counts), kept as it is. */
+const OWN_RESULT = new Set([RULE_KILL, ROTATION_SHUFFLE]);
/** Why the game refused a whisper or kick, as a fixed phrase: its status and its code. */
function refusal(err: unknown): string {
@@ -949,6 +953,7 @@ async function execute(client: WardogsClient, row: OutboxRow): Promise
const params = (row.params as Record) ?? {};
if (row.action === AFK_ROUND) return afkRound(client, params, memoryOf(row.serverId));
if (row.action === RULE_KILL) return killAndTell(client, params);
+ if (row.action === ROTATION_SHUFFLE) return shuffleOnServer(client, params);
if (row.action === 'empty_reset') {
let rotationOn = false;
try {
diff --git a/src/lib/server/rotation-shuffle.test.ts b/src/lib/server/rotation-shuffle.test.ts
new file mode 100644
index 00000000..69eb3fcb
--- /dev/null
+++ b/src/lib/server/rotation-shuffle.test.ts
@@ -0,0 +1,457 @@
+// The Rotation shuffle rule: when it shuffles (shuffleStep), and the shuffle itself against the mock
+// game's config document, whichever way the game moves its pointer when the rotation is rewritten.
+import { afterEach, describe, expect, test } from 'bun:test';
+import type { Env } from './env';
+import { WardogsClient, GameError } from './rcon';
+import { ApiError } from './http';
+import { readConfig } from './actions';
+import {
+ shuffleOnServer,
+ shuffleStep,
+ rotationShuffleState,
+ validateRotationShuffle,
+ type RotationShuffleState
+} from './rotation-shuffle';
+import { rotationFromText, rotationIntoText } from '../rotation-doc';
+import type { MapSelection } from '../types';
+import { evaluateTriggers, type TickContext } from './triggers';
+import type { TriggerRow } from './db/schema';
+
+const H = 3600_000;
+const START = Date.UTC(2026, 9, 9, 4, 0, 0);
+
+describe('shuffleStep', () => {
+ const look = (startedAt: number, restartDue = false) => ({ startedAt, restartDue });
+
+ test('a new rule shuffles at once; nothing while the start is unknown', () => {
+ expect(shuffleStep(null, look(0))).toBeNull();
+ expect(shuffleStep(null, look(START))).toEqual({
+ state: { boot: START, forNext: false, spentBy: 0 },
+ when: 'now'
+ });
+ // Saved in the last round: that shuffle is the one the next run starts on.
+ expect(shuffleStep(null, look(START, true))).toEqual({
+ state: { boot: START, forNext: true, spentBy: 0 },
+ when: 'before-restart'
+ });
+ });
+
+ test('once a run: in the last round before the restart, and the run after starts on it', () => {
+ let s: RotationShuffleState = { boot: START, forNext: false, spentBy: 0 };
+ // Same run, a few seconds of drift in the start: nothing.
+ expect(shuffleStep(s, look(START + 4_000))).toBeNull();
+ // The restart is due: shuffle for the next run, once.
+ const due = shuffleStep(s, look(START, true))!;
+ expect(due.when).toBe('before-restart');
+ s = due.state;
+ expect(shuffleStep(s, look(START, true))).toBeNull();
+ // The next run comes up on that order: only noted, no shuffle.
+ const next = START + 24 * H + 20 * 60_000;
+ const up = shuffleStep(s, look(next))!;
+ expect(up).toEqual({ state: { ...s, spentBy: next }, when: null });
+ s = up.state;
+ expect(shuffleStep(s, look(next + 30_000))).toBeNull();
+ // Its own last round shuffles again.
+ expect(shuffleStep(s, look(next, true))?.when).toBe('before-restart');
+ });
+
+ test('after a restart no shuffle was made for: a crash, a set restart time, or a second one', () => {
+ const made: RotationShuffleState = { boot: START, forNext: false, spentBy: 0 };
+ expect(shuffleStep(made, look(START + 6 * H))).toEqual({
+ state: { boot: START + 6 * H, forNext: false, spentBy: 0 },
+ when: 'after-restart'
+ });
+ // Prepared for, and spent by one run; another restart before its own last round.
+ const spent: RotationShuffleState = { boot: START, forNext: true, spentBy: START + 24 * H };
+ expect(shuffleStep(spent, look(START + 30 * H))?.when).toBe('after-restart');
+ // One the rule could not see coming falls in the restart window of the new run: before.
+ expect(shuffleStep(made, look(START + 30 * H, true))?.when).toBe('before-restart');
+ });
+
+ test('reads back only a state it wrote', () => {
+ expect(rotationShuffleState(null)).toBeNull();
+ expect(rotationShuffleState({ enabledAt: 5 })).toBeNull();
+ expect(rotationShuffleState({ boot: START, forNext: true, spentBy: 'x' })).toEqual({
+ boot: START,
+ forNext: true,
+ spentBy: 0
+ });
+ });
+});
+
+test('the rule queues one shuffle a run, saying what it is for, and keeps its state on the row', async () => {
+ const row = {
+ id: 'rule-1',
+ serverId: 'server-1',
+ orgId: 'org-1',
+ kind: 'rotation_shuffle',
+ name: 'Rotation shuffle',
+ enabled: true,
+ config: { maps: ['Europe', 'Kavkazi'] },
+ state: null,
+ lastFiredAt: null,
+ lastResult: ''
+ } as unknown as TriggerRow;
+ const look = (startedAt: number, at: number) =>
+ evaluateTriggers(
+ {} as Env,
+ {
+ server: { id: 'server-1', name: 'Test' },
+ status: { map: 'Kavkazi', rotationNow: 4, playerCount: 40, scores: [] },
+ players: [],
+ startedAt,
+ ts: new Date(at)
+ } as unknown as TickContext,
+ [row]
+ );
+ let out = await look(START, START + H);
+ expect(out.intents).toHaveLength(1);
+ expect(out.intents[0]).toMatchObject({
+ action: 'rotation_shuffle',
+ params: { maps: ['Europe', 'Kavkazi'], when: 'now', playing: 'Kavkazi', now: 4 },
+ steamId: null
+ });
+ expect(out.updates).toEqual([
+ {
+ id: 'rule-1',
+ lastFiredAt: new Date(START + H),
+ lastResult: 'Shuffling now.',
+ state: { boot: START, forNext: false, spentBy: 0 }
+ }
+ ]);
+ // The rest of the run, a few seconds of drift in the start: nothing.
+ out = await look(START + 3_000, START + 5 * H);
+ expect(out).toEqual({ intents: [], updates: [] });
+ // The 24-hour restart is due: once, for the run after it.
+ out = await look(START, START + 24 * H + 60_000);
+ expect(out.intents.map((i) => i.params.when)).toEqual(['before-restart']);
+ expect(out.updates[0].lastResult).toBe('Shuffling for after the restart.');
+ expect((await look(START, START + 24 * H + 120_000)).intents).toEqual([]);
+ // The run that comes up on it is only noted.
+ out = await look(START + 24 * H + 600_000, START + 25 * H);
+ expect(out.intents).toEqual([]);
+ expect(out.updates[0].state).toMatchObject({ spentBy: START + 24 * H + 600_000 });
+ // Unknown uptime: nothing at all.
+ expect(await look(0, START + 26 * H)).toEqual({ intents: [], updates: [] });
+ // Saved again (the state starts over): another shuffle, which the outbox must not take for the
+ // first one.
+ const first = (await look(START, START + H)).intents;
+ row.state = null;
+ const again = (await look(START, START + H + 60_000)).intents;
+ expect(again.map((i) => i.params.when)).toEqual(['now']);
+ expect(again[0].dedupeKey).not.toBe(first[0]?.dedupeKey ?? '');
+ row.state = null;
+ const one = await look(START, START + H);
+ row.state = null;
+ const two = await look(START, START + 2 * H);
+ expect(one.intents[0].dedupeKey).not.toBe(two.intents[0].dedupeKey);
+ // Saved in the last round, after that round's own shuffle: it counts as one for after the
+ // restart, and is still not taken for the first.
+ row.state = null;
+ await look(START, START + H);
+ const due = await look(START, START + 24 * H + 60_000);
+ row.state = null;
+ const saved = await look(START, START + 24 * H + 120_000);
+ expect(saved.intents.map((i) => i.params.when)).toEqual(['before-restart']);
+ expect(saved.intents[0].dedupeKey).not.toBe(due.intents[0].dedupeKey);
+});
+
+test('validateRotationShuffle keeps map ids once each, in order', () => {
+ expect(
+ validateRotationShuffle({
+ maps: ['Europe', 'kavkazi', 'EUROPE', '', '', 'NorthAmerica', 7]
+ })
+ ).toEqual({ maps: ['Europe', 'kavkazi', 'NorthAmerica', '7'] });
+ expect(validateRotationShuffle(null)).toEqual({ maps: [] });
+ expect(validateRotationShuffle({ maps: 'Europe' })).toEqual({ maps: [] });
+});
+
+// ---- the shuffle against the mock game ----
+
+const EXP: Record = {
+ Europe: 'Madrid_KOTH_01',
+ Kavkazi: 'Bakurani_KOTH_01',
+ NorthAmerica: 'Detroit_KOTH_01'
+};
+const LIGHTS = ['DayStartClear', 'DayEarlyClear', 'DayClear', 'DayEndClear'];
+/** a whole rotation grouped by map, as an admin writes one: every map, zone and time of day */
+const grouped = (zones: Record): MapSelection[] =>
+ Object.entries(zones).flatMap(([map, own]) =>
+ own.flatMap((z) =>
+ LIGHTS.map((lighting) => ({
+ map,
+ experiences: [EXP[map]],
+ lighting,
+ zoneAlternator: `ZoneAlternator.${map}.${z}`
+ }))
+ )
+ );
+/** as many entries on each map: the turn holds all the way round */
+const EVEN = grouped({
+ Europe: ['A', 'B', 'C', 'D'],
+ Kavkazi: ['A', 'B', 'C', 'D'],
+ NorthAmerica: ['A', 'B', 'C', 'D']
+});
+/** one map with fewer: whole rounds until it runs out, then the other two in turn */
+const UNEVEN = grouped({
+ Europe: ['A', 'B', 'C', 'D'],
+ Kavkazi: ['A', 'B', 'C', 'D'],
+ NorthAmerica: ['A', 'B', 'C']
+});
+const ORDER = ['Europe', 'Kavkazi', 'NorthAmerica'];
+const after = (map: string) => ORDER[(ORDER.indexOf(map) + 1) % ORDER.length];
+
+let n = 0;
+const clientFor = () =>
+ new WardogsClient(
+ {} as Env,
+ { id: `shuffle-${++n}`, host: 'demo', port: 1, scheme: 'http' },
+ 'demo',
+ `shuffle-test-${Date.now()}-${n}`
+ );
+async function setRotation(client: WardogsClient, entries: MapSelection[], mode = 'random') {
+ const doc = await readConfig(client);
+ const text = rotationIntoText(doc.text, {
+ enabled: true,
+ mode: mode as 'random' | 'ordered',
+ entries
+ });
+ const { status } = await client.configCall('PUT', '/v1/config', text, doc.revision);
+ expect(status).toBe(200);
+}
+const live = (client: WardogsClient) =>
+ client.json('GET', '/v1/rotation') as Promise<{
+ mode: string;
+ entries: (MapSelection & { status: string })[];
+ }>;
+const docRotation = async (client: WardogsClient) =>
+ rotationFromText((await readConfig(client)).text);
+const repeats = (list: MapSelection[]) =>
+ list.filter((e, i) => i > 0 && e.map === list[i - 1].map).length;
+
+describe('shuffleOnServer on the mock game', () => {
+ const was = process.env.MOCK_ROTATION_POINTER;
+ afterEach(() => {
+ if (was === undefined) delete process.env.MOCK_ROTATION_POINTER;
+ else process.env.MOCK_ROTATION_POINTER = was;
+ });
+
+ /** a server some way into its old order: `matches` map changes after the rotation was set */
+ async function playing(entries: MapSelection[], matches: number) {
+ const client = clientFor();
+ await setRotation(client, entries);
+ for (let i = 0; i < matches; i++) await client.json('POST', '/v1/match/end');
+ return { client, on: (await live(client)).entries.find((e) => e.status === 'now')! };
+ }
+ const nextOn = async (client: WardogsClient) =>
+ (await live(client)).entries.find((e) => e.status === 'next')!;
+
+ for (const pointer of ['entry', 'index', 'top']) {
+ test(`the maps in turn, and the next map follows the one on (pointer: ${pointer})`, async () => {
+ process.env.MOCK_ROTATION_POINTER = pointer;
+ for (let matches = 0; matches < 48; matches += 5) {
+ const { client, on } = await playing(EVEN, matches);
+ const { message } = await shuffleOnServer(client, { maps: ORDER, when: 'now' });
+ const r = await docRotation(client);
+ expect(r.mode).toBe('ordered');
+ expect(new Set(r.entries.map((e) => JSON.stringify(e)))).toEqual(
+ new Set(EVEN.map((e) => JSON.stringify(e)))
+ );
+ expect(repeats(r.entries)).toBe(0);
+ // whole rounds in the rule's order, turned by under a round to follow the map on
+ const maps = r.entries.map((e) => e.map);
+ expect(maps.every((m, i) => i === 0 || m === after(maps[i - 1]))).toBe(true);
+ const next = await nextOn(client);
+ expect(next.map).toBe(after(on.map));
+ expect(message).toContain(
+ `Shuffled ${EVEN.length} entries: Ozeti, Bakurani, Zestafona in turn`
+ );
+ expect(message).toContain(
+ `${['Ozeti', 'Bakurani', 'Zestafona'][ORDER.indexOf(next.map)]} next`
+ );
+ }
+ });
+ test(`a map with fewer entries: never the one on next (pointer: ${pointer})`, async () => {
+ process.env.MOCK_ROTATION_POINTER = pointer;
+ for (let matches = 0; matches < 44; matches += 3) {
+ const { client, on } = await playing(UNEVEN, matches);
+ await shuffleOnServer(client, { maps: ORDER, when: 'now' });
+ expect(repeats((await docRotation(client)).entries)).toBe(0);
+ expect((await nextOn(client)).map).not.toBe(on.map);
+ }
+ });
+ }
+
+ test('turns the order once more when the server does not have the map after the one on next', async () => {
+ // The rotation set while the server keeps its entry: it is on the top one (Ozeti). Then it keeps
+ // its place in the list instead, where the new order (Ozeti last, Bakurani first) has Zestafona.
+ const { client, on } = await playing(EVEN, 0);
+ expect(on.map).toBe('Europe');
+ process.env.MOCK_ROTATION_POINTER = 'index';
+ const raw = client.raw.bind(client);
+ let puts = 0;
+ client.raw = async (method, path, body, headers) => {
+ if (method === 'PUT' && path === '/v1/config') puts++;
+ return raw(method, path, body, headers);
+ };
+ const { message } = await shuffleOnServer(client, { maps: ORDER, when: 'now' });
+ expect(puts).toBe(2);
+ expect((await nextOn(client)).map).toBe('Kavkazi');
+ expect(message).toEndWith('; Bakurani next.');
+ expect(repeats((await docRotation(client)).entries)).toBe(0);
+ });
+
+ test('before the restart, the top of the order is the map after the one on', async () => {
+ for (let round = 0; round < 6; round++) {
+ const { client, on } = await playing(UNEVEN, round * 7);
+ const { message } = await shuffleOnServer(client, {
+ maps: ORDER,
+ when: 'before-restart'
+ });
+ const r = await docRotation(client);
+ expect(r.entries[0].map).toBe(after(on.map));
+ expect(repeats(r.entries)).toBe(0);
+ expect(message).toMatch(/starting on (Ozeti|Bakurani|Zestafona) after the restart\.$/);
+ }
+ });
+
+ test('leaves a rotation that is off, or too short, as it is', async () => {
+ const client = clientFor();
+ const doc = await readConfig(client);
+ const off = rotationIntoText(doc.text, { enabled: false, mode: 'random', entries: UNEVEN });
+ await client.configCall('PUT', '/v1/config', off, doc.revision);
+ expect((await shuffleOnServer(client, { maps: ORDER, when: 'now' })).message).toBe(
+ 'Nothing shuffled: the rotation is switched off on this server.'
+ );
+ expect((await docRotation(client)).entries).toEqual(UNEVEN);
+ await setRotation(client, UNEVEN.slice(0, 1));
+ expect((await shuffleOnServer(client, { maps: ORDER, when: 'now' })).message).toBe(
+ 'Nothing to shuffle: the rotation has fewer than two entries.'
+ );
+ });
+
+ test("names the turn in the rule's order, the rotation's other maps after", async () => {
+ const { client } = await playing(EVEN, 4);
+ const { message } = await shuffleOnServer(client, { maps: ['NorthAmerica'], when: 'now' });
+ expect(message).toStartWith(
+ `Shuffled ${EVEN.length} entries: Zestafona, Ozeti, Bakurani in turn`
+ );
+ });
+
+ test('a map with half the entries on: the server does not come back up on it', async () => {
+ const half = grouped({
+ Europe: ['A', 'B', 'C', 'D'],
+ Kavkazi: ['A', 'B'],
+ NorthAmerica: ['A', 'B']
+ });
+ for (let matches = 0; matches < 32; matches += 3) {
+ const { client, on } = await playing(half, matches);
+ await shuffleOnServer(client, { maps: ORDER, when: 'before-restart' });
+ const r = await docRotation(client);
+ expect([on.map, r.entries[0].map === on.map]).toEqual([on.map, false]);
+ expect(repeats(r.entries)).toBe(0);
+ }
+ });
+
+ test('the map on comes round next on another control zone', async () => {
+ for (let matches = 0; matches < 48; matches += 5) {
+ const { client, on } = await playing(EVEN, matches);
+ await shuffleOnServer(client, { maps: ORDER, when: 'now' });
+ const r = await docRotation(client);
+ const own = r.entries.find((e) => e.map === on.map)!;
+ expect(own.zoneAlternator).not.toBe(on.zoneAlternator);
+ }
+ });
+
+ test('a refused second turn is told, the shuffle stands; too fast holds the server', async () => {
+ process.env.MOCK_ROTATION_POINTER = 'entry';
+ for (const answer of [429, 500]) {
+ const { client } = await playing(EVEN, 0);
+ process.env.MOCK_ROTATION_POINTER = 'index';
+ const raw = client.raw.bind(client);
+ let puts = 0;
+ client.raw = async (method, path, body, headers) => {
+ if (method === 'PUT' && path === '/v1/config' && ++puts === 2)
+ return {
+ status: answer,
+ statusText: '',
+ headers: answer === 429 ? { 'retry-after': '3' } : {},
+ text: JSON.stringify({
+ ok: false,
+ error: { code: answer === 429 ? 'rate_limited' : 'oops', message: 'x' }
+ })
+ };
+ return raw(method, path, body, headers);
+ };
+ const out = await shuffleOnServer(client, { maps: ORDER, when: 'now' });
+ expect(puts).toBe(2);
+ expect((await docRotation(client)).mode).toBe('ordered');
+ if (answer === 429) {
+ expect(out.message).toEndWith('turning it once more was refused for sending too fast.');
+ expect(out.retryAfterMs).toBeGreaterThan(0);
+ } else expect(out.message).toEndWith('It could not be turned once more.');
+ process.env.MOCK_ROTATION_POINTER = 'entry';
+ }
+ });
+
+ test('a document the server refuses is told by its status and code, never its words', async () => {
+ const { client } = await playing(EVEN, 0);
+ const raw = client.raw.bind(client);
+ client.raw = async (method, path, body, headers) =>
+ method === 'PUT' && path === '/v1/config'
+ ? {
+ status: 400,
+ statusText: 'Bad Request',
+ headers: {},
+ text: JSON.stringify({
+ ok: false,
+ error: { code: 'invalid', message: 'Password=hunter2 is not allowed here' },
+ errors: [{ line: 3, message: 'Password=hunter2' }]
+ })
+ }
+ : raw(method, path, body, headers);
+ const err = await shuffleOnServer(client, { maps: ORDER, when: 'now' }).catch((e) => e);
+ expect(err).toBeInstanceOf(GameError);
+ expect([err.status, err.code, err.message, err.body]).toEqual([
+ 400,
+ 'invalid',
+ 'The server did not take the new rotation.',
+ null
+ ]);
+ });
+
+ test('a read-only document is refused in the panel’s words; a conflict is tried once more', async () => {
+ const client = clientFor();
+ await setRotation(client, UNEVEN);
+ const raw = client.raw.bind(client);
+ let puts = 0;
+ let conflicts = 1;
+ let writable = false;
+ client.raw = async (method, path, body, headers) => {
+ const res = await raw(method, path, body, headers);
+ if (method === 'GET' && path === '/v1/config' && !writable)
+ return { ...res, text: JSON.stringify({ ...JSON.parse(res.text), writable: false }) };
+ if (method === 'PUT' && path === '/v1/config' && puts++ < conflicts)
+ return {
+ ...res,
+ status: 412,
+ text: JSON.stringify({ ok: false, error: { code: 'revision_mismatch', message: 'x' } })
+ };
+ return res;
+ };
+ const refused = await shuffleOnServer(client, { maps: ORDER, when: 'now' }).catch((e) => e);
+ expect(refused).toBeInstanceOf(ApiError);
+ expect(refused.message).toContain('read-only');
+ writable = true;
+ puts = 0;
+ expect((await shuffleOnServer(client, { maps: ORDER, when: 'now' })).message).toContain(
+ `Shuffled ${UNEVEN.length} entries`
+ );
+ expect(puts).toBe(2);
+ puts = 0;
+ conflicts = 2;
+ const twice = await shuffleOnServer(client, { maps: ORDER, when: 'now' }).catch((e) => e);
+ expect(twice).toBeInstanceOf(GameError);
+ expect(twice.code).toBe('revision_conflict');
+ });
+});
diff --git a/src/lib/server/rotation-shuffle.ts b/src/lib/server/rotation-shuffle.ts
new file mode 100644
index 00000000..a44b53f6
--- /dev/null
+++ b/src/lib/server/rotation-shuffle.ts
@@ -0,0 +1,222 @@
+// The Rotation shuffle rule. An Ordered rotation of every map, time of day and control zone starts
+// from the top at each game start, so a server plays the same few entries every day; a Random one
+// can put a map on several times in a row. Live builds change the rotation only through the config
+// document, so the rule writes it in a new order once a run: in the last round before the 24-hour
+// restart where it can see that coming, so the server comes back up on it, otherwise right after the
+// restart; and once when the rule is saved. The maps take turns in the rule's order and each map's
+// control zones take turns ($lib/rotation-doc). Where a server goes next once its order changes has
+// not been seen, so the new order starts with the map after the one on and puts the entry on last
+// (a server that stays on its entry, or goes back to the top, then goes on from the top), is read
+// back, and is turned once more if the server does not have the map after the one on next.
+import { ApiError, str } from './http';
+import { ACTIONS, configResult, readConfig } from './actions';
+import { classifyGameError, GameError, type WardogsClient } from './rcon';
+import {
+ alignRotation,
+ entryKey,
+ rotationFromText,
+ rotationIntoText,
+ shuffleFor,
+ turnOf,
+ type RotationDoc
+} from '../rotation-doc';
+import { mapName } from '../format';
+import type { MapSelection } from '../types';
+
+/** The outbox action that shuffles a server's rotation (not in ACTIONS). */
+export const ROTATION_SHUFFLE = 'rotation_shuffle';
+
+export interface RotationShuffleConfig {
+ /** map ids in the order they take turns; a map the rotation has and this leaves out follows */
+ maps: string[];
+}
+
+const MAP_ID = /^[A-Za-z0-9_.-]{1,100}$/;
+
+export function validateRotationShuffle(raw: unknown): RotationShuffleConfig {
+ const c = (raw && typeof raw === 'object' ? raw : {}) as Record;
+ const maps: string[] = [];
+ for (const m of Array.isArray(c.maps) ? c.maps : []) {
+ const id = str(m, 100);
+ if (MAP_ID.test(id) && !maps.some((x) => x.toLowerCase() === id.toLowerCase())) maps.push(id);
+ }
+ return { maps: maps.slice(0, 20) };
+}
+
+export interface RotationShuffleState {
+ /** when the game started (ms) in the run the last shuffle was made in */
+ boot: number;
+ /** made in that run's last round, for the next run to start on */
+ forNext: boolean;
+ /** when the run that came up on that order started; 0 until one has */
+ spentBy: number;
+}
+
+/** A state the rule wrote, or null for anything else (a rule switched on again has none). */
+export function rotationShuffleState(v: unknown): RotationShuffleState | null {
+ if (!v || typeof v !== 'object') return null;
+ const s = v as Record;
+ const n = (x: unknown) => (typeof x === 'number' && Number.isFinite(x) ? x : 0);
+ if (!n(s.boot)) return null;
+ return { boot: n(s.boot), forNext: s.forNext === true, spentBy: n(s.spentBy) };
+}
+
+export type ShuffleWhen = 'now' | 'before-restart' | 'after-restart';
+
+/** The start is worked out from the uptime at each look, so it moves by the look's latency. */
+const SAME_RUN_MS = 60_000;
+
+/**
+ * One look at the server: whether to shuffle, and the state to keep (null: nothing changes). A rule
+ * with no state (new, or switched on again) shuffles at once. After that, once a run: when the
+ * 24-hour restart is due (the round on is the last), for the next run to start on; or after a
+ * restart that no shuffle was made for (one the rule could not see coming, or a second one in a
+ * row), for the rest of that run. Nothing while the start is unknown.
+ */
+export function shuffleStep(
+ prev: RotationShuffleState | null,
+ look: { startedAt: number; restartDue: boolean }
+): { state: RotationShuffleState; when: ShuffleWhen | null } | null {
+ const start = look.startedAt;
+ if (!start) return null;
+ const same = (t: number) => Math.abs(t - start) <= SAME_RUN_MS;
+ const made = (when: ShuffleWhen) => ({
+ state: { boot: start, forNext: look.restartDue, spentBy: 0 },
+ when: look.restartDue ? ('before-restart' as const) : when
+ });
+ if (!prev) return made('now');
+ if (same(prev.boot)) return look.restartDue && !prev.forNext ? made('before-restart') : null;
+ // A later run. One that came up on the order made for it keeps it until its own restart.
+ if (prev.forNext && (!prev.spentBy || same(prev.spentBy))) {
+ if (look.restartDue) return made('before-restart');
+ return prev.spentBy ? null : { state: { ...prev, spentBy: start }, when: null };
+ }
+ return made('after-restart');
+}
+
+interface LiveRotation {
+ enabled: boolean;
+ nowIndex: number;
+ nextIndex: number;
+ entries: MapSelection[];
+}
+
+const readLive = (client: WardogsClient) =>
+ ACTIONS.rotation.run(client, {}) as Promise;
+
+/**
+ * The rotation in the config document rewritten by `order` (given the rotation the document holds;
+ * null leaves it as it is), against the revision read, once more on a revision conflict. Only the
+ * rotation's keys change, and the mode becomes Ordered. The document's text never leaves here.
+ */
+async function rewrite(
+ client: WardogsClient,
+ order: (rotation: RotationDoc) => MapSelection[] | null
+): Promise {
+ for (let attempt = 0; ; attempt++) {
+ const doc = await readConfig(client);
+ if (!doc.writable)
+ throw new ApiError(
+ 409,
+ 'The config document is read-only on this server, so its rotation cannot be changed from the panel.'
+ );
+ const rotation = rotationFromText(doc.text);
+ const entries = order(rotation);
+ if (!entries) return null;
+ const text = rotationIntoText(doc.text, { ...rotation, mode: 'ordered', entries });
+ const { status, body, etag } = await client.configCall('PUT', '/v1/config', text, doc.revision);
+ const r = configResult(status, body, etag);
+ if (r.ok) return entries;
+ if (r.conflict && !attempt) continue;
+ // Kept to its status and code: a refused document's own words can quote the file.
+ const e = r.conflict
+ ? new GameError(412, '', 'revision_conflict')
+ : classifyGameError('PUT', '/v1/config', status, '', body);
+ const refused = new GameError(e.status, 'The server did not take the new rotation.', e.code);
+ refused.retryAfterMs = e.retryAfterMs;
+ throw refused;
+ }
+}
+
+/** What the server says comes next, when it plays `written` (null when it shows another order). */
+async function nextOf(client: WardogsClient, written: MapSelection[]) {
+ const back = await readLive(client).catch(() => null);
+ if (
+ !back ||
+ back.entries.length !== written.length ||
+ back.entries.some((e, i) => entryKey(e) !== entryKey(written[i]))
+ )
+ return null;
+ return { index: back.nextIndex, entry: back.entries[back.nextIndex] ?? null };
+}
+
+/**
+ * The shuffle, at delivery: the rotation in the document put in a new order (maps in the rule's
+ * turn, each map's zones in turn) for the entry on. Before the restart, the server comes back up on
+ * the top of it; otherwise what the server has next is read back, and set right once if it is not
+ * the map after the one on. The result names counts and maps only.
+ */
+export async function shuffleOnServer(
+ client: WardogsClient,
+ params: Record
+): Promise<{ message: string; retryAfterMs?: number }> {
+ const before = params.when === 'before-restart';
+ const { maps } = validateRotationShuffle(params);
+ const live = await readLive(client);
+ const now = live.nowIndex >= 0 ? live.nowIndex : -1;
+ const playing = live.entries[now]?.map || str(params.playing, 100);
+ const on: MapSelection | null =
+ live.entries[now] ??
+ (playing ? { map: playing, experiences: [], lighting: '', zoneAlternator: '' } : null);
+ let skipped = '';
+ let turn: string[] = [];
+ const written = await rewrite(client, ({ enabled, entries }) => {
+ skipped = !enabled
+ ? 'Nothing shuffled: the rotation is switched off on this server.'
+ : entries.length < 2
+ ? 'Nothing to shuffle: the rotation has fewer than two entries.'
+ : '';
+ turn = turnOf(entries, maps);
+ return skipped ? null : shuffleFor(entries, maps, on);
+ });
+ if (!written) return { message: skipped };
+ const head = `Shuffled ${written.length} entries: ${turn.map(mapName).join(', ')} in turn`;
+ if (before)
+ return { message: `${head}, starting on ${mapName(written[0].map)} after the restart.` };
+ const same = (a: string, b: string) => !!a && !!b && a.toLowerCase() === b.toLowerCase();
+ const at = turn.findIndex((m) => same(m, playing));
+ const follows = at < 0 ? '' : turn[(at + 1) % turn.length];
+ const right = (map: string) => (follows ? same(map, follows) : !same(map, playing));
+ let next = await nextOf(client, written);
+ let unturned = '';
+ if (next?.entry && !right(next.entry.map)) {
+ // The server took its next entry from somewhere else (it keeps its place in the list, say):
+ // turn the order for that place, unless no turn puts a better map there. The shuffle has
+ // landed either way, so a refusal here is told, not thrown.
+ const place = next.index;
+ const first = next.entry.map;
+ try {
+ const fixed = await rewrite(client, ({ entries }) => {
+ const turned = entries.length < 2 ? entries : alignRotation(entries, playing, [place]);
+ return turned[place] && entries[place] && same(turned[place].map, entries[place].map)
+ ? null
+ : turned;
+ });
+ if (fixed) next = await nextOf(client, fixed);
+ } catch (err) {
+ if (err instanceof GameError && err.code === 'rate_limited')
+ return {
+ message: `${head}; ${mapName(first)} next: turning it once more was refused for sending too fast.`,
+ retryAfterMs: err.retryAfterMs || 5_000
+ };
+ unturned = ' It could not be turned once more.';
+ }
+ }
+ if (!next) return { message: `${head}. The server did not show the new order back.` };
+ if (!next.entry) return { message: `${head}.` };
+ return {
+ message: same(next.entry.map, playing)
+ ? `${head}. The server still puts ${mapName(playing)} on next.${unturned}`
+ : `${head}; ${mapName(next.entry.map)} next.${unturned}`
+ };
+}
diff --git a/src/lib/server/trigger-rules.ts b/src/lib/server/trigger-rules.ts
index 2f8b899f..54b1a71c 100644
--- a/src/lib/server/trigger-rules.ts
+++ b/src/lib/server/trigger-rules.ts
@@ -10,6 +10,7 @@ import { validateTwoTeams, type TwoTeamsConfig } from './two-teams';
import { validateAfkProtection, type AfkProtectionConfig } from './afk-protection';
import { validateNameChange, type NameChangeConfig } from './name-change';
import { validateBounty, type BountyConfig } from './bounty';
+import { validateRotationShuffle, type RotationShuffleConfig } from './rotation-shuffle';
import { causeTags } from './cause-tags';
import { RESTART_AFTER_HOURS, restartWindow } from '$lib/uptime';
import { MAX_CHAT } from '$lib/chat';
@@ -36,7 +37,8 @@ export const TRIGGER_KINDS: TriggerKind[] = [
'kill_distance',
'afk_protection',
'name_change',
- 'bounty'
+ 'bounty',
+ 'rotation_shuffle'
];
export const TRIGGER_LABELS: Record = {
welcome: 'Welcome whisper',
@@ -55,7 +57,8 @@ export const TRIGGER_LABELS: Record = {
kill_distance: 'Kill distance watch',
afk_protection: 'AFK protection',
name_change: 'Name change watch',
- bounty: 'Bounty'
+ bounty: 'Bounty',
+ rotation_shuffle: 'Rotation shuffle'
};
export interface WelcomeConfig {
@@ -230,7 +233,8 @@ export type TriggerConfig =
| KillDistanceConfig
| AfkProtectionConfig
| NameChangeConfig
- | BountyConfig;
+ | BountyConfig
+ | RotationShuffleConfig;
/** A kick reason: not chat, so not held to the game's chat cap. */
export const MAX_REASON = 200;
@@ -427,6 +431,8 @@ export function validateConfig(kind: TriggerKind, raw: unknown): TriggerConfig {
return validateNameChange(c);
case 'bounty':
return validateBounty(c);
+ case 'rotation_shuffle':
+ return validateRotationShuffle(c);
}
}
diff --git a/src/lib/server/triggers.ts b/src/lib/server/triggers.ts
index 29db1ae5..32d7fe3c 100644
--- a/src/lib/server/triggers.ts
+++ b/src/lib/server/triggers.ts
@@ -182,6 +182,13 @@ import {
type OpenBounty
} from './bounty';
import { fmtUptime, RESTART_AFTER_HOURS, restartWindow } from '$lib/uptime';
+import {
+ ROTATION_SHUFFLE,
+ rotationShuffleState,
+ shuffleStep,
+ type RotationShuffleConfig,
+ type ShuffleWhen
+} from './rotation-shuffle';
import { DEFAULT_SCORE_CAP } from '$lib/match';
import { mapName } from '$lib/format';
import { statsIn, usesStats } from '$lib/placeholders';
@@ -282,7 +289,8 @@ const RULE_NEEDS: Record<
kill_rate: ['players.kick', 'flags players'],
two_teams: ['players.move', 'moves players between teams'],
afk_protection: ['players.kill', 'kills players'],
- name_change: ['players.kick', 'flags or kicks players']
+ name_change: ['players.kick', 'flags or kicks players'],
+ rotation_shuffle: ['config.apply', 'rewrites the rotation in the config document']
};
/** What a reserved slot a rule hands out needs: one here, or on the organisation's list. */
@@ -426,6 +434,9 @@ export async function updateTrigger(
set.state = null;
// Any save of a Bounty rule calls off its open bounty: the worker starts it over (bounty.ts).
if (row.kind === 'bounty') set.state = null;
+ // A Rotation shuffle rule's settings saved (its order may have changed): it shuffles once more,
+ // as it does when switched on again (below).
+ if (row.kind === 'rotation_shuffle' && body.config !== undefined) set.state = null;
// Switched on again, a Team balance rule starts over in the worker (its memory is keyed on this):
// who switched sides while it was off is unknown, so whoever is on then counts as placed. Whether
// it was off is the row's at the write, not the read above, so a switch-off landing between them
@@ -436,7 +447,9 @@ export async function updateTrigger(
const switchOn =
row.kind === 'two_teams' && set.enabled === true
? sql`CASE WHEN ${triggers.enabled} THEN ${triggers.state} ELSE (${JSON.stringify({ enabledAt: Date.now() })}::text)::jsonb END`
- : row.kind === 'afk_protection' && set.enabled === true
+ : (row.kind === 'afk_protection' ||
+ (row.kind === 'rotation_shuffle' && set.state === undefined)) &&
+ set.enabled === true
? sql`CASE WHEN ${triggers.enabled} THEN ${triggers.state} ELSE NULL END`
: undefined;
requireRuleCaps(row.kind, set.config ?? row.config, server, access);
@@ -811,6 +824,9 @@ export async function evaluateTriggers(
// Kills move it on as they arrive (feed-events.ts); a look ends it.
evalBounty(env, ctx, row, row.config as BountyConfig, out);
break;
+ case 'rotation_shuffle':
+ evalRotationShuffle(ctx, row, row.config as RotationShuffleConfig, out);
+ break;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@@ -1416,6 +1432,58 @@ function evalRestartNotice(
});
}
+const SHUFFLE_WHEN: Record = {
+ now: 'now',
+ 'before-restart': 'for after the restart',
+ 'after-restart': 'after the restart'
+};
+
+/** Once a run, and once when saved: see shuffleStep. The shuffle itself is the delivery's. */
+function evalRotationShuffle(
+ ctx: TickContext,
+ row: TriggerRow,
+ cfg: RotationShuffleConfig,
+ out: Evaluation
+) {
+ const now = ctx.ts.getTime();
+ const w = ctx.startedAt
+ ? restartWindow(new Date(ctx.startedAt).toISOString(), RESTART_AFTER_HOURS, now)
+ : null;
+ const prev = rotationShuffleState(row.state);
+ const step = shuffleStep(prev, { startedAt: ctx.startedAt, restartDue: !!w?.due });
+ if (!step) return;
+ row.state = step.state;
+ if (!step.when) {
+ out.updates.push({ id: row.id, state: step.state });
+ return;
+ }
+ out.intents.push({
+ trigger: row,
+ action: ROTATION_SHUFFLE,
+ params: {
+ maps: cfg.maps,
+ when: step.when,
+ playing: ctx.status.map,
+ now: ctx.status.rotationNow
+ },
+ target: 'rotation',
+ okMessage: 'Rotation shuffled.',
+ detail: { when: step.when, startedAt: new Date(ctx.startedAt).toISOString() },
+ steamId: null,
+ // once a run before or after a restart; one on saving (no state yet, whenever in the run it
+ // comes), every time it is saved
+ dedupeKey: key(row, step.when, step.state.boot, prev ? 0 : ctx.ts.getTime())
+ });
+ // Marked now, with the state, so a slow delivery cannot shuffle twice.
+ row.lastFiredAt = ctx.ts;
+ out.updates.push({
+ id: row.id,
+ lastFiredAt: ctx.ts,
+ lastResult: `Shuffling ${SHUFFLE_WHEN[step.when]}.`,
+ state: step.state
+ });
+}
+
/**
* When each AFK protection rule's server was first seen empty, in worker memory, counted only over
* looks the rule itself took: a restart, an outage or the rule starting over (switched on again)
@@ -2648,6 +2716,32 @@ export async function dryRun(
);
return result;
}
+ if (kind === 'rotation_shuffle') {
+ const c = cfg as RotationShuffleConfig;
+ const [live] = await env.db
+ .select({ startedAt: serverLive.startedAt })
+ .from(serverLive)
+ .where(eq(serverLive.serverId, server.id))
+ .limit(1);
+ const w = live?.startedAt
+ ? restartWindow(live.startedAt.toISOString(), RESTART_AFTER_HOURS, to.getTime())
+ : null;
+ if (!w || !live?.startedAt)
+ result.notes.push(
+ 'The worker has not read this server’s uptime yet (GET /v1/health), so there is nothing to project.'
+ );
+ else {
+ const dueAt = new Date(live.startedAt.getTime() + RESTART_AFTER_HOURS * 3600_000);
+ push(w.due ? to : dueAt, 'shuffle in the last round, for after the restart');
+ result.notes.push(
+ `Up ${fmtUptime(w.upMs)}; ${w.due ? 'the 24-hour restart comes when this round ends' : `the 24-hour restart is due in ${fmtUptime(w.untilDueMs ?? 0)}`}. A restart that comes first (a set restart time, a crash, one from the host's panel) is followed by a shuffle once the server is back.`
+ );
+ }
+ result.notes.push(
+ `Maps in turn: ${c.maps.length ? `${c.maps.map(mapName).join(', ')}, then any other in the order the rotation has it` : 'in the order the rotation has them'}; each map's control zones in turn as well. Saving the rule shuffles once straight away.`
+ );
+ return result;
+ }
if (kind === 'restart_notice') {
const c = cfg as RestartNoticeConfig;
const [live] = await env.db
diff --git a/src/lib/server/webhook-delivery.ts b/src/lib/server/webhook-delivery.ts
index 37c13367..d246a136 100644
--- a/src/lib/server/webhook-delivery.ts
+++ b/src/lib/server/webhook-delivery.ts
@@ -160,6 +160,7 @@ const ACTION_TITLES: Record = {
'trigger.afk_protection': 'Trigger · AFK protection',
'trigger.name_change': 'Trigger · name change watch',
'trigger.bounty': 'Trigger · bounty',
+ 'trigger.rotation_shuffle': 'Trigger · rotation shuffle',
'player.note': 'Player note',
'player.watch': 'Watchlist',
'list.add': 'Org list · added',
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 5d23bb89..034ded1c 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -579,7 +579,8 @@ export type TriggerKind =
| 'kill_distance'
| 'afk_protection'
| 'name_change'
- | 'bounty';
+ | 'bounty'
+ | 'rotation_shuffle';
export interface TriggerView {
id: string;
diff --git a/src/test/access.test.ts b/src/test/access.test.ts
index 40708c07..c2d2efb1 100644
--- a/src/test/access.test.ts
+++ b/src/test/access.test.ts
@@ -538,7 +538,8 @@ describe.skipIf(!hasTestDb)('access', () => {
['name_change', { action: 'kick' }, 'players.kick'],
['seed_reward', { minutes: 60, scope: 'server' }, 'slots.manage'],
['seed_reward', { minutes: 60, scope: 'org' }, 'lists.reserve'],
- ['bounty', { reward: 'none' }, 'chat.send']
+ ['bounty', { reward: 'none' }, 'chat.send'],
+ ['rotation_shuffle', { maps: ['Europe', 'Kavkazi'] }, 'config.apply']
];
for (const [kind, config, cap] of rules) {
await holds([]);
@@ -1297,7 +1298,8 @@ describe.skipIf(!hasTestDb)('access', () => {
seed_reward: { minutes: 60, scope: 'server' },
two_teams: { closedFaction: 'Lonestar' },
afk_protection: {},
- bounty: {}
+ bounty: {},
+ rotation_shuffle: {}
};
const save = (kind: string, name: string, config: Record) =>
api(w, 'owner', 'POST api/servers/[id]/triggers', {
diff --git a/src/test/rotation-shuffle.test.ts b/src/test/rotation-shuffle.test.ts
new file mode 100644
index 00000000..edca4175
--- /dev/null
+++ b/src/test/rotation-shuffle.test.ts
@@ -0,0 +1,68 @@
+// A Rotation shuffle rule shuffles once more when its settings are saved or it is switched on again:
+// both start it over (no state), so the worker's next look shuffles. A rename or a switch-off keeps
+// where it stands, as does switching on a rule that was on.
+import { beforeAll, describe, expect, test } from 'bun:test';
+import { join } from 'node:path';
+import { eq } from 'drizzle-orm';
+import type { Env } from '$lib/server/env';
+import { triggers } from '$lib/server/db/schema';
+import { hasTestDb, testEnv } from './db';
+import { callApi, stubGateway } from './call';
+import { seedWorld, type World } from './world';
+
+const ROUTES = join(import.meta.dir, '..', 'routes');
+
+describe.skipIf(!hasTestDb)('Rotation shuffle rule state', () => {
+ let env: Env;
+ let w: World;
+ const route = async (method: string, path: string) =>
+ (await import(join(ROUTES, path, '+server.ts')))[method];
+ const STATE = { boot: 1_791_500_000_000, forNext: true, spentBy: 0 };
+
+ beforeAll(async () => {
+ env = await testEnv();
+ stubGateway();
+ w = await seedWorld(env);
+ });
+
+ test('saving the settings, or switching it on again, starts it over', async () => {
+ const made = await callApi(await route('POST', 'api/servers/[id]/triggers'), w.users.owner, {
+ method: 'POST',
+ params: { id: w.server.id },
+ body: { kind: 'rotation_shuffle', name: 'Shuffle', enabled: true, config: { maps: [] } }
+ });
+ expect(made.status).toBe(201);
+ const id = (made.body as { trigger: { id: string } }).trigger.id;
+ const patch = await route('PATCH', 'api/servers/[id]/triggers/[triggerId]');
+ const save = (body: Record) =>
+ callApi(patch, w.users.owner, {
+ method: 'PATCH',
+ params: { id: w.server.id, triggerId: id },
+ body
+ });
+ const state = async () =>
+ (await env.db.select({ state: triggers.state }).from(triggers).where(eq(triggers.id, id)))[0]
+ .state;
+ const shuffled = () => env.db.update(triggers).set({ state: STATE }).where(eq(triggers.id, id));
+
+ await shuffled();
+ expect((await save({ name: 'Daily shuffle' })).status).toBe(200);
+ expect(await state()).toEqual(STATE);
+ expect((await save({ enabled: true })).status).toBe(200);
+ expect(await state()).toEqual(STATE);
+ expect((await save({ config: { maps: ['Europe', 'Kavkazi'] } })).status).toBe(200);
+ expect(await state()).toBeNull();
+
+ await shuffled();
+ expect((await save({ enabled: false })).status).toBe(200);
+ expect(await state()).toEqual(STATE);
+ expect((await save({ enabled: true })).status).toBe(200);
+ expect(await state()).toBeNull();
+ // The editor sends everything at once: a save of a rule that is on starts it over too.
+ await shuffled();
+ expect(
+ (await save({ name: 'Daily shuffle', enabled: true, config: { maps: ['Kavkazi'] } })).status
+ ).toBe(200);
+ expect(await state()).toBeNull();
+ });
+});
From f753299cdbaffbad96c79d185279e6eec43a39df Mon Sep 17 00:00:00 2001
From: James Causon
Date: Fri, 9 Oct 2026 17:55:25 +0100
Subject: [PATCH 074/102] Automation tab: Rotation shuffle rule; Map rotation:
Shuffle
---
.../(app)/server/[id]/automation/+page.svelte | 140 ++++++++++++++++--
.../(app)/server/[id]/rotation/+page.svelte | 42 ++++++
2 files changed, 173 insertions(+), 9 deletions(-)
diff --git a/src/routes/(app)/server/[id]/automation/+page.svelte b/src/routes/(app)/server/[id]/automation/+page.svelte
index c6fbbc8e..27c33d37 100644
--- a/src/routes/(app)/server/[id]/automation/+page.svelte
+++ b/src/routes/(app)/server/[id]/automation/+page.svelte
@@ -148,7 +148,9 @@
? 'lapsed'
: action === 'bounty_reward'
? 'reserved slot'
- : action;
+ : action === 'rotation_shuffle'
+ ? 'shuffle'
+ : action;
const label = (kind: TriggerKind) => KINDS.find((k) => k.kind === kind)?.label ?? kind;
const blurb = (kind: TriggerKind) => KINDS.find((k) => k.kind === kind)?.blurb ?? '';
/** Why a kind cannot run on this server yet, or '' when it can. */
@@ -175,6 +177,10 @@
: !canChat
? 'Saving needs the Chat capability as well as Automation: the bounty is announced to the server.'
: '';
+ case 'rotation_shuffle':
+ return canConfig
+ ? ''
+ : 'Saving needs the Config & settings capability as well as Automation: the rule rewrites the rotation in the config document.';
default:
return '';
}
@@ -193,6 +199,8 @@
let canChat = $derived(can(data.server.caps, 'chat.send'));
let canKill = $derived(can(data.server.caps, 'players.kill'));
let canBanHere = $derived(can(data.server.caps, 'bans.manage'));
+ /** a Rotation shuffle rule rewrites the rotation in the config document */
+ let canConfig = $derived(can(data.server.caps, 'config.apply'));
let canBanOrg = $derived(can(data.server.caps, 'lists.ban'));
/** Charges that are placed and set off from anywhere: how far away the killer was says nothing. */
const PLACED = new Set([
@@ -279,7 +287,9 @@
? 'Needs a Steam key'
: kind === 'seed_reward'
? 'Needs Reserved slots'
- : '';
+ : kind === 'rotation_shuffle'
+ ? 'Needs Config & settings'
+ : '';
/** A Bounty rule's texts until they are written: a slot is named, an announcement-only rule asks. */
const bountyTexts = (reward: 'slot' | 'none') =>
reward === 'slot'
@@ -451,6 +461,8 @@
setMessage: string;
claimMessage: string;
whisper: string;
+ /** a Rotation shuffle rule's map ids, in the order they take turns */
+ mapOrder: string[];
}
/** WARDOGS' factions, offered for Team balance; a name the game adds later can still be typed. */
const FACTIONS = ['Lonestar', 'Valkyra', 'Manticore'];
@@ -511,6 +523,26 @@
}
});
+ /** A Rotation shuffle rule's turn: the maps it names, then the game's other maps. */
+ function mapTurn(named: unknown): string[] {
+ const seen = new Set();
+ const out: string[] = [];
+ for (const id of [
+ ...(Array.isArray(named) ? (named as string[]) : []),
+ ...data.catalog.maps.map((m) => m.id)
+ ])
+ if (!seen.has(id.toLowerCase())) {
+ seen.add(id.toLowerCase());
+ out.push(id);
+ }
+ return out;
+ }
+ function moveMap(f: Form, i: number, by: -1 | 1) {
+ const list = f.mapOrder.slice();
+ [list[i], list[i + by]] = [list[i + by], list[i]];
+ f.mapOrder = list;
+ }
+
/** The editor for a new rule of a kind, an existing rule, or a copy of one (`copy`). */
function open(kind: TriggerKind, t?: TriggerView, copy = false) {
fromPicker = false;
@@ -658,7 +690,8 @@
bountyReward,
setMessage: s('setMessage', bountyTexts(bountyReward).setMessage),
claimMessage: s('claimMessage', bountyTexts(bountyReward).claimMessage),
- whisper: s('whisper', bountyTexts(bountyReward).whisper)
+ whisper: s('whisper', bountyTexts(bountyReward).whisper),
+ mapOrder: mapTurn(c.maps)
};
dry = null;
pendingSel =
@@ -675,11 +708,13 @@
const dryLabel = (kind: TriggerKind) =>
kind === 'restart_notice'
? 'Preview next cycle'
- : kind === 'name_filter'
- ? 'Dry run, past players'
- : kind === 'ping_kick'
- ? 'Check dry-run limits'
- : 'Dry run, last 24 h';
+ : kind === 'rotation_shuffle'
+ ? 'Preview next shuffle'
+ : kind === 'name_filter'
+ ? 'Dry run, past players'
+ : kind === 'ping_kick'
+ ? 'Check dry-run limits'
+ : 'Dry run, last 24 h';
const lines = (text: string) =>
text
.split(/[\n,]/)
@@ -837,6 +872,8 @@
claimMessage: f.claimMessage,
whisper: f.whisper
};
+ case 'rotation_shuffle':
+ return { maps: f.mapOrder };
}
}
@@ -1062,6 +1099,10 @@
}
case 'seed_reward':
return `${c.minutes} min with ${c.lowAt} or fewer on${c.untilFull === false ? '' : `, staying until ${typeof c.fullAt === 'number' ? `${c.fullAt}+ on` : 'it fills'}`}, within ${c.windowDays} day${c.windowDays === 1 ? '' : 's'} · slot ${c.scope === 'server' ? 'here' : 'on every server'} for ${c.slotDays} day${c.slotDays === 1 ? '' : 's'}${c.replaceExisting ? ` · replaces an existing slot${c.protectedNoteWord ? ` except notes containing “${c.protectedNoteWord}”` : ''}` : ''}${c.message ? ' · with a whisper' : ''}`;
+ case 'rotation_shuffle': {
+ const maps = Array.isArray(c.maps) ? (c.maps as string[]) : [];
+ return `${maps.length ? maps.map((m) => mapLabel(data.catalog, m)).join(', ') : 'The maps'} in turn · zones in turn · a new order every day`;
+ }
}
}
@@ -1285,7 +1326,11 @@
{#snippet dryResult(r: DryRunResult, title: string)}
From bbbd175b7f4c2b7be53bbc643d92d760408e6f0f Mon Sep 17 00:00:00 2001
From: James Causon
Date: Sat, 10 Oct 2026 00:00:37 +0100
Subject: [PATCH 096/102] README: Game restart, a time set here, RestartTimeUtc
or 24 hours up
---
README.md | 34 ++++++++++++++++++++++++----------
1 file changed, 24 insertions(+), 10 deletions(-)
diff --git a/README.md b/README.md
index c266f5b3..32f6de36 100644
--- a/README.md
+++ b/README.md
@@ -671,8 +671,8 @@ rule at most, so once it has one, picking that kind opens the rule it has.
| Restart notice | Broadcasts a heads-up N minutes before the server's scheduled restart and a message once it is due, optionally repeated every N minutes while the round drags on, with at least N players on. The restart itself lands when the round in progress ends. Follows the server's [game restart](#game-restart) schedule. Besides the server's placeholders, `{minutes}` gives the time to the restart window. |
| Seeding reward | Time a player spends on with at most N players counts as seed time, by default banked only once the server has filled (a count the rule sets, else the limit the server reports) with the player still on, so staying until the threshold and leaving, or a few minutes on an empty server, earns nothing (a switch on the rule counts every low minute instead); M minutes of it over the sessions that ended in the last D days earns a reserved slot for E days, with an optional whisper: on this server only (its own reserved-slot list, which needs the Reserved slots capability) or on every server in the organisation (the org list, which needs Org reserved slots), chosen on the rule. The seeded server applies it at once and, for an org-wide slot, the other servers at their next sync; it lapses on its own and can be earned again; players who already hold a slot here are skipped. Seed time is kept on each session, so the dossier (a total, per server and per session, and the player's progress toward the reward for _Automation_ holders), the leaderboard's Seed time column and the dry run show it. |
| AFK protection | While the server seeds (someone on, fewer than the rule's count, 20 by default, and no side has scored), kills everyone on every N minutes (3 by default, 2 to 10), so the game's idle kick, which also runs while a server waits for its players, does not remove the seeders. The panel cannot tell who is idle, so players are killed too. Set the count no higher than the players the server's match starts at. A side scoring or the count being reached turns it off until the server has sat empty for 10 minutes or the game restarts, so a dip below the count mid-match, or the empty list of a map change, never starts it again; switched off and on, it starts from what the server shows then, so switching it on during a match nobody has scored in yet takes that match for seeding until the first score. Switching it off drops a round not yet sent. Its row says whether it is active or paused, and why. Optional broadcasts after each round and once when the match it kept the seeders for goes live (`{goal}` is the count it turns off at). A round is one action in the trail, kept off Discord unless it fails; it goes only within 30 seconds of being decided, while the last look still showed the server seeding, and players who left since are passed over. One rule per server. |
-| Bounty | Puts a bounty on a player who gets N kills in a row without dying in one match (15 by default) while at least M players are on (20 by default), and announces it to the server. A run is the match page's Best streak: a kill of another player adds to it, a team kill or a suicide does not, and any death ends it. One bounty is open at a time; once it ends, the next goes to whoever is on a run of N or more, at their next kill. The first enemy to kill the marked player claims it: the claim can be announced and the claimer whispered, and they get a reserved slot for D days on this server or on every server in the organisation, or nothing but the announcement. A team kill, a suicide or the environment never ends a bounty, nor a kill between players whose sides the panel has not seen, so nobody clears their own or has a friend do it. It lapses when the marked player leaves (gone past the minute's leave grace, so the empty list of a map change is no leave), when the match ends, or when the panel has lost sight of the server for a few looks (its own 24-hour restart is one such time), and runs start over when the panel sees the match end. A claimer who already holds a slot on that list keeps whichever lasts longer. The open bounty shows on the rule's row and outlives a worker restart, which forgets the runs (one from a match that ended meanwhile lapses at the next kill of its player); saving the rule or switching it off calls it off. A **Bounties** box on a [Discord webhook](#discord-webhooks) posts a card when one is set, claimed or lapses, with names and never a SteamID, for a players' channel. Needs the [kill feed](#kill-feed), acted on as each kill arrives; like every feed rule it believes the feed, and a friend on the other side can claim a bounty. The dry run replays the day's kills against the player counts of the server's samples. One rule per server. |
-| Rotation shuffle | Puts the rotation in a new order once a day. An Ordered rotation of every map, time of day and control zone starts from the top after each restart, so the server plays the same entries every day; a Random one can put a map on several times in a row. The maps take turns in the order the rule sets, every match a different map (Ozeti, Bakurani, Zestafona, Ozeti…); among each map's own entries its control zones take turns and its times of day come in a new order. Whole rounds of the maps run for as long as the rest can still be kept apart, so a map with fewer entries drops out only near the end of the list, and a map with more than half the entries plays in runs as even as they can be. The rule writes the rotation in the config document (its entries, and the mode set to Ordered; against the revision read, once more after a conflict) once when it is saved, then once a run: in the last round before the 24-hour restart (from the uptime the header shows), so the server comes back up on the new order starting with the map that follows the one on, or straight after a restart it could not see coming (a set restart time, a crash, one from the host's panel). Where a live server goes next when its rotation is rewritten mid-match has not been seen, so the new order is turned, by less than a round, to put the map that follows the one on wherever the server may take its next entry from; the server's rotation is then read back, and turned once more if it does not have that map next. The result names the maps and the next one, and a refusal is told in the panel's own words. A rotation that is switched off, or has fewer than two entries, is left alone, and a shuffle the server refuses is not sent again before the next one is due. Its preview shows when the next shuffle is due. One rule per server. |
+| Bounty | Puts a bounty on a player who gets N kills in a row without dying in one match (15 by default) while at least M players are on (20 by default), and announces it to the server. A run is the match page's Best streak: a kill of another player adds to it, a team kill or a suicide does not, and any death ends it. One bounty is open at a time; once it ends, the next goes to whoever is on a run of N or more, at their next kill. The first enemy to kill the marked player claims it: the claim can be announced and the claimer whispered, and they get a reserved slot for D days on this server or on every server in the organisation, or nothing but the announcement. A team kill, a suicide or the environment never ends a bounty, nor a kill between players whose sides the panel has not seen, so nobody clears their own or has a friend do it. It lapses when the marked player leaves (gone past the minute's leave grace, so the empty list of a map change is no leave), when the match ends, or when the panel has lost sight of the server for a few looks (its daily restart is one such time), and runs start over when the panel sees the match end. A claimer who already holds a slot on that list keeps whichever lasts longer. The open bounty shows on the rule's row and outlives a worker restart, which forgets the runs (one from a match that ended meanwhile lapses at the next kill of its player); saving the rule or switching it off calls it off. A **Bounties** box on a [Discord webhook](#discord-webhooks) posts a card when one is set, claimed or lapses, with names and never a SteamID, for a players' channel. Needs the [kill feed](#kill-feed), acted on as each kill arrives; like every feed rule it believes the feed, and a friend on the other side can claim a bounty. The dry run replays the day's kills against the player counts of the server's samples. One rule per server. |
+| Rotation shuffle | Puts the rotation in a new order once a day. An Ordered rotation of every map, time of day and control zone starts from the top after each restart, so the server plays the same entries every day; a Random one can put a map on several times in a row. The maps take turns in the order the rule sets, every match a different map (Ozeti, Bakurani, Zestafona, Ozeti…); among each map's own entries its control zones take turns and its times of day come in a new order. Whole rounds of the maps run for as long as the rest can still be kept apart, so a map with fewer entries drops out only near the end of the list, and a map with more than half the entries plays in runs as even as they can be. The rule writes the rotation in the config document (its entries, and the mode set to Ordered; against the revision read, once more after a conflict) once when it is saved, then once a run: in the last round before the server's [game restart](#game-restart) (timed from the uptime the header shows), so the server comes back up on the new order starting with the map that follows the one on, or straight after a restart it could not see coming (a set restart time, a crash, one from the host's panel). Where a live server goes next when its rotation is rewritten mid-match has not been seen, so the new order is turned, by less than a round, to put the map that follows the one on wherever the server may take its next entry from; the server's rotation is then read back, and turned once more if it does not have that map next. The result names the maps and the next one, and a refusal is told in the panel's own words. A rotation that is switched off, or has fewer than two entries, is left alone, and a shuffle the server refuses is not sent again before the next one is due. Its preview shows when the next shuffle is due. One rule per server. |
**Dry run** replays the last 24 hours of the server's own history (joins, player counts, empty
stretches, cached Steam data) against a rule and lists what it would have done, so you can tune a
@@ -942,13 +942,23 @@ The purge is audited with the counts.
### Game restart
-A WARDOGS server restarts itself on a schedule, always at the end of the round then in progress.
-Which schedule depends on the host, so an org owner sets it per server under **Game restart** on
-the Settings tab: after N hours up (the game's own restart, 24 unless set; it was twelve before
-September 2026), daily at a time in a time zone (hosts that let you pick the time, such as
-BisectHosting: enter the time and zone their panel shows, e.g. `07:00` `America/Chicago`;
-daylight saving is followed), or none. The header's restart countdown, the status cards'
-"Restarts after this round" and the Restart notice rule all follow it.
+A WARDOGS server restarts itself when the round in progress ends, once its restart time has come.
+The header's restart countdown, the status cards' "Restarts after this round" and the Restart
+notice and Rotation shuffle rules follow that time, which Warcon takes from the first of these:
+
+1. **A time set here.** An org owner can tick _Set the restart time myself_ under **Game restart**
+ on the server's Settings tab and give a daily time in UTC. It is used instead of the server's
+ own setting, for a host that restarts the server from its own panel at a set time.
+2. **The server's config.** `RestartTimeUtc=HH:MM` (UTC) in the
+ `[/Script/WDGame.WDServerLifecycleSubsystem]` section of `ServerSettings.ini` restarts the game
+ daily at that time. The game reads it when it starts, so a change applies from the next
+ restart: Warcon reads it with its hourly look at the server's settings and reads the settings
+ again just after each restart, keeps the time the running server started with apart from what
+ the file says now, and the panel shows a change waiting for the next restart. Set it on the Config tab.
+3. **24 hours up**, the game's default when neither is set.
+
+Under **Game restart** everyone who can open the server sees which of the three applies and when
+the next window opens, in UTC and in their own time.
### Public pages
@@ -1127,7 +1137,9 @@ for a list of servers too long for a URL, and `?slim=1` leaves out each server's
what the panel's own pages show and costs
the game server nothing. By default the panel reads the players every two seconds and the status
every five while people are on, and looks at an empty server every thirty seconds; `observedAt`
-says when it last looked. Poll this rather than the game actions. `GET /api/servers/:id/summary`
+says when it last looked. `restartTimeUtc` is the daily restart time the running server started
+with and `restartTimeUtcFile` the one its config holds now, from the next restart (see
+[Game restart](#game-restart)); null when not set. Poll this rather than the game actions. `GET /api/servers/:id/summary`
answers the same for one server, with the key's capabilities there (its `ok` is the server's, as in
the live view).
@@ -1179,6 +1191,8 @@ the live view).
],
"statusAt": "2026-09-24T13:00:59.215Z",
"playersAt": "2026-09-24T13:00:58.213Z",
+ "restartTimeUtc": null,
+ "restartTimeUtcFile": "08:00",
"observedAt": "2026-09-24T13:00:59.215Z"
}
}
From e360b44c94240cd5fb4fad3f3e5692b808d86e09 Mon Sep 17 00:00:00 2001
From: James Causon
Date: Sat, 10 Oct 2026 19:09:53 +0100
Subject: [PATCH 097/102] Live server name: append the live score to the server
name
A rule keeps the server's own name and an appended part ({scoreline},
{map}, {players}, {max}); the worker writes ServerName in the config
document at most once a minute and, when the rule is off, puts the name
back while the document still holds the rule's last one.
- Saving, switching off and deleting it need Config & settings.
- No outbox or audit row per write; the row keeps the last write or a
fixed phrase. No migration.
---
src/lib/live-name.test.ts | 59 +++
src/lib/live-name.ts | 55 +++
src/lib/rule-kinds.test.ts | 3 +-
src/lib/rule-kinds.ts | 19 +-
src/lib/server/db/schema.ts | 3 +-
src/lib/server/live-name-write.ts | 157 ++++++++
src/lib/server/live-name.test.ts | 88 +++++
src/lib/server/live-name.ts | 81 ++++
src/lib/server/observe.ts | 10 +
src/lib/server/trigger-rules.ts | 13 +-
src/lib/server/triggers.ts | 43 ++-
src/lib/server/webhook-delivery.ts | 1 +
src/lib/types.ts | 14 +-
.../[id]/triggers/[triggerId]/+server.ts | 4 +-
src/test/access.test.ts | 36 +-
src/test/live-name-rule.test.ts | 350 ++++++++++++++++++
16 files changed, 922 insertions(+), 14 deletions(-)
create mode 100644 src/lib/live-name.test.ts
create mode 100644 src/lib/live-name.ts
create mode 100644 src/lib/server/live-name-write.ts
create mode 100644 src/lib/server/live-name.test.ts
create mode 100644 src/lib/server/live-name.ts
create mode 100644 src/test/live-name-rule.test.ts
diff --git a/src/lib/live-name.test.ts b/src/lib/live-name.test.ts
new file mode 100644
index 00000000..7676afe8
--- /dev/null
+++ b/src/lib/live-name.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, test } from 'bun:test';
+import { LIVE_NAME_MAX, liveName, liveNameVars, oneLine } from './live-name';
+
+const look = {
+ map: 'Kavkazi',
+ playerCount: 27,
+ maxPlayers: 30,
+ scores: [
+ { name: 'Valkyra', colorHex: '#D86060', score: 34 },
+ { name: 'Lonestar', colorHex: '#5B95D8', score: 27 },
+ { name: 'Manticore', colorHex: '#7BC462', score: 30 }
+ ]
+};
+
+describe('the live server name', () => {
+ test('the server name, a space, and the appended part filled in', () => {
+ expect(liveName({ base: 'Demo Clan #1 | Infantry Only', append: '| {scoreline}' }, look)).toBe(
+ 'Demo Clan #1 | Infantry Only | 34-27-30'
+ );
+ expect(liveName({ base: 'Demo', append: '| {map} {scoreline} ({players}/{max})' }, look)).toBe(
+ 'Demo | Bakurani 34-27-30 (27/30)'
+ );
+ });
+
+ test('scores in the order the game lists them, whole and never below nothing', () => {
+ expect(
+ liveNameVars({
+ ...look,
+ scores: [
+ { name: 'A', colorHex: '', score: 2.6 },
+ { name: 'B', colorHex: '', score: -1 }
+ ]
+ }).get('scoreline')
+ ).toBe('3-0');
+ expect(liveNameVars({ ...look, scores: [] }).get('scoreline')).toBe('');
+ });
+
+ test('a name it does not know stays as typed, including the names every object has', () => {
+ expect(liveName({ base: 'Demo', append: '{constructor} {Scoreline} {nope}' }, look)).toBe(
+ 'Demo {constructor} 34-27-30 {nope}'
+ );
+ });
+
+ test('one line, whatever was typed or sent: a line break cannot start a new line of the config', () => {
+ const name = liveName(
+ { base: 'Demo\r\n[/Script/WDRCON.WDRCONSettings]\nPassword=stolen', append: '|\u2028{map}' },
+ { ...look, map: 'Kavkazi\nPassword=x' }
+ );
+ expect(name).not.toMatch(/[\r\n\u2028\u2029\u0085]/);
+ expect(oneLine('a\tb\u0000c\u007f')).toBe('a b c');
+ });
+
+ test('cut to the longest name it writes, never through an emoji', () => {
+ const base = `${'x'.repeat(LIVE_NAME_MAX - 3)}`;
+ const name = liveName({ base, append: '🎯🎯🎯🎯' }, look);
+ expect(Array.from(name)).toHaveLength(LIVE_NAME_MAX);
+ expect(name.endsWith(' 🎯🎯')).toBe(true);
+ });
+});
diff --git a/src/lib/live-name.ts b/src/lib/live-name.ts
new file mode 100644
index 00000000..11f577dd
--- /dev/null
+++ b/src/lib/live-name.ts
@@ -0,0 +1,55 @@
+// The Live server name rule's name: the server's own name with a short part appended, filled in
+// from what the server shows at the look (each faction's score, the map, the player count). The
+// worker writes it and the editor previews it with the same function. Pure and client-safe.
+import { mapName } from './format';
+import type { Status } from './types';
+
+/** What the appended part can say: all read off the server's status, nothing a player types. */
+export const LIVE_NAME_PLACEHOLDERS = ['scoreline', 'map', 'players', 'max'] as const;
+
+/** The longest name the rule writes, and the longest server name and appended part it saves. */
+export const LIVE_NAME_MAX = 100;
+export const LIVE_NAME_APPEND_MAX = 60;
+export const LIVE_NAME_APPEND_DEFAULT = '| {scoreline}';
+
+export interface LiveNameConfig {
+ /** the server's own name: written back when the rule is switched off */
+ base: string;
+ /** what follows it, after a space, with the placeholders above */
+ append: string;
+}
+
+/**
+ * Text on one line: every control character (a line break would start a new line of the config
+ * document) becomes a space, and the ends are trimmed.
+ */
+export const oneLine = (text: string): string =>
+ text.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, ' ').trim();
+
+/** At most `max` characters, never half an emoji. */
+const cut = (text: string, max: number): string => Array.from(text).slice(0, max).join('');
+
+type Look = Pick;
+
+/** The appended part's placeholders, from the server's status. */
+export function liveNameVars(s: Look): Map {
+ return new Map([
+ ['scoreline', s.scores.map((f) => String(Math.max(0, Math.round(f.score)))).join('-')],
+ ['map', s.map ? mapName(s.map) : ''],
+ ['players', String(s.playerCount)],
+ ['max', String(s.maxPlayers)]
+ ]);
+}
+
+/**
+ * The name the rule writes: the server's name, a space, then the appended part filled in. A name
+ * it does not know stays as typed. One line, at most LIVE_NAME_MAX characters.
+ */
+export function liveName(cfg: LiveNameConfig, s: Look): string {
+ const vars = liveNameVars(s);
+ const tail = cfg.append.replace(
+ /\{([a-z_]+)\}/gi,
+ (whole, name: string) => vars.get(name.toLowerCase()) ?? whole
+ );
+ return cut(oneLine(`${oneLine(cfg.base)} ${oneLine(tail)}`), LIVE_NAME_MAX).trim();
+}
diff --git a/src/lib/rule-kinds.test.ts b/src/lib/rule-kinds.test.ts
index 9c118419..0810d84a 100644
--- a/src/lib/rule-kinds.test.ts
+++ b/src/lib/rule-kinds.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
-import { FAILURES_ONLY, ONE_PER_SERVER, RULE_GROUPS, RULE_KINDS } from './rule-kinds';
+import { FAILURES_ONLY, NO_ACTIONS, ONE_PER_SERVER, RULE_GROUPS, RULE_KINDS } from './rule-kinds';
import { TRIGGER_KINDS, TRIGGER_LABELS } from './server/trigger-rules';
describe('the kinds of rule the panel lists', () => {
@@ -11,6 +11,7 @@ describe('the kinds of rule the panel lists', () => {
}
for (const kind of FAILURES_ONLY) expect(TRIGGER_KINDS).toContain(kind);
for (const kind of ONE_PER_SERVER) expect(TRIGGER_KINDS).toContain(kind);
+ for (const kind of NO_ACTIONS) expect(TRIGGER_KINDS).toContain(kind);
});
test('listed group by group, in the order the groups are shown', () => {
diff --git a/src/lib/rule-kinds.ts b/src/lib/rule-kinds.ts
index bce51676..94e02780 100644
--- a/src/lib/rule-kinds.ts
+++ b/src/lib/rule-kinds.ts
@@ -122,6 +122,13 @@ export const RULE_KINDS: { kind: TriggerKind; group: RuleGroup; label: string; b
group: 'Server',
label: 'Rotation shuffle',
blurb: 'A new rotation order every day, the maps in turn so none plays twice in a row.'
+ },
+ {
+ kind: 'live_name',
+ group: 'Server',
+ label: 'Live server name',
+ blurb:
+ "Add the match score, map or player count to the end of the server's name in the browser."
}
];
@@ -129,14 +136,16 @@ export const RULE_KINDS: { kind: TriggerKind; group: RuleGroup; label: string; b
* Kinds a server holds one rule of (createTrigger refuses a second): seed time is one count per
* server, two Team balance rules would move players back and forth, two AFK protection rules would
* kill everyone twice a round, two Bounty rules would mark two players at once, two Rotation shuffle
- * rules would each undo the other's order. The picker opens the one a server has.
+ * rules would each undo the other's order, two Live server name rules would each write their own
+ * name over the other's. The picker opens the one a server has.
*/
export const ONE_PER_SERVER: TriggerKind[] = [
'seed_reward',
'two_teams',
'afk_protection',
'bounty',
- 'rotation_shuffle'
+ 'rotation_shuffle',
+ 'live_name'
];
/**
@@ -145,3 +154,9 @@ export const ONE_PER_SERVER: TriggerKind[] = [
* those that fail (webhook-delivery.ts).
*/
export const FAILURES_ONLY: TriggerKind[] = ['two_teams', 'afk_protection'];
+
+/**
+ * Rules that queue no actions, so a Discord webhook has nothing of theirs to carry: the Live server
+ * name rule writes the name at the worker's look (live-name.ts). The webhook editor leaves them out.
+ */
+export const NO_ACTIONS: TriggerKind[] = ['live_name'];
diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts
index 6ae9d32a..a323d1e8 100644
--- a/src/lib/server/db/schema.ts
+++ b/src/lib/server/db/schema.ts
@@ -853,7 +853,8 @@ export const triggers = pgTable(
'afk_protection',
'name_change',
'bounty',
- 'rotation_shuffle'
+ 'rotation_shuffle',
+ 'live_name'
]
}).notNull(),
name: text('name').notNull(),
diff --git a/src/lib/server/live-name-write.ts b/src/lib/server/live-name-write.ts
new file mode 100644
index 00000000..9325f7cf
--- /dev/null
+++ b/src/lib/server/live-name-write.ts
@@ -0,0 +1,157 @@
+// The Live server name rule's write, at the worker's look (live-name.ts says when and why).
+import { ApiError, forLog } from './http';
+import { configResult, readConfig } from './actions';
+import { classifyGameError, GameError, type WardogsClient } from './rcon';
+import { syncPhrase } from './lists-sync';
+import { applyTriggerUpdates } from './outbox';
+import { withOwnedTransaction } from './leadership';
+import {
+ LIVE_NAME_EVERY_MS,
+ LIVE_NAME_SAME_MS,
+ liveNameState,
+ nameDue,
+ validateLiveName,
+ type LiveNameState,
+ type NameMemory
+} from './live-name';
+import type { Env } from './env';
+import type { TriggerRow } from './db/schema';
+import { getScalar, parseIni, setScalarInText, unquote } from '../config-doc';
+import { lockedKeys, S_SESSION } from '../config-fields';
+import { liveName } from '../live-name';
+import type { ConfigSection, Status } from '../types';
+
+const PINNED =
+ "ServerName is pinned by a launch argument on this server's command line, so the panel cannot change it.";
+
+/** A failed write in a fixed phrase: the game's words go to the log only. */
+export function nameRefusal(err: unknown): string {
+ if (err instanceof GameError && err.code === 'pinned') return PINNED;
+ if (err instanceof ApiError) return syncPhrase(err);
+ console.warn('[warcon] live server name:', forLog(err));
+ return 'The name could not be written.';
+}
+
+const isServerName = (section: string, key: string) =>
+ section.toLowerCase() === S_SESSION.toLowerCase() && key.toLowerCase() === 'servername';
+
+/** Refusals that hold until the server's own setup changes: tried again only every ten minutes. */
+const lasting = (err: unknown) =>
+ err instanceof GameError &&
+ (err.status === 405 || ['pinned', 'config_readonly', 'no_route'].includes(err.code));
+
+/**
+ * ServerName set in the config document, against the revision read: only that line changes. With
+ * `ifNow`, only while the document still holds that name ('changed' when it holds another: someone
+ * set one since). A document that already holds the name is not written again ('same'), nor is a
+ * revision conflict tried again here (the next look tries). Refusals carry status and code only.
+ */
+export async function writeServerName(
+ client: WardogsClient,
+ name: string,
+ ifNow?: string
+): Promise<'written' | 'same' | 'changed'> {
+ const doc = await readConfig(client);
+ if (!doc.writable)
+ throw new GameError(409, 'The config document is read-only.', 'config_readonly');
+ if (lockedKeys(doc.sections as ConfigSection[]).some((k) => isServerName(k.section, k.key)))
+ throw new GameError(409, 'ServerName is pinned.', 'pinned');
+ // as the document reads: a name written in quotes reads without them
+ const held = getScalar(parseIni(doc.text), S_SESSION, 'ServerName') ?? '';
+ if (held === unquote(name)) return 'same';
+ if (ifNow !== undefined && held !== unquote(ifNow)) return 'changed';
+ const text = setScalarInText(doc.text, S_SESSION, 'ServerName', name);
+ if (text === doc.text) return 'same';
+ const { status, body, etag } = await client.configCall('PUT', '/v1/config', text, doc.revision);
+ const r = configResult(status, body, etag);
+ if (r.ok) return 'written';
+ if (r.conflict)
+ throw new GameError(412, 'The config document changed meanwhile.', 'revision_conflict');
+ const e = classifyGameError('PUT', '/v1/config', status, '', body);
+ e.body = null;
+ throw e;
+}
+
+/** The parts of the worker's memory of a server this reads and moves. */
+export interface NameLook {
+ status: Status | null;
+ liveName: NameMemory | null;
+ holdUntil: number;
+}
+
+/**
+ * One look: the rule's name written when it is due, or, with the rule gone from the enabled ones,
+ * its own name put back. That is decided from the document, not the status the look holds (which
+ * can be older than the last write): only where this process wrote a name and the document still
+ * holds it, so a name someone set after switching the rule off stands. The row records what became
+ * of it.
+ */
+export async function keepName(
+ env: Env,
+ m: NameLook,
+ client: WardogsClient,
+ rule: TriggerRow | undefined,
+ now: number,
+ period: number
+): Promise {
+ const status = m.status;
+ if (!status) return;
+ const cfg = rule ? validateLiveName(rule.config) : null;
+ if (rule && cfg)
+ m.liveName = {
+ ...(m.liveName ?? { attemptAt: 0, written: '', writtenAt: 0, refused: '' }),
+ ruleId: rule.id,
+ base: cfg.base
+ };
+ const mem = m.liveName;
+ if (!mem) return;
+ const want = cfg ? liveName(cfg, status) : mem.base;
+ if (cfg) {
+ if (!nameDue(mem, want, status.serverName, now)) return;
+ } else {
+ // Off with nothing written by this process: nothing of its own to take back.
+ if (!mem.written) {
+ m.liveName = null;
+ return;
+ }
+ if (now - mem.attemptAt < LIVE_NAME_EVERY_MS) return;
+ }
+ mem.attemptAt = now;
+ let refused = '';
+ try {
+ const done = await writeServerName(client, want, cfg ? undefined : mem.written);
+ if (done === 'changed') {
+ m.liveName = null;
+ return;
+ }
+ mem.written = want;
+ mem.writtenAt = now;
+ } catch (err) {
+ if (err instanceof GameError && err.code === 'rate_limited')
+ m.holdUntil = Math.max(m.holdUntil, now + (err.retryAfterMs || 5000));
+ if (lasting(err)) mem.attemptAt = now + LIVE_NAME_SAME_MS - LIVE_NAME_EVERY_MS;
+ refused = nameRefusal(err);
+ }
+ // A refusal is recorded when it is new; a write every time, which is what the row shows.
+ if (refused && refused === mem.refused) return;
+ mem.refused = refused;
+ const was = rule ? liveNameState(rule.state) : null;
+ const state: LiveNameState = refused
+ ? {
+ name: mem.written || was?.name || '',
+ at: mem.writtenAt || was?.at || 0,
+ refused,
+ since: now,
+ restored: false
+ }
+ : { name: want, at: now, refused: '', since: 0, restored: !cfg };
+ if (!cfg && !refused) m.liveName = null;
+ await withOwnedTransaction(
+ env,
+ (tx) =>
+ applyTriggerUpdates(tx, [
+ { id: mem.ruleId, state, ...(refused ? {} : { lastFiredAt: new Date(now) }) }
+ ]),
+ { period }
+ );
+}
diff --git a/src/lib/server/live-name.test.ts b/src/lib/server/live-name.test.ts
new file mode 100644
index 00000000..02cc9614
--- /dev/null
+++ b/src/lib/server/live-name.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, test } from 'bun:test';
+import {
+ LIVE_NAME_EVERY_MS,
+ LIVE_NAME_SAME_MS,
+ liveNameState,
+ nameDue,
+ validateLiveName,
+ type NameMemory
+} from './live-name';
+
+describe('Live server name settings', () => {
+ test('the server name is needed; what is appended defaults to the score line', () => {
+ expect(() => validateLiveName({})).toThrow(/server's own name/);
+ expect(() => validateLiveName({ base: ' \n ' })).toThrow(/server's own name/);
+ expect(validateLiveName({ base: ' Demo ' })).toEqual({ base: 'Demo', append: '| {scoreline}' });
+ expect(() => validateLiveName({ base: 'Demo', append: ' ' })).toThrow(/what to append/);
+ });
+
+ test('saved on one line, and no longer than the name and the appended part may be', () => {
+ const c = validateLiveName({
+ base: `Demo\r\n[/Script/WDRCON.WDRCONSettings]\r\nPassword=stolen${'x'.repeat(200)}`,
+ append: `|\n{scoreline}${'y'.repeat(100)}`
+ });
+ expect(c.base).not.toMatch(/[\r\n]/);
+ expect(c.append).not.toMatch(/[\r\n]/);
+ expect(Array.from(c.base)).toHaveLength(100);
+ expect(Array.from(c.append)).toHaveLength(60);
+ });
+
+ test('the row state reads back, and anything else is no state', () => {
+ expect(liveNameState(null)).toBeNull();
+ expect(liveNameState({ name: 'A', at: 5, refused: '', since: 0, restored: true })).toEqual({
+ name: 'A',
+ at: 5,
+ refused: '',
+ since: 0,
+ restored: true
+ });
+ expect(liveNameState({ name: 3, at: 'x' })).toEqual({
+ name: '',
+ at: 0,
+ refused: '',
+ since: 0,
+ restored: false
+ });
+ });
+});
+
+describe('when the worker writes', () => {
+ const mem = (over: Partial = {}): NameMemory => ({
+ ruleId: 'r',
+ base: 'Demo',
+ attemptAt: 0,
+ written: '',
+ writtenAt: 0,
+ refused: '',
+ ...over
+ });
+ const now = 10_000_000;
+
+ test('when the server shows another name, at most once a minute', () => {
+ expect(nameDue(mem(), 'Demo | 1-2', 'Demo', now)).toBe(true);
+ expect(nameDue(mem(), 'Demo | 1-2', 'Demo | 1-2', now)).toBe(false);
+ expect(
+ nameDue(mem({ attemptAt: now - LIVE_NAME_EVERY_MS + 1 }), 'Demo | 1-3', 'Demo', now)
+ ).toBe(false);
+ expect(nameDue(mem({ attemptAt: now - LIVE_NAME_EVERY_MS }), 'Demo | 1-3', 'Demo', now)).toBe(
+ true
+ );
+ });
+
+ test('the name it wrote last, which the server shows otherwise, only every ten minutes', () => {
+ const wrote = mem({
+ attemptAt: now - 120_000,
+ written: 'Demo | 1-2',
+ writtenAt: now - 120_000
+ });
+ expect(nameDue(wrote, 'Demo | 1-2', 'Demo | 1-', now)).toBe(false);
+ expect(
+ nameDue({ ...wrote, writtenAt: now - LIVE_NAME_SAME_MS }, 'Demo | 1-2', 'Demo | 1-', now)
+ ).toBe(true);
+ expect(nameDue(wrote, 'Demo | 1-3', 'Demo | 1-', now)).toBe(true);
+ });
+
+ test('never an empty name', () => {
+ expect(nameDue(mem(), '', 'Demo', now)).toBe(false);
+ });
+});
diff --git a/src/lib/server/live-name.ts b/src/lib/server/live-name.ts
new file mode 100644
index 00000000..0bf4c925
--- /dev/null
+++ b/src/lib/server/live-name.ts
@@ -0,0 +1,81 @@
+// The Live server name rule's settings, the state its row keeps and when the worker writes. The
+// write itself is live-name-write.ts. At the worker's look at a server with the rule on, the name it
+// should carry ($lib/live-name: its own name with the live part appended) goes into the config
+// document's ServerName, which the game applies at once, whenever the name the server shows
+// differs: at most once a minute, and the same name again at most every ten minutes, in case the
+// server shows a name otherwise than it was written. Switched off or deleted, the rule's own name
+// goes back the same way, from what the worker remembers (a worker that restarts in between has
+// forgotten it). No outbox row and no audit row per write, which would come every minute a match is
+// on: the rule keeps the name it last wrote and when, or a fixed phrase for why it could not.
+import { ApiError } from './http';
+import {
+ LIVE_NAME_APPEND_DEFAULT,
+ LIVE_NAME_APPEND_MAX,
+ LIVE_NAME_MAX,
+ oneLine,
+ type LiveNameConfig
+} from '../live-name';
+
+/** The fewest milliseconds between two writes, and between two writes of the same name. */
+export const LIVE_NAME_EVERY_MS = 60_000;
+export const LIVE_NAME_SAME_MS = 10 * 60_000;
+
+const upTo = (text: string, max: number) => Array.from(oneLine(text)).slice(0, max).join('').trim();
+
+export function validateLiveName(raw: unknown): LiveNameConfig {
+ const c = (raw && typeof raw === 'object' ? raw : {}) as Record;
+ const base = upTo(String(c.base ?? ''), LIVE_NAME_MAX);
+ if (!base)
+ throw new ApiError(
+ 400,
+ "Give the server's own name: the live part is appended to it, and it goes back to it when the rule is off."
+ );
+ const append = upTo(String(c.append ?? LIVE_NAME_APPEND_DEFAULT), LIVE_NAME_APPEND_MAX);
+ if (!append) throw new ApiError(400, 'Say what to append, such as | {scoreline}.');
+ return { base, append };
+}
+
+/** What the rule's row keeps: the name it last wrote and when, and why the latest try wrote nothing. */
+export interface LiveNameState {
+ name: string;
+ /** ms since the epoch; 0 until a name was written */
+ at: number;
+ /** a fixed phrase, '' when the latest try wrote the name */
+ refused: string;
+ /** when the tries began to fail (ms) */
+ since: number;
+ /** the rule's own name was put back once it was switched off */
+ restored: boolean;
+}
+
+export function liveNameState(v: unknown): LiveNameState | null {
+ if (!v || typeof v !== 'object') return null;
+ const s = v as Record;
+ const n = (x: unknown) => (typeof x === 'number' && Number.isFinite(x) ? x : 0);
+ return {
+ name: typeof s.name === 'string' ? s.name : '',
+ at: n(s.at),
+ refused: typeof s.refused === 'string' ? s.refused : '',
+ since: n(s.since),
+ restored: s.restored === true
+ };
+}
+
+/** What the worker remembers of a server's rule between looks. */
+export interface NameMemory {
+ /** the rule last seen on, and its own name, which goes back once it is off */
+ ruleId: string;
+ base: string;
+ attemptAt: number;
+ /** the last name this process wrote, and when */
+ written: string;
+ writtenAt: number;
+ /** the phrase last recorded for a failed try */
+ refused: string;
+}
+
+/** Whether to write `want` at this look, the server showing `shown`. */
+export function nameDue(mem: NameMemory, want: string, shown: string, now: number): boolean {
+ if (!want || want === shown || now - mem.attemptAt < LIVE_NAME_EVERY_MS) return false;
+ return want !== mem.written || now - mem.writtenAt >= LIVE_NAME_SAME_MS;
+}
diff --git a/src/lib/server/observe.ts b/src/lib/server/observe.ts
index 3030a783..37541bec 100644
--- a/src/lib/server/observe.ts
+++ b/src/lib/server/observe.ts
@@ -75,6 +75,8 @@ import { observations, observationSeconds } from './metrics';
import { notifyWatchedJoins } from './webhook-delivery';
import { nextDue, withHold } from './poller-schedule';
import { cashByFaction } from '$lib/cash';
+import { keepName } from './live-name-write';
+import type { NameMemory } from './live-name';
import type { Features, LiveView, Player, Status } from '$lib/types';
export type Tier = LiveView['tier'];
@@ -141,6 +143,8 @@ export interface ServerMemory {
startedAt: number;
/** the build answered "no such endpoint" to /v1/health; asked again when the identity is re-read */
healthUnserved: boolean;
+ /** the Live server name rule's write: its own name, to put back once it is off (live-name.ts) */
+ liveName: NameMemory | null;
/** observations completed */
count: number;
}
@@ -223,6 +227,7 @@ export function memoryFor(server: ServerRow, org: OrgRow): ServerMemory {
},
startedAt: 0,
healthUnserved: false,
+ liveName: null,
count: 0
};
registry.set(server.id, m);
@@ -809,6 +814,11 @@ export async function observeServer(env: Env, m: ServerMemory, kinds: ObserveKin
const seen = players?.map((p) => p.steamId) ?? [];
if (isOwner() && seen.length && m.bans.size)
await stage('bans', m, () => kickBanned(env, server, m.org, client, seen, m.bans));
+ // The Live server name rule's name, or its own name put back once it is off: nothing for a server
+ // that has never had the rule on.
+ const nameRule = rows.find((r) => r.kind === 'live_name');
+ if (isOwner() && m.status && (nameRule || m.liveName))
+ await stage('name', m, () => keepName(env, m, client, nameRule, started, period));
if (diff.joined.length && steamEnabled(env))
void getProfiles(
env,
diff --git a/src/lib/server/trigger-rules.ts b/src/lib/server/trigger-rules.ts
index 9d194bfa..a37254a5 100644
--- a/src/lib/server/trigger-rules.ts
+++ b/src/lib/server/trigger-rules.ts
@@ -11,6 +11,8 @@ import { validateAfkProtection, type AfkProtectionConfig } from './afk-protectio
import { validateNameChange, type NameChangeConfig } from './name-change';
import { validateBounty, type BountyConfig } from './bounty';
import { validateRotationShuffle, type RotationShuffleConfig } from './rotation-shuffle';
+import { validateLiveName } from './live-name';
+import type { LiveNameConfig } from '$lib/live-name';
import { causeTags } from './cause-tags';
import { MAX_RESTART_LEAD_MINUTES, restartWindow, type RestartSchedule } from '$lib/uptime';
import { MAX_CHAT } from '$lib/chat';
@@ -38,7 +40,8 @@ export const TRIGGER_KINDS: TriggerKind[] = [
'afk_protection',
'name_change',
'bounty',
- 'rotation_shuffle'
+ 'rotation_shuffle',
+ 'live_name'
];
export const TRIGGER_LABELS: Record = {
welcome: 'Welcome whisper',
@@ -58,7 +61,8 @@ export const TRIGGER_LABELS: Record = {
afk_protection: 'AFK protection',
name_change: 'Name change watch',
bounty: 'Bounty',
- rotation_shuffle: 'Rotation shuffle'
+ rotation_shuffle: 'Rotation shuffle',
+ live_name: 'Live server name'
};
export interface WelcomeConfig {
@@ -234,7 +238,8 @@ export type TriggerConfig =
| AfkProtectionConfig
| NameChangeConfig
| BountyConfig
- | RotationShuffleConfig;
+ | RotationShuffleConfig
+ | LiveNameConfig;
/** A kick reason: not chat, so not held to the game's chat cap. */
export const MAX_REASON = 200;
@@ -433,6 +438,8 @@ export function validateConfig(kind: TriggerKind, raw: unknown): TriggerConfig {
return validateBounty(c);
case 'rotation_shuffle':
return validateRotationShuffle(c);
+ case 'live_name':
+ return validateLiveName(c);
}
}
diff --git a/src/lib/server/triggers.ts b/src/lib/server/triggers.ts
index a8bca171..aef04ff3 100644
--- a/src/lib/server/triggers.ts
+++ b/src/lib/server/triggers.ts
@@ -196,6 +196,8 @@ import {
type ShuffleWhen
} from './rotation-shuffle';
import { DEFAULT_SCORE_CAP } from '$lib/match';
+import { liveName, type LiveNameConfig } from '$lib/live-name';
+import { liveNameState } from './live-name';
import { mapName } from '$lib/format';
import { statsIn, usesStats } from '$lib/placeholders';
import { settings } from './settings';
@@ -237,9 +239,19 @@ const shape = (t: TriggerRow): TriggerView => ({
fireCount: t.fireCount,
createdAt: t.createdAt ? t.createdAt.toISOString() : null,
...(t.kind === 'afk_protection' ? { phase: afkPhase(t.state) } : {}),
- ...(t.kind === 'bounty' ? { bounty: openBounty(t) } : {})
+ ...(t.kind === 'bounty' ? { bounty: openBounty(t) } : {}),
+ ...(t.kind === 'live_name' ? { liveName: lastName(t.state) } : {})
});
+/** A Live server name rule's last write and latest refusal, as its row shows them. */
+function lastName(state: unknown): TriggerView['liveName'] {
+ const s = liveNameState(state);
+ const iso = (ms: number) => (ms ? new Date(ms).toISOString() : null);
+ return s
+ ? { name: s.name, at: iso(s.at), refused: s.refused, since: iso(s.since), restored: s.restored }
+ : null;
+}
+
/** A Bounty rule's open bounty, as its row shows it: the one its last save holds, if any. */
function openBounty(t: TriggerRow): TriggerView['bounty'] {
const b = savedBounty(t.state, bountyVersion(t));
@@ -296,7 +308,8 @@ const RULE_NEEDS: Record<
two_teams: ['players.move', 'moves players between teams'],
afk_protection: ['players.kill', 'kills players'],
name_change: ['players.kick', 'flags or kicks players'],
- rotation_shuffle: ['config.apply', 'rewrites the rotation in the config document']
+ rotation_shuffle: ['config.apply', 'rewrites the rotation in the config document'],
+ live_name: ['config.apply', 'writes the server name in the config document']
};
/** What a reserved slot a rule hands out needs: one here, or on the organisation's list. */
@@ -488,14 +501,22 @@ export async function updateTrigger(
return shape(updated);
}
+/**
+ * Kinds whose deletion acts on the server: a Live server name rule's own name is written back to the
+ * config document, so deleting one needs what switching it off does.
+ */
+const ACTS_WHEN_DELETED: TriggerKind[] = ['live_name'];
+
export async function deleteTrigger(
env: Env,
req: Request,
user: SessionUser,
server: ServerRow,
+ access: ServerAccess,
id: string
): Promise {
const row = await triggerOf(env, server.id, id);
+ if (ACTS_WHEN_DELETED.includes(row.kind)) requireRuleCaps(row.kind, row.config, server, access);
await env.db.delete(triggers).where(eq(triggers.id, row.id));
if (SETTINGS_KEYS[row.kind])
await dropQueued(env, row.id, 'The rule was deleted before this was sent.');
@@ -835,6 +856,9 @@ export async function evaluateTriggers(
case 'rotation_shuffle':
evalRotationShuffle(ctx, row, row.config as RotationShuffleConfig, out);
break;
+ case 'live_name':
+ // Written at the look itself, after the rules (live-name-write.ts): no action is queued.
+ break;
}
} catch (err) {
// The rule shows a fixed phrase unless the panel refused in its own words; the error goes
@@ -2735,6 +2759,21 @@ export async function dryRun(
);
return result;
}
+ if (kind === 'live_name') {
+ const [live] = await env.db
+ .select({ status: serverLive.status })
+ .from(serverLive)
+ .where(eq(serverLive.serverId, server.id))
+ .limit(1);
+ const status = live?.status as Status | null | undefined;
+ if (status) push(to, `name: ${liveName(cfg as LiveNameConfig, status)}`);
+ else
+ result.notes.push('The worker has not seen this server yet, so there is no score to show.');
+ result.notes.push(
+ 'Nothing to replay: the rule writes the name from what the server shows at each look, whenever it changes and at most once a minute. Switched off, it puts the name back without the appended part.'
+ );
+ return result;
+ }
if (kind === 'rotation_shuffle') {
const c = cfg as RotationShuffleConfig;
const [live] = await env.db
diff --git a/src/lib/server/webhook-delivery.ts b/src/lib/server/webhook-delivery.ts
index 078ab7ef..c133f53f 100644
--- a/src/lib/server/webhook-delivery.ts
+++ b/src/lib/server/webhook-delivery.ts
@@ -162,6 +162,7 @@ const ACTION_TITLES: Record = {
'trigger.name_change': 'Trigger · name change watch',
'trigger.bounty': 'Trigger · bounty',
'trigger.rotation_shuffle': 'Trigger · rotation shuffle',
+ 'trigger.live_name': 'Trigger · live server name',
'player.note': 'Player note',
'player.watch': 'Watchlist',
'list.add': 'Org list · added',
diff --git a/src/lib/types.ts b/src/lib/types.ts
index 03411d62..b8cfb0e2 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -590,7 +590,8 @@ export type TriggerKind =
| 'afk_protection'
| 'name_change'
| 'bounty'
- | 'rotation_shuffle';
+ | 'rotation_shuffle'
+ | 'live_name';
export interface TriggerView {
id: string;
@@ -606,6 +607,17 @@ export interface TriggerView {
phase?: { on: boolean; since: string | null; why: string } | null;
/** a Bounty rule's open bounty: on whom, the run that set it, and when */
bounty?: { steamId: string; name: string; streak: number; setAt: string } | null;
+ /**
+ * a Live server name rule's last write (the name and when) and why the latest try wrote nothing
+ * ('' when it did); `restored` once its own name went back after it was switched off
+ */
+ liveName?: {
+ name: string;
+ at: string | null;
+ refused: string;
+ since: string | null;
+ restored: boolean;
+ } | null;
}
export interface DryRunResult {
diff --git a/src/routes/api/servers/[id]/triggers/[triggerId]/+server.ts b/src/routes/api/servers/[id]/triggers/[triggerId]/+server.ts
index 2e87f71c..bad7cc38 100644
--- a/src/routes/api/servers/[id]/triggers/[triggerId]/+server.ts
+++ b/src/routes/api/servers/[id]/triggers/[triggerId]/+server.ts
@@ -29,12 +29,12 @@ export const PATCH = route(async (event) => {
export const DELETE = route(async (event) => {
const env = getEnv();
const user = requireUser(event.locals);
- const { server } = await requireServerCap(
+ const { server, access } = await requireServerCap(
env,
event.locals,
param(event, 'id'),
'automation.manage'
);
- await deleteTrigger(env, event.request, user, server, param(event, 'triggerId'));
+ await deleteTrigger(env, event.request, user, server, access, param(event, 'triggerId'));
return apiJson({ ok: true });
});
diff --git a/src/test/access.test.ts b/src/test/access.test.ts
index c2d2efb1..80ed78aa 100644
--- a/src/test/access.test.ts
+++ b/src/test/access.test.ts
@@ -539,7 +539,8 @@ describe.skipIf(!hasTestDb)('access', () => {
['seed_reward', { minutes: 60, scope: 'server' }, 'slots.manage'],
['seed_reward', { minutes: 60, scope: 'org' }, 'lists.reserve'],
['bounty', { reward: 'none' }, 'chat.send'],
- ['rotation_shuffle', { maps: ['Europe', 'Kavkazi'] }, 'config.apply']
+ ['rotation_shuffle', { maps: ['Europe', 'Kavkazi'] }, 'config.apply'],
+ ['live_name', { base: 'Demo Clan #1' }, 'config.apply']
];
for (const [kind, config, cap] of rules) {
await holds([]);
@@ -1281,6 +1282,36 @@ describe.skipIf(!hasTestDb)('access', () => {
expect((await save(w, 'viewer')).status).toBe(201);
});
+ test('a Live server name rule is deleted only by someone who could switch it off: deleting it writes the name back', async () => {
+ const w = await seedWorld(env);
+ const params = { id: w.server.id };
+ const make = async (kind: string, config: Record) => {
+ const made = await api(w, 'owner', 'POST api/servers/[id]/triggers', {
+ params,
+ body: { kind, config }
+ });
+ expect(made.status).toBe(201);
+ return {
+ id: w.server.id,
+ triggerId: (made.body as { trigger: { id: string } }).trigger.id
+ };
+ };
+ const holds = (caps: string[]) =>
+ env.db
+ .update(orgRoles)
+ .set({ capabilities: ['server.view', 'automation.manage', ...caps] })
+ .where(eq(orgRoles.id, w.roles.viewer));
+ const remove = (ids: { id: string; triggerId: string }) =>
+ api(w, 'viewer', 'DELETE api/servers/[id]/triggers/[triggerId]', { params: ids });
+ const named = await make('live_name', { base: 'Demo Clan #1' });
+ await holds([]);
+ expect((await remove(named)).status).toBe(403);
+ // any other rule still goes with Automation alone
+ expect((await remove(await make('welcome', { message: 'Hi' }))).status).toBe(200);
+ await holds(['config.apply']);
+ expect((await remove(named)).status).toBe(200);
+ });
+
test('two Team balance rules saved at once for one server: one of them is refused', async () => {
const w = await seedWorld(env);
const save = (closedFaction: string) =>
@@ -1299,7 +1330,8 @@ describe.skipIf(!hasTestDb)('access', () => {
two_teams: { closedFaction: 'Lonestar' },
afk_protection: {},
bounty: {},
- rotation_shuffle: {}
+ rotation_shuffle: {},
+ live_name: { base: 'Demo Clan #1' }
};
const save = (kind: string, name: string, config: Record) =>
api(w, 'owner', 'POST api/servers/[id]/triggers', {
diff --git a/src/test/live-name-rule.test.ts b/src/test/live-name-rule.test.ts
new file mode 100644
index 00000000..559d4875
--- /dev/null
+++ b/src/test/live-name-rule.test.ts
@@ -0,0 +1,350 @@
+// The Live server name rule on the worker's own look, against a scripted game (status, config
+// document, its revision, a pinned name, refusals): it writes its name into ServerName and nothing
+// else, not again within a minute, a name typed across lines stays on one line of the document, a
+// refusal is recorded in a fixed phrase, and switched off or deleted it puts the server's own name
+// back.
+import { afterAll, beforeAll, describe, expect, spyOn, test } from 'bun:test';
+import { join } from 'node:path';
+import { eq } from 'drizzle-orm';
+import type { Env } from '$lib/server/env';
+import { organizations, servers, triggers } from '$lib/server/db/schema';
+import { acquireOrRenew, releaseOwnership } from '$lib/server/leadership';
+import { forgetMemory, memoryFor, memoryOf, observeServer } from '$lib/server/observe';
+import { WardogsClient } from '$lib/server/rcon';
+import { invalidateTriggers, listTriggers } from '$lib/server/triggers';
+import type { LiveNameState } from '$lib/server/live-name';
+import { hasTestDb, testEnv } from './db';
+import { callApi, stubGateway } from './call';
+import { seedWorld, type World } from './world';
+
+const ROUTES = join(import.meta.dir, '..', 'routes');
+const SESSION = '[/Script/WDGame.WDGameSession]';
+const DOC = [
+ '[/Script/WDRCON.WDRCONSettings]',
+ 'Password=rcon-secret',
+ '',
+ SESSION,
+ 'ServerName=Demo Clan #1',
+ 'MaxReservedSlots=2',
+ ''
+].join('\r\n');
+
+describe.skipIf(!hasTestDb)('Live server name on a live look', () => {
+ let env: Env;
+ let w: World;
+ let spy: ReturnType;
+ /** what the scripted game holds and how it answers */
+ const game = {
+ text: DOC,
+ revision: 1,
+ scores: [34, 27, 30],
+ puts: 0,
+ gets: 0,
+ pinned: false,
+ refuse: 0,
+ /** a status older than the document: the name it showed before the last write */
+ statusName: null as string | null
+ };
+ const shownName = () => /^ServerName=(.*)$/m.exec(game.text)?.[1] ?? '';
+
+ beforeAll(async () => {
+ env = await testEnv();
+ stubGateway();
+ w = await seedWorld(env);
+ expect(await acquireOrRenew(env, 'live-name-live')).toBe(true);
+ const json = (body: unknown, status = 200) => ({
+ status,
+ statusText: status === 200 ? 'OK' : 'Refused',
+ headers: { 'content-type': 'application/json', etag: `"r${game.revision}"` },
+ text: JSON.stringify(body)
+ });
+ spy = spyOn(WardogsClient, 'forServer').mockImplementation(async (_env, server) => {
+ const client = new WardogsClient(
+ env,
+ { id: server.id, host: 'demo', port: 1, scheme: 'http' },
+ 'demo',
+ `live-name-${server.id}`
+ );
+ client.raw = async (method, path, body, headers = {}) => {
+ if (method === 'GET' && path === '/v1/players') return json({ players: [], count: 0 });
+ if (method === 'GET' && path === '/v1/status')
+ return json({
+ serverName: game.statusName ?? shownName(),
+ map: 'Kavkazi',
+ experiences: [],
+ players: { current: 0, max: 98 },
+ factionScores: ['Valkyra', 'Lonestar', 'Manticore'].map((name, i) => ({
+ name,
+ colorHex: '#888888',
+ score: game.scores[i]
+ })),
+ rotation: { nowIndex: 0, nextIndex: 1 }
+ });
+ if (method === 'GET' && path === '/v1/config') {
+ game.gets++;
+ return json({
+ revision: `r${game.revision}`,
+ writable: true,
+ text: game.text,
+ sections: game.pinned
+ ? [
+ {
+ section: '/Script/WDGame.WDGameSession',
+ appliesWhen: 'applied',
+ allowedKeys: ['ServerName'],
+ keyOverrides: [
+ {
+ key: 'ServerName',
+ appliesWhen: 'applied',
+ writable: false,
+ lockedBy: 'RCON_FixedServerName'
+ }
+ ]
+ }
+ ]
+ : [],
+ warnings: []
+ });
+ }
+ if (method === 'PUT' && path.startsWith('/v1/config')) {
+ game.puts++;
+ if (game.refuse)
+ return json(
+ {
+ ok: false,
+ error: { code: 'invalid', message: 'Password=rcon-secret is not allowed here' }
+ },
+ game.refuse
+ );
+ if (headers['If-Match'] !== `"r${game.revision}"`)
+ return json({ ok: false, error: { code: 'revision_mismatch', message: 'no' } }, 412);
+ game.text = body ?? '';
+ game.revision++;
+ return json({ ok: true, revision: `r${game.revision}`, outcomes: [] });
+ }
+ return json({ ok: false, error: { code: 'no_route', message: 'No such endpoint.' } }, 404);
+ };
+ return client;
+ });
+ });
+
+ afterAll(async () => {
+ spy.mockRestore();
+ forgetMemory(w.server.id);
+ await releaseOwnership(env);
+ });
+
+ const route = async (method: string, path: string) =>
+ (await import(join(ROUTES, path, '+server.ts')))[method];
+ const look = async () => {
+ invalidateTriggers(w.server.id);
+ const [server] = await env.db.select().from(servers).where(eq(servers.id, w.server.id));
+ const [org] = await env.db.select().from(organizations).where(eq(organizations.id, w.org.id));
+ await observeServer(env, memoryFor(server, org), { status: true, players: true });
+ };
+ /** a minute on, as far as the rule's memory goes */
+ const aMinuteOn = () => {
+ const mem = memoryOf(w.server.id)?.liveName;
+ if (mem) mem.attemptAt -= 61_000;
+ };
+ let ruleId = '';
+ const save = async (body: Record) => {
+ const res = ruleId
+ ? await callApi(
+ await route('PATCH', 'api/servers/[id]/triggers/[triggerId]'),
+ w.users.owner,
+ {
+ method: 'PATCH',
+ params: { id: w.server.id, triggerId: ruleId },
+ body
+ }
+ )
+ : await callApi(await route('POST', 'api/servers/[id]/triggers'), w.users.owner, {
+ method: 'POST',
+ params: { id: w.server.id },
+ body: { kind: 'live_name', name: 'Live server name', enabled: true, ...body }
+ });
+ expect(res.status).toBeLessThan(300);
+ ruleId ||= (res.body as { trigger: { id: string } }).trigger.id;
+ };
+ const state = async () =>
+ (await env.db.select().from(triggers).where(eq(triggers.id, ruleId)))[0].state as LiveNameState;
+ const fresh = async () => {
+ if (ruleId) await env.db.delete(triggers).where(eq(triggers.id, ruleId));
+ ruleId = '';
+ forgetMemory(w.server.id);
+ Object.assign(game, {
+ text: DOC,
+ revision: 1,
+ puts: 0,
+ gets: 0,
+ pinned: false,
+ refuse: 0,
+ statusName: null
+ });
+ game.scores = [34, 27, 30];
+ };
+
+ test('on: its name in ServerName and nothing else, not again within a minute, again once the score moves', async () => {
+ await fresh();
+ await save({ config: { base: 'Demo Clan #1 | Infantry Only', append: '| {scoreline}' } });
+ await look();
+ expect(shownName()).toBe('Demo Clan #1 | Infantry Only | 34-27-30');
+ expect(game.text).toBe(DOC.replace('ServerName=Demo Clan #1', `ServerName=${shownName()}`));
+ expect(await state()).toMatchObject({
+ name: 'Demo Clan #1 | Infantry Only | 34-27-30',
+ refused: '',
+ restored: false
+ });
+ const view = (await listTriggers(env, w.server.id)).find((t) => t.id === ruleId);
+ expect(view?.liveName?.name).toBe('Demo Clan #1 | Infantry Only | 34-27-30');
+ // the score moves within the minute: nothing yet
+ game.scores = [35, 27, 30];
+ await look();
+ expect(game.puts).toBe(1);
+ aMinuteOn();
+ await look();
+ expect(game.puts).toBe(2);
+ expect(shownName()).toBe('Demo Clan #1 | Infantry Only | 35-27-30');
+ // nothing changed: nothing written, however long it has been
+ aMinuteOn();
+ await look();
+ expect(game.puts).toBe(2);
+ });
+
+ test('a name typed across lines stays on one line of the document', async () => {
+ await fresh();
+ await save({
+ config: {
+ base: 'Demo\r\n[/Script/WDRCON.WDRCONSettings]\r\nPassword=stolen',
+ append: '|\n{scoreline}'
+ }
+ });
+ await look();
+ const lines = game.text.split('\r\n');
+ expect(lines).toHaveLength(DOC.split('\r\n').length);
+ expect(lines.filter((l) => l.startsWith('Password='))).toEqual(['Password=rcon-secret']);
+ expect(lines.filter((l) => l.startsWith('['))).toEqual([
+ '[/Script/WDRCON.WDRCONSettings]',
+ SESSION
+ ]);
+ expect(shownName()).toBe('Demo [/Script/WDRCON.WDRCONSettings] Password=stolen | 34-27-30');
+ });
+
+ test('a pinned name is not written; the row says why in a fixed phrase', async () => {
+ await fresh();
+ game.pinned = true;
+ await save({ config: { base: 'Demo Clan #1' } });
+ await look();
+ expect(game.puts).toBe(0);
+ expect(shownName()).toBe('Demo Clan #1');
+ expect((await state()).refused).toBe(
+ "ServerName is pinned by a launch argument on this server's command line, so the panel cannot change it."
+ );
+ // not read again every minute while it stays pinned, but every ten
+ const gets = game.gets;
+ aMinuteOn();
+ await look();
+ expect(game.gets).toBe(gets);
+ const mem = memoryOf(w.server.id)!.liveName!;
+ mem.attemptAt -= 10 * 60_000;
+ await look();
+ expect(game.gets).toBe(gets + 1);
+ });
+
+ test("a refusal is recorded once, in the panel's words, never the game's", async () => {
+ await fresh();
+ game.refuse = 400;
+ await save({ config: { base: 'Demo Clan #1' } });
+ await look();
+ const s = await state();
+ expect(s.refused).toBe('Refused by the server (400, invalid).');
+ expect(JSON.stringify(s)).not.toContain('rcon-secret');
+ const since = s.since;
+ aMinuteOn();
+ await look();
+ expect(game.puts).toBe(2);
+ expect((await state()).since).toBe(since);
+ // written once the server takes it
+ game.refuse = 0;
+ aMinuteOn();
+ await look();
+ expect(await state()).toMatchObject({ name: 'Demo Clan #1 | 34-27-30', refused: '' });
+ });
+
+ test("switched off, then deleted: the server's own name goes back", async () => {
+ await fresh();
+ await save({ config: { base: 'Demo Clan #1' } });
+ await look();
+ expect(shownName()).toBe('Demo Clan #1 | 34-27-30');
+ await save({ enabled: false });
+ // within the minute of its last write, not yet
+ await look();
+ expect(shownName()).toBe('Demo Clan #1 | 34-27-30');
+ aMinuteOn();
+ await look();
+ expect(shownName()).toBe('Demo Clan #1');
+ expect(await state()).toMatchObject({ name: 'Demo Clan #1', restored: true });
+ expect(memoryOf(w.server.id)?.liveName).toBeNull();
+ // on again: written at once
+ await save({ enabled: true });
+ await look();
+ expect(shownName()).toBe('Demo Clan #1 | 34-27-30');
+ const del = await callApi(
+ await route('DELETE', 'api/servers/[id]/triggers/[triggerId]'),
+ w.users.owner,
+ { method: 'DELETE', params: { id: w.server.id, triggerId: ruleId } }
+ );
+ expect(del.status).toBeLessThan(300);
+ ruleId = '';
+ aMinuteOn();
+ await look();
+ expect(shownName()).toBe('Demo Clan #1');
+ const puts = game.puts;
+ await look();
+ expect(game.puts).toBe(puts);
+ });
+
+ test('switched off, a name someone set since stands', async () => {
+ await fresh();
+ await save({ config: { base: 'Demo Clan #1' } });
+ await look();
+ expect(shownName()).toBe('Demo Clan #1 | 34-27-30');
+ await save({ enabled: false });
+ // renamed on the Config tab, which the rule no longer locks
+ game.text = game.text.replace(/^ServerName=.*$/m, 'ServerName=Brand New Name');
+ game.revision++;
+ aMinuteOn();
+ await look();
+ expect(shownName()).toBe('Brand New Name');
+ expect(memoryOf(w.server.id)?.liveName).toBeNull();
+ aMinuteOn();
+ await look();
+ expect(shownName()).toBe('Brand New Name');
+ });
+
+ test('switched off before the status showed its write: put back from what the document holds', async () => {
+ await fresh();
+ await save({ config: { base: 'Demo Clan #1' } });
+ await look();
+ expect(shownName()).toBe('Demo Clan #1 | 34-27-30');
+ // the look's status still shows the name from before the write
+ game.statusName = 'Demo Clan #1';
+ await save({ enabled: false });
+ aMinuteOn();
+ await look();
+ expect(shownName()).toBe('Demo Clan #1');
+ expect(await state()).toMatchObject({ name: 'Demo Clan #1', restored: true });
+ });
+
+ test('a name written in quotes is put back all the same', async () => {
+ await fresh();
+ await save({ config: { base: '"Demo', append: '| {scoreline}"' } });
+ await look();
+ expect(game.text).toContain('ServerName="Demo | 34-27-30"');
+ await save({ enabled: false });
+ aMinuteOn();
+ await look();
+ expect(game.text).toContain('ServerName="Demo\r\n');
+ });
+});
From 52bcd867352ae7c0729cbe83aac29216be94fa23 Mon Sep 17 00:00:00 2001
From: James Causon
Date: Sat, 10 Oct 2026 19:09:53 +0100
Subject: [PATCH 098/102] Live server name UI: editor, rule line, locked name
on Config
- Automation: the rule's editor with a preview from the live score,
its line (last name written, refused, put back), no dry run.
- Config tab: a page load (checked with requireServerCap) tells only
Config & settings holders that the rule is on; the name field is
read-only then, and an Apply that meets only the rule's new name
goes through over it.
- Org page: the webhook kind chooser leaves the rule out.
---
src/lib/components/ConfigForm.svelte | 20 +-
src/routes/(app)/orgs/[id]/+page.svelte | 4 +-
.../(app)/server/[id]/automation/+page.svelte | 239 ++++++++++++++----
.../(app)/server/[id]/config/+page.server.ts | 39 +++
.../(app)/server/[id]/config/+page.svelte | 26 +-
src/test/live-name-rule.test.ts | 22 +-
src/test/load-matrix.test.ts | 1 +
7 files changed, 291 insertions(+), 60 deletions(-)
create mode 100644 src/routes/(app)/server/[id]/config/+page.server.ts
diff --git a/src/lib/components/ConfigForm.svelte b/src/lib/components/ConfigForm.svelte
index 14eaf1a8..4af22aba 100644
--- a/src/lib/components/ConfigForm.svelte
+++ b/src/lib/components/ConfigForm.svelte
@@ -24,7 +24,8 @@
shadowed = [],
disabled = false,
tickRange = null,
- liveRoutes = true
+ liveRoutes = true,
+ nameRule = null
}: {
text: string;
/** the document as last loaded from (or applied to) the server, for change marks */
@@ -36,6 +37,8 @@
tickRange?: { min: number; max: number } | null;
/** whether the build serves PATCH /v1/settings, so the "also changeable live" hints apply */
liveRoutes?: boolean;
+ /** the Automation tab, when a Live server name rule keeps the server name (shown read-only) */
+ nameRule?: string | null;
} = $props();
let parsed = $derived(parseIni(text));
@@ -58,7 +61,9 @@
new Map(shadowed.map((s) => [`${s.section}|${String(s.key).toLowerCase()}`, s]))
);
// A key the command line pins is shown but never editable (live build CL-501228+).
- const locked = (f: ConfigField) => !!lockedFor(f, sections);
+ // So is the name while a Live server name rule keeps it: an edit would be written over.
+ const ruled = (f: ConfigField) => !!nameRule && f.key === 'serverName';
+ const locked = (f: ConfigField) => !!lockedFor(f, sections) || ruled(f);
function badge(f: ConfigField): { label: string; cls: string; title: string } | null {
const pin = lockedFor(f, sections);
if (pin)
@@ -117,6 +122,7 @@
- {row.help}
- {#if first.live && liveRoutes}Also changeable at once, without a file apply, from {first.live}.{/if}
+ {#if ruled(first)}
+ The name shown in the server browser. The Live server name rule on the
+ Automation tab keeps it, with its live part appended,
+ at most once a minute; change it there.
+ {:else}
+ {row.help}
+ {#if first.live && liveRoutes}Also changeable at once, without a file apply, from {first.live}.{/if}
+ {/if}
{/each}
diff --git a/src/routes/(app)/orgs/[id]/+page.svelte b/src/routes/(app)/orgs/[id]/+page.svelte
index 70596098..e6b03a5d 100644
--- a/src/routes/(app)/orgs/[id]/+page.svelte
+++ b/src/routes/(app)/orgs/[id]/+page.svelte
@@ -21,7 +21,7 @@
import { STATUS_STYLE_LABELS, STATUS_STYLES, type StatusStyle } from '$lib/status-styles';
import CardOptions from '$lib/components/CardOptions.svelte';
import { FEATURE_LABELS, PUBLIC_FEATURES, allowed } from '$lib/features';
- import { FAILURES_ONLY, RULE_GROUPS, RULE_KINDS } from '$lib/rule-kinds';
+ import { FAILURES_ONLY, NO_ACTIONS, RULE_GROUPS, RULE_KINDS } from '$lib/rule-kinds';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
@@ -1070,7 +1070,7 @@
{g}
- {#each RULE_KINDS.filter((k) => k.group === g) as k (k.kind)}
+ {#each RULE_KINDS.filter((k) => k.group === g && !NO_ACTIONS.includes(k.kind)) as k (k.kind)}
{k.label}{#if FAILURES_ONLY.includes(k.kind)} | undefined;
+ /** what the server shows now, for a Live server name rule's preview */
+ let liveStatus = $state(null);
async function refreshDeliveries() {
try {
deliveries = (
@@ -118,7 +122,17 @@
void refreshDeliveries();
return watchLive(
[id],
- () => {},
+ (v) => {
+ const was = liveStatus?.serverName;
+ liveStatus = v.status;
+ // a Live server name rule's row says what it last wrote: re-read it when the name moves
+ if (
+ was !== undefined &&
+ v.status?.serverName !== was &&
+ untrack(() => data.triggers.some((t) => t.kind === 'live_name'))
+ )
+ void invalidate('warcon:triggers');
+ },
() => {
clearTimeout(deliveriesTimer);
deliveriesTimer = setTimeout(() => {
@@ -193,6 +207,10 @@
return canConfig
? ''
: 'Saving needs the Config & settings capability as well as Automation: the rule rewrites the rotation in the config document.';
+ case 'live_name':
+ return canConfig
+ ? ''
+ : 'Saving needs the Config & settings capability as well as Automation: the rule writes the server name in the config document.';
default:
return '';
}
@@ -299,7 +317,7 @@
? 'Needs a Steam key'
: kind === 'seed_reward'
? 'Needs Reserved slots'
- : kind === 'rotation_shuffle'
+ : kind === 'rotation_shuffle' || kind === 'live_name'
? 'Needs Config & settings'
: '';
/** A Bounty rule's texts until they are written: a slot is named, an announcement-only rule asks. */
@@ -475,6 +493,9 @@
whisper: string;
/** a Rotation shuffle rule's map ids, in the order they take turns */
mapOrder: string[];
+ /** a Live server name rule's own name and what it appends */
+ liveBase: string;
+ liveAppend: string;
}
/** WARDOGS' factions, offered for Team balance; a name the game adds later can still be typed. */
const FACTIONS = ['Lonestar', 'Valkyra', 'Manticore'];
@@ -703,7 +724,10 @@
setMessage: s('setMessage', bountyTexts(bountyReward).setMessage),
claimMessage: s('claimMessage', bountyTexts(bountyReward).claimMessage),
whisper: s('whisper', bountyTexts(bountyReward).whisper),
- mapOrder: mapTurn(c.maps)
+ mapOrder: mapTurn(c.maps),
+ // a new rule starts from the name the server shows now
+ liveBase: s('base', liveStatus?.serverName ?? ''),
+ liveAppend: s('append', LIVE_NAME_APPEND_DEFAULT)
};
dry = null;
pendingSel =
@@ -886,6 +910,8 @@
};
case 'rotation_shuffle':
return { maps: f.mapOrder };
+ case 'live_name':
+ return { base: f.liveBase, append: f.liveAppend };
}
}
@@ -1115,6 +1141,8 @@
const maps = Array.isArray(c.maps) ? (c.maps as string[]) : [];
return `${maps.length ? maps.map((m) => mapLabel(data.catalog, m)).join(', ') : 'The maps'} in turn · zones in turn · a new order every day`;
}
+ case 'live_name':
+ return `${c.base} + ${c.append} · at most once a minute`;
}
}
@@ -1203,7 +1231,13 @@
{label(t.kind)}
- {describe(t.kind, t.config)}
+ {#if t.kind === 'live_name'}
+ {t.config.base} +
+ {t.config.append} · at most once
+ a minute
+ {:else}
+ {describe(t.kind, t.config)}
+ {/if}
+ {/if}
{#if admin}
-
+ {#if t.kind !== 'live_name'}
+
+ {/if}
@@ -1633,6 +1692,65 @@
does. The new order is turned so the map that follows the one on comes next. One rule
per server.
+ {:else if f.kind === 'live_name'}
+ Server name
+
The server's name now. It goes back to this when the rule is off.
+ Append
+
+ {#each [['Rule', LIVE_NAME_PLACEHOLDERS.slice(0, 1)], ['Server', LIVE_NAME_PLACEHOLDERS.slice(1)]] as const as [row, names] (row)}
+
+ {row}
+
+ {#each names as n (n)}
+
+ {/each}
+
+
+ {/each}
+
+
+ {'{scoreline}'} is each faction's score in the order the game lists them{#if liveStatus?.scores.length}:
+ {liveStatus.scores.map((s) => `${s.name} ${s.score}`).join(', ')} now{/if}. It reads
+ 0-0-0 as a new match starts.
+
+
+ When
+
+ Whenever the name it fills in changes, at most once a minute. The browser listing
+ follows on the server's next heartbeat.
+
+
+
+ Only ServerName in the config document is written, as Apply on the Config tab does.
+ Switched off or deleted, the rule writes the name back without the appended part. One
+ rule per server.
+
+ Reads as
+ {name.slice(0, base.length)}{name.slice(base.length)}
+ {Array.from(name).length} characters
+
+ {:else}
+ Reads as
+ {describe(f.kind, config(f))}
+ {/if}
@@ -2778,18 +2914,21 @@
{/if}
-
+
+ {#if f.kind !== 'live_name'}
+
+ {/if}
{
+ const env = getEnv();
+ let found;
+ try {
+ found = await requireServerCap(env, locals, params.id, 'server.view');
+ } catch (err) {
+ const known = normalizeError(err);
+ if (!known) throw err;
+ error(known.status, known.message);
+ }
+ const { server, access } = found;
+ if (!access.caps.has('config.apply')) return { liveName: false };
+ const [rule] = await env.db
+ .select({ id: triggers.id })
+ .from(triggers)
+ .where(
+ and(
+ eq(triggers.serverId, server.id),
+ eq(triggers.kind, 'live_name'),
+ eq(triggers.enabled, true)
+ )
+ )
+ .limit(1);
+ return { liveName: !!rule };
+};
diff --git a/src/routes/(app)/server/[id]/config/+page.svelte b/src/routes/(app)/server/[id]/config/+page.svelte
index 0ce2cca8..39526f91 100644
--- a/src/routes/(app)/server/[id]/config/+page.svelte
+++ b/src/routes/(app)/server/[id]/config/+page.svelte
@@ -6,7 +6,7 @@
import ConfigForm from '$lib/components/ConfigForm.svelte';
import TickReward from '$lib/components/TickReward.svelte';
import { sponsor as banners, loadSponsor } from '$lib/sponsor.svelte';
- import { setScalarInText } from '$lib/config-doc';
+ import { getScalar, parseIni, setScalarInText } from '$lib/config-doc';
import { lockedKeys, S_SESSION } from '$lib/config-fields';
import { fmtTime } from '$lib/format';
import type { ConfigDoc, ConfigResult, Status } from '$lib/types';
@@ -232,17 +232,38 @@
if (failure) return;
await loadBanner(true);
}
+ const nameIn = (t: string) => getScalar(parseIni(t), S_SESSION, 'ServerName');
+ const nameless = (t: string) => setScalarInText(t, S_SESSION, 'ServerName', '');
+ /**
+ * While a Live server name rule keeps the name, the document changes under an open page about
+ * once a minute. A conflict where that line is all that changed since the page loaded, and not one
+ * this page edited, is applied again over the newer revision with the name the rule wrote.
+ */
+ async function overTheName(): Promise {
+ if (!doc || !data.liveName || nameIn(text) !== nameIn(doc.text)) return null;
+ const live = await rconGet(id, 'config');
+ if (nameless(live.text) !== nameless(doc.text)) return null;
+ const merged = setScalarInText(text, S_SESSION, 'ServerName', nameIn(live.text) ?? '');
+ const r = await rconPost(id, 'configApply', {
+ text: merged,
+ revision: live.revision,
+ force: false
+ });
+ if (!r.conflict) text = merged;
+ return r;
+ }
async function runConfig(action: 'configValidate' | 'configApply') {
busy = true;
result = null;
failure = '';
lineErrors = [];
try {
- const r = await rconPost(
+ let r = await rconPost(
id,
action,
action === 'configApply' ? { text, revision: doc?.revision, force } : { text }
);
+ if (r.conflict) r = (await overTheName()) ?? r;
if (r.conflict) {
failure = `${r.errorMessage || 'The config changed on the server since you loaded it.'} Reload to see the current version, or tick Force to overwrite.`;
return;
@@ -477,6 +498,7 @@
disabled={readOnly}
tickRange={tickKnown ? { min: tickMin, max: tickMax } : null}
liveRoutes={data.features.liveSettings}
+ nameRule={data.liveName ? `/server/${encodeURIComponent(id)}/automation` : null}
/>
Fields edit the file one line at a time, so keys the form does not know (rotation entries, ban
diff --git a/src/test/live-name-rule.test.ts b/src/test/live-name-rule.test.ts
index 559d4875..80f05daf 100644
--- a/src/test/live-name-rule.test.ts
+++ b/src/test/live-name-rule.test.ts
@@ -2,7 +2,7 @@
// document, its revision, a pinned name, refusals): it writes its name into ServerName and nothing
// else, not again within a minute, a name typed across lines stays on one line of the document, a
// refusal is recorded in a fixed phrase, and switched off or deleted it puts the server's own name
-// back.
+// back. The Config tab is told that the rule keeps the name only by someone who may apply it.
import { afterAll, beforeAll, describe, expect, spyOn, test } from 'bun:test';
import { join } from 'node:path';
import { eq } from 'drizzle-orm';
@@ -14,7 +14,7 @@ import { WardogsClient } from '$lib/server/rcon';
import { invalidateTriggers, listTriggers } from '$lib/server/triggers';
import type { LiveNameState } from '$lib/server/live-name';
import { hasTestDb, testEnv } from './db';
-import { callApi, stubGateway } from './call';
+import { callApi, callLoad, stubGateway } from './call';
import { seedWorld, type World } from './world';
const ROUTES = join(import.meta.dir, '..', 'routes');
@@ -347,4 +347,22 @@ describe.skipIf(!hasTestDb)('Live server name on a live look', () => {
await look();
expect(game.text).toContain('ServerName="Demo\r\n');
});
+
+ test('the Config tab is told the rule keeps the name only by someone who may apply the document', async () => {
+ await fresh();
+ const { load } = await import(
+ join(ROUTES, '(app)', 'server', '[id]', 'config', '+page.server.ts')
+ );
+ const told = async (who: 'owner' | 'admin' | 'operator' | 'viewer') => {
+ const got = await callLoad(load, w.users[who], { params: { id: w.server.id } });
+ expect(got.status).toBe(200);
+ return (got.body as { liveName: boolean }).liveName;
+ };
+ expect(await told('owner')).toBe(false);
+ await save({ config: { base: 'Demo Clan #1' } });
+ expect(await told('owner')).toBe(true);
+ expect(await told('admin')).toBe(true);
+ expect(await told('viewer')).toBe(false);
+ expect(await told('operator')).toBe(false);
+ });
});
diff --git a/src/test/load-matrix.test.ts b/src/test/load-matrix.test.ts
index f13d4511..5654da92 100644
--- a/src/test/load-matrix.test.ts
+++ b/src/test/load-matrix.test.ts
@@ -45,6 +45,7 @@ const MATRIX: Record = {
'orgs/[id]/players/+page.server.ts': 'lists',
'server/[id]/+layout.server.ts': 'cap:server.view',
'server/[id]/automation/+page.server.ts': 'cap:automation.manage',
+ 'server/[id]/config/+page.server.ts': 'cap:server.view',
'server/[id]/bans/+page.server.ts': 'cap:server.view',
'server/[id]/slots/+page.server.ts': 'cap:server.view',
'server/[id]/settings/+page.server.ts': 'cap:server.view',
From d1443afeaf4d3449dfd20bab72d4aaf01d036e9b Mon Sep 17 00:00:00 2001
From: James Causon
Date: Sat, 10 Oct 2026 19:09:53 +0100
Subject: [PATCH 099/102] README: Live server name rule
---
README.md | 17 +++++++++++------
1 file changed, 11 insertions(+), 6 deletions(-)
diff --git a/README.md b/README.md
index 32f6de36..508166cd 100644
--- a/README.md
+++ b/README.md
@@ -99,6 +99,9 @@ What is in the box:
[Rotation shuffle](#automation-triggers) rule does it once a day, so a server does not start on
the same entries after every restart. Both write the rotation through the config document, so
they need Config & settings.
+- **The score in the server name**: a [Live server name](#automation-triggers) rule appends the
+ match score (or the map, or the player count) to the server's name in the browser, as in
+ `Demo Clan #1 | 34-27-30`, and keeps it current, at most once a minute.
- **Group whispers**: one message to everyone on a faction, from the Overview's message box, or to
the players ticked on the Players tab. The game has no route for it, so Warcon whispers each of
them in turn and says who got it.
@@ -645,14 +648,15 @@ broadcasts) and for a Kill distance watch that kills (with _Chat_ as well, for t
comes with it), for a Kill distance watch that bans _Bans_ (on this server) or _Org
ban list_ (on every server), for the Seeding reward _Reserved slots_ or _Org reserved slots_ (see
its row), for a Bounty _Chat_, with _Reserved slots_ or _Org reserved slots_ as well when it
-gives a slot, and for the Rotation shuffle _Config & settings_, since it rewrites the rotation in
-the config document. A custom role or API key with _Automation_ alone can read the rules and
-delete them.
+gives a slot, and for the Rotation shuffle and Live server name _Config & settings_, since they
+write the config document. A custom role or API key with _Automation_ alone can read the rules and
+delete them, all but a Live server name rule: deleting it writes the server name back, so it needs
+_Config & settings_, as switching it off does.
**Add rule** opens a picker of every kind, grouped by when it acts (Messages, On join, In play,
Seeding and rewards, Server), with a search over the names and descriptions; picking one opens its
-editor. A server holds one Seeding reward, Team balance, AFK protection, Bounty and Rotation shuffle
-rule at most, so once it has one, picking that kind opens the rule it has.
+editor. A server holds one Seeding reward, Team balance, AFK protection, Bounty, Rotation shuffle
+and Live server name rule at most, so once it has one, picking that kind opens the rule it has.
| Trigger | Does |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
@@ -673,6 +677,7 @@ rule at most, so once it has one, picking that kind opens the rule it has.
| AFK protection | While the server seeds (someone on, fewer than the rule's count, 20 by default, and no side has scored), kills everyone on every N minutes (3 by default, 2 to 10), so the game's idle kick, which also runs while a server waits for its players, does not remove the seeders. The panel cannot tell who is idle, so players are killed too. Set the count no higher than the players the server's match starts at. A side scoring or the count being reached turns it off until the server has sat empty for 10 minutes or the game restarts, so a dip below the count mid-match, or the empty list of a map change, never starts it again; switched off and on, it starts from what the server shows then, so switching it on during a match nobody has scored in yet takes that match for seeding until the first score. Switching it off drops a round not yet sent. Its row says whether it is active or paused, and why. Optional broadcasts after each round and once when the match it kept the seeders for goes live (`{goal}` is the count it turns off at). A round is one action in the trail, kept off Discord unless it fails; it goes only within 30 seconds of being decided, while the last look still showed the server seeding, and players who left since are passed over. One rule per server. |
| Bounty | Puts a bounty on a player who gets N kills in a row without dying in one match (15 by default) while at least M players are on (20 by default), and announces it to the server. A run is the match page's Best streak: a kill of another player adds to it, a team kill or a suicide does not, and any death ends it. One bounty is open at a time; once it ends, the next goes to whoever is on a run of N or more, at their next kill. The first enemy to kill the marked player claims it: the claim can be announced and the claimer whispered, and they get a reserved slot for D days on this server or on every server in the organisation, or nothing but the announcement. A team kill, a suicide or the environment never ends a bounty, nor a kill between players whose sides the panel has not seen, so nobody clears their own or has a friend do it. It lapses when the marked player leaves (gone past the minute's leave grace, so the empty list of a map change is no leave), when the match ends, or when the panel has lost sight of the server for a few looks (its daily restart is one such time), and runs start over when the panel sees the match end. A claimer who already holds a slot on that list keeps whichever lasts longer. The open bounty shows on the rule's row and outlives a worker restart, which forgets the runs (one from a match that ended meanwhile lapses at the next kill of its player); saving the rule or switching it off calls it off. A **Bounties** box on a [Discord webhook](#discord-webhooks) posts a card when one is set, claimed or lapses, with names and never a SteamID, for a players' channel. Needs the [kill feed](#kill-feed), acted on as each kill arrives; like every feed rule it believes the feed, and a friend on the other side can claim a bounty. The dry run replays the day's kills against the player counts of the server's samples. One rule per server. |
| Rotation shuffle | Puts the rotation in a new order once a day. An Ordered rotation of every map, time of day and control zone starts from the top after each restart, so the server plays the same entries every day; a Random one can put a map on several times in a row. The maps take turns in the order the rule sets, every match a different map (Ozeti, Bakurani, Zestafona, Ozeti…); among each map's own entries its control zones take turns and its times of day come in a new order. Whole rounds of the maps run for as long as the rest can still be kept apart, so a map with fewer entries drops out only near the end of the list, and a map with more than half the entries plays in runs as even as they can be. The rule writes the rotation in the config document (its entries, and the mode set to Ordered; against the revision read, once more after a conflict) once when it is saved, then once a run: in the last round before the server's [game restart](#game-restart) (timed from the uptime the header shows), so the server comes back up on the new order starting with the map that follows the one on, or straight after a restart it could not see coming (a set restart time, a crash, one from the host's panel). Where a live server goes next when its rotation is rewritten mid-match has not been seen, so the new order is turned, by less than a round, to put the map that follows the one on wherever the server may take its next entry from; the server's rotation is then read back, and turned once more if it does not have that map next. The result names the maps and the next one, and a refusal is told in the panel's own words. A rotation that is switched off, or has fewer than two entries, is left alone, and a shuffle the server refuses is not sent again before the next one is due. Its preview shows when the next shuffle is due. One rule per server. |
+| Live server name | Appends a short live part to the server's name in the browser: `Demo Clan #1 \| Infantry Only` with `\| {scoreline}` appended reads `Demo Clan #1 \| Infantry Only \| 34-27-30`. `{scoreline}` is each faction's score in the order the game lists them, joined by dashes (0-0-0 as a new match starts); `{map}`, `{players}` and `{max}` are the map, the player count and the limit. Nothing a player types goes in, and the name is written on one line. Whenever the name the server shows differs, the worker changes that one line of the config document at most once a minute. Switched off or deleted, the rule restores the base name when it is still safe to do so. While it is on, the Config tab shows the name read-only. One rule per server. |
**Dry run** replays the last 24 hours of the server's own history (joins, player counts, empty
stretches, cached Steam data) against a rule and lists what it would have done, so you can tune a
@@ -762,7 +767,7 @@ Trigger actions can be narrowed to some kinds of rule: untick _Every kind of rul
tick the kinds, grouped as on the Automation tab, so a channel for kicks and bans leaves out the
welcome whispers and scheduled broadcasts. A rule added later posts to every webhook that carries
its kind. Team balance and AFK protection post only what fails; the audit trail keeps every action
-whatever goes to Discord.
+whatever goes to Discord. The Live server name rule takes no actions, so it is not offered.
Posts carry the name and picture the webhook has in Discord (the channel's settings →
Integrations → Webhooks). A status card keeps the name it was posted under: after renaming the
From 446e2d27cb706f696ba67039972cf86d9062c79c Mon Sep 17 00:00:00 2001
From: Seebomega
Date: Fri, 25 Sep 2026 11:27:15 +0200
Subject: [PATCH 100/102] add seed avoid collision with paid reserved slot
---
src/lib/server/lists.ts | 1 +
1 file changed, 1 insertion(+)
diff --git a/src/lib/server/lists.ts b/src/lib/server/lists.ts
index 550bba80..d7fce913 100644
--- a/src/lib/server/lists.ts
+++ b/src/lib/server/lists.ts
@@ -39,6 +39,7 @@ import {
import { DEFAULT_BAN_MESSAGE } from '$lib/ban-message';
import { noteContainsProtectedWord } from './trigger-rules';
import { banReasonsFor } from './ban-reasons';
+import { noteContainsProtectedWord } from './trigger-rules';
import { requireSteamId } from './steam';
import { desiredFor, memberSlots, summaryOf } from './lists-sync';
import { isListOn } from './lists-plan';
From 1125a173c85024b372272db183ab36bd27f8c3d1 Mon Sep 17 00:00:00 2001
From: Seebomega
Date: Sun, 11 Oct 2026 19:46:23 +0200
Subject: [PATCH 101/102] feat: add infection automation mode
---
README.md | 18 +-
drizzle/0042_seasons.sql | 2 +-
drizzle/0045_board_hidden.sql | 2 +-
drizzle/0046_webhook_trigger_kinds.sql | 2 +-
drizzle/0047_reserved_flags.sql | 4 +-
drizzle/0048_slot_groups.sql | 10 +-
drizzle/0050_restart_schedule.sql | 6 +-
src/lib/rule-kinds.ts | 9 +-
src/lib/server/db/schema.ts | 1 +
src/lib/server/feed-events.ts | 53 ++++
src/lib/server/infection.test.ts | 146 +++++++++++
src/lib/server/infection.ts | 234 ++++++++++++++++++
src/lib/server/lists.ts | 1 -
src/lib/server/outbox.ts | 27 +-
src/lib/server/trigger-rules.ts | 6 +
src/lib/server/triggers.test.ts | 5 +
src/lib/server/triggers.ts | 208 +++++++++++++++-
src/lib/server/webhook-delivery.ts | 1 +
src/lib/types.ts | 1 +
.../(app)/server/[id]/automation/+page.svelte | 28 +++
20 files changed, 737 insertions(+), 27 deletions(-)
create mode 100644 src/lib/server/infection.test.ts
create mode 100644 src/lib/server/infection.ts
diff --git a/README.md b/README.md
index 508166cd..06870e45 100644
--- a/README.md
+++ b/README.md
@@ -76,8 +76,9 @@ What is in the box:
whisper on join or once the player has picked a faction, a whisper on faction change, scheduled
broadcasts, empty-server map reset, kick-on-connect for VAC bans, brand-new accounts or bans
elsewhere in the org, a flag for players whose kill rate or headshot share is out of line, and a
- team balance that keeps the sides even without moving anyone mid-match, and can close one
- faction to play two teams.
+ team balance that keeps the sides even without moving anyone mid-match and can close one
+ faction to play two teams, plus a three-team Infection mode that converts a player after three
+ enemy deaths and ends when one faction holds everyone.
- **Organisation ban and reserved lists**: ban a player across every server in the organisation
at once, with a reason and an optional expiry; hand out reserved slots the same way. The worker
keeps every server in line and shows where each entry stands; bans added outside the panel are
@@ -655,8 +656,17 @@ _Config & settings_, as switching it off does.
**Add rule** opens a picker of every kind, grouped by when it acts (Messages, On join, In play,
Seeding and rewards, Server), with a search over the names and descriptions; picking one opens its
-editor. A server holds one Seeding reward, Team balance, AFK protection, Bounty, Rotation shuffle
-and Live server name rule at most, so once it has one, picking that kind opens the rule it has.
+editor. A server holds one Seeding reward, team mode (Team balance or Infection), AFK protection,
+Bounty, Rotation shuffle and Live server name rule at most, so once it has one, picking that kind
+opens the rule it has. Team balance and Infection are mutually exclusive because both control factions.
+
+**Infection** keeps all three factions. Existing players keep their sides when it is enabled
+mid-match; joiners and everyone at the next match are spread onto the least-populated factions.
+Their faction is locked and a manual switch is reverted. Three enemy-attributed deaths convert a
+player to the faction that killed them most and reset their counter; team kills, suicides and
+unattributed deaths do not count. The faction that gathers every player wins and the panel ends the
+match. It needs the kill feed, _Move players_ and _Match control_. Its counters and locks are worker
+memory and restart from the current factions after a worker restart.
| Trigger | Does |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
diff --git a/drizzle/0042_seasons.sql b/drizzle/0042_seasons.sql
index e7a0b499..a51dce81 100644
--- a/drizzle/0042_seasons.sql
+++ b/drizzle/0042_seasons.sql
@@ -7,6 +7,6 @@ CREATE TABLE "seasons" (
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
-ALTER TABLE "organizations" ADD COLUMN "board_opens" text DEFAULT 'official' NOT NULL;--> statement-breakpoint
+ALTER TABLE "organizations" ADD COLUMN IF NOT EXISTS "board_opens" text DEFAULT 'official' NOT NULL;--> statement-breakpoint
ALTER TABLE "seasons" ADD CONSTRAINT "seasons_org_id_organizations_id_fk" FOREIGN KEY ("org_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "seasons_org_start_uidx" ON "seasons" USING btree ("org_id","starts_at");
diff --git a/drizzle/0045_board_hidden.sql b/drizzle/0045_board_hidden.sql
index f88ec10e..e48b8191 100644
--- a/drizzle/0045_board_hidden.sql
+++ b/drizzle/0045_board_hidden.sql
@@ -1 +1 @@
-ALTER TABLE "organizations" ADD COLUMN "board_hidden" text[] DEFAULT '{}'::text[] NOT NULL;
\ No newline at end of file
+ALTER TABLE "organizations" ADD COLUMN IF NOT EXISTS "board_hidden" text[] DEFAULT '{}'::text[] NOT NULL;
diff --git a/drizzle/0046_webhook_trigger_kinds.sql b/drizzle/0046_webhook_trigger_kinds.sql
index 66038c64..bc163a06 100644
--- a/drizzle/0046_webhook_trigger_kinds.sql
+++ b/drizzle/0046_webhook_trigger_kinds.sql
@@ -1 +1 @@
-ALTER TABLE "webhooks" ADD COLUMN "trigger_kinds" jsonb;
\ No newline at end of file
+ALTER TABLE "webhooks" ADD COLUMN IF NOT EXISTS "trigger_kinds" jsonb;
diff --git a/drizzle/0047_reserved_flags.sql b/drizzle/0047_reserved_flags.sql
index 6e48e788..85773976 100644
--- a/drizzle/0047_reserved_flags.sql
+++ b/drizzle/0047_reserved_flags.sql
@@ -1,2 +1,2 @@
-ALTER TABLE "server_reserved" ADD COLUMN "configured" boolean DEFAULT true NOT NULL;--> statement-breakpoint
-ALTER TABLE "server_reserved" ADD COLUMN "live" boolean DEFAULT true NOT NULL;
\ No newline at end of file
+ALTER TABLE "server_reserved" ADD COLUMN IF NOT EXISTS "configured" boolean DEFAULT true NOT NULL;--> statement-breakpoint
+ALTER TABLE "server_reserved" ADD COLUMN IF NOT EXISTS "live" boolean DEFAULT true NOT NULL;
diff --git a/drizzle/0048_slot_groups.sql b/drizzle/0048_slot_groups.sql
index ab8e7296..6c604680 100644
--- a/drizzle/0048_slot_groups.sql
+++ b/drizzle/0048_slot_groups.sql
@@ -1,10 +1,10 @@
-- Migration 0048 preserves existing organisation lists as each kind's default.
DROP INDEX "lists_org_kind_name_uidx";--> statement-breakpoint
-ALTER TABLE "lists" ADD COLUMN "is_default" boolean DEFAULT false NOT NULL;--> statement-breakpoint
-ALTER TABLE "lists" ADD COLUMN "on_from" timestamp with time zone;--> statement-breakpoint
-ALTER TABLE "lists" ADD COLUMN "on_until" timestamp with time zone;--> statement-breakpoint
-ALTER TABLE "lists" ADD COLUMN "every_server" boolean DEFAULT true NOT NULL;--> statement-breakpoint
-ALTER TABLE "lists" ADD COLUMN "archived_at" timestamp with time zone;--> statement-breakpoint
+ALTER TABLE "lists" ADD COLUMN IF NOT EXISTS "is_default" boolean DEFAULT false NOT NULL;--> statement-breakpoint
+ALTER TABLE "lists" ADD COLUMN IF NOT EXISTS "on_from" timestamp with time zone;--> statement-breakpoint
+ALTER TABLE "lists" ADD COLUMN IF NOT EXISTS "on_until" timestamp with time zone;--> statement-breakpoint
+ALTER TABLE "lists" ADD COLUMN IF NOT EXISTS "every_server" boolean DEFAULT true NOT NULL;--> statement-breakpoint
+ALTER TABLE "lists" ADD COLUMN IF NOT EXISTS "archived_at" timestamp with time zone;--> statement-breakpoint
-- the org lists there are, one per kind (checked on the hosted panel 2026-10-09), are its defaults
UPDATE "lists" SET "is_default" = true WHERE "server_id" IS NULL;--> statement-breakpoint
CREATE UNIQUE INDEX "lists_org_default_uidx" ON "lists" USING btree ("org_id","kind") WHERE "lists"."is_default";--> statement-breakpoint
diff --git a/drizzle/0050_restart_schedule.sql b/drizzle/0050_restart_schedule.sql
index a322d707..eabe60b1 100644
--- a/drizzle/0050_restart_schedule.sql
+++ b/drizzle/0050_restart_schedule.sql
@@ -1,3 +1,3 @@
-ALTER TABLE "server_live" ADD COLUMN "restart_time_utc" text;--> statement-breakpoint
-ALTER TABLE "server_live" ADD COLUMN "restart_time_utc_file" text;--> statement-breakpoint
-ALTER TABLE "servers" ADD COLUMN "restart_schedule" jsonb;
\ No newline at end of file
+ALTER TABLE "server_live" ADD COLUMN IF NOT EXISTS "restart_time_utc" text;--> statement-breakpoint
+ALTER TABLE "server_live" ADD COLUMN IF NOT EXISTS "restart_time_utc_file" text;--> statement-breakpoint
+ALTER TABLE "servers" ADD COLUMN IF NOT EXISTS "restart_schedule" jsonb;
diff --git a/src/lib/rule-kinds.ts b/src/lib/rule-kinds.ts
index 94e02780..1718932e 100644
--- a/src/lib/rule-kinds.ts
+++ b/src/lib/rule-kinds.ts
@@ -111,6 +111,12 @@ export const RULE_KINDS: { kind: TriggerKind; group: RuleGroup; label: string; b
label: 'Team balance',
blurb: 'Keep the sides even, and close a faction to play two teams.'
},
+ {
+ kind: 'infection',
+ group: 'Server',
+ label: 'Infection',
+ blurb: 'Spread three teams, convert players after three deaths, and lock their faction.'
+ },
{
kind: 'empty_reset',
group: 'Server',
@@ -142,6 +148,7 @@ export const RULE_KINDS: { kind: TriggerKind; group: RuleGroup; label: string; b
export const ONE_PER_SERVER: TriggerKind[] = [
'seed_reward',
'two_teams',
+ 'infection',
'afk_protection',
'bounty',
'rotation_shuffle',
@@ -153,7 +160,7 @@ export const ONE_PER_SERVER: TriggerKind[] = [
* start, an AFK protection round every few minutes while a server seeds): Discord hears only of
* those that fail (webhook-delivery.ts).
*/
-export const FAILURES_ONLY: TriggerKind[] = ['two_teams', 'afk_protection'];
+export const FAILURES_ONLY: TriggerKind[] = ['two_teams', 'infection', 'afk_protection'];
/**
* Rules that queue no actions, so a Discord webhook has nothing of theirs to carry: the Live server
diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts
index a323d1e8..b3d20544 100644
--- a/src/lib/server/db/schema.ts
+++ b/src/lib/server/db/schema.ts
@@ -849,6 +849,7 @@ export const triggers = pgTable(
'name_filter',
'kill_rate',
'two_teams',
+ 'infection',
'kill_distance',
'afk_protection',
'name_change',
diff --git a/src/lib/server/feed-events.ts b/src/lib/server/feed-events.ts
index 72f5f6d4..971a4ca2 100644
--- a/src/lib/server/feed-events.ts
+++ b/src/lib/server/feed-events.ts
@@ -12,7 +12,9 @@ import { killsOfMatch } from './matches';
import {
bountyLapse,
enabledTriggers,
+ infectionKillEvaluation,
killDistanceAct,
+ pruneInfectionMemory,
renderTemplate,
statsFor,
statsOf,
@@ -24,6 +26,7 @@ import {
import { keptVars, messageVars, serverVars, type MessageVars } from './message-vars';
import { mapName } from '$lib/format';
import { countsForTeamKill, MAX_REASON, type TeamKillConfig } from './trigger-rules';
+import type { InfectionConfig } from './infection';
import { MAX_CHAT } from '$lib/chat';
import {
countsForRate,
@@ -122,6 +125,12 @@ export async function onKillsIngested(
if (!(err instanceof LostOwnership))
console.warn(`[warcon] bounty rules on ${serverId}:`, publicMessage(err));
}
+ try {
+ await actOnInfection(env, serverId, kills);
+ } catch (err) {
+ if (!(err instanceof LostOwnership))
+ console.warn(`[warcon] infection rules on ${serverId}:`, publicMessage(err));
+ }
const teamKills = kills.filter((k) => k.teamKill && k.killer);
if (!teamKills.length) return;
const m = memoryOf(serverId);
@@ -134,6 +143,50 @@ export async function onKillsIngested(
}
}
+async function actOnInfection(env: Env, serverId: string, batch: KillView[]): Promise {
+ const rows = (await enabledTriggers(env, serverId)).filter((row) => row.kind === 'infection');
+ pruneInfectionMemory(serverId, new Set(rows.map((row) => row.id)));
+ if (!rows.length) return;
+ const memory = memoryOf(serverId);
+ const players = memory?.players ?? [];
+ const factions = (memory?.status?.scores ?? [])
+ .map((score) => score.name)
+ .filter(Boolean)
+ .slice(0, 3);
+ const kills = batch.map((kill) => ({
+ eventId: kill.eventId,
+ killer: kill.killer ? { steamId: kill.killer.steamId, faction: kill.killer.faction } : null,
+ victim: {
+ steamId: kill.victim.steamId,
+ name: kill.victim.name,
+ faction: kill.victim.faction
+ },
+ teamKill: kill.teamKill,
+ suicide: kill.suicide
+ }));
+ const now = Date.now();
+ const steps = rows.map((row) =>
+ infectionKillEvaluation(
+ row,
+ serverId,
+ row.config as InfectionConfig,
+ kills,
+ players,
+ factions,
+ now
+ )
+ );
+ const intents = steps.flatMap((step) => step.intents);
+ const updates = steps.flatMap((step) => step.updates);
+ if (intents.length)
+ await withOwnedTransaction(env, async (tx) => {
+ await enqueueIntents(tx, serverId, intents);
+ await applyTriggerUpdates(tx, updates);
+ });
+ for (const step of steps) step.commit();
+ if (intents.length) wakeDelivery();
+}
+
/** Both players of every kill as the feed named them, in the order the game played them; a suicide
* names its player once, and the environment no one. */
function namedIn(batch: KillView[]) {
diff --git a/src/lib/server/infection.test.ts b/src/lib/server/infection.test.ts
new file mode 100644
index 00000000..cf71864f
--- /dev/null
+++ b/src/lib/server/infection.test.ts
@@ -0,0 +1,146 @@
+import { describe, expect, test } from 'bun:test';
+import {
+ emptyInfectionState,
+ infectionKillStep,
+ infectionObserveStep,
+ type InfectionKill
+} from './infection';
+import type { Player } from '$lib/types';
+
+const factions = ['Lonestar', 'Valkyra', 'Manticore'];
+const player = (steamId: string, faction: string | null): Player => ({
+ steamId,
+ name: steamId,
+ faction,
+ kills: 0,
+ deaths: 0,
+ cash: 0,
+ ping: 20
+});
+const kill = (
+ id: string,
+ victimFaction: string | null,
+ killerFaction: string | null,
+ extra: Partial = {}
+): InfectionKill => ({
+ eventId: id,
+ killer: killerFaction ? { steamId: `k-${id}`, faction: killerFaction } : null,
+ victim: { steamId: 'victim', name: 'Victim', faction: victimFaction },
+ teamKill: false,
+ suicide: false,
+ ...extra
+});
+
+describe('Infection mode', () => {
+ test('keeps an existing round in place, spreads joiners over three teams, and locks factions', () => {
+ const first = infectionObserveStep(
+ null,
+ [player('a', 'Lonestar'), player('b', 'Valkyra')],
+ factions,
+ 1,
+ 6
+ );
+ expect(first.moves).toEqual([]);
+ const joined = infectionObserveStep(
+ first.state,
+ [player('a', 'Lonestar'), player('b', 'Valkyra'), player('c', 'Lonestar')],
+ factions,
+ 2,
+ 6
+ );
+ expect(joined.moves).toMatchObject([
+ { steamId: 'c', from: 'Lonestar', to: 'Manticore', why: 'join' }
+ ]);
+ const landed = infectionObserveStep(
+ joined.state,
+ [player('a', 'Lonestar'), player('b', 'Valkyra'), player('c', 'Manticore')],
+ factions,
+ 3,
+ 6
+ );
+ const switched = infectionObserveStep(
+ landed.state,
+ [player('a', 'Valkyra'), player('b', 'Valkyra'), player('c', 'Manticore')],
+ factions,
+ 4,
+ 6
+ );
+ expect(switched.moves).toMatchObject([
+ { steamId: 'a', from: 'Valkyra', to: 'Lonestar', why: 'lock' }
+ ]);
+ });
+
+ test('only enemy-attributed deaths count, then the most frequent killer faction takes the victim', () => {
+ const state = emptyInfectionState();
+ const result = infectionKillStep(
+ { deaths: 3 },
+ state,
+ [
+ kill('environment', 'Lonestar', null),
+ kill('suicide', 'Lonestar', 'Lonestar', { suicide: true }),
+ kill('team', 'Lonestar', 'Lonestar', { teamKill: true }),
+ kill('one', 'Lonestar', 'Valkyra'),
+ kill('two', 'Lonestar', 'Manticore'),
+ kill('three', 'Lonestar', 'Valkyra')
+ ],
+ 10
+ );
+ expect(result.moves).toMatchObject([
+ { steamId: 'victim', from: 'Lonestar', to: 'Valkyra', why: 'infected' }
+ ]);
+ expect(result.state.deaths.has('victim')).toBe(false);
+ });
+
+ test('a three-way tie is won by the faction that dealt the threshold death', () => {
+ const result = infectionKillStep(
+ { deaths: 3 },
+ emptyInfectionState(),
+ [
+ kill('one', 'Lonestar', 'Valkyra'),
+ kill('two', 'Lonestar', 'Manticore'),
+ kill('three', 'Lonestar', 'Third')
+ ],
+ 10
+ );
+ expect(result.moves[0].to).toBe('Third');
+ });
+
+ test('declares a winner only after opposing teams existed and every move landed', () => {
+ let step = infectionObserveStep(
+ null,
+ [player('a', 'Lonestar'), player('b', 'Valkyra')],
+ factions,
+ 1,
+ 6
+ );
+ step.state.assigned.set('b', { faction: 'Lonestar', seen: 2 });
+ step.state.moving.set('b', { from: 'Valkyra', to: 'Lonestar', at: 2 });
+ step = infectionObserveStep(
+ step.state,
+ [player('a', 'Lonestar'), player('b', 'Lonestar')],
+ factions,
+ 3,
+ 6
+ );
+ expect(step.winner).toBe('Lonestar');
+ });
+
+ test('does not declare a winner while any player has no known faction', () => {
+ let step = infectionObserveStep(
+ null,
+ [player('a', 'Lonestar'), player('b', 'Valkyra')],
+ factions,
+ 1,
+ 6
+ );
+ step.state.assigned.set('b', { faction: 'Lonestar', seen: 2 });
+ step = infectionObserveStep(
+ step.state,
+ [player('a', 'Lonestar'), player('b', null)],
+ factions,
+ 3,
+ 6
+ );
+ expect(step.winner).toBeNull();
+ });
+});
diff --git a/src/lib/server/infection.ts b/src/lib/server/infection.ts
new file mode 100644
index 00000000..7af6c5ad
--- /dev/null
+++ b/src/lib/server/infection.ts
@@ -0,0 +1,234 @@
+// Infection mode's pure state machine. Observation places joiners and enforces the faction lock;
+// kill-feed batches count enemy-attributed deaths and change a victim's assigned faction on the
+// configured threshold. The worker owns the state in memory, as Team balance does.
+import { settingsFingerprint } from './fingerprint';
+import type { Player } from '$lib/types';
+
+export const INFECTION_DEATHS = 3;
+export const INFECTION_MOVE_RETRY_MS = 30_000;
+export const INFECTION_SIDE_FORGET_MS = 10 * 60_000;
+export const INFECTION_WIN = 'infection_win';
+
+export interface InfectionConfig {
+ deaths: number;
+}
+
+export const infectionSettingsKey = (cfg: InfectionConfig): string => settingsFingerprint(cfg);
+
+interface Assigned {
+ faction: string;
+ seen: number;
+}
+
+interface Deaths {
+ count: number;
+ byFaction: Map;
+}
+
+export interface InfectionMove {
+ steamId: string;
+ name: string;
+ from: string | null;
+ to: string;
+ why: 'join' | 'lock' | 'infected';
+ eventId?: string;
+}
+
+export interface InfectionState {
+ assigned: Map;
+ deaths: Map;
+ moving: Map;
+ seeded: boolean;
+ hadOpposition: boolean;
+ winner: string | null;
+}
+
+export const emptyInfectionState = (): InfectionState => ({
+ assigned: new Map(),
+ deaths: new Map(),
+ moving: new Map(),
+ seeded: false,
+ hadOpposition: false,
+ winner: null
+});
+
+export function cloneInfectionState(state: InfectionState): InfectionState {
+ return {
+ assigned: new Map([...state.assigned].map(([id, a]) => [id, { ...a }])),
+ deaths: new Map(
+ [...state.deaths].map(([id, d]) => [id, { count: d.count, byFaction: new Map(d.byFaction) }])
+ ),
+ moving: new Map([...state.moving].map(([id, m]) => [id, { ...m }])),
+ seeded: state.seeded,
+ hadOpposition: state.hadOpposition,
+ winner: state.winner
+ };
+}
+
+const factionOf = (p: Pick, factions: Set): string | null =>
+ p.faction && factions.has(p.faction) ? p.faction : null;
+
+/** A least-populated faction, keeping the player's current one when it is tied for least. */
+function leastFaction(counts: Map, current: string | null): string {
+ const least = Math.min(...counts.values());
+ if (current && counts.get(current) === least) return current;
+ return [...counts].find(([, n]) => n === least)![0];
+}
+
+export function infectionObserveStep(
+ previous: InfectionState | null,
+ players: Player[],
+ factionNames: string[],
+ now: number,
+ maxMoves: number,
+ newMatch = false
+): { state: InfectionState; moves: InfectionMove[]; winner: string | null } {
+ const factions = [...new Set(factionNames.filter(Boolean))].slice(0, 3);
+ const allowed = new Set(factions);
+ let state = previous ? cloneInfectionState(previous) : emptyInfectionState();
+ if (newMatch) {
+ state = emptyInfectionState();
+ // Unlike enabling the rule in a match already under way, a known new match is ours to spread.
+ state.seeded = true;
+ }
+ const moves: InfectionMove[] = [];
+ if (factions.length < 3) return { state, moves, winner: null };
+
+ for (const [id, moving] of state.moving)
+ if (now - moving.at >= INFECTION_MOVE_RETRY_MS) state.moving.delete(id);
+
+ const online = new Set(players.map((p) => p.steamId));
+ for (const [id, assigned] of state.assigned)
+ if (!online.has(id) && now - assigned.seen >= INFECTION_SIDE_FORGET_MS) {
+ state.assigned.delete(id);
+ state.deaths.delete(id);
+ state.moving.delete(id);
+ }
+
+ // Enabling Infection mid-round does not reshuffle everyone. Their current sides become the lock;
+ // only later joiners are spread. A match boundary clears these assignments above.
+ if (!state.seeded) {
+ for (const p of players) {
+ const faction = factionOf(p, allowed);
+ if (faction) state.assigned.set(p.steamId, { faction, seen: now });
+ }
+ state.seeded = true;
+ }
+
+ // A conversion landed when the roster shows its target. Keep the target as the player's lock.
+ for (const p of players) {
+ const moving = state.moving.get(p.steamId);
+ if (moving && p.faction === moving.to) state.moving.delete(p.steamId);
+ const assigned = state.assigned.get(p.steamId);
+ if (assigned) assigned.seen = now;
+ }
+
+ const counts = new Map(factions.map((f) => [f, 0]));
+ for (const p of players) {
+ const effective = state.moving.get(p.steamId)?.to ?? state.assigned.get(p.steamId)?.faction;
+ if (effective && counts.has(effective)) counts.set(effective, counts.get(effective)! + 1);
+ }
+
+ const ask = (p: Player, to: string, why: InfectionMove['why']) => {
+ if (moves.length >= maxMoves || state.moving.has(p.steamId)) return;
+ state.moving.set(p.steamId, { from: p.faction, to, at: now });
+ moves.push({ steamId: p.steamId, name: p.name, from: p.faction, to, why });
+ };
+
+ // First give every joiner a least-populated team. Then enforce the remembered assignment as a
+ // strict faction lock: unlike Team balance, even a switch to a smaller team is reverted.
+ for (const p of players) {
+ const current = factionOf(p, allowed);
+ let assigned = state.assigned.get(p.steamId)?.faction ?? null;
+ if (!assigned || !allowed.has(assigned)) {
+ assigned = leastFaction(counts, current);
+ state.assigned.set(p.steamId, { faction: assigned, seen: now });
+ counts.set(assigned, counts.get(assigned)! + 1);
+ if (current !== assigned) ask(p, assigned, 'join');
+ } else if (current !== null && current !== assigned) ask(p, assigned, 'lock');
+ }
+
+ const effectiveSides = new Set(
+ players
+ .map((p) => state.moving.get(p.steamId)?.to ?? state.assigned.get(p.steamId)?.faction)
+ .filter((f): f is string => !!f && allowed.has(f))
+ );
+ if (effectiveSides.size >= 2) state.hadOpposition = true;
+ const actualSides = new Set(
+ players.map((p) => factionOf(p, allowed)).filter((f): f is string => f !== null)
+ );
+ let winner: string | null = null;
+ if (
+ !state.winner &&
+ state.hadOpposition &&
+ players.length >= 2 &&
+ players.every((player) => factionOf(player, allowed) !== null) &&
+ actualSides.size === 1 &&
+ !state.moving.size
+ ) {
+ state.winner = [...actualSides][0];
+ winner = state.winner;
+ }
+ return { state, moves, winner };
+}
+
+export interface InfectionKill {
+ eventId: string;
+ killer: { steamId: string; faction: string | null } | null;
+ victim: { steamId: string; name: string; faction: string | null };
+ teamKill: boolean;
+ suicide: boolean;
+}
+
+export function infectionKillStep(
+ cfg: InfectionConfig,
+ previous: InfectionState,
+ kills: InfectionKill[],
+ now: number
+): { state: InfectionState; moves: InfectionMove[] } {
+ const state = cloneInfectionState(previous);
+ const moves: InfectionMove[] = [];
+ if (state.winner) return { state, moves };
+ for (const kill of kills) {
+ const killerFaction = kill.killer?.faction;
+ const victimFaction = kill.victim.faction;
+ if (
+ kill.teamKill ||
+ kill.suicide ||
+ !kill.killer?.steamId ||
+ !killerFaction ||
+ !victimFaction ||
+ killerFaction === victimFaction
+ )
+ continue;
+ if (!state.assigned.has(kill.victim.steamId))
+ state.assigned.set(kill.victim.steamId, { faction: victimFaction, seen: now });
+ const deaths = state.deaths.get(kill.victim.steamId) ?? {
+ count: 0,
+ byFaction: new Map()
+ };
+ deaths.count++;
+ deaths.byFaction.set(killerFaction, (deaths.byFaction.get(killerFaction) ?? 0) + 1);
+ state.deaths.set(kill.victim.steamId, deaths);
+ if (deaths.count < cfg.deaths) continue;
+ const most = Math.max(...deaths.byFaction.values());
+ const target =
+ deaths.byFaction.get(killerFaction) === most
+ ? killerFaction
+ : [...deaths.byFaction].find(([, count]) => count === most)![0];
+ state.deaths.delete(kill.victim.steamId);
+ state.assigned.set(kill.victim.steamId, { faction: target, seen: now });
+ state.hadOpposition = true;
+ if (target === victimFaction) continue;
+ state.moving.set(kill.victim.steamId, { from: victimFaction, to: target, at: now });
+ moves.push({
+ steamId: kill.victim.steamId,
+ name: kill.victim.name,
+ from: victimFaction,
+ to: target,
+ why: 'infected',
+ eventId: kill.eventId
+ });
+ }
+ return { state, moves };
+}
diff --git a/src/lib/server/lists.ts b/src/lib/server/lists.ts
index d7fce913..550bba80 100644
--- a/src/lib/server/lists.ts
+++ b/src/lib/server/lists.ts
@@ -39,7 +39,6 @@ import {
import { DEFAULT_BAN_MESSAGE } from '$lib/ban-message';
import { noteContainsProtectedWord } from './trigger-rules';
import { banReasonsFor } from './ban-reasons';
-import { noteContainsProtectedWord } from './trigger-rules';
import { requireSteamId } from './steam';
import { desiredFor, memberSlots, summaryOf } from './lists-sync';
import { isListOn } from './lists-plan';
diff --git a/src/lib/server/outbox.ts b/src/lib/server/outbox.ts
index a49025c0..0c20e2bb 100644
--- a/src/lib/server/outbox.ts
+++ b/src/lib/server/outbox.ts
@@ -46,6 +46,7 @@ import {
AFK_ROUND_MAX_MS,
AFK_ROUND_MAX_PLAYERS
} from './afk-protection';
+import { INFECTION_WIN } from './infection';
import { MAX_CHAT } from '$lib/chat';
import type { OutboxView } from '$lib/types';
@@ -323,12 +324,27 @@ function skipReason(row: OutboxRow, m: ReturnType): string | nu
}
// A Team balance move for a player already off the side it was decided from: the move before it
// landed, or they changed side themselves. Moving someone twice is not harmless.
- if (row.triggerKind === 'two_teams' && row.action === 'changeTeam') {
+ if (
+ (row.triggerKind === 'two_teams' || row.triggerKind === 'infection') &&
+ row.action === 'changeTeam'
+ ) {
const from = (row.params as { from?: string } | null)?.from;
const p = m.players.find((q) => q.steamId === row.steamId);
if (from && p && p.faction !== from) return `Already off ${from}.`;
- const verdict = twoTeamsMoveVerdict(row);
- if (verdict !== 'send' && verdict !== 'wait') return verdict;
+ if (row.triggerKind === 'two_teams') {
+ const verdict = twoTeamsMoveVerdict(row);
+ if (verdict !== 'send' && verdict !== 'wait') return verdict;
+ }
+ }
+ if (row.action === INFECTION_WIN) {
+ if (age > 30_000) return `Stale (${Math.round(age / 1000)}s old).`;
+ const faction = (row.params as { faction?: unknown } | null)?.faction;
+ if (
+ typeof faction !== 'string' ||
+ m.players.length < 2 ||
+ m.players.some((player) => player.faction !== faction)
+ )
+ return 'A faction no longer has every player.';
}
return null;
}
@@ -358,7 +374,9 @@ function mustWait(row: OutboxRow, m: ReturnType): boolean {
*/
const movesSent = new Map();
const moveOf = (row: OutboxRow) =>
- row.triggerKind === 'two_teams' && row.action === 'changeTeam' && row.steamId
+ (row.triggerKind === 'two_teams' || row.triggerKind === 'infection') &&
+ row.action === 'changeTeam' &&
+ row.steamId
? `${row.serverId}:${row.steamId}`
: null;
@@ -960,6 +978,7 @@ async function afkRound(
async function execute(client: WardogsClient, row: OutboxRow): Promise {
const params = (row.params as Record) ?? {};
if (row.action === AFK_ROUND) return afkRound(client, params, memoryOf(row.serverId));
+ if (row.action === INFECTION_WIN) return ACTIONS.endMatch.run(client, {});
if (row.action === RULE_KILL) return killAndTell(client, params);
if (row.action === ROTATION_SHUFFLE) return shuffleOnServer(client, params);
if (row.action === 'empty_reset') {
diff --git a/src/lib/server/trigger-rules.ts b/src/lib/server/trigger-rules.ts
index a37254a5..f59ff968 100644
--- a/src/lib/server/trigger-rules.ts
+++ b/src/lib/server/trigger-rules.ts
@@ -7,6 +7,7 @@ import { validateNameFilter, type NameFilterConfig } from './name-filter';
import { validateKillRate, type KillRateConfig } from './kill-rate';
import { validateKillDistance, type KillDistanceConfig } from './kill-distance';
import { validateTwoTeams, type TwoTeamsConfig } from './two-teams';
+import { INFECTION_DEATHS, type InfectionConfig } from './infection';
import { validateAfkProtection, type AfkProtectionConfig } from './afk-protection';
import { validateNameChange, type NameChangeConfig } from './name-change';
import { validateBounty, type BountyConfig } from './bounty';
@@ -36,6 +37,7 @@ export const TRIGGER_KINDS: TriggerKind[] = [
'name_filter',
'kill_rate',
'two_teams',
+ 'infection',
'kill_distance',
'afk_protection',
'name_change',
@@ -57,6 +59,7 @@ export const TRIGGER_LABELS: Record = {
name_filter: 'Name filter',
kill_rate: 'Kill rate watch',
two_teams: 'Team balance',
+ infection: 'Infection',
kill_distance: 'Kill distance watch',
afk_protection: 'AFK protection',
name_change: 'Name change watch',
@@ -234,6 +237,7 @@ export type TriggerConfig =
| NameFilterConfig
| KillRateConfig
| TwoTeamsConfig
+ | InfectionConfig
| KillDistanceConfig
| AfkProtectionConfig
| NameChangeConfig
@@ -428,6 +432,8 @@ export function validateConfig(kind: TriggerKind, raw: unknown): TriggerConfig {
return validateKillRate(c);
case 'two_teams':
return validateTwoTeams(c);
+ case 'infection':
+ return { deaths: INFECTION_DEATHS };
case 'kill_distance':
return validateKillDistance(c);
case 'afk_protection':
diff --git a/src/lib/server/triggers.test.ts b/src/lib/server/triggers.test.ts
index 743b5eb7..a51242f0 100644
--- a/src/lib/server/triggers.test.ts
+++ b/src/lib/server/triggers.test.ts
@@ -135,6 +135,11 @@ describe('validateConfig', () => {
durationSeconds: 30
});
});
+
+ test('infection always converts on the third counted death', () => {
+ expect(validateConfig('infection', {})).toEqual({ deaths: 3 });
+ expect(validateConfig('infection', { deaths: 99 })).toEqual({ deaths: 3 });
+ });
test('seed_reward needs a seed time that fits its window, and fills in the rest', () => {
expect(() => validateConfig('seed_reward', {})).toThrow('minutes');
expect(() => validateConfig('seed_reward', { minutes: 60 * 24 * 8, windowDays: 7 })).toThrow(
diff --git a/src/lib/server/triggers.ts b/src/lib/server/triggers.ts
index aef04ff3..869933ee 100644
--- a/src/lib/server/triggers.ts
+++ b/src/lib/server/triggers.ts
@@ -16,6 +16,8 @@
// or across the org
// two_teams close one faction and move its players to the smaller of the other two
// (two-teams.ts)
+// infection spread three factions, lock them, and convert players after three enemy deaths
+// (infection.ts; deaths are acted on in feed-events.ts)
// kill_distance flag, warn, kill, kick or ban a player who kills with a chosen weapon or
// vehicle, from further than it reaches or from any distance (kill-distance.ts,
// acted on in feed-events.ts)
@@ -215,6 +217,15 @@ import {
type StatsBy
} from './message-vars';
import { seedProgress } from './seed-progress';
+import {
+ INFECTION_WIN,
+ infectionKillStep,
+ infectionObserveStep,
+ infectionSettingsKey,
+ type InfectionConfig,
+ type InfectionKill,
+ type InfectionState
+} from './infection';
export * from './trigger-rules';
@@ -306,6 +317,7 @@ const RULE_NEEDS: Record<
team_kill: ['players.kick', 'kicks players'],
kill_rate: ['players.kick', 'flags players'],
two_teams: ['players.move', 'moves players between teams'],
+ infection: ['players.move', 'moves players between teams'],
afk_protection: ['players.kill', 'kills players'],
name_change: ['players.kick', 'flags or kicks players'],
rotation_shuffle: ['config.apply', 'rewrites the rotation in the config document'],
@@ -353,6 +365,8 @@ function ruleAlsoNeeds(kind: TriggerKind, config: unknown): [Capability, string]
return ['chat.send', 'messages players'];
if (kind === 'two_teams' && str((config as Partial | null)?.message))
return ['chat.send', 'whispers players'];
+ if (kind === 'infection')
+ return ['match.control', 'ends the match when one faction has everyone'];
const afk = config as Partial | null;
if (kind === 'afk_protection' && (str(afk?.message) || str(afk?.doneMessage)))
return ['chat.send', 'messages players'];
@@ -395,14 +409,23 @@ export async function createTrigger(
if (ONE_PER_SERVER.includes(kind)) {
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtext(${`triggers:${server.id}`}))`);
const [other] = await tx
- .select({ name: triggers.name })
+ .select({ name: triggers.name, kind: triggers.kind })
.from(triggers)
- .where(and(eq(triggers.serverId, server.id), eq(triggers.kind, kind)))
+ .where(
+ and(
+ eq(triggers.serverId, server.id),
+ kind === 'two_teams' || kind === 'infection'
+ ? inArray(triggers.kind, ['two_teams', 'infection'])
+ : eq(triggers.kind, kind)
+ )
+ )
.limit(1);
if (other)
throw new ApiError(
409,
- `This server already has a ${TRIGGER_LABELS[kind]} rule ("${other.name}"); edit that one instead.`,
+ other.kind !== kind
+ ? `This server already has the ${TRIGGER_LABELS[other.kind]} team mode ("${other.name}"); switch or remove it before adding ${TRIGGER_LABELS[kind]}.`
+ : `This server already has a ${TRIGGER_LABELS[kind]} rule ("${other.name}"); edit that one instead.`,
'duplicate'
);
}
@@ -464,7 +487,7 @@ export async function updateTrigger(
// emptied or restarted while the rule was off is unknown. An edit keeps where it stands, so
// changing a message while a match is on cannot turn it on.
const switchOn =
- row.kind === 'two_teams' && set.enabled === true
+ (row.kind === 'two_teams' || row.kind === 'infection') && set.enabled === true
? sql`CASE WHEN ${triggers.enabled} THEN ${triggers.state} ELSE (${JSON.stringify({ enabledAt: Date.now() })}::text)::jsonb END`
: (row.kind === 'afk_protection' ||
(row.kind === 'rotation_shuffle' && set.state === undefined)) &&
@@ -543,6 +566,7 @@ export async function deleteTrigger(
*/
export const SETTINGS_KEYS: Partial string>> = {
two_teams: (c) => twoTeamsSettingsKey(c as TwoTeamsConfig),
+ infection: (c) => infectionSettingsKey(c as InfectionConfig),
kill_distance: (c) => killDistanceSettingsKey(c as KillDistanceConfig),
afk_protection: (c) => afkSettingsKey(c as AfkProtectionConfig),
name_change: (c) => nameChangeSettingsKey(c as NameChangeConfig)
@@ -846,6 +870,9 @@ export async function evaluateTriggers(
case 'two_teams':
await evalTwoTeams(ctx, row, row.config as TwoTeamsConfig, out, read);
break;
+ case 'infection':
+ evalInfection(ctx, row, row.config as InfectionConfig, out);
+ break;
case 'afk_protection':
evalAfkProtection(ctx, row, row.config as AfkProtectionConfig, out);
break;
@@ -1428,6 +1455,172 @@ async function evalTwoTeams(
});
}
+/** Infection keeps its faction locks and death counters in the worker, like Team balance's sides. */
+const infectionMemory = new Map<
+ string,
+ { serverId: string; key: string; state: InfectionState; newMatch: boolean }
+>();
+
+function infectionEntry(
+ row: TriggerRow,
+ serverId: string,
+ cfg: InfectionConfig,
+ players: Player[],
+ factions: string[],
+ now: number,
+ reset = false
+) {
+ const key = `${infectionSettingsKey(cfg)}:${JSON.stringify(row.state ?? null)}`;
+ let memory = infectionMemory.get(row.id);
+ if (!memory || memory.key !== key || reset) {
+ // A feed batch can be the first thing seen after a worker starts. Seed from the current roster
+ // without moving it, just as enabling the mode during an existing match does.
+ const state = infectionObserveStep(null, players, factions, now, 0).state;
+ memory = { serverId, key, state, newMatch: false };
+ infectionMemory.set(row.id, memory);
+ }
+ return memory;
+}
+
+export function pruneInfectionMemory(serverId: string, live: Set): void {
+ for (const [id, memory] of infectionMemory)
+ if (memory.serverId === serverId && !live.has(id)) infectionMemory.delete(id);
+}
+
+/** Apply one kill-feed batch to an Infection rule; the caller commits `state` after its DB write. */
+export function infectionKillEvaluation(
+ row: TriggerRow,
+ serverId: string,
+ cfg: InfectionConfig,
+ kills: InfectionKill[],
+ players: Player[],
+ factions: string[],
+ now: number
+): { intents: Intent[]; updates: TriggerUpdate[]; commit: () => void } {
+ const memory = infectionEntry(row, serverId, cfg, players, factions, now);
+ const step = infectionKillStep(cfg, memory.state, kills, now);
+ const rule = infectionSettingsKey(cfg);
+ const intents = step.moves.map((move) => ({
+ trigger: row,
+ action: 'changeTeam',
+ params: {
+ steamId: move.steamId,
+ faction: move.to,
+ kill: false,
+ from: move.from,
+ rule
+ },
+ target: move.steamId,
+ okMessage: `Infected ${move.name}: moved to ${move.to}.`,
+ detail: { name: move.name, from: move.from, to: move.to, why: move.why },
+ steamId: move.steamId,
+ dedupeKey: key(row, move.steamId, 'infected', move.eventId ?? now)
+ }));
+ const updates: TriggerUpdate[] = intents.length
+ ? [
+ {
+ id: row.id,
+ lastFiredAt: new Date(now),
+ lastResult:
+ intents.length === 1
+ ? intents[0].okMessage.replace(/\.$/, '')
+ : `Infecting ${intents.length} players`
+ }
+ ]
+ : [];
+ return { intents, updates, commit: () => (memory.state = step.state) };
+}
+
+function evalInfection(
+ ctx: TickContext,
+ row: TriggerRow,
+ cfg: InfectionConfig,
+ out: Evaluation
+): void {
+ const now = ctx.ts.getTime();
+ const factions = (ctx.status.scores ?? [])
+ .map((score) => score.name)
+ .filter(Boolean)
+ .slice(0, 3);
+ const memory = infectionEntry(
+ row,
+ ctx.server.id,
+ cfg,
+ ctx.players,
+ factions,
+ now,
+ !!ctx.recovered
+ );
+ // A match end can arrive with the old roster. Hold the boundary until the first player-list look
+ // for the next round, as Team balance does, so we do not reshuffle the match that just ended.
+ if (ctx.matchEnd) memory.newMatch = true;
+ if (!ctx.playersObserved) return;
+ const perLook = Math.min(
+ TWO_TEAMS_MAX_MOVES_PER_LOOK,
+ Math.max(
+ 1,
+ Math.floor((TWO_TEAMS_MOVES_PER_SECOND * Math.max(ctx.playersIntervalMs, 1000)) / 1000)
+ )
+ );
+ const step = infectionObserveStep(
+ memory.state,
+ ctx.players,
+ factions,
+ now,
+ perLook,
+ memory.newMatch
+ );
+ const rule = infectionSettingsKey(cfg);
+ for (const move of step.moves)
+ out.intents.push({
+ trigger: row,
+ action: 'changeTeam',
+ params: {
+ steamId: move.steamId,
+ faction: move.to,
+ kill: false,
+ from: move.from,
+ rule
+ },
+ target: move.steamId,
+ okMessage:
+ move.why === 'lock'
+ ? `Returned ${move.name} to ${move.to}.`
+ : `Placed ${move.name} on ${move.to}.`,
+ detail: { name: move.name, from: move.from, to: move.to, why: move.why },
+ steamId: move.steamId,
+ dedupeKey: key(row, move.steamId, move.why, now)
+ });
+ if (step.winner)
+ out.intents.push({
+ trigger: row,
+ action: INFECTION_WIN,
+ params: { faction: step.winner, rule },
+ target: step.winner,
+ okMessage: `${step.winner} won Infection.`,
+ detail: { faction: step.winner, players: ctx.players.length },
+ steamId: null,
+ dedupeKey: key(row, 'winner', step.winner, now)
+ });
+ const consumedNewMatch = memory.newMatch && factions.length >= 3;
+ (out.afterCommit ??= []).push(() => {
+ memory.state = step.state;
+ if (consumedNewMatch) memory.newMatch = false;
+ });
+ if (step.winner || step.moves.length)
+ out.updates.push({
+ id: row.id,
+ lastFiredAt: ctx.ts,
+ lastResult: step.winner
+ ? `${step.winner} has every player; ending the match`
+ : step.moves.length === 1
+ ? step.moves[0].why === 'lock'
+ ? `Returning ${step.moves[0].name} to ${step.moves[0].to}`
+ : `Placing ${step.moves[0].name} on ${step.moves[0].to}`
+ : `Moving ${step.moves.length} players`
+ });
+}
+
function evalRestartNotice(
ctx: TickContext,
row: TriggerRow,
@@ -1709,6 +1902,7 @@ export function forgetRuleMemory(): void {
matchHeld.clear();
seedState.clear();
twoTeamsMemory.clear();
+ infectionMemory.clear();
staffMoves.clear();
afkEmpty.clear();
forgetBounties();
@@ -2222,6 +2416,12 @@ export async function dryRun(
);
return result;
}
+ if (kind === 'infection') {
+ result.notes.push(
+ 'Faction switches are not kept in the session history, so Infection cannot be replayed. The live rule counts enemy-attributed deaths from the event feed, locks factions, and spreads joiners across all three teams.'
+ );
+ return result;
+ }
if (kind === 'ping_kick') {
result.notes.push(
'Ping is not stored in historical samples, so past high-ping streaks cannot be replayed. The live rule checks each fresh player-list sample and resets a streak when ping recovers, becomes unavailable, or sampling is interrupted.'
diff --git a/src/lib/server/webhook-delivery.ts b/src/lib/server/webhook-delivery.ts
index c133f53f..462601ce 100644
--- a/src/lib/server/webhook-delivery.ts
+++ b/src/lib/server/webhook-delivery.ts
@@ -157,6 +157,7 @@ const ACTION_TITLES: Record = {
'trigger.name_filter': 'Trigger · name filter',
'trigger.kill_rate': 'Trigger · kill rate watch',
'trigger.two_teams': 'Trigger · team balance',
+ 'trigger.infection': 'Trigger · infection',
'trigger.kill_distance': 'Trigger · kill distance watch',
'trigger.afk_protection': 'Trigger · AFK protection',
'trigger.name_change': 'Trigger · name change watch',
diff --git a/src/lib/types.ts b/src/lib/types.ts
index b8cfb0e2..e2cdb9ab 100644
--- a/src/lib/types.ts
+++ b/src/lib/types.ts
@@ -586,6 +586,7 @@ export type TriggerKind =
| 'name_filter'
| 'kill_rate'
| 'two_teams'
+ | 'infection'
| 'kill_distance'
| 'afk_protection'
| 'name_change'
diff --git a/src/routes/(app)/server/[id]/automation/+page.svelte b/src/routes/(app)/server/[id]/automation/+page.svelte
index fdbc86fc..16034ace 100644
--- a/src/routes/(app)/server/[id]/automation/+page.svelte
+++ b/src/routes/(app)/server/[id]/automation/+page.svelte
@@ -877,6 +877,8 @@
exempt: f.exempt.split(/[\s,]+/).filter(Boolean),
watchOnly: f.watchOnly
};
+ case 'infection':
+ return {};
case 'afk_protection':
return {
everyMinutes: Number(f.everyMinutes),
@@ -1120,6 +1122,8 @@
.filter(Boolean)
.join(' · ');
}
+ case 'infection':
+ return '3 counted deaths convert a player · three teams spread evenly · factions locked · last faction wins';
case 'afk_protection':
return `kill everyone every ${c.everyMinutes} min while fewer than ${c.stopAt} are on and no side has scored · then off until the server empties or restarts${c.message ? ' · with a broadcast' : ''}${c.doneMessage ? ' · thanks at the start' : ''}`;
case 'bounty': {
@@ -2158,6 +2162,30 @@
or below, is unavailable, the player leaves, or the player list cannot be sampled on
time.
+ {:else if f.kind === 'infection'}
+
+
+
Three-team Infection
+
+ Joiners are placed on the least-populated of Lonestar, Valkyra and Manticore. Their
+ faction is then locked; a manual switch is reverted.
+
+
+
+ After three counted deaths, a player joins the faction that killed them most and their
+ counter starts again at zero. A tie is decided by the faction that dealt the third
+ death.
+
+
+ Team kills, suicides and deaths without an attributed killer do not count. The first
+ faction containing every player wins and ends the match. Enabling the mode mid-match
+ locks the current teams; spreading starts with joiners and at the next match.
+
+
+ Infection and Team balance are exclusive because both control factions. This mode
+ requires Move players and Match control.
+
+
{:else if f.kind === 'two_teams'}
From fd6ae5e1b44bc0968a8f3a2b1b9d352dd94718e0 Mon Sep 17 00:00:00 2001
From: Seebomega
Date: Sun, 11 Oct 2026 21:16:28 +0200
Subject: [PATCH 102/102] fix: make rebased migrations replay-safe
---
drizzle/0040_player_totals.sql | 47 +++++++++++++++++++++++---------
drizzle/0042_seasons.sql | 16 +++++++++--
drizzle/0043_org_ban_reasons.sql | 14 ++++++++--
drizzle/0044_player_aliases.sql | 4 +--
drizzle/0048_slot_groups.sql | 30 ++++++++++++++++----
5 files changed, 85 insertions(+), 26 deletions(-)
diff --git a/drizzle/0040_player_totals.sql b/drizzle/0040_player_totals.sql
index 903c0757..dce57cfe 100644
--- a/drizzle/0040_player_totals.sql
+++ b/drizzle/0040_player_totals.sql
@@ -24,7 +24,7 @@
-- holding its table against writes until the migrations commit; then the rebuild, into the table
-- before its key exists, and the key last. The rebuild sees every session closed and every match
-- ended before the locks, and nothing can close or end between it and the triggers taking over.
-CREATE TABLE "player_totals" (
+CREATE TABLE IF NOT EXISTS "player_totals" (
"server_id" text NOT NULL,
"steam_id" text NOT NULL,
"sessions" integer DEFAULT 0 NOT NULL,
@@ -48,7 +48,7 @@ CREATE TABLE "player_totals" (
-- A player's result in a match: matchResult() in $lib/leaderboard, as lines() in leaderboards.ts
-- writes it out (no faction, or a faction not on a scoreboard that has one: none; then the
-- winner; then a draw if anybody scored a number; else none).
-CREATE FUNCTION "match_result"(final_scores jsonb, winner text, faction text) RETURNS text
+CREATE OR REPLACE FUNCTION "match_result"(final_scores jsonb, winner text, faction text) RETURNS text
LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
SELECT CASE WHEN faction IS NULL THEN NULL
WHEN jsonb_typeof(final_scores) = 'array' AND jsonb_array_length(final_scores) > 0
@@ -62,7 +62,7 @@ LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
$$;--> statement-breakpoint
-- Every writer of the totals reads its sources at READ COMMITTED: a recount from an older snapshot
-- (REPEATABLE READ, SERIALIZABLE) could put back what a purge committed meanwhile had removed.
-CREATE FUNCTION "player_totals_lock"(server_id text) RETURNS void LANGUAGE plpgsql AS $$
+CREATE OR REPLACE FUNCTION "player_totals_lock"(server_id text) RETURNS void LANGUAGE plpgsql AS $$
BEGIN
IF current_setting('transaction_isolation') <> 'read committed' THEN
RAISE EXCEPTION 'player_totals is written at READ COMMITTED only (this transaction is %)',
@@ -72,7 +72,7 @@ BEGIN
END $$;--> statement-breakpoint
-- These players' rows on this server, worked out again from their closed sessions and their lines
-- of ended matches (joined on match and server, as lines() joins them); a row with neither goes.
-CREATE FUNCTION "player_totals_recount"(sid text, pids text[]) RETURNS void LANGUAGE plpgsql AS $$
+CREATE OR REPLACE FUNCTION "player_totals_recount"(sid text, pids text[]) RETURNS void LANGUAGE plpgsql AS $$
BEGIN
PERFORM player_totals_lock(sid);
INSERT INTO player_totals AS t (server_id, steam_id, sessions, seconds, seed_seconds, cash, last_seen,
@@ -110,7 +110,7 @@ BEGIN
DELETE FROM player_totals WHERE server_id = sid AND steam_id = ANY (pids) AND sessions = 0 AND matches = 0;
END $$;--> statement-breakpoint
-- A session closing (BEFORE, so a recount later in the same statement sees it once).
-CREATE FUNCTION "player_totals_session_closed"() RETURNS trigger LANGUAGE plpgsql AS $$
+CREATE OR REPLACE FUNCTION "player_totals_session_closed"() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
PERFORM player_totals_lock(NEW.server_id);
INSERT INTO player_totals AS t (server_id, steam_id, sessions, seconds, seed_seconds, cash, last_seen)
@@ -123,7 +123,7 @@ BEGIN
RETURN NEW;
END $$;--> statement-breakpoint
-- A closed session inserted, changed or deleted: its pair (and the pair it moved to) recounted.
-CREATE FUNCTION "player_totals_session_changed"() RETURNS trigger LANGUAGE plpgsql AS $$
+CREATE OR REPLACE FUNCTION "player_totals_session_changed"() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP <> 'INSERT' THEN
PERFORM player_totals_recount(OLD.server_id, ARRAY[OLD.steam_id]);
@@ -135,7 +135,7 @@ BEGIN
END $$;--> statement-breakpoint
-- A match ending: each of its lines added with its result (BEFORE, as for a close). The worker
-- writes the lines and the feed's columns before it sets ended_at, in the same transaction.
-CREATE FUNCTION "player_totals_match_ended"() RETURNS trigger LANGUAGE plpgsql AS $$
+CREATE OR REPLACE FUNCTION "player_totals_match_ended"() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
PERFORM player_totals_lock(NEW.server_id);
INSERT INTO player_totals AS t (server_id, steam_id, matches, kills, deaths, headshots, team_kills, suicides,
@@ -158,7 +158,7 @@ BEGIN
END $$;--> statement-breakpoint
-- An ended match inserted, changed or deleted: the pairs of its lines recounted, on its server
-- before and after.
-CREATE FUNCTION "player_totals_match_changed"() RETURNS trigger LANGUAGE plpgsql AS $$
+CREATE OR REPLACE FUNCTION "player_totals_match_changed"() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
pids text[];
BEGIN
@@ -175,7 +175,7 @@ END $$;--> statement-breakpoint
-- Lines of ended matches inserted, changed or deleted (one function per event, as transition
-- tables require): every pair they touch recounted, a server at a time. The worker only writes
-- the lines of the match in progress, which these find nothing to do for.
-CREATE FUNCTION "player_totals_lines_inserted"() RETURNS trigger LANGUAGE plpgsql AS $$
+CREATE OR REPLACE FUNCTION "player_totals_lines_inserted"() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
s record;
BEGIN
@@ -189,7 +189,7 @@ BEGIN
END LOOP;
RETURN NULL;
END $$;--> statement-breakpoint
-CREATE FUNCTION "player_totals_lines_updated"() RETURNS trigger LANGUAGE plpgsql AS $$
+CREATE OR REPLACE FUNCTION "player_totals_lines_updated"() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
s record;
BEGIN
@@ -205,7 +205,7 @@ BEGIN
END LOOP;
RETURN NULL;
END $$;--> statement-breakpoint
-CREATE FUNCTION "player_totals_lines_deleted"() RETURNS trigger LANGUAGE plpgsql AS $$
+CREATE OR REPLACE FUNCTION "player_totals_lines_deleted"() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
s record;
BEGIN
@@ -223,7 +223,7 @@ END $$;--> statement-breakpoint
-- bulk load ends with (see the top of this file). One insert of both aggregates joined; each match's
-- result worked out once per side that played it rather than once per line (Postgres runs the
-- SELECT of an INSERT without parallel workers, so the per-line function calls were most of it).
-CREATE FUNCTION "player_totals_rebuild"() RETURNS void LANGUAGE plpgsql AS $$
+CREATE OR REPLACE FUNCTION "player_totals_rebuild"() RETURNS void LANGUAGE plpgsql AS $$
BEGIN
DELETE FROM player_totals;
INSERT INTO player_totals (server_id, steam_id, sessions, seconds, seed_seconds, cash, last_seen,
@@ -261,27 +261,48 @@ BEGIN
COALESCE(l.wins, 0), COALESCE(l.losses, 0), COALESCE(l.draws, 0)
FROM s FULL JOIN l ON l.server_id = s.server_id AND l.steam_id = s.steam_id;
END $$;--> statement-breakpoint
+DROP TRIGGER IF EXISTS "player_totals_session_closed" ON "player_sessions";--> statement-breakpoint
CREATE TRIGGER "player_totals_session_closed" BEFORE UPDATE OF "left_at" ON "player_sessions"
FOR EACH ROW WHEN (OLD.left_at IS NULL AND NEW.left_at IS NOT NULL) EXECUTE FUNCTION player_totals_session_closed();--> statement-breakpoint
+DROP TRIGGER IF EXISTS "player_totals_session_inserted" ON "player_sessions";--> statement-breakpoint
CREATE TRIGGER "player_totals_session_inserted" AFTER INSERT ON "player_sessions"
FOR EACH ROW WHEN (NEW.left_at IS NOT NULL) EXECUTE FUNCTION player_totals_session_changed();--> statement-breakpoint
+DROP TRIGGER IF EXISTS "player_totals_session_updated" ON "player_sessions";--> statement-breakpoint
CREATE TRIGGER "player_totals_session_updated" AFTER UPDATE ON "player_sessions"
FOR EACH ROW WHEN (OLD.left_at IS NOT NULL) EXECUTE FUNCTION player_totals_session_changed();--> statement-breakpoint
+DROP TRIGGER IF EXISTS "player_totals_session_deleted" ON "player_sessions";--> statement-breakpoint
CREATE TRIGGER "player_totals_session_deleted" AFTER DELETE ON "player_sessions"
FOR EACH ROW WHEN (OLD.left_at IS NOT NULL) EXECUTE FUNCTION player_totals_session_changed();--> statement-breakpoint
+DROP TRIGGER IF EXISTS "player_totals_lines_inserted" ON "match_players";--> statement-breakpoint
CREATE TRIGGER "player_totals_lines_inserted" AFTER INSERT ON "match_players"
REFERENCING NEW TABLE AS lines_new FOR EACH STATEMENT EXECUTE FUNCTION player_totals_lines_inserted();--> statement-breakpoint
+DROP TRIGGER IF EXISTS "player_totals_lines_updated" ON "match_players";--> statement-breakpoint
CREATE TRIGGER "player_totals_lines_updated" AFTER UPDATE ON "match_players"
REFERENCING OLD TABLE AS lines_old NEW TABLE AS lines_new FOR EACH STATEMENT EXECUTE FUNCTION player_totals_lines_updated();--> statement-breakpoint
+DROP TRIGGER IF EXISTS "player_totals_lines_deleted" ON "match_players";--> statement-breakpoint
CREATE TRIGGER "player_totals_lines_deleted" AFTER DELETE ON "match_players"
REFERENCING OLD TABLE AS lines_old FOR EACH STATEMENT EXECUTE FUNCTION player_totals_lines_deleted();--> statement-breakpoint
+DROP TRIGGER IF EXISTS "player_totals_match_ended" ON "matches";--> statement-breakpoint
CREATE TRIGGER "player_totals_match_ended" BEFORE UPDATE OF "ended_at" ON "matches"
FOR EACH ROW WHEN (OLD.ended_at IS NULL AND NEW.ended_at IS NOT NULL) EXECUTE FUNCTION player_totals_match_ended();--> statement-breakpoint
+DROP TRIGGER IF EXISTS "player_totals_match_inserted" ON "matches";--> statement-breakpoint
CREATE TRIGGER "player_totals_match_inserted" AFTER INSERT ON "matches"
FOR EACH ROW WHEN (NEW.ended_at IS NOT NULL) EXECUTE FUNCTION player_totals_match_changed();--> statement-breakpoint
+DROP TRIGGER IF EXISTS "player_totals_match_updated" ON "matches";--> statement-breakpoint
CREATE TRIGGER "player_totals_match_updated" AFTER UPDATE ON "matches"
FOR EACH ROW WHEN (OLD.ended_at IS NOT NULL) EXECUTE FUNCTION player_totals_match_changed();--> statement-breakpoint
+DROP TRIGGER IF EXISTS "player_totals_match_deleted" ON "matches";--> statement-breakpoint
CREATE TRIGGER "player_totals_match_deleted" AFTER DELETE ON "matches"
FOR EACH ROW WHEN (OLD.ended_at IS NOT NULL) EXECUTE FUNCTION player_totals_match_changed();--> statement-breakpoint
SELECT player_totals_rebuild();--> statement-breakpoint
-ALTER TABLE "player_totals" ADD CONSTRAINT "player_totals_server_id_steam_id_pk" PRIMARY KEY("server_id","steam_id");
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_constraint
+ WHERE conrelid = 'player_totals'::regclass
+ AND contype = 'p'
+ ) THEN
+ ALTER TABLE "player_totals"
+ ADD CONSTRAINT "player_totals_server_id_steam_id_pk" PRIMARY KEY("server_id","steam_id");
+ END IF;
+END $$;
diff --git a/drizzle/0042_seasons.sql b/drizzle/0042_seasons.sql
index a51dce81..a2a3fe77 100644
--- a/drizzle/0042_seasons.sql
+++ b/drizzle/0042_seasons.sql
@@ -1,4 +1,4 @@
-CREATE TABLE "seasons" (
+CREATE TABLE IF NOT EXISTS "seasons" (
"id" text PRIMARY KEY NOT NULL,
"org_id" text NOT NULL,
"name" text NOT NULL,
@@ -8,5 +8,15 @@ CREATE TABLE "seasons" (
);
--> statement-breakpoint
ALTER TABLE "organizations" ADD COLUMN IF NOT EXISTS "board_opens" text DEFAULT 'official' NOT NULL;--> statement-breakpoint
-ALTER TABLE "seasons" ADD CONSTRAINT "seasons_org_id_organizations_id_fk" FOREIGN KEY ("org_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
-CREATE UNIQUE INDEX "seasons_org_start_uidx" ON "seasons" USING btree ("org_id","starts_at");
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_constraint
+ WHERE conrelid = 'seasons'::regclass
+ AND conname = 'seasons_org_id_organizations_id_fk'
+ ) THEN
+ ALTER TABLE "seasons" ADD CONSTRAINT "seasons_org_id_organizations_id_fk"
+ FOREIGN KEY ("org_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;
+ END IF;
+END $$;--> statement-breakpoint
+CREATE UNIQUE INDEX IF NOT EXISTS "seasons_org_start_uidx" ON "seasons" USING btree ("org_id","starts_at");
diff --git a/drizzle/0043_org_ban_reasons.sql b/drizzle/0043_org_ban_reasons.sql
index 3b58d460..826b5c9a 100644
--- a/drizzle/0043_org_ban_reasons.sql
+++ b/drizzle/0043_org_ban_reasons.sql
@@ -1,7 +1,17 @@
-CREATE TABLE "org_ban_reasons" (
+CREATE TABLE IF NOT EXISTS "org_ban_reasons" (
"org_id" text PRIMARY KEY NOT NULL,
"reasons" jsonb NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
-ALTER TABLE "org_ban_reasons" ADD CONSTRAINT "org_ban_reasons_org_id_organizations_id_fk" FOREIGN KEY ("org_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;
\ No newline at end of file
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_constraint
+ WHERE conrelid = 'org_ban_reasons'::regclass
+ AND conname = 'org_ban_reasons_org_id_organizations_id_fk'
+ ) THEN
+ ALTER TABLE "org_ban_reasons" ADD CONSTRAINT "org_ban_reasons_org_id_organizations_id_fk"
+ FOREIGN KEY ("org_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;
+ END IF;
+END $$;
diff --git a/drizzle/0044_player_aliases.sql b/drizzle/0044_player_aliases.sql
index 643078bc..0124b1d3 100644
--- a/drizzle/0044_player_aliases.sql
+++ b/drizzle/0044_player_aliases.sql
@@ -1,4 +1,4 @@
-CREATE TABLE "player_aliases" (
+CREATE TABLE IF NOT EXISTS "player_aliases" (
"server_id" text NOT NULL,
"steam_id" text NOT NULL,
"name" text NOT NULL,
@@ -8,4 +8,4 @@ CREATE TABLE "player_aliases" (
CONSTRAINT "player_aliases_server_id_steam_id_name_pk" PRIMARY KEY("server_id","steam_id","name")
);
--> statement-breakpoint
-CREATE INDEX "player_aliases_steam_idx" ON "player_aliases" USING btree ("steam_id");
\ No newline at end of file
+CREATE INDEX IF NOT EXISTS "player_aliases_steam_idx" ON "player_aliases" USING btree ("steam_id");
diff --git a/drizzle/0048_slot_groups.sql b/drizzle/0048_slot_groups.sql
index 6c604680..686e3227 100644
--- a/drizzle/0048_slot_groups.sql
+++ b/drizzle/0048_slot_groups.sql
@@ -1,5 +1,5 @@
-- Migration 0048 preserves existing organisation lists as each kind's default.
-DROP INDEX "lists_org_kind_name_uidx";--> statement-breakpoint
+DROP INDEX IF EXISTS "lists_org_kind_name_uidx";--> statement-breakpoint
ALTER TABLE "lists" ADD COLUMN IF NOT EXISTS "is_default" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "lists" ADD COLUMN IF NOT EXISTS "on_from" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "lists" ADD COLUMN IF NOT EXISTS "on_until" timestamp with time zone;--> statement-breakpoint
@@ -7,8 +7,26 @@ ALTER TABLE "lists" ADD COLUMN IF NOT EXISTS "every_server" boolean DEFAULT true
ALTER TABLE "lists" ADD COLUMN IF NOT EXISTS "archived_at" timestamp with time zone;--> statement-breakpoint
-- the org lists there are, one per kind (checked on the hosted panel 2026-10-09), are its defaults
UPDATE "lists" SET "is_default" = true WHERE "server_id" IS NULL;--> statement-breakpoint
-CREATE UNIQUE INDEX "lists_org_default_uidx" ON "lists" USING btree ("org_id","kind") WHERE "lists"."is_default";--> statement-breakpoint
-CREATE UNIQUE INDEX "lists_org_kind_name_uidx" ON "lists" USING btree ("org_id","kind",lower("name")) WHERE "lists"."server_id" is null and "lists"."archived_at" is null;--> statement-breakpoint
-ALTER TABLE "lists" ADD CONSTRAINT "lists_group_kind" CHECK ("lists"."server_id" is not null or "lists"."is_default" or "lists"."kind" = 'reserve');--> statement-breakpoint
-ALTER TABLE "lists" ADD CONSTRAINT "lists_group_shape" CHECK (("lists"."server_id" is null and not "lists"."is_default") or ("lists"."on_from" is null and "lists"."on_until" is null and "lists"."every_server" and "lists"."archived_at" is null));--> statement-breakpoint
-ALTER TABLE "lists" ADD CONSTRAINT "lists_window" CHECK ("lists"."on_from" is null or "lists"."on_until" is null or "lists"."on_from" < "lists"."on_until");
+CREATE UNIQUE INDEX IF NOT EXISTS "lists_org_default_uidx" ON "lists" USING btree ("org_id","kind") WHERE "lists"."is_default";--> statement-breakpoint
+CREATE UNIQUE INDEX IF NOT EXISTS "lists_org_kind_name_uidx" ON "lists" USING btree ("org_id","kind",lower("name")) WHERE "lists"."server_id" is null and "lists"."archived_at" is null;--> statement-breakpoint
+DO $$
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'lists'::regclass AND conname = 'lists_group_kind') THEN
+ ALTER TABLE "lists" ADD CONSTRAINT "lists_group_kind"
+ CHECK ("lists"."server_id" is not null or "lists"."is_default" or "lists"."kind" = 'reserve');
+ END IF;
+END $$;--> statement-breakpoint
+DO $$
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'lists'::regclass AND conname = 'lists_group_shape') THEN
+ ALTER TABLE "lists" ADD CONSTRAINT "lists_group_shape"
+ CHECK (("lists"."server_id" is null and not "lists"."is_default") or ("lists"."on_from" is null and "lists"."on_until" is null and "lists"."every_server" and "lists"."archived_at" is null));
+ END IF;
+END $$;--> statement-breakpoint
+DO $$
+BEGIN
+ IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conrelid = 'lists'::regclass AND conname = 'lists_window') THEN
+ ALTER TABLE "lists" ADD CONSTRAINT "lists_window"
+ CHECK ("lists"."on_from" is null or "lists"."on_until" is null or "lists"."on_from" < "lists"."on_until");
+ END IF;
+END $$;