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

+13
View File
@@ -17,6 +17,19 @@ open mode gates.
## Current checkpoint and work
**Basic Richese reveal during additional responses — 29 October. Prototyped /
Partial.** Richese may now reveal an existing No-Field during clean Emperor
allied gifts, Tleilaxu revival income, selected Atreides Spice forecasts and
Guild's own ordinary reserve-shipment rate response in a different territory.
The original response, committed revival and shipment remain in place; normal
Kull/Semuta cancellation continuations and original payments still resolve.
Native action fixtures cover allow/cancel, projected control, legal bot replies
and source boundaries; the revival fixture stages conserved Tanks counters and
the gift fixture stages an alliance and physical card swap. Nine related files
pass 36 cases; types/lint and production build pass. Other transactions,
commitments and complete printed reveal timing remain open; deployed acceptance
is outstanding. [Scope](RICHESE_NO_FIELD_RULES.md#basic-additional-response-windows--29-october-2026).
**Advanced mixed-family paired arrivals — 29 October. Prototyped / Partial.**
An Advanced native-faction table with Ecaz and Moritani can now retain both
Ambassador and Terror opportunities when one movement enters their shared
+14
View File
@@ -2,6 +2,20 @@
Audit date: 2026-09-06. This document distinguishes the printed token lifecycle from engine integration and unresolved combinations. It does not certify Richese, Homeworlds, or any other gated game mode.
## Basic additional response windows — 29 October 2026
The voluntary before-Battle reveal also works during clean ordinary Emperor
allied spice gifts, Tleilaxu revival-income responses, and Atreides' Movement
phase Spice forecast when Bene Gesserit is absent. It can additionally occur
while Guild's own ordinary reserve shipment awaits its native rate response,
when its destination is in a different territory from the marker. The response,
its passes and the original shipment or payment remain committed; a Karama
cancellation still follows its original continuation. Revelation uses the
reserves available at the time, including any already completed revival.
This ordering is a provisional online interpretation. Other shipment and
revival transactions, nested responses, active commitments, current-turn
Truthtrance history and optional modules remain outside this scope.
## Basic phase-opening and economic interruptions — 29 October 2026
The same voluntary reveal is also prototyped for the clean CHOAM Charity,
+8
View File
@@ -1,5 +1,13 @@
# Rule decision index
## Richese voluntary reveal during Basic responses — 29 October 2026
The printed before-Battle reveal now composes with clean Emperor gifts,
Tleilaxu revival income, selected Atreides Spice forecasts and Guild's own
ordinary reserve shipment rate response. Their original decisions and payments
remain intact; online ordering is provisional and other transactions remain
guarded. [Scope](RICHESE_NO_FIELD_RULES.md#basic-additional-response-windows--29-october-2026).
## Advanced mixed-family native arrivals — 29 October 2026
Advanced native-faction tables can use the existing paired Ecaz Ambassador and
+1
View File
@@ -1130,6 +1130,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'In Basic play, your own single concealed No-Field may provisionally ship into an ally’s territory if it can legally enter; move it and any paid accompanying forces out before your turn ends. Paid companions require the High Richese Homeworld power. If the marker remains, reveal it and send its newly materialized forces to Tanks under the provisional no-departure policy. A zero marker reveals but contributes no casualties. Existing physical forces and the ally are not part of that new-visitor loss. Paired markers keep their separate guards.',
'A normal No-Field shipment pays the one-force price. The concealed token moves like a force. Before Battle, Richese may reveal it voluntarily; a token involved in battle is revealed with the Battle Plan. Revelation brings the indicated forces from reserves, limited by what remains there.',
'In supported Basic native games, voluntary revelation is also available during an Ix phase opening or selected auction and market decisions without replacing that decision. Other interruption windows remain unfinished.',
'In supported Basic games, your voluntary reveal can also interrupt a clean Emperor allied spice gift, Tleilaxu revival-income response, Atreides Movement-phase Spice forecast without Bene Gesserit, or Guild’s own ordinary reserve-shipment rate response when the shipment enters a different territory from the marker. The original response and payment continue once; other interruptions remain guarded.',
'Provisional Guild transport treats your owned concealed marker as one billable force alongside any same-origin physical counters. Native Guild allies may cross-ship it through the normal permission and rate responses. The actual absent-Guild Secret Ally may cross-ship or return it face down, spending its Nexus once and paying the Bank. Cross keeps concealment; return creates no denomination-sized reserve deposit and retains the same-token next-shipment ban. Ordinary Guild allies cannot return. Select the marker separately from physical counters; ordinary movement remains available.',
'In supported native Homeworld games, you may provisionally ship one concealed No-Field from your reserves to a foreign non-allied Homeworld, alone or with physical forces from those reserves. On a later turn, you may ship that same concealed marker between distinct eligible Homeworlds, alone or with selected physical Richese counters from the marker’s current World; its own Homeworld is an eligible endpoint only in Advanced. Price the whole group as one plus those counters; a purchased Guild rate halves the total, rounding up once. The marker retains its identity and last-shipment history, and its value does not materialize until reveal; available reserves are placed there unless it is already on its own World, where the physical pool stays unchanged. Only one marker may be deployed globally. Fresh placement from the screen to your own Homeworld and optional-module combinations remain unavailable; an Advanced native return uses the provisional physical-reserve interpretation described below. The supplied revised rulebook permits World-to-World shipment (physical p. 22) and counts a concealed marker as one force for effects (physical p. 36), but does not explicitly resolve this combination; this is a provisional interpretation.',
'With a living, uncaptured Planetologist, your own concealed marker may provisionally use either ordinary movement alternative. Extra range adds one, capped at three, for one origin. Gathering combines exactly two territories at one destination using each selected sector’s ordinary range; the marker can supply one origin alone or with physical companions, and physical counters supply the other. The alternatives do not stack. The marker stays concealed, including zero, and spends one movement without materializing reserves. Physical and Discovery flight use the provisional skill transport policy with original marker quotas; complete combinations remain unfinished.',
+36 -1
View File
@@ -32,9 +32,27 @@ export function richeseRevealInterruptSupported(g: Game): boolean {
// interruption behavior; fresh games initialize an explicit empty history.
if (!Array.isArray(g.truthHistory) || g.truthHistory.some(entry => entry.turn === g.turn)) return false;
// Each pending field is an active child/continuation, not a completed
// An ordinary Guild reserve shipment is independent of Richese's reserves
// and marker when its destination is in a different territory. Its exact
// rate response remains open while the marker is materialized.
const shipment = g.pendingShipment;
const richese = g.players.find(player => player.faction === 'richese');
const marker = richese?.noField?.deployed?.location;
const guildRate = g.response?.kind === 'guildRate' && g.phase === 5 &&
!!shipment && !!richese && !!marker && 'territory' in marker &&
shipment.player === g.response.owner && shipment.player === g.active &&
g.players.some(player => player.id === shipment.player && player.faction === 'guild') &&
shipment.turn === g.turn && shipment.territory !== marker.territory &&
typeof shipment.guildRateEvent === 'string' && shipment.guildRateEvent.length > 0 &&
shipment.guildRateEvent === g.response.guildRateEvent &&
!shipment.source && !shipment.noField && !shipment.alliedNoField && !shipment.richesePair &&
!shipment.guildSecretEvent && !shipment.guildNexusEvent && !shipment.nexusEvent &&
!shipment.homeworldSources && !shipment.smuggler && !shipment.smugglerCompanion;
// Each other pending field is an active child/continuation, not a completed
// receipt. Empty pending arrays do not represent a live child.
if (Object.entries(g).some(([key, value]) => key.startsWith('pending') &&
!(key === 'pendingShipment' && guildRate) &&
(Array.isArray(value) ? value.length > 0 : value !== null && value !== undefined && value !== false))) return false;
const live = (rows: readonly {turn: number; fulfilled?: boolean; released?: boolean}[] | undefined) =>
@@ -65,6 +83,23 @@ export function richeseRevealInterruptSupported(g: Game): boolean {
const owner = g.players.find(player => player.id === response.owner);
if (!owner) return false;
const keys = Object.keys(response).sort().join(',');
if (response.kind === 'guildRate')
return !!guildRate && keys === 'guildRateEvent,intent,kind,owner,passed';
if (response.kind === 'atreidesSpice')
return keys === 'kind,owner,passed' && g.phase === 5 && owner.faction === 'atreides' &&
!g.players.some(player => player.faction === 'beneGesserit') &&
Array.isArray(g.spiceDeck) && g.spiceDeck.length > 0 && g.spicePeekKnown === false &&
Array.isArray(g.movementRemaining) && g.movementRemaining.length > 0;
if (response.kind === 'emperorGift') {
const recipient = g.players.find(player => player.id === response.recipient);
return keys === 'amount,elite,kind,owner,passed,recipient' && owner.faction === 'emperor' &&
!!recipient && recipient.id !== owner.id && owner.ally === recipient.id && recipient.ally === owner.id &&
Number.isSafeInteger(response.amount) && response.amount! > 0 && response.amount! <= owner.spice && response.elite === 0;
}
if (response.kind === 'revivalIncome')
return keys === 'amount,kind,owner,passed,recipient' && owner.faction === 'tleilaxu' &&
g.players.some(player => player.id === response.recipient) &&
Number.isSafeInteger(response.amount) && response.amount! > 0;
if (response.kind === 'choamCharity')
return keys === 'kind,owner,passed' && g.phase === 2 && owner.faction === 'choam' &&
g.choamCharity?.turn !== g.turn;
@@ -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);
}
});