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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -530,7 +530,7 @@ delete them.
| 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 (a new rule starts with the defibrillator; charges that are placed and set off from anywhere are not offered) 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. It flags them (audit trail and the Discord mirror, whose post opens the player's page; again only after a cooldown), 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 kicked or banned player is left alone for a minute while that lands; one who is back and does it again that match is acted on at their next such kill. 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. |
| Two-team mode | Closes one faction and moves everyone on it to whichever of the other two has fewer players, with the game's own move-then-kill so they respawn on their new side. 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 asked for again once a newer list shows it did not land. A player asked to move three times in ten minutes is left where they are until the ten minutes pass. It never moves players between the two open sides. Optional names for the open sides (e.g. Red and Green) and a whisper, `{team}`, sent once to each player it places (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 and the asks. Nothing to dry-run: faction moves are not in the session history. |
| Two-team mode | Closes one faction and moves everyone on it to whichever of the other two has fewer players, with the game's own move-then-kill so they respawn on their new side. 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 asked for again once a newer list shows it did not land. A player asked to move three times in ten minutes is left where they are until the ten minutes pass. It never moves players between the two open sides. Optionally keeps clans together: a player whose name starts with a tag in square brackets (`[WOLF] Dan`) is placed on the side most of that clan is on, moves in flight included, while that leaves the side at most N players ahead (up to 10); a clan split evenly, or one that would tip the sides further, is placed on the smaller side, as is a move asked for a second time. Optional names for the open sides (e.g. Red and Green) and a whisper, `{team}`, sent once to each player it places (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 and the asks. Nothing to dry-run: faction moves are not in the session history. |
| 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. Placeholders `{faction}` `{score}` `{scores}` `{cap}` `{previous}` `{map}` `{server}` `{players}` `{max}`, and from the players' lines of the match that ended `{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. |

Expand Down
20 changes: 20 additions & 0 deletions src/lib/server/triggers-two-teams.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,4 +121,24 @@ describe('the Two-team mode rule', () => {
'Moving P106; left P7 on Lonestar: asked to move 3 times in 10 min'
]);
});

test('with clans kept together, a move says which clan decided the side', async () => {
const rule = row({ ...CLOSED, clanGap: 3 });
const players = [
{ ...player('1', 'Valkyra'), name: '[WOLF] A' },
player('2', 'Valkyra'),
{ ...player('3', 'Lonestar'), name: '[WOLF] C' },
player('4', 'Lonestar')
];
const ev = await look(rule, tick(players, 0));
const moves = ev.intents.filter((i) => i.action === 'changeTeam');
expect(moves.map((i) => [i.params.faction, i.okMessage, i.detail])).toEqual([
[
'Valkyra',
'Moved [WOLF] C to Valkyra, with [WOLF].',
{ name: '[WOLF] C', from: 'Lonestar', to: 'Valkyra', clan: 'WOLF' }
],
['Manticore', 'Moved P4 to Manticore.', { name: 'P4', from: 'Lonestar', to: 'Manticore' }]
]);
});
});
4 changes: 2 additions & 2 deletions src/lib/server/triggers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -898,8 +898,8 @@ function evalTwoTeams(ctx: TickContext, row: TriggerRow, cfg: TwoTeamsConfig, ou
action: 'changeTeam',
params: { steamId: m.steamId, faction: m.to, from: m.from, rule: config },
target: m.steamId,
okMessage: `Moved ${m.name} to ${teamName(cfg, m.to)}.`,
detail: { name: m.name, from: m.from, to: m.to },
okMessage: `Moved ${m.name} to ${teamName(cfg, m.to)}${m.clan ? `, with [${m.clan}]` : ''}.`,
detail: { name: m.name, from: m.from, to: m.to, ...(m.clan ? { clan: m.clan } : {}) },
steamId: m.steamId,
dedupeKey: key(row, m.steamId, now)
});
Expand Down
132 changes: 132 additions & 0 deletions src/lib/server/two-teams.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { describe, expect, test } from 'bun:test';
import {
clanTag,
emptyTwoTeamsState,
TWO_TEAMS_ASK_WINDOW_MS,
TWO_TEAMS_FORGET_MS,
TWO_TEAMS_MAX_ASKS,
TWO_TEAMS_MAX_CLAN_GAP,
TWO_TEAMS_RETRY_MS,
teamName,
twoTeamsSettingsKey,
Expand Down Expand Up @@ -144,6 +146,136 @@ describe('twoTeamsStep', () => {
});
});

describe('clanTag', () => {
test('reads square brackets at the front of a name, folded', () => {
expect(clanTag('[WOLF] Dan')).toBe('WOLF');
expect(clanTag(' [wolf]Dan')).toBe('WOLF');
expect(clanTag('[ w f ] Dan')).toBe('W F');
});
test('is null without a tag, for an empty or overlong bracket, and for one later in the name', () => {
expect(clanTag('Dan')).toBeNull();
expect(clanTag('[] Dan')).toBeNull();
expect(clanTag('[ ] Dan')).toBeNull();
expect(clanTag('[the strongest soldier] Dan')).toBeNull();
expect(clanTag('Dan [WOLF]')).toBeNull();
});
});

describe('twoTeamsStep keeping clans together', () => {
const clans = { ...cfg, clanGap: 3 };
const named = (id: string, name: string, faction: string | null) => ({
steamId: id,
name,
faction
});
const go = (state: TwoTeamsState, players: ReturnType<typeof named>[], now = 0, c = clans) =>
twoTeamsStep(c, state, players, OPEN, now, ALL, first);

test('a rule without the setting places a clan member on the smaller side, as before', () => {
const players = [
named('1', '[WOLF] A', 'Valkyra'),
named('2', 'B', 'Valkyra'),
named('3', '[WOLF] C', 'Lonestar')
];
const r = twoTeamsStep(cfg, emptyTwoTeamsState(), players, OPEN, 0, ALL, first);
expect(r.moves).toEqual([
{ steamId: '3', name: '[WOLF] C', from: 'Lonestar', to: 'Manticore' }
]);
});

test('a player joins the side their clan is on, though it is the larger one', () => {
const players = [
named('1', '[WOLF] A', 'Valkyra'),
named('2', 'B', 'Valkyra'),
named('3', '[wolf] C', 'Lonestar'),
named('4', 'D', 'Lonestar')
];
const r = go(emptyTwoTeamsState(), players);
expect(r.moves).toEqual([
{ steamId: '3', name: '[wolf] C', from: 'Lonestar', to: 'Valkyra', clan: 'WOLF' },
{ steamId: '4', name: 'D', from: 'Lonestar', to: 'Manticore' }
]);
});

test('a clan that lands on the closed faction together follows its first member', () => {
const players = Array.from({ length: 4 }, (_, i) =>
named(String(i), `[WOLF] ${i}`, 'Lonestar')
);
const r = go(emptyTwoTeamsState(), players);
// the first is placed as anyone; the next two join it; a fourth would put the side 4 ahead
expect(r.moves.map((m) => m.to)).toEqual(['Valkyra', 'Valkyra', 'Valkyra', 'Manticore']);
expect(r.moves.map((m) => m.clan)).toEqual([undefined, 'WOLF', 'WOLF', undefined]);
});

test('members still on their way count: the rest of a clan follows at the next look', () => {
const a = go(emptyTwoTeamsState(), [named('1', '[WOLF] A', 'Lonestar')]);
const b = go(
a.state,
[named('1', '[WOLF] A', 'Lonestar'), named('2', '[WOLF] B', 'Lonestar')],
1000
);
expect(b.moves).toEqual([
expect.objectContaining({ steamId: '2', to: a.moves[0].to, clan: 'WOLF' })
]);
});

test('never lets the clan side further ahead than the gap', () => {
const players = [
...Array.from({ length: 5 }, (_, i) => named(`v${i}`, `[WOLF] V${i}`, 'Valkyra')),
...Array.from({ length: 2 }, (_, i) => named(`m${i}`, `M${i}`, 'Manticore')),
named('x', '[WOLF] X', 'Lonestar')
];
// 5 v 2: joining the clan would make it 6 v 2
expect(go(emptyTwoTeamsState(), players).moves).toEqual([
{ steamId: 'x', name: '[WOLF] X', from: 'Lonestar', to: 'Manticore' }
]);
// a wider gap lets them through
expect(go(emptyTwoTeamsState(), players, 0, { ...cfg, clanGap: 4 }).moves[0]).toEqual(
expect.objectContaining({ to: 'Valkyra', clan: 'WOLF' })
);
});

test('a clan split evenly has no side, and is placed on the smaller one', () => {
const players = [
named('1', '[WOLF] A', 'Valkyra'),
named('2', '[WOLF] B', 'Manticore'),
named('3', 'C', 'Manticore'),
named('4', '[WOLF] D', 'Lonestar')
];
expect(go(emptyTwoTeamsState(), players).moves).toEqual([
{ steamId: '4', name: '[WOLF] D', from: 'Lonestar', to: 'Valkyra' }
]);
});

test('a move asked for a second time goes to the smaller side, not back to the clan', () => {
const players = [
named('1', '[WOLF] A', 'Valkyra'),
named('2', 'B', 'Valkyra'),
named('3', '[WOLF] C', 'Lonestar')
];
const a = go(emptyTwoTeamsState(), players);
expect(a.moves[0]).toEqual(expect.objectContaining({ to: 'Valkyra', clan: 'WOLF' }));
// still on the closed faction once the wait is over: the move did not land
const b = go(a.state, players, TWO_TEAMS_RETRY_MS);
expect(b.moves).toEqual([
{ steamId: '3', name: '[WOLF] C', from: 'Lonestar', to: 'Manticore' }
]);
});

test('validateTwoTeams keeps a gap from 1 to the limit, and leaves the setting out when it is off', () => {
const base = { closedFaction: 'Lonestar' };
expect(validateTwoTeams({ ...base, clanGap: 3 }).clanGap).toBe(3);
expect(validateTwoTeams({ ...base, clanGap: 99 }).clanGap).toBe(TWO_TEAMS_MAX_CLAN_GAP);
for (const off of [0, -2, '', null, 'x'])
expect('clanGap' in validateTwoTeams({ ...base, clanGap: off })).toBe(false);
// so a rule saved before the setting existed keeps its fingerprint when saved again
expect(twoTeamsSettingsKey(validateTwoTeams({ ...cfg, clanGap: 0 }))).toBe(
twoTeamsSettingsKey(cfg)
);
expect(twoTeamsSettingsKey({ ...cfg, clanGap: 3 })).not.toBe(twoTeamsSettingsKey(cfg));
});
});

test('teamName falls back to the faction, never to an inherited property', () => {
expect(teamName(cfg, 'Valkyra')).toBe('Red');
expect(teamName({ ...cfg, names: {} }, 'Valkyra')).toBe('Valkyra');
Expand Down
Loading
Loading