Connect staged Advanced classic group alliances
Verify and publish checkpoint container / container (push) Successful in 10m53s
Verify and publish checkpoint container / container (push) Successful in 10m53s
This commit is contained in:
1 parent
ef92ddf2b9
commit
3ba9087405
22 files changed
+561
-44
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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'] },
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user