Add optional short alliance victory
Verify and publish checkpoint container / container (push) Successful in 11m2s

This commit is contained in:
Quintine committed 2026-10-28 21:40:24 +11:00
1 parent 8fb9f59106
commit e896570f07
25 files changed
+632 -27

No files matched your search

+4
View File
@@ -137,6 +137,7 @@ export function freshAdminLobby(game: Game): boolean {
game.nexusCards != null ||
game.leaderSkills != null ||
game.ecazTreachery != null ||
(game.shortAllianceVictory !== undefined && typeof game.shortAllianceVictory !== 'boolean') ||
(game.discoveryEnabled !== undefined && game.discoveryEnabled !== false)
)
return false;
@@ -159,6 +160,7 @@ export function freshAdminLobby(game: Game): boolean {
'leaderSkills',
'ecazTreachery',
'discoveryEnabled',
'shortAllianceVictory',
]);
for (const key of new Set([...Object.keys(game), ...Object.keys(expected)]))
if (
@@ -196,6 +198,7 @@ export function projectAdminLobby(
advanced: false,
techTokens: false,
strongholdCards: false,
shortAllianceVictory: false,
players: [],
control,
};
@@ -228,6 +231,7 @@ export function projectAdminLobby(
advanced: game.advanced === true,
techTokens: !!game.techTokens,
strongholdCards: !!game.strongholdCards,
shortAllianceVictory: game.shortAllianceVictory === true,
host: game.players.some((player) => player.id === game.host)
? game.host
: null,
+22
View File
@@ -0,0 +1,22 @@
import {
controllerStrongholdTarget,
physicalControllerCount,
type TwoPlayerControllers,
} from './two-player-controllers';
/** GF9's optional two-member alliance / three-stronghold variant. The
* two-person four-stronghold rule still applies, as do ordinary solo targets.
* Callers add any separately agreed increase after this base target. */
export function allianceVictoryTarget(
seats: readonly string[],
controllers: TwoPlayerControllers | null | undefined,
allied: boolean,
shortAlliance?: boolean,
): 3 | 4 {
if (shortAlliance !== undefined && typeof shortAlliance !== 'boolean')
throw new TypeError('Choose whether the shorter alliance victory option is enabled.');
const target = controllerStrongholdTarget(seats, controllers, allied);
if (shortAlliance === true && allied && physicalControllerCount(seats, controllers) >= 3)
return 3;
return target;
}
+20 -4
View File
@@ -1437,6 +1437,8 @@ export type Game = {
factionDraft?: FactionDraft;
/** Explicit two-human/four-faction setup; absent from ordinary and legacy games. */
twoPlayerControllers?: TwoPlayerControllers;
/** Optional printed two-member alliance victory target; absent in older games. */
shortAllianceVictory?: boolean;
/** Explicit final turn after an agreed extension; absent means turn ten. */
victoryFinalTurn?: number;
/** Agreed addition to the ordinary stronghold win targets. Older games omit it. */
@@ -5749,6 +5751,8 @@ function twoPlayerRule<T>(operation: () => T): T {
/** Reject stale or ambiguous ownership before either projecting or mutating a game. */
function twoPlayerControllersIntegrity(g: Game): void {
requireRule(g.shortAllianceVictory === undefined || typeof g.shortAllianceVictory === 'boolean',
'The optional alliance victory setting must be true or false.');
if (g.twoPlayerControllers === undefined) return;
const seats = g.players.map(player => player.id);
twoPlayerRule(() => {
@@ -10689,7 +10693,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']);
'twoPlayerControllers', 'shortAllianceVictory']);
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])),
@@ -31695,7 +31699,7 @@ function futureBattleLeaderTruthAnswers(g: Game, p: Player, q: FutureBattleLeade
...(g.bidPromises ?? []), ...(g.phaseBidPromises ?? []), ...(g.richeseBidPromises ?? []),
...(g.earlyRicheseBidPromises ?? []), ...(g.alliancePromises ?? []), ...(g.allianceExpressionPromises ?? []),
];
if (q.turn !== g.turn || q.target !== p.id || g.phase !== 5 ||
if (q.turn !== g.turn || q.target !== p.id || (g.phase !== 4 && g.phase !== 5) ||
(g.futureBattleLeaderPromises ?? []).some(row => row.turn === g.turn && row.player === p.id &&
(row.status === 'waiting' || row.status === 'bound')) ||
otherPromises.some(row => row.player === p.id && row.turn === g.turn && !row.fulfilled && !row.released) ||
@@ -43681,6 +43685,17 @@ 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 === 'shortAllianceVictory') {
requireRule(id === g.host && typeof action.enabled === 'boolean' &&
Object.keys(action).every(key => key === 'type' || key === 'enabled'),
'Only the host may change the optional alliance victory target.');
if (action.enabled === !!g.shortAllianceVictory) return g;
if (action.enabled) g.shortAllianceVictory = true;
else delete g.shortAllianceVictory;
g.players.forEach(player => (player.ready = !!player.bot));
log(g, `${p.name} ${action.enabled ? 'enabled' : 'disabled'} the optional three-stronghold alliance victory. Human readiness was cleared.`);
return g;
}
if (t === 'rules') {
requireRule(id === g.host, 'Only the host can change the rules mode.');
requireRule(typeof action.advanced === 'boolean' &&
@@ -45792,10 +45807,10 @@ export function viewGame(state: Game, id: string, now?: number) {
truthFutureBattleLeaderAnswers: g.truthtrance?.stage === 'answer' &&
g.truthtrance.question?.target === id && g.truthtrance.question.kind === 'futureBattleLeader'
? futureBattleLeaderTruthAnswers(g, me, g.truthtrance.question) : null,
truthFutureBattleLeaderTargets: g.status === 'playing' && !g.advanced && g.phase === 5 &&
truthFutureBattleLeaderTargets: g.status === 'playing' && !g.advanced && (g.phase === 4 || g.phase === 5) &&
!g.expansions.length && !g.techTokens && !g.strongholdCards && !g.nexusCards && !g.homeworlds &&
!g.leaderSkills && !g.discoveryEnabled && !g.ecazTreachery && !g.battle && !g.response &&
!g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard
!g.decision && !g.phaseOpening && !g.pendingRevival && !g.pendingKarama && !g.pendingTreacheryDiscard
? g.players.filter(player => player.id !== id && !(g.futureBattleLeaderPromises ?? []).some(row =>
row.turn === g.turn && row.player === player.id && (row.status === 'waiting' || row.status === 'bound')))
.map(player => player.id) : [],
@@ -45916,6 +45931,7 @@ export function viewGame(state: Game, id: string, now?: number) {
factionDraft: g.factionDraft ?? null,
factionSwap: g.factionSwap ?? null,
twoPlayerControllers: g.twoPlayerControllers ?? null,
shortAllianceVictory: g.shortAllianceVictory === true,
controlledFactionSeats: g.twoPlayerControllers
? g.players.filter(player => g.twoPlayerControllers!.controllers.some(controller =>
controller.seats.includes(id) && controller.seats.includes(player.id)))
+9 -2
View File
@@ -3305,7 +3305,7 @@ export const RULE_TOPICS: RuleTopic[] = [
category: 'Cards', coverage: 'Partial', developmentStage: 'Prototyped',
summary: 'Combine printed Karama shipment pricing and current-lot costs, original-window Weather/Atomics/Harvester plays, native Voice/Prescience declarations, future printed Hajr/Ghola plays, current battle decisions, final Nexus membership, ordinary bids, native Revival, future payments and physical transport in one whole current-turn answer, with optional frozen facts.',
steps: [
'In a quiet Basic Shipment and Movement window, ask whether a player will use a named native leader in their first later battle this turn, if one occurs. The answer waits for an actual battle; if none occurs, it expires. This separate provisional question currently supports classic factions without optional modules.',
'In a quiet Basic Revival or Shipment and Movement window, ask whether a player will use a named native leader in their first later battle this turn, if one occurs. The answer waits for an actual battle; if none occurs, it expires. This separate provisional question currently supports classic factions without optional modules and uses only currently available leaders for its answer.',
'Choose Combine printed card plays, native battle powers and this turn’s other actions. Full AND/OR groups share sixteen logical leaves and four grouping levels, with optional current facts. This is a provisional composition of original-window card plays, native power declarations, printed Hajr/Ghola use, current battle decisions, final Nexus membership, current-lot bids, ordinary Revival, funded payments and owned-stage travel; wider action families and optional combinations remain unfinished.',
'A final-alliance clause names another current participant during this original quiet native Nexus. Existing invitations and reciprocal formation remain authoritative. Offers, temporary membership and your own Ready do not settle the clause; final actual membership is fixed only when the Nexus closes.',
'Whole No can permit a temporary paid pair and later require a break. Own Ready must preserve a completion after final membership is fixed; remaining payment or physical-action alternatives keep their later deadlines. Definite answers, including constants, uniformly reopen own Nexus readiness; later ordinary payments require confirmation again.',
@@ -4477,7 +4477,7 @@ export const RULE_TOPICS: RuleTopic[] = [
summary:
'Stronghold targets, storm-contested occupation, prediction and final-turn fallback.',
steps: [
'Victory is checked when the Mentat pause begins. A faction needs three strongholds, or four when allied or playing a two-player game. Allies combine distinct qualifying strongholds.',
'Victory is checked when the Mentat pause begins. A faction normally needs three strongholds, or four when allied or playing a two-player game. Allies combine distinct qualifying strongholds. The host may enable the printed optional three-stronghold target for two-member alliances with at least three controllers; two-controller games retain their four-stronghold target.',
'In the provisional two-person setup, each person controls two separately played factions and switches between their seats. They are not automatically allied and do not pool holdings unless they form an alliance. Each side needs four strongholds. Ordinary two-seat games also use four.',
'Ecaz and its reciprocal ally can also win by jointly occupying three strongholds: both factions need non-advisor presence in each, without opposing fighters. The ordinary four-stronghold alliance route remains available. Occupy cancellation does not remove this victory condition. A concealed No-Field supplies public presence; its secret value is not revealed by this check.',
'The separately marked fresh native Ecaz Homeworld preview has another printed route: native Ecaz must currently have at least seven reserves, the reciprocal alliance must jointly hold one actual uncontested stronghold, and it must occupy worlds of two other native factions. Two Emperor worlds count as one faction. This joins normal winners before a correct BG prediction or final-turn fallback; it adds no ordinary stronghold points.',
@@ -4491,6 +4491,13 @@ export const RULE_TOPICS: RuleTopic[] = [
],
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 alliance target shares the progress and actual victory calculation; default and two-controller targets remain unchanged.', evidence: ['game/alliance-victory-option.ts', 'game/victory-progress.ts', 'game/victory-quote.ts', 'game/engine.ts'] },
{ area: 'Player controls', status: 'Partial', detail: 'The lobby offers the option and the active table displays its scope.', evidence: ['components/game-table.tsx', 'tests/short-alliance-victory-controls.test.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Existing legal alliance and victory paths use the same shared target; 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 target, lobby, native Nexus and staged Mentat evidence; larger alliances and full combinations remain open.', evidence: ['tests/alliance-victory-option.test.ts', 'tests/short-alliance-victory-runtime.test.ts'] },
],
related: [
'mentat',
'tech-tokens',
+3 -3
View File
@@ -602,11 +602,11 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
check(Object.keys(v).sort().join(',') === 'kind,leader,target,turn' &&
v.turn === g.turn && typeof v.leader === 'string' &&
leaders(target.faction).some(leader => leader.id === v.leader) &&
g.status === 'playing' && !g.advanced && g.phase === 5 && !g.expansions.length &&
g.status === 'playing' && !g.advanced && (g.phase === 4 || g.phase === 5) && !g.expansions.length &&
!g.techTokens && !g.strongholdCards && !g.nexusCards && !g.homeworlds &&
!g.leaderSkills && !g.discoveryEnabled && !g.ecazTreachery && !g.battle &&
!g.response && !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard,
'Choose a native leader and a quiet Basic turn before Battle.');
!g.response && !g.decision && !g.phaseOpening && !g.pendingRevival && !g.pendingKarama && !g.pendingTreacheryDiscard,
'Choose a native leader in a quiet Basic Revival or Shipment and Movement window.');
return { kind: 'futureBattleLeader', target: v.target, turn: g.turn, leader: v.leader };
}
if (v.kind === 'battlePlan') {
+5 -3
View File
@@ -9,7 +9,8 @@ import { gameTerritories } from './board';
import { fighterCount } from './advisors';
import { TECH_TOKENS } from './tech-tokens';
import { isDiscoveryLocationId, JACURUTU_SIETCH } from './discoveries';
import { controllerStrongholdTarget, type TwoPlayerControllers } from './two-player-controllers';
import type { TwoPlayerControllers } from './two-player-controllers';
import { allianceVictoryTarget } from './alliance-victory-option';
export type StrongholdProgress = {
player: string;
@@ -49,6 +50,7 @@ export function strongholdProgress(
g: BoardContext & Pick<Game, 'techTokens'> & {
victoryExtraStrongholds?: number;
twoPlayerControllers?: TwoPlayerControllers;
shortAllianceVictory?: boolean;
},
): { released: AdvisorRelease[]; progress: StrongholdProgress[] } {
try {
@@ -133,8 +135,8 @@ export function strongholdProgress(
members.some((member) =>
TECH_TOKENS.every(({ id }) => tech[id].owner === member),
);
const target = controllerStrongholdTarget(
players.map((player) => player.id), g.twoPlayerControllers, !!partner,
const target = allianceVictoryTarget(
players.map((player) => player.id), g.twoPlayerControllers, !!partner, g.shortAllianceVictory,
) + extra;
return {
player: id,
+1 -1
View File
@@ -40,7 +40,7 @@ const record = (value: unknown): value is Record<string, unknown> =>
export type VictoryContext = Omit<BoardContext, 'players'> &
Pick<
Game,
'turn' | 'phase' | 'status' | 'winner' | 'techTokens' | 'strongholdCards' | 'victoryExtraStrongholds' | 'victoryFinalTurn'
'turn' | 'phase' | 'status' | 'winner' | 'techTokens' | 'strongholdCards' | 'victoryExtraStrongholds' | 'victoryFinalTurn' | 'shortAllianceVictory'
> & {
players: readonly (BoardSeat & Pick<Player, 'prediction'>)[];
twoPlayerControllers?: TwoPlayerControllers;