Expand Basic No-Field reveal response timing
Verify and publish checkpoint container / container (push) Successful in 11m10s

This commit is contained in:
Quintine committed 2026-10-29 09:47:39 +11:00
1 parent eb68f28a3a
commit 3dbd326891
8 files changed
+574 -1

No files matched your search

@@ -0,0 +1,173 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { richeseRevealInterruptSupported } from '../game/richese-reveal-interruption';
import { actTruthMovementNativeEarly as act, nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early';
import { truthMovementInventory } from './fixture-truth-movement';
const richese = 'richese', guild = 'guild', atreides = 'atreides';
const player = (game: Game, id: string) => {
const result = game.players.find(row => row.id === id); assert.ok(result); return result;
};
const clean = (game: Game) => !game.response && !game.decision && !game.phaseOpening &&
!game.truthtrance && !game.pendingTreacheryDiscard;
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
/** Real public Basic setup, paid No-Field shipment and Guild-rate declaration.
* Only unopened starting and Spice card positions and scalar lotteries are
* selected; no hands, balance, forces, alliances or pending events are staged. */
function nativeRateSource() {
let game = createGame('RICHESEGUILDRATE', newPlayer(richese, 'Richese', 'richese'), false, ['choam']);
joinGame(game, newPlayer(guild, 'Guild', 'guild'));
joinGame(game, newPlayer(atreides, 'Atreides', 'atreides'));
for (const seat of game.players) game = act(game, seat.id, {type:'ready'});
game = act(game, game.host, {type:'start'});
assert.equal(game.status, 'setup');
const ordinary = game.deck.filter(card => ['projectile','poison','shield','snooper'].includes(card.kind)).slice(0, 2);
const karama = game.deck.find(card => card.effect === 'karama');
assert.equal(ordinary.length, 2); assert.ok(karama);
for (const [position, card] of [...ordinary, karama].entries()) {
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
}
const reach = (until: (state: Game) => boolean) => {
for (let count = 0; count < 2200; count++) {
if (until(game)) return;
assert.notEqual(game.status, 'finished');
if (game.phase === 5 && game.active === richese && clean(game) &&
!player(game, richese).noField?.deployed && !player(game, richese).shipped) {
const owner = player(game, richese), marker = owner.noField!.tokens.find(token => token.value === 3);
assert.ok(marker); assert.ok(owner.noFieldEvent);
game = act(game, richese, {type:'ship', noField:marker.id, event:owner.noFieldEvent,
territory:'polar_sink', sector:0, allyPayment:0});
continue;
}
const step = nextTruthMovementNativeEarlyStep(game, {
position:'first', method:'onceAround', direction:'clockwise', keepUnbid:false,
});
assert.ok(step, `Missing native action at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = act(game, step.actor, step.action);
}
throw Error('The genuine Guild-rate shipment was not reached');
};
reach(state => state.status === 'playing');
assert.ok(player(game, atreides).hand.some(card => card.id === karama.id));
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]];
}
reach(state => state.phase === 5 && state.active === guild && clean(state) &&
!!player(state, richese).noField?.deployed && !player(state, guild).shipped);
const marker = player(game, richese).noField!.deployed!;
const event = player(game, richese).noFieldEvent; assert.ok(event);
const initialGuild = structuredClone(player(game, guild));
game = act(game, guild, {type:'ship', territory:'arrakeen', sector:10, amount:3, allyPayment:0});
assert.equal(game.response?.kind, 'guildRate');
assert.equal(game.pendingShipment?.player, guild);
assert.equal(game.pendingShipment?.territory, 'arrakeen');
assert.equal(game.pendingShipment?.cost, 2);
assert.equal(player(game, guild).reserves, initialGuild.reserves);
assert.equal(player(game, guild).spice, initialGuild.spice);
assert.equal(player(game, guild).shipped, false);
assert.ok(viewGame(game, atreides).responseControls?.cancelCards.includes(karama.id));
return {game, marker:marker.tokenId, event, karama:karama.id, initialGuild};
}
let saved: ReturnType<typeof nativeRateSource> | undefined;
const source = () => saved ??= nativeRateSource();
function reveal() {
const {marker,event} = source(), before = structuredClone(source().game);
const response = structuredClone(before.response), shipment = structuredClone(before.pendingShipment);
const oldRichese = player(before, richese), cards = truthMovementInventory(before);
assert.equal(viewGame(before, richese).richeseNoField?.canReveal, true);
const action = {type:'revealNoField', token:marker, event} satisfies Action;
const wrong = structuredClone(before);
assert.throws(() => act(wrong, atreides, action));
assert.deepEqual(wrong, before);
const after = act(before, richese, action);
assert.deepEqual(after.response, response);
assert.deepEqual(after.pendingShipment, shipment);
assert.equal(player(after, richese).noField?.deployed, null);
assert.equal(player(after, richese).noField?.lastShipped, marker);
assert.notEqual(player(after, richese).noFieldEvent, event);
assert.equal(player(after, richese).reserves, oldRichese.reserves - 3);
assert.equal(player(after, richese).forces['polar_sink:0'], (oldRichese.forces['polar_sink:0'] ?? 0) + 3);
assert.equal(player(after, richese).spice, oldRichese.spice);
assert.deepEqual(truthMovementInventory(after), cards);
assert.throws(() => act(after, richese, action));
return after;
}
for (const cancel of [false, true]) void test(
`Basic No-Field reveal preserves Guild-rate ${cancel ? 'cancellation' : 'allowance'} and the original shipment`, () => {
const {karama,initialGuild} = source();
const before = structuredClone(source().game), after = reveal();
const action: Action = cancel ? {type:'card', mode:'cancel', card:karama} : {type:'passResponse'};
let original = act(reload(before), atreides, action);
let continued = act(reload(after), atreides, action);
if (cancel) {
for (const state of [original, continued]) {
assert.equal(state.pendingTreacheryDiscard?.batch.cause, 'karama:guildRate');
assert.equal(player(state, guild).reserves, initialGuild.reserves);
assert.equal(player(state, guild).spice, initialGuild.spice);
assert.equal(player(state, guild).shipped, false);
assert.ok(state.pendingShipment);
}
const finishDiscard = (state: Game) => {
for (let count = 0; state.pendingTreacheryDiscard && count < state.players.length + 1; count++) {
const step = nextTruthMovementNativeEarlyStep(state); assert.ok(step);
assert.equal(step.action.type, 'semutaPass');
state = act(state, step.actor, step.action);
}
assert.equal(state.pendingTreacheryDiscard, null);
return state;
};
original = finishDiscard(original);
continued = finishDiscard(continued);
}
assert.equal(continued.response?.kind === 'guildRate', false);
assert.equal(continued.pendingShipment, null);
assert.deepEqual(player(continued, guild), player(original, guild));
assert.equal(player(continued, guild).reserves, initialGuild.reserves - 3);
assert.equal(player(continued, guild).forces['arrakeen:10'], (initialGuild.forces['arrakeen:10'] ?? 0) + 3);
assert.equal(player(continued, guild).spice, initialGuild.spice - (cancel ? 3 : 2));
assert.equal(player(continued, guild).shipped, true);
assert.equal(player(continued, richese).forces['polar_sink:0'], 3);
assert.equal(continued.discard.filter(card => card.id === karama).length, cancel ? 1 : 0);
assert.throws(() => act(continued, atreides, action));
},
);
void test('all four bot profiles can answer the preserved Guild-rate response', () => {
const after = reveal();
for (const difficulty of DIFFICULTIES) {
const view = viewGame(after, atreides);
view.players.find(seat => seat.id === atreides)!.bot = difficulty;
const choice = botActions(view)[0]; assert.ok(choice, difficulty);
const next = act(reload(after), atreides, choice);
assert.equal(next.response?.kind === 'guildRate', false);
assert.equal(player(next, richese).forces['polar_sink:0'], 3);
assert.equal(player(next, guild).forces['arrakeen:10'], 3);
}
});
void test('Guild-rate reveal quote rejects a changed source, conflicting destination, stale event and other live work', () => {
const original = structuredClone(source().game);
assert.equal(richeseRevealInterruptSupported(original), true);
const variations: [string, (game: Game) => void][] = [
['destination at marker', game => { game.pendingShipment!.territory = 'polar_sink'; }],
['changed shipper', game => { game.pendingShipment!.player = richese; }],
['stale event', game => { game.response!.guildRateEvent = 'stale'; }],
['other pending child', game => { Object.assign(game, {pendingIxMove: {event:'other-child'}}); }],
['current Truthtrance history', game => { Object.assign(game, {truthHistory: [{turn:game.turn}]}); }],
];
for (const [name, mutate] of variations) {
const copy = structuredClone(original); mutate(copy);
const before = structuredClone(copy);
assert.equal(richeseRevealInterruptSupported(copy), false, name);
assert.deepEqual(copy, before, `${name}: a quote must not change game state`);
}
});
@@ -0,0 +1,177 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { actTruthMovementNativeEarly as act, nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early';
import { truthMovementInventory as inventory } from './fixture-truth-movement';
const seat = (g: Game, id: string) => g.players.find(player => player.id === id)!;
const quiet = (g: Game) => !g.response && !g.decision && !g.phaseOpening && !g.truthtrance && !g.pendingTreacheryDiscard;
function next(g: Game) {
if (g.phase === 5 && g.active === 'emperor' && quiet(g))
return {actor: 'emperor', action: {type:'endMovement'} satisfies Action};
return nextTruthMovementNativeEarlyStep(g, {position:'first', method:'onceAround', direction:'clockwise', keepUnbid:false});
}
function reach(g: Game, predicate: (g: Game) => boolean): Game {
for (let count = 0; count < 1800; count++) {
if (predicate(g)) return g;
assert.notEqual(g.status, 'finished');
const step = next(g);
assert.ok(step, `No native step at ${g.turn}/${g.phase}/${g.decision?.kind}`);
g = act(g, step.actor, step.action);
}
throw Error('The native game did not reach its response.');
}
/** Original Basic setup, actual paid first-turn No-Field shipment, then the
* following Movement phase. Only unopened starting-card and Spice positions
* are controlled, preserving every original physical card. */
function source() {
let g = createGame('RICHESEORDINARY', newPlayer('richese', 'Richese', 'richese'), false, ['choam','ix']);
for (const id of ['choam','atreides','emperor'] as const) joinGame(g, newPlayer(id, id, id));
for (const p of g.players) g = act(g, p.id, {type:'ready'});
g = act(g, g.host, {type:'start'});
const ordinary = g.deck.filter(card => ['projectile','poison','shield','snooper'].includes(card.kind)).slice(0, 3);
const karama = g.deck.find(card => card.effect === 'karama');
assert.equal(ordinary.length, 3); assert.ok(karama);
for (const [position, card] of [...ordinary, karama].entries()) {
const from = g.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
[g.deck[position], g.deck[from]] = [g.deck[from], g.deck[position]];
}
g = reach(g, state => state.status === 'playing');
assert.ok(seat(g, 'emperor').hand.some(card => card.id === karama.id));
for (const position of [0,1]) {
const from = g.spiceDeck.findIndex((card,index) => index >= position && 'territory' in card && !card.discovery);
assert.ok(from >= position);
[g.spiceDeck[position],g.spiceDeck[from]] = [g.spiceDeck[from],g.spiceDeck[position]];
}
g = reach(g, state => state.turn === 1 && state.phase === 5 && state.active === 'richese' && quiet(state));
const token = seat(g,'richese').noField!.tokens.find(item => item.value === 3); assert.ok(token);
const event = seat(g,'richese').noFieldEvent; assert.ok(event);
g = act(g, 'richese', {type:'ship', noField:token.id, event, territory:'polar_sink', sector:0, allyPayment:0});
g = reach(g, quiet);
g = act(g, 'richese', {type:'endMovement'});
g = reach(g, state => state.turn === 2 && state.phase === 5 && state.response?.kind === 'atreidesSpice');
assert.equal(seat(g,'richese').noField?.deployed?.tokenId, token.id);
const revealEvent = seat(g,'richese').noFieldEvent; assert.ok(revealEvent);
return {game:g, token:token.id, event:revealEvent, karama:karama.id};
}
let cached: ReturnType<typeof source> | undefined;
const fixture = () => cached ??= source();
function reveal(g: Game) {
const {token,event} = fixture();
assert.equal(viewGame(g,'richese').richeseNoField?.canReveal, true);
return act(g,'richese',{type:'revealNoField',token,event});
}
function assertMaterialized(before: Game, after: Game) {
assert.equal(seat(after,'richese').noField?.deployed, null);
assert.equal(seat(after,'richese').reserves, seat(before,'richese').reserves - 3);
assert.equal(seat(after,'richese').forces['polar_sink:0'], (seat(before,'richese').forces['polar_sink:0'] ?? 0) + 3);
assert.deepEqual(inventory(after), inventory(before));
}
function finishCancellation(g: Game, continuation: 'karamaTerminalDiscard' | 'karamaEmperorShareDiscard') {
const kull = g.pendingKull;
assert.ok(kull);
assert.equal(kull.stage, 'offer');
assert.equal(kull.card, fixture().karama);
g = act(g, kull.player, {type:'kullDecision',event:kull.event,decline:true});
const original = g.pendingTreacheryDiscard;
assert.ok(original);
assert.equal(original.continuation.kind, continuation);
const event = original.batch.event;
assert.deepEqual(original.batch.entries.map(entry => entry.card.id), [fixture().karama]);
while (g.pendingTreacheryDiscard?.batch.event === event) {
const reaction = g.pendingTreacheryDiscard.reaction;
assert.equal(reaction?.stage, 'offer');
const actor = g.players.find(player => !reaction.passed.includes(player.id));
assert.ok(actor);
g = act(g, actor.id, {type:'semutaPass',event});
}
return g;
}
void test('voluntary reveal preserves the native Atreides Spice inspection and its existing passes', () => {
let before = structuredClone(fixture().game);
before = act(before,'richese',{type:'passResponse'});
assert.equal(before.response?.kind,'atreidesSpice');
const response = structuredClone(before.response), deck = structuredClone(before.spiceDeck);
const after = reveal(before);
assert.deepEqual(after.response,response); assert.deepEqual(after.spiceDeck,deck);
assertMaterialized(before,after);
const control = act(before,'emperor',{type:'passResponse'});
const continued = act(after,'emperor',{type:'passResponse'});
assert.equal(continued.response?.kind === 'atreidesSpice',false);
assert.equal(continued.spicePeekKnown,control.spicePeekKnown);
assert.deepEqual(continued.spiceDeck,control.spiceDeck);
assert.equal(seat(continued,'richese').forces['polar_sink:0'],seat(after,'richese').forces['polar_sink:0']);
});
void test('Karama still cancels the original Spice inspection after the marker reveal', () => {
const before = structuredClone(fixture().game), after = reveal(before);
const continued = finishCancellation(act(after,'emperor',{type:'card',card:fixture().karama,mode:'cancel'}),'karamaTerminalDiscard');
assert.equal(continued.response?.kind === 'atreidesSpice',false);
assert.equal(continued.spicePeekKnown,false);
assert.equal(continued.discard.filter(card => card.id === fixture().karama).length,1);
assert.deepEqual(continued.spiceDeck,before.spiceDeck);
assert.equal(seat(continued,'richese').forces['polar_sink:0'],seat(after,'richese').forces['polar_sink:0']);
});
function giftSource() {
let g = structuredClone(fixture().game);
g = reach(g,state => state.turn === 2 && state.phase === 5 && state.active === 'emperor' && quiet(state));
// The real gift declaration is used; only the pre-existing reciprocal
// alliance is staged so this fixture does not depend on a random Nexus.
seat(g,'emperor').ally = 'richese'; seat(g,'richese').ally = 'emperor';
// Transfer the original Karama to a separate responder with a physical
// card swap so the gift has a real counter opportunity.
const emperor = seat(g,'emperor'), atreides = seat(g,'atreides');
const index = emperor.hand.findIndex(card => card.id === fixture().karama);
assert.ok(index >= 0 && atreides.hand.length);
[emperor.hand[index],atreides.hand[0]] = [atreides.hand[0],emperor.hand[index]];
g = act(g,'emperor',{type:'emperorGift',amount:3});
assert.equal(g.response?.kind,'emperorGift');
return g;
}
void test('voluntary reveal preserves an ordinary allied Emperor gift and its once-only payment', () => {
let before = giftSource();
before = act(before,'richese',{type:'passResponse'});
const response = structuredClone(before.response), emperor = seat(before,'emperor').spice,
richese = seat(before,'richese').spice;
const after = reveal(before);
assert.deepEqual(after.response,response); assertMaterialized(before,after);
const control = act(before,'atreides',{type:'passResponse'});
const continued = act(after,'atreides',{type:'passResponse'});
assert.equal(continued.response?.kind === 'emperorGift',false);
assert.equal(seat(continued,'emperor').spice,emperor - 3);
assert.equal(seat(continued,'richese').spice,richese + 3);
assert.equal(seat(continued,'emperor').spice,seat(control,'emperor').spice);
assert.equal(seat(continued,'richese').spice,seat(control,'richese').spice);
assert.equal(seat(continued,'richese').forces['polar_sink:0'],seat(after,'richese').forces['polar_sink:0']);
});
void test('Karama still cancels the original allied gift after the marker reveal', () => {
const before = giftSource(), after = reveal(before);
const emperor = seat(before,'emperor').spice, richese = seat(before,'richese').spice;
const continued = finishCancellation(act(after,'atreides',{type:'card',card:fixture().karama,mode:'cancel'}),'karamaEmperorShareDiscard');
assert.equal(continued.response?.kind === 'emperorGift',false);
assert.equal(seat(continued,'emperor').spice,emperor);
assert.equal(seat(continued,'richese').spice,richese);
assert.equal(continued.discard.filter(card => card.id === fixture().karama).length,1);
assert.equal(seat(continued,'richese').forces['polar_sink:0'],seat(after,'richese').forces['polar_sink:0']);
});
void test('all bot profiles retain a legal response to either original parent after reveal', () => {
for (const before of [structuredClone(fixture().game),giftSource()]) {
const after = reveal(before);
for (const difficulty of DIFFICULTIES) {
const actor = before.response?.kind === 'emperorGift' ? 'atreides' : 'emperor';
const view = viewGame(after,actor);
view.players.find(p => p.id === actor)!.bot = difficulty;
const action = botActions(view).find(item => item.type === 'passResponse' || item.type === 'card' && item.mode === 'cancel');
assert.ok(action, `${difficulty} needs an original response action`);
assert.doesNotThrow(() => applyAction(after,actor,action));
}
}
});
@@ -0,0 +1,152 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { actTruthMovementNativeEarly as act, nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early';
import { truthMovementInventory as inventory } from './fixture-truth-movement';
const richese = 'richese', tleilaxu = 'tleilaxu', emperor = 'emperor';
const seat = (game: Game, id: string) => {
const player = game.players.find(candidate => candidate.id === id);
assert.ok(player, `Missing ${id}`);
return player;
};
const clean = (game: Game) => !game.response && !game.decision && !game.phaseOpening &&
!game.truthtrance && !game.pendingRevival && !game.pendingTreacheryDiscard;
function next(game: Game) {
if (game.turn === 1 && game.phase === 5 && game.active === richese && clean(game)) {
const owner = seat(game, richese);
if (!owner.shipped) {
const token = owner.noField!.tokens.find(item => item.value === 3); assert.ok(token);
return { actor:richese, action:{ type:'ship', noField:token.id, event:owner.noFieldEvent,
territory:'polar_sink', sector:0, allyPayment:0 } satisfies Action };
}
return { actor:richese, action:{type:'endMovement'} satisfies Action };
}
if (game.phase === 5 && game.active === emperor && clean(game))
return { actor:emperor, action:{type:'endMovement'} satisfies Action };
return nextTruthMovementNativeEarlyStep(game, {position:'first',method:'onceAround',direction:'clockwise'});
}
function reach(game: Game, condition: (state: Game) => boolean): Game {
for (let i = 0; i < 2000; i++) {
if (condition(game)) return game;
assert.notEqual(game.status, 'finished', 'The native game ended before Revival');
const step = next(game);
assert.ok(step, `Supply the original action at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = act(game, step.actor, step.action);
}
throw Error('The native game did not reach the requested Revival boundary');
}
/** Genuine mixed-deck setup, real first-turn marker shipment, and real next-turn
* revival. One physical Karama is swapped into Emperor's starting hand, and
* three Richese counters are moved from reserves to Tanks at the Revival
* boundary; both controlled preparations conserve their physical inventories. */
function source() {
let game = createGame('RICHESEINCOMEREVEAL', newPlayer(richese, 'Richese', 'richese'), false, ['choam','ix']);
joinGame(game, newPlayer(tleilaxu, 'Tleilaxu', 'tleilaxu'));
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'});
assert.equal(game.status, 'setup');
const ordinary = game.deck.filter(card => ['projectile','poison','shield','snooper'].includes(card.kind)).slice(0, 2);
const karama = game.deck.find(card => card.effect === 'karama');
assert.equal(ordinary.length, 2); assert.ok(karama);
for (const [position, card] of [...ordinary,karama].entries()) {
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
}
game = reach(game, state => state.status === 'playing');
assert.ok(seat(game, emperor).hand.some(card => card.id === karama.id));
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 === 2 && state.phase === 4 && clean(state));
const owner = seat(game, richese), marker = owner.noField?.deployed, event = owner.noFieldEvent;
assert.ok(marker); assert.ok(event); assert.ok(owner.reserves >= 3);
owner.reserves -= 3; owner.tanks += 3;
const before = structuredClone(game), beforeReserves = owner.reserves, beforeTanks = owner.tanks;
game = act(game, richese, {type:'revive', amount:2, elite:0});
// The real native request has already returned counters and created the
// separate Tleilaxu income response before the No-Field is revealed.
assert.equal(seat(game, richese).reserves, beforeReserves + 2);
assert.equal(seat(game, richese).tanks, beforeTanks - 2);
assert.equal(game.pendingRevival, null);
assert.equal(game.response?.kind, 'revivalIncome');
assert.equal(game.response.owner, tleilaxu);
assert.equal(game.response.recipient, richese);
assert.equal(game.response.amount, 1);
assert.ok(seat(game, emperor).hand.some(card => card.id === karama.id));
return {game, before, token:marker.tokenId, event, karama:karama.id, cards:inventory(game)};
}
let saved: ReturnType<typeof source> | undefined;
const original = () => saved ??= source();
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
function passIncome(game: Game): Game {
for (let i = 0; game.response?.kind === 'revivalIncome' && i < 8; i++) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
game = act(game, actor.id, {type:'passResponse'});
}
assert.notEqual(game.response?.kind, 'revivalIncome');
return game;
}
void test('Richese reveals after its real returned forces while native Tleilaxu income remains pending', () => {
const {game,token,event,cards} = original(), response = structuredClone(game.response),
reserves = seat(game, richese).reserves, tanks = seat(game, richese).tanks,
field = seat(game, richese).forces['polar_sink:0'] ?? 0, income = seat(game, tleilaxu).spice;
assert.equal(viewGame(game, richese).richeseNoField?.canReveal, true);
reject(game, emperor, {type:'revealNoField',token,event});
reject(game, richese, {type:'revealNoField',token,event:'stale'});
let after = act(game, richese, {type:'revealNoField',token,event});
assert.deepEqual(after.response, response);
assert.equal(seat(after, richese).reserves, reserves - 3);
assert.equal(seat(after, richese).forces['polar_sink:0'], field + 3);
assert.equal(seat(after, richese).tanks, tanks);
assert.equal(seat(after, richese).noField?.deployed, null);
assert.deepEqual(inventory(after), cards);
reject(after, richese, {type:'revealNoField',token,event});
after = passIncome(after);
assert.equal(seat(after, tleilaxu).spice, income + 1);
assert.equal(seat(after, richese).tanks, tanks);
assert.equal(seat(after, richese).forces['polar_sink:0'], field + 3);
assert.deepEqual(inventory(after), cards);
});
void test('Karama cancels the original income after reveal without undoing revival or materializing twice', () => {
const {game,token,event,karama} = original(), income = seat(game, tleilaxu).spice,
tanks = seat(game, richese).tanks, reserves = seat(game, richese).reserves;
let after = act(game, richese, {type:'revealNoField',token,event});
after = act(after, emperor, {type:'card',card:karama,mode:'cancel'});
after = passIncome(after);
assert.equal(seat(after, tleilaxu).spice, income);
assert.equal(seat(after, richese).tanks, tanks);
assert.equal(seat(after, richese).reserves, reserves - 3);
assert.equal(seat(after, richese).forces['polar_sink:0'], 3);
assert.equal(after.discard.filter(card => card.id === karama).length, 1);
});
void test('every minimal bot profile can continue the original income response after reveal', () => {
const {game,token,event} = original();
const revealed = act(game, richese, {type:'revealNoField',token,event});
for (const difficulty of DIFFICULTIES) {
const view = viewGame(revealed, emperor);
view.players.find(player => player.id === emperor)!.bot = difficulty;
const action = botActions(view).find(candidate => candidate.type === 'passResponse' ||
candidate.type === 'card' && candidate.mode === 'cancel');
assert.ok(action, difficulty);
const continued = applyAction(revealed, emperor, action);
assert.ok(continued.response?.kind !== 'revivalIncome' || continued.response.passed.includes(emperor) ||
continued.pendingKarama || continued.pendingTreacheryDiscard, difficulty);
assert.equal(seat(continued, richese).forces['polar_sink:0'], 3);
}
});