Add random faction deal and prediction draft

This commit is contained in:
Quintine committed 2026-10-28 00:16:04 +11:00
1 parent a244aeb5f8
commit 4dc2df2513
16 files changed
+819 -12

No files matched your search

+100
View File
@@ -1,5 +1,7 @@
import { createTwoPlayerControllers, validateTwoPlayerControllersLobby, validateTwoPlayerControllersComplete,
addTwoPlayerControlledSeat, TwoPlayerControllersError, type TwoPlayerControllers } from './two-player-controllers';
import { createFactionDraft, validateFactionDraft, currentFactionDrafter, chooseFaction,
FactionDraftError, type FactionDraft } from './faction-draft';
import { CharityPromiseError, liveCharityPromises, charityPromiseAnswers, bindCharityPromise, guardCharityClaim, guardCharityReady, reconcileCharityPromises, type CharityPromise } from './charity-promises';
import { victoryFinalTurn, continueVictoryCalendar, VictoryCalendarError } from './victory-calendar';
import { createVictoryContinuation, validateVictoryContinuation, voteVictoryContinuation,
@@ -1409,6 +1411,8 @@ type RicheseAllyOffer = {
payer: string;
};
export type Game = {
/** Public prediction-number faction selection, present only while in progress. */
factionDraft?: FactionDraft;
/** Explicit two-human/four-faction setup; absent from ordinary and legacy games. */
twoPlayerControllers?: TwoPlayerControllers;
/** Explicit final turn after an agreed extension; absent means turn ten. */
@@ -5711,6 +5715,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.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.');
@@ -5754,6 +5759,51 @@ export function joinControlledFaction(g: Game, p: Player, actorId: string) {
'Human faction seats must belong to a human controller.');
joinFactionSeat(g, p, controller.id);
}
function factionDraftRule<T>(operation: () => T): T {
try { return operation(); }
catch (error) {
if (error instanceof FactionDraftError) throw new RuleError(error.message);
throw error;
}
}
function eligibleLobbyFactions(g: Game): FactionId[] {
return FACTIONS.filter(entry => entry.expansion === 'base' || g.expansions.includes(entry.expansion))
.map(entry => entry.id);
}
function factionDraftIntegrity(g: Game): void {
if (g.factionDraft === undefined) return;
requireRule(g.status === 'lobby', 'The faction draft belongs to the lobby.');
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 assignLobbyFaction(player: Player, chosen: FactionId): void {
player.faction = chosen;
if (player.bot) player.name = `${faction(chosen).name} AI`;
player.elites = chosen === 'ixians' ? { reserves: 7, tanks: 0, forces: {}, revived: 0 } : undefined;
player.leaders = leaders(chosen);
}
/** Bot draft choices are made immediately: online bot workers are not armed in
* the lobby. Each choice uses a currently available faction and the real order. */
function continueFactionDraft(g: Game): void {
while (g.factionDraft) {
const draft = g.factionDraft;
const actor = factionDraftRule(() => currentFactionDrafter(draft));
if (!actor) {
for (const pick of draft.picks) assignLobbyFaction(getPlayer(g, pick.player), pick.faction);
delete g.factionDraft;
g.players.forEach(player => (player.ready = !!player.bot));
log(g, 'Faction draft completed. The selected factions are assigned and human readiness was cleared.');
return;
}
const player = getPlayer(g, actor);
if (!player.bot) return;
const available = eligibleLobbyFactions(g).filter(f => !draft.picks.some(pick => pick.faction === f));
const chosen = available.includes(player.faction) ? player.faction : available[0];
requireRule(chosen, 'The AI drafter has no enabled faction available.');
g.factionDraft = factionDraftRule(() => chooseFaction(draft, g.players.map(seat => seat.id), eligibleLobbyFactions(g), actor, chosen));
log(g, `${player.name} selected ${faction(chosen).name} in the faction draft.`);
}
}
function setMovementDiscardKnowledge(g: Game, knowledge: MovementDiscardKnowledge | undefined) {
if (knowledge === undefined) delete g.movementDiscardKnowledge;
else g.movementDiscardKnowledge = knowledge;
@@ -39929,6 +39979,11 @@ function normalizeCardNames(g: Game) {
}
export function applyAction(state: Game, id: string, action: Action): Game {
twoPlayerControllersIntegrity(state);
factionDraftIntegrity(state);
if (state.factionDraft) requireRule(
['factionDraftPick', 'factionDraftCancel', 'advanceBots'].includes(action?.type),
'Finish or cancel the faction draft before changing the lobby.',
);
victoryContinuationIntegrity(state);
bidPromiseIntegrity(state);
phaseBidIntegrity(state);
@@ -40545,6 +40600,7 @@ function settleAutomaticContinuations(g: Game) {
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
export function normalizeAutomaticGame(state: Game): Game {
twoPlayerControllersIntegrity(state);
factionDraftIntegrity(state);
victoryContinuationIntegrity(state);
if (state.victoryContinuation) return state;
bidPromiseIntegrity(state);
@@ -42871,6 +42927,48 @@ function applyActionInner(
return g;
}
if (g.status === 'lobby') {
if (g.factionDraft) {
const draft = g.factionDraft;
requireRule(action.event === draft.event, 'This faction draft is no longer current.');
if (t === 'factionDraftCancel') {
requireRule(id === g.host && Object.keys(action).every(key => ['type', 'event'].includes(key)),
'Only the host can cancel the current faction draft.');
delete g.factionDraft;
g.players.forEach(player => (player.ready = !!player.bot));
log(g, `${p.name} canceled the faction draft; previous factions remain assigned.`);
return g;
}
requireRule(t === 'factionDraftPick' && Object.keys(action).every(key => ['type', 'event', 'faction'].includes(key)),
'Choose the current faction draft action.');
requireRule(!p.bot, 'AI faction choices are made automatically.');
const chosen = stringField(action.faction) as FactionId;
g.factionDraft = factionDraftRule(() => chooseFaction(draft, g.players.map(seat => seat.id), eligibleLobbyFactions(g), id, chosen));
log(g, `${p.name} selected ${faction(chosen).name} in the faction draft.`);
continueFactionDraft(g);
return g;
}
if (t === 'factionAllocation') {
requireRule(id === g.host && Object.keys(action).every(key => ['type', 'mode'].includes(key)) &&
(action.mode === 'random' || action.mode === 'draft'),
'Only the host can choose a random deal or prediction-number faction draft.');
requireRule(g.players.length >= 2, 'At least two seated factions are needed for faction allocation.');
const seats = g.players.map(player => player.id);
const eligible = eligibleLobbyFactions(g);
requireRule(eligible.length >= seats.length, 'Not enough enabled factions are available.');
if (action.mode === 'random') {
const dealt = shuffle(eligible).slice(0, seats.length);
g.players.forEach((player, index) => assignLobbyFaction(player, dealt[index]));
g.players.forEach(player => (player.ready = !!player.bot));
log(g, `${p.name} dealt random factions. Human readiness was cleared.`);
} else {
const cards = shuffle([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]).slice(0, seats.length);
g.factionDraft = factionDraftRule(() => createFactionDraft(seats, cards, crypto.randomUUID()));
g.players.forEach(player => (player.ready = !!player.bot));
log(g, `${p.name} started a faction draft in descending prediction-card order.`);
continueFactionDraft(g);
}
return g;
}
if (t === 'twoPlayerMode') {
requireRule(id === g.host, 'Only the host can change two-person setup.');
requireRule(typeof action.enabled === 'boolean' &&
@@ -44793,6 +44891,7 @@ function applyActionInner(
}
export function viewGame(state: Game, id: string, now?: number) {
twoPlayerControllersIntegrity(state);
factionDraftIntegrity(state);
victoryContinuationIntegrity(state);
revivalOrderIntegrity(state);
charityOrderIntegrity(state);
@@ -45091,6 +45190,7 @@ export function viewGame(state: Game, id: string, now?: number) {
version: g.version,
host: g.host,
status: g.status,
factionDraft: g.factionDraft ?? null,
twoPlayerControllers: g.twoPlayerControllers ?? null,
controlledFactionSeats: g.twoPlayerControllers
? g.players.filter(player => g.twoPlayerControllers!.controllers.some(controller =>
+119
View File
@@ -0,0 +1,119 @@
import { FACTIONS, type FactionId } from './catalog';
/** Public prediction-card order and completed faction choices. No hidden deck
* or session credential is stored here; the host deals once before selection. */
export type FactionDraft = {
event: string;
order: { player: string; card: number }[];
picks: { player: string; faction: FactionId }[];
};
export class FactionDraftError extends Error {
constructor(message: string) {
super(message);
this.name = 'FactionDraftError';
}
}
const record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
const identifier = (value: unknown): value is string =>
typeof value === 'string' && value.length > 0 && value === value.trim();
const keys = (value: Record<string, unknown>, expected: string): boolean =>
Object.keys(value).sort().join(',') === expected;
const factionId = (value: unknown): value is FactionId =>
typeof value === 'string' && FACTIONS.some(faction => faction.id === value);
function requireDraft(condition: unknown, message: string): asserts condition {
if (!condition) throw new FactionDraftError(message);
}
function validateSeats(seats: readonly string[]): void {
requireDraft(Array.isArray(seats) && seats.length >= 2 && seats.length <= 6 &&
seats.every(identifier) && new Set(seats).size === seats.length,
'A faction draft needs two through six distinct seated players.');
}
function validateEligible(factions: readonly FactionId[], seats: readonly string[]): void {
requireDraft(Array.isArray(factions) && factions.length >= seats.length &&
factions.length <= FACTIONS.length && factions.every(factionId) &&
new Set(factions).size === factions.length,
'The draft needs enough distinct enabled factions for every seat.');
}
/** numberCards are the unique prediction numbers dealt in seat order. */
export function createFactionDraft(
seatIds: readonly string[], numberCards: readonly number[], event: string,
): FactionDraft {
validateSeats(seatIds);
requireDraft(identifier(event), 'The faction draft needs a distinct event.');
requireDraft(Array.isArray(numberCards) && numberCards.length === seatIds.length &&
numberCards.every(card => Number.isSafeInteger(card) && card >= 1 && card <= 10) &&
new Set(numberCards).size === numberCards.length,
'Deal one distinct prediction number from 1 through 10 to each seat.');
return {
event,
order: seatIds.map((player, index) => ({ player, card: numberCards[index] }))
.sort((left, right) => right.card - left.card),
picks: [],
};
}
/** Validate a saved public draft against its actual seats and allowed faction
* pool. Picks are a prefix of the immutable descending-number order. */
export function validateFactionDraft(
value: unknown, seatIds: readonly string[], eligibleFactions: readonly FactionId[],
): asserts value is FactionDraft {
validateSeats(seatIds);
validateEligible(eligibleFactions, seatIds);
requireDraft(record(value) && keys(value, 'event,order,picks') && identifier(value.event) &&
Array.isArray(value.order) && value.order.length === seatIds.length && Array.isArray(value.picks) &&
value.picks.length <= seatIds.length,
'The saved faction draft is malformed.');
const order = value.order;
const selected = new Set<string>();
const dealt = new Set<number>();
for (const [index, entry] of order.entries()) {
requireDraft(record(entry) && keys(entry, 'card,player') && identifier(entry.player) &&
seatIds.includes(entry.player) && !selected.has(entry.player) &&
Number.isSafeInteger(entry.card) && (entry.card as number) >= 1 && (entry.card as number) <= 10 &&
!dealt.has(entry.card as number) &&
(index === 0 || (order[index - 1] as { card: number }).card > (entry.card as number)),
'The faction draft must preserve distinct seated players and descending dealt numbers.');
selected.add(entry.player);
dealt.add(entry.card as number);
}
const factions = new Set<string>();
for (const [index, pick] of value.picks.entries()) {
requireDraft(record(pick) && keys(pick, 'faction,player') &&
pick.player === (order[index] as { player: string }).player && factionId(pick.faction) &&
eligibleFactions.includes(pick.faction) && !factions.has(pick.faction),
'Faction choices must follow draft order and use distinct enabled factions.');
factions.add(pick.faction);
}
}
/** A validated completed draft has no current picker. */
export function currentFactionDrafter(draft: FactionDraft): string | null {
requireDraft(record(draft) && Array.isArray(draft.order) && Array.isArray(draft.picks),
'The saved faction draft is malformed.');
const seats = draft.order.map(entry => record(entry) ? entry.player : undefined);
requireDraft(seats.every(identifier), 'The saved faction draft has invalid seats.');
validateFactionDraft(draft, seats, FACTIONS.map(faction => faction.id));
return draft.order[draft.picks.length]?.player ?? null;
}
/** An accepted choice appends exactly once; the caller commits the returned
* snapshot through the same authoritative version fence as other actions. */
export function chooseFaction(
draft: FactionDraft, seatIds: readonly string[], eligibleFactions: readonly FactionId[],
actor: string, faction: FactionId,
): FactionDraft {
validateFactionDraft(draft, seatIds, eligibleFactions);
requireDraft(identifier(actor) && currentFactionDrafter(draft) === actor,
'Only the current faction drafter may choose.');
requireDraft(factionId(faction) && eligibleFactions.includes(faction) &&
!draft.picks.some(pick => pick.faction === faction),
'Choose an enabled faction that has not already been selected.');
return {
event: draft.event,
order: draft.order.map(entry => ({ ...entry })),
picks: [...draft.picks.map(pick => ({ ...pick })), { player: actor, faction }],
};
}
+2 -1
View File
@@ -2446,6 +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.',
'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.',
@@ -2459,7 +2460,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
'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 now starts selected expansions and the Leader Skills, Ecaz Treachery Card, Nexus, Discovery, Homeworld, Tech and Stronghold modules through their existing genuine setups as labelled previews; unsupported combinations still reject at start. Fresh native Ecaz/classic World games retain ordinary Arrakis Occupy, with or without supported Skills.',
'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.',
},
{
area: 'Player controls',