Allow Richese reveal during Emperor revival
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
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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user