Add Basic group alliance preview to lobby
Verify and publish checkpoint container / container (push) Successful in 10m49s

This commit is contained in:
Quintine committed 2026-10-29 03:55:42 +11:00
1 parent 44e7cf8001
commit 89386af95b
7 files changed
+273 -10

No files matched your search

+30 -6
View File
@@ -752,7 +752,7 @@ import {
} from './advisors';
import { isAlliedShipmentVisit, makeBasicAlliedShipment, quoteBasicAlliedShipment, basicAlliedShipmentHasWitness,
type BasicAlliedShipment } from './allied-separation';
import { answerCoalitionAdmission, beginCoalitionAdmission, coalitionAllies, coalitionGroups, coalitionOf,
import { answerCoalitionAdmission, beginCoalitionAdmission, coalitionAllies, coalitionGroups, coalitionOf, emptyCoalitionState,
leaveCoalition, CoalitionAllianceError, type CoalitionState, type CoalitionTransition } from './coalition-alliance';
import { coalitionBattleBeneficiary } from './coalition-battle-preparation';
import { createCoalitionWormProtection, currentCoalitionWormAlly, decideCoalitionWormProtection,
@@ -1448,7 +1448,9 @@ type RicheseAllyOffer = {
payer: string;
};
export type Game = {
/** Unexposed group-alliance integration state; absent in existing games. */
/** Explicit incomplete Basic group preview; absent in legacy games. */
coalitionPreview?: 'three' | 'three-short' | 'unlimited';
/** Group-alliance integration state; absent in ordinary existing games. */
coalitionState?: CoalitionState;
coalitionEscrow?: CoalitionEscrowState;
coalitionWormProtection?: CoalitionWormProtectionFrame;
@@ -5804,6 +5806,8 @@ function twoPlayerRule<T>(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<keyof Game>(['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)))
+3 -2
View File
@@ -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'] },