Support consensual lobby faction swaps

This commit is contained in:
Quintine committed 2026-10-28 00:22:46 +11:00
1 parent 4dc2df2513
commit c6b4a165bd
15 files changed
+680 -14

No files matched your search

+58
View File
@@ -2,6 +2,8 @@ import { createTwoPlayerControllers, validateTwoPlayerControllersLobby, validate
addTwoPlayerControlledSeat, TwoPlayerControllersError, type TwoPlayerControllers } from './two-player-controllers';
import { createFactionDraft, validateFactionDraft, currentFactionDrafter, chooseFaction,
FactionDraftError, type FactionDraft } from './faction-draft';
import { createFactionSwap, validateFactionSwap, answerFactionSwap, cancelFactionSwap,
FactionSwapError, type FactionSwap } from './faction-swap';
import { CharityPromiseError, liveCharityPromises, charityPromiseAnswers, bindCharityPromise, guardCharityClaim, guardCharityReady, reconcileCharityPromises, type CharityPromise } from './charity-promises';
import { victoryFinalTurn, continueVictoryCalendar, VictoryCalendarError } from './victory-calendar';
import { createVictoryContinuation, validateVictoryContinuation, voteVictoryContinuation,
@@ -1411,6 +1413,8 @@ type RicheseAllyOffer = {
payer: string;
};
export type Game = {
/** A human-to-human lobby offer, pending the recipient's consent. */
factionSwap?: FactionSwap;
/** Public prediction-number faction selection, present only while in progress. */
factionDraft?: FactionDraft;
/** Explicit two-human/four-faction setup; absent from ordinary and legacy games. */
@@ -5716,6 +5720,7 @@ function twoPlayerControllersIntegrity(g: Game): void {
function joinFactionSeat(g: Game, p: Player, controllerId?: string) {
twoPlayerControllersIntegrity(g);
requireRule(!g.factionDraft, 'Finish or cancel the faction draft before adding a seat.');
requireRule(!g.factionSwap, 'Resolve the faction swap before adding a seat.');
requireRule(g.status === 'lobby', 'This game has already started.');
requireRule(g.players.length < 6, 'This table has six players.');
requireRule(!g.players.some((x) => x.id === p.id), 'Already seated.');
@@ -5776,6 +5781,21 @@ function factionDraftIntegrity(g: Game): void {
factionDraftRule(() => validateFactionDraft(g.factionDraft, g.players.map(player => player.id), eligibleLobbyFactions(g)));
requireRule(g.factionDraft.picks.length < g.players.length, 'A completed faction draft must be applied once.');
}
function factionSwapRule<T>(operation: () => T): T {
try { return operation(); }
catch (error) {
if (error instanceof FactionSwapError) throw new RuleError(error.message);
throw error;
}
}
function factionSwapIntegrity(g: Game): void {
if (g.factionSwap === undefined) return;
requireRule(g.status === 'lobby' && !g.factionDraft, 'A faction swap belongs to the lobby outside a draft.');
factionSwapRule(() => validateFactionSwap(g.factionSwap, g.players));
requireRule(g.players.some(player => player.id === g.factionSwap!.from && !player.bot) &&
g.players.some(player => player.id === g.factionSwap!.to && !player.bot),
'Faction swap participants must be human seats.');
}
function assignLobbyFaction(player: Player, chosen: FactionId): void {
player.faction = chosen;
if (player.bot) player.name = `${faction(chosen).name} AI`;
@@ -39980,6 +40000,11 @@ function normalizeCardNames(g: Game) {
export function applyAction(state: Game, id: string, action: Action): Game {
twoPlayerControllersIntegrity(state);
factionDraftIntegrity(state);
factionSwapIntegrity(state);
if (state.factionSwap) requireRule(
['factionSwapRespond', 'factionSwapCancel', 'advanceBots'].includes(action?.type),
'Resolve the faction swap before changing the lobby.',
);
if (state.factionDraft) requireRule(
['factionDraftPick', 'factionDraftCancel', 'advanceBots'].includes(action?.type),
'Finish or cancel the faction draft before changing the lobby.',
@@ -40601,6 +40626,7 @@ function settleAutomaticContinuations(g: Game) {
export function normalizeAutomaticGame(state: Game): Game {
twoPlayerControllersIntegrity(state);
factionDraftIntegrity(state);
factionSwapIntegrity(state);
victoryContinuationIntegrity(state);
if (state.victoryContinuation) return state;
bidPromiseIntegrity(state);
@@ -42927,6 +42953,27 @@ function applyActionInner(
return g;
}
if (g.status === 'lobby') {
if (g.factionSwap) {
const offer = g.factionSwap;
requireRule(action.event === offer.event, 'This faction swap is no longer current.');
if (t === 'factionSwapCancel') {
requireRule(Object.keys(action).every(key => ['type', 'event'].includes(key)), 'Invalid faction swap cancellation.');
factionSwapRule(() => cancelFactionSwap(g.players, offer, id));
delete g.factionSwap;
log(g, `${p.name} canceled the faction swap offer.`);
return g;
}
requireRule(t === 'factionSwapRespond' && typeof action.accept === 'boolean' &&
Object.keys(action).every(key => ['type', 'event', 'accept'].includes(key)), 'Choose whether to accept the faction swap.');
const result = factionSwapRule(() => answerFactionSwap(g.players, offer, id, action.accept as boolean));
delete g.factionSwap;
if (result.accepted) {
for (const assignment of result.assignments) assignLobbyFaction(getPlayer(g, assignment.player), assignment.faction);
g.players.forEach(player => (player.ready = !!player.bot));
log(g, `${p.name} accepted the faction swap. Human readiness was cleared.`);
} else log(g, `${p.name} declined the faction swap offer.`);
return g;
}
if (g.factionDraft) {
const draft = g.factionDraft;
requireRule(action.event === draft.event, 'This faction draft is no longer current.');
@@ -42947,6 +42994,15 @@ function applyActionInner(
continueFactionDraft(g);
return g;
}
if (t === 'factionSwapOffer') {
requireRule(Object.keys(action).every(key => ['type', 'target'].includes(key)) &&
typeof action.target === 'string' && !p.bot, 'Choose another human seat for the faction swap.');
const target = g.players.find(player => player.id === action.target);
requireRule(target && !target.bot, 'Choose another human seat for the faction swap.');
g.factionSwap = factionSwapRule(() => createFactionSwap(g.players, id, target.id, crypto.randomUUID()));
log(g, `${p.name} offered to exchange factions with ${target.name}.`);
return g;
}
if (t === 'factionAllocation') {
requireRule(id === g.host && Object.keys(action).every(key => ['type', 'mode'].includes(key)) &&
(action.mode === 'random' || action.mode === 'draft'),
@@ -44892,6 +44948,7 @@ function applyActionInner(
export function viewGame(state: Game, id: string, now?: number) {
twoPlayerControllersIntegrity(state);
factionDraftIntegrity(state);
factionSwapIntegrity(state);
victoryContinuationIntegrity(state);
revivalOrderIntegrity(state);
charityOrderIntegrity(state);
@@ -45191,6 +45248,7 @@ export function viewGame(state: Game, id: string, now?: number) {
host: g.host,
status: g.status,
factionDraft: g.factionDraft ?? null,
factionSwap: g.factionSwap ?? null,
twoPlayerControllers: g.twoPlayerControllers ?? null,
controlledFactionSeats: g.twoPlayerControllers
? g.players.filter(player => g.twoPlayerControllers!.controllers.some(controller =>
+87
View File
@@ -0,0 +1,87 @@
import { FACTIONS, type FactionId } from './catalog';
/** A public offer, bound to both seats' current faction assignments. */
export type FactionSwap = {
event: string;
from: string;
to: string;
fromFaction: FactionId;
toFaction: FactionId;
};
export type FactionSwapSeat = { id: string; faction: FactionId };
export type FactionSwapAssignment = { player: string; faction: FactionId };
export class FactionSwapError extends Error {
constructor(message: string) {
super(message);
this.name = 'FactionSwapError';
}
}
const identifier = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0 && value === value.trim();
const factionId = (value: unknown): value is FactionId =>
typeof value === 'string' && FACTIONS.some(faction => faction.id === value);
const record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
function requireSwap(condition: unknown, message: string): asserts condition {
if (!condition) throw new FactionSwapError(message);
}
function validateRoster(players: readonly FactionSwapSeat[]): void {
requireSwap(Array.isArray(players) && players.length >= 2 && players.length <= 6 &&
players.every(player => record(player) && identifier(player.id) && factionId(player.faction)) &&
new Set(players.map(player => player.id)).size === players.length &&
new Set(players.map(player => player.faction)).size === players.length,
'A faction swap needs two through six distinct seats with distinct factions.');
}
/** Offer an exchange without changing the players or their assignments. */
export function createFactionSwap(
players: readonly FactionSwapSeat[], actor: string, target: string, event: string,
): FactionSwap {
validateRoster(players);
requireSwap(identifier(event), 'A faction swap needs a distinct event.');
requireSwap(identifier(actor) && identifier(target) && actor !== target,
'A faction swap needs two different players.');
const from = players.find(player => player.id === actor);
const to = players.find(player => player.id === target);
requireSwap(from && to, 'Both players must be seated.');
return { event, from: from.id, to: to.id, fromFaction: from.faction, toFaction: to.faction };
}
/** A saved offer remains valid only while both seats retain its exact factions. */
export function validateFactionSwap(value: unknown, players: readonly FactionSwapSeat[]): asserts value is FactionSwap {
validateRoster(players);
requireSwap(record(value) && Object.keys(value).sort().join(',') === 'event,from,fromFaction,to,toFaction' &&
identifier(value.event) && identifier(value.from) && identifier(value.to) && value.from !== value.to &&
factionId(value.fromFaction) && factionId(value.toFaction) && value.fromFaction !== value.toFaction,
'The saved faction swap is malformed.');
requireSwap(players.some(player => player.id === value.from && player.faction === value.fromFaction) &&
players.some(player => player.id === value.to && player.faction === value.toFaction),
'The faction swap no longer matches the seated players.');
}
/** Only the recipient can accept or reject; the caller applies an acceptance atomically. */
export function answerFactionSwap(
players: readonly FactionSwapSeat[], offer: unknown, actor: string, accept: boolean,
): { accepted: boolean; assignments: FactionSwapAssignment[] } {
validateFactionSwap(offer, players);
requireSwap(actor === offer.to, 'Only the recipient may answer a faction swap.');
requireSwap(typeof accept === 'boolean', 'A faction swap answer must accept or reject.');
return {
accepted: accept,
assignments: accept ? [
{ player: offer.from, faction: offer.toFaction },
{ player: offer.to, faction: offer.fromFaction },
] : [],
};
}
/** Only the proposer may withdraw an offer. */
export function cancelFactionSwap(players: readonly FactionSwapSeat[], offer: unknown, actor: string): void {
validateFactionSwap(offer, players);
requireSwap(actor === offer.from, 'Only the proposer may cancel a faction swap.');
}
+2 -2
View File
@@ -2446,7 +2446,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Create a room, invite rivals with its code, and complete each faction’s starting choices.',
steps: [
'Each player chooses a distinct faction and an unoccupied player circle, then marks Ready. The six circles appear around the map and determine storm order. Changing your circle resets readiness. The host starts when everyone is ready.',
'The host may instead deal random enabled factions or draw distinct prediction numbers for a faction draft. The highest number picks first; choices apply together after all seats pick. AI seats choose automatically and human readiness clears. Consensual faction swaps are not yet available.',
'The host may instead deal random enabled factions or draw distinct prediction numbers for a faction draft. The highest number picks first; choices apply together after all seats pick. AI seats choose automatically and human readiness clears. Human seats may offer to swap their factions; the recipient must accept, and human readiness then clears. Other lobby changes wait for the offer to be accepted, declined or canceled. AI seats cannot swap yet.',
'After positions are fixed, Bene Gesserit seals a secret prediction of a faction and turn. No traitor or Treachery Cards have been dealt yet. Other players wait for this choice; if Bene Gesserit is absent, the table continues automatically.',
'Each player receives four private traitor choices and keeps one. Harkonnen keeps all four automatically. Every remaining selection must finish before starting spice and forces are placed. Your starting Treachery hand is still empty.',
'Starting spice and fixed forces are then placed. Fremen distributes ten forces among Sietch Tabr, False Wall South and False Wall West, choosing their printed sectors. Basic Bene Gesserit starts with one fighter in Polar Sink. In Advanced setup, Bene Gesserit instead chooses a printed territory and sector after Fremen placement; its advisor becomes a fighter when alone.',
@@ -2460,7 +2460,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
'Lobby random faction dealing and prediction-number drafting retain seat identities and clear readiness; consensual swaps remain unfinished. Base setup enforces prediction, traitors, forces and automatic dealing in order, with private choices and preserved saved tables. Advanced starting advisors and elite forces share the same setup logic. The normal lobby starts selected expansions and optional modules through existing genuine setups as labelled previews; unsupported combinations still reject at start.',
'Lobby random faction dealing, prediction-number drafting and human-to-human consensual swaps retain seat identities and clear readiness on assignment; AI swaps remain unfinished. Base setup enforces prediction, traitors, forces and automatic dealing in order, with private choices and preserved saved tables. Advanced starting advisors and elite forces share the same setup logic. The normal lobby starts selected expansions and optional modules through existing genuine setups as labelled previews; unsupported combinations still reject at start.',
},
{
area: 'Player controls',