Allow Emperor gifts during Basic Ix phase openings
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
538247e777
commit
d4abc6bb83
9 files changed
+294
-4
No files matched your search
@@ -12,8 +12,9 @@ export function EmperorTimingGift({ game, act, busy }: {
|
||||
}) {
|
||||
const id = useId();
|
||||
const [raw, setRaw] = useState('1');
|
||||
const phaseOpening = game.emperorPhaseOpeningGift;
|
||||
const extortion = game.emperorExtortionGift;
|
||||
const offer = extortion ?? game.emperorGuildTimingGift;
|
||||
const offer = phaseOpening ?? extortion ?? game.emperorGuildTimingGift;
|
||||
if (!offer) return null;
|
||||
const amount = /^[1-9]\d*$/.test(raw) ? Number(raw) : NaN;
|
||||
const canShare = !busy && Number.isSafeInteger(amount) && amount >= 1 && amount <= offer.maximum;
|
||||
@@ -21,9 +22,11 @@ export function EmperorTimingGift({ game, act, busy }: {
|
||||
|
||||
return <section className="notice min-w-0 space-y-3 break-words" aria-label="Emperor spice gift">
|
||||
<h3>Share spice with {recipient}</h3>
|
||||
<p className="fine">{extortion
|
||||
? 'The Moritani Extortion decision resumes after the Karama response to your gift.'
|
||||
: 'The Guild timing decision resumes after the Karama response to your gift.'}</p>
|
||||
<p className="fine">{phaseOpening
|
||||
? 'Phase opening resumes after the Karama response to your gift.'
|
||||
: extortion
|
||||
? 'The Moritani Extortion decision resumes after the Karama response to your gift.'
|
||||
: 'The Guild timing decision resumes after the Karama response to your gift.'}</p>
|
||||
<label htmlFor={`${id}-amount`}>Spice to share</label>
|
||||
<input id={`${id}-amount`} type="number" inputMode="numeric" min={1} max={offer.maximum}
|
||||
step={1} value={raw} disabled={busy} onChange={event => setRaw(event.target.value)} />
|
||||
|
||||
@@ -2547,6 +2547,7 @@ export function GameTable({
|
||||
<>
|
||||
<span className="eyebrow">Phase opening</span>
|
||||
<h2>Before {PHASES[g.phase]}</h2>
|
||||
<EmperorTimingGift game={g} act={act} busy={busy} />
|
||||
<p>
|
||||
Play Amal now, or pass to begin this phase. Phase effects wait
|
||||
until everyone has passed.
|
||||
|
||||
@@ -17,6 +17,17 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Emperor gift during Basic Ix phase openings — 29 October. Prototyped /
|
||||
Partial.** A clean classic-faction Ix-deck opening now allows an Emperor to
|
||||
share spice with a mutual ally even after passing; allowance or Karama
|
||||
cancellation resumes the original opening and passes. Real Amal applies to the
|
||||
updated wallets, and a Mentat opening retains its deferred initialization.
|
||||
Thirty-seven related cases, type checking, lint and the production build pass.
|
||||
Expansion factions, optional modules and other interrupted parents remain open.
|
||||
As with Extortion, bot seats may finish the opening before a human Emperor acts;
|
||||
deployed acceptance also remains open.
|
||||
[Scope](EMPEROR_ALLIANCE_TIMING.md#basic-emperor-gift-during-ix-phase-openings--29-october-2026).
|
||||
|
||||
**Emperor gift during Moritani Extortion — 29 October. Prototyped / Partial.**
|
||||
In clean Basic and Advanced Moritani games, the Emperor can give the current
|
||||
allied payer spice before their Extortion choice; the original queue, token and
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# Emperor alliance gift timing
|
||||
|
||||
## Basic Emperor gift during Ix phase openings — 29 October 2026
|
||||
|
||||
In a clean Basic game with the Ix deck and classic factions, the Emperor may
|
||||
share spice with a mutual ally during an Amal phase opening, including after
|
||||
passing. The opening and its exact pass history pause while the gift's Karama
|
||||
response resolves; allowance transfers the spice immediately and cancellation
|
||||
transfers nothing. The same opening then resumes, and a subsequent Amal applies
|
||||
to the updated balances and resets passes normally.
|
||||
|
||||
The [publisher FAQ, p. 5](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf)
|
||||
permits direct allied gifts for any reason and immediate collection. Applying
|
||||
that power during the Basic opening is a provisional online timing
|
||||
interpretation. The initial scope excludes expansion factions, optional modules,
|
||||
competing continuations and outstanding commitments requiring a combined
|
||||
planner. Advanced and other interruption windows remain unfinished.
|
||||
[Runtime cases](../tests/emperor-phase-opening-gift.test.ts) and
|
||||
[rendered controls](../tests/emperor-timing-gift-controls.test.tsx).
|
||||
|
||||
## Emperor gift during Moritani Extortion — 29 October 2026
|
||||
|
||||
In supported Basic and Advanced games with Moritani, the Emperor can give
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# Rule decision index
|
||||
|
||||
## Basic Emperor gift during Ix phase openings — 29 October 2026
|
||||
|
||||
The publisher FAQ permits immediate direct gifts to an ally. Allowing a gift
|
||||
inside an Amal opening is a provisional Basic online timing interpretation;
|
||||
the original pass history resumes after allowance or cancellation.
|
||||
[Scope](EMPEROR_ALLIANCE_TIMING.md#basic-emperor-gift-during-ix-phase-openings--29-october-2026).
|
||||
|
||||
## Emperor gift during Moritani Extortion — 29 October 2026
|
||||
|
||||
The publisher FAQ permits direct allied spice gifts for any reason; the
|
||||
|
||||
@@ -1236,6 +1236,8 @@ export type ResponseWindow = {
|
||||
emperorGuildTimingGift?: { event: string; signature: string };
|
||||
/** The original Extortion payment decision remains live during this gift. */
|
||||
emperorExtortionGift?: { event: string; signature: string };
|
||||
/** Suspends the original Ix opening until the gift response finishes. */
|
||||
emperorPhaseOpeningGift?: { event: string; opening: { passed: string[]; initialize: boolean }; signature: string };
|
||||
/** Links an allied auction gift to the still-pending payment opportunity. */
|
||||
emperorAuctionGiftEvent?: string;
|
||||
automaticAdvisorFlip?: AutomaticAdvisorFlipSource;
|
||||
@@ -19432,6 +19434,7 @@ function validateKaramaUse(
|
||||
if (use.kind === 'cancel') {
|
||||
validateEmperorGuildTimingGift(g, use.response);
|
||||
validateEmperorExtortionGift(g, use.response);
|
||||
validateEmperorPhaseOpeningGift(g, use.response);
|
||||
requireRule(
|
||||
use.response && use.response.owner !== p.id,
|
||||
'Karama cancels another faction’s power.',
|
||||
@@ -29401,6 +29404,53 @@ function emperorGuildTimingGiftIntegrity(g: Game) {
|
||||
if (g.pendingKarama?.use.kind === 'cancel')
|
||||
validateEmperorGuildTimingGift(g, g.pendingKarama.use.response);
|
||||
}
|
||||
function emperorPhaseOpeningGiftSignature(g: Game, opening: {passed: string[]; initialize: boolean}, owner: string, recipient: string, amount: number): string {
|
||||
return JSON.stringify([g.turn, g.phase, g.active, g.order, g.ready, g.amalWindowOpen ?? false,
|
||||
opening, owner, recipient, amount]);
|
||||
}
|
||||
/** Initial Basic Ix opening support, with no independent competing windows. */
|
||||
function emperorPhaseOpeningGiftQuote(g: Game, p: Player): {recipient: string; maximum: number} | null {
|
||||
if (g.advanced || g.status !== 'playing' || g.expansions.length !== 1 || g.expansions[0] !== 'ix' ||
|
||||
g.players.some(seat => !['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(seat.faction)) ||
|
||||
g.techTokens || g.strongholdCards || g.leaderSkills || g.homeworlds || g.nexusCards ||
|
||||
g.discoveryEnabled || g.sandtrout || g.coalitionState || g.semutaPreview || g.semutaRecruitsPreview ||
|
||||
!g.phaseOpening || !g.amalWindowOpen ||
|
||||
g.response || g.decision || g.truthtrance || g.pendingKarama || g.pendingTreacheryDiscard || g.battle ||
|
||||
g.pendingRicheseGift || g.pendingNullentropy || g.pendingShipment || g.pendingGuildTransport ||
|
||||
g.pendingArrivalOverlap || g.pendingAmbassador || g.pendingEmperorAuctionGift ||
|
||||
p.faction !== 'emperor' || !p.ally ||
|
||||
!g.players.some(seat => seat.id === p.ally && seat.ally === p.id) ||
|
||||
liveShipmentPromises(g.shipmentPromises ?? [], p.id, g.turn).length ||
|
||||
hasMovementPromises(g, p.id, true) || hasBribePromises(g, p.id, true) ||
|
||||
liveRevivalPromises(g.revivalPromises ?? [], p.id, g.turn).some(row => row.answer) ||
|
||||
liveLeaderRevivalPromises(g.leaderRevivalPromises ?? [], p.id, g.turn).some(row => row.answer) ||
|
||||
liveRevivalExpressionPromises(g.revivalExpressionPromises ?? [], p.id, g.turn).some(row => row.answer) ||
|
||||
liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn).length ||
|
||||
livePhaseBidPromises(g.phaseBidPromises ?? [], p.id, g.turn).some(row => row.answer)) return null;
|
||||
const maximum = Math.max(0, uncommittedSpice(g, p));
|
||||
return maximum > 0 ? {recipient: p.ally, maximum} : null;
|
||||
}
|
||||
function validateEmperorPhaseOpeningGift(g: Game, response: ResponseWindow) {
|
||||
const parent = response.emperorPhaseOpeningGift;
|
||||
if (!parent) return;
|
||||
const owner = g.players.find(player => player.id === response.owner);
|
||||
const recipient = g.players.find(player => player.id === response.recipient);
|
||||
requireRule(response.kind === 'emperorGift' && typeof parent.event === 'string' && !!parent.event &&
|
||||
parent.opening && Array.isArray(parent.opening.passed) && typeof parent.opening.initialize === 'boolean' &&
|
||||
parent.opening.passed.length < g.players.length &&
|
||||
new Set(parent.opening.passed).size === parent.opening.passed.length &&
|
||||
parent.opening.passed.every(id => g.players.some(player => player.id === id)) &&
|
||||
!g.phaseOpening && !g.decision && owner?.faction === 'emperor' && recipient &&
|
||||
owner.ally === recipient.id && recipient.ally === owner.id &&
|
||||
typeof response.amount === 'number' && Number.isSafeInteger(response.amount) && response.amount > 0 &&
|
||||
response.amount <= uncommittedSpice(g, owner) &&
|
||||
parent.signature === emperorPhaseOpeningGiftSignature(g, parent.opening, owner.id, recipient.id, response.amount),
|
||||
'The Emperor gift lost its original phase opening.');
|
||||
}
|
||||
function emperorPhaseOpeningGiftIntegrity(g: Game) {
|
||||
if (g.response) validateEmperorPhaseOpeningGift(g, g.response);
|
||||
if (g.pendingKarama?.use.kind === 'cancel') validateEmperorPhaseOpeningGift(g, g.pendingKarama.use.response);
|
||||
}
|
||||
function emperorExtortionGiftSignature(g: Game, owner: string, recipient: string, amount: number): string {
|
||||
return JSON.stringify([g.turn, g.phase, g.active, g.order, g.decision,
|
||||
g.moritaniExtortion, g.moritaniTerror?.placementTurn, owner, recipient, amount]);
|
||||
@@ -37214,6 +37264,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
||||
const response = g.response!;
|
||||
validateEmperorGuildTimingGift(g, response);
|
||||
validateEmperorExtortionGift(g, response);
|
||||
validateEmperorPhaseOpeningGift(g, response);
|
||||
const advisorPreparationSource = currentAdvisorPreparationResponse(g, response);
|
||||
const automaticAdvisorFlip = response.automaticAdvisorFlip ? currentAutomaticAdvisorFlip(g, response) : null;
|
||||
const fremenGrant = response.kind === 'fremenRevivalGrant' ? currentFremenRevivalGrant(g, response) : null;
|
||||
@@ -38375,6 +38426,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
||||
`${emperor.name} ${response.kind === 'emperorGift' ? 'shared spice with' : `offered ${amount} extra force revivals to`} ${recipient.name}.`,
|
||||
);
|
||||
}
|
||||
if (response.emperorPhaseOpeningGift) g.phaseOpening = structuredClone(response.emperorPhaseOpeningGift.opening);
|
||||
if (response.emperorAuctionGiftEvent) {
|
||||
requireRule(g.pendingEmperorAuctionGift?.event === response.emperorAuctionGiftEvent,
|
||||
'The Emperor gift lost its original auction opportunity.');
|
||||
@@ -42053,9 +42105,15 @@ export function applyAction(state: Game, id: string, action: Action): Game {
|
||||
* automatic deal payment; it is never supplied by a client action. */
|
||||
function applyActionWithInitiator(state: Game, id: string, action: Action, initiator: string,
|
||||
options?: {insolventDealPayment?: boolean}): Game {
|
||||
emperorPhaseOpeningGiftIntegrity(state);
|
||||
emperorExtortionGiftIntegrity(state);
|
||||
emperorAuctionGiftIntegrity(state);
|
||||
emperorGuildTimingGiftIntegrity(state);
|
||||
const phaseGiftResponse = state.response?.emperorPhaseOpeningGift ||
|
||||
(state.pendingKarama?.use.kind === 'cancel' && state.pendingKarama.use.response.emperorPhaseOpeningGift);
|
||||
if (phaseGiftResponse && action?.type !== 'advanceBots')
|
||||
requireRule(action?.type === 'passResponse' || action?.type === 'card' && action.mode === 'cancel',
|
||||
'Resolve the Emperor gift response before another action.');
|
||||
const extortionGiftResponse = state.response?.emperorExtortionGift ||
|
||||
(state.pendingKarama?.use.kind === 'cancel' && state.pendingKarama.use.response.emperorExtortionGift);
|
||||
if (extortionGiftResponse && action?.type !== 'advanceBots')
|
||||
@@ -42808,6 +42866,7 @@ function settleAutomaticContinuations(g: Game) {
|
||||
}
|
||||
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
|
||||
export function normalizeAutomaticGame(state: Game): Game {
|
||||
emperorPhaseOpeningGiftIntegrity(state);
|
||||
emperorExtortionGiftIntegrity(state);
|
||||
emperorAuctionGiftIntegrity(state);
|
||||
emperorGuildTimingGiftIntegrity(state);
|
||||
@@ -43494,6 +43553,20 @@ function applyActionInner(
|
||||
decideBiddingEnd(g, p, action);
|
||||
return g;
|
||||
}
|
||||
if (t === 'emperorGift' && g.phaseOpening && !g.response) {
|
||||
const quote = emperorPhaseOpeningGiftQuote(g, p);
|
||||
requireRule(quote && Object.keys(action).sort().join(',') === 'amount,type',
|
||||
'This phase opening gift is unavailable or no longer current.');
|
||||
const amount = integer(action.amount, 1, quote.maximum, 'Spice');
|
||||
const opening = structuredClone(g.phaseOpening);
|
||||
g.phaseOpening = null;
|
||||
g.response = { kind: 'emperorGift', owner: id, recipient: quote.recipient,
|
||||
amount, elite: 0, passed: [], emperorPhaseOpeningGift: {
|
||||
event: crypto.randomUUID(), opening,
|
||||
signature: emperorPhaseOpeningGiftSignature(g, opening, id, quote.recipient, amount),
|
||||
} };
|
||||
return g;
|
||||
}
|
||||
if (g.phaseOpening) {
|
||||
requireRule(
|
||||
g.status === 'playing',
|
||||
@@ -47374,6 +47447,7 @@ function applyActionInner(
|
||||
throw new RuleError('That action is not available.');
|
||||
}
|
||||
export function viewGame(state: Game, id: string, now?: number) {
|
||||
emperorPhaseOpeningGiftIntegrity(state);
|
||||
emperorExtortionGiftIntegrity(state);
|
||||
emperorAuctionGiftIntegrity(state);
|
||||
emperorGuildTimingGiftIntegrity(state);
|
||||
@@ -48430,6 +48504,7 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
: {}),
|
||||
...(g.response.emperorGuildTimingGift ? { emperorGuildTimingGift: undefined } : {}),
|
||||
...(g.response.emperorExtortionGift ? { emperorExtortionGift: undefined } : {}),
|
||||
...(g.response.emperorPhaseOpeningGift ? { emperorPhaseOpeningGift: undefined } : {}),
|
||||
...(g.response.emperorAuctionGiftEvent ? { emperorAuctionGiftEvent: undefined } : {}),
|
||||
}
|
||||
: null,
|
||||
@@ -48498,6 +48573,7 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
},
|
||||
emperorGuildTimingGift: emperorGuildTimingGiftQuote(g, me),
|
||||
emperorExtortionGift: emperorExtortionGiftQuote(g, me),
|
||||
emperorPhaseOpeningGift: emperorPhaseOpeningGiftQuote(g, me),
|
||||
emperorAuctionGiftPending: g.pendingEmperorAuctionGift
|
||||
? { owner: g.pendingEmperorAuctionGift.owner } : null,
|
||||
emperorAuctionGift: g.pendingEmperorAuctionGift?.owner === id && !g.response && !g.pendingKarama
|
||||
|
||||
@@ -87,6 +87,7 @@ export const FACTION_RULES: Record<
|
||||
'Receive payments for other players’ auction purchases. Your own purchases pay the bank; a Karama purchase pays no spice. A cancellation sends one auction payment to the bank.',
|
||||
'Give spice directly to your ally. After the response window it is immediately spendable, unlike a deferred bribe between non-allies.',
|
||||
'In supported Moritani games, you may also give spice while your ally is choosing whether to pay Extortion. The original choice resumes after the gift’s Karama response.',
|
||||
'In supported Basic Ix-deck games, you may give an ally spice during the Amal phase opening, even after you have passed. The original opening resumes after the gift response.',
|
||||
'During Revival, pay two spice per extra allied force, up to three extra forces per turn. These are separate from the ally’s normal three-force allowance and may be purchased before or after ordinary revivals.',
|
||||
'Spice gifts and extra revival uses have Karama response windows. Canceling a use spends neither the Emperor’s spice nor the extra-revival allowance.',
|
||||
],
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { createTruthTurnAmalFixture, truthTurnAmalInventory as inventory } from './fixture-truth-turn-amal';
|
||||
|
||||
const act = (game: Game, id: string, action: Action) => applyAction(game, id, action);
|
||||
const seat = (game: Game, id: string) => {
|
||||
const player = game.players.find(row => row.id === id); assert.ok(player); return player;
|
||||
};
|
||||
const copy = (game: Game): Game => JSON.parse(JSON.stringify(game));
|
||||
|
||||
/** Original public Basic Ix Harkonnen/Atreides/Emperor setup opens the first
|
||||
* real phase. Stage only the pair and conserved Amal/Karama custody; passes,
|
||||
* gift, response and phase initialization use native actions. */
|
||||
function fixture() {
|
||||
const source = createTruthTurnAmalFixture();
|
||||
let game = source.game;
|
||||
const original = inventory(game);
|
||||
seat(game, 'emperor').ally = 'atreides'; seat(game, 'atreides').ally = 'emperor';
|
||||
const amalIndex = seat(game, 'emperor').hand.findIndex(card => card.effect === 'amal');
|
||||
assert.ok(amalIndex >= 0);
|
||||
seat(game, 'harkonnen').hand.push(seat(game, 'emperor').hand.splice(amalIndex, 1)[0]);
|
||||
// A real held opposing Karama keeps the gift's response open; existing
|
||||
// physical copies remain in their actual zones, never fabricated.
|
||||
for (const player of game.players) {
|
||||
const cards = player.hand.filter(card => card.effect === 'karama');
|
||||
player.hand = player.hand.filter(card => card.effect !== 'karama');
|
||||
game.deck.push(...cards);
|
||||
}
|
||||
const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
|
||||
const [counter] = game.deck.splice(index, 1); seat(game, 'harkonnen').hand.push(counter);
|
||||
assert.deepEqual(inventory(game), original);
|
||||
assert.ok(game.phaseOpening);
|
||||
game = act(game, 'emperor', {type:'ready'});
|
||||
game = act(game, 'atreides', {type:'ready'});
|
||||
assert.deepEqual(game.phaseOpening?.passed, ['emperor','atreides']);
|
||||
return {game, original, counter:counter.id, amal:source.amal};
|
||||
}
|
||||
|
||||
function passResponses(game: Game) {
|
||||
for (let count = 0; game.response && count < 15; count++) {
|
||||
const player = game.players.find(row => !game.response!.passed.includes(row.id)); assert.ok(player);
|
||||
game = act(game, player.id, {type:'passResponse'});
|
||||
}
|
||||
assert.equal(game.response, null); return game;
|
||||
}
|
||||
|
||||
for (const cancel of [false,true]) {
|
||||
void test(`Basic Ix opening gift ${cancel ? 'cancellation' : 'allowance'} resumes exact passes through the original opening`, () => {
|
||||
const f = fixture(), initial = f.game, opening = structuredClone(initial.phaseOpening);
|
||||
const emperor = seat(initial, 'emperor').spice, ally = seat(initial, 'atreides').spice;
|
||||
const phase = initial.phase, turn = initial.turn;
|
||||
assert.deepEqual(viewGame(initial, 'emperor').emperorPhaseOpeningGift, {recipient:'atreides', maximum:emperor});
|
||||
assert.equal(viewGame(initial, 'atreides').emperorPhaseOpeningGift, null);
|
||||
assert.equal(viewGame(initial, 'harkonnen').emperorPhaseOpeningGift, null);
|
||||
let game = act(initial, 'emperor', {type:'emperorGift', amount:3});
|
||||
assert.equal(game.response?.kind, 'emperorGift');
|
||||
assert.equal(game.phaseOpening, null);
|
||||
assert.equal(seat(game, 'emperor').spice, emperor);
|
||||
assert.equal(seat(game, 'atreides').spice, ally);
|
||||
assert.equal(viewGame(game, 'emperor').response?.amount, 3);
|
||||
assert.equal(viewGame(game, 'atreides').response?.amount, 3);
|
||||
assert.equal(viewGame(game, 'harkonnen').response?.amount, undefined);
|
||||
assert.equal(viewGame(game, 'emperor').emperorPhaseOpeningGift, null);
|
||||
assert.throws(() => act(game, 'emperor', {type:'emperorGift', amount:1}));
|
||||
game = cancel ? act(copy(game), 'harkonnen', {type:'card', card:f.counter, mode:'cancel'}) : passResponses(copy(game));
|
||||
assert.equal(game.response, null);
|
||||
assert.deepEqual(game.phaseOpening, opening);
|
||||
assert.equal(game.phase, phase); assert.equal(game.turn, turn);
|
||||
assert.equal(seat(game, 'emperor').spice, emperor - (cancel ? 0 : 3));
|
||||
assert.equal(seat(game, 'atreides').spice, ally + (cancel ? 0 : 3));
|
||||
assert.equal(game.discard.filter(card => card.id === f.counter).length, Number(cancel));
|
||||
assert.throws(() => act(game, 'harkonnen', {type:'passResponse'}));
|
||||
|
||||
// The unpassed Harkonnen can still play the original physical Amal.
|
||||
const beforeAmal = game.players.map(player => player.spice);
|
||||
game = act(game, 'harkonnen', {type:'card', card:f.amal});
|
||||
assert.deepEqual(game.players.map(player => player.spice), beforeAmal.map(amount => Math.floor(amount / 2)));
|
||||
assert.deepEqual(game.phaseOpening?.passed, []);
|
||||
assert.equal(game.discard.filter(card => card.id === f.amal).length, 1);
|
||||
for (const id of ['emperor','atreides','harkonnen']) game = act(game, id, {type:'ready'});
|
||||
assert.equal(game.phaseOpening, null);
|
||||
assert.equal(game.phase, phase); assert.equal(game.turn, turn);
|
||||
assert.deepEqual(game.players.map(player => player.spice), beforeAmal.map(amount => Math.floor(amount / 2)));
|
||||
assert.throws(() => act(game, 'harkonnen', {type:'card', card:f.amal}));
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
});
|
||||
}
|
||||
|
||||
void test('all minimal bots can legally respond to an opening gift without losing its parent', () => {
|
||||
const f = fixture(), opening = structuredClone(f.game.phaseOpening);
|
||||
const pending = act(f.game, 'emperor', {type:'emperorGift', amount:1});
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
let game = copy(pending);
|
||||
const view = viewGame(game, 'harkonnen'); view.players.find(player => player.id === 'harkonnen')!.bot = difficulty;
|
||||
const action = botActions(view).find(item => item.type === 'passResponse' || item.type === 'card');
|
||||
assert.ok(action); game = act(game, 'harkonnen', action);
|
||||
game = passResponses(game);
|
||||
assert.deepEqual(game.phaseOpening, opening);
|
||||
assert.equal(game.phase, f.game.phase);
|
||||
}
|
||||
});
|
||||
|
||||
void test('invalid opening gifts and a mismatched original opening cannot commit', () => {
|
||||
const f = fixture(), before = copy(f.game);
|
||||
for (const [actor, action] of [
|
||||
['atreides', {type:'emperorGift', amount:1}],
|
||||
['emperor', {type:'emperorGift', amount:0}],
|
||||
['emperor', {type:'emperorGift', amount:11}],
|
||||
] as [string, Action][]) {
|
||||
assert.throws(() => act(f.game, actor, action));
|
||||
assert.deepEqual(f.game, before);
|
||||
}
|
||||
const unallied = copy(f.game);
|
||||
seat(unallied, 'emperor').ally = null; seat(unallied, 'atreides').ally = null;
|
||||
assert.equal(viewGame(unallied, 'emperor').emperorPhaseOpeningGift, null);
|
||||
assert.throws(() => act(unallied, 'emperor', {type:'emperorGift', amount:1}));
|
||||
const pending = act(f.game, 'emperor', {type:'emperorGift', amount:1});
|
||||
const altered = copy(pending);
|
||||
altered.response!.emperorPhaseOpeningGift!.opening.passed = [];
|
||||
assert.throws(() => viewGame(altered, 'emperor'), /original phase opening/);
|
||||
assert.throws(() => act(altered, 'harkonnen', {type:'passResponse'}), /original phase opening/);
|
||||
});
|
||||
|
||||
void test('a deferred Mentat initialization follows a gift and credits the original bribe once', () => {
|
||||
const f = createTruthTurnAmalFixture({phase:8, incoming:4});
|
||||
let game = f.game;
|
||||
seat(game, 'emperor').ally = 'atreides'; seat(game, 'atreides').ally = 'emperor';
|
||||
assert.equal(game.phaseOpening?.initialize, true);
|
||||
assert.equal(seat(game, 'emperor').spice, 10); assert.equal(seat(game, 'emperor').bribes, 4);
|
||||
game = act(game, 'emperor', {type:'ready'});
|
||||
assert.deepEqual(viewGame(game, 'emperor').emperorPhaseOpeningGift, {recipient:'atreides', maximum:10});
|
||||
assert.throws(() => act(game, 'emperor', {type:'emperorGift', amount:11}));
|
||||
game = act(game, 'emperor', {type:'emperorGift', amount:3});
|
||||
game = passResponses(game);
|
||||
assert.deepEqual(game.phaseOpening, {passed:['emperor'], initialize:true});
|
||||
assert.equal(seat(game, 'emperor').spice, 7); assert.equal(seat(game, 'emperor').bribes, 4);
|
||||
game = act(game, 'atreides', {type:'ready'});
|
||||
game = act(game, 'harkonnen', {type:'ready'});
|
||||
assert.equal(game.phaseOpening, null);
|
||||
assert.equal(seat(game, 'emperor').spice, 11); assert.equal(seat(game, 'emperor').bribes, 0);
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Game, type GameView } from '../game/engine';
|
||||
import { EmperorTimingGift } from '../components/emperor-timing-gift';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
import { createTruthTurnAmalFixture } from './fixture-truth-turn-amal';
|
||||
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
@@ -98,3 +99,31 @@ void test('an Extortion quote presents the recipient and restores its original d
|
||||
assert.match(table(own), /Moritani Extortion decision resumes after the Karama response/);
|
||||
assert.doesNotMatch(table(observer), /Share spice with Harkonnen/);
|
||||
});
|
||||
|
||||
void test('a phase-opening quote gives only its owner the gift control', () => {
|
||||
const game = fixture();
|
||||
// Exercise the isolated projected contract; the engine owns its eligibility.
|
||||
const own: GameView = {...viewGame(game, 'e'), emperorGuildTimingGift:null,
|
||||
emperorExtortionGift:null, emperorPhaseOpeningGift:{recipient:'g', maximum:6}};
|
||||
const observer: GameView = {...viewGame(game, 'h'), emperorGuildTimingGift:null,
|
||||
emperorExtortionGift:null, emperorPhaseOpeningGift:null};
|
||||
const html = component(own);
|
||||
assert.match(html, /Share spice with Guild/);
|
||||
assert.match(html, /Phase opening resumes after the Karama response to your gift/);
|
||||
assert.match(html, /type="number"[^>]*min="1"[^>]*max="6"/);
|
||||
assert.match(html, /<button\b(?![^>]*disabled)[^>]*>Share spice<\/button>/);
|
||||
assert.match(component(own, true), /<button\b[^>]*disabled=""[^>]*>Share spice<\/button>/);
|
||||
assert.equal(component(observer), '');
|
||||
assert.equal(component({...own, emperorPhaseOpeningGift:null}), '');
|
||||
});
|
||||
|
||||
void test('the real Basic Ix opening table exposes the control only to the Emperor', () => {
|
||||
const {game} = createTruthTurnAmalFixture();
|
||||
game.players.find(player => player.id === 'emperor')!.ally = 'atreides';
|
||||
game.players.find(player => player.id === 'atreides')!.ally = 'emperor';
|
||||
const own = table(viewGame(game, 'emperor'));
|
||||
const observer = table(viewGame(game, 'harkonnen'));
|
||||
assert.match(own, /Before Storm/);
|
||||
assert.match(own, /Phase opening resumes after the Karama response to your gift/);
|
||||
assert.doesNotMatch(observer, /Phase opening resumes after the Karama response to your gift/);
|
||||
});
|
||||
Reference in new issue
Block a user