Preserve Emperor gift opportunity before bot Extortion
Verify and publish checkpoint container / container (push) Successful in 10m54s
Verify and publish checkpoint container / container (push) Successful in 10m54s
This commit is contained in:
1 parent
a6be17fc5d
commit
7c1f4b4e09
13 files changed
+370
-13
No files matched your search
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import type { Action } from '@/game/engine';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
/** The same public opportunity is shown regardless of the Emperor's spice. */
|
||||
export type EmperorExtortionPriorityView = {
|
||||
event: string;
|
||||
recipient: string;
|
||||
canContinue: boolean;
|
||||
};
|
||||
|
||||
export type EmperorExtortionPriorityProps = {
|
||||
game: {
|
||||
emperorExtortionPriority: EmperorExtortionPriorityView | null;
|
||||
players: readonly { id: string; name: string }[];
|
||||
};
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
};
|
||||
|
||||
/** The payer's normal Extortion choice remains pending until the Emperor
|
||||
* completes this optional alliance opportunity. */
|
||||
export function EmperorExtortionPriority({ game, act, busy }: EmperorExtortionPriorityProps) {
|
||||
const priority = game.emperorExtortionPriority;
|
||||
if (!priority) return null;
|
||||
const event = priority.event;
|
||||
const recipient = game.players.find(player => player.id === priority.recipient)?.name ?? 'the ally';
|
||||
const canAct = () => !busy && game.emperorExtortionPriority === priority &&
|
||||
priority.event === event && typeof event === 'string' && event.length > 0 && priority.canContinue;
|
||||
|
||||
return <section className="notice space-y-3" aria-label="Emperor Extortion opportunity" aria-live="polite">
|
||||
<h3>Emperor alliance opportunity</h3>
|
||||
<p>Before {recipient} makes the Extortion choice, the Emperor may share spice with their ally. The existing gift control shows any available amount.</p>
|
||||
{priority.canContinue ? <Button className="game-action min-h-11 whitespace-normal"
|
||||
data-testid="emperor-extortion-continue" disabled={!canAct()}
|
||||
onClick={() => { if (canAct()) act({type:'emperorExtortionContinue', event}); }}>
|
||||
Continue to Extortion
|
||||
</Button> : <p>Waiting for the Emperor to continue.</p>}
|
||||
</section>;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
import { SaphoArrivalOrder } from './sapho-arrival-order';
|
||||
import { SaphoStationPriority } from './sapho-station-priority';
|
||||
import { EmperorExtortionPriority } from './emperor-extortion-priority';
|
||||
import { RicheseBidPromisePanel } from './richese-bid-promise-panel';
|
||||
import { EarlyRicheseBidPromises } from './early-richese-bid-promises';
|
||||
import { TravelPromises } from './travel-promises';
|
||||
@@ -1536,6 +1537,7 @@ export function GameTable({
|
||||
<EcazLoyaltyCard loyalty={g.ecazLoyalty} />
|
||||
<MoritaniTerrorSupply game={g} />
|
||||
<MoritaniExtortionNotice game={g} />
|
||||
{g.emperorExtortionPriority && <EmperorExtortionPriority game={g} act={act} busy={reactionBusy} />}
|
||||
{g.saphoStationPriority && <SaphoStationPriority game={g} act={act}
|
||||
busy={transportBusy || !!g.roomControl?.paused || !!g.roomControl?.closed || !!me.autopilot} />}
|
||||
{g.saphoArrivalOrder && <SaphoArrivalOrder view={g.saphoArrivalOrder} me={g.me}
|
||||
|
||||
@@ -17,6 +17,17 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Human Emperor before automated Extortion — 30 October. Prototyped /
|
||||
Partial.** In supported Basic and Advanced games, an automated Extortion
|
||||
payer now waits for its human Emperor ally's optional gift and Continue.
|
||||
The same public window appears without revealing private spice. Gift or Karama
|
||||
cancellation retains the original event and returns to the opportunity.
|
||||
Station bot priority also precedes unrelated bot policies; its competing-guide
|
||||
test is synthetic rather than evidence of a reachable stall. Eight related
|
||||
files pass 57 cases; fixtures stage alliance, wallets and Terror placement.
|
||||
Other gift timings, full games and deployed verification remain open.
|
||||
[Scope](EMPEROR_ALLIANCE_TIMING.md#emperor-gift-during-moritani-extortion--29-october-2026).
|
||||
|
||||
**Testing Station Sapho priority — 30 October. Prototyped / Partial.** New
|
||||
two-occupant Station choices with Richese seated give both occupants a neutral,
|
||||
persistent opportunity before the first adjustment. A bot passes without
|
||||
|
||||
@@ -37,9 +37,15 @@ immediately. The user-authorized revised Advanced sheet, physical p. 31,
|
||||
explicitly says any time. Applying the Basic gift at this decision is a
|
||||
provisional online timing interpretation. This initial entry is limited to the
|
||||
Ecaz/Moritani expansion with native Moritani and classic factions, without
|
||||
independent Ecaz cards, optional modules or competing parents. An automated payer can still act before
|
||||
a human Emperor chooses to gift; broader priority and other interrupted windows
|
||||
remain unfinished. [Runtime cases](../tests/emperor-extortion-gift.test.ts).
|
||||
independent Ecaz cards, optional modules or competing parents. When the payer
|
||||
is automated and its Emperor ally is human, a neutral public opportunity now
|
||||
waits for the Emperor to finish sharing or Continue. The same opportunity
|
||||
appears regardless of the Emperor's private spice, and a gift or canceled
|
||||
gift returns to it. The original Extortion event and payer stay unchanged.
|
||||
The explicit Continue is a provisional online priority, not a publisher
|
||||
timing ruling. Other interrupted windows remain unfinished.
|
||||
[Runtime cases](../tests/emperor-extortion-gift.test.ts) and
|
||||
[bot-priority cases](../tests/emperor-extortion-bot-priority.test.ts).
|
||||
|
||||
## Advanced allied auction payment — 29 October 2026
|
||||
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# Rule decision index
|
||||
|
||||
## Human Emperor gift before automated Extortion — 30 October 2026
|
||||
|
||||
A human Emperor's automated allied payer waits for an event-bound Continue,
|
||||
allowing the existing spice-gift control and its Karama response first. The
|
||||
neutral wait does not disclose private funds or commitments. The timing is a
|
||||
provisional online interpretation. [Scope](EMPEROR_ALLIANCE_TIMING.md#emperor-gift-during-moritani-extortion--29-october-2026).
|
||||
|
||||
## Testing Station Sapho priority — 30 October 2026
|
||||
|
||||
A new two-occupant Station choice with Richese seated opens a neutral shared
|
||||
|
||||
+7
-6
@@ -1250,12 +1250,6 @@ function policyActions(g: GameView): Action[] {
|
||||
? [{ type: 'victoryContinuation', event: g.victoryContinuation.event, mode: 'finish' }]
|
||||
: [];
|
||||
if (g.homeworldTupileBlocked) return [];
|
||||
if (g.saphoStationPriority) {
|
||||
const own = g.players.find(player => player.id === g.me), priority = g.saphoStationPriority;
|
||||
return (own?.bot || own?.autopilot) && g.status === 'playing' && priority.canPass &&
|
||||
!g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending
|
||||
? [{type:'saphoStationPass', event:priority.event}] : [];
|
||||
}
|
||||
const dukeGrant = g.homeworldEcazDuke;
|
||||
if (dukeGrant && dukeGrant.occupier === me.id && !dukeGrant.blocked && dukeGrant.event && dukeGrant.recipients.length)
|
||||
return [{ type: 'homeworldDuke', event: dukeGrant.event, recipient: dukeGrant.recipients[0].id }];
|
||||
@@ -4377,6 +4371,13 @@ function botAdvisorMatchingAllowed(g:GameView,action:Action) {
|
||||
} catch(error) { if(error instanceof AdvisorMatchingError) return false; throw error; }
|
||||
}
|
||||
export function botActions(g: GameView): Action[] {
|
||||
if (g.saphoStationPriority) {
|
||||
const own = g.players.find(player => player.id === g.me), priority = g.saphoStationPriority;
|
||||
return (own?.bot || own?.autopilot) && g.status === 'playing' && priority.canPass &&
|
||||
!g.roomControl?.paused && !g.roomControl?.closed && !g.automaticContinuationPending
|
||||
? [{type:'saphoStationPass', event:priority.event}] : [];
|
||||
}
|
||||
if (g.emperorExtortionPriority) return [];
|
||||
if (g.emperorAuctionGiftPending && !g.response) {
|
||||
const me = g.players.find(player => player.id === g.me);
|
||||
return (me?.bot || me?.autopilot) && g.status === 'playing' &&
|
||||
|
||||
+35
-2
@@ -2379,6 +2379,8 @@ export type Game = {
|
||||
moritaniTerror?: TerrorState;
|
||||
moritaniAtomics?: AtomicsAftermath | null;
|
||||
moritaniExtortion?: ExtortionState;
|
||||
/** Public release of the human Emperor's optional gift opportunity. */
|
||||
emperorExtortionGiftRelease?: { event: string; emperor: string; recipient: string };
|
||||
grummanCollection?: GrummanCollection;
|
||||
pendingMoritaniPlacement?: {
|
||||
token: string;
|
||||
@@ -30345,7 +30347,7 @@ function emperorExtortionGiftSignature(g: Game, owner: string, recipient: string
|
||||
return JSON.stringify([g.turn, g.phase, g.active, g.order, g.decision,
|
||||
g.moritaniExtortion, g.moritaniTerror?.placementTurn, owner, recipient, amount]);
|
||||
}
|
||||
function emperorExtortionGiftQuote(g: Game, p: Player): {recipient: string; maximum: number} | null {
|
||||
function emperorExtortionGiftPublicEligible(g: Game, p: Player): boolean {
|
||||
const decision = g.decision, extortion = g.moritaniExtortion;
|
||||
if (g.status !== 'playing' || g.phase !== 8 || g.expansions.length !== 1 ||
|
||||
g.expansions[0] !== 'ecaz' || g.players.some(seat =>
|
||||
@@ -30357,7 +30359,11 @@ function emperorExtortionGiftQuote(g: Game, p: Player): {recipient: string; maxi
|
||||
!extortion || extortion.stage !== 'payment' || decision?.kind !== 'moritaniExtortion' ||
|
||||
decision.event !== extortionEvent(extortion) || decision.player !== extortion.queue[extortion.cursor] ||
|
||||
p.faction !== 'emperor' || !p.ally || decision.player !== p.ally ||
|
||||
!g.players.some(seat => seat.id === p.ally && seat.ally === p.id) ||
|
||||
!g.players.some(seat => seat.id === p.ally && seat.ally === p.id)) return false;
|
||||
return true;
|
||||
}
|
||||
function emperorExtortionGiftQuote(g: Game, p: Player): {recipient: string; maximum: number} | null {
|
||||
if (!emperorExtortionGiftPublicEligible(g, p) || !p.ally ||
|
||||
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) ||
|
||||
@@ -30368,6 +30374,18 @@ function emperorExtortionGiftQuote(g: Game, p: Player): {recipient: string; maxi
|
||||
const maximum = Math.max(0, uncommittedSpice(g, p));
|
||||
return maximum > 0 ? {recipient: p.ally, maximum} : null;
|
||||
}
|
||||
/** A neutral window for a human Emperor before an automated ally answers.
|
||||
* The visible wait never depends on either player's private spice or promises. */
|
||||
function emperorExtortionPriority(g: Game): {event: string; emperor: string; recipient: string} | null {
|
||||
const emperor = byFaction(g, 'emperor'), decision = g.decision;
|
||||
if (!emperor || (emperor.bot ?? emperor.autopilot) || !emperorExtortionGiftPublicEligible(g, emperor) ||
|
||||
decision?.kind !== 'moritaniExtortion') return null;
|
||||
const recipient = g.players.find(player => player.id === decision.player);
|
||||
if (!recipient || !(recipient.bot ?? recipient.autopilot)) return null;
|
||||
const release = g.emperorExtortionGiftRelease;
|
||||
if (release?.event === decision.event && release.emperor === emperor.id && release.recipient === recipient.id) return null;
|
||||
return {event:decision.event, emperor:emperor.id, recipient:recipient.id};
|
||||
}
|
||||
function validateEmperorExtortionGift(g: Game, response: ResponseWindow) {
|
||||
const parent = response.emperorExtortionGift;
|
||||
if (!parent) return;
|
||||
@@ -44943,6 +44961,14 @@ function applyActionInner(
|
||||
specialKarama(g, p, action);
|
||||
return g;
|
||||
}
|
||||
if (t === 'emperorExtortionContinue') {
|
||||
const priority = emperorExtortionPriority(g);
|
||||
requireRule(priority && priority.emperor === id && action.event === priority.event &&
|
||||
Object.keys(action).sort().join(',') === 'event,type',
|
||||
'Continue only your current Emperor Extortion gift opportunity.');
|
||||
g.emperorExtortionGiftRelease = priority;
|
||||
return g;
|
||||
}
|
||||
if (t === 'emperorGift' && g.decision?.kind === 'moritaniExtortion' && !g.response) {
|
||||
const quote = emperorExtortionGiftQuote(g, p);
|
||||
requireRule(quote && Object.keys(action).sort().join(',') === 'amount,type',
|
||||
@@ -45081,6 +45107,9 @@ function applyActionInner(
|
||||
t === 'decision' && decision.player === id,
|
||||
'Waiting for the player with the pending decision.',
|
||||
);
|
||||
if (decision.kind === 'moritaniExtortion')
|
||||
requireRule(!emperorExtortionPriority(g),
|
||||
'Wait for the Emperor to finish the allied gift opportunity.');
|
||||
g.decision = null;
|
||||
requireRule(decision.kind !== 'leaderSkillVisibility' && decision.kind !== 'leaderSkillRevival', 'Use the Leader Skills controls for this decision.');
|
||||
if (decision.kind === 'moritaniAssassinate') {actMoritaniAssassinate(g,decision,action);return g;}
|
||||
@@ -49913,6 +49942,10 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
},
|
||||
emperorGuildTimingGift: emperorGuildTimingGiftQuote(g, me),
|
||||
emperorExtortionGift: emperorExtortionGiftQuote(g, me),
|
||||
emperorExtortionPriority: (() => {
|
||||
const priority = emperorExtortionPriority(g);
|
||||
return priority ? {event:priority.event, recipient:priority.recipient, canContinue:priority.emperor === id} : null;
|
||||
})(),
|
||||
emperorPhaseOpeningGift: emperorPhaseOpeningGiftQuote(g, me),
|
||||
emperorAuctionGiftPending: g.pendingEmperorAuctionGift
|
||||
? { owner: g.pendingEmperorAuctionGift.owner } : null,
|
||||
|
||||
@@ -4284,6 +4284,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'For this table’s selected timing, Moritani’s normal Mentat Terror placement or relocation opportunity resolves first, including any Karama response. Only afterward does Moritani collect the five spice and offer the Extortion payment opportunity. A token recovered afterward cannot be placed again during that same Mentat Pause.',
|
||||
'If Extortion is revealed during Mentat after placement has already finished, resolve that committed entry and its interruptions first. Then collect the bank award and offer payment before any player can end the turn.',
|
||||
'After collection, each non-Moritani player in storm order may pay Moritani three spice or decline. Sapho may provisionally reorder only unanswered choices; earlier declines cannot repeat. A player without three spice cannot pay but may decline. The first payment permanently removes Extortion from play and ends the offers; it does not undo the bank award.',
|
||||
'In supported games, a human Emperor allied to an automated payer gets a shared opportunity to give spice or Continue before that payer decides. Gifts retain their normal Karama response; the opportunity remains until Continue. This online priority is provisional.',
|
||||
'If every eligible player declines, rotate Extortion and return it face down to Moritani’s hidden supply for a future placement. Other revealed Terror tokens have no corresponding recovery provision.',
|
||||
'These controls cover Extortion collection, payer ordering, the payment choice and native return/removal. Supported native Moritani starts remain labelled previews; complete-mode acceptance is unfinished.',
|
||||
],
|
||||
|
||||
+2
-2
@@ -12,13 +12,13 @@ type TurnView = Pick<
|
||||
| 'phaseOpening'
|
||||
| 'automaticContinuationPending'
|
||||
> &
|
||||
Partial<Pick<GameView, 'saphoStationPriority' | 'biddingEnd' | 'kullReaction' | 'richeseBetrayalReaction' | 'guildBetrayalReaction' | 'nexusIxianReplacement' | 'nexusIxianBetrayalReaction' | 'nexusHarkonnenBetrayalReaction'>>;
|
||||
Partial<Pick<GameView, 'emperorExtortionPriority' | 'saphoStationPriority' | 'biddingEnd' | 'kullReaction' | 'richeseBetrayalReaction' | 'guildBetrayalReaction' | 'nexusIxianReplacement' | 'nexusIxianBetrayalReaction' | 'nexusHarkonnenBetrayalReaction'>>;
|
||||
|
||||
/** The single current decision owner for the table banner and seat highlight.
|
||||
* Shared response windows have no single acting seat. Only public view fields
|
||||
* participate; this does not decide which gameplay actions are legal. */
|
||||
export function tableActionOwner(g: TurnView): string | null {
|
||||
if (g.saphoStationPriority) return null;
|
||||
if (g.saphoStationPriority || g.emperorExtortionPriority) return null;
|
||||
if (g.richeseBetrayalReaction || g.guildBetrayalReaction) return null;
|
||||
if (g.kullReaction && g.status === 'playing')
|
||||
return g.players.some(p => p.id === g.kullReaction!.player)
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
|
||||
import { territory } from '../game/board';
|
||||
import { placeTerror } from '../game/moritani-terror';
|
||||
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
|
||||
|
||||
export const extortionSeat = (game: Game, id: string) => {
|
||||
const player = game.players.find(row => row.id === id); assert.ok(player); return player;
|
||||
};
|
||||
export const extortionAct = (game: Game, id: string, action: Action) => applyAction(game, id, action);
|
||||
export function extortionEvent(game: Game): string {
|
||||
assert.ok(game.decision?.kind === 'moritaniExtortion');
|
||||
return game.decision.event;
|
||||
}
|
||||
export function extortionPayment(game: Game, pay: boolean): Action {
|
||||
return {type:'decision', event:extortionEvent(game), pay};
|
||||
}
|
||||
export function finishExtortionGiftResponse(game: Game): Game {
|
||||
for (let count = 0; game.response && count < 30; count++) {
|
||||
const responder = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(responder);
|
||||
game = extortionAct(game, responder.id, {type:'passResponse'});
|
||||
}
|
||||
assert.equal(game.response, null);
|
||||
return game;
|
||||
}
|
||||
|
||||
/** A fresh public E3 Basic/Advanced start, genuine setup, shipment and Terror
|
||||
* revelation. The alliance, movement boundary, token placement and wallets are
|
||||
* staged; the original Extortion owner and subsequent choices are real. */
|
||||
export function createExtortionBotPriority(advanced: boolean,
|
||||
options: {unallied?: boolean; emperorSpice?: number; autopilot?: boolean} = {}) {
|
||||
let game = createGame('EXTORTIONBOTPRIORITY', newPlayer('m', 'Moritani', 'moritani'), advanced, ['ecaz']);
|
||||
for (const [id, name, faction] of [['e','Emperor','emperor'], ['a','Atreides','atreides'],
|
||||
['h','Harkonnen','harkonnen']] as const) joinGame(game, newPlayer(id, name, faction));
|
||||
for (const player of game.players) game = extortionAct(game, player.id, {type:'ready'});
|
||||
game = extortionAct(game, 'm', advanced ? {type:'start', advancedPreview:true} : {type:'start'});
|
||||
for (let count = 0; game.status === 'setup' && count < 100; count++) {
|
||||
if (game.decision?.kind === 'moritaniSetup') game = extortionAct(game, 'm',
|
||||
{type:'decision', territory:'sietch_tabr', sector:territory('sietch_tabr').sectors[0]});
|
||||
else {
|
||||
const step = nextStrongholdFactionsNativeStep(game); assert.ok(step);
|
||||
game = extortionAct(game, step.actor, step.action);
|
||||
}
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
if (!options.unallied) { extortionSeat(game, 'e').ally = 'a'; extortionSeat(game, 'a').ally = 'e'; }
|
||||
const token = game.moritaniTerror!.tokens.find(row => row.kind === 'extortion'); assert.ok(token);
|
||||
game.moritaniTerror = placeTerror(game.moritaniTerror!, token.id, 'habbanya_ridge_sietch', 1);
|
||||
Object.assign(game, {phase:5, turn:2, storm:18, order:['e','a','h','m'], active:'e',
|
||||
movementRemaining:['e','a','h','m'], response:null, decision:null, phaseOpening:null, ready:[]});
|
||||
extortionSeat(game, 'e').spice = 10; extortionSeat(game, 'a').spice = 2;
|
||||
const harkonnen = extortionSeat(game, 'h');
|
||||
let card = harkonnen.hand.find(row => row.effect === 'karama');
|
||||
if (!card) {
|
||||
const sourceOwner = game.players.find(player => player.id !== 'h' && player.hand.some(row => row.effect === 'karama'));
|
||||
const source = sourceOwner?.hand ?? game.deck, index = source.findIndex(row => row.effect === 'karama');
|
||||
assert.ok(index >= 0); [card] = source.splice(index, 1); harkonnen.hand.push(card);
|
||||
}
|
||||
const karama = card.id;
|
||||
game = extortionAct(game, 'e', {type:'ship', territory:'habbanya_ridge_sietch', sector:17, amount:1});
|
||||
assert.equal(game.decision?.kind, 'moritaniTerror');
|
||||
game = extortionAct(game, 'm', {type:'decision', reveal:true});
|
||||
game.phase = 7; game.ready = [];
|
||||
for (const player of game.players) game = extortionAct(game, player.id, {type:'ready'});
|
||||
assert.equal(game.phase, 8);
|
||||
assert.equal(game.decision?.kind, 'moritaniPlacement');
|
||||
game = extortionAct(game, 'm', {type:'decision', decline:true});
|
||||
assert.equal(game.decision?.kind, 'moritaniExtortion');
|
||||
assert.equal(game.decision?.player, 'e');
|
||||
if (options.autopilot) extortionSeat(game, 'a').autopilot = 'Easy';
|
||||
else extortionSeat(game, 'a').bot = 'Easy';
|
||||
if (options.emperorSpice !== undefined) extortionSeat(game, 'e').spice = options.emperorSpice;
|
||||
game = extortionAct(game, 'e', extortionPayment(game, false));
|
||||
assert.equal(game.decision?.kind, 'moritaniExtortion');
|
||||
assert.equal(game.decision?.player, 'a');
|
||||
return {game, token:token.id, karama};
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { viewGame } from '../game/engine';
|
||||
import { botActions, runBots } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { createExtortionBotPriority, extortionAct, extortionEvent, extortionPayment, extortionSeat,
|
||||
finishExtortionGiftResponse } from './emperor-extortion-bot-priority-fixture';
|
||||
|
||||
for (const advanced of [false, true]) for (const autopilot of [false, true]) void test(
|
||||
`${advanced ? 'Advanced' : 'Basic'} human Emperor gets Extortion gift priority before ${autopilot ? 'autopilot' : 'bot'} ally`, () => {
|
||||
const {game: initial} = createExtortionBotPriority(advanced, {autopilot});
|
||||
let game = initial;
|
||||
const event = extortionEvent(game);
|
||||
assert.deepEqual(viewGame(game, 'e').emperorExtortionPriority, {event, recipient:'a', canContinue:true});
|
||||
for (const id of ['a','h','m'])
|
||||
assert.deepEqual(viewGame(game, id).emperorExtortionPriority, {event, recipient:'a', canContinue:false});
|
||||
const e = extortionSeat(game, 'e').spice, a = extortionSeat(game, 'a').spice;
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, 'a');
|
||||
const actor = view.players.find(player => player.id === 'a')!;
|
||||
if (autopilot) actor.autopilot = difficulty; else actor.bot = difficulty;
|
||||
assert.ok(!botActions(view).some(action => action.type === 'decision' && action.event === event), difficulty);
|
||||
}
|
||||
const waiting = runBots(game, 5);
|
||||
assert.equal(waiting.decision?.kind, 'moritaniExtortion');
|
||||
assert.equal(extortionEvent(waiting), event);
|
||||
assert.equal(extortionSeat(waiting, 'e').spice, e);
|
||||
assert.equal(extortionSeat(waiting, 'a').spice, a);
|
||||
const original = structuredClone(game);
|
||||
assert.throws(() => extortionAct(game, 'a', extortionPayment(game, false)));
|
||||
assert.throws(() => extortionAct(game, 'a', {type:'emperorExtortionContinue', event}));
|
||||
assert.throws(() => extortionAct(game, 'e', {type:'emperorExtortionContinue', event:'stale'}));
|
||||
assert.deepEqual(game, original);
|
||||
game = extortionAct(game, 'e', {type:'emperorExtortionContinue', event});
|
||||
assert.ok(!viewGame(game, 'e').emperorExtortionPriority);
|
||||
assert.deepEqual(game.emperorExtortionGiftRelease, {event, emperor:'e', recipient:'a'});
|
||||
const action = botActions(viewGame(game, 'a')).find(row => row.type === 'decision' && row.event === event);
|
||||
assert.ok(action); assert.equal(action.pay, false);
|
||||
game = runBots(game, 1);
|
||||
assert.ok(game.decision?.kind !== 'moritaniExtortion' || game.decision.event !== event);
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) for (const canceled of [false, true]) void test(
|
||||
`${advanced ? 'Advanced' : 'Basic'} ${canceled ? 'canceled' : 'successful'} Emperor gift preserves priority until Continue`, () => {
|
||||
const fixture = createExtortionBotPriority(advanced);
|
||||
let game = fixture.game;
|
||||
const event = extortionEvent(game), emperor = extortionSeat(game, 'e').spice;
|
||||
game = extortionAct(game, 'e', {type:'emperorGift', amount:1});
|
||||
assert.equal(game.response?.kind, 'emperorGift');
|
||||
game = canceled ? extortionAct(game, 'h', {type:'card', card:fixture.karama, mode:'cancel'}) :
|
||||
finishExtortionGiftResponse(game);
|
||||
assert.equal(game.response, null);
|
||||
assert.equal(game.decision?.kind, 'moritaniExtortion');
|
||||
assert.equal(extortionEvent(game), event);
|
||||
assert.deepEqual(viewGame(game, 'e').emperorExtortionPriority, {event, recipient:'a', canContinue:true});
|
||||
assert.equal(extortionSeat(game, 'a').spice, canceled ? 2 : 3);
|
||||
assert.equal(extortionSeat(game, 'e').spice, emperor - (canceled ? 0 : 1));
|
||||
const before = runBots(game, 5);
|
||||
assert.equal(extortionEvent(before), event);
|
||||
game = extortionAct(game, 'e', {type:'emperorExtortionContinue', event});
|
||||
const action = botActions(viewGame(game, 'a')).find(row => row.type === 'decision' && row.event === event);
|
||||
// The current minimal bot policy declines even when it can afford to pay.
|
||||
assert.ok(action); assert.equal(action.pay, false);
|
||||
game = runBots(game, 1);
|
||||
assert.ok(game.decision?.kind !== 'moritaniExtortion' || game.decision.event !== event);
|
||||
assert.equal(game.moritaniTerror!.tokens.find(row => row.id === fixture.token)?.status, 'extortion');
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) void test(
|
||||
`${advanced ? 'Advanced' : 'Basic'} no-spice Emperor still has neutral Continue priority`, () => {
|
||||
const {game} = createExtortionBotPriority(advanced, {emperorSpice:0});
|
||||
const event = extortionEvent(game);
|
||||
assert.deepEqual(viewGame(game, 'e').emperorExtortionPriority, {event, recipient:'a', canContinue:true});
|
||||
assert.deepEqual(viewGame(game, 'a').emperorExtortionPriority, {event, recipient:'a', canContinue:false});
|
||||
assert.ok(!botActions(viewGame(game, 'a')).some(action => action.type === 'decision'));
|
||||
const continued = extortionAct(game, 'e', {type:'emperorExtortionContinue', event});
|
||||
assert.ok(botActions(viewGame(continued, 'a')).some(action => action.type === 'decision' && action.pay === false));
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) void test(
|
||||
`${advanced ? 'Advanced' : 'Basic'} unaffiliated bot pays or declines without Emperor priority`, () => {
|
||||
const {game} = createExtortionBotPriority(advanced, {unallied:true});
|
||||
const event = extortionEvent(game);
|
||||
assert.ok(!viewGame(game, 'e').emperorExtortionPriority);
|
||||
const action = botActions(viewGame(game, 'a')).find(row => row.type === 'decision' && row.event === event);
|
||||
assert.ok(action); assert.equal(action.pay, false);
|
||||
const next = runBots(game, 1);
|
||||
assert.ok(next.decision?.kind !== 'moritaniExtortion' || next.decision.event !== event);
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Children, createElement, isValidElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import type { Action } from '../game/engine';
|
||||
import { EmperorExtortionPriority, type EmperorExtortionPriorityProps } from '../components/emperor-extortion-priority';
|
||||
|
||||
function props(): EmperorExtortionPriorityProps & { sent: Action[] } {
|
||||
const sent: Action[] = [];
|
||||
return {game:{emperorExtortionPriority:{event:'extortion-event',recipient:'ally',canContinue:true},
|
||||
players:[{id:'ally',name:'Ally'}]}, busy:false, sent, act(action) {sent.push(action);}};
|
||||
}
|
||||
const render = (input: EmperorExtortionPriorityProps) => renderToStaticMarkup(createElement(EmperorExtortionPriority, input));
|
||||
function button(input: EmperorExtortionPriorityProps) {
|
||||
const section = EmperorExtortionPriority(input); assert.ok(section);
|
||||
const match = Children.toArray(section.props.children).find(child => isValidElement(child) &&
|
||||
(child.props as {'data-testid'?: string})['data-testid'] === 'emperor-extortion-continue');
|
||||
assert.ok(isValidElement<{onClick:() => void; disabled:boolean}>(match));
|
||||
return match;
|
||||
}
|
||||
|
||||
void test('the Emperor sees a persistent, event-bound Extortion opportunity', () => {
|
||||
const input = props(), html = render(input);
|
||||
assert.match(html, /Before Ally makes the Extortion choice/);
|
||||
assert.match(html, /Continue to Extortion/);
|
||||
assert.equal(button(input).props.disabled, false);
|
||||
button(input).props.onClick();
|
||||
assert.deepEqual(input.sent, [{type:'emperorExtortionContinue',event:'extortion-event'}]);
|
||||
});
|
||||
|
||||
void test('other viewers see the same neutral opportunity without a Continue control or private data', () => {
|
||||
const input = props();
|
||||
input.game.emperorExtortionPriority!.canContinue = false;
|
||||
Object.assign(input.game, {privateSpice:19, privateCard:'secret-card'});
|
||||
const html = render(input);
|
||||
assert.match(html, /Waiting for the Emperor/);
|
||||
assert.doesNotMatch(html, /emperor-extortion-continue|secret-card|19/);
|
||||
assert.deepEqual(input.sent, []);
|
||||
input.game.emperorExtortionPriority = null;
|
||||
assert.equal(render(input), '');
|
||||
});
|
||||
|
||||
void test('busy, invalid, stale or consumed opportunities cannot submit', () => {
|
||||
for (const change of ['busy','empty','event','permission','replace','close'] as const) {
|
||||
const input = props();
|
||||
if (change === 'busy') input.busy = true;
|
||||
if (change === 'empty') input.game.emperorExtortionPriority!.event = '';
|
||||
const control = button(input);
|
||||
if (change === 'busy' || change === 'empty') assert.equal(control.props.disabled, true);
|
||||
if (change === 'event') input.game.emperorExtortionPriority!.event = 'other';
|
||||
if (change === 'permission') input.game.emperorExtortionPriority!.canContinue = false;
|
||||
if (change === 'replace') input.game.emperorExtortionPriority = {...input.game.emperorExtortionPriority!};
|
||||
if (change === 'close') input.game.emperorExtortionPriority = null;
|
||||
control.props.onClick();
|
||||
assert.deepEqual(input.sent, [], change);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame } from '../game/engine';
|
||||
import { botActions, runBots } from '../game/bots';
|
||||
import { createSaphoStationPriorityFixture } from './sapho-station-priority-fixture';
|
||||
|
||||
for (const advanced of [false, true]) void test(
|
||||
`${advanced ? 'Advanced' : 'Basic'} Station priority wins over a synthetic competing bot guide`, () => {
|
||||
const fixture = createSaphoStationPriorityFixture(advanced);
|
||||
const initial = fixture.game;
|
||||
const event = initial.saphoStationPriority!.event;
|
||||
const view = viewGame(initial, fixture.first);
|
||||
|
||||
// A supported Station decision normally suppresses turn-action guidance.
|
||||
// Inject a competing projected guide to exercise defensive dispatch order;
|
||||
// this does not claim that the combination occurs in an ordinary game.
|
||||
view.turnActionGuide = {event:'synthetic-guide', action:{type:'ready'}};
|
||||
const action = botActions(view)[0];
|
||||
assert.deepEqual(action, {type:'saphoStationPass', event});
|
||||
const afterPass = applyAction(initial, fixture.first, action);
|
||||
assert.deepEqual(afterPass.saphoStationPriority?.passed, [fixture.first]);
|
||||
assert.deepEqual(afterPass.ecologicalStorm?.queue?.choices, []);
|
||||
assert.equal(viewGame(afterPass, fixture.holder).saphoStationPriority?.canPass, true);
|
||||
|
||||
// Real scheduler behavior is separately checked on the public-start state;
|
||||
// only the fixture's Station placement and card custody were staged.
|
||||
const scheduled = runBots(initial, 12);
|
||||
assert.deepEqual(scheduled.saphoStationPriority?.passed, [fixture.first]);
|
||||
assert.deepEqual(scheduled.ecologicalStorm?.queue?.choices, []);
|
||||
assert.equal(viewGame(scheduled, fixture.holder).saphoStationPriority?.canPass, true);
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user