Support staged group Emperor aid
Verify and publish checkpoint container / container (push) Successful in 10m13s

This commit is contained in:
Quintine committed 2026-10-28 23:33:37 +11:00
1 parent b89c225d5b
commit 9081249574
10 files changed
+413 -17

No files matched your search

+9 -6
View File
@@ -3426,8 +3426,11 @@ function policyActions(g: GameView): Action[] {
}
if (g.fremenRevivalGrant && !g.fremenRevivalGrant.granted && !g.fremenRevivalGrant.blocked)
actions.push({ type: 'grantRevival' });
if (me.faction === 'emperor' && me.ally && level > 0) {
const ally = g.players.find((p) => p.id === me.ally)!;
const emperorGroup = g.coalitionEmperor;
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 =
g.revivalPrevention?.player === ally.id
? 0
@@ -3439,7 +3442,7 @@ function policyActions(g: GameView): Action[] {
eliteRevivalRemaining(ally, g.advanced),
),
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--) {
const minimumElite = Math.max(
@@ -3450,8 +3453,8 @@ function policyActions(g: GameView): Action[] {
Math.min(amount, eliteRevivalRemaining(ally, g.advanced)),
minimumElite,
]))
if (paidForceRevivalCost(ally, amount, elite) <= (me.spice ?? 0) - 3)
actions.push({ type: 'emperorRevival', amount, elite });
if (paidForceRevivalCost(ally, amount, elite) <= available - 3)
actions.push({ type: 'emperorRevival', amount, elite, ...(emperorGroup ? {target: ally.id} : {}) });
}
}
const freeRemaining = g.revival.freeRemaining;
@@ -3505,7 +3508,7 @@ function policyActions(g: GameView): Action[] {
return !g.revival.orderBlocked &&
(action.type === 'ready' || action.type === 'cancelLeaderRequest' || !g.revival.prevented);
if (action.type === 'emperorRevival' && g.revivalOrder)
return g.revivalOrder.remaining[0] === me.ally;
return g.revivalOrder.remaining[0] === (action.target ?? me.ally);
return true;
});
}
+20 -6
View File
@@ -16060,6 +16060,14 @@ function coalitionFundingAvailable(g: Game, p: Player, committedBid = false): bo
const group = coalitionRule(() => coalitionOf(g.players, g.coalitionState, p.id));
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 {
return coalitionRule(() => groupGuildShipmentRate(g.players, p.id, g.coalitionState));
}
@@ -44640,11 +44648,12 @@ function applyActionInner(
return g;
}
if (t === 'emperorGift' || t === 'emperorRevival') {
requireRule(
p.faction === 'emperor' && p.ally,
'Only the Emperor may use this power for an ally.',
);
const recipient = getPlayer(g, p.ally);
const group = coalitionEmperorGroup(g, p);
const recipientId = group ? stringField(action.target) : p.ally;
requireRule(p.faction === 'emperor' && recipientId &&
(group ? recipientId !== id && group.members.includes(recipientId) : recipientId === p.ally),
'Only the Emperor may use this power for an ally.');
const recipient = getPlayer(g, recipientId);
if (t === 'emperorRevival') {
requireRule(
!revivalPrevented(g, recipient.id),
@@ -44685,7 +44694,7 @@ function applyActionInner(
);
if (t === 'emperorRevival')
requireRule(
p.spice >= paidForceRevivalCost(recipient, amount, elite),
uncommittedSpice(g, p) >= paidForceRevivalCost(recipient, amount, elite),
'Not enough spice for these extra revivals.',
);
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
: 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) ? (() => {
const group = coalitionRule(() => coalitionOf(g.players, g.coalitionState, id))!;
const pledges = g.coalitionEscrow!.pledges;
+4 -4
View File
@@ -4500,11 +4500,11 @@ 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: '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 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: '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: '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: [
'mentat',
+14 -1
View File
@@ -3,6 +3,7 @@ import {
GuildAmbassadorContinuationError,
} from './guild-ambassador-continuation';
import type { Game, ResponseWindow } from './engine';
import { coalitionOf, CoalitionAllianceError } from './coalition-alliance';
import {
homeworldMovementForesightBlock,
homeworldSpiritualAdvisorAllowance,
@@ -57,6 +58,7 @@ export type TerminalCancellationContext = Pick<
| 'turn'
| 'advanced'
| 'players'
| 'coalitionState'
| 'auction'
| 'richeseAuction'
| 'stormCard'
@@ -138,10 +140,21 @@ export function validateTerminalCancellation(
case 'emperorRevival': {
faction('emperor');
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(
recipient() &&
response.recipient !== owner.id &&
owner.ally === response.recipient,
allied,
'The canceled Emperor power needs its originally declared ally.',
);
const elite = response.elite ?? 0;