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

+8 -5
View File
@@ -77,19 +77,21 @@ export function AdminLobbyControls({ accountId, code, canManage, onClose, onUpda
} }
function selectKind(next: AdminLobbyAction['type']) { function selectKind(next: AdminLobbyAction['type']) {
setKind(next); setConfirmed(false); setTarget(''); setKind(next); setConfirmed(false); setTarget('');
setEnabled(next === 'rules' ? lobby?.advanced ?? false : next === 'techTokens' ? lobby?.techTokens ?? false : lobby?.strongholdCards ?? false); setEnabled(next === 'rules' ? lobby?.advanced ?? false : next === 'techTokens' ? lobby?.techTokens ?? false :
next === 'shortAllianceVictory' ? lobby?.shortAllianceVictory ?? false : lobby?.strongholdCards ?? false);
if (next === 'addBot') setFaction(baseFactions.find(item => !lobby?.players.some(player => player.faction === item.id))?.id ?? 'atreides'); if (next === 'addBot') setFaction(baseFactions.find(item => !lobby?.players.some(player => player.faction === item.id))?.id ?? 'atreides');
if (next === 'removeBot' || next === 'configureBot') selectTarget(lobby?.players.find(player => player.bot)?.id ?? ''); if (next === 'removeBot' || next === 'configureBot') selectTarget(lobby?.players.find(player => player.bot)?.id ?? '');
if (next === 'assignHost') setTarget(lobby?.players.find(player => player.hostEligible && player.id !== lobby.host)?.id ?? ''); if (next === 'assignHost') setTarget(lobby?.players.find(player => player.hostEligible && player.id !== lobby.host)?.id ?? '');
} }
const action: AdminLobbyAction = kind === 'rules' ? { type: kind, advanced: enabled } : const action: AdminLobbyAction = kind === 'rules' ? { type: kind, advanced: enabled } :
kind === 'techTokens' || kind === 'strongholdCards' ? { type: kind, enabled } : kind === 'techTokens' || kind === 'strongholdCards' || kind === 'shortAllianceVictory' ? { type: kind, enabled } :
kind === 'addBot' ? { type: kind, faction, difficulty } : kind === 'addBot' ? { type: kind, faction, difficulty } :
kind === 'configureBot' ? { type: kind, target, faction, difficulty, position } : { type: kind, target }; kind === 'configureBot' ? { type: kind, target, faction, difficulty, position } : { type: kind, target };
const selectedPlayer = lobby?.players.find(player => player.id === target); const selectedPlayer = lobby?.players.find(player => player.id === target);
const availableFactions = baseFactions.filter(item => !lobby?.players.some(player => player.faction === item.id && (kind !== 'configureBot' || player.id !== target))); const availableFactions = baseFactions.filter(item => !lobby?.players.some(player => player.faction === item.id && (kind !== 'configureBot' || player.id !== target)));
const actionable = lobby?.editable && (kind === 'rules' ? enabled !== lobby.advanced : const actionable = lobby?.editable && (kind === 'rules' ? enabled !== lobby.advanced :
kind === 'techTokens' ? enabled !== lobby.techTokens : kind === 'strongholdCards' ? enabled !== lobby.strongholdCards && (!enabled || lobby.advanced) : kind === 'techTokens' ? enabled !== lobby.techTokens : kind === 'strongholdCards' ? enabled !== lobby.strongholdCards && (!enabled || lobby.advanced) :
kind === 'shortAllianceVictory' ? enabled !== lobby.shortAllianceVictory :
kind === 'addBot' ? lobby.players.length < 6 && availableFactions.some(item => item.id === faction) : kind === 'addBot' ? lobby.players.length < 6 && availableFactions.some(item => item.id === faction) :
kind === 'assignHost' ? selectedPlayer?.hostEligible && target !== lobby.host : kind === 'assignHost' ? selectedPlayer?.hostEligible && target !== lobby.host :
kind === 'removeBot' ? !!selectedPlayer?.bot : !!selectedPlayer?.bot && availableFactions.some(item => item.id === faction) && kind === 'removeBot' ? !!selectedPlayer?.bot : !!selectedPlayer?.bot && availableFactions.some(item => item.id === faction) &&
@@ -142,7 +144,7 @@ export function AdminLobbyControls({ accountId, code, canManage, onClose, onUpda
<div className="admin-section-heading"><h2 id="admin-lobby-title" ref={title} tabIndex={-1}>Lobby configuration · {code}</h2><Button variant="outline" disabled={busy} onClick={onClose}>Close lobby configuration</Button></div> <div className="admin-section-heading"><h2 id="admin-lobby-title" ref={title} tabIndex={-1}>Lobby configuration · {code}</h2><Button variant="outline" disabled={busy} onClick={onClose}>Close lobby configuration</Button></div>
{notice && <output className="admin-notice">{notice}</output>} {notice && <output className="admin-notice">{notice}</output>}
{loading ? <output>Loading lobby configuration…</output> : !lobby ? <Button disabled={busy} onClick={() => void refresh()}>Retry loading lobby</Button> : <> {loading ? <output>Loading lobby configuration…</output> : !lobby ? <Button disabled={busy} onClick={() => void refresh()}>Retry loading lobby</Button> : <>
<p>Current rules: <strong>{lobby.advanced ? 'Advanced preview' : 'Basic'}</strong> · Tech Tokens {lobby.techTokens ? 'on' : 'off'} · Stronghold Cards {lobby.strongholdCards ? 'on' : 'off'}.</p> <p>Current rules: <strong>{lobby.advanced ? 'Advanced preview' : 'Basic'}</strong> · Tech Tokens {lobby.techTokens ? 'on' : 'off'} · Stronghold Cards {lobby.strongholdCards ? 'on' : 'off'} · Three-stronghold alliance victory {lobby.shortAllianceVictory ? 'on' : 'off'}.</p>
<p>Advanced preview is unfinished. Players must review the warning and the host must explicitly start the preview.</p> <p>Advanced preview is unfinished. Players must review the warning and the host must explicitly start the preview.</p>
<ul className="admin-lobby-roster">{lobby.players.map(player => <li key={player.id}> <ul className="admin-lobby-roster">{lobby.players.map(player => <li key={player.id}>
<strong>{player.name}</strong>{player.id === lobby.host ? ' · Host' : ''} · {FACTIONS.find(item => item.id === player.faction)?.name ?? player.faction} · {player.bot ? `AI: ${player.bot}` : 'Human'} · Circle {player.position} · {player.ready ? 'Ready' : 'Not ready'} <strong>{player.name}</strong>{player.id === lobby.host ? ' · Host' : ''} · {FACTIONS.find(item => item.id === player.faction)?.name ?? player.faction} · {player.bot ? `AI: ${player.bot}` : 'Human'} · Circle {player.position} · {player.ready ? 'Ready' : 'Not ready'}
@@ -159,14 +161,15 @@ export function AdminLobbyControls({ accountId, code, canManage, onClose, onUpda
<Button disabled={busy} onClick={() => void submit(pending)}>Retry saved lobby request</Button> <Button disabled={busy} onClick={() => void submit(pending)}>Retry saved lobby request</Button>
</div> : lobby.editable && <form onSubmit={event => { event.preventDefault(); void submit(); }}> </div> : lobby.editable && <form onSubmit={event => { event.preventDefault(); void submit(); }}>
<label>Change<select value={kind} disabled={busy} onChange={e => selectKind(e.target.value as AdminLobbyAction['type'])}> <label>Change<select value={kind} disabled={busy} onChange={e => selectKind(e.target.value as AdminLobbyAction['type'])}>
<option value="rules">Rules mode</option><option value="techTokens">Tech Tokens</option><option value="strongholdCards">Stronghold Cards</option> <option value="rules">Rules mode</option><option value="techTokens">Tech Tokens</option><option value="strongholdCards">Stronghold Cards</option><option value="shortAllianceVictory">Three-stronghold alliance victory</option>
<option value="addBot">Add AI player</option><option value="configureBot">Configure AI player</option><option value="removeBot">Remove AI player</option><option value="assignHost">Assign host</option> <option value="addBot">Add AI player</option><option value="configureBot">Configure AI player</option><option value="removeBot">Remove AI player</option><option value="assignHost">Assign host</option>
</select></label> </select></label>
{(kind === 'rules' || kind === 'techTokens' || kind === 'strongholdCards') && <label>{kind === 'rules' ? 'Rules mode' : kind === 'techTokens' ? 'Tech Tokens' : 'Stronghold Cards'}<select value={String(enabled)} disabled={busy} onChange={e => { setEnabled(e.target.value === 'true'); setConfirmed(false); }}> {(kind === 'rules' || kind === 'techTokens' || kind === 'strongholdCards' || kind === 'shortAllianceVictory') && <label>{kind === 'rules' ? 'Rules mode' : kind === 'techTokens' ? 'Tech Tokens' : kind === 'strongholdCards' ? 'Stronghold Cards' : 'Three-stronghold alliance victory'}<select value={String(enabled)} disabled={busy} onChange={e => { setEnabled(e.target.value === 'true'); setConfirmed(false); }}>
<option value="false">{kind === 'rules' ? 'Basic' : 'Off'}</option><option value="true" disabled={kind === 'strongholdCards' && !lobby.advanced}>{kind === 'rules' ? 'Advanced preview' : 'On'}</option> <option value="false">{kind === 'rules' ? 'Basic' : 'Off'}</option><option value="true" disabled={kind === 'strongholdCards' && !lobby.advanced}>{kind === 'rules' ? 'Advanced preview' : 'On'}</option>
</select></label>} </select></label>}
{kind === 'rules' && !enabled && lobby.strongholdCards && <p>Switching to Basic also turns off Stronghold Cards.</p>} {kind === 'rules' && !enabled && lobby.strongholdCards && <p>Switching to Basic also turns off Stronghold Cards.</p>}
{kind === 'techTokens' && <p>Tech Tokens require at least three players before the game can start.</p>} {kind === 'techTokens' && <p>Tech Tokens require at least three players before the game can start.</p>}
{kind === 'shortAllianceVictory' && <p>Two-member alliances need three strongholds with at least three controllers; two-controller games retain four.</p>}
{(kind === 'configureBot' || kind === 'removeBot' || kind === 'assignHost') && <label>{kind === 'assignHost' ? 'New host' : 'AI player'}<select value={target} disabled={busy} onChange={e => selectTarget(e.target.value)}> {(kind === 'configureBot' || kind === 'removeBot' || kind === 'assignHost') && <label>{kind === 'assignHost' ? 'New host' : 'AI player'}<select value={target} disabled={busy} onChange={e => selectTarget(e.target.value)}>
<option value="">Choose a player</option>{lobby.players.filter(player => kind === 'assignHost' ? player.hostEligible && player.id !== lobby.host : player.bot).map(player => <option key={player.id} value={player.id}>{player.name}</option>)} <option value="">Choose a player</option>{lobby.players.filter(player => kind === 'assignHost' ? player.hostEligible && player.id !== lobby.host : player.bot).map(player => <option key={player.id} value={player.id}>{player.name}</option>)}
</select></label>} </select></label>}
+8
View File
@@ -2104,6 +2104,9 @@ export function GameTable({
{g.status === 'playing' && <MixedBidPromises game={g} send={act} busy={busy} />} {g.status === 'playing' && <MixedBidPromises game={g} send={act} busy={busy} />}
{g.status === 'playing' && <MixedBattlePromises game={g} />} {g.status === 'playing' && <MixedBattlePromises game={g} />}
{g.status === 'playing' && <FutureBattleLeaderPromises game={g} />} {g.status === 'playing' && <FutureBattleLeaderPromises game={g} />}
{g.status === 'playing' && g.shortAllianceVictory && <p className="notice">
Optional alliance rule: a two-member alliance wins with three strongholds when at least three people or AI controllers are playing. Two-controller games retain their four-stronghold target.
</p>}
{g.status === 'playing' && <MixedTraitorPromises game={g} />} {g.status === 'playing' && <MixedTraitorPromises game={g} />}
{g.status === 'playing' && <TravelPromises game={g} busy={busy} act={act} />} {g.status === 'playing' && <TravelPromises game={g} busy={busy} act={act} />}
{g.status === 'playing' && <TurnActionPromises game={g} busy={g.semutaReaction && {g.status === 'playing' && <TurnActionPromises game={g} busy={g.semutaReaction &&
@@ -2244,6 +2247,11 @@ export function GameTable({
g.players.some(player => player.faction === 'choam') && <p className="notice"> g.players.some(player => player.faction === 'choam') && <p className="notice">
Printed Kull is inactive in this lobby. Its preview requires a supported {g.advanced ? 'Advanced' : 'Basic'} configuration and may need different module selections. Printed Kull is inactive in this lobby. Its preview requires a supported {g.advanced ? 'Advanced' : 'Basic'} configuration and may need different module selections.
</p>} </p>}
<label className="checkbox-row">
<input type="checkbox" checked={g.shortAllianceVictory} disabled={busy || g.host !== me.id}
onChange={(e) => act({type: 'shortAllianceVictory', enabled: e.target.checked})} />
Optional: two-member alliances win with three strongholds (three or more controllers)
</label>
<label className="checkbox-row"> <label className="checkbox-row">
<input <input
type="checkbox" type="checkbox"
+2
View File
@@ -114,6 +114,7 @@ function missing(code: string, version: number): AdminLobbyView {
advanced: false, advanced: false,
techTokens: false, techTokens: false,
strongholdCards: false, strongholdCards: false,
shortAllianceVictory: false,
players: [], players: [],
control: { paused: false, joinLocked: false, revision: 0, updatedAt: null }, control: { paused: false, joinLocked: false, revision: 0, updatedAt: null },
}; };
@@ -149,6 +150,7 @@ const auditConfiguration = (view: AdminLobbyView) =>
advanced: view.advanced, advanced: view.advanced,
techTokens: view.techTokens, techTokens: view.techTokens,
strongholdCards: view.strongholdCards, strongholdCards: view.strongholdCards,
shortAllianceVictory: view.shortAllianceVictory,
players: view.players.map( players: view.players.map(
({ id, name, faction, bot, position, ready }) => ({ ({ id, name, faction, bot, position, ready }) => ({
id, id,
+18
View File
@@ -16,6 +16,24 @@ open mode gates.
## Current checkpoint and work ## Current checkpoint and work
**Optional three-stronghold two-member alliance — 28 October. Prototyped /
Partial.** The host can select the printed shorter target; changes clear human
readiness and the normal target remains the default. Public progress and the
actual Mentat outcome use the same calculation. A genuine Nexus alliance with
a staged three-stronghold board tests both selected and default outcomes;
the existing four-stronghold target remains for two physical controllers as a
provisional precedence choice. The combined batch passes 96 focused cases and
three administrator boundary HTTP cases. The administrator's existing audited
lobby controls can also show and change the setting. Larger alliances, wider interactions and deployed
acceptance remain open. [Scope](OPTIONAL_ALLIANCE_VICTORY.md).
**Conditional future-battle Truthtrance in Basic Revival — 28 October.
Prototyped / Partial.** The existing phase-five question also works in a quiet
phase-four window, using leaders available at answer time without predicting a
revival. Native Revival, subsequent real battles and no-battle expiry are
covered in the 96-case combined batch. Pending transactions and broader
promise compositions remain separately guarded.
**Conditional future-battle Truthtrance — 28 October. Prototyped / Partial.** **Conditional future-battle Truthtrance — 28 October. Prototyped / Partial.**
A quiet Basic classic game can ask whether a named native leader will be used A quiet Basic classic game can ask whether a named native leader will be used
in the respondent's first later battle this turn, if there is one. The answer in the respondent's first later battle this turn, if there is one. The answer
+20
View File
@@ -0,0 +1,20 @@
# Optional three-stronghold alliance victory
**Prototyped / Partial — 28 October 2026.** The host can select a shorter
two-member alliance game in the lobby. With at least three physical
controllers, allied factions need three distinct qualifying strongholds
instead of the default four. A solo faction keeps its normal target, a
separately agreed extra-stronghold requirement is added afterward, and Tech
Tokens retain their existing ownership requirements. The same calculation
drives public progress and the Mentat victory decision. Changing the setting
clears human readiness; older games omit it and retain the default.
The 2019 GF9 rulebook's optional alliances table, printed p. 24, lists a
two-member alliance with three strongholds as a shorter game. The
[publisher rulebook mirror](https://www.boardgamesbot.com/assets/games-library/dune/manual_eng.pdf)
preserves that table. This first version keeps the existing four-stronghold
target for two physical controllers, including the two-faction-per-person
setup; priority between that special setup and this optional table is a
provisional interpretation. Larger alliance variants require separate
membership and interaction work. Advanced and expansion combinations are not
certified by the Basic-focused evidence.
+7
View File
@@ -1,5 +1,12 @@
# Rule decision index # Rule decision index
## Optional short alliance victory — 28 October 2026
The printed optional two-member alliance target is selectable at three
strongholds. This prototype preserves the four-stronghold two-controller
setup as a provisional precedence choice; larger alliances remain separate.
[Scope and source](OPTIONAL_ALLIANCE_VICTORY.md).
## Conditional future-battle leader question — 28 October 2026 ## Conditional future-battle leader question — 28 October 2026
The printed current-turn question can provisionally refer to a named native The printed current-turn question can provisionally refer to a named native
+3 -1
View File
@@ -2,7 +2,7 @@
## Conditional next-battle leader question — 28 October 2026 ## Conditional next-battle leader question — 28 October 2026
**Prototyped / Partial.** In a quiet Basic Shipment and Movement window with **Prototyped / Partial.** In a quiet Basic Revival or Shipment and Movement window with
classic factions and no optional modules, a player can ask whether another classic factions and no optional modules, a player can ask whether another
player will use a named native leader in that player's first later battle this player will use a named native leader in that player's first later battle this
turn, if one occurs. A definite answer creates a waiting receipt; it does not turn, if one occurs. A definite answer creates a waiting receipt; it does not
@@ -13,6 +13,8 @@ ends. An independently impossible answer is released by the existing battle
promise reconciliation. Another structured action answer for that respondent promise reconciliation. Another structured action answer for that respondent
is guarded while the conditional answer is active; broader promise composition, is guarded while the conditional answer is active; broader promise composition,
expansion factions, optional modules and Advanced are not supported here. expansion factions, optional modules and Advanced are not supported here.
During Revival, definite answers use currently available native leaders or
Cheap Heroes; they do not forecast a future revival or spend its resources.
The publisher's [November 2020 FAQ](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf) The publisher's [November 2020 FAQ](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf)
permits game-related current-turn action questions and requires honoring an permits game-related current-turn action questions and requires honoring an
+4
View File
@@ -137,6 +137,7 @@ export function freshAdminLobby(game: Game): boolean {
game.nexusCards != null || game.nexusCards != null ||
game.leaderSkills != null || game.leaderSkills != null ||
game.ecazTreachery != null || game.ecazTreachery != null ||
(game.shortAllianceVictory !== undefined && typeof game.shortAllianceVictory !== 'boolean') ||
(game.discoveryEnabled !== undefined && game.discoveryEnabled !== false) (game.discoveryEnabled !== undefined && game.discoveryEnabled !== false)
) )
return false; return false;
@@ -159,6 +160,7 @@ export function freshAdminLobby(game: Game): boolean {
'leaderSkills', 'leaderSkills',
'ecazTreachery', 'ecazTreachery',
'discoveryEnabled', 'discoveryEnabled',
'shortAllianceVictory',
]); ]);
for (const key of new Set([...Object.keys(game), ...Object.keys(expected)])) for (const key of new Set([...Object.keys(game), ...Object.keys(expected)]))
if ( if (
@@ -196,6 +198,7 @@ export function projectAdminLobby(
advanced: false, advanced: false,
techTokens: false, techTokens: false,
strongholdCards: false, strongholdCards: false,
shortAllianceVictory: false,
players: [], players: [],
control, control,
}; };
@@ -228,6 +231,7 @@ export function projectAdminLobby(
advanced: game.advanced === true, advanced: game.advanced === true,
techTokens: !!game.techTokens, techTokens: !!game.techTokens,
strongholdCards: !!game.strongholdCards, strongholdCards: !!game.strongholdCards,
shortAllianceVictory: game.shortAllianceVictory === true,
host: game.players.some((player) => player.id === game.host) host: game.players.some((player) => player.id === game.host)
? game.host ? game.host
: null, : 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; factionDraft?: FactionDraft;
/** Explicit two-human/four-faction setup; absent from ordinary and legacy games. */ /** Explicit two-human/four-faction setup; absent from ordinary and legacy games. */
twoPlayerControllers?: TwoPlayerControllers; 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. */ /** Explicit final turn after an agreed extension; absent means turn ten. */
victoryFinalTurn?: number; victoryFinalTurn?: number;
/** Agreed addition to the ordinary stronghold win targets. Older games omit it. */ /** 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. */ /** Reject stale or ambiguous ownership before either projecting or mutating a game. */
function twoPlayerControllersIntegrity(g: Game): void { 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; if (g.twoPlayerControllers === undefined) return;
const seats = g.players.map(player => player.id); const seats = g.players.map(player => player.id);
twoPlayerRule(() => { twoPlayerRule(() => {
@@ -10689,7 +10693,7 @@ function requireFreshBaseRuntime(g: Game) {
const configuration = new Set<keyof Game>(['code', 'version', 'host', 'players', 'playerPositions', const configuration = new Set<keyof Game>(['code', 'version', 'host', 'players', 'playerPositions',
'advanced', 'expansions', 'log', 'botsPending', 'botNextActionAt', 'techTokens', 'strongholdCards', 'advanced', 'expansions', 'log', 'botsPending', 'botNextActionAt', 'techTokens', 'strongholdCards',
'homeworlds', 'nexusCards', 'discoveryEnabled', 'leaderSkills', 'ecazTreachery', 'lobbyOptions', 'kullDefaultPolicy', 'homeworlds', 'nexusCards', 'discoveryEnabled', 'leaderSkills', 'ecazTreachery', 'lobbyOptions', 'kullDefaultPolicy',
'twoPlayerControllers']); 'twoPlayerControllers', 'shortAllianceVictory']);
const keys = [...new Set([...Object.keys(g), ...Object.keys(fresh)])] as (keyof Game)[]; const keys = [...new Set([...Object.keys(g), ...Object.keys(fresh)])] as (keyof Game)[];
requireRule(keys.every(key => configuration.has(key) || requireRule(keys.every(key => configuration.has(key) ||
JSON.stringify(g[key]) === JSON.stringify(fresh[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.bidPromises ?? []), ...(g.phaseBidPromises ?? []), ...(g.richeseBidPromises ?? []),
...(g.earlyRicheseBidPromises ?? []), ...(g.alliancePromises ?? []), ...(g.allianceExpressionPromises ?? []), ...(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 && (g.futureBattleLeaderPromises ?? []).some(row => row.turn === g.turn && row.player === p.id &&
(row.status === 'waiting' || row.status === 'bound')) || (row.status === 'waiting' || row.status === 'bound')) ||
otherPromises.some(row => row.player === p.id && row.turn === g.turn && !row.fulfilled && !row.released) || 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.`); log(g, `${p.name} ${action.enabled ? 'enabled' : 'disabled'} the provisional two-person, four-faction setup. Human readiness was cleared.`);
return g; 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') { if (t === 'rules') {
requireRule(id === g.host, 'Only the host can change the rules mode.'); requireRule(id === g.host, 'Only the host can change the rules mode.');
requireRule(typeof action.advanced === 'boolean' && requireRule(typeof action.advanced === 'boolean' &&
@@ -45792,10 +45807,10 @@ export function viewGame(state: Game, id: string, now?: number) {
truthFutureBattleLeaderAnswers: g.truthtrance?.stage === 'answer' && truthFutureBattleLeaderAnswers: g.truthtrance?.stage === 'answer' &&
g.truthtrance.question?.target === id && g.truthtrance.question.kind === 'futureBattleLeader' g.truthtrance.question?.target === id && g.truthtrance.question.kind === 'futureBattleLeader'
? futureBattleLeaderTruthAnswers(g, me, g.truthtrance.question) : null, ? 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.expansions.length && !g.techTokens && !g.strongholdCards && !g.nexusCards && !g.homeworlds &&
!g.leaderSkills && !g.discoveryEnabled && !g.ecazTreachery && !g.battle && !g.response && !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 => ? 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'))) row.turn === g.turn && row.player === player.id && (row.status === 'waiting' || row.status === 'bound')))
.map(player => player.id) : [], .map(player => player.id) : [],
@@ -45916,6 +45931,7 @@ export function viewGame(state: Game, id: string, now?: number) {
factionDraft: g.factionDraft ?? null, factionDraft: g.factionDraft ?? null,
factionSwap: g.factionSwap ?? null, factionSwap: g.factionSwap ?? null,
twoPlayerControllers: g.twoPlayerControllers ?? null, twoPlayerControllers: g.twoPlayerControllers ?? null,
shortAllianceVictory: g.shortAllianceVictory === true,
controlledFactionSeats: g.twoPlayerControllers controlledFactionSeats: g.twoPlayerControllers
? g.players.filter(player => g.twoPlayerControllers!.controllers.some(controller => ? g.players.filter(player => g.twoPlayerControllers!.controllers.some(controller =>
controller.seats.includes(id) && controller.seats.includes(player.id))) 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', 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.', 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: [ 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.', '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.', '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.', '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: summary:
'Stronghold targets, storm-contested occupation, prediction and final-turn fallback.', 'Stronghold targets, storm-contested occupation, prediction and final-turn fallback.',
steps: [ 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.', '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.', '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.', '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: 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.', '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: [ related: [
'mentat', 'mentat',
'tech-tokens', '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' && check(Object.keys(v).sort().join(',') === 'kind,leader,target,turn' &&
v.turn === g.turn && typeof v.leader === 'string' && v.turn === g.turn && typeof v.leader === 'string' &&
leaders(target.faction).some(leader => leader.id === v.leader) && 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.techTokens && !g.strongholdCards && !g.nexusCards && !g.homeworlds &&
!g.leaderSkills && !g.discoveryEnabled && !g.ecazTreachery && !g.battle && !g.leaderSkills && !g.discoveryEnabled && !g.ecazTreachery && !g.battle &&
!g.response && !g.decision && !g.phaseOpening && !g.pendingKarama && !g.pendingTreacheryDiscard, !g.response && !g.decision && !g.phaseOpening && !g.pendingRevival && !g.pendingKarama && !g.pendingTreacheryDiscard,
'Choose a native leader and a quiet Basic turn before Battle.'); '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 }; return { kind: 'futureBattleLeader', target: v.target, turn: g.turn, leader: v.leader };
} }
if (v.kind === 'battlePlan') { if (v.kind === 'battlePlan') {
+5 -3
View File
@@ -9,7 +9,8 @@ import { gameTerritories } from './board';
import { fighterCount } from './advisors'; import { fighterCount } from './advisors';
import { TECH_TOKENS } from './tech-tokens'; import { TECH_TOKENS } from './tech-tokens';
import { isDiscoveryLocationId, JACURUTU_SIETCH } from './discoveries'; 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 = { export type StrongholdProgress = {
player: string; player: string;
@@ -49,6 +50,7 @@ export function strongholdProgress(
g: BoardContext & Pick<Game, 'techTokens'> & { g: BoardContext & Pick<Game, 'techTokens'> & {
victoryExtraStrongholds?: number; victoryExtraStrongholds?: number;
twoPlayerControllers?: TwoPlayerControllers; twoPlayerControllers?: TwoPlayerControllers;
shortAllianceVictory?: boolean;
}, },
): { released: AdvisorRelease[]; progress: StrongholdProgress[] } { ): { released: AdvisorRelease[]; progress: StrongholdProgress[] } {
try { try {
@@ -133,8 +135,8 @@ export function strongholdProgress(
members.some((member) => members.some((member) =>
TECH_TOKENS.every(({ id }) => tech[id].owner === member), TECH_TOKENS.every(({ id }) => tech[id].owner === member),
); );
const target = controllerStrongholdTarget( const target = allianceVictoryTarget(
players.map((player) => player.id), g.twoPlayerControllers, !!partner, players.map((player) => player.id), g.twoPlayerControllers, !!partner, g.shortAllianceVictory,
) + extra; ) + extra;
return { return {
player: id, 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'> & export type VictoryContext = Omit<BoardContext, 'players'> &
Pick< Pick<
Game, Game,
'turn' | 'phase' | 'status' | 'winner' | 'techTokens' | 'strongholdCards' | 'victoryExtraStrongholds' | 'victoryFinalTurn' 'turn' | 'phase' | 'status' | 'winner' | 'techTokens' | 'strongholdCards' | 'victoryExtraStrongholds' | 'victoryFinalTurn' | 'shortAllianceVictory'
> & { > & {
players: readonly (BoardSeat & Pick<Player, 'prediction'>)[]; players: readonly (BoardSeat & Pick<Player, 'prediction'>)[];
twoPlayerControllers?: TwoPlayerControllers; twoPlayerControllers?: TwoPlayerControllers;
+3 -2
View File
@@ -10,11 +10,11 @@ const keys = (value: Record<string, unknown>, names: string[]) => Object.keys(va
const integer = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 0; const integer = (value: unknown): value is number => Number.isSafeInteger(value) && Number(value) >= 0;
export function validAdminLobbyView(value: unknown): value is AdminLobbyView { export function validAdminLobbyView(value: unknown): value is AdminLobbyView {
if (!object(value) || !keys(value, ['code', 'version', 'status', 'editable', 'blockedReason', 'host', 'advanced', 'techTokens', 'strongholdCards', 'players', 'control']) || if (!object(value) || !keys(value, ['code', 'version', 'status', 'editable', 'blockedReason', 'host', 'advanced', 'techTokens', 'strongholdCards', 'shortAllianceVictory', 'players', 'control']) ||
typeof value.code !== 'string' || value.code.length !== 8 || !/^[A-Z2-9]{8}$/.test(value.code) || !integer(value.version) || typeof value.code !== 'string' || value.code.length !== 8 || !/^[A-Z2-9]{8}$/.test(value.code) || !integer(value.version) ||
!['lobby', 'setup', 'playing', 'finished', 'unreadable'].includes(String(value.status)) || typeof value.editable !== 'boolean' || !['lobby', 'setup', 'playing', 'finished', 'unreadable'].includes(String(value.status)) || typeof value.editable !== 'boolean' ||
!(value.blockedReason === null || typeof value.blockedReason === 'string') || !(value.host === null || typeof value.host === 'string') || !(value.blockedReason === null || typeof value.blockedReason === 'string') || !(value.host === null || typeof value.host === 'string') ||
typeof value.advanced !== 'boolean' || typeof value.techTokens !== 'boolean' || typeof value.strongholdCards !== 'boolean' || typeof value.advanced !== 'boolean' || typeof value.techTokens !== 'boolean' || typeof value.strongholdCards !== 'boolean' || typeof value.shortAllianceVictory !== 'boolean' ||
!Array.isArray(value.players) || value.players.length > 6 || !object(value.control)) return false; !Array.isArray(value.players) || value.players.length > 6 || !object(value.control)) return false;
const control = value.control; const control = value.control;
return keys(control, ['paused', 'joinLocked', 'revision', 'updatedAt', ...(control.closed === true ? ['closed'] : [])]) && typeof control.paused === 'boolean' && typeof control.joinLocked === 'boolean' && return keys(control, ['paused', 'joinLocked', 'revision', 'updatedAt', ...(control.closed === true ? ['closed'] : [])]) && typeof control.paused === 'boolean' && typeof control.joinLocked === 'boolean' &&
@@ -74,6 +74,7 @@ export function describeAdminLobbyAction(action: AdminLobbyAction, view: AdminLo
case 'rules': return `Select ${action.advanced ? 'Advanced preview' : 'Basic'} rules`; case 'rules': return `Select ${action.advanced ? 'Advanced preview' : 'Basic'} rules`;
case 'techTokens': return `${action.enabled ? 'Enable' : 'Disable'} Tech Tokens`; case 'techTokens': return `${action.enabled ? 'Enable' : 'Disable'} Tech Tokens`;
case 'strongholdCards': return `${action.enabled ? 'Enable' : 'Disable'} Stronghold Cards`; case 'strongholdCards': return `${action.enabled ? 'Enable' : 'Disable'} Stronghold Cards`;
case 'shortAllianceVictory': return `${action.enabled ? 'Enable' : 'Disable'} the optional three-stronghold alliance victory`;
case 'addBot': return `Add ${faction} AI at ${action.difficulty}`; case 'addBot': return `Add ${faction} AI at ${action.difficulty}`;
case 'removeBot': return `Remove AI player ${player}`; case 'removeBot': return `Remove AI player ${player}`;
case 'configureBot': return `Configure ${player}: ${faction}, ${action.difficulty}, circle ${action.position}`; case 'configureBot': return `Configure ${player}: ${faction}, ${action.difficulty}, circle ${action.position}`;
+3 -2
View File
@@ -4,7 +4,7 @@ import type { RoomControl } from './room-control';
export type AdminLobbyAction = export type AdminLobbyAction =
| { type: 'rules'; advanced: boolean } | { type: 'rules'; advanced: boolean }
| { type: 'techTokens' | 'strongholdCards'; enabled: boolean } | { type: 'techTokens' | 'strongholdCards' | 'shortAllianceVictory'; enabled: boolean }
| { type: 'addBot'; faction: FactionId; difficulty: Difficulty } | { type: 'addBot'; faction: FactionId; difficulty: Difficulty }
| { type: 'removeBot' | 'assignHost'; target: string } | { type: 'removeBot' | 'assignHost'; target: string }
| { | {
@@ -30,6 +30,7 @@ export type AdminLobbyView = {
advanced: boolean; advanced: boolean;
techTokens: boolean; techTokens: boolean;
strongholdCards: boolean; strongholdCards: boolean;
shortAllianceVictory: boolean;
players: { players: {
id: string; id: string;
name: string; name: string;
@@ -84,7 +85,7 @@ export function validAdminLobbyInput(value: unknown): value is AdminLobbyInput {
return ( return (
keys(action, ['type', 'advanced']) && typeof action.advanced === 'boolean' keys(action, ['type', 'advanced']) && typeof action.advanced === 'boolean'
); );
if (action.type === 'techTokens' || action.type === 'strongholdCards') if (action.type === 'techTokens' || action.type === 'strongholdCards' || action.type === 'shortAllianceVictory')
return ( return (
keys(action, ['type', 'enabled']) && typeof action.enabled === 'boolean' keys(action, ['type', 'enabled']) && typeof action.enabled === 'boolean'
); );
+16 -2
View File
@@ -1,11 +1,11 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { adminLobbyConfirmation, adminLobbyResponse, clearAdminLobbyRequest, newAdminLobbyRequest, readAdminLobbyRequest, saveAdminLobbyRequest } from '../lib/admin-lobby-client'; import { adminLobbyConfirmation, adminLobbyResponse, clearAdminLobbyRequest, describeAdminLobbyAction, newAdminLobbyRequest, readAdminLobbyRequest, saveAdminLobbyRequest } from '../lib/admin-lobby-client';
import type { AdminLobbyView } from '../lib/admin-lobby-configuration'; import type { AdminLobbyView } from '../lib/admin-lobby-configuration';
import { DEFAULT_ROOM_CONTROL } from '../lib/room-control'; import { DEFAULT_ROOM_CONTROL } from '../lib/room-control';
const view = (): AdminLobbyView => ({ code: 'ABCD2345', version: 4, status: 'lobby', editable: true, blockedReason: null, host: 'host', const view = (): AdminLobbyView => ({ code: 'ABCD2345', version: 4, status: 'lobby', editable: true, blockedReason: null, host: 'host',
advanced: false, techTokens: false, strongholdCards: false, control: { ...DEFAULT_ROOM_CONTROL }, advanced: false, techTokens: false, strongholdCards: false, shortAllianceVictory: false, control: { ...DEFAULT_ROOM_CONTROL },
players: [{ id: 'host', name: 'Host', faction: 'atreides', bot: null, position: 1, ready: false, hostEligible: true }] }); players: [{ id: 'host', name: 'Host', faction: 'atreides', bot: null, position: 1, ready: false, hostEligible: true }] });
function storage() { function storage() {
const values = new Map<string, string>(); const values = new Map<string, string>();
@@ -59,3 +59,17 @@ void test('lobby response accepts unreadable room diagnostics but rejects privat
{ ...lobby, control: { ...lobby.control, reason: 'Private audit' } }, { ...lobby, players: [{ ...lobby.players[0], position: 7 }] }]) { ...lobby, control: { ...lobby.control, reason: 'Private audit' } }, { ...lobby, players: [{ ...lobby.players[0], position: 7 }] }])
assert.throws(() => adminLobbyResponse(invalid, lobby.code)); assert.throws(() => adminLobbyResponse(invalid, lobby.code));
}); });
void test('short alliance selection survives the same validated retry and confirmation path', () => {
const current = view(), tab = storage();
const action = { type: 'shortAllianceVictory' as const, enabled: true };
const input = newAdminLobbyRequest(current, action, 'Use shorter alliance game');
assert.match(describeAdminLobbyAction(action, current), /Enable.*three-stronghold alliance victory/);
saveAdminLobbyRequest(tab, 'a', current.code, input);
assert.deepEqual(readAdminLobbyRequest(tab, 'a', current.code), input);
const updated = { ...current, version: 5, shortAllianceVictory: true };
assert.equal(adminLobbyResponse(updated, current.code).shortAllianceVictory, true);
assert.equal(adminLobbyConfirmation({ operationId: input.operationId, replayed: false, appliedVersion: 5, lobby: updated }, current.code, input).lobby.shortAllianceVictory, true);
assert.throws(() => adminLobbyResponse({ ...updated, shortAllianceVictory: 'true' }, current.code));
assert.throws(() => newAdminLobbyRequest(current, { ...action, enabled: 'true' } as unknown as typeof action, 'QA'));
});
+36 -1
View File
@@ -191,11 +191,12 @@ void test('lobby migration is additive and preserves existing game/seat/admin/cr
} }
}); });
void test('admin lobby input permits only seven exact bounded commands with valid base factions and public parameters', () => { void test('admin lobby input permits only exact bounded commands with valid base factions and public parameters', () => {
const valid: AdminLobbyAction[] = [ const valid: AdminLobbyAction[] = [
{ type: 'rules', advanced: true }, { type: 'rules', advanced: true },
{ type: 'techTokens', enabled: true }, { type: 'techTokens', enabled: true },
{ type: 'strongholdCards', enabled: false }, { type: 'strongholdCards', enabled: false },
{ type: 'shortAllianceVictory', enabled: true },
{ type: 'addBot', faction: 'guild', difficulty: 'Hard' }, { type: 'addBot', faction: 'guild', difficulty: 'Hard' },
{ type: 'removeBot', target: 'bot' }, { type: 'removeBot', target: 'bot' },
{ {
@@ -217,6 +218,8 @@ void test('admin lobby input permits only seven exact bounded commands with vali
{ type: 'addBot', faction: 'ixians', difficulty: 'Hard' }, { type: 'addBot', faction: 'ixians', difficulty: 'Hard' },
{ type: 'addBot', faction: 'guild', difficulty: 'Impossible' }, { type: 'addBot', faction: 'guild', difficulty: 'Impossible' },
{ type: 'rules', advanced: 'true' }, { type: 'rules', advanced: 'true' },
{ type: 'shortAllianceVictory', enabled: 'true' },
{ type: 'shortAllianceVictory', enabled: true, extra: true },
{ type: 'assignHost', target: '' }, { type: 'assignHost', target: '' },
{ type: 'assignHost', target: 'guest', token: secret() }, { type: 'assignHost', target: 'guest', token: secret() },
{ {
@@ -264,6 +267,7 @@ void test('viewer receives only public lobby configuration and active-human host
'editable', 'editable',
'host', 'host',
'players', 'players',
'shortAllianceVictory',
'status', 'status',
'strongholdCards', 'strongholdCards',
'techTokens', 'techTokens',
@@ -298,6 +302,37 @@ void test('viewer receives only public lobby configuration and active-human host
} }
}); });
void test('audited admin short-alliance selection preserves seats, resets readiness and replays exactly once', async () => {
const f = await fixture();
try {
assert.equal((await f.read()).shortAllianceVictory, false);
for (const auth of [f.auth, f.guestAuth])
await f.rooms.act(f.code, auth, f.version(), { type: 'ready' });
const secure = f.secureRows();
const input = operation(f.version(), { type: 'shortAllianceVictory', enabled: true });
const changed = await f.configure(input);
assert.equal(changed.lobby.shortAllianceVictory, true);
assert.ok(changed.lobby.players.every(player => !player.ready));
assert.equal((await f.rooms.readRoom(f.code)).shortAllianceVictory, true);
assert.deepEqual(f.secureRows(), secure);
assert.equal(audits(f).length, 1);
const audit = audits(f)[0];
assert.equal(JSON.parse(String(audit.before_configuration)).shortAllianceVictory, false);
assert.equal(JSON.parse(String(audit.after_configuration)).shortAllianceVictory, true);
const replay = await f.configure(input);
assert.equal(replay.replayed, true);
assert.equal(replay.appliedVersion, changed.appliedVersion);
assert.equal(replay.lobby.shortAllianceVictory, true);
assert.equal(audits(f).length, 1);
const disabled = await f.configure(operation(f.version(), { type: 'shortAllianceVictory', enabled: false }));
assert.equal(disabled.lobby.shortAllianceVictory, false);
assert.equal((await f.rooms.readRoom(f.code)).shortAllianceVictory, undefined);
assert.deepEqual(f.secureRows(), secure);
} finally {
f.sqlite.close();
}
});
void test('neutral admin changes reuse ordinary rule/AI actions, preserve host sessions and clear human readiness', async () => { void test('neutral admin changes reuse ordinary rule/AI actions, preserve host sessions and clear human readiness', async () => {
const f = await fixture(); const f = await fixture();
try { try {
+41
View File
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { allianceVictoryTarget } from '../game/alliance-victory-option';
import type { TwoPlayerControllers } from '../game/two-player-controllers';
const seats = ['atreides', 'harkonnen', 'fremen', 'guild', 'emperor', 'beneGesserit'];
const twoPeople: TwoPlayerControllers = {
version: 1,
controllers: [
{ id: 'atreides', seats: ['atreides', 'harkonnen'] },
{ id: 'fremen', seats: ['fremen', 'guild'] },
],
};
void test('existing solo and alliance targets remain unchanged unless the option is selected', () => {
for (let count = 3; count <= 6; count++) {
const players = seats.slice(0, count);
assert.equal(allianceVictoryTarget(players, undefined, false), 3);
assert.equal(allianceVictoryTarget(players, undefined, true), 4);
assert.equal(allianceVictoryTarget(players, null, true, false), 4);
assert.equal(allianceVictoryTarget(players, undefined, false, true), 3);
assert.equal(allianceVictoryTarget(players, undefined, true, true), 3);
}
});
void test('two physical controllers retain four strongholds with either setup', () => {
const before = structuredClone(twoPeople);
assert.equal(allianceVictoryTarget(seats.slice(0, 2), undefined, false, true), 4);
assert.equal(allianceVictoryTarget(seats.slice(0, 2), undefined, true, true), 4);
assert.equal(allianceVictoryTarget(seats.slice(0, 4), twoPeople, false, true), 4);
assert.equal(allianceVictoryTarget(seats.slice(0, 4), twoPeople, true, true), 4);
assert.deepEqual(twoPeople, before);
});
void test('invalid selection, side and controller mappings cannot change the target', () => {
assert.throws(() => allianceVictoryTarget(seats.slice(0, 3), undefined, true, null as unknown as boolean), TypeError);
assert.throws(() => allianceVictoryTarget(seats.slice(0, 3), undefined, true, 'yes' as unknown as boolean), TypeError);
assert.throws(() => allianceVictoryTarget(seats.slice(0, 3), undefined, 'yes' as unknown as boolean, true));
assert.throws(() => allianceVictoryTarget(['same', 'same', 'third'], undefined, true, true));
assert.throws(() => allianceVictoryTarget(seats.slice(0, 3), twoPeople, true, true));
});
+96
View File
@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import { territory } from '../game/board';
import { botActions } from '../game/bots';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
export type ShortAllianceStep = { actor: string; action: Action };
export function shortAllianceLobby(): Game {
const game = createGame('SHORTALLIANCE', newPlayer('at', 'Atreides', 'atreides'));
joinGame(game, newPlayer('em', 'Emperor', 'emperor'));
joinGame(game, newPlayer('gu', 'Guild', 'guild'));
return game;
}
/** Only real owned controls advance setup and the first turn. Quiet Movement
* is deliberately ended so the fixture cannot win before the real Nexus. */
function nextStep(game: Game): ShortAllianceStep | null {
for (const player of game.players) {
const view = viewGame(game, player.id);
view.players.find(seat => seat.id === player.id)!.bot = 'Easy';
const action = botActions(view)[0];
if (!action) continue;
if (game.status === 'playing' && game.phase === 5 && game.active === player.id &&
!game.response && !game.decision && !game.truthtrance && !game.phaseOpening)
return { actor: player.id, action: { type: 'endMovement' } };
return { actor: player.id, action };
}
return null;
}
export function advanceShortAlliance(game: Game, predicate: (game: Game) => boolean,
actions: ShortAllianceStep[], maximum = 1200): Game {
for (let count = 0; count < maximum; count++) {
if (predicate(game)) return game;
assert.notEqual(game.status, 'finished', 'The native game ended before the requested boundary.');
const step = nextStep(game);
assert.ok(step, `No native continuation at ${game.status}/${game.turn}/${game.phase}.`);
actions.push(structuredClone(step));
game = applyAction(game, step.actor, step.action);
}
throw Error('The original game did not reach the requested alliance boundary.');
}
/** Genuine Basic setup and genuine Nexus/alliance; only the unopened original
* Spice deck order is selected to make the next normal worm deterministic. */
export function genuineShortAlliance(enabled: boolean) {
let game = shortAllianceLobby();
const actions: ShortAllianceStep[] = [];
const act = (actor: string, action: Action) => {
actions.push({ actor, action });
game = applyAction(game, actor, action);
};
if (enabled) act('at', { type: 'shortAllianceVictory', enabled: true });
for (const player of game.players) act(player.id, { type: 'ready' });
act('at', { type: 'start' });
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, 'The original unopened deck retains an ordinary worm.');
const beforeDeck = 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(), beforeDeck);
game = advanceShortAlliance(game, state => state.nexus && !state.spiceWindow && !state.spiceResolution &&
!state.response && !state.decision && !state.truthtrance && !state.phaseOpening, actions);
act('at', { type: 'alliance', target: 'em' });
act('em', { type: 'alliance', target: 'at' });
assert.equal(game.players.find(player => player.id === 'at')!.ally, 'em');
assert.equal(game.players.find(player => player.id === 'em')!.ally, 'at');
game = advanceShortAlliance(game, state => !state.nexus && state.phase >= 2, actions);
return { game, actions };
}
/** The setup and alliance above are real. For the rare three-stronghold
* victory boundary, this arranges conserved forces and the Collection phase;
* the actual final Collection readiness and Mentat win remain native actions. */
export function shortAllianceCollection(source: Game): Game {
const game = structuredClone(source);
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, count) => sum + count, 0);
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, count) => sum + count, 0);
player.elites.forces = {};
}
}
const at = game.players.find(player => player.id === 'at')!;
const em = game.players.find(player => player.id === 'em')!;
for (const id of ['arrakeen', 'carthag']) {
at.forces[`${id}:${territory(id).sectors[0]}`] = 1;
at.reserves--;
}
em.forces[`tueks_sietch:${territory('tueks_sietch').sectors[0]}`] = 1;
em.reserves--;
Object.assign(game, { phase: 7, ready: [], active: null, storm: 18,
response: null, decision: null, truthtrance: null, phaseOpening: null });
return game;
}
@@ -0,0 +1,91 @@
import assert from 'node:assert/strict';
import { TERRITORIES } from '../game/board';
import { viewGame, type Action, type Game } from '../game/engine';
import {
actTruthRevival, createTruthRevivalFixture, nextTruthRevival, truthRevivalClean,
truthRevivalInventory, truthRevivalPlayer,
} from './fixture-truth-revival';
import { futureBattleLeaderRecords } from './fixture-truth-future-battle-leader';
export const actFutureBattleRevival = actTruthRevival;
export const futureBattleRevivalInventory = truthRevivalInventory;
export const futureBattleRevivalRecords = futureBattleLeaderRecords;
/** The source has real Basic turn-two storm casualties and has reached quiet
* Revival, with one original Truthtrance still in Harkonnen's hand. */
export function createFutureBattleRevivalFixture() {
const base = createTruthRevivalFixture();
const game = structuredClone(base.game);
assert.equal(game.phase, 4); assert.equal(game.advanced, false);
assert.deepEqual(game.expansions, []); assert.ok(truthRevivalClean(game));
const target = truthRevivalPlayer(game, base.target);
const available = target.leaders.filter(leader => !leader.dead && !leader.capturedBy && !leader.gholaBy && !leader.usedAt);
assert.ok(available.length >= 2);
const leader = available[0].id, alternative = available[1].id;
const question = {kind: 'futureBattleLeader', target: base.target, turn: game.turn, leader};
return { base, game, source: structuredClone(game), asker: base.asker, target: base.target,
truth: base.truth, leader, alternative, question, originalCards: base.originalCards,
ask: {type: 'truthAsk', question} as unknown as Action };
}
export type FutureBattleRevivalFixture = ReturnType<typeof createFutureBattleRevivalFixture>;
export function askFutureBattleRevival(f: FutureBattleRevivalFixture): Game {
let game = actFutureBattleRevival(f.game, f.asker, {type: 'card', card: f.truth});
for (let count = 0; game.truthtrance?.stage === 'priority' && count < game.players.length; count++) {
const seat = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(seat);
game = actFutureBattleRevival(game, seat.id, {type: 'truthPass'});
}
assert.equal(game.truthtrance?.stage, 'ask');
return actFutureBattleRevival(game, f.asker, f.ask);
}
/** Finish Revival and skip unrelated Movement opportunities, preserving the
* target's own turn for its explicit decision or no-battle expiry. */
export function reachFutureBattleRevivalMovement(game: Game, f: FutureBattleRevivalFixture): Game {
for (let count = 0; count < 800; count++) {
if (game.phase === 5 && game.active === f.target && truthRevivalClean(game)) return game;
if (game.phase === 5 && game.active && truthRevivalClean(game)) {
game = actFutureBattleRevival(game, game.active, {type: 'endMovement'}); continue;
}
const next = nextTruthRevival(game); assert.ok(next, 'Finish the original native Revival opportunity.');
game = actFutureBattleRevival(game, next.actor, next.action);
}
throw Error('The original Revival source did not reach the respondent Movement turn.');
}
/** Make a real one-force shipment into an existing unallied opponent group,
* then use ordinary battle-selection and preparation actions. */
export function reachFutureBattleRevivalBattle(game: Game, f: FutureBattleRevivalFixture): Game {
const target = truthRevivalPlayer(game, f.target);
const location = game.players.filter(player => player.id !== f.target && player.id !== target.ally)
.flatMap(player => Object.entries(player.forces)).find(([key, count]) => {
const separator = key.lastIndexOf(':'), name = key.slice(0, separator), sector = Number(key.slice(separator + 1));
return count > 0 && sector !== game.storm && TERRITORIES.some(territory => territory.id === name && territory.sectors.includes(sector));
});
assert.ok(location, 'The native source retains an eligible opponent group.');
const separator = location[0].lastIndexOf(':'), territory = location[0].slice(0, separator);
const sector = Number(location[0].slice(separator + 1));
game = actFutureBattleRevival(game, f.target, {type: 'ship', territory, sector, amount: 1, elite: 0, allyPayment: 0});
for (let count = 0; count < 800; count++) {
if (game.phase === 5 && game.active === f.target && truthRevivalClean(game)) {
game = actFutureBattleRevival(game, f.target, {type: 'endMovement'}); continue;
}
if (game.phase === 5 && game.active && truthRevivalClean(game)) {
game = actFutureBattleRevival(game, game.active, {type: 'endMovement'}); continue;
}
if (game.phase === 6 && game.battle && [game.battle.attacker, game.battle.defender].includes(f.target) &&
truthRevivalClean(game) && !game.battle.preparation && game.battle.preLeader?.closed !== false) return game;
if (game.phase === 6 && !game.battle && game.active && truthRevivalClean(game)) {
const choice = viewGame(game, game.active).battleChoices.find(row => row.territory === territory &&
[row.attacker, row.defender].includes(f.target));
if (choice) {
game = actFutureBattleRevival(game, choice.chooser, {type: 'chooseBattle', territory: choice.territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker});
continue;
}
}
const next = nextTruthRevival(game); assert.ok(next, 'Finish the original native battle preparation.');
game = actFutureBattleRevival(game, next.actor, next.action);
}
throw Error('The native source did not reach the target’s first battle.');
}
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, viewGame, type GameView } from '../game/engine';
import { advanceShortAlliance, shortAllianceLobby, type ShortAllianceStep } from './fixture-short-alliance-victory';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' :
specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
},
});
const { GameTable } = await import('../components/game-table');
aliases.deregister();
const table = (game: GameView, busy = false) => renderToStaticMarkup(createElement(GameTable,
{ game, busy, send: async () => {}, onExit() {} }));
function allianceLabel(html: string) {
const label = html.match(/<label\b[^>]*>(?:(?!<\/label>)[\s\S])*Optional: two-member alliances win with three strongholds(?:(?!<\/label>)[\s\S])*<\/label>/)?.[0];
assert.ok(label, 'The optional alliance victory control is present.');
return label;
}
void test('short alliance lobby option shows its selected state and belongs to the host', () => {
const original = shortAllianceLobby();
const fresh = allianceLabel(table(viewGame(original, 'at')));
assert.match(fresh, /type="checkbox"/);
assert.doesNotMatch(fresh, /\bchecked=/);
assert.doesNotMatch(fresh, /\bdisabled=/);
const selected = applyAction(original, 'at', { type: 'shortAllianceVictory', enabled: true });
const host = allianceLabel(table(viewGame(selected, 'at')));
const guest = allianceLabel(table(viewGame(selected, 'em')));
assert.match(host, /\bchecked=/);
assert.doesNotMatch(host, /\bdisabled=/);
assert.match(guest, /\bchecked=/);
assert.match(guest, /\bdisabled=/);
assert.match(allianceLabel(table(viewGame(selected, 'at'), true)), /\bdisabled=/);
assert.throws(() => applyAction(selected, 'em', { type: 'shortAllianceVictory', enabled: false }));
});
void test('a real started game keeps its selected alliance rule visible to every seat', () => {
let game = applyAction(shortAllianceLobby(), 'at', { type: 'shortAllianceVictory', enabled: true });
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
game = applyAction(game, 'at', { type: 'start' });
const actions: ShortAllianceStep[] = [];
game = advanceShortAlliance(game, state => state.status === 'playing', actions);
assert.ok(actions.length > 0, 'Complete the real private setup controls.');
for (const player of game.players) {
const html = table(viewGame(game, player.id));
assert.match(html, /Optional alliance rule: a two-member alliance wins with three strongholds/);
assert.match(html, /Two-controller games retain their four-stronghold target/);
}
});
@@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinControlledFaction, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
import { quoteVictory } from '../game/victory-quote';
import { advanceShortAlliance, genuineShortAlliance, shortAllianceCollection, shortAllianceLobby,
type ShortAllianceStep } from './fixture-short-alliance-victory';
void test('only the host changes the optional target, clearing readiness and preserving the saved selection', () => {
let game = shortAllianceLobby();
assert.equal(viewGame(game, 'at').shortAllianceVictory, false);
game = applyAction(game, 'at', { type: 'ready' });
game = applyAction(game, 'em', { type: 'ready' });
const before = structuredClone(game);
assert.throws(() => applyAction(game, 'em', { type: 'shortAllianceVictory', enabled: true }));
assert.throws(() => applyAction(game, 'at', { type: 'shortAllianceVictory', enabled: 'true' }));
assert.deepEqual(game, before);
game = applyAction(game, 'at', { type: 'shortAllianceVictory', enabled: true });
assert.equal(game.shortAllianceVictory, true);
assert.ok(game.players.every(player => !player.ready));
for (const player of game.players) assert.equal(viewGame(game, player.id).shortAllianceVictory, true);
const restored = JSON.parse(JSON.stringify(game)) as Game;
assert.equal(viewGame(restored, 'em').shortAllianceVictory, true);
game = applyAction(restored, 'at', { type: 'shortAllianceVictory', enabled: false });
assert.equal(viewGame(game, 'at').shortAllianceVictory, false);
assert.equal(game.shortAllianceVictory, undefined);
});
for (const enabled of [false, true]) {
void test(`Basic genuine Nexus alliance ${enabled ? 'wins' : 'does not win'} three strongholds under ${enabled ? 'short' : 'default'} rules`, () => {
const native = genuineShortAlliance(enabled);
assert.ok(native.actions.some(step => step.action.type === 'alliance' && step.actor === 'at'));
assert.ok(native.actions.some(step => step.action.type === 'alliance' && step.actor === 'em'));
const game = shortAllianceCollection(native.game);
const before = structuredClone(game);
const progress = viewGame(game, 'at').victoryProgress.find(row => row.player === 'at');
assert.ok(progress);
assert.deepEqual(new Set(progress.members), new Set(['at', 'em']));
assert.equal(progress.strongholds.length, 3);
assert.equal(progress.target, enabled ? 3 : 4);
assert.equal(progress.qualifies, enabled);
const quote = quoteVictory(game);
assert.deepEqual(new Set(quote.winner), new Set(enabled ? ['at', 'em'] : []));
let current = game;
for (const player of game.players) {
if (current.status === 'finished') break;
current = applyAction(current, player.id, { type: 'ready' });
}
assert.deepEqual(game, before, 'Checking and resolving must not mutate the staged source.');
assert.equal(current.status, enabled ? 'finished' : 'playing');
assert.deepEqual(new Set(current.winner), new Set(enabled ? ['at', 'em'] : []));
assert.equal(viewGame(JSON.parse(JSON.stringify(current)), 'at').shortAllianceVictory, enabled);
});
}
void test('two real controllers with four faction seats retain the four-stronghold target', () => {
let game = createGame('TWOALLIANCE', newPlayer('at', 'Atreides', 'atreides'));
game = applyAction(game, 'at', { type: 'twoPlayerMode', enabled: true });
joinControlledFaction(game, newPlayer('em', 'Emperor', 'emperor'), 'at');
joinGame(game, newPlayer('gu', 'Guild', 'guild'));
joinControlledFaction(game, newPlayer('fr', 'Fremen', 'fremen'), 'gu');
game = applyAction(game, 'at', { type: 'shortAllianceVictory', enabled: true });
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
game = applyAction(game, 'at', { type: 'start' });
const actions: ShortAllianceStep[] = [];
game = advanceShortAlliance(game, state => state.status === 'playing', actions);
// This controlled two-person boundary stages reciprocal alliance and board
// positions; the other cases above exercise an actual Nexus alliance.
game.players.find(player => player.id === 'at')!.ally = 'em';
game.players.find(player => player.id === 'em')!.ally = 'at';
game = shortAllianceCollection(game);
const progress = viewGame(game, 'at').victoryProgress.find(row => row.player === 'at');
assert.ok(progress);
assert.equal(progress.target, 4);
assert.equal(progress.qualifies, false);
assert.deepEqual(quoteVictory(game).winner, []);
});
+70
View File
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { type Action, type Game } from '../game/engine';
import { nextTruthRevival, truthRevivalClean, truthRevivalPlayer } from './fixture-truth-revival';
import {
actFutureBattleRevival as act, askFutureBattleRevival as ask,
createFutureBattleRevivalFixture as fixture, futureBattleRevivalInventory as inventory,
futureBattleRevivalRecords as records, reachFutureBattleRevivalBattle as reachBattle,
reachFutureBattleRevivalMovement as reachMovement,
} from './fixture-truth-future-battle-revival';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game, actor, action));
assert.deepEqual(game, before);
}
for (const answer of ['yes', 'no'] as const) {
void test(`Basic Revival future-battle leader ${answer} binds the first actual later battle`, () => {
const f = fixture(), asked = ask(f);
assert.equal(asked.phase, 4); assert.deepEqual(asked.truthtrance?.question, f.question);
let game = act(asked, f.target, {type: 'truthAnswer', answer});
assert.equal(records(game).length, 1); assert.equal(records(game)[0].status, 'waiting');
game = reachMovement(game, f);
assert.equal(records(game)[0].status, 'waiting');
game = reachBattle(game, f);
assert.equal(records(game)[0].status, 'bound');
assert.equal(records(game)[0].battle, game.battle!.event);
const plan = (leader: string): Action => ({type: 'battlePlan', leader, dial: 0, support: 0, weapon: null, defense: null});
rejected(game, f.target, plan(answer === 'yes' ? f.alternative : f.leader));
game = act(game, f.target, plan(answer === 'yes' ? f.leader : f.alternative));
assert.equal(records(game)[0].status, 'bound');
const opponent = game.battle!.attacker === f.target ? game.battle!.defender : game.battle!.attacker;
const other = truthRevivalPlayer(game, opponent).leaders.find(leader =>
!leader.dead && !leader.capturedBy && !leader.gholaBy && !leader.usedAt);
assert.ok(other);
game = act(game, opponent, plan(other.id));
assert.ok(game.battle?.revealed); assert.equal(records(game)[0].status, 'settled');
assert.equal(records(game).length, 1);
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
assert.deepEqual(inventory(game), f.originalCards);
});
}
void test('Basic Revival conditional leader answers expire if the respondent never fights', () => {
const f = fixture();
for (const answer of ['yes', 'no'] as const) {
let game = act(ask(f), f.target, {type: 'truthAnswer', answer});
game = reachMovement(game, f);
assert.ok(Object.values(truthRevivalPlayer(game, f.target).forces).every(amount => amount === 0));
for (let count = 0; count < 1200 && game.turn === f.game.turn && game.phase <= 6 && game.status === 'playing'; count++) {
if (game.phase === 5 && game.active && truthRevivalClean(game)) {
game = act(game, game.active, {type: 'endMovement'}); continue;
}
const next = nextTruthRevival(game); assert.ok(next, 'Finish the original turn without inventing a target battle.');
game = act(game, next.actor, next.action);
}
assert.equal(records(game).length, 1); assert.equal(records(game)[0].status, 'expired');
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
assert.deepEqual(inventory(game), f.originalCards);
}
});
void test('a pending native Truthtrance priority must finish before its Revival-phase question can be asked', () => {
const f = fixture();
const game = act(f.game, f.asker, {type: 'card', card: f.truth});
assert.equal(game.truthtrance?.stage, 'priority');
rejected(game, f.asker, f.ask);
assert.deepEqual(inventory(game), f.originalCards);
});
+6 -1
View File
@@ -17,7 +17,8 @@ export async function verifyLobbyConfiguration(request, adminCookie, report) {
let current = await request(url, undefined, adminCookie); let current = await request(url, undefined, adminCookie);
assert.equal(current.status, 200); assert.equal(current.rawCookie, null); assert.equal(current.status, 200); assert.equal(current.rawCookie, null);
assert.equal(current.data.editable, true); assert.equal(current.data.editable, true);
assert.deepEqual(Object.keys(current.data).sort(), ['advanced', 'blockedReason', 'code', 'control', 'editable', 'host', 'players', 'status', 'strongholdCards', 'techTokens', 'version']); assert.deepEqual(Object.keys(current.data).sort(), ['advanced', 'blockedReason', 'code', 'control', 'editable', 'host', 'players', 'shortAllianceVictory', 'status', 'strongholdCards', 'techTokens', 'version']);
assert.equal(current.data.shortAllianceVictory, false);
for (const player of current.data.players) assert.deepEqual(Object.keys(player).sort(), ['bot', 'faction', 'hostEligible', 'id', 'name', 'position', 'ready']); for (const player of current.data.players) assert.deepEqual(Object.keys(player).sort(), ['bot', 'faction', 'hostEligible', 'id', 'name', 'position', 'ready']);
const makeInput = action => ({ operationId: randomUUID(), expectedVersion: current.data.version, action, reason: 'Dedicated lobby configuration acceptance' }); const makeInput = action => ({ operationId: randomUUID(), expectedVersion: current.data.version, action, reason: 'Dedicated lobby configuration acceptance' });
const firstInput = makeInput({ type: 'rules', advanced: true }); const firstInput = makeInput({ type: 'rules', advanced: true });
@@ -33,6 +34,8 @@ export async function verifyLobbyConfiguration(request, adminCookie, report) {
return result; return result;
} }
await apply(firstInput); assert.equal(current.data.advanced, true); await apply(firstInput); assert.equal(current.data.advanced, true);
await apply(makeInput({ type: 'shortAllianceVictory', enabled: true }));
assert.equal(current.data.shortAllianceVictory, true);
await apply(makeInput({ type: 'techTokens', enabled: true })); await apply(makeInput({ type: 'techTokens', enabled: true }));
await apply(makeInput({ type: 'strongholdCards', enabled: true })); await apply(makeInput({ type: 'strongholdCards', enabled: true }));
await apply(makeInput({ type: 'addBot', faction: 'guild', difficulty: 'Easy' })); await apply(makeInput({ type: 'addBot', faction: 'guild', difficulty: 'Easy' }));
@@ -44,6 +47,8 @@ export async function verifyLobbyConfiguration(request, adminCookie, report) {
assert.equal(current.data.players.length, 2); assert.equal(current.data.players.length, 2);
await apply(makeInput({ type: 'rules', advanced: false })); assert.equal(current.data.strongholdCards, false); await apply(makeInput({ type: 'rules', advanced: false })); assert.equal(current.data.strongholdCards, false);
await apply(makeInput({ type: 'techTokens', enabled: false })); await apply(makeInput({ type: 'techTokens', enabled: false }));
await apply(makeInput({ type: 'shortAllianceVictory', enabled: false }));
assert.equal(current.data.shortAllianceVictory, false);
const stale = await request(url, { ...firstInput, operationId: randomUUID() }, adminCookie); const stale = await request(url, { ...firstInput, operationId: randomUUID() }, adminCookie);
assert.equal(stale.status, 409); assert.equal(stale.rawCookie, null); assert.equal(stale.status, 409); assert.equal(stale.rawCookie, null);
report.checks.push('Neutral admin rules/modules and AI add/configure/remove persist; public-only projection, origin/account/host denial and stale version rejection'); report.checks.push('Neutral admin rules/modules and AI add/configure/remove persist; public-only projection, origin/account/host denial and stale version rejection');