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
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user