From d8d3cfd26c037ea0da2883beecfc1d3b1d78c2ee Mon Sep 17 00:00:00 2001
From: Quintine <15965533+Quintine@users.noreply.github.com>
Date: Thu, 29 Oct 2026 04:00:19 +1100
Subject: [PATCH] Enable Advanced classic group preview
---
components/game-table.tsx | 2 +-
docs/CURRENT_STATUS.md | 12 ++-
docs/OPTIONAL_ALLIANCE_VICTORY.md | 29 +++++--
game/engine.ts | 6 +-
game/reference.ts | 16 ++--
.../coalition-advanced-preview-lobby.test.ts | 82 +++++++++++++++++++
tests/coalition-preview-controls.test.tsx | 9 ++
tests/coalition-preview-lobby.test.ts | 2 +-
8 files changed, 138 insertions(+), 20 deletions(-)
create mode 100644 tests/coalition-advanced-preview-lobby.test.ts
diff --git a/components/game-table.tsx b/components/game-table.tsx
index 3e623dc1..3b667d59 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -2293,7 +2293,7 @@ export function GameTable({
{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.
+ This incomplete preview needs three to six classic factions, without expansion decks, other optional modules, two-person setup or the separate short-alliance setting. Advanced rules require their separate preview start. Some powers and interactions remain unfinished. Changing the selection clears human readiness.
}
0 || g.players.length < 3 ||
+ (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.';
+ return 'The group-alliance preview needs at least three classic factions 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))
diff --git a/game/reference.ts b/game/reference.ts
index ad253d2e..54a64411 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -298,7 +298,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'The recipient keeps paid spice separately until the next Mentat opening. A payment after the current opening waits for next turn’s opening. Your incoming bribes are shown separately from your spendable supply; they cannot fund bids, shipments or another bribe yet.',
'You may pay out of turn when no decision or response is pending, including during Mentat after opening collection. You cannot bribe yourself or your ally, or while Inflation shows Double. Emperor gifts to allies use their own power controls.',
'Your payment limit excludes reserved spending and preserves binding Truthtrance answers. If an alliance, pending decision or available supply changes, recheck the recipient and amount. A rejected payment does not change the game.',
- 'In the unexposed Basic group prototype, both group allies are excluded from ordinary bribes and nonallied deals. Existing agreements remain recorded and can be released; a deferred bribe to a newly allied recipient stays pending while it is unavailable, subject to its original deadline.',
+ 'In the Basic group preview, group allies are excluded from ordinary bribes and nonallied deals. Existing agreements remain recorded and can be released; a deferred bribe to a newly allied recipient stays pending while it is unavailable, subject to its original deadline.',
'A qualifying Bureaucrat may divert two spice to the Bank without reducing the payer’s cost. The remainder stays deferred. Unsupported combinations remain unavailable and do not imply an official rule prohibition.',
'In Public deals, propose written terms to one or more human seats. Every pair of participants must be nonallied when the offer is made and accepted, a provisional interpretation. Every recipient must accept; any recipient can decline, and the proposer can withdraw the outstanding offer. Any party may request release; all must agree. The record remains active until mutually released, even when players consider it fulfilled.',
'A deal may include one immediate spice payment between two participants. The last acceptance invokes the normal bribe action: the payer loses the gross amount immediately, Bureaucrat may divert two, and the recipient collects the remaining share at the next Mentat opening. Nothing is reserved before acceptance, and an unavailable payment leaves the offer open.',
@@ -2697,12 +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.',
+ 'The host may select an unfinished classic group-alliance preview for three to six players under Basic or explicitly selected Advanced preview rules. 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: '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: 'Implementation', status: 'Partial', detail: 'Ordinary reciprocal offers, withdrawal and break preserve readiness and configured alliance restrictions. The classic group preview adds unanimous admission and leave in Basic or Advanced; 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 classic 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'] },
@@ -4499,14 +4499,14 @@ export const RULE_TOPICS: RuleTopic[] = [
'On the final turn, normally turn ten, a normal stronghold victory comes first. Otherwise, check Fremen’s special condition, then award the game to Guild and its ally if present. If Guild is absent, Fremen and its ally win even when the usual Fremen special condition fails. An agreed extra turn provisionally moves these final-turn checks to the new final turn.',
'When neither Guild nor Fremen is playing and nobody has reached the normal target, compare individual qualifying stronghold counts. Every player tied for the highest count wins. A correct Bene Gesserit prediction can replace this stronghold win.',
'With tech tokens enabled, owning all three adds one stronghold. One player must hold all three, even in an alliance. In Advanced, when multiple unallied players qualify, the highest stronghold count wins; equal counts provisionally share. Qualifying sides sharing a win may instead unanimously agree to continue another turn and optionally increase ordinary stronghold targets. A finish vote settles the original shared result before any correct prediction replaces it. Final-turn extension and exceptional compositions use provisional timing.',
- 'Stronghold victory progress shows current territories, a complete Tech Token set, Ecaz joint occupation and Fremen final-turn conditions. Basic permits reciprocal Fremen/Ecaz cooccupation at Tabr; supplied Advanced p32 permits both protected sietches. Solitary/nonallied Ecaz still blocks. Public counts do not award victory before the proper checkpoint; Tech Tokens cannot supply joint occupation.',
+ 'Stronghold victory progress shows current territories, a complete Tech Token set, Ecaz joint occupation and Fremen final-turn conditions. Reciprocal Fremen/Ecaz cooccupation is permitted in both protected sietches; the Basic Habbanya allowance is provisional. Solitary/nonallied Ecaz still blocks. Public counts do not award victory before the proper checkpoint; Tech Tokens cannot supply joint occupation.',
'Other expansion additions to victory remain in development. Advanced preview supports selected factions and combinations; complete rules acceptance remains open.',
],
example:
'At the final turn with neither Guild nor Fremen, two players each hold two qualifying strongholds and a third holds one. The two leaders of the count share the win.',
checklist: [
- { area: 'Implementation', status: 'Partial', detail: 'A host-selectable shorter two-member target shares victory calculation. Unexposed staged Basic groups connect Nexus, selected funding including purchased Karama shipment prices, Guild transport, Emperor and Fremen aid, Fremen worm protection, Voice, Prescience and Harkonnen traitor support. Separate staged variants support up to three members at three strongholds and up to six members at three strongholds; other group interactions remain open.', evidence: ['game/alliance-victory-option.ts', 'game/victory-progress.ts', 'game/victory-quote.ts', 'game/engine.ts', 'game/coalition-worm-protection.ts', 'game/coalition-battle-preparation.ts', 'game/traitor-declarations.ts', 'game/terminal-cancellation.ts'] },
- { area: 'Player controls', status: 'Partial', detail: 'The lobby offers the pair option. A staged group Emperor can choose either ally for spice and extra revival; group games cannot yet be started from the lobby.', evidence: ['components/game-table.tsx', 'components/coalition-emperor-aid.tsx', 'tests/short-alliance-victory-controls.test.tsx'] },
+ { area: 'Implementation', status: 'Partial', detail: 'A host-selectable shorter two-member target shares victory calculation. Classic group previews connect Nexus, selected funding including purchased Karama shipment prices, Guild transport, Emperor and Fremen aid, Fremen worm protection, Voice, Prescience and Harkonnen traitor support. Variants support up to three members at three or four strongholds and up to six members at three strongholds; other group interactions remain open.', evidence: ['game/alliance-victory-option.ts', 'game/victory-progress.ts', 'game/victory-quote.ts', 'game/engine.ts', 'game/coalition-worm-protection.ts', 'game/coalition-battle-preparation.ts', 'game/traitor-declarations.ts', 'game/terminal-cancellation.ts'] },
+ { area: 'Player controls', status: 'Partial', detail: 'The lobby offers the pair option and an unfinished classic group preview in Basic or Advanced. A group Emperor can choose an ally for spice and extra revival.', evidence: ['components/game-table.tsx', 'components/coalition-emperor-aid.tsx', 'tests/short-alliance-victory-controls.test.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Legal alliance and victory paths use the shared target; staged group Emperor bots can offer a targeted revival. Strategic tuning remains deferred.', evidence: ['game/bots.ts', 'game/victory-progress.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'The printed option and provisional two-controller precedence are recorded.', evidence: ['docs/OPTIONAL_ALLIANCE_VICTORY.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused pair and staged group evidence includes real Nexus actions, admission with live bribe and alliance answers including matching joint commitments, four-member unlimited admission, funding and victory, staged larger-group faction helpers, funding with outsider-only commitments, Guild transport, Emperor and Fremen aid, worm protection, battle preparation and allied traitor votes; complete group play and combinations remain open.', evidence: ['tests/coalition-bribe-admission.test.ts', 'tests/coalition-admission-deal-truth.test.ts', 'tests/coalition-admission-joint-commitments.test.ts', 'tests/coalition-admission-joint-stay.test.ts', 'tests/coalition-admission-joint-leave.test.ts', 'tests/coalition-pending-joint-leave.test.ts', 'tests/coalition-joint-leave-payment-order.test.ts', 'tests/coalition-whole-turn-leave-guard.test.ts', 'tests/coalition-alliance-admission-commitments.test.ts', 'tests/coalition-admission-bribe-controls.test.tsx', 'tests/alliance-victory-option.test.ts', 'tests/short-alliance-victory-runtime.test.ts', 'tests/coalition-unlimited-engine.test.ts', 'tests/coalition-unlimited-powers.test.ts', 'tests/coalition-unlimited-emperor.test.ts', 'tests/coalition-truth-alliance.test.ts', 'tests/coalition-truth-alliance-controls.test.tsx', 'tests/coalition-truth-bribe-targets.test.ts', 'tests/coalition-bribe-departure.test.tsx', 'tests/coalition-bribe-alliance-guides.test.ts', 'tests/coalition-outsider-commitment-funding.test.ts', 'tests/coalition-unlimited-worm-engine.test.ts', 'tests/coalition-nexus-engine.test.ts', 'tests/coalition-shipment-funding.test.ts', 'tests/coalition-bid-funding.test.ts', 'tests/coalition-guild-shipment.test.ts', 'tests/coalition-guild-transport.test.ts', 'tests/coalition-fremen-worm.test.ts', 'tests/coalition-emperor-aid.test.ts', 'tests/coalition-fremen-revival.test.ts', 'tests/coalition-battle-preparation.test.ts', 'tests/coalition-battle-power-question.test.ts', 'tests/coalition-harkonnen-traitor.test.ts', 'tests/coalition-traitor-declarations.test.ts'] },
@@ -6545,7 +6545,7 @@ export const RULE_TOPICS: RuleTopic[] = [
"The original owner, ally and turn remain bound while another player may prevent the grant with Karama. All-pass commits once. Under the provisional prevention policy, cancellation denies this grant for the current Revival, preserving the ally’s intrinsic free rate, Recruits and completed returns/payments. The next native Revival clears the denial.",
"Recruits doubles the current granted rate including applicable bonuses before caps. With no other bonus, three becomes six. Provisionally accept grants before or after the card with the same calculation: deduct recorded free usage, preserve all earlier total returns and paid classifications, and give no refund. Finish a pending transaction before declaring a grant.",
"The Fremen panel keeps status and unavailable reasons visible. Your ally’s normal revival panel supplies the actual remaining amount. Complete modes, broader combinations and final acceptance remain unfinished.",
- "In an unexposed staged Basic three-member group, Fremen can provisionally grant each ally the rate separately; each named declaration has its own Karama response. Group games remain unavailable in the lobby."
+ "In the Basic group preview, Fremen can provisionally grant each ally the rate separately; each named declaration has its own Karama response. Group interactions remain incomplete."
],
"related": [
"revival",
diff --git a/tests/coalition-advanced-preview-lobby.test.ts b/tests/coalition-advanced-preview-lobby.test.ts
new file mode 100644
index 00000000..17e23160
--- /dev/null
+++ b/tests/coalition-advanced-preview-lobby.test.ts
@@ -0,0 +1,82 @@
+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, type ShortAllianceStep } from './fixture-short-alliance-victory';
+
+const apply = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
+function lobby(): Game {
+ const game = createGame('ADVGROUPPREVIEW',newPlayer('at','Atreides','atreides'));
+ joinGame(game,newPlayer('em','Emperor','emperor'));
+ joinGame(game,newPlayer('ha','Harkonnen','harkonnen'));
+ return game;
+}
+function prepare(variant: 'three' | 'three-short' | 'unlimited'): Game {
+ let game = apply(lobby(),'at',{type:'rules',advanced:true});
+ game = apply(game,'at',{type:'coalitionPreview',variant});
+ for (const seat of game.players) game = apply(game,seat.id,{type:'ready'});
+ assert.throws(() => apply(game,'at',{type:'start'}), /advanced|preview/i);
+ return apply(game,'at',{type:'start',advancedPreview:true});
+}
+function accept(game: Game, actor: string): Game {
+ const offer = game.coalitionState?.proposal; assert.ok(offer);
+ return apply(game,actor,{type:'coalitionAnswer',event:offer.event,nexus:offer.nexus,
+ turn:offer.turn,accept:true,expectedRevision:game.coalitionState!.revision});
+}
+
+void test('fresh Advanced classic group variants require explicit preview and retain their selected target', () => {
+ for (const variant of ['three','three-short','unlimited'] as const) {
+ const game = prepare(variant);
+ assert.equal(game.status,'setup');
+ assert.equal(game.advanced,true);
+ assert.equal(game.advancedPreview,true);
+ assert.equal(viewGame(game,'em').coalitionPreview,variant);
+ assert.ok(game.coalitionState);
+ assert.equal(game.coalitionState.variant,variant === 'three' ? undefined : variant);
+ assert.deepEqual(game.coalitionState.groups,[]);
+ assert.equal(game.coalitionState.proposal,null);
+ }
+});
+
+/** Actual Advanced setup and first turn. Only unopened original Spice cards
+ * are reordered to select a normal worm at the next Spice Blow. */
+function pairAtNexus(): Game {
+ let game = prepare('three-short');
+ 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);
+ const inventory = game.spiceDeck.map(card => JSON.stringify(card)).sort();
+ game.spiceDeck.unshift(game.spiceDeck.splice(worm,1)[0]);
+ assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(),inventory);
+ 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('a genuine Advanced preview reaches group formation, legal bots, and the next turn', () => {
+ const original = pairAtNexus();
+ 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,'ha'); view.players.find(player => player.id === 'ha')!.bot = difficulty;
+ const proposal = botActions(view).find(action => action.type === 'coalitionPropose'); assert.ok(proposal,difficulty);
+ let game = apply(original,'ha',proposal);
+ game = accept(game,'at'); game = accept(game,'em');
+ assert.deepEqual(new Set(coalitionOf(game.players,game.coalitionState,'at')!.members),new Set(['at','em','ha']));
+ 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);
+ for (const seat of formed.players)
+ if (formed.nexus && !formed.ready.includes(seat.id)) formed = apply(formed,seat.id,{type:'ready'});
+ const continued = advanceShortAlliance(formed,
+ state => state.status === 'playing' && state.turn === 3 && state.phase === 0, []);
+ assert.deepEqual(new Set(coalitionOf(continued.players,continued.coalitionState,'at')!.members),new Set(['at','em','ha']));
+});
diff --git a/tests/coalition-preview-controls.test.tsx b/tests/coalition-preview-controls.test.tsx
index 0dfb93e1..36a05511 100644
--- a/tests/coalition-preview-controls.test.tsx
+++ b/tests/coalition-preview-controls.test.tsx
@@ -61,3 +61,12 @@ void test('selected preview warns in the lobby and through real setup', () => {
assert.match(html,/Some faction powers and interactions are incomplete/);
}
});
+
+void test('Advanced group preview keeps the explicit opt-in and incompatibility warning', () => {
+ let game = applyAction(lobby(),'at',{type:'coalitionPreview',variant:'three'});
+ game = applyAction(game,'at',{type:'rules',advanced:true});
+ const html = table(viewGame(game,'at'));
+ assert.match(html,/Advanced rules require their separate preview start/);
+ assert.match(html,/without expansion decks, other optional modules/);
+ assert.match(html,/Begin Advanced preview/);
+});
diff --git a/tests/coalition-preview-lobby.test.ts b/tests/coalition-preview-lobby.test.ts
index 88414d4c..98d4d133 100644
--- a/tests/coalition-preview-lobby.test.ts
+++ b/tests/coalition-preview-lobby.test.ts
@@ -56,7 +56,7 @@ void test('group preview refuses incompatible rules and optional alliance settin
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);
+ assert.throws(() => apply(advanced,'at',{type:'start'}), /Advanced.*preview/i);
let shortened = apply(lobby(),'at',{type:'coalitionPreview',variant:'three'});
shortened = apply(shortened,'at',{type:'shortAllianceVictory',enabled:true});
shortened = ready(shortened);