Prototype Jacurutu income, Testing Station and Shrine effects

Connect automatic unambiguous Jacurutu physical-force income, a sole-occupant
Testing Station storm choice and Shrine reciprocal physical card roles to
engine actions, player controls, legal AI and saved continuation. Preserve
Weather Control exclusion, exact card/force custody, private hand boundaries
and committed effects after restart. Shared role checks cover existing ordinary
and special Karama paths; reserved cards cannot enter another pending use.

Keep Orgiz, mixed-force Jacurutu allocation and contested Cistern/Testing
Station benefits explicitly pending. These are bounded prototypes; complete
Discovery/Advanced/expansion and publication gates remain closed.

Validation: types, lint and 4,345 offline tests passed (32 added), production
build passed, and 40 HTTP tests passed. Independent cross-reviews repaired
reserved-card deadlock, physical-card selection, alternate-role permission,
storm source/token binding and public-entitlement bot ordering. The first broad
run found five failures; focused repairs preceded the stable repeated check.

Browser A5EJQ2W4 used genuine Discovery setup/reveal/free entry with targeted QA
positioning. A human increased storm movement from two to three sectors,
moving sector 6 to 9. Refresh restored station forces and the private Snooper.
This was targeted control/recovery evidence, not an unmodified full game.
All 187 opening saved rooms remained unchanged; 31 QA/HTTP rooms were added
(218 total). Reused the running server; no restart or save reset.

Source-bound report:
/home/quintine/.local/share/dune/checkpoints/20260913-discovery-location-effects/verification-final/report.json
Source fingerprint: beecb85ab7ec39319115389c115434d161591e8549802c542a6343a0fc19bea6
Durations: check 140540ms; build 6741ms; integration 24956ms.
Aggregate goal counter 63,116,444 to 64,295,091 at final verification:
1,178,647 tokens before final Git/push overhead for three bounded functions.
Standing authorization: Push all from now on.
This commit is contained in:
Quintine committed 2026-09-13 14:50:09 +10:00
1 parent ab0ab52849
commit f0d3d9c464
31 files changed
+2524 -138

No files matched your search

+6 -2
View File
@@ -9,6 +9,7 @@ import {
type Game,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { placeFixtureHand } from './fixture-hand';
const cards = baseDeck();
const shield = cards.find((c) => c.kind === 'shield')!;
const karama = cards.find((c) => c.effect === 'karama')!;
@@ -191,13 +192,16 @@ void test('normalization does not invent an ally split or bypass provisional unf
void test('Truthtrance, phase opening and genuine responses retain priority over a singleton payment', () => {
for (const overlay of ['truth', 'opening', 'response'] as const) {
const g = pendingPayment(auction());
if (overlay === 'truth')
if (overlay === 'truth') {
const truth = cards.find((card) => card.effect === 'truthtrance')!;
placeFixtureHand(g, 2, [truth]);
g.truthtrance = {
stage: 'ask',
queue: [{ player: 'a', card: 'truth' }],
queue: [{ player: 'a', card: truth.id }],
passed: [],
question: null,
};
}
if (overlay === 'opening')
g.phaseOpening = { initialize: false, passed: [] };
if (overlay === 'response') {
+6 -3
View File
@@ -10,6 +10,7 @@ import {
} from '../game/engine';
import { baseDeck, type Card } from '../game/cards';
import type { FactionId } from '../game/catalog';
import { placeFixtureHand } from './fixture-hand';
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
const cards = baseDeck();
@@ -264,14 +265,16 @@ void test('pending conversion never offers a nested Worthless conversion; real K
void test('Truthtrance and phase-opening overlays pause normalization without payment or response alteration', () => {
for (const overlay of ['truth', 'phase'] as const) {
const g = fixture();
if (overlay === 'truth')
if (overlay === 'truth') {
const truth = cards.find((card) => card.effect === 'truthtrance')!;
placeFixtureHand(g, 1, [truth]);
g.truthtrance = {
stage: 'ask',
queue: [{ player: 'b', card: 'spent-truth' }],
queue: [{ player: 'b', card: truth.id }],
passed: [],
question: null,
};
else g.phaseOpening = { initialize: false, passed: [] };
} else g.phaseOpening = { initialize: false, passed: [] };
const next = normalizeAutomaticGame(g);
assert.deepEqual(next.response, g.response);
assert.equal(next.players[0].spice, 10);
+112
View File
@@ -0,0 +1,112 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { DatabaseSync } from 'node:sqlite';
import type { RoomsClock } from '../db/rooms';
import { unitStore } from './fixture-nexus-room-store';
import { discoveryBattleBeforeFinalVote } from './fixture-discovery-battle';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
void test('SQLite restoration and competing final votes commit Jacurutu income exactly once', async () => {
const store = unitStore();
const sqlite: DatabaseSync = store.sqlite;
try {
const created = await store.rooms.createRoom(
'Jacurutu recovery',
'atreides',
false,
[],
);
const joined = await store.rooms.joinRoom(
created.view.code,
'Guild',
'guild',
);
const third = await store.rooms.joinRoom(
created.view.code,
'Fremen',
'fremen',
);
const tokens = [created.token, joined.token!, third.token!];
const auths = await Promise.all(
tokens.map((token) => store.rooms.authenticate(created.view.code, token)),
);
const ids = auths.map((auth) => auth.playerId) as [string, string, string];
const stored = await store.rooms.readRoom(created.view.code);
const { game, attacker, defender } = discoveryBattleBeforeFinalVote(
false,
ids,
);
game.code = created.view.code;
game.version = stored.version;
sqlite
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(game), game.version, game.code);
const seats = sqlite
.prepare('SELECT * FROM seats ORDER BY player_id')
.all();
let arrivals = 0;
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
store.hooks.beforeWrite = async () => {
if (++arrivals === 2) release();
await gate;
};
const timer = setTimeout(release, 2000);
try {
const results = await Promise.allSettled(
[0, 1].map(() =>
store
.restart()
.act(
game.code,
auths[1],
game.version,
{ type: 'traitorCall', call: false },
clock,
),
),
);
assert.equal(arrivals, 2);
assert.equal(
results.filter((result) => result.status === 'fulfilled').length,
1,
);
} finally {
clearTimeout(timer);
delete store.hooks.beforeWrite;
}
const done = await store.restart().readRoom(game.code);
assert.equal(done.version, game.version + 1);
assert.equal(
done.players.find((player) => player.id === attacker)!.spice,
23,
);
assert.equal(
done.players.find((player) => player.id === defender)!.tanks,
5,
);
assert.equal(
done.log.filter(
(entry) => entry.automatic?.name === 'Jacurutu Sietch income',
).length,
1,
);
const stable = JSON.stringify(done);
await store.restart().continueRoomAutomatic(game.code, clock);
assert.equal(
JSON.stringify(await store.restart().readRoom(game.code)),
stable,
);
assert.deepEqual(
sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(),
seats,
);
} finally {
sqlite.close();
}
});
+200
View File
@@ -0,0 +1,200 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
quoteJacurutuBattleIncome,
JACURUTU_BATTLE_INCOME_RULE,
} from '../game/discovery-battle';
import { applyAction, normalizeAutomaticGame, type Game } from '../game/engine';
import { JACURUTU_SIETCH } from '../game/discoveries';
import { discoveryBattleBeforeFinalVote } from './fixture-discovery-battle';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
void test('the source-bound contract quotes only opposing undialed counters actually sent to the Tanks', () => {
assert.match(JACURUTU_BATTLE_INCOME_RULE, /opposing undialed force/);
const ordinary = {
normal: 5,
elite: 0,
eliteStrength: 1 as const,
freeSupport: true,
};
assert.deepEqual(
quoteJacurutuBattleIncome({
territory: JACURUTU_SIETCH,
jacurutuRevealed: true,
winner: 'winner',
opponent: 'loser',
opponentForces: ordinary,
opponentDial: 2,
opponentSupport: 0,
sentToTanks: { normal: 5, elite: 0 },
}),
{
kind: 'income',
winner: 'winner',
opponent: 'loser',
amount: 3,
sentToTanks: { normal: 5, elite: 0 },
dialed: [{ normal: 2, elite: 0, paidNormal: 0, paidElite: 0 }],
},
);
const withdrawn = quoteJacurutuBattleIncome({
territory: JACURUTU_SIETCH,
jacurutuRevealed: true,
winner: 'winner',
opponent: 'loser',
opponentForces: ordinary,
opponentDial: 2,
opponentSupport: 0,
sentToTanks: { normal: 2, elite: 0 },
});
assert.equal(withdrawn?.kind, 'income');
assert.equal(withdrawn?.kind === 'income' ? withdrawn.amount : -1, 0);
});
void test('mixed Advanced allocations remain explicit when they imply different physical payouts', () => {
const quote = quoteJacurutuBattleIncome({
territory: JACURUTU_SIETCH,
jacurutuRevealed: true,
winner: 'winner',
opponent: 'emperor',
opponentForces: {
normal: 5,
elite: 1,
eliteStrength: 2,
freeSupport: false,
},
opponentDial: 3,
opponentSupport: 1,
sentToTanks: { normal: 5, elite: 1 },
});
assert.equal(quote?.kind, 'allocationRequired');
if (quote?.kind !== 'allocationRequired') return;
assert.deepEqual(
[...new Set(quote.choices.map((choice) => choice.amount))].sort(
(left, right) => left - right,
),
[1, 2, 3],
);
});
void test('no winner, another territory, or a hidden Jacurutu never creates income', () => {
const partial = {
opponent: null,
opponentForces: null,
opponentDial: null,
opponentSupport: null,
sentToTanks: null,
};
assert.equal(
quoteJacurutuBattleIncome({
...partial,
territory: JACURUTU_SIETCH,
jacurutuRevealed: true,
winner: null,
}),
null,
);
assert.equal(
quoteJacurutuBattleIncome({
...partial,
territory: 'arrakeen',
jacurutuRevealed: true,
winner: 'winner',
}),
null,
);
assert.equal(
quoteJacurutuBattleIncome({
...partial,
territory: JACURUTU_SIETCH,
jacurutuRevealed: false,
winner: 'winner',
}),
null,
);
});
void test('real Basic and Advanced battles pay once from the losing plan and add a typed chronicle event', () => {
for (const advanced of [false, true]) {
const {
game: pending,
attacker,
defender,
} = discoveryBattleBeforeFinalVote(advanced);
const winnerBefore = pending.players.find(
(player) => player.id === attacker,
)!.spice;
const loser = pending.players.find((player) => player.id === defender)!;
loser.tanks = 4;
loser.reserves = 11;
const before = structuredClone(pending);
const done = applyAction(reload(pending), defender, {
type: 'traitorCall',
call: false,
});
assert.deepEqual(pending, before);
assert.equal(
done.players.find((player) => player.id === attacker)!.spice,
winnerBefore - (advanced ? 1 : 0) + 3,
);
assert.equal(
done.players.find((player) => player.id === defender)!.tanks,
9,
);
assert.equal(
done.log.filter(
(entry) => entry.automatic?.name === 'Jacurutu Sietch income',
).length,
1,
);
assert.match(done.log.at(-1)!.text, /Jacurutu|battle|Ready|won/i);
const stable = reload(done);
assert.deepEqual(normalizeAutomaticGame(reload(stable)), stable);
}
});
void test('winner casualties and pre-existing Tanks do not increase Jacurutu income', () => {
const {
game: pending,
attacker,
defender,
} = discoveryBattleBeforeFinalVote(false);
const winner = pending.players.find((player) => player.id === attacker)!;
winner.tanks = 3;
winner.reserves = 12;
const beforeSpice = winner.spice;
const done = applyAction(pending, defender, {
type: 'traitorCall',
call: false,
});
const settled = done.players.find((player) => player.id === attacker)!;
assert.equal(settled.tanks, 4);
assert.equal(settled.spice, beforeSpice + 3);
});
void test('a real traitor winner still receives only the opposing undialed Tanks income', () => {
const {
game: pending,
attacker,
defender,
} = discoveryBattleBeforeFinalVote(false, ['a', 'g', 'f'], true);
const beforeSpice = pending.players.find(
(player) => player.id === attacker,
)!.spice;
const done = applyAction(pending, defender, {
type: 'traitorCall',
call: false,
});
assert.equal(done.lastBattleContext?.result, 'traitor');
assert.equal(
done.players.find((player) => player.id === attacker)!.spice,
beforeSpice + 1 + 3,
);
assert.equal(
done.log.filter(
(entry) => entry.automatic?.name === 'Jacurutu Sietch income',
).length,
1,
);
});
+1 -1
View File
@@ -105,7 +105,7 @@ void test('all eight authorized faces have readable rules while shared location
'protected from storms and sandworms', 'before the storm and the mobile stronghold move']) assert.ok(markup.includes(text), text);
assert.doesNotMatch(markup, /href="https?:/);
assert.ok(markup.includes('Next-turn free entry, sole-occupant Cistern income and later-turn Ornithopter movement are available in the development prototype.'));
assert.ok(markup.includes('Jacurutu battle income, Ecological Testing Station, Shrine and Orgiz abilities remain in development.'));
assert.ok(markup.includes('Orgiz, mixed-force Jacurutu rewards and contested Cistern/Testing Station benefits remain pending.'));
});
void test('all four AI profiles inspect and then reveal through fresh private views and actual saved actions', () => {
+442
View File
@@ -0,0 +1,442 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import {
applyAction,
createGame,
newPlayer,
normalizeAutomaticGame,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { richeseCards } from '../game/richese-cards';
import { createDiscoveryState } from '../game/discoveries';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { truthFactAnswer } from '../game/truthtrance';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(
specifier === 'next/link'
? 'vinext/shims/link'
: specifier === 'next/image'
? 'vinext/shims/image'
: specifier,
context,
);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries(
[...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map((match) => [
match[1],
match[1],
]),
);
return {
format: 'module',
source: `export default ${JSON.stringify(classes)}`,
shortCircuit: true,
};
},
});
const { Truthtrance } = await import('../components/truthtrance');
const { GameTable } = await import('../components/game-table');
aliases.deregister();
const reload = <T>(value: T): T => JSON.parse(JSON.stringify(value));
function fixture(advanced = true) {
const game = createGame('SHRINE01', newPlayer('a', 'Atreides', 'atreides'));
game.players.push(
newPlayer('e', 'Emperor', 'emperor'),
newPlayer('g', 'Guild', 'guild'),
);
Object.assign(game, {
status: 'playing',
phase: 4,
turn: 2,
active: 'a',
advanced,
discoveryEnabled: true,
discoveries: createDiscoveryState(() => 0),
order: ['a', 'e', 'g'],
deck: baseDeck(),
response: null,
decision: null,
phaseOpening: null,
});
for (const player of game.players) {
player.hand = [];
player.forces = {};
player.traitorChoices = [];
}
const shrine = game.discoveries!.tokens.find(
(token) => token.face === 'shrine',
)!;
Object.assign(shrine, {
status: 'placed',
territory: 'gara_kulon',
sector: 8,
revealedTurn: 1,
});
game.players[0].forces['shrine:0'] = 1;
game.players[0].reserves--;
return game;
}
function hold(game: Game, player: string, effect: 'karama' | 'truthtrance') {
const index = game.deck.findIndex((card) => card.effect === effect);
assert.ok(index >= 0, effect);
const card = game.deck.splice(index, 1)[0];
game.players.find((candidate) => candidate.id === player)!.hand.push(card);
return card;
}
function rejectUnchanged(game: Game, player: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, player, action));
assert.deepEqual(game, before);
}
function finishPriority(state: Game) {
let game = state;
while (game.truthtrance?.stage === 'priority') {
const player = game.players.find(
(candidate) => !game.truthtrance!.passed.includes(candidate.id),
)!;
game = applyAction(game, player.id, { type: 'truthPass' });
}
return game;
}
void test('a current Shrine occupant spends physical Truthtrance through ordinary Karama cancellation, shipment and purchase paths', () => {
for (const advanced of [false, true]) {
const cancellation = fixture(advanced);
const card = hold(cancellation, 'a', 'truthtrance');
cancellation.players[1].ally = 'g';
cancellation.players[2].ally = 'e';
cancellation.response = {
kind: 'emperorGift',
owner: 'e',
recipient: 'g',
amount: 5,
passed: [],
};
const beforeView = viewGame(cancellation, 'a');
assert.equal(beforeView.players[0].hand![0].effect, 'truthtrance');
assert.ok(beforeView.responseControls!.cancelCards.includes(card.id));
assert.equal(
truthFactAnswer(cancellation.players[0], {
kind: 'handInventory',
category: 'special',
compare: 'eq',
value: 1,
}),
'yes',
);
const canceled = applyAction(reload(cancellation), 'a', {
type: 'card',
card: card.id,
mode: 'cancel',
});
assert.equal(canceled.response, null);
assert.equal(canceled.pendingKarama ?? null, null);
assert.equal(canceled.discard.at(-1)?.id, card.id);
assert.equal(canceled.discard.at(-1)?.effect, 'truthtrance');
assert.ok(
canceled.log.some((entry) =>
entry.text.includes('used Truthtrance as Karama'),
),
);
const shipment = fixture(advanced);
const shippingCard = hold(shipment, 'a', 'truthtrance');
shipment.phase = 5;
const shipped = applyAction(shipment, 'a', {
type: 'card',
card: shippingCard.id,
mode: 'shipment',
target: 'a',
});
assert.deepEqual(shipped.karamaShipping, {
player: 'a',
owner: 'a',
card: shippingCard.id,
});
assert.equal(shipped.discard.at(-1)?.effect, 'truthtrance');
const purchase = fixture(advanced);
const purchaseCard = hold(purchase, 'a', 'truthtrance');
purchase.phase = 3;
const lot = purchase.deck.shift()!;
purchase.auction = {
cards: [lot],
index: 0,
bid: 0,
bidder: null,
active: 'a',
passed: [],
opener: 0,
};
const bought = applyAction(purchase, 'a', {
type: 'card',
card: purchaseCard.id,
mode: 'purchase',
});
assert.ok(bought.players[0].hand.some((card) => card.id === lot.id));
assert.equal(bought.discard.at(-1)?.id, purchaseCard.id);
assert.equal(bought.discard.at(-1)?.effect, 'truthtrance');
}
});
void test('hidden Shrine, nonoccupants, advisors, and departed occupants cannot start a converted use', () => {
const cases: [string, (game: Game) => void][] = [
['hidden', (game: Game) => {
game.discoveries!.tokens.find((token) => token.face === 'shrine')!.revealedTurn =
null;
}],
['departed', (game: Game) => {
game.players[0].forces = {};
}],
['advisor', (game: Game) => {
game.players[0].faction = 'beneGesserit';
game.players[0].advisors = { shrine: {} };
game.players[1].forces['shrine:0'] = 1;
game.players[1].reserves--;
}],
];
for (const [label, alter] of cases) {
const game = fixture();
const card = hold(game, 'a', 'truthtrance');
alter(game);
game.players[1].ally = 'g';
game.players[2].ally = 'e';
game.response = {
kind: 'emperorGift',
owner: 'e',
recipient: 'g',
amount: 5,
passed: [],
};
assert.ok(
!viewGame(game, 'a').responseControls!.cancelCards.includes(card.id),
label,
);
rejectUnchanged(game, 'a', {
type: 'card',
card: card.id,
mode: 'cancel',
});
}
});
void test('a physical card reserved for a pending Richese gift cannot also enter the Truthtrance queue', () => {
let game = fixture();
const owner = game.players[0];
owner.faction = 'richese';
owner.ally = 'e';
game.players[1].ally = owner.id;
const karama = richeseCards().find((card) => card.effect === 'karama')!;
owner.hand = [karama];
hold(game, 'g', 'karama');
game = applyAction(game, owner.id, { type: 'richeseGift', card: karama.id });
assert.equal(game.pendingRicheseGift?.intent.cardId, karama.id);
rejectUnchanged(game, owner.id, {
type: 'card',
card: karama.id,
shrineTruthtrance: [karama.id],
});
});
void test('a Shrine occupant can spend physical Truthtrance on an implemented special Karama power', () => {
const game = fixture();
const emperor = game.players[0];
emperor.faction = 'emperor';
emperor.hand = [];
emperor.tanks = 3;
emperor.reserves = 17;
const truthtrance = hold(game, emperor.id, 'truthtrance');
const markup = renderToStaticMarkup(
createElement(GameTable, {
game: viewGame(game, emperor.id),
send: async () => {},
onExit() {},
busy: false,
}),
);
assert.match(markup, /Use special Karama · forces/);
const done = applyAction(game, emperor.id, {
type: 'card',
mode: 'special',
card: truthtrance.id,
amount: 3,
elite: 0,
});
assert.equal(done.players[0].specialKaramaUsed, true);
assert.equal(done.players[0].tanks, 0);
assert.equal(done.players[0].reserves, 20);
assert.equal(done.discard.at(-1)?.id, truthtrance.id);
assert.equal(done.discard.at(-1)?.effect, 'truthtrance');
assert.ok(
done.log.some((entry) =>
entry.text.includes('physical Truthtrance as Karama'),
),
);
});
void test('Karama committed as Truthtrance keeps its Shrine receipt through JSON, departure, answer, and discard continuation', () => {
let game = fixture();
const karama = hold(game, 'a', 'karama');
hold(game, 'e', 'truthtrance');
game.players[1].ally = 'g';
game.players[2].ally = 'e';
const suspended = {
kind: 'emperorGift' as const,
owner: 'e',
recipient: 'g',
amount: 5,
passed: [] as string[],
};
game.response = suspended;
game = applyAction(game, 'a', {
type: 'card',
card: karama.id,
shrineTruthtrance: [karama.id],
});
assert.deepEqual(game.truthtrance!.queue[0], {
player: 'a',
card: karama.id,
source: 'shrine',
});
const pending = reload(game);
assert.deepEqual(pending.response, suspended);
assert.deepEqual(normalizeAutomaticGame(reload(pending)), pending);
pending.players[0].forces = { 'arrakeen:10': 1 };
game = finishPriority(pending);
game = applyAction(game, 'a', {
type: 'truthAsk',
question: {
kind: 'fact',
target: 'e',
fact: { kind: 'hand', name: 'Truthtrance' },
},
});
assert.deepEqual(game.response, suspended);
assert.equal(viewGame(reload(game), 'e').truthAnswer, 'yes');
game = applyAction(reload(game), 'e', {
type: 'truthAnswer',
answer: 'yes',
});
assert.equal(game.truthtrance, null);
assert.equal(game.truthHistory!.at(-1)?.answer, 'yes');
assert.equal(game.discard.at(-1)?.id, karama.id);
assert.equal(game.discard.at(-1)?.effect, 'karama');
const forged = reload(pending);
delete forged.truthtrance!.queue[0].source;
assert.throws(() => viewGame(forged, 'a'), /committed physical card/);
});
void test('an unknown converted question may save the same Karama, but departure revokes a fresh conversion', () => {
let game = fixture();
const karama = hold(game, 'a', 'karama');
game = finishPriority(
applyAction(game, 'a', {
type: 'card',
card: karama.id,
shrineTruthtrance: [karama.id],
}),
);
game = applyAction(game, 'a', {
type: 'truthAsk',
question: {
kind: 'freeform',
target: 'e',
text: 'Will the next battle be won by a traitor?',
scope: 'currentTurn',
},
});
game = applyAction(game, 'e', { type: 'truthAnswer', answer: 'unknown' });
game = applyAction(reload(game), 'a', { type: 'truthSave' });
assert.equal(game.truthtrance, null);
assert.equal(game.players[0].hand[0].id, karama.id);
game.players[0].forces = {};
rejectUnchanged(game, 'a', {
type: 'card',
card: karama.id,
shrineTruthtrance: [karama.id],
});
});
void test('all four AI profiles use the converted Truthtrance control and complete the existing question path', () => {
for (const difficulty of DIFFICULTIES) {
let game = fixture();
const karama = hold(game, 'a', 'karama');
game.phase = 6;
game.battle = {
territory: 'arrakeen',
attacker: 'a',
defender: 'e',
prepared: true,
plans: {},
revealed: false,
traitorCalls: {},
};
for (const player of game.players) player.bot = difficulty;
const action = botActions(viewGame(game, 'a'))[0];
assert.deepEqual(action, {
type: 'card',
card: karama.id,
shrineTruthtrance: [karama.id],
});
game = applyAction(game, 'a', action);
while (game.truthtrance?.stage === 'priority') {
const next = game.players.find(
(player) => !game.truthtrance!.passed.includes(player.id),
)!;
const pass = botActions(viewGame(game, next.id))[0];
assert.deepEqual(pass, { type: 'truthPass' });
game = applyAction(game, next.id, pass);
}
const question = botActions(viewGame(game, 'a'))[0];
assert.equal(question.type, 'truthAsk');
game = applyAction(game, 'a', question);
const target = game.truthtrance!.question!.target;
game.players.find((player) => player.id === target)!.bot = difficulty;
const answer = botActions(viewGame(game, target))[0];
assert.equal(answer.type, 'truthAnswer');
game = applyAction(game, target, answer);
assert.equal(game.truthtrance, null);
assert.equal(game.discard.at(-1)?.id, karama.id);
}
});
void test('Truthtrance priority renders the owner conversion without exposing another hand', () => {
let game = fixture();
const initiator = hold(game, 'a', 'truthtrance');
const karama = hold(game, 'e', 'karama');
const truthtrance = hold(game, 'e', 'truthtrance');
game.players[1].forces['shrine:0'] = 1;
game.players[1].reserves--;
game = applyAction(game, 'a', { type: 'card', card: initiator.id });
const view = viewGame(game, 'e');
const markup = renderToStaticMarkup(
createElement(Truthtrance, { game: view, busy: false, act() {} }),
);
assert.match(markup, /Use my Karama as Truthtrance/);
assert.match(markup, /Declare my Truthtrance/);
assert.match(markup, /Declare all 2 as Truthtrance/);
assert.deepEqual(
view.players[1].hand!.map((card) => card.id),
[karama.id, truthtrance.id],
);
assert.equal(view.players[0].hand, undefined);
});
+194
View File
@@ -0,0 +1,194 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { DatabaseSync } from 'node:sqlite';
import { baseDeck } from '../game/cards';
import { createDiscoveryState } from '../game/discoveries';
import { createGame, newPlayer, type Game } from '../game/engine';
import type { RoomsClock } from '../db/rooms';
import { unitStore } from './fixture-nexus-room-store';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
function savedShrineGame(code: string, ids: [string, string, string]): Game {
const game = createGame(code, newPlayer(ids[0], 'Atreides', 'atreides'));
game.players.push(
newPlayer(ids[1], 'Emperor', 'emperor'),
newPlayer(ids[2], 'Guild', 'guild'),
);
Object.assign(game, {
status: 'playing',
phase: 4,
turn: 2,
active: ids[0],
advanced: true,
discoveryEnabled: true,
discoveries: createDiscoveryState(() => 0),
order: ids,
deck: baseDeck(),
response: {
kind: 'emperorGift',
owner: ids[1],
recipient: ids[2],
amount: 5,
passed: [],
},
decision: null,
phaseOpening: null,
});
for (const player of game.players) {
player.hand = [];
player.forces = {};
player.spice = 10;
player.traitorChoices = [];
}
game.players[1].ally = ids[2];
game.players[2].ally = ids[1];
const shrine = game.discoveries!.tokens.find(
(token) => token.face === 'shrine',
)!;
Object.assign(shrine, {
status: 'placed',
territory: 'gara_kulon',
sector: 8,
revealedTurn: 1,
});
game.players[0].forces['shrine:0'] = 1;
game.players[0].reserves--;
const index = game.deck.findIndex((card) => card.effect === 'karama');
assert.ok(index >= 0);
game.players[0].hand.push(game.deck.splice(index, 1)[0]);
return game;
}
void test('SQLite restoration and competing Shrine declarations keep one physical Karama private and complete once', async () => {
const store = unitStore();
const sqlite: DatabaseSync = store.sqlite;
try {
const created = await store.rooms.createRoom(
'Shrine recovery',
'atreides',
false,
[],
);
const joined = await store.rooms.joinRoom(
created.view.code,
'Emperor',
'emperor',
);
const third = await store.rooms.joinRoom(
created.view.code,
'Guild',
'guild',
);
const auths = await Promise.all(
[created.token, joined.token!, third.token!].map((token) =>
store.rooms.authenticate(created.view.code, token),
),
);
const ids = auths.map((auth) => auth.playerId) as [string, string, string];
const stored = await store.rooms.readRoom(created.view.code);
const game = savedShrineGame(created.view.code, ids);
game.version = stored.version;
const karama = game.players[0].hand[0];
sqlite
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(game), game.version, game.code);
const seats = sqlite
.prepare('SELECT * FROM seats ORDER BY player_id')
.all();
const action = {
type: 'card' as const,
card: karama.id,
shrineTruthtrance: [karama.id],
};
let arrivals = 0;
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
store.hooks.beforeWrite = async () => {
if (++arrivals === 2) release();
await gate;
};
const timer = setTimeout(release, 2000);
try {
const results = await Promise.allSettled(
[0, 1].map(() =>
store
.restart()
.act(game.code, auths[0], game.version, action, clock),
),
);
assert.equal(arrivals, 2);
assert.equal(
results.filter((result) => result.status === 'fulfilled').length,
1,
);
} finally {
clearTimeout(timer);
delete store.hooks.beforeWrite;
}
let saved = await store.restart().readRoom(game.code);
assert.equal(saved.version, game.version + 1);
assert.deepEqual(saved.truthtrance!.queue, [
{ player: ids[0], card: karama.id, source: 'shrine' },
]);
assert.equal(saved.players[0].hand[0].effect, 'karama');
const ownerView = await store.restart().readSeatView(game.code, auths[0]);
const otherView = await store.restart().readSeatView(game.code, auths[1]);
assert.equal(ownerView.players[0].hand![0].effect, 'karama');
assert.equal(otherView.players[0].hand, undefined);
for (let i = 1; i < auths.length; i++) {
await store
.restart()
.act(game.code, auths[i], saved.version, { type: 'truthPass' }, clock);
saved = await store.restart().readRoom(game.code);
}
assert.equal(saved.truthtrance!.stage, 'ask');
await store.restart().act(
game.code,
auths[0],
saved.version,
{
type: 'truthAsk',
question: {
kind: 'fact',
target: ids[1],
fact: { kind: 'spice', compare: 'eq', value: 10 },
},
},
clock,
);
saved = await store.restart().readRoom(game.code);
assert.deepEqual(saved.response, game.response);
await store.restart().act(
game.code,
auths[1],
saved.version,
{ type: 'truthAnswer', answer: 'yes' },
clock,
);
const done = await store.restart().readRoom(game.code);
assert.equal(done.truthtrance, null);
assert.equal(done.response, null);
assert.equal(done.players[1].spice, 5);
assert.equal(done.players[2].spice, 15);
assert.equal(
done.players[0].hand.some((card) => card.id === karama.id),
false,
);
assert.deepEqual(
done.discard.filter((card) => card.id === karama.id),
[{ ...karama, effect: 'karama' }],
);
assert.deepEqual(
sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(),
seats,
);
} finally {
sqlite.close();
}
});
+146
View File
@@ -0,0 +1,146 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { unitStore } from './fixture-nexus-room-store';
import {
discoveryStormFixture,
confirmDiscoveryStorm,
} from './fixture-discovery-storm';
import { discoveryStormActions } from '../game/discovery-storm-options';
import {
createStormSource,
discoveryStormSignature,
} from '../game/discovery-storm';
import { DiscoveryStormDecision } from '../components/discovery-storm';
import { normalizeAutomaticGame, viewGame, type Game } from '../game/engine';
import type { RoomsClock } from '../db/rooms';
const clock: RoomsClock = { now: () => 10000, sleep: async () => {} };
void test('restored SQLite station choice keeps private controls and competing submissions adjust the storm only once', async () => {
const store = unitStore();
try {
const created = await store.rooms.createRoom(
'Station recovery',
'atreides',
false,
[],
);
const code = created.view.code,
sessions = [created.token];
for (const faction of ['guild', 'fremen'] as const)
sessions.push(
(await store.rooms.joinRoom(code, faction, faction)).token!,
);
const auths = await Promise.all(
sessions.map((token) => store.rooms.authenticate(code, token)),
);
const ids = auths.map((auth) => auth.playerId) as [string, string, string];
const game = confirmDiscoveryStorm(discoveryStormFixture(false, ids));
game.code = code;
store.sqlite
.prepare('UPDATE rooms SET state = ?, version = ? WHERE code = ?')
.run(JSON.stringify(game), game.version, code);
const seats = store.sqlite
.prepare('SELECT * FROM seats ORDER BY player_id')
.all();
for (const [i, auth] of auths.entries()) {
const view = await store.restart().readSeatView(code, auth);
assert.deepEqual(view, viewGame(game, auth.playerId));
assert.equal(view.ecologicalStorm !== null, i === 0);
assert.equal(JSON.stringify(view).includes('stormMovementSource'), false);
}
const action = discoveryStormActions(viewGame(game, ids[0])).find(
(a) => a.delta === 1,
)!;
let arrivals = 0,
release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
store.hooks.beforeWrite = async () => {
if (++arrivals === 2) release();
await gate;
};
const timer = setTimeout(release, 2000);
try {
const results = await Promise.allSettled(
[0, 1].map(() =>
store.restart().act(code, auths[0], game.version, action, clock),
),
);
assert.equal(arrivals, 2);
assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
} finally {
clearTimeout(timer);
delete store.hooks.beforeWrite;
}
const done = await store.restart().readRoom(code);
assert.equal(done.version, game.version + 1);
assert.equal(done.storm, 9);
assert.equal(done.ecologicalStorm!.stage, 'complete');
assert.equal(
done.log.filter((e) => e.text.includes('increased storm movement'))
.length,
1,
);
await assert.rejects(
store.restart().act(code, auths[0], done.version, action, clock),
);
assert.deepEqual(await store.restart().readRoom(code), done);
assert.deepEqual(
store.sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(),
seats,
);
} finally {
store.sqlite.close();
}
});
void test('station controls show all legal distances only to the owner and disable while busy or on autopilot', () => {
const g = confirmDiscoveryStorm(discoveryStormFixture());
const render = (id: string, busy = false) =>
renderToStaticMarkup(
createElement(DiscoveryStormDecision, {
game: viewGame(g, id),
busy,
act() {},
}),
);
const markup = render('a');
for (const label of [
'Decrease: 1 sector',
'Keep: 2 sectors',
'Increase: 3 sectors',
])
assert.ok(markup.includes(label));
assert.equal(render('g'), '');
assert.equal((render('a', true).match(/\sdisabled=""/g) ?? []).length, 3);
g.players[0].autopilot = 'Easy';
assert.equal((render('a').match(/\sdisabled=""/g) ?? []).length, 3);
});
void test('source turn, source phase and exact station token are bound across restored decisions', () => {
const source = confirmDiscoveryStorm(discoveryStormFixture());
for (const mutate of [
(g: Game) => {
g.stormMovementSource = createStormSource(g.turn - 1, 'dials', 2);
},
(g: Game) => {
g.phase = 1;
},
(g: Game) => {
g.ecologicalStorm!.token = g.discoveries!.tokens.find(
(t) => t.face !== 'ecological-testing-station',
)!.id;
g.ecologicalStorm!.signature = discoveryStormSignature(
g.ecologicalStorm!,
);
},
]) {
const g = structuredClone(source);
mutate(g);
const before = structuredClone(g);
assert.throws(() => viewGame(g, 'a'));
assert.throws(() => normalizeAutomaticGame(g));
assert.deepEqual(g, before);
}
});
+200
View File
@@ -0,0 +1,200 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyAction,
normalizeAutomaticGame,
viewGame,
type Game,
type Action,
} from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { discoveryStormActions } from '../game/discovery-storm-options';
import {
createStormSource,
discoveryStormOffer,
discoveryStormSignature,
} from '../game/discovery-storm';
import {
discoveryStormFixture,
confirmDiscoveryStorm,
holdStormCard,
} from './fixture-discovery-storm';
const reload = (g: Game): Game => JSON.parse(JSON.stringify(g));
function reject(g: Game, owner: string, action: Action) {
const before = reload(g);
assert.throws(() => applyAction(g, owner, action));
assert.deepEqual(g, before);
}
for (const advanced of [false, true])
for (const delta of [-1, 0, 1]) {
void test(`Ecological Testing Station changes genuine ${advanced ? 'Advanced card' : 'Basic dial'} movement by ${delta} after the storm window`, () => {
let g = discoveryStormFixture(advanced);
assert.equal(g.ecologicalStorm, undefined);
assert.equal(g.stormMovementSource?.kind, advanced ? 'card' : 'dials');
g = confirmDiscoveryStorm(g);
assert.equal(g.decision?.kind, 'ecologicalStorm');
assert.equal(g.storm, 6);
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
const action = discoveryStormActions(viewGame(g, 'a')).find(
(a) => a.delta === delta,
)!;
const before = reload(g);
g = applyAction(reload(g), 'a', action);
assert.deepEqual(
before.players.map((p) => p.hand),
g.players.map((p) => p.hand),
);
assert.equal(g.storm, 8 + delta);
assert.equal(g.phase, 1);
assert.equal(g.ecologicalStorm?.stage, 'complete');
assert.equal(g.stormMovementSource, undefined);
assert.equal(g.stormResolution, null);
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
reject(g, 'a', action);
});
}
void test('station choice is private to its current sole fighter occupant; wrong owner, stale event and injected distance are immutable rejections', () => {
const g = confirmDiscoveryStorm(discoveryStormFixture()),
before = reload(g);
assert.equal(viewGame(g, 'g').ecologicalStorm, null);
assert.deepEqual(discoveryStormActions(viewGame(g, 'g')), []);
const v = viewGame(g, 'a'),
action = discoveryStormActions(v)[0];
v.ecologicalStorm!.options[0].distance = 999;
assert.deepEqual(g, before);
reject(g, 'g', action);
reject(g, 'a', { ...action, event: 'stale' });
for (const delta of [-2, 2, 0.5, NaN]) reject(g, 'a', { ...action, delta });
reject(g, 'a', { ...action, distance: 10 });
reject(g, 'a', { type: 'ready' });
});
void test('all four AI levels select a legal owned station choice', () => {
const g = confirmDiscoveryStorm(discoveryStormFixture(true));
for (const level of DIFFICULTIES) {
const v = viewGame(g, 'a');
v.players[0].bot = level;
const actions = botActions(v);
assert.ok(actions.length);
for (const action of actions)
assert.doesNotThrow(() => applyAction(g, 'a', action));
}
});
void test('Weather Control replaces natural provenance and bypasses the station, including zero movement', () => {
for (const advanced of [false, true])
for (const amount of [0, 3]) {
let g = discoveryStormFixture(advanced);
const card = holdStormCard(g, 'a', 'weather');
g = applyAction(g, 'g', { type: 'ready' });
g = applyAction(g, 'a', { type: 'card', card: card.id, amount });
assert.equal(g.stormMovementSource?.kind, 'weather');
assert.deepEqual(g.ready, []);
assert.match(discoveryStormOffer(g)!.blocked!, /Weather Control/);
g = confirmDiscoveryStorm(reload(g));
assert.equal(g.ecologicalStorm, undefined);
assert.equal(g.storm, 6 + amount);
assert.equal(g.discard.filter((c) => c.id === card.id).length, 1);
}
});
void test('unoccupied, contested and legacy unknown-source storms continue without inventing entitlement or changing distance', () => {
for (const mode of ['empty', 'contested', 'legacy']) {
let g = discoveryStormFixture();
if (mode === 'empty') {
g.players[0].reserves +=
g.players[0].forces['ecological-testing-station:0'];
delete g.players[0].forces['ecological-testing-station:0'];
}
if (mode === 'contested') {
g.players[1].reserves--;
g.players[1].forces['ecological-testing-station:0'] = 1;
}
if (mode === 'legacy') delete g.stormMovementSource;
g = confirmDiscoveryStorm(g);
assert.equal(g.ecologicalStorm, undefined);
assert.equal(g.storm, 8);
}
});
void test('station can reduce a one-sector natural storm to zero and never offers a negative distance', () => {
let g = discoveryStormFixture(true);
g.stormPending = 1;
g.stormMovementSource = createStormSource(g.turn, 'card', 1);
g = confirmDiscoveryStorm(g);
g = applyAction(g, 'a', {
type: 'decision',
event: g.ecologicalStorm!.event,
delta: -1,
});
assert.equal(g.storm, 6);
assert.equal(g.phase, 1);
const source = discoveryStormFixture();
source.stormPending = 0;
source.stormMovementSource = createStormSource(source.turn, 'dials', 0);
assert.deepEqual(
discoveryStormOffer(source)!.options.map((o) => o.delta),
[0, 1],
);
});
void test('orphaned station choices, rewritten Weather sources and mismatched traversal receipts fail closed on reads and normalization', () => {
const original = confirmDiscoveryStorm(discoveryStormFixture());
for (const mutate of [
(g: Game) => {
delete g.ecologicalStorm;
},
(g: Game) => {
g.decision = null;
},
(g: Game) => {
g.ecologicalStorm!.owner = 'g';
g.ecologicalStorm!.signature = discoveryStormSignature(
g.ecologicalStorm!,
);
},
(g: Game) => {
g.stormMovementSource = createStormSource(g.turn, 'weather', 2);
},
(g: Game) => {
g.stormPending = 3;
},
]) {
const g = reload(original);
mutate(g);
const before = reload(g);
assert.throws(() => viewGame(g, 'a'));
assert.throws(() => normalizeAutomaticGame(g));
assert.deepEqual(g, before);
}
});
void test('adjusted Advanced storm retains its receipt through Fremen protection and typed loss continuation', () => {
let g = discoveryStormFixture(true);
const f = g.players[2];
f.reserves -= 3;
f.forces['red_chasm:7'] = 3;
f.elites!.reserves--;
f.elites!.forces['red_chasm:7'] = 1;
holdStormCard(g, 'g', 'karama');
g = confirmDiscoveryStorm(g);
g = applyAction(g, 'a', {
type: 'decision',
event: g.ecologicalStorm!.event,
delta: 1,
});
assert.equal(g.response?.kind, 'stormProtection');
assert.equal(g.ecologicalStorm!.stage, 'traversal');
assert.equal(g.stormResolution!.discoveryStorm, g.ecologicalStorm!.signature);
assert.deepEqual(normalizeAutomaticGame(reload(g)), g);
const corrupt = reload(g);
delete corrupt.stormResolution!.discoveryStorm;
assert.throws(() => viewGame(corrupt, 'f'));
for (let step = 0; g.response?.kind === 'stormProtection'; step++) {
assert.ok(step < 10);
const owner = g.players.find((p) => !g.response!.passed.includes(p.id))!.id;
g = applyAction(reload(g), owner, { type: 'passResponse' });
}
assert.equal(g.decision?.kind, 'stormLosses');
g = applyAction(reload(g), 'f', { type: 'decision', elite: 1 });
assert.equal(g.players[2].forces['red_chasm:7'], 1);
assert.equal(g.players[2].elites!.forces['red_chasm:7'] ?? 0, 0);
assert.equal(g.ecologicalStorm?.stage, 'complete');
assert.equal(g.storm, 9);
});
+97
View File
@@ -0,0 +1,97 @@
import assert from 'node:assert/strict';
import { applyAction } from '../game/engine';
import { JACURUTU_SIETCH } from '../game/discoveries';
import { discoveryFixture, putDiscovery } from './fixture-discovery';
export function discoveryBattleBeforeFinalVote(
advanced = false,
ids: [string, string, string] = ['a', 'g', 'f'],
attackerTraitor = false,
) {
let game = discoveryFixture(advanced, ids);
const attacker = game.players[0];
const defender = game.players[1];
const token = putDiscovery(game, 'jacurutu-sietch');
token.revealedTurn = game.turn;
for (const player of game.players) {
player.forces = {};
player.reserves = 20;
player.tanks = 0;
player.spice = 20;
player.hand = [];
player.traitors = [];
for (const leader of player.leaders) leader.strength = 0;
}
attacker.forces[`${JACURUTU_SIETCH}:0`] = 5;
attacker.reserves = 15;
defender.forces[`${JACURUTU_SIETCH}:0`] = 5;
defender.reserves = 15;
attacker.leaders[0].strength = 5;
defender.leaders[0].strength = 1;
if (attackerTraitor) attacker.traitors = [defender.leaders[0].id];
Object.assign(game, {
phase: 6,
active: attacker.id,
ready: [],
response: null,
decision: null,
phaseOpening: null,
battle: null,
lastBattle: [],
lastBattleContext: undefined,
storm: 18,
});
game = applyAction(game, attacker.id, {
type: 'chooseBattle',
territory: JACURUTU_SIETCH,
target: defender.id,
});
for (const id of [attacker.id, defender.id])
if (game.battle?.preLeader && !game.battle.preLeader.ready.includes(id))
game = applyAction(game, id, {
type: 'battlePreparationReady',
event: game.battle.event,
});
for (
let step = 0;
step < 30 && (game.response || game.battle?.preparation);
step++
) {
if (game.response)
game = applyAction(
game,
game.players.find(
(player) => !game.response!.passed.includes(player.id),
)!.id,
{ type: 'passResponse' },
);
else
game = applyAction(game, game.battle!.preparation!.owner, {
type: 'declineBattlePower',
});
}
assert.equal(game.response, null);
assert.equal(game.battle?.preparation, undefined);
if (game.decision?.kind === 'fullPlanOffer')
game = applyAction(game, game.decision.player, {
type: 'decision',
decline: true,
});
game = applyAction(game, attacker.id, {
type: 'battlePlan',
dial: 1,
support: advanced ? 1 : 0,
leader: attacker.leaders[0].id,
});
game = applyAction(game, defender.id, {
type: 'battlePlan',
dial: 2,
support: advanced ? 2 : 0,
leader: defender.leaders[0].id,
});
game = applyAction(game, attacker.id, {
type: 'traitorCall',
call: attackerTraitor,
});
return { game, attacker: attacker.id, defender: defender.id };
}
+72
View File
@@ -0,0 +1,72 @@
import assert from 'node:assert/strict';
import { applyAction, viewGame, type Game } from '../game/engine';
import {
discoveryFixture,
enterDiscoveryCollection,
} from './fixture-discovery';
/** Genuine module setup, physical reveal and next-turn entry; positions are
* arranged for a focused storm rather than an unmodified complete game. */
export function discoveryStormFixture(
advanced = false,
ids: [string, string, string] = ['a', 'g', 'f'],
) {
let g = discoveryFixture(advanced, ids);
const token = enterDiscoveryCollection(
g,
'ecological-testing-station',
ids[0],
);
if (viewGame(g, ids[0]).discoveries!.canInspect.includes(token.id))
g = applyAction(g, ids[0], {
type: 'discovery',
token: token.id,
reveal: false,
});
g = applyAction(g, ids[0], {
type: 'discovery',
token: token.id,
reveal: true,
});
Object.assign(g, {
phase: 8,
ready: [],
decision: null,
response: null,
phaseOpening: null,
stormCard: 2,
});
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
assert.equal(g.decision?.kind, 'discoveryEntry');
const offer = viewGame(g, ids[0]).discoveryEntry!;
g = applyAction(g, ids[0], {
type: 'decision',
event: offer.event,
accept: true,
groups: offer.sources,
});
assert.equal(g.discoveryEntry, undefined);
g.storm = 6;
if (!advanced)
for (const id of g.stormDialers)
g = applyAction(g, id, { type: 'stormDial', amount: 1 });
assert.equal(g.stormPending, 2);
return g;
}
export function confirmDiscoveryStorm(g: Game) {
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
return g;
}
export function holdStormCard(g: Game, owner: string, effect: string) {
const card = [
...g.deck,
...g.discard,
...g.players.flatMap((p) => p.hand),
].find((c) => c.effect === effect)!;
assert.ok(card);
g.deck = g.deck.filter((c) => c.id !== card.id);
g.discard = g.discard.filter((c) => c.id !== card.id);
for (const p of g.players) p.hand = p.hand.filter((c) => c.id !== card.id);
g.players.find((p) => p.id === owner)!.hand.push(card);
return card;
}
+1 -1
View File
@@ -195,7 +195,7 @@ void test('reserved Black Market Truthtrance cannot hide in the additional queue
g,
'r',
{ type: 'card', card: main.id, cards: [offered.id] },
/reserved.*Truthtrance/,
/card offered for Black Market must remain available/,
);
assert.equal(g.truthtrance ?? null, null);
assert.deepEqual(