Connect staged Advanced classic group alliances
Verify and publish checkpoint container / container (push) Successful in 10m53s

This commit is contained in:
Quintine committed 2026-10-29 02:37:23 +11:00
1 parent ef92ddf2b9
commit 3ba9087405
22 files changed
+561 -44

No files matched your search

+4 -2
View File
@@ -3,6 +3,7 @@ import { ecazOccupancyRelation } from './ecaz-occupy';
import { presenceAt, type ForcePresence } from './force-presence';
import type { OccupancySeat } from './occupancy';
import { coalitionAllies, coalitionGroups, type CoalitionState } from './coalition-alliance';
import { FACTIONS } from './catalog';
/** Physical baseline distinguishes this turn's visitors from old co-occupation. */
export type BasicAlliedShipment = Readonly<{
@@ -40,8 +41,9 @@ function currentShipmentAllies(context: ShipmentContext, player: OccupancySeat):
}));
const groups = coalitionGroups(seats, context.coalitionState);
if (groups.some(group => group.members.length >= 3) &&
(context.advanced || context.players.some(seat => seat.faction === 'ecaz')))
throw new Error('Three-member shipment rules require Basic play without Ecaz.');
(context.players.some(seat => seat.faction === 'ecaz') || context.advanced &&
context.players.some(seat => !FACTIONS.some(faction => faction.id === seat.faction && faction.expansion === 'base'))))
throw new Error('Larger alliance shipment rules require a supported roster without Ecaz.');
return coalitionAllies(seats, context.coalitionState, player.id);
}
+8 -6
View File
@@ -1,4 +1,5 @@
import type { Player } from './engine';
import { FACTIONS } from './catalog';
import { advisorStanceAfterSettlement } from './advisor-automatic-flip';
import type { ForcePresence } from './force-presence';
import { presenceAt, presenceByLocation } from './force-presence';
@@ -29,7 +30,7 @@ export type BoardSeat = Pick<Player, 'id' | 'faction' | 'ally' | 'allySinceTurn'
ForcePresence;
export type BoardContext = MobileBoard & {
advanced: boolean;
/** Fresh Basic coalition protocol; existing played Basic tables are not retrofitted. */
/** Existing pair games retain their original protocol. */
ecazOccupyPreview?: boolean;
storm: number;
order: readonly string[];
@@ -37,13 +38,14 @@ export type BoardContext = MobileBoard & {
coalitionState?: CoalitionState;
};
/** Three-member board rules are provisionally restricted to Basic without Ecaz. */
/** Advanced larger groups are currently scoped to the classic roster. */
function validateBoardCoalitions(g: BoardContext) {
if (g.coalitionState === undefined) return;
try {
for (const group of coalitionGroups(g.players, g.coalitionState))
if (group.members.length >= 3 && (g.advanced || g.players.some(player => player.faction === 'ecaz')))
throw new Error('Three-member board rules require Basic play without Ecaz.');
if (group.members.length >= 3 && (g.players.some(player => player.faction === 'ecaz') ||
g.advanced && g.players.some(player => !FACTIONS.some(faction => faction.id === player.faction && faction.expansion === 'base'))))
throw new Error('Larger alliance board rules require a supported roster without Ecaz.');
} catch (error) {
throw new BoardResolutionError((error as Error).message);
}
@@ -175,8 +177,8 @@ export function quoteBattleBoard(g: BoardContext) {
return { released, battles };
}
/** Union the per-edge Basic separation obligations without inventing an age for
* an older pair when a third player joins. No forces are removed by this quote. */
/** Union per-edge separation obligations without inventing an age for an older
* pair when a third player joins. Advanced applies at each player's turn end. */
export function quoteCoalitionAlliedSeparation(
g: BoardContext & { turn: number; remaining: readonly string[] },
player: string,
+3 -3
View File
@@ -20,8 +20,8 @@ export type CoalitionBattlePreparationContext = {
const classicFactions = new Set(['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen']);
/** Select the owner or a single allied combatant for native Voice or
* Prescience. The unexposed group extension is limited to the same bare Basic
* classic profile as its other faction powers. Ambiguous group battles fail
* Prescience. The unexposed group extension supports bare Basic and Advanced
* classic profiles. Ambiguous group battles fail
* closed. `requireMutualPair` preserves the stricter legacy Truthtrance quote;
* normal battle preparation historically accepted the owner's scalar ally. */
export function coalitionBattleBeneficiary(
@@ -36,7 +36,7 @@ export function coalitionBattleBeneficiary(
if (!owner || !game.players.some(player => player.id === attacker) ||
!game.players.some(player => player.id === defender)) return null;
const combatants = [attacker, defender];
if (game.coalitionState && game.status === 'playing' && game.advanced === false &&
if (game.coalitionState && game.status === 'playing' && typeof game.advanced === 'boolean' &&
Array.isArray(game.expansions) && game.expansions.length === 0 &&
!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.ecazTreachery && !game.discoveryEnabled &&
+2 -2
View File
@@ -35,10 +35,10 @@ const classic = new Set(['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild
const validTerritory = (value: string) => TERRITORIES.some(row => row.id === value &&
(row.type === 'sand' || SPICE_CARDS.some(([territory]) => territory === value)));
/** Only the existing unexposed bare Basic classic group profile is supported.
/** Only the unexposed bare Basic/Advanced classic group profile is supported.
* Return null for all legacy pair games and other profiles. */
function threatened(g: Game, owner: string, territory: string): string[] | null {
if (!g.coalitionState || g.status !== 'playing' || g.phase !== 1 || g.advanced ||
if (!g.coalitionState || g.status !== 'playing' || g.phase !== 1 ||
g.expansions.length || g.homeworlds || g.nexusCards || g.leaderSkills || g.techTokens ||
g.strongholdCards || g.ecazTreachery || g.discoveryEnabled ||
!g.players.every(player => classic.has(player.faction))) return null;
+6 -5
View File
@@ -16200,7 +16200,7 @@ function unsettledPromises(records: readonly {fulfilled?: boolean; released?: bo
* mechanics are integrated. This narrow profile keeps existing pair games
* and unrelated commitments on their original paths. */
function coalitionNexusAvailable(g: Game): boolean {
return g.coalitionState !== undefined && g.status === 'playing' && !g.advanced &&
return g.coalitionState !== undefined && g.status === 'playing' &&
g.phase === 1 && !!g.nexus && !g.spiceWindow && !g.spiceResolution &&
!g.response && !g.decision && !g.truthtrance && !g.phaseOpening &&
!g.pendingKarama && !g.pendingTreacheryDiscard && !g.pendingKull &&
@@ -16306,13 +16306,14 @@ function applyCoalitionTransition(g: Game, result: CoalitionTransition) {
}
syncCoalitionEscrow(g, before);
}
/** Staged group-funding scope: Basic physical shipments and ordinary bids;
/** Staged classic group-funding scope: physical shipments and ordinary bids;
* optional modules and special transactions remain separate. */
function coalitionFundingAvailable(g: Game, p: Player, committedBid = false): boolean {
if (!g.coalitionEscrow || !g.coalitionState || g.status !== 'playing' || g.advanced || ![3, 5].includes(g.phase) ||
if (!g.coalitionEscrow || !g.coalitionState || g.status !== 'playing' || ![3, 5].includes(g.phase) ||
g.expansions.length || g.homeworlds || g.nexusCards || g.leaderSkills || g.techTokens ||
g.strongholdCards || g.ecazTreachery || g.discoveryEnabled ||
g.spiceBankerIncomePreview || g.kullPreview || g.richeseAuction || g.ixAuction) return false;
g.spiceBankerIncomePreview || g.kullPreview || g.richeseAuction || g.ixAuction ||
!g.players.every(seat => ['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(seat.faction))) return false;
const group = coalitionRule(() => coalitionOf(g.players, g.coalitionState, p.id));
if (!group || group.members.length < 3) return false;
const groupHasUnsettled = (records: readonly {player: string; fulfilled?: boolean; released?: boolean}[] | undefined) =>
@@ -16322,7 +16323,7 @@ function coalitionFundingAvailable(g: Game, p: Player, committedBid = false): bo
g.bribePromises, g.bribeExpressionPromises].some(groupHasUnsettled);
}
function coalitionEmperorGroup(g: Game, p: Player) {
if (!g.coalitionState || p.faction !== 'emperor' || g.status !== 'playing' || g.advanced ||
if (!g.coalitionState || p.faction !== 'emperor' || g.status !== 'playing' ||
g.expansions.length || g.homeworlds || g.nexusCards || g.leaderSkills || g.techTokens ||
g.strongholdCards || g.ecazTreachery || g.discoveryEnabled ||
!g.players.every(seat => ['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(seat.faction))) return null;
+2 -2
View File
@@ -25,10 +25,10 @@ const id = (value: unknown): value is string =>
const count = (value: unknown): value is number =>
Number.isSafeInteger(value) && (value as number) >= 0;
/** Match the unexposed Basic classic group profile; other profiles retain the
/** Match the unexposed Basic/Advanced classic group profile; other profiles retain the
* existing mutual-pair path until their interactions are connected. */
function groupRecipients(g: FremenRevivalGrantContext, owner: string): string[] | null {
if (!g.coalitionState || g.status !== 'playing' || g.advanced !== false ||
if (!g.coalitionState || g.status !== 'playing' || typeof g.advanced !== 'boolean' ||
!Array.isArray(g.expansions) || g.expansions.length || g.homeworlds || g.nexusCards ||
g.leaderSkills || g.techTokens || g.strongholdCards || g.ecazTreachery || g.discoveryEnabled ||
!g.players.every(player => ['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(player.faction)))
+1 -1
View File
@@ -2700,7 +2700,7 @@ export const RULE_TOPICS: RuleTopic[] = [
],
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 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: '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: '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'] },
+6 -5
View File
@@ -12,6 +12,7 @@ import { isDiscoveryLocationId, JACURUTU_SIETCH } from './discoveries';
import type { TwoPlayerControllers } from './two-player-controllers';
import { allianceVictoryTarget } from './alliance-victory-option';
import { coalitionGroups } from './coalition-alliance';
import { FACTIONS } from './catalog';
export type StrongholdProgress = {
player: string;
@@ -38,9 +39,8 @@ function requireProgress(
if (!condition) throw new VictoryProgressError(message);
}
/** Ordered side membership for the bounded coalition victory quote. Three-member
* Advanced and Ecaz combinations remain unavailable until their board rules
* and faction interactions are integrated. No game mode is enabled here. */
/** Ordered side membership for the bounded coalition victory quote. Advanced
* larger groups are scoped to classic rosters; no game mode is enabled here. */
export function victoryMembers(g: BoardContext, player: string): string[] {
const seat = g.players.find(candidate => candidate.id === player);
requireProgress(seat, 'Victory needs a seated player.');
@@ -48,8 +48,9 @@ export function victoryMembers(g: BoardContext, player: string): string[] {
return g.order.filter(member => member === player || member === seat.ally);
const groups = coalitionGroups(g.players, g.coalitionState);
requireProgress(!groups.some(group => group.members.length >= 3 &&
(g.advanced || g.players.some(candidate => candidate.faction === 'ecaz'))),
'Three-member victory rules require Basic play without Ecaz.');
(g.players.some(candidate => candidate.faction === 'ecaz') || g.advanced &&
g.players.some(candidate => !FACTIONS.some(faction => faction.id === candidate.faction && faction.expansion === 'base')))),
'Larger alliance victory rules require a supported roster without Ecaz.');
const group = groups.find(candidate => candidate.members.includes(player));
return g.order.filter(member => member === player || !!group?.members.includes(member));
}