From 15792f12b4807b23b7c2b5ae42555a84582e927f Mon Sep 17 00:00:00 2001
From: jbob0
Date: Thu, 1 Oct 2026 09:50:20 -0400
Subject: [PATCH] Two-team mode: optionally keep clans together, within a gap
A rule can place a closed-faction player on the open side most of their clan
is on, while that leaves the side at most N players ahead of the other (1 to
10; absent or 0 keeps today's behaviour). A clan is the tag in square brackets
at the front of the name, folded for case. Moves in flight count, so a clan
that lands on the closed faction together at a match start follows its first
member. A clan split evenly, or one that would put a side further ahead than
the gap, is placed on the smaller side as before, and so is any move asked for
a second time, so a clan move that does not land costs nothing new.
The setting is left out of the config when off, so a rule saved before it
existed keeps its fingerprint when saved again and its queued moves are not
dropped. A move decided by a clan says so in its audit line and detail.
Co-Authored-By: Claude Opus 5.5 (1M context)
Claude-Session: https://claude.ai/code/session_01KVnrnLs54SCrQzy3JDv3mz
---
README.md | 2 +-
src/lib/server/triggers-two-teams.test.ts | 20 +++
src/lib/server/triggers.ts | 4 +-
src/lib/server/two-teams.test.ts | 132 ++++++++++++++++++
src/lib/server/two-teams.ts | 79 ++++++++++-
.../(app)/server/[id]/automation/+page.svelte | 28 +++-
6 files changed, 254 insertions(+), 11 deletions(-)
diff --git a/README.md b/README.md
index 59bad6b0..035ada13 100644
--- a/README.md
+++ b/README.md
@@ -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. |
diff --git a/src/lib/server/triggers-two-teams.test.ts b/src/lib/server/triggers-two-teams.test.ts
index 31c59f26..9dbb6e0e 100644
--- a/src/lib/server/triggers-two-teams.test.ts
+++ b/src/lib/server/triggers-two-teams.test.ts
@@ -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' }]
+ ]);
+ });
});
diff --git a/src/lib/server/triggers.ts b/src/lib/server/triggers.ts
index 448c5952..bf676d97 100644
--- a/src/lib/server/triggers.ts
+++ b/src/lib/server/triggers.ts
@@ -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)
});
diff --git a/src/lib/server/two-teams.test.ts b/src/lib/server/two-teams.test.ts
index d48843bd..1ed8340b 100644
--- a/src/lib/server/two-teams.test.ts
+++ b/src/lib/server/two-teams.test.ts
@@ -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,
@@ -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[], 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');
diff --git a/src/lib/server/two-teams.ts b/src/lib/server/two-teams.ts
index c139a9a6..d5d2f297 100644
--- a/src/lib/server/two-teams.ts
+++ b/src/lib/server/two-teams.ts
@@ -5,7 +5,7 @@
// runs the step on each fresh player list, keeps its state in the worker's memory and writes the
// moves to the outbox.
import { settingsFingerprint } from './fingerprint';
-import { ApiError, str } from './http';
+import { ApiError, int, str } from './http';
import { MAX_CHAT } from '$lib/chat';
/** How long a move is waited on before it is asked for again (the player is still on the closed faction). */
@@ -37,6 +37,25 @@ export interface TwoTeamsConfig {
names: Record;
/** whispered once a moved player lands, with {team}; '' sends nothing */
message: string;
+ /**
+ * Keep clans together: a player whose clan tag is mostly on one open side is placed there, while
+ * that would leave the side at most this many players ahead of the other. Absent on a rule that
+ * does not group clans, so such a rule's fingerprint is what it was before the setting existed.
+ */
+ clanGap?: number;
+}
+
+/** The most a side may be let ahead for a clan's sake. */
+export const TWO_TEAMS_MAX_CLAN_GAP = 10;
+
+/**
+ * A player's clan tag, as the game shows it: square brackets at the front of the name, "[WOLF] Dan".
+ * Folded, so [wolf] and [WOLF] are one clan; null without one. Twelve characters at most: a longer
+ * bracket is a name, not a tag.
+ */
+export function clanTag(name: string): string | null {
+ const m = /^\s*\[([^\]]{1,12})\]/.exec(name);
+ return (m && m[1].normalize('NFKC').trim().toUpperCase()) || null;
}
export function validateTwoTeams(c: Record): TwoTeamsConfig {
@@ -49,7 +68,13 @@ export function validateTwoTeams(c: Record): TwoTeamsConfig {
const name = str(v, 40);
if (faction && name && faction !== closedFaction) names[faction] = name;
}
- return { closedFaction, names, message: str(c.message, MAX_CHAT) };
+ const clanGap = int(c.clanGap, 0, 0, TWO_TEAMS_MAX_CLAN_GAP);
+ return {
+ closedFaction,
+ names,
+ message: str(c.message, MAX_CHAT),
+ ...(clanGap ? { clanGap } : {})
+ };
}
/**
@@ -79,8 +104,8 @@ export const emptyTwoTeamsState = (): TwoTeamsState => ({
export interface TwoTeamsStep {
state: TwoTeamsState;
- /** players to move now, and where */
- moves: { steamId: string; name: string; from: string; to: string }[];
+ /** players to move now, and where; `clan` is the tag a player was placed with, when one decided the side */
+ moves: { steamId: string; name: string; from: string; to: string; clan?: string }[];
/** moved players now on their side and not told yet */
whispers: { steamId: string; name: string; faction: string }[];
/** players the rule has just stopped moving for being asked too often */
@@ -92,6 +117,13 @@ export interface TwoTeamsStep {
* minus the closed one); at most `maxMoves` moves are asked for. Players already being moved count
* toward their target, so a burst at a match start splits evenly; a move not seen landed after
* TWO_TEAMS_RETRY_MS is asked for again.
+ *
+ * With `clanGap`, a player with a clan tag goes to the open side most of that clan is on (players
+ * there, and moves on their way there), while that leaves the side at most `clanGap` ahead of the
+ * other; a clan split evenly, or one that would tip the sides further, is placed as anyone else. So a
+ * clan that lands on the closed faction together at a match start follows its first member. Only a
+ * player's first ask in the window is placed this way: a move that did not land is asked for again
+ * toward the smaller side, as it was before clans were looked at.
*/
export function twoTeamsStep(
cfg: TwoTeamsConfig,
@@ -144,6 +176,19 @@ export function twoTeamsStep(
if (p.faction === cfg.closedFaction && m && counts.has(m.to))
counts.set(m.to, counts.get(m.to)! + 1);
}
+ // Each clan's players per open side, those on their way there included.
+ const gap = cfg.clanGap ?? 0;
+ const clans = new Map>();
+ const joins = (tag: string, side: string) => {
+ const sides = clans.get(tag) ?? new Map();
+ clans.set(tag, sides.set(side, (sides.get(side) ?? 0) + 1));
+ };
+ if (gap > 0)
+ for (const p of players) {
+ const tag = clanTag(p.name);
+ const side = p.faction === cfg.closedFaction ? state.moving.get(p.steamId)?.to : p.faction;
+ if (tag && side && counts.has(side)) joins(tag, side);
+ }
for (const p of players) {
if (p.faction !== cfg.closedFaction || state.moving.has(p.steamId)) continue;
const times = state.asked.get(p.steamId) ?? [];
@@ -157,11 +202,31 @@ export function twoTeamsStep(
if (moves.length >= maxMoves) continue;
const low = Math.min(...counts.values());
const ties = open.filter((f) => counts.get(f) === low);
- const to = ties[Math.min(ties.length - 1, Math.floor(random() * ties.length))];
- counts.set(to, low + 1);
+ let to = ties[Math.min(ties.length - 1, Math.floor(random() * ties.length))];
+ const tag = gap > 0 ? clanTag(p.name) : null;
+ let clan: string | undefined;
+ if (tag && !times.length) {
+ // The side with more of the clan than any other, if joining it keeps the sides within the gap.
+ const sides = clans.get(tag);
+ const most = Math.max(0, ...(sides?.values() ?? []));
+ const held = open.filter((f) => most > 0 && sides!.get(f) === most);
+ const others = open.filter((f) => f !== held[0]).map((f) => counts.get(f)!);
+ if (held.length === 1 && counts.get(held[0])! + 1 - Math.min(...others) <= gap) {
+ to = held[0];
+ clan = tag;
+ }
+ }
+ if (tag) joins(tag, to);
+ counts.set(to, counts.get(to)! + 1);
state.moving.set(p.steamId, { to, at: now });
state.asked.set(p.steamId, [...times, now]);
- moves.push({ steamId: p.steamId, name: p.name, from: cfg.closedFaction, to });
+ moves.push({
+ steamId: p.steamId,
+ name: p.name,
+ from: cfg.closedFaction,
+ to,
+ ...(clan ? { clan } : {})
+ });
}
return { state, moves, whispers, stopped };
}
diff --git a/src/routes/(app)/server/[id]/automation/+page.svelte b/src/routes/(app)/server/[id]/automation/+page.svelte
index c7bbba51..b4ba557a 100644
--- a/src/routes/(app)/server/[id]/automation/+page.svelte
+++ b/src/routes/(app)/server/[id]/automation/+page.svelte
@@ -396,6 +396,7 @@
headshotMinKills: number;
closedFaction: string;
teamNames: Record;
+ clanGap: number;
causes: string[];
minDistanceM: number;
count: number;
@@ -547,6 +548,7 @@
closedFaction: s('closedFaction', 'Lonestar'),
teamNames:
c.names && typeof c.names === 'object' ? { ...(c.names as Record) } : {},
+ clanGap: n('clanGap', 0),
causes: Array.isArray(c.causes)
? [...(c.causes as string[])]
: ['Id.Item.Defibrillator.Standard'],
@@ -693,7 +695,9 @@
([k, v]) => k !== f.closedFaction.trim() && v?.trim()
)
),
- message: f.message
+ message: f.message,
+ // left out when off, so a rule that does not group clans is saved as it always was
+ ...(Number(f.clanGap) > 0 ? { clanGap: Number(f.clanGap) } : {})
};
case 'seed_reward':
return {
@@ -890,6 +894,7 @@
return [
`${c.closedFaction} closed, its players moved to the smaller side`,
names.length ? names.join(', ') : '',
+ Number(c.clanGap) > 0 ? `clans kept together, up to ${c.clanGap} ahead` : '',
c.message ? 'with a whisper' : ''
]
.filter(Boolean)
@@ -1731,6 +1736,27 @@
Used for {'{team}'} in the whisper, e.g. Red and Green. Empty keeps the faction name.