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:
1 parent
ab0ab52849
commit
f0d3d9c464
31 files changed
+2524
-138
No files matched your search
@@ -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') {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user