diff --git a/components/game-table.tsx b/components/game-table.tsx
index ec9ca572..3e623dc1 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -2131,6 +2131,9 @@ export function GameTable({
{g.status === 'playing' && g.shortAllianceVictory &&
Optional alliance rule: a two-member alliance wins with three strongholds when at least three people or AI controllers are playing. Two-controller games retain their four-stronghold target.
}
+ {(g.status === 'playing' || g.status === 'setup') && g.coalitionPreview !== 'off' &&
+ Group alliances are an unfinished preview. Some faction powers and interactions are incomplete; this game does not certify the full optional rules.
+
}
{g.status === 'playing' && }
{g.status === 'playing' && }
{g.status === 'playing' && act({type: 'shortAllianceVictory', enabled: e.target.checked})} />
Optional: two-member alliances win with three strongholds (three or more controllers)
+ Group alliances · unfinished preview
+ act({ type: 'coalitionPreview', variant: e.target.value })}>
+ Off
+ Up to three members · four strongholds
+ Up to three members · three strongholds
+ Unlimited members · three strongholds
+
+
+ {g.coalitionPreview !== 'off' &&
+ This incomplete preview needs three to six classic factions in Basic rules, without expansion decks, other optional modules, two-person setup or the separate short-alliance setting. Some powers and interactions remain unfinished. Changing the selection clears human readiness.
+
}
(operation: () => T): T {
function twoPlayerControllersIntegrity(g: Game): void {
requireRule(g.shortAllianceVictory === undefined || typeof g.shortAllianceVictory === 'boolean',
'The optional alliance victory setting must be true or false.');
+ requireRule(g.coalitionPreview === undefined || ['three', 'three-short', 'unlimited'].includes(g.coalitionPreview),
+ 'Invalid group-alliance preview setting.');
if (g.twoPlayerControllers === undefined) return;
const seats = g.players.map(player => player.id);
twoPlayerRule(() => {
@@ -10881,6 +10885,12 @@ function startModules(state: Game): Game {
}
/** Public consistency only; each module's genuine initializer validates its own envelope. */
function baseStartProfileBlock(g: Game): string | null {
+ if (g.coalitionPreview && g.status === 'lobby' &&
+ (g.advanced || g.expansions.length > 0 || g.players.length < 3 ||
+ !g.players.every(p => CLASSIC_FACTIONS[p.faction] === true) || g.twoPlayerControllers ||
+ g.shortAllianceVictory || g.techTokens || g.strongholdCards || g.homeworlds ||
+ g.nexusCards || g.discoveryEnabled || g.leaderSkills || g.ecazTreachery || lobbyModulesSelected(g)))
+ return 'The group-alliance preview needs at least three classic factions, Basic rules, and no expansion decks, other optional modules, two-person setup or separate short-alliance setting.';
if (g.status === 'lobby' && (g.moritaniAssassinatePreview || g.moritaniAssassinate))
return 'Separate development profiles cannot be enabled by a normal start.';
if (g.status === 'lobby' && (g.leaderSkills || g.ecazTreachery))
@@ -10945,7 +10955,7 @@ function requireFreshBaseRuntime(g: Game) {
const configuration = new Set(['code', 'version', 'host', 'players', 'playerPositions',
'advanced', 'expansions', 'log', 'botsPending', 'botNextActionAt', 'techTokens', 'strongholdCards',
'homeworlds', 'nexusCards', 'discoveryEnabled', 'leaderSkills', 'ecazTreachery', 'lobbyOptions', 'kullDefaultPolicy',
- 'twoPlayerControllers', 'shortAllianceVictory']);
+ 'twoPlayerControllers', 'shortAllianceVictory', 'coalitionPreview']);
const keys = [...new Set([...Object.keys(g), ...Object.keys(fresh)])] as (keyof Game)[];
requireRule(keys.every(key => configuration.has(key) ||
JSON.stringify(g[key]) === JSON.stringify(fresh[key])),
@@ -10982,6 +10992,8 @@ function start(g: Game, advancedPreview = false): Game {
'Start with unused Stronghold Cards before assigning ownership.');
requireFreshBaseRuntime(g);
initializeSetup(g);
+ if (g.coalitionPreview) g.coalitionState = emptyCoalitionState(
+ g.coalitionPreview === 'three' ? undefined : g.coalitionPreview);
if (advancedPreview) g.advancedPreview = true;
return g;
}
@@ -16278,9 +16290,8 @@ function allianceQuestionWindow(g: Game): boolean {
function unsettledPromises(records: readonly {fulfilled?: boolean; released?: boolean}[] | undefined): boolean {
return !!records?.some(record => !record.fulfilled && !record.released);
}
-/** Group negotiation is deliberately unexposed until its other alliance
- * mechanics are integrated. This narrow profile keeps existing pair games
- * and unrelated commitments on their original paths. */
+/** The incomplete group preview and staged fixtures retain this narrow profile;
+ * existing pair games and unrelated commitments keep their original paths. */
function coalitionNexusAvailable(g: Game): boolean {
return g.coalitionState !== undefined && g.status === 'playing' &&
g.phase === 1 && !!g.nexus && !g.spiceWindow && !g.spiceResolution &&
@@ -44695,6 +44706,18 @@ function applyActionInner(
log(g, `${p.name} ${action.enabled ? 'enabled' : 'disabled'} the provisional two-person, four-faction setup. Human readiness was cleared.`);
return g;
}
+ if (t === 'coalitionPreview') {
+ requireRule(id === g.host && Object.keys(action).every(key => key === 'type' || key === 'variant') &&
+ (action.variant === 'off' || action.variant === 'three' || action.variant === 'three-short' || action.variant === 'unlimited'),
+ 'Only the host may choose a group-alliance preview option.');
+ const variant = action.variant === 'off' ? undefined : action.variant;
+ if (variant === g.coalitionPreview) return g;
+ if (variant) g.coalitionPreview = variant;
+ else delete g.coalitionPreview;
+ g.players.forEach(player => (player.ready = !!player.bot));
+ log(g, `${p.name} changed the unfinished group-alliance preview. Human readiness was cleared.`);
+ return g;
+ }
if (t === 'shortAllianceVictory') {
requireRule(id === g.host && typeof action.enabled === 'boolean' &&
Object.keys(action).every(key => key === 'type' || key === 'enabled'),
@@ -47056,6 +47079,7 @@ export function viewGame(state: Game, id: string, now?: number) {
factionSwap: g.factionSwap ?? null,
twoPlayerControllers: g.twoPlayerControllers ?? null,
shortAllianceVictory: g.shortAllianceVictory === true,
+ coalitionPreview: g.coalitionPreview ?? 'off',
controlledFactionSeats: g.twoPlayerControllers
? g.players.filter(player => g.twoPlayerControllers!.controllers.some(controller =>
controller.seats.includes(id) && controller.seats.includes(player.id)))
diff --git a/game/reference.ts b/game/reference.ts
index 37e19e88..ad253d2e 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -2697,11 +2697,12 @@ export const RULE_TOPICS: RuleTopic[] = [
'The table chronicle records proposals, formation, withdrawals and breaks. Changing an offer clears Nexus readiness so the table can review the updated negotiations.',
'Refresh restores saved offers and alliances. If acceptance and withdrawal arrive together, only one action is committed; refresh or reconcile a changed table before choosing again. Pending responses and decisions must finish first.',
'Allied forces, cards and spice keep their owners. Alliance funding and individual faction powers use their own controls; special alliances and expansion restrictions remain separate.',
+ 'The host may select an unfinished Basic classic group-alliance preview for three to six players. The group panel supports unanimous admission and leaving; powers and interactions remain incomplete. Expansion decks, other modules and the separate short-alliance setting cannot be combined with this preview.',
],
related: ['spice-blow', 'alliance-funding', 'table-discussion', 'privacy'],
checklist: [
- { area: 'Implementation', status: 'Partial', detail: 'Existing ordinary reciprocal offers, withdrawal and break preserve readiness and configured alliance restrictions. Staged Basic and bare-classic Advanced group Nexus actions add unanimous admission and leave; the option is not yet available in the lobby and broader group mechanics remain unfinished.', evidence: ['game/nexus-alliance.ts', 'game/coalition-alliance.ts', 'game/engine.ts'] },
- { area: 'Player controls', status: 'Partial', detail: 'Named outgoing offers have live withdrawal. The group panel is connected in staged games only; no lobby enables group play yet.', evidence: ['components/game-table.tsx', 'components/coalition-nexus.tsx'] },
+ { area: 'Implementation', status: 'Partial', detail: 'Ordinary reciprocal offers, withdrawal and break preserve readiness and configured alliance restrictions. The Basic classic group preview adds unanimous admission and leave; broader group mechanics remain unfinished.', evidence: ['game/nexus-alliance.ts', 'game/coalition-alliance.ts', 'game/engine.ts'] },
+ { area: 'Player controls', status: 'Partial', detail: 'Named outgoing offers have live withdrawal. The host can select an explicitly incomplete Basic group preview; group controls appear during a supported Nexus.', evidence: ['components/game-table.tsx', 'components/coalition-nexus.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'All four existing profiles can continue legally after withdrawal and answer staged group invitations. Full diplomacy and difficulty tuning remain deferred.' },
{ area: 'Documentation', status: 'Implemented', detail: 'Offer versus alliance, incoming invitations, readiness, refresh and concurrent choices are explained.', evidence: ['docs/NEXUS_OFFERS.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Full-table rendering, JSON continuation, pending-window rejection and SQLite acceptance/withdrawal races are covered. Staged group cases exercise actual proposals, answers, leaving, table rendering and legal bot responses. All-configuration and full visual acceptance remain open.', evidence: ['tests/coalition-nexus-engine.test.ts', 'tests/nexus-offer-controls.test.tsx', 'tests/nexus-offer-recovery.test.ts', 'tests/nexus-alliance.test.ts'] },
diff --git a/tests/coalition-preview-controls.test.tsx b/tests/coalition-preview-controls.test.tsx
new file mode 100644
index 00000000..0dfb93e1
--- /dev/null
+++ b/tests/coalition-preview-controls.test.tsx
@@ -0,0 +1,63 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { registerHooks } from 'node:module';
+import { readFileSync } from 'node:fs';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game, type GameView } from '../game/engine';
+
+const aliases = registerHooks({
+ resolve(specifier, context, next) {
+ return next(specifier === 'next/image' ? 'vinext/shims/image' :
+ specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
+ },
+ load(url, context, next) {
+ if (!url.endsWith('.module.css')) return next(url, context);
+ const css = readFileSync(new URL(url), 'utf8');
+ const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
+ return { format:'module', source:`export default ${JSON.stringify(classes)}`, shortCircuit:true };
+ },
+});
+const { GameTable } = await import('../components/game-table');
+aliases.deregister();
+
+function lobby(): Game {
+ const game = createGame('GROUPPREVIEWUI',newPlayer('at','Atreides','atreides'));
+ joinGame(game,newPlayer('em','Emperor','emperor'));
+ joinGame(game,newPlayer('gu','Guild','guild'));
+ return game;
+}
+const table = (game: GameView, busy = false) => renderToStaticMarkup(createElement(GameTable,
+ {game,busy,send:async () => {},onExit() {}}));
+function groupLabel(html: string): string {
+ const label = html.match(/]*>(?:(?!<\/label>)[\s\S])*Group alliances · unfinished preview(?:(?!<\/label>)[\s\S])*<\/label>/)?.[0];
+ assert.ok(label, 'The group-alliance preview selector is visible.');
+ return label;
+}
+
+void test('host sees each group variant and guests cannot change the selector', () => {
+ const game = lobby(), host = groupLabel(table(viewGame(game,'at')));
+ assert.match(host,/value="off"/);
+ assert.match(host,/value="three"/);
+ assert.match(host,/value="three-short"/);
+ assert.match(host,/value="unlimited"/);
+ assert.match(host,/Up to three members/);
+ assert.match(host,/Unlimited members/);
+ assert.doesNotMatch(host,/]*disabled/);
+ assert.match(groupLabel(table(viewGame(game,'em'))),/]*disabled/);
+ assert.match(groupLabel(table(viewGame(game,'at'),true)),/]*disabled/);
+});
+
+void test('selected preview warns in the lobby and through real setup', () => {
+ let game = applyAction(lobby(),'at',{type:'coalitionPreview',variant:'three-short'});
+ const lobbyHtml = table(viewGame(game,'at'));
+ assert.match(lobbyHtml,/This incomplete preview needs three to six classic factions/);
+ assert.match(lobbyHtml,/Some powers and interactions remain unfinished/);
+ for (const seat of game.players) game = applyAction(game,seat.id,{type:'ready'});
+ game = applyAction(game,'at',{type:'start'});
+ for (const actor of ['at','em','gu']) {
+ const html = table(viewGame(game,actor));
+ assert.match(html,/Group alliances are an unfinished preview/);
+ assert.match(html,/Some faction powers and interactions are incomplete/);
+ }
+});
diff --git a/tests/coalition-preview-lobby.test.ts b/tests/coalition-preview-lobby.test.ts
new file mode 100644
index 00000000..88414d4c
--- /dev/null
+++ b/tests/coalition-preview-lobby.test.ts
@@ -0,0 +1,125 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { botActions } from '../game/bots';
+import { DIFFICULTIES } from '../game/bot-profiles';
+import { coalitionOf } from '../game/coalition-alliance';
+import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
+import { advanceShortAlliance, shortAllianceLobby, type ShortAllianceStep } from './fixture-short-alliance-victory';
+
+const apply = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
+function lobby(): Game {
+ const game = shortAllianceLobby();
+ joinGame(game, newPlayer('ha', 'Harkonnen', 'harkonnen'));
+ return game;
+}
+function ready(game: Game): Game {
+ for (const player of game.players) if (!player.ready) game = apply(game, player.id, {type:'ready'});
+ return game;
+}
+function answer(game: Game, actor: string): Game {
+ const proposal = game.coalitionState?.proposal; assert.ok(proposal);
+ return apply(game, actor, {type:'coalitionAnswer', event:proposal.event, nexus:proposal.nexus,
+ turn:proposal.turn, accept:true, expectedRevision:game.coalitionState!.revision});
+}
+
+void test('host selects a group preview, clearing readiness, and can restore ordinary rules', () => {
+ let game = ready(lobby());
+ assert.equal(viewGame(game, 'at').coalitionPreview, 'off');
+ const original = structuredClone(game);
+ assert.throws(() => apply(game,'em',{type:'coalitionPreview',variant:'three'}));
+ assert.deepEqual(game, original);
+ assert.throws(() => apply(game,'at',{type:'coalitionPreview',variant:'bad'}));
+ assert.throws(() => apply(game,'at',{type:'coalitionPreview',variant:'three', extra:true}));
+ game = apply(game,'at',{type:'coalitionPreview',variant:'three'});
+ assert.equal(viewGame(game,'em').coalitionPreview,'three');
+ assert.ok(game.players.every(player => !player.ready));
+ game = apply(ready(game),'at',{type:'coalitionPreview',variant:'off'});
+ assert.equal(viewGame(game,'em').coalitionPreview,'off');
+ assert.ok(game.players.every(player => !player.ready));
+ game = apply(ready(game),'at',{type:'start'});
+ assert.equal(game.coalitionState, undefined);
+});
+
+void test('each selected Basic group variant starts a genuine game with the intended fresh ledger', () => {
+ for (const variant of ['three','three-short','unlimited'] as const) {
+ let game = apply(lobby(),'at',{type:'coalitionPreview',variant});
+ game = apply(ready(game),'at',{type:'start'});
+ assert.ok(game.coalitionState);
+ assert.equal(game.coalitionState.variant, variant === 'three' ? undefined : variant);
+ assert.deepEqual(game.coalitionState.groups, []);
+ assert.equal(game.coalitionState.proposal, null);
+ assert.equal(viewGame(game,'em').coalitionPreview,variant);
+ }
+});
+
+void test('group preview refuses incompatible rules and optional alliance settings at start', () => {
+ let advanced = apply(lobby(),'at',{type:'coalitionPreview',variant:'three'});
+ advanced = apply(advanced,'at',{type:'rules',advanced:true});
+ advanced = ready(advanced);
+ assert.throws(() => apply(advanced,'at',{type:'start',advancedPreview:true}), /group|alliance/i);
+ let shortened = apply(lobby(),'at',{type:'coalitionPreview',variant:'three'});
+ shortened = apply(shortened,'at',{type:'shortAllianceVictory',enabled:true});
+ shortened = ready(shortened);
+ assert.throws(() => apply(shortened,'at',{type:'start'}), /group|alliance/i);
+ for (const action of [{type:'homeworlds',enabled:true}, {type:'techTokens',enabled:true},
+ {type:'moduleOption',module:'leaderSkills',enabled:true}] as const) {
+ let configured = apply(lobby(),'at',{type:'coalitionPreview',variant:'three'});
+ configured = apply(configured,'at',action);
+ configured = ready(configured);
+ assert.throws(() => apply(configured,'at',{type:'start'}), /group|alliance/i);
+ }
+ let expansion = createGame('GROUPEXP',newPlayer('at','Atreides','atreides'),false,['ix']);
+ joinGame(expansion,newPlayer('em','Emperor','emperor'));
+ joinGame(expansion,newPlayer('gu','Guild','guild'));
+ expansion = apply(expansion,'at',{type:'coalitionPreview',variant:'three'});
+ expansion = ready(expansion);
+ assert.throws(() => apply(expansion,'at',{type:'start'}), /group|alliance/i);
+ let twoSeats = createGame('GROUP2',newPlayer('at','Atreides','atreides'));
+ joinGame(twoSeats,newPlayer('em','Emperor','emperor'));
+ twoSeats = apply(twoSeats,'at',{type:'coalitionPreview',variant:'three'});
+ twoSeats = ready(twoSeats);
+ assert.throws(() => apply(twoSeats,'at',{type:'start'}), /three|3|group/i);
+});
+
+/** Real deal and setup, native first-turn actions, and actual Nexus negotiation.
+ * Only an unopened original worm card is moved to the top of the Spice deck. */
+function groupNexus(): Game {
+ let game = apply(lobby(),'at',{type:'coalitionPreview',variant:'three-short'});
+ game = apply(ready(game),'at',{type:'start'});
+ const actions: ShortAllianceStep[] = [];
+ game = advanceShortAlliance(game, state => state.status === 'playing' && state.turn === 2 && state.phase === 0, actions);
+ const worm = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
+ assert.ok(worm >= 0);
+ game.spiceDeck.unshift(game.spiceDeck.splice(worm, 1)[0]);
+ game = advanceShortAlliance(game, state => state.nexus && !state.spiceWindow && !state.spiceResolution &&
+ !state.response && !state.decision && !state.truthtrance && !state.phaseOpening, actions);
+ game = apply(game,'at',{type:'alliance',target:'em'});
+ return apply(game,'em',{type:'alliance',target:'at'});
+}
+
+void test('an actual started preview reaches group formation, unanimous consent and legal bot proposals', () => {
+ const original = groupNexus();
+ const pair = coalitionOf(original.players,original.coalitionState,'at'); assert.ok(pair);
+ assert.deepEqual(new Set(pair.members),new Set(['at','em']));
+ let formed: Game | undefined;
+ for (const difficulty of DIFFICULTIES) {
+ const view = viewGame(original,'gu'); view.players.find(player => player.id === 'gu')!.bot = difficulty;
+ const proposal = botActions(view).find(action => action.type === 'coalitionPropose'); assert.ok(proposal,difficulty);
+ let game = apply(original,'gu',proposal);
+ game = answer(game,'at'); game = answer(game,'em');
+ assert.deepEqual(new Set(coalitionOf(game.players,game.coalitionState,'at')!.members),new Set(['at','em','gu']));
+ assert.equal(game.coalitionState!.proposal,null);
+ const progress = viewGame(game,'at').victoryProgress.find(row => row.player === 'at'); assert.ok(progress);
+ assert.equal(progress.target,3);
+ formed = game;
+ }
+ assert.ok(formed);
+ // Once all original seats finish Nexus, use native controls for the rest of
+ // the turn. The next Storm opens before another Nexus can change alliances.
+ for (const player of formed.players)
+ if (formed.nexus && !formed.ready.includes(player.id)) formed = apply(formed,player.id,{type:'ready'});
+ const continued = advanceShortAlliance(formed,
+ state => state.status === 'playing' && state.turn === 3 && state.phase === 0, []);
+ assert.equal(continued.turn,3);
+ assert.deepEqual(new Set(coalitionOf(continued.players,continued.coalitionState,'at')!.members),new Set(['at','em','gu']));
+});