Support staged group Emperor aid
Verify and publish checkpoint container / container (push) Successful in 10m13s
Verify and publish checkpoint container / container (push) Successful in 10m13s
This commit is contained in:
1 parent
b89c225d5b
commit
9081249574
10 files changed
+413
-17
No files matched your search
@@ -0,0 +1,103 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useId, useState } from 'react';
|
||||||
|
import type { Player } from '@/game/engine';
|
||||||
|
import { eliteRevivalRemaining, paidForceRevivalCost } from '@/game/revival';
|
||||||
|
|
||||||
|
/** Public alliance members only; the engine remains the legality authority. */
|
||||||
|
export type CoalitionEmperorAlly = Pick<Player, 'id' | 'name' | 'faction' | 'tanks' | 'elites'>;
|
||||||
|
export type CoalitionEmperorAidAction =
|
||||||
|
| { type: 'emperorGift'; target: string; amount: number }
|
||||||
|
| { type: 'emperorRevival'; target: string; amount: number; elite: number };
|
||||||
|
|
||||||
|
function positiveInteger(raw: string): number | null {
|
||||||
|
if (!/^[1-9]\d*$/.test(raw)) return null;
|
||||||
|
const value = Number(raw);
|
||||||
|
return Number.isSafeInteger(value) ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render for an Emperor in a staged group alliance; pair controls stay separate. */
|
||||||
|
export function CoalitionEmperorAid({ allies, phase, advanced, availableSpice,
|
||||||
|
emperorExtra, revivalOrderHead, busy = false, act }: {
|
||||||
|
allies: readonly CoalitionEmperorAlly[];
|
||||||
|
phase: number;
|
||||||
|
advanced: boolean;
|
||||||
|
/** Spice not committed to coalition pledges or other obligations. */
|
||||||
|
availableSpice: number;
|
||||||
|
emperorExtra: Readonly<Record<string, number>>;
|
||||||
|
revivalOrderHead?: string | null;
|
||||||
|
busy?: boolean;
|
||||||
|
act: (action: CoalitionEmperorAidAction) => void;
|
||||||
|
}) {
|
||||||
|
const id = useId();
|
||||||
|
const [giftTarget, setGiftTarget] = useState('');
|
||||||
|
const [giftRaw, setGiftRaw] = useState('1');
|
||||||
|
const [revivalTarget, setRevivalTarget] = useState('');
|
||||||
|
const [revivalRaw, setRevivalRaw] = useState('1');
|
||||||
|
const [eliteRaw, setEliteRaw] = useState('0');
|
||||||
|
const giftAlly = allies.find(ally => ally.id === giftTarget);
|
||||||
|
const revivalAlly = allies.find(ally => ally.id === revivalTarget);
|
||||||
|
const gift = positiveInteger(giftRaw);
|
||||||
|
const amount = positiveInteger(revivalRaw);
|
||||||
|
const elite = /^(0|[1-9]\d*)$/.test(eliteRaw) && Number.isSafeInteger(Number(eliteRaw))
|
||||||
|
? Number(eliteRaw) : null;
|
||||||
|
const maximum = revivalAlly ? Math.max(0, Math.min(
|
||||||
|
3 - (emperorExtra[revivalAlly.id] ?? 0), revivalAlly.tanks,
|
||||||
|
Math.floor(availableSpice / 2),
|
||||||
|
)) : 0;
|
||||||
|
const cost = revivalAlly && amount && elite !== null
|
||||||
|
? paidForceRevivalCost(revivalAlly, amount, elite) : null;
|
||||||
|
const eliteTanks = revivalAlly?.elites?.tanks ?? 0;
|
||||||
|
const legalElite = revivalAlly && amount && elite !== null &&
|
||||||
|
elite <= Math.min(amount, eliteRevivalRemaining(revivalAlly, advanced)) &&
|
||||||
|
amount - elite <= revivalAlly.tanks - eliteTanks;
|
||||||
|
const canGift = !!giftAlly && gift !== null && gift <= availableSpice && !busy;
|
||||||
|
const canRevive = phase === 4 && !!revivalAlly && amount !== null && amount <= maximum &&
|
||||||
|
legalElite && cost !== null && cost <= availableSpice &&
|
||||||
|
(!revivalOrderHead || revivalOrderHead === revivalAlly.id) && !busy;
|
||||||
|
|
||||||
|
return <section className="notice min-w-0 space-y-3 break-words" aria-label="Emperor group alliance aid">
|
||||||
|
<h3>Emperor alliance aid</h3>
|
||||||
|
<p className="fine">Choose which ally receives your aid. Spice and revivals take effect after the Karama response.</p>
|
||||||
|
<label htmlFor={`${id}-gift-target`}>Ally to receive spice</label>
|
||||||
|
<select id={`${id}-gift-target`} value={giftAlly?.id ?? ''} disabled={busy}
|
||||||
|
onChange={event => setGiftTarget(event.target.value)}>
|
||||||
|
<option value="">Choose an ally</option>
|
||||||
|
{allies.map(ally => <option key={ally.id} value={ally.id}>{ally.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<label htmlFor={`${id}-gift-amount`}>Spice to share</label>
|
||||||
|
<input id={`${id}-gift-amount`} type="number" inputMode="numeric" min={1} max={availableSpice}
|
||||||
|
step={1} value={giftRaw} disabled={busy} onChange={event => setGiftRaw(event.target.value)} />
|
||||||
|
<button type="button" className="game-action" disabled={!canGift}
|
||||||
|
onClick={() => { if (giftAlly && gift !== null && canGift) act({ type: 'emperorGift', target: giftAlly.id, amount: gift }); }}>
|
||||||
|
Share spice
|
||||||
|
</button>
|
||||||
|
{phase === 4 && <div className="space-y-2">
|
||||||
|
<label htmlFor={`${id}-revival-target`}>Ally to receive extra revivals</label>
|
||||||
|
<select id={`${id}-revival-target`} value={revivalAlly?.id ?? ''} disabled={busy}
|
||||||
|
onChange={event => { setRevivalTarget(event.target.value); setEliteRaw('0'); }}>
|
||||||
|
<option value="">Choose an ally</option>
|
||||||
|
{allies.map(ally => <option key={ally.id} value={ally.id}>{ally.name}</option>)}
|
||||||
|
</select>
|
||||||
|
{revivalAlly && <>
|
||||||
|
<p className="fine">{revivalAlly.name} has {revivalAlly.tanks} forces in the tanks and can receive up to {maximum} extra revivals now.</p>
|
||||||
|
{revivalOrderHead && revivalOrderHead !== revivalAlly.id && <p className="fine">Wait for this ally’s revival turn.</p>}
|
||||||
|
<label htmlFor={`${id}-revival-amount`}>Forces to revive</label>
|
||||||
|
<input id={`${id}-revival-amount`} type="number" inputMode="numeric" min={1} max={maximum}
|
||||||
|
step={1} value={revivalRaw} disabled={busy} onChange={event => setRevivalRaw(event.target.value)} />
|
||||||
|
{revivalAlly.elites && <>
|
||||||
|
<label htmlFor={`${id}-revival-elite`}>{revivalAlly.faction === 'ixians' ? 'Cyborgs' : 'Elite forces'}</label>
|
||||||
|
<input id={`${id}-revival-elite`} type="number" inputMode="numeric" min={0}
|
||||||
|
max={Math.min(amount ?? 0, eliteRevivalRemaining(revivalAlly, advanced))} step={1}
|
||||||
|
value={eliteRaw} disabled={busy} onChange={event => setEliteRaw(event.target.value)} />
|
||||||
|
</>}
|
||||||
|
{cost !== null && <p className="fine">You pay {cost} spice. This uses {revivalAlly.name}’s extra-revival allowance.</p>}
|
||||||
|
</>}
|
||||||
|
<button type="button" className="game-action" disabled={!canRevive}
|
||||||
|
onClick={() => { if (revivalAlly && amount !== null && elite !== null && canRevive)
|
||||||
|
act({ type: 'emperorRevival', target: revivalAlly.id, amount, elite }); }}>
|
||||||
|
Pay for extra ally revivals
|
||||||
|
</button>
|
||||||
|
</div>}
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
@@ -286,6 +286,7 @@ import { MoritaniExtortion, MoritaniExtortionNotice } from './moritani-extortion
|
|||||||
import { AdvancedAllySeparation } from './advanced-ally-separation';
|
import { AdvancedAllySeparation } from './advanced-ally-separation';
|
||||||
import { CoalitionNexus } from './coalition-nexus';
|
import { CoalitionNexus } from './coalition-nexus';
|
||||||
import { CoalitionFunding, coalitionFundingContributions, type CoalitionFundingContribution } from './coalition-funding';
|
import { CoalitionFunding, coalitionFundingContributions, type CoalitionFundingContribution } from './coalition-funding';
|
||||||
|
import { CoalitionEmperorAid } from './coalition-emperor-aid';
|
||||||
import { NexusEmperorPayment } from './nexus-emperor-payment';
|
import { NexusEmperorPayment } from './nexus-emperor-payment';
|
||||||
import { NexusEmperorSellerPurchase } from './nexus-emperor-seller-purchase';
|
import { NexusEmperorSellerPurchase } from './nexus-emperor-seller-purchase';
|
||||||
import { NexusEmperorAmbassadorPurchase } from './nexus-emperor-ambassador-purchase';
|
import { NexusEmperorAmbassadorPurchase } from './nexus-emperor-ambassador-purchase';
|
||||||
@@ -4539,6 +4540,11 @@ export function GameTable({
|
|||||||
)}
|
)}
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
|
{g.coalitionEmperor && <CoalitionEmperorAid
|
||||||
|
allies={g.players.filter(player => g.coalitionEmperor!.allies.includes(player.id))}
|
||||||
|
phase={g.phase} advanced={g.advanced} availableSpice={g.coalitionEmperor.availableSpice}
|
||||||
|
emperorExtra={g.emperorExtra} revivalOrderHead={g.revivalOrder?.remaining[0]}
|
||||||
|
busy={busy} act={act} />}
|
||||||
{g.phase === 4 && (
|
{g.phase === 4 && (
|
||||||
<>
|
<>
|
||||||
<RevivalTurn game={g} act={act} busy={busy} />
|
<RevivalTurn game={g} act={act} busy={busy} />
|
||||||
|
|||||||
@@ -17,6 +17,16 @@ open mode gates.
|
|||||||
|
|
||||||
## Current checkpoint and work
|
## Current checkpoint and work
|
||||||
|
|
||||||
|
**Staged group Emperor aid — 28 October. Prototyped / Partial.** An Emperor
|
||||||
|
can choose either group ally for a spice gift or paid extra revival; the
|
||||||
|
selected recipient survives the Karama response. Separate per-ally revival
|
||||||
|
allowances are provisional. Controls and minimal targeted bot actions are
|
||||||
|
connected only for staged Basic classic groups. The five-file related batch
|
||||||
|
passes 114 cases, including real Nexus formation after a staged setup and
|
||||||
|
static control rendering. Other group benefits and normal lobby access remain
|
||||||
|
unfinished.
|
||||||
|
[Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-emperor-aid--28-october-2026).
|
||||||
|
|
||||||
**Staged group Guild-rate shipments — 28 October. Prototyped / Partial.**
|
**Staged group Guild-rate shipments — 28 October. Prototyped / Partial.**
|
||||||
Guild group members use the native ordinary reserve rate and unpaid Karama
|
Guild group members use the native ordinary reserve rate and unpaid Karama
|
||||||
response. A canceled rate preserves the selected donor shares, charging the
|
response. A canceled rate preserves the selected donor shares, charging the
|
||||||
|
|||||||
@@ -57,6 +57,20 @@ remaining faction benefits and interactions are not connected. No lobby can
|
|||||||
start a group game yet. The recipient-specific pledge and allocation procedure
|
start a group game yet. The recipient-specific pledge and allocation procedure
|
||||||
is a provisional online convention rather than a printed worked example.
|
is a provisional online convention rather than a printed worked example.
|
||||||
|
|
||||||
|
## Group Emperor aid — 28 October 2026
|
||||||
|
|
||||||
|
**Staged Basic prototype, not a selectable game mode.** The Emperor chooses
|
||||||
|
which of the two group allies receives a spice gift or paid extra revival.
|
||||||
|
The ordinary Karama response retains the selected recipient, and payment and
|
||||||
|
revival occur only if that response is passed. A staged table exposes a
|
||||||
|
recipient selector; bots can offer legal targeted revivals. The native
|
||||||
|
per-recipient allowance is provisionally applied separately to both allies:
|
||||||
|
the printed power and [publisher FAQ](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf)
|
||||||
|
describe an ally but do not settle a three-member alliance's limit. This is
|
||||||
|
an implementation interpretation, not an official ruling. The prototype is
|
||||||
|
restricted to Basic classic factions without optional modules; other group
|
||||||
|
faction benefits and combinations remain unfinished and no lobby exposes it.
|
||||||
|
|
||||||
## Group Nexus negotiation — 28 October 2026
|
## Group Nexus negotiation — 28 October 2026
|
||||||
|
|
||||||
**Integrated groundwork, not a selectable game mode.** In a staged Basic
|
**Integrated groundwork, not a selectable game mode.** In a staged Basic
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Rule decision index
|
# Rule decision index
|
||||||
|
|
||||||
|
## Group Emperor aid — 28 October 2026
|
||||||
|
|
||||||
|
The staged Basic group prototype provisionally gives each ally a separate
|
||||||
|
three-force Emperor-funded allowance. The printed text and publisher FAQ do
|
||||||
|
not settle the multi-ally limit. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-emperor-aid--28-october-2026).
|
||||||
|
|
||||||
## Group Guild-rate shipments — 28 October 2026
|
## Group Guild-rate shipments — 28 October 2026
|
||||||
|
|
||||||
Guild's native rate provisionally applies to each group ally. Karama reprices
|
Guild's native rate provisionally applies to each group ally. Karama reprices
|
||||||
|
|||||||
+9
-6
@@ -3426,8 +3426,11 @@ function policyActions(g: GameView): Action[] {
|
|||||||
}
|
}
|
||||||
if (g.fremenRevivalGrant && !g.fremenRevivalGrant.granted && !g.fremenRevivalGrant.blocked)
|
if (g.fremenRevivalGrant && !g.fremenRevivalGrant.granted && !g.fremenRevivalGrant.blocked)
|
||||||
actions.push({ type: 'grantRevival' });
|
actions.push({ type: 'grantRevival' });
|
||||||
if (me.faction === 'emperor' && me.ally && level > 0) {
|
const emperorGroup = g.coalitionEmperor;
|
||||||
const ally = g.players.find((p) => p.id === me.ally)!;
|
const emperorAllies = me.ally ? [me.ally] : emperorGroup?.allies ?? [];
|
||||||
|
if (me.faction === 'emperor' && level > 0) for (const allyId of emperorAllies) {
|
||||||
|
const ally = g.players.find((p) => p.id === allyId)!;
|
||||||
|
const available = emperorGroup?.availableSpice ?? me.spice ?? 0;
|
||||||
const extra =
|
const extra =
|
||||||
g.revivalPrevention?.player === ally.id
|
g.revivalPrevention?.player === ally.id
|
||||||
? 0
|
? 0
|
||||||
@@ -3439,7 +3442,7 @@ function policyActions(g: GameView): Action[] {
|
|||||||
eliteRevivalRemaining(ally, g.advanced),
|
eliteRevivalRemaining(ally, g.advanced),
|
||||||
),
|
),
|
||||||
3 - (g.emperorExtra[ally.id] ?? 0),
|
3 - (g.emperorExtra[ally.id] ?? 0),
|
||||||
Math.floor(Math.max(0, (me.spice ?? 0) - 3) / 2),
|
Math.floor(Math.max(0, available - 3) / 2),
|
||||||
);
|
);
|
||||||
for (let amount = extra; amount > 0; amount--) {
|
for (let amount = extra; amount > 0; amount--) {
|
||||||
const minimumElite = Math.max(
|
const minimumElite = Math.max(
|
||||||
@@ -3450,8 +3453,8 @@ function policyActions(g: GameView): Action[] {
|
|||||||
Math.min(amount, eliteRevivalRemaining(ally, g.advanced)),
|
Math.min(amount, eliteRevivalRemaining(ally, g.advanced)),
|
||||||
minimumElite,
|
minimumElite,
|
||||||
]))
|
]))
|
||||||
if (paidForceRevivalCost(ally, amount, elite) <= (me.spice ?? 0) - 3)
|
if (paidForceRevivalCost(ally, amount, elite) <= available - 3)
|
||||||
actions.push({ type: 'emperorRevival', amount, elite });
|
actions.push({ type: 'emperorRevival', amount, elite, ...(emperorGroup ? {target: ally.id} : {}) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const freeRemaining = g.revival.freeRemaining;
|
const freeRemaining = g.revival.freeRemaining;
|
||||||
@@ -3505,7 +3508,7 @@ function policyActions(g: GameView): Action[] {
|
|||||||
return !g.revival.orderBlocked &&
|
return !g.revival.orderBlocked &&
|
||||||
(action.type === 'ready' || action.type === 'cancelLeaderRequest' || !g.revival.prevented);
|
(action.type === 'ready' || action.type === 'cancelLeaderRequest' || !g.revival.prevented);
|
||||||
if (action.type === 'emperorRevival' && g.revivalOrder)
|
if (action.type === 'emperorRevival' && g.revivalOrder)
|
||||||
return g.revivalOrder.remaining[0] === me.ally;
|
return g.revivalOrder.remaining[0] === (action.target ?? me.ally);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-6
@@ -16060,6 +16060,14 @@ function coalitionFundingAvailable(g: Game, p: Player, committedBid = false): bo
|
|||||||
const group = coalitionRule(() => coalitionOf(g.players, g.coalitionState, p.id));
|
const group = coalitionRule(() => coalitionOf(g.players, g.coalitionState, p.id));
|
||||||
return group?.members.length === 3;
|
return group?.members.length === 3;
|
||||||
}
|
}
|
||||||
|
function coalitionEmperorGroup(g: Game, p: Player) {
|
||||||
|
if (!g.coalitionState || p.faction !== 'emperor' || g.status !== 'playing' || g.advanced ||
|
||||||
|
g.expansions.length || g.homeworlds || g.nexusCards || g.leaderSkills || g.techTokens ||
|
||||||
|
g.strongholdCards || g.ecazTreachery || g.discoveryEnabled ||
|
||||||
|
!g.players.every(seat => ['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(seat.faction))) return null;
|
||||||
|
const group = coalitionRule(() => coalitionOf(g.players, g.coalitionState, p.id));
|
||||||
|
return group?.members.length === 3 ? group : null;
|
||||||
|
}
|
||||||
function guildGroupAlly(g: Game, p: Player): boolean {
|
function guildGroupAlly(g: Game, p: Player): boolean {
|
||||||
return coalitionRule(() => groupGuildShipmentRate(g.players, p.id, g.coalitionState));
|
return coalitionRule(() => groupGuildShipmentRate(g.players, p.id, g.coalitionState));
|
||||||
}
|
}
|
||||||
@@ -44640,11 +44648,12 @@ function applyActionInner(
|
|||||||
return g;
|
return g;
|
||||||
}
|
}
|
||||||
if (t === 'emperorGift' || t === 'emperorRevival') {
|
if (t === 'emperorGift' || t === 'emperorRevival') {
|
||||||
requireRule(
|
const group = coalitionEmperorGroup(g, p);
|
||||||
p.faction === 'emperor' && p.ally,
|
const recipientId = group ? stringField(action.target) : p.ally;
|
||||||
'Only the Emperor may use this power for an ally.',
|
requireRule(p.faction === 'emperor' && recipientId &&
|
||||||
);
|
(group ? recipientId !== id && group.members.includes(recipientId) : recipientId === p.ally),
|
||||||
const recipient = getPlayer(g, p.ally);
|
'Only the Emperor may use this power for an ally.');
|
||||||
|
const recipient = getPlayer(g, recipientId);
|
||||||
if (t === 'emperorRevival') {
|
if (t === 'emperorRevival') {
|
||||||
requireRule(
|
requireRule(
|
||||||
!revivalPrevented(g, recipient.id),
|
!revivalPrevented(g, recipient.id),
|
||||||
@@ -44685,7 +44694,7 @@ function applyActionInner(
|
|||||||
);
|
);
|
||||||
if (t === 'emperorRevival')
|
if (t === 'emperorRevival')
|
||||||
requireRule(
|
requireRule(
|
||||||
p.spice >= paidForceRevivalCost(recipient, amount, elite),
|
uncommittedSpice(g, p) >= paidForceRevivalCost(recipient, amount, elite),
|
||||||
'Not enough spice for these extra revivals.',
|
'Not enough spice for these extra revivals.',
|
||||||
);
|
);
|
||||||
if (liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length ||
|
if (liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length ||
|
||||||
@@ -47019,6 +47028,11 @@ export function viewGame(state: Game, id: string, now?: number) {
|
|||||||
? g.aid[me.ally].amount
|
? g.aid[me.ally].amount
|
||||||
: 0,
|
: 0,
|
||||||
},
|
},
|
||||||
|
coalitionEmperor: (() => {
|
||||||
|
const group = coalitionEmperorGroup(g, me);
|
||||||
|
return group ? {allies: group.members.filter(member => member !== id),
|
||||||
|
availableSpice: Math.max(0, uncommittedSpice(g, me))} : null;
|
||||||
|
})(),
|
||||||
coalitionFunding: coalitionFundingAvailable(g, me) ? (() => {
|
coalitionFunding: coalitionFundingAvailable(g, me) ? (() => {
|
||||||
const group = coalitionRule(() => coalitionOf(g.players, g.coalitionState, id))!;
|
const group = coalitionRule(() => coalitionOf(g.players, g.coalitionState, id))!;
|
||||||
const pledges = g.coalitionEscrow!.pledges;
|
const pledges = g.coalitionEscrow!.pledges;
|
||||||
|
|||||||
+4
-4
@@ -4500,11 +4500,11 @@ 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: [
|
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: 'Implementation', status: 'Partial', detail: 'A host-selectable shorter two-member target shares victory calculation. Unexposed staged Basic groups connect Nexus, selected funding and the Emperor’s targeted aid; other group benefits remain open.', evidence: ['game/alliance-victory-option.ts', 'game/victory-progress.ts', 'game/victory-quote.ts', 'game/engine.ts', 'game/terminal-cancellation.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: 'Player controls', status: 'Partial', detail: 'The lobby offers the pair option. A staged group Emperor can choose either ally for spice and extra revival; group games cannot yet be started from the lobby.', evidence: ['components/game-table.tsx', 'components/coalition-emperor-aid.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: 'AI', status: 'Partial', detail: 'Legal alliance and victory paths use the shared target; staged group Emperor bots can offer a targeted revival. 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: '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'] },
|
{ area: 'Verification', status: 'Partial', detail: 'Focused pair and staged group evidence includes real Nexus actions and Emperor aid; larger alliances and full combinations remain open.', evidence: ['tests/alliance-victory-option.test.ts', 'tests/short-alliance-victory-runtime.test.ts', 'tests/coalition-emperor-aid.test.ts'] },
|
||||||
],
|
],
|
||||||
related: [
|
related: [
|
||||||
'mentat',
|
'mentat',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
GuildAmbassadorContinuationError,
|
GuildAmbassadorContinuationError,
|
||||||
} from './guild-ambassador-continuation';
|
} from './guild-ambassador-continuation';
|
||||||
import type { Game, ResponseWindow } from './engine';
|
import type { Game, ResponseWindow } from './engine';
|
||||||
|
import { coalitionOf, CoalitionAllianceError } from './coalition-alliance';
|
||||||
import {
|
import {
|
||||||
homeworldMovementForesightBlock,
|
homeworldMovementForesightBlock,
|
||||||
homeworldSpiritualAdvisorAllowance,
|
homeworldSpiritualAdvisorAllowance,
|
||||||
@@ -57,6 +58,7 @@ export type TerminalCancellationContext = Pick<
|
|||||||
| 'turn'
|
| 'turn'
|
||||||
| 'advanced'
|
| 'advanced'
|
||||||
| 'players'
|
| 'players'
|
||||||
|
| 'coalitionState'
|
||||||
| 'auction'
|
| 'auction'
|
||||||
| 'richeseAuction'
|
| 'richeseAuction'
|
||||||
| 'stormCard'
|
| 'stormCard'
|
||||||
@@ -138,10 +140,21 @@ export function validateTerminalCancellation(
|
|||||||
case 'emperorRevival': {
|
case 'emperorRevival': {
|
||||||
faction('emperor');
|
faction('emperor');
|
||||||
amount();
|
amount();
|
||||||
|
let allied = owner.ally === response.recipient;
|
||||||
|
if (!allied && g.coalitionState) {
|
||||||
|
try {
|
||||||
|
const group = coalitionOf(g.players, g.coalitionState, owner.id);
|
||||||
|
allied = !!group && group.members.length === 3 && !!response.recipient &&
|
||||||
|
group.members.includes(response.recipient);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof CoalitionAllianceError) throw new TerminalCancellationError(error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
requireTerminal(
|
requireTerminal(
|
||||||
recipient() &&
|
recipient() &&
|
||||||
response.recipient !== owner.id &&
|
response.recipient !== owner.id &&
|
||||||
owner.ally === response.recipient,
|
allied,
|
||||||
'The canceled Emperor power needs its originally declared ally.',
|
'The canceled Emperor power needs its originally declared ally.',
|
||||||
);
|
);
|
||||||
const elite = response.elite ?? 0;
|
const elite = response.elite ?? 0;
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { baseDeck } from '../game/cards';
|
||||||
|
import { createElement } from 'react';
|
||||||
|
import { renderToStaticMarkup } from 'react-dom/server';
|
||||||
|
import { CoalitionEmperorAid } from '../components/coalition-emperor-aid';
|
||||||
|
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
|
||||||
|
import { botActions } from '../game/bots';
|
||||||
|
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||||
|
|
||||||
|
const send = (game: Game, id: string, action: Action) => applyAction(game, id, action);
|
||||||
|
const player = (game: Game, id: string) => game.players.find(seat => seat.id === id)!;
|
||||||
|
|
||||||
|
/** Stage a Basic Nexus because group play has no lobby entry. The actual
|
||||||
|
* alliance and unanimous admission actions create the three-member group.
|
||||||
|
* Tanks and reserves are subsequently set to conserved ordinary forces. */
|
||||||
|
function fixture(phase = 4) {
|
||||||
|
let game = createGame('GROUPEMP', newPlayer('e', 'Emperor', 'emperor'), false);
|
||||||
|
game.players.push(newPlayer('a', 'Atreides', 'atreides'),
|
||||||
|
newPlayer('h', 'Harkonnen', 'harkonnen'), newPlayer('g', 'Guild', 'guild'));
|
||||||
|
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:['e','a','h','g'],
|
||||||
|
ready:[], storm:18, deck:baseDeck(), response:null, decision:null, phaseOpening:null});
|
||||||
|
game.coalitionState = emptyCoalitionState();
|
||||||
|
game = send(game, 'e', {type:'alliance', target:'a'});
|
||||||
|
game = send(game, 'a', {type:'alliance', target:'e'});
|
||||||
|
const pair = coalitionOf(game.players, game.coalitionState, 'e'); assert.ok(pair);
|
||||||
|
game = send(game, 'e', {type:'coalitionPropose', groupId:pair.id, entrant:'h',
|
||||||
|
expectedRevision:game.coalitionState!.revision});
|
||||||
|
for (const id of ['a', 'h']) {
|
||||||
|
const proposal = game.coalitionState!.proposal!;
|
||||||
|
game = send(game, id, {type:'coalitionAnswer', event:proposal.event, nexus:proposal.nexus,
|
||||||
|
turn:proposal.turn, accept:true, expectedRevision:game.coalitionState!.revision});
|
||||||
|
}
|
||||||
|
assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'e')!.members, ['a','e','h']);
|
||||||
|
Object.assign(game, {phase, nexus:false, ready:[], response:null, decision:null});
|
||||||
|
for (const seat of game.players) {
|
||||||
|
seat.forces = {}; seat.reserves = 20; seat.tanks = 0; seat.spice = 10;
|
||||||
|
seat.hand = []; seat.traitors = []; seat.traitorChoices = [];
|
||||||
|
}
|
||||||
|
for (const id of ['a','h']) {
|
||||||
|
player(game, id).reserves = 16;
|
||||||
|
player(game, id).tanks = 4;
|
||||||
|
}
|
||||||
|
const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
|
||||||
|
const [karama] = game.deck.splice(index, 1);
|
||||||
|
player(game, 'g').hand.push(karama);
|
||||||
|
return {game, karama};
|
||||||
|
}
|
||||||
|
function passResponses(game: Game): Game {
|
||||||
|
for (let count = 0; game.response && count < 24; count++) {
|
||||||
|
const next = game.players.find(seat => !game.response!.passed.includes(seat.id));
|
||||||
|
assert.ok(next, 'Each response has a remaining eligible player.');
|
||||||
|
game = send(game, next.id, {type:'passResponse'});
|
||||||
|
}
|
||||||
|
assert.equal(game.response ?? null, null);
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
function reject(game: Game, id: string, action: Action) {
|
||||||
|
const before = structuredClone(game);
|
||||||
|
assert.throws(() => send(game, id, action));
|
||||||
|
assert.deepEqual(game, before);
|
||||||
|
}
|
||||||
|
function conserved(game: Game) {
|
||||||
|
for (const seat of game.players)
|
||||||
|
assert.equal(seat.reserves + seat.tanks + Object.values(seat.forces).reduce((a,b) => a+b, 0), 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('Emperor gifts can reach either group ally and settle from the Emperor wallet', () => {
|
||||||
|
let {game} = fixture(3);
|
||||||
|
let pending = send(game, 'e', {type:'emperorGift', target:'a', amount:3});
|
||||||
|
assert.equal(pending.response?.recipient, 'a');
|
||||||
|
assert.equal(player(pending, 'e').spice, 10);
|
||||||
|
game = passResponses(pending);
|
||||||
|
assert.equal(player(game, 'e').spice, 7);
|
||||||
|
assert.equal(player(game, 'a').spice, 13);
|
||||||
|
assert.equal(player(game, 'h').spice, 10);
|
||||||
|
pending = send(game, 'e', {type:'emperorGift', target:'h', amount:2});
|
||||||
|
assert.equal(pending.response?.recipient, 'h');
|
||||||
|
game = passResponses(pending);
|
||||||
|
assert.equal(player(game, 'e').spice, 5);
|
||||||
|
assert.equal(player(game, 'a').spice, 13);
|
||||||
|
assert.equal(player(game, 'h').spice, 12);
|
||||||
|
reject(game, 'e', {type:'emperorGift', target:'a', amount:6});
|
||||||
|
conserved(game);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('Emperor revival reaches each group ally, costs two per force and tracks their separate caps', () => {
|
||||||
|
let {game} = fixture();
|
||||||
|
player(game, 'e').spice = 12;
|
||||||
|
let pending = send(game, 'e', {type:'emperorRevival', target:'a', amount:3, elite:0});
|
||||||
|
assert.equal(pending.response?.recipient, 'a');
|
||||||
|
assert.equal(player(pending, 'a').tanks, 4);
|
||||||
|
game = passResponses(pending);
|
||||||
|
assert.equal(player(game, 'e').spice, 6);
|
||||||
|
assert.equal(player(game, 'a').tanks, 1);
|
||||||
|
assert.equal(player(game, 'a').reserves, 19);
|
||||||
|
assert.equal(player(game, 'a').revived, 0);
|
||||||
|
assert.equal(game.emperorExtra.a, 3);
|
||||||
|
reject(game, 'e', {type:'emperorRevival', target:'a', amount:1, elite:0});
|
||||||
|
pending = send(game, 'e', {type:'emperorRevival', target:'h', amount:3, elite:0});
|
||||||
|
assert.equal(pending.response?.recipient, 'h');
|
||||||
|
game = passResponses(pending);
|
||||||
|
assert.equal(player(game, 'e').spice, 0);
|
||||||
|
assert.equal(player(game, 'h').tanks, 1);
|
||||||
|
assert.equal(player(game, 'h').reserves, 19);
|
||||||
|
assert.equal(player(game, 'h').revived, 0);
|
||||||
|
assert.equal(game.emperorExtra.h, 3);
|
||||||
|
conserved(game);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('Emperor aid rejects outsiders, self, missing targets and non-Emperor actors', () => {
|
||||||
|
const {game} = fixture();
|
||||||
|
for (const type of ['emperorGift', 'emperorRevival'] as const) {
|
||||||
|
for (const target of ['g','e','missing'])
|
||||||
|
reject(game, 'e', {type, target, amount:1, elite:0});
|
||||||
|
reject(game, 'e', {type, amount:1, elite:0});
|
||||||
|
reject(game, 'a', {type, target:'h', amount:1, elite:0});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('Emperor revival requires Revival, available tanks and enough Emperor spice', () => {
|
||||||
|
const {game} = fixture(3);
|
||||||
|
reject(game, 'e', {type:'emperorRevival', target:'h', amount:1, elite:0});
|
||||||
|
game.phase = 4;
|
||||||
|
reject(game, 'e', {type:'emperorRevival', target:'h', amount:5, elite:0});
|
||||||
|
player(game, 'e').spice = 3;
|
||||||
|
reject(game, 'e', {type:'emperorRevival', target:'h', amount:2, elite:0});
|
||||||
|
const done = passResponses(send(game, 'e', {type:'emperorRevival', target:'h', amount:1, elite:0}));
|
||||||
|
assert.equal(player(done, 'e').spice, 1);
|
||||||
|
assert.equal(player(done, 'h').tanks, 3);
|
||||||
|
conserved(done);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const type of ['emperorGift', 'emperorRevival'] as const)
|
||||||
|
void test(`Karama cancels the selected group Emperor ${type} without payment or revival`, () => {
|
||||||
|
const {game, karama} = fixture();
|
||||||
|
const declared = send(game, 'e', {type, target:'h', amount:2, elite:0});
|
||||||
|
assert.equal(declared.response?.recipient, 'h');
|
||||||
|
const done = passResponses(send(declared, 'g', {type:'card', mode:'cancel', card:karama.id}));
|
||||||
|
assert.equal(player(done, 'e').spice, 10);
|
||||||
|
assert.equal(player(done, 'h').spice, 10);
|
||||||
|
assert.equal(player(done, 'h').tanks, 4);
|
||||||
|
assert.equal(done.emperorExtra.h ?? 0, 0);
|
||||||
|
conserved(done);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('a Medium Emperor bot proposes a legal targeted group revival', () => {
|
||||||
|
const {game} = fixture();
|
||||||
|
const view = viewGame(game, 'e');
|
||||||
|
view.players.find(seat => seat.id === 'e')!.bot = 'Medium';
|
||||||
|
const revival = botActions(view).find(action => action.type === 'emperorRevival');
|
||||||
|
assert.ok(revival);
|
||||||
|
assert.ok(revival.target === 'a' || revival.target === 'h');
|
||||||
|
const declared = send(game, 'e', revival);
|
||||||
|
assert.equal(declared.response?.recipient, revival.target);
|
||||||
|
conserved(passResponses(declared));
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('group Emperor controls render both recipients and require an explicit selection', () => {
|
||||||
|
const {game} = fixture();
|
||||||
|
const view = viewGame(game, 'e');
|
||||||
|
const projection = view.coalitionEmperor!;
|
||||||
|
const html = renderToStaticMarkup(createElement(CoalitionEmperorAid, {
|
||||||
|
allies:view.players.filter(seat => projection.allies.includes(seat.id)), phase:view.phase,
|
||||||
|
advanced:view.advanced, availableSpice:projection.availableSpice,
|
||||||
|
emperorExtra:view.emperorExtra, act:() => {},
|
||||||
|
}));
|
||||||
|
assert.match(html, /Atreides/);
|
||||||
|
assert.match(html, /Harkonnen/);
|
||||||
|
assert.match(html, /Choose an ally/);
|
||||||
|
assert.match(html, /disabled=""[^>]*>Share spice/);
|
||||||
|
assert.match(html, /disabled=""[^>]*>Pay for extra ally revivals/);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('a two-player alliance retains the original implicit Emperor recipient', () => {
|
||||||
|
let game = createGame('PAIREMP', newPlayer('e', 'Emperor', 'emperor'), false);
|
||||||
|
game.players.push(newPlayer('a', 'Atreides', 'atreides'), newPlayer('g', 'Guild', 'guild'));
|
||||||
|
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:['e','a','g'],
|
||||||
|
ready:[], storm:18, deck:baseDeck(), response:null, decision:null, phaseOpening:null});
|
||||||
|
game.coalitionState = emptyCoalitionState();
|
||||||
|
game = send(game, 'e', {type:'alliance', target:'a'});
|
||||||
|
game = send(game, 'a', {type:'alliance', target:'e'});
|
||||||
|
Object.assign(game, {phase:4, nexus:false, ready:[]});
|
||||||
|
for (const seat of game.players) {
|
||||||
|
seat.forces = {}; seat.reserves = 20; seat.tanks = 0; seat.spice = 10;
|
||||||
|
seat.hand = []; seat.traitors = []; seat.traitorChoices = [];
|
||||||
|
}
|
||||||
|
player(game, 'a').reserves = 18;
|
||||||
|
player(game, 'a').tanks = 2;
|
||||||
|
assert.equal(viewGame(game, 'e').coalitionEmperor, null);
|
||||||
|
game = passResponses(send(game, 'e', {type:'emperorGift', amount:2}));
|
||||||
|
assert.equal(player(game, 'a').spice, 12);
|
||||||
|
game = passResponses(send(game, 'e', {type:'emperorRevival', amount:2, elite:0}));
|
||||||
|
assert.equal(player(game, 'e').spice, 4);
|
||||||
|
assert.equal(player(game, 'a').tanks, 0);
|
||||||
|
assert.equal(game.emperorExtra.a, 2);
|
||||||
|
conserved(game);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('the staged three-member Emperor benefit is unavailable in Advanced play', () => {
|
||||||
|
const {game} = fixture();
|
||||||
|
game.advanced = true;
|
||||||
|
assert.equal(viewGame(game, 'e').coalitionEmperor, null);
|
||||||
|
reject(game, 'e', {type:'emperorGift', target:'a', amount:1});
|
||||||
|
reject(game, 'e', {type:'emperorRevival', target:'h', amount:1, elite:0});
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('group Emperor aid projects and respects spice committed to its own leading bid', () => {
|
||||||
|
let {game} = fixture(2);
|
||||||
|
Object.assign(game, {active:null, ready:[]});
|
||||||
|
for (const seat of game.players) game = send(game, seat.id, {type:'ready'});
|
||||||
|
assert.equal(game.phase, 3);
|
||||||
|
game = passResponses(game);
|
||||||
|
assert.equal(game.auction?.active, 'e');
|
||||||
|
game = send(game, 'e', {type:'bid', amount:6, allyPayment:0});
|
||||||
|
assert.equal(game.auction?.bidder, 'e');
|
||||||
|
assert.equal(viewGame(game, 'e').coalitionEmperor?.availableSpice, 4);
|
||||||
|
reject(game, 'e', {type:'emperorGift', target:'h', amount:5});
|
||||||
|
const pending = send(game, 'e', {type:'emperorGift', target:'h', amount:4});
|
||||||
|
assert.equal(pending.response?.recipient, 'h');
|
||||||
|
game = passResponses(pending);
|
||||||
|
assert.equal(player(game, 'e').spice, 6);
|
||||||
|
assert.equal(player(game, 'h').spice, 14);
|
||||||
|
assert.equal(game.auction?.bidder, 'e');
|
||||||
|
assert.equal(game.auction?.bid, 6);
|
||||||
|
assert.equal(viewGame(game, 'e').coalitionEmperor?.availableSpice, 0);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user