Allow Richese reveal during Emperor revival
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 18:41:15 +11:00
1 parent cfccfe5acb
commit a10e019b01
9 files changed
+260 -16

No files matched your search

@@ -0,0 +1,142 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { actTruthMovementNativeEarly as act, nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early';
import { truthMovementInventory as inventory } from './fixture-truth-movement';
const seat = (game: Game, id: string) => {
const player = game.players.find(candidate => candidate.id === id);
assert.ok(player, `Missing ${id}`);
return player;
};
const quiet = (game: Game) => !game.response && !game.decision && !game.phaseOpening &&
!game.truthtrance && !game.pendingRevival && !game.pendingTreacheryDiscard;
function next(game: Game) {
if (game.phase === 5 && game.active === 'emperor' && quiet(game))
return {actor:'emperor', action:{type:'endMovement'} satisfies Action};
return nextTruthMovementNativeEarlyStep(game, {position:'first',method:'onceAround',direction:'clockwise'});
}
function reach(game: Game, predicate: (state: Game) => boolean): Game {
for (let i = 0; i < 2000; i++) {
if (predicate(game)) return game;
assert.notEqual(game.status, 'finished');
const step = next(game);
assert.ok(step, `No native step at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = act(game, step.actor, step.action);
}
throw Error('The native game did not reach Revival');
}
/** Native Basic setup and first-turn shipment; stage reciprocal alliance and
* conserved casualties at the following Revival boundary. A card swap supplies
* a real Karama response without creating or destroying a physical card. */
function source(value: 0 | 3, recipient: 'atreides' | 'richese') {
let game = createGame(`REVEALREVIVAL${value}${recipient}`, newPlayer('richese','Richese','richese'), false, ['choam']);
joinGame(game, newPlayer('atreides','Atreides','atreides'));
joinGame(game, newPlayer('emperor','Emperor','emperor'));
for (const player of game.players) game = act(game, player.id, {type:'ready'});
game = act(game, game.host, {type:'start'});
game = reach(game, state => state.status === 'playing');
for (const position of [0,1]) {
const from = game.spiceDeck.findIndex((card,index) => index >= position && 'territory' in card && !card.discovery);
assert.ok(from >= position);
[game.spiceDeck[position],game.spiceDeck[from]] = [game.spiceDeck[from],game.spiceDeck[position]];
}
game = reach(game, state => state.turn === 1 && state.phase === 5 && state.active === 'richese' && quiet(state));
const owner = seat(game,'richese'), token = owner.noField!.tokens.find(item => item.value === value);
assert.ok(token); assert.ok(owner.noFieldEvent);
game = act(game,'richese',{type:'ship',noField:token.id,event:owner.noFieldEvent,territory:'polar_sink',sector:0,allyPayment:0});
game = reach(game, quiet);
game = act(game,'richese',{type:'endMovement'});
game = reach(game, state => state.turn === 2 && state.phase === 4 && quiet(state));
const allied = seat(game,recipient), emperor = seat(game,'emperor');
allied.ally = 'emperor'; emperor.ally = recipient;
assert.ok(allied.reserves >= 2); allied.reserves -= 2; allied.tanks += 2;
if (recipient === 'richese') {
// The future paid return must not increase the earlier reveal's pool.
allied.tanks += allied.reserves - 1; allied.reserves = 1;
}
const responder = seat(game,recipient === 'richese' ? 'atreides' : 'richese');
if (!responder.hand.some(card => card.effect === 'karama')) {
const zones = [game.deck, game.discard, ...game.players.filter(player => player.id !== responder.id).map(player => player.hand)];
const zone = zones.find(cards => cards.some(card => card.effect === 'karama')); assert.ok(zone);
assert.ok(responder.hand.length);
const index = zone.findIndex(card => card.effect === 'karama');
[responder.hand[0],zone[index]] = [zone[index],responder.hand[0]];
}
const event = seat(game,'richese').noFieldEvent; assert.ok(event);
game = act(game,'emperor',{type:'emperorRevival',amount:2});
assert.equal(game.response?.kind,'emperorRevival');
return {game, token:token.id, event, recipient};
}
function passResponse(game: Game): Game {
for (let i = 0; game.response?.kind === 'emperorRevival' && i < 8; i++) {
const player = game.players.find(candidate => !game.response!.passed.includes(candidate.id)); assert.ok(player);
game = act(game,player.id,{type:'passResponse'});
}
assert.notEqual(game.response?.kind,'emperorRevival');
return game;
}
for (const value of [0,3] as const) {
void test(`Richese ${value} reveal preserves an Emperor extra-revival response and its one-time payment`, () => {
const {game,token,event,recipient} = source(value,'atreides');
const response = structuredClone(game.response), cards = inventory(game);
const reserves = seat(game,'richese').reserves, forces = seat(game,'richese').forces['polar_sink:0'] ?? 0;
const allyReserves = seat(game,recipient).reserves, allyTanks = seat(game,recipient).tanks;
const emperorSpice = seat(game,'emperor').spice;
assert.equal(viewGame(game,'richese').richeseNoField?.canReveal,true);
let after = act(game,'richese',{type:'revealNoField',token,event});
assert.deepEqual(after.response,response);
assert.equal(seat(after,'richese').forces['polar_sink:0'] ?? 0,forces + value);
assert.equal(seat(after,'richese').reserves,reserves - value);
assert.deepEqual(inventory(after),cards);
after = passResponse(after);
assert.equal(seat(after,'emperor').spice,emperorSpice - 4);
assert.equal(seat(after,recipient).reserves,allyReserves + 2);
assert.equal(seat(after,recipient).tanks,allyTanks - 2);
assert.equal(after.emperorExtra[recipient],2);
assert.equal(seat(after,'richese').forces['polar_sink:0'] ?? 0,forces + value);
});
}
void test('Emperor extra revival to Richese does not replenish the reserves used by the earlier reveal', () => {
const {game,token,event} = source(3,'richese');
const response = structuredClone(game.response), reserves = seat(game,'richese').reserves;
const tanks = seat(game,'richese').tanks, emperorSpice = seat(game,'emperor').spice;
let after = act(game,'richese',{type:'revealNoField',token,event});
assert.deepEqual(after.response,response);
assert.equal(reserves,1);
assert.equal(seat(after,'richese').reserves,0);
assert.equal(seat(after,'richese').tanks,tanks);
after = passResponse(after);
assert.equal(seat(after,'richese').forces['polar_sink:0'],1);
assert.equal(seat(after,'richese').reserves,2);
assert.equal(seat(after,'richese').tanks,tanks - 2);
assert.equal(seat(after,'emperor').spice,emperorSpice - 4);
});
void test('printed Karama cancels the Emperor revival after reveal without undoing it', () => {
const {game,token,event,recipient} = source(3,'atreides');
const karama = seat(game,'richese').hand.find(card => card.effect === 'karama'); assert.ok(karama);
const emperorSpice = seat(game,'emperor').spice, reserves = seat(game,recipient).reserves,
tanks = seat(game,recipient).tanks;
let after = act(game,'richese',{type:'revealNoField',token,event});
after = act(after,'richese',{type:'card',mode:'cancel',card:karama.id});
if (after.pendingKull?.stage === 'offer')
after = act(after,after.pendingKull.player,{type:'kullDecision',event:after.pendingKull.event,decline:true});
while (after.pendingTreacheryDiscard?.reaction?.stage === 'offer') {
const reaction = after.pendingTreacheryDiscard.reaction, batch = after.pendingTreacheryDiscard.batch.event;
const responder = after.players.find(player => !reaction.passed.includes(player.id)); assert.ok(responder);
after = act(after,responder.id,{type:'semutaPass',event:batch});
}
assert.notEqual(after.response?.kind,'emperorRevival');
assert.equal(seat(after,'emperor').spice,emperorSpice);
assert.equal(seat(after,recipient).reserves,reserves);
assert.equal(seat(after,recipient).tanks,tanks);
assert.equal(after.emperorExtra[recipient] ?? 0,0);
assert.equal(seat(after,'richese').noField?.deployed,null);
assert.equal(seat(after,'richese').forces['polar_sink:0'],3);
assert.equal(after.discard.filter(card => card.id === karama.id).length,1);
});
+60
View File
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, newPlayer, type Game } from '../game/engine';
import { richeseRevealInterruptSupported as supported } from '../game/richese-reveal-interruption';
/** Quote-only staged response; native action and physical continuation are
* exercised separately by the integration owner. */
function source(target: 'richese' | 'atreides' = 'atreides'): Game {
const game = createGame('REVEALEXTRAREVIVAL', newPlayer('richese', 'Richese', 'richese'), false, ['choam']);
game.players.push(newPlayer('emperor', 'Emperor', 'emperor'), newPlayer('atreides', 'Atreides', 'atreides'));
game.status = 'playing'; game.phase = 4; game.turn = 2;
const emperor = game.players.find(player => player.id === 'emperor')!;
const recipient = game.players.find(player => player.id === target)!;
emperor.ally = target; recipient.ally = emperor.id; recipient.tanks = 2; emperor.spice = 10;
game.response = { kind: 'emperorRevival', owner: emperor.id, recipient: target, amount: 2, elite: 0, passed: [] };
return game;
}
void test('a clean Emperor extra revival preserves the accepted response for either ally', () => {
for (const recipient of ['richese', 'atreides'] as const) {
const game = source(recipient), before = structuredClone(game);
assert.equal(supported(game), true, recipient);
assert.deepEqual(game, before);
}
});
void test('extra revival admission checks its original funding, physical forces and mutual ally', () => {
for (const mutate of [
(g: Game) => { g.phase = 3; },
(g: Game) => { g.response!.owner = 'atreides'; },
(g: Game) => { g.response!.recipient = 'emperor'; },
(g: Game) => { g.players.find(p => p.id === 'atreides')!.ally = null; },
(g: Game) => { g.players.find(p => p.id === 'atreides')!.tanks = 1; },
(g: Game) => { g.players.find(p => p.id === 'emperor')!.spice = 3; },
(g: Game) => { g.response!.amount = 0; },
(g: Game) => { g.response!.elite = 1; },
(g: Game) => { g.emperorExtra.atreides = 2; },
(g: Game) => { g.revivalPrevention = {player:'atreides',turn:g.turn}; },
(g: Game) => { g.response!.passed = ['atreides','atreides']; },
(g: Game) => { g.response!.intent = 'other'; },
(g: Game) => { g.decision = {kind:'auctionPayment',player:'emperor'}; },
(g: Game) => { g.pendingRevival = {} as NonNullable<Game['pendingRevival']>; },
]) {
const game = source(); mutate(game);
const before = structuredClone(game);
assert.equal(supported(game), false);
assert.deepEqual(game, before);
}
});
void test('Ixian extra revivals retain the special-force price and typed tanks', () => {
const game = source(); game.expansions.push('ix');
const recipient = game.players.find(player => player.id === 'atreides')!;
recipient.faction = 'ixians'; recipient.elites = {reserves:0,tanks:1,forces:{},revived:0};
game.response!.elite = 1;
const emperor = game.players.find(player => player.id === 'emperor')!;
emperor.spice = 5; assert.equal(supported(game), true);
emperor.spice = 4; assert.equal(supported(game), false);
emperor.spice = 5; game.response!.elite = 2; assert.equal(supported(game), false);
});
@@ -15,7 +15,7 @@ const player = (g: Game, id: string) => {
/** Genuine Advanced Richese/Atreides/Emperor setup, Black Market offer and
* successful inspection. Only the existing Truthtrance is moved between hands
* so a different faction can ask the native Atreides seat. */
function inspectedLot() {
function inspectedLot(method: 'silent' | 'onceAround' = 'silent') {
const fixture = createTruthtranceRichesePromisesFixture({ advanced: true });
let game = structuredClone(fixture.game);
const atreides = player(game, 'atreides'), emperor = player(game, 'emperor');
@@ -25,7 +25,7 @@ function inspectedLot() {
game = advance(game, state => state.decision?.kind === 'richeseBlackMarket');
const card = player(game, 'richese').hand.find(candidate => candidate.name === 'Shield'); assert.ok(card);
game = act(game, 'richese', { type:'decision', event:game.richeseBidding!.event,
card:card.id, method:'silent' } as Action);
card:card.id, method, ...(method === 'onceAround' ? { direction:'clockwise' } : {}) } as Action);
for (let count = 0; game.response && count < 20; count++) {
const respondent = game.players.find(candidate => !game.response!.passed.includes(candidate.id));
assert.ok(respondent); game = act(game, respondent.id, {type:'passResponse'});
@@ -48,8 +48,9 @@ function ask(game: Game, truth: string, name: string): Game {
fact:{kind:'auctionPeek',name}}});
}
void test('native Advanced Black Market inspection gives Atreides a private Yes or No and all bots answer legally', () => {
const {game, card, truth} = inspectedLot();
for (const method of ['silent', 'onceAround'] as const) void test(`native Advanced ${method} Black Market inspection gives Atreides a private Yes or No and all bots answer legally`, () => {
const {game, card, truth} = inspectedLot(method);
assert.equal(game.richeseAuction?.method, method);
for (const [name,answer] of [[card.name,'yes'],['Snooper','no']] as const) {
const question = ask(structuredClone(game), truth.id, name);
assert.equal(viewGame(question, 'atreides').truthAnswer, answer);