Add optional short alliance victory
Verify and publish checkpoint container / container (push) Successful in 11m2s
Verify and publish checkpoint container / container (push) Successful in 11m2s
This commit is contained in:
1 parent
8fb9f59106
commit
e896570f07
25 files changed
+632
-27
No files matched your search
@@ -77,19 +77,21 @@ export function AdminLobbyControls({ accountId, code, canManage, onClose, onUpda
|
||||
}
|
||||
function selectKind(next: AdminLobbyAction['type']) {
|
||||
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 === '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 ?? '');
|
||||
}
|
||||
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 === 'configureBot' ? { type: kind, target, faction, difficulty, position } : { type: kind, 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 actionable = lobby?.editable && (kind === 'rules' ? 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 === 'assignHost' ? selectedPlayer?.hostEligible && target !== lobby.host :
|
||||
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>
|
||||
{notice && <output className="admin-notice">{notice}</output>}
|
||||
{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>
|
||||
<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'}
|
||||
@@ -159,14 +161,15 @@ export function AdminLobbyControls({ accountId, code, canManage, onClose, onUpda
|
||||
<Button disabled={busy} onClick={() => void submit(pending)}>Retry saved lobby request</Button>
|
||||
</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'])}>
|
||||
<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>
|
||||
</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>
|
||||
</select></label>}
|
||||
{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 === '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)}>
|
||||
<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>}
|
||||
|
||||
@@ -2104,6 +2104,9 @@ export function GameTable({
|
||||
{g.status === 'playing' && <MixedBidPromises game={g} send={act} busy={busy} />}
|
||||
{g.status === 'playing' && <MixedBattlePromises 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' && <TravelPromises game={g} busy={busy} act={act} />}
|
||||
{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">
|
||||
Printed Kull is inactive in this lobby. Its preview requires a supported {g.advanced ? 'Advanced' : 'Basic'} configuration and may need different module selections.
|
||||
</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">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -114,6 +114,7 @@ function missing(code: string, version: number): AdminLobbyView {
|
||||
advanced: false,
|
||||
techTokens: false,
|
||||
strongholdCards: false,
|
||||
shortAllianceVictory: false,
|
||||
players: [],
|
||||
control: { paused: false, joinLocked: false, revision: 0, updatedAt: null },
|
||||
};
|
||||
@@ -149,6 +150,7 @@ const auditConfiguration = (view: AdminLobbyView) =>
|
||||
advanced: view.advanced,
|
||||
techTokens: view.techTokens,
|
||||
strongholdCards: view.strongholdCards,
|
||||
shortAllianceVictory: view.shortAllianceVictory,
|
||||
players: view.players.map(
|
||||
({ id, name, faction, bot, position, ready }) => ({
|
||||
id,
|
||||
|
||||
@@ -16,6 +16,24 @@ open mode gates.
|
||||
|
||||
|
||||
## 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.**
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
@@ -1,5 +1,12 @@
|
||||
# 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
|
||||
|
||||
The printed current-turn question can provisionally refer to a named native
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 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
|
||||
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
|
||||
@@ -13,6 +13,8 @@ ends. An independently impossible answer is released by the existing battle
|
||||
promise reconciliation. Another structured action answer for that respondent
|
||||
is guarded while the conditional answer is active; broader promise composition,
|
||||
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)
|
||||
permits game-related current-turn action questions and requires honoring an
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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') {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
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) ||
|
||||
!['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') ||
|
||||
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;
|
||||
const control = value.control;
|
||||
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 'techTokens': return `${action.enabled ? 'Enable' : 'Disable'} Tech Tokens`;
|
||||
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 'removeBot': return `Remove AI player ${player}`;
|
||||
case 'configureBot': return `Configure ${player}: ${faction}, ${action.difficulty}, circle ${action.position}`;
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { RoomControl } from './room-control';
|
||||
|
||||
export type AdminLobbyAction =
|
||||
| { type: 'rules'; advanced: boolean }
|
||||
| { type: 'techTokens' | 'strongholdCards'; enabled: boolean }
|
||||
| { type: 'techTokens' | 'strongholdCards' | 'shortAllianceVictory'; enabled: boolean }
|
||||
| { type: 'addBot'; faction: FactionId; difficulty: Difficulty }
|
||||
| { type: 'removeBot' | 'assignHost'; target: string }
|
||||
| {
|
||||
@@ -30,6 +30,7 @@ export type AdminLobbyView = {
|
||||
advanced: boolean;
|
||||
techTokens: boolean;
|
||||
strongholdCards: boolean;
|
||||
shortAllianceVictory: boolean;
|
||||
players: {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -84,7 +85,7 @@ export function validAdminLobbyInput(value: unknown): value is AdminLobbyInput {
|
||||
return (
|
||||
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 (
|
||||
keys(action, ['type', 'enabled']) && typeof action.enabled === 'boolean'
|
||||
);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import test from 'node:test';
|
||||
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 { DEFAULT_ROOM_CONTROL } from '../lib/room-control';
|
||||
|
||||
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 }] });
|
||||
function storage() {
|
||||
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 }] }])
|
||||
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'));
|
||||
});
|
||||
@@ -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[] = [
|
||||
{ type: 'rules', advanced: true },
|
||||
{ type: 'techTokens', enabled: true },
|
||||
{ type: 'strongholdCards', enabled: false },
|
||||
{ type: 'shortAllianceVictory', enabled: true },
|
||||
{ type: 'addBot', faction: 'guild', difficulty: 'Hard' },
|
||||
{ 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: 'guild', difficulty: 'Impossible' },
|
||||
{ type: 'rules', advanced: 'true' },
|
||||
{ type: 'shortAllianceVictory', enabled: 'true' },
|
||||
{ type: 'shortAllianceVictory', enabled: true, extra: true },
|
||||
{ type: 'assignHost', target: '' },
|
||||
{ type: 'assignHost', target: 'guest', token: secret() },
|
||||
{
|
||||
@@ -264,6 +267,7 @@ void test('viewer receives only public lobby configuration and active-human host
|
||||
'editable',
|
||||
'host',
|
||||
'players',
|
||||
'shortAllianceVictory',
|
||||
'status',
|
||||
'strongholdCards',
|
||||
'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 () => {
|
||||
const f = await fixture();
|
||||
try {
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -17,7 +17,8 @@ export async function verifyLobbyConfiguration(request, adminCookie, report) {
|
||||
let current = await request(url, undefined, adminCookie);
|
||||
assert.equal(current.status, 200); assert.equal(current.rawCookie, null);
|
||||
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']);
|
||||
const makeInput = action => ({ operationId: randomUUID(), expectedVersion: current.data.version, action, reason: 'Dedicated lobby configuration acceptance' });
|
||||
const firstInput = makeInput({ type: 'rules', advanced: true });
|
||||
@@ -33,6 +34,8 @@ export async function verifyLobbyConfiguration(request, adminCookie, report) {
|
||||
return result;
|
||||
}
|
||||
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: 'strongholdCards', enabled: true }));
|
||||
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);
|
||||
await apply(makeInput({ type: 'rules', advanced: false })); assert.equal(current.data.strongholdCards, 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);
|
||||
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');
|
||||
|
||||
Reference in new issue
Block a user