Open Tleilaxu special Karama throughout Revival
Permit native Advanced quiet any-player declarations alongside the original target-bound pending request. Preserve completed returns and charges. Adopt explicit provisional Ghola inclusion during the remaining Revival and phase expiry; keep printed Emperor special immunity and the existing low-Homeworld guard. Connect persistent controls and minimal legal bots through the shared quote. Validation: 14 related files148/148, types/lint/build, six actual native control/engine/GameTable SSR journeys and four existing-profile continuations. Private-driver-only feedback reuses passed source-identical checks. Native111/113-action setup, paid shipment and battle sources earn originals. No saved-room/D1/restart/deployment operation; full modes and browser/deployed acceptance remain open.
This commit is contained in:
1 parent
6ea691255a
commit
1a505525a8
18 files changed
+977
-59
No files matched your search
@@ -0,0 +1,212 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
|
||||
export type TleilaxuSpecialKaramaStep = { actor: string; action: Action };
|
||||
export type TleilaxuSpecialKaramaTransition = {
|
||||
before: Game; steps: TleilaxuSpecialKaramaStep[]; after: Game;
|
||||
};
|
||||
export const tleilaxuSpecialKaramaPlayer = (game: Game, id: string) => {
|
||||
const player = game.players.find(player => player.id === id); assert.ok(player); return player;
|
||||
};
|
||||
export const tleilaxuSpecialKaramaClean = (game: Game) => !game.phaseOpening && !game.response &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRevival;
|
||||
export const tleilaxuSpecialKaramaInventory = (game: Game) => {
|
||||
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])];
|
||||
const ids = cards.map(card => card.id).sort();
|
||||
assert.equal(new Set(ids).size, ids.length, 'Original physical Treachery Cards retain distinct current slots');
|
||||
return ids;
|
||||
};
|
||||
|
||||
/** Select original scalar shuffle order. UUIDs and every other native byte
|
||||
* source stay unchanged; no played resource, card hand or casualty is installed. */
|
||||
function originalScalarOrder<T>(run: () => T): T {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const original = crypto.getRandomValues.bind(crypto);
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1) array[0] = 0xffffffff;
|
||||
else Reflect.apply(original, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return run(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
export function actTleilaxuSpecialKarama(game: Game, actor: string, action: Action,
|
||||
actions: TleilaxuSpecialKaramaStep[] = []): Game {
|
||||
const next = originalScalarOrder(() => applyAction(game, actor, action));
|
||||
actions.push({ actor, action: structuredClone(action) }); return next;
|
||||
}
|
||||
/** Native controls only. Preserve Karama/Ghola, decline the optional special
|
||||
* inspection and Face Dance, and allow a pending normal revival by default. */
|
||||
export function nextTleilaxuSpecialKarama(game: Game): TleilaxuSpecialKaramaStep | null {
|
||||
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
|
||||
const decision = game.decision;
|
||||
if (decision?.kind === 'revivalStop' || decision?.kind === 'fullPlanOffer')
|
||||
return { actor: decision.player, action: { type: 'decision', decline: true } };
|
||||
if (decision?.kind === 'fullPlanRead')
|
||||
return { actor: decision.player, action: { type: 'decision', continue: true } };
|
||||
if (decision?.kind === 'faceDance')
|
||||
return { actor: decision.player, action: { type: 'decision', reveal: false } };
|
||||
}
|
||||
return nextRicheseStrongholdsNativeStep(game);
|
||||
}
|
||||
export function advanceTleilaxuSpecialKarama(game: Game, until: (state: Game) => boolean,
|
||||
actions: TleilaxuSpecialKaramaStep[] = []): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The genuine Tleilaxu chronology ended before its requested boundary');
|
||||
const next = nextTleilaxuSpecialKarama(game);
|
||||
assert.ok(next, `Supply the original private plan at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actTleilaxuSpecialKarama(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The native Tleilaxu special chronology did not reach its finite boundary');
|
||||
}
|
||||
export function passTleilaxuSpecialKarama(game: Game,
|
||||
actions: TleilaxuSpecialKaramaStep[] = []): Game {
|
||||
for (let count = 0; game.response && count < 60; count++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actTleilaxuSpecialKarama(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.ok(!game.response); return game;
|
||||
}
|
||||
|
||||
/** Genuine public Advanced Tleilaxu/Atreides/Emperor setup. Two paid shipments
|
||||
* make twelve native Atreides counters in Arrakeen; Hidar's real unarmed win
|
||||
* puts all twelve in Tanks. Stop at actual turn-two Revival with the original
|
||||
* Karama and Ghola still held. No card/army/phase/wallet/casualty is assigned. */
|
||||
export function createTleilaxuSpecialKaramaFixture(options: { priorRevival?: boolean } = {}) {
|
||||
const owner = 'tleilaxu', target = 'atreides', observer = 'emperor';
|
||||
let game = createGame('TLEILAXUSPECIALKARAMA', newPlayer(owner, 'Tleilaxu', 'tleilaxu'), true, ['ix']);
|
||||
joinGame(game, newPlayer(target, 'Atreides', 'atreides'));
|
||||
joinGame(game, newPlayer(observer, 'Emperor', 'emperor'));
|
||||
const initial = structuredClone(game), actions: TleilaxuSpecialKaramaStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actTleilaxuSpecialKarama(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceTleilaxuSpecialKarama(game, until, actions); };
|
||||
const transition = (run: () => void): TleilaxuSpecialKaramaTransition => {
|
||||
const before = structuredClone(game), start = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
|
||||
};
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game); act(game.host, { type: 'start', advancedPreview: true });
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true);
|
||||
assert.deepEqual(game.expansions, ['ix']);
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
|
||||
assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
const originalInventory = tleilaxuSpecialKaramaInventory(game);
|
||||
const karama = game.deck.find(card => card.effect === 'karama');
|
||||
const ghola = game.deck.find(card => card.effect === 'ghola');
|
||||
const ordinary = game.deck.find(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind));
|
||||
assert.ok(karama && ghola && ordinary);
|
||||
const selected = [karama, ghola, ordinary];
|
||||
const dealOrder = selected.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), selected.map(card => card.id));
|
||||
assert.equal(tleilaxuSpecialKaramaPlayer(game, owner).faceDancers?.length, 3);
|
||||
assert.equal(tleilaxuSpecialKaramaPlayer(game, target).forces['arrakeen:10'], 10);
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder = [0, 1, 2, 3].map(position => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
|
||||
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === owner && tleilaxuSpecialKaramaClean(state));
|
||||
const ownerShipment = transition(() => {
|
||||
act(owner, { type: 'ship', territory: 'arrakeen', sector: 10, amount: 1, elite: 0, allyPayment: 0 });
|
||||
reach(tleilaxuSpecialKaramaClean);
|
||||
});
|
||||
assert.equal(tleilaxuSpecialKaramaPlayer(ownerShipment.before, owner).spice - tleilaxuSpecialKaramaPlayer(game, owner).spice, 1);
|
||||
act(owner, { type: 'endMovement' });
|
||||
reach(state => state.phase === 5 && state.active === target && tleilaxuSpecialKaramaClean(state));
|
||||
const targetShipment = transition(() => {
|
||||
act(target, { type: 'ship', territory: 'arrakeen', sector: 10, amount: 2, elite: 0, allyPayment: 0 });
|
||||
reach(tleilaxuSpecialKaramaClean);
|
||||
});
|
||||
assert.equal(tleilaxuSpecialKaramaPlayer(targetShipment.before, target).spice - tleilaxuSpecialKaramaPlayer(game, target).spice, 2);
|
||||
assert.equal(tleilaxuSpecialKaramaPlayer(game, target).forces['arrakeen:10'], 12);
|
||||
reach(state => state.phase === 6 && tleilaxuSpecialKaramaClean(state) && !!state.active);
|
||||
const beforeBattle = structuredClone(game), choice = viewGame(game, game.active!).battleChoices
|
||||
.find(choice => choice.territory === 'arrakeen'); assert.ok(choice);
|
||||
const battleStep: TleilaxuSpecialKaramaStep = { actor: choice.chooser,
|
||||
action: { type: 'chooseBattle', territory: 'arrakeen',
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
|
||||
act(battleStep.actor, battleStep.action);
|
||||
const beforePreparation = structuredClone(game);
|
||||
reach(state => !!state.battle && tleilaxuSpecialKaramaClean(state) &&
|
||||
!state.battle.preparation && state.battle.preLeader?.closed !== false);
|
||||
const plans: TleilaxuSpecialKaramaStep[] = [
|
||||
{ actor: owner, action: { type: 'battlePlan', leader: 'tleilaxu-1', dial: 0, support: 0, weapon: null, defense: null } },
|
||||
{ actor: target, action: { type: 'battlePlan', leader: 'atreides-4', dial: 0, support: 0, weapon: null, defense: null } },
|
||||
];
|
||||
for (const step of plans) act(step.actor, step.action);
|
||||
const revealed = structuredClone(game);
|
||||
reach(state => !state.battle && tleilaxuSpecialKaramaClean(state));
|
||||
const afterBattle = structuredClone(game);
|
||||
assert.equal(game.lastBattleContext?.winner, owner);
|
||||
assert.equal(tleilaxuSpecialKaramaPlayer(game, target).tanks, 12);
|
||||
assert.equal(tleilaxuSpecialKaramaPlayer(game, owner).tanks, 0);
|
||||
assert.equal(tleilaxuSpecialKaramaPlayer(game, target).leaders.find(leader => leader.id === 'atreides-4')?.dead, false);
|
||||
reach(state => state.turn === 2 && state.phase === 4 && tleilaxuSpecialKaramaClean(state));
|
||||
const beforePriorRevival = structuredClone(game);
|
||||
let priorRevival: TleilaxuSpecialKaramaTransition | null = null;
|
||||
if (options.priorRevival) priorRevival = transition(() => {
|
||||
act(target, { type: 'revive', amount: 1, elite: 0 });
|
||||
reach(tleilaxuSpecialKaramaClean);
|
||||
});
|
||||
assert.equal(game.turn, 2); assert.equal(game.phase, 4); assert.ok(tleilaxuSpecialKaramaClean(game));
|
||||
assert.equal(tleilaxuSpecialKaramaPlayer(game, target).tanks, options.priorRevival ? 11 : 12);
|
||||
assert.equal(tleilaxuSpecialKaramaPlayer(game, target).revived, options.priorRevival ? 1 : 0);
|
||||
assert.equal(tleilaxuSpecialKaramaPlayer(game, target).freeForcesRevived, options.priorRevival ? 1 : 0);
|
||||
assert.ok(tleilaxuSpecialKaramaPlayer(game, owner).hand.some(card => card.id === karama.id));
|
||||
assert.ok(tleilaxuSpecialKaramaPlayer(game, target).hand.some(card => card.id === ghola.id));
|
||||
assert.ok(!tleilaxuSpecialKaramaPlayer(game, owner).specialKaramaUsed);
|
||||
assert.deepEqual(tleilaxuSpecialKaramaInventory(game), originalInventory);
|
||||
const special: Action = { type: 'card', card: karama.id, mode: 'special', target };
|
||||
const ordinaryRevival: Action = { type: 'revive', amount: 1, elite: 0 };
|
||||
const gholaAction: Action = { type: 'card', card: ghola.id, amount: 5, elite: 0 };
|
||||
return { game, source: structuredClone(game), initial, lobby, setup, beforeStartingDeal, afterSetup,
|
||||
ownerShipment, targetShipment, beforeBattle, battleStep, beforePreparation, plans, revealed, afterBattle,
|
||||
beforePriorRevival, priorRevival, owner, tleilaxu: owner, target, atreides: target, observer, emperor: observer,
|
||||
karama: karama.id, card: karama.id, ghola: ghola.id, special, action: special,
|
||||
ordinaryRevival, gholaAction, prior: !!options.priorRevival,
|
||||
actions, sourceActionCount: actions.length, inventory: originalInventory, originalInventory,
|
||||
originalSpice, dealOrder, spiceOrder,
|
||||
staging: [
|
||||
'Actual public native Advanced Tleilaxu/Atreides/Emperor ready/startPreview, private Traitor choices and original three-card Face Dancer deal. No optional module or changed rules mode is introduced.',
|
||||
'Select only original scalar shuffle order, three original unopened Treachery positions and four original unopened ordinary Spice positions. Tleilaxu really receives its starting Karama and Atreides its starting Ghola; neither card is played during the source.',
|
||||
'Tleilaxu actually pays one to ship one reserve into Arrakeen. Atreides actually pays two to ship two reserves into its original ten home counters. Native first Storm, dual Spice, Charity, auction and Revival controls all execute.',
|
||||
'The real Arrakeen battle seals unarmed Hidar4 and Yueh1 at dial0/support0. The native Tleilaxu victory puts all twelve original Atreides counters into Tanks without killing either leader or costing Tleilaxu a force. Actual native Battle loss/Kwisatz history remains.',
|
||||
'Continue real Collection, Mentat, second Storm/dual Spice, Charity and Bidding into clean second Revival, retaining original Karama/Ghola. No phase, army, wallet, card hand, Tanks, prevention or receipt is assigned.',
|
||||
options.priorRevival
|
||||
? 'Before the special choice, Atreides actually returns one own counter free. Tleilaxu declines the native revivalStop and original response passes settle its normal income; the completed return remains earned state.'
|
||||
: 'Stop before any normal revival request or special Karama. Native revivalStop and later ordinary/Ghola continuations remain caller actions.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type TleilaxuSpecialKaramaFixture = ReturnType<typeof createTleilaxuSpecialKaramaFixture>;
|
||||
|
||||
/** Optional native partial-return branch. The initial requested snapshot keeps
|
||||
* the real revivalStop available for explicit prevention or ordinary decline. */
|
||||
export function reviveTleilaxuSpecialKarama(source: Game, fixture: TleilaxuSpecialKaramaFixture,
|
||||
amount = 1, actions: TleilaxuSpecialKaramaStep[] = []) {
|
||||
const before = structuredClone(source), start = actions.length;
|
||||
const step: TleilaxuSpecialKaramaStep = { actor: fixture.target, action: { type: 'revive', amount, elite: 0 } };
|
||||
const requested = actTleilaxuSpecialKarama(source, step.actor, step.action, actions);
|
||||
const game = advanceTleilaxuSpecialKarama(requested, tleilaxuSpecialKaramaClean, actions);
|
||||
return { game, before, step, requested, after: structuredClone(game), steps: structuredClone(actions.slice(start)) };
|
||||
}
|
||||
export function finishTleilaxuSpecialKarama(source: Game, actions: TleilaxuSpecialKaramaStep[] = []): Game {
|
||||
return advanceTleilaxuSpecialKarama(source, game => game.phase === 5 && tleilaxuSpecialKaramaClean(game), actions);
|
||||
}
|
||||
@@ -223,7 +223,7 @@ void test('foreign-ghola and negotiated early-revival bot candidates explicitly
|
||||
);
|
||||
}
|
||||
});
|
||||
void test('ordinary revival prevention does not itself prevent independent Ghola recovery', () => {
|
||||
void test('bots respect Ghola prevention during Revival and can use it after phase expiry', () => {
|
||||
const { g, card } = fixture();
|
||||
g.phase = 4;
|
||||
g.active = null;
|
||||
@@ -231,7 +231,14 @@ void test('ordinary revival prevention does not itself prevent independent Ghola
|
||||
g.revivalPrevention = { player: 'p', turn: g.turn };
|
||||
for (const level of DIFFICULTIES) {
|
||||
assert.equal(view(g, level).revival.prevented, true);
|
||||
const { a, done } = apply(g, level);
|
||||
assert.equal(view(g, level).ghola.available, false);
|
||||
const blocked = apply(g, level);
|
||||
assert.notEqual(blocked.a.card, card.id);
|
||||
assert.ok(blocked.done.players[0].hand.some(c => c.id === card.id));
|
||||
const expired = structuredClone(g);
|
||||
expired.phase = 5;
|
||||
expired.active = 'p';
|
||||
const { a, done } = apply(expired, level);
|
||||
assert.equal(a.card, card.id);
|
||||
assert.equal(done.players[0].reserves, 5);
|
||||
}
|
||||
|
||||
@@ -234,7 +234,7 @@ void test('Ghola options are own-hand only and ignore opponents private spice ca
|
||||
assert.deepEqual(viewGame(g, 'p').ghola, a);
|
||||
assert.deepEqual(before.players[0], g.players[0]);
|
||||
});
|
||||
void test('Ghola respects pending table windows while normal revival prevention does not block it', () => {
|
||||
void test('Ghola respects table windows and Revival prevention, which expires outside Revival', () => {
|
||||
const { g, card } = fixture();
|
||||
Object.assign(g.players[0], {
|
||||
tanks: 5,
|
||||
@@ -243,6 +243,13 @@ void test('Ghola respects pending table windows while normal revival prevention
|
||||
leaderRevived: true,
|
||||
});
|
||||
g.revivalPrevention = { player: 'p', turn: 2 };
|
||||
assert.equal(viewGame(g, 'p').ghola.available, false);
|
||||
const blocked = structuredClone(g);
|
||||
assert.throws(() => applyAction(g, 'p', { type: 'card', card, amount: 5 }), /prevented.*Ghola/);
|
||||
assert.deepEqual(g, blocked);
|
||||
// Unit timing branch; the native fixture separately proves actual phase exit.
|
||||
g.phase = 5;
|
||||
g.active = 'p';
|
||||
assert.equal(viewGame(g, 'p').ghola.available, true);
|
||||
const done = applyAction(g, 'p', { type: 'card', card, amount: 5 });
|
||||
assert.equal(done.players[0].reserves, 20);
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Children, createElement, isValidElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { TleilaxuSpecialKarama } from '../components/tleilaxu-special-karama';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import {
|
||||
createGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
type Action,
|
||||
type GameView,
|
||||
} from '../game/engine';
|
||||
|
||||
// Client projections isolate eligibility controls. The runtime suite covers
|
||||
// physical Karama spending, original pending returns and prevention duration.
|
||||
function projected(): GameView {
|
||||
const game = createGame('TLEILAXUSPECIALCONTROLS', newPlayer('t', 'Tleilaxu', 'tleilaxu'), true, ['ix']);
|
||||
game.players.push(newPlayer('a', 'Ally', 'atreides'), newPlayer('e', 'Ready Emperor', 'emperor'), newPlayer('g', 'Earlier Guild', 'guild'));
|
||||
Object.assign(game, { status: 'playing', phase: 4, turn: 3, order: ['t', 'a', 'e', 'g'] });
|
||||
game.players[0].ally = 'a';
|
||||
game.players[1].ally = 't';
|
||||
game.players[2].ready = true;
|
||||
game.players[3].revived = 1;
|
||||
game.players[3].freeForcesRevived = 1;
|
||||
const cards = baseDeck().filter((card) => card.effect === 'karama').slice(0, 2);
|
||||
game.players[0].hand = cards;
|
||||
game.deck = baseDeck().filter((card) => !cards.some((held) => held.id === card.id));
|
||||
const view = viewGame(game, 't');
|
||||
view.tleilaxuSpecialKarama = {
|
||||
blocked: null, cards: cards.map((card) => card.id), pending: false,
|
||||
targets: game.players.map((player) => ({ player: player.id, blocked: null })),
|
||||
};
|
||||
return view;
|
||||
}
|
||||
|
||||
function markup(game: GameView, busy = false) {
|
||||
return renderToStaticMarkup(createElement(TleilaxuSpecialKarama, { game, busy, act() {} }));
|
||||
}
|
||||
|
||||
function click(node: ReactNode, selector: string): boolean {
|
||||
for (const child of Children.toArray(node)) {
|
||||
if (!isValidElement<{
|
||||
children?: ReactNode;
|
||||
onClick?: () => void;
|
||||
'data-testid'?: string;
|
||||
}>(child)) continue;
|
||||
if (child.props['data-testid'] === selector && child.props.onClick) {
|
||||
child.props.onClick();
|
||||
return true;
|
||||
}
|
||||
if (click(child.props.children, selector)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function prevent(game: GameView, card: string, target = 'e', busy = false): Action[] {
|
||||
const actions: Action[] = [];
|
||||
click(TleilaxuSpecialKarama({ game, busy, act(action) { actions.push(action); } }),
|
||||
`tleilaxu-special-karama-${card}-${target}`);
|
||||
return actions;
|
||||
}
|
||||
|
||||
void test('actual quiet-window buttons include self, ally, ready and prior-return targets using every projected card', () => {
|
||||
const game = projected();
|
||||
const html = markup(game);
|
||||
assert.match(html, /aria-label="Tleilaxu special Karama revival prevention"/);
|
||||
assert.match(html, /prevent revival for Tleilaxu/);
|
||||
assert.match(html, /prevent revival for Ally/);
|
||||
assert.match(html, /prevent revival for Ready Emperor/);
|
||||
assert.match(html, /prevent revival for Earlier Guild/);
|
||||
assert.equal((html.match(/data-testid="tleilaxu-special-karama-/g) ?? []).length, 8);
|
||||
for (const card of game.tleilaxuSpecialKarama!.cards) {
|
||||
for (const target of game.tleilaxuSpecialKarama!.targets) {
|
||||
assert.deepEqual(prevent(game, card, target.player), [{ type: 'card', mode: 'special', card, target: target.player }]);
|
||||
}
|
||||
}
|
||||
assert.deepEqual(prevent(game, 'stale-card'), []);
|
||||
assert.deepEqual(prevent(game, game.tleilaxuSpecialKarama!.cards[0], 'not-a-target'), []);
|
||||
});
|
||||
|
||||
void test('persistent guidance includes current-Revival Ghola, phase expiry and protected Emperor special without reversing completed returns', () => {
|
||||
const game = projected();
|
||||
for (let render = 0; render < 3; render++) {
|
||||
const html = markup(game);
|
||||
assert.match(html, /once-per-game special Karama/);
|
||||
assert.match(html, /force and leader revivals for the rest of this Revival, including returns from Ghola Treachery/);
|
||||
assert.match(html, /yourself or an ally/);
|
||||
assert.match(html, /even if the chosen faction is ready or has already completed revivals/);
|
||||
assert.match(html, /Completed returns and payments stay unchanged/);
|
||||
assert.match(html, /does not remove returned pieces or refund spice/);
|
||||
assert.match(html, /Provisional rule: Ghola Treachery is included during the affected Revival/);
|
||||
assert.match(html, /prevention expires when the phase ends/);
|
||||
assert.match(html, /does not stop Ghola outside Revival/);
|
||||
assert.match(html, /Emperor’s native special Karama remains protected by the printed immunity of special Karama powers/);
|
||||
}
|
||||
});
|
||||
|
||||
void test('global and per-target projected block reasons remain readable and prevent only the affected actions', () => {
|
||||
const game = projected();
|
||||
const card = game.tleilaxuSpecialKarama!.cards[0];
|
||||
game.tleilaxuSpecialKarama!.targets.find((target) => target.player === 'e')!.blocked = 'This Homeworld revival combination is not yet available.';
|
||||
const html = markup(game);
|
||||
assert.match(html, /Ready Emperor: This Homeworld revival combination is not yet available/);
|
||||
assert.match(html, /aria-describedby="tleilaxu-special-karama-e-unavailable"/);
|
||||
assert.deepEqual(prevent(game, card, 'e'), []);
|
||||
assert.deepEqual(prevent(game, card, 'a'), [{ type: 'card', mode: 'special', card, target: 'a' }]);
|
||||
game.tleilaxuSpecialKarama!.blocked = 'This once-per-game special Karama has already been used.';
|
||||
assert.match(markup(game), /once-per-game special Karama has already been used/);
|
||||
assert.deepEqual(prevent(game, card, 'a'), []);
|
||||
});
|
||||
|
||||
void test('cardless and targetless projections show useful guidance without inventing actions', () => {
|
||||
const game = projected();
|
||||
const card = game.tleilaxuSpecialKarama!.cards[0];
|
||||
game.tleilaxuSpecialKarama!.cards = [];
|
||||
assert.match(markup(game), /No spendable Karama is currently available for this special power/);
|
||||
assert.doesNotMatch(markup(game), /data-testid="tleilaxu-special-karama-/);
|
||||
assert.deepEqual(prevent(game, card), []);
|
||||
game.tleilaxuSpecialKarama!.targets = [];
|
||||
assert.match(markup(game), /No faction is currently available for this revival-prevention power/);
|
||||
});
|
||||
|
||||
void test('the owned pending revival-stop uses its original projected recipient without adding a decline or acknowledgement', () => {
|
||||
const game = projected();
|
||||
const card = game.tleilaxuSpecialKarama!.cards[0];
|
||||
game.tleilaxuSpecialKarama!.pending = true;
|
||||
game.tleilaxuSpecialKarama!.targets = [{ player: 'e', blocked: null }];
|
||||
game.decision = { kind: 'revivalStop', player: 't', recipient: 'e', revival: 'forces' };
|
||||
const html = markup(game);
|
||||
assert.match(html, /Only the original recipient can be selected during this pending revival/);
|
||||
assert.deepEqual(prevent(game, card), [{ type: 'card', mode: 'special', card, target: 'e' }]);
|
||||
assert.deepEqual(prevent(game, card, 'a'), []);
|
||||
assert.doesNotMatch(html, /Allow this revival|Decline|Acknowledge|Confirm prevention/);
|
||||
game.decision = null;
|
||||
assert.deepEqual(prevent(game, card), []);
|
||||
});
|
||||
|
||||
void test('busy and other current priorities block actual callbacks while retaining the guide', () => {
|
||||
const game = projected();
|
||||
const card = game.tleilaxuSpecialKarama!.cards[0];
|
||||
assert.deepEqual(prevent(game, card, 'e', true), []);
|
||||
assert.equal((markup(game, true).match(/<button(?=[^>]*data-testid="tleilaxu-special-karama-)(?=[^>]*disabled="")[^>]*>/g) ?? []).length, 8);
|
||||
for (const field of ['response', 'decision', 'phaseOpening', 'truthtrance', 'nexus'] as const) {
|
||||
const changed = structuredClone(game);
|
||||
if (field === 'response') changed.response = {} as NonNullable<GameView['response']>;
|
||||
if (field === 'decision') changed.decision = { kind: 'revivalStop', player: 'e', recipient: 'e', revival: 'forces' };
|
||||
if (field === 'phaseOpening') changed.phaseOpening = {} as NonNullable<GameView['phaseOpening']>;
|
||||
if (field === 'truthtrance') changed.truthtrance = {} as NonNullable<GameView['truthtrance']>;
|
||||
if (field === 'nexus') changed.nexusTraitors = {
|
||||
offer: null, pending: {} as NonNullable<NonNullable<GameView['nexusTraitors']>['pending']>,
|
||||
};
|
||||
assert.deepEqual(prevent(changed, card), [], field);
|
||||
assert.match(markup(changed), /Finish the current response, decision or phase opening/, field);
|
||||
}
|
||||
});
|
||||
|
||||
void test('only the native Advanced Tleilaxu owner receives these phase-four controls', () => {
|
||||
for (const field of ['projection', 'basic', 'viewer', 'faction', 'phase', 'status'] as const) {
|
||||
const game = projected();
|
||||
const card = game.tleilaxuSpecialKarama!.cards[0];
|
||||
if (field === 'projection') game.tleilaxuSpecialKarama = null;
|
||||
if (field === 'basic') game.advanced = false;
|
||||
if (field === 'viewer') game.me = 'a';
|
||||
if (field === 'faction') game.players[0].faction = 'atreides';
|
||||
if (field === 'phase') game.phase = 5;
|
||||
if (field === 'status') game.status = 'lobby';
|
||||
assert.equal(markup(game), '', field);
|
||||
assert.deepEqual(prevent(game, card), [], field);
|
||||
}
|
||||
});
|
||||
|
||||
void test('eligibility controls never inspect private hands, Tanks, leader liveness, readiness or revival counters', () => {
|
||||
const game = projected();
|
||||
const card = game.tleilaxuSpecialKarama!.cards[0];
|
||||
for (const player of game.players) {
|
||||
for (const field of ['hand', 'spice', 'tanks', 'leaders', 'ready', 'revived', 'freeForcesRevived', 'leaderRevived']) {
|
||||
Object.defineProperty(player, field, {
|
||||
get() { throw new Error(`Revival-prevention UI read ${player.id} private ${field}`); },
|
||||
});
|
||||
}
|
||||
}
|
||||
assert.match(markup(game), /prevent revival for Ready Emperor/);
|
||||
assert.deepEqual(prevent(game, card, 't'), [{ type: 'card', mode: 'special', card, target: 't' }]);
|
||||
assert.deepEqual(prevent(game, card, 'g'), [{ type: 'card', mode: 'special', card, target: 'g' }]);
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { quoteTleilaxuSpecialKarama as quote, type TleilaxuSpecialKaramaContext } from '../game/tleilaxu-special-karama';
|
||||
|
||||
function context(): TleilaxuSpecialKaramaContext {
|
||||
return { status: 'playing', phase: 4, advanced: true, owner: { id: 't', faction: 'tleilaxu' },
|
||||
players: [{ id: 't' }, { id: 'a' }, { id: 'e' }], cards: ['original-karama'],
|
||||
pendingPlayer: null, blocked: null };
|
||||
}
|
||||
void test('quiet targets use only seated identities and preserve independent target restrictions', () => {
|
||||
const source = context(), before = structuredClone(source);
|
||||
source.targetBlocks = { e: 'Unresolved low-population Homeworld overlap.' };
|
||||
const offered = quote(source);
|
||||
assert.equal(offered.blocked, null);
|
||||
assert.deepEqual(offered.targets, [{ player: 't', blocked: null }, { player: 'a', blocked: null },
|
||||
{ player: 'e', blocked: source.targetBlocks.e }]);
|
||||
offered.cards.pop();
|
||||
assert.deepEqual(source.cards, before.cards);
|
||||
});
|
||||
void test('pending original recipient is the only target, and an absent recipient cannot silently retarget', () => {
|
||||
assert.deepEqual(quote({ ...context(), pendingPlayer: 'a' }).targets, [{ player: 'a', blocked: null }]);
|
||||
assert.match(quote({ ...context(), pendingPlayer: 'missing' }).blocked!, /Choose a player/);
|
||||
});
|
||||
void test('mode, native owner, phase, use, priority and spendable card each gate permission', () => {
|
||||
const source = context();
|
||||
for (const change of [
|
||||
{ advanced: false }, { owner: { id: 't', faction: 'guild' as const } },
|
||||
{ status: 'finished' }, { phase: 5 }, { owner: { ...source.owner, specialKaramaUsed: true } },
|
||||
{ blocked: 'Finish the response first.' }, { cards: [] },
|
||||
]) assert.ok(quote({ ...source, ...change }).blocked);
|
||||
assert.equal(quote(source).blocked, null);
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, executeSpecialKaramaIntent, prepareSpecialKaramaIntent, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
createTleilaxuSpecialKaramaFixture as fixture,
|
||||
actTleilaxuSpecialKarama as act,
|
||||
tleilaxuSpecialKaramaPlayer as player,
|
||||
tleilaxuSpecialKaramaInventory as inventory,
|
||||
reviveTleilaxuSpecialKarama as revive,
|
||||
finishTleilaxuSpecialKarama as finish,
|
||||
passTleilaxuSpecialKarama as pass,
|
||||
} from './fixture-tleilaxu-special-karama';
|
||||
|
||||
function rejected(game: Game, actor: string, action: Action, reason?: RegExp) {
|
||||
const before = structuredClone(game);
|
||||
if (reason) assert.throws(() => applyAction(game, actor, action), reason);
|
||||
else assert.throws(() => applyAction(game, actor, action));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
void test('native quiet Revival permits any player before a request and spends the original card exactly once', () => {
|
||||
const f = fixture(), before = structuredClone(f.game);
|
||||
const quote = viewGame(f.game, f.owner).tleilaxuSpecialKarama;
|
||||
assert.ok(!f.game.pendingRevival);
|
||||
assert.equal(quote?.blocked, null);
|
||||
assert.equal(quote?.pending, false);
|
||||
assert.deepEqual(quote?.cards, [f.karama]);
|
||||
assert.deepEqual(quote?.targets, f.game.players.map(p => ({ player: p.id, blocked: null })));
|
||||
for (const actor of [f.target, f.observer]) assert.equal(viewGame(f.game, actor).tleilaxuSpecialKarama, null);
|
||||
const stopped = act(f.game, f.owner, f.special);
|
||||
assert.deepEqual(f.game, before);
|
||||
assert.equal(player(stopped, f.owner).specialKaramaUsed, true);
|
||||
assert.equal(player(stopped, f.owner).hand.some(c => c.id === f.karama), false);
|
||||
assert.equal(stopped.discard.filter(c => c.id === f.karama).length, 1);
|
||||
assert.deepEqual(stopped.revivalPrevention, { player: f.target, turn: stopped.turn });
|
||||
assert.deepEqual(player(stopped, f.target), player(before, f.target));
|
||||
assert.deepEqual(stopped.ready, before.ready);
|
||||
assert.equal(stopped.decision, null);
|
||||
assert.equal(stopped.pendingRevival, null);
|
||||
assert.deepEqual(inventory(stopped), f.inventory);
|
||||
rejected(stopped, f.target, f.ordinaryRevival, /prevented/);
|
||||
rejected(stopped, f.target, f.gholaAction, /prevented.*Ghola/);
|
||||
rejected(stopped, f.owner, f.special, /already been used/);
|
||||
assert.equal(viewGame(stopped, f.target).ghola?.available, false);
|
||||
});
|
||||
|
||||
void test('ready, self and empty-Tanks targets do not narrow quiet declaration permission', () => {
|
||||
const f = fixture();
|
||||
const ready = act(f.game, f.observer, { type: 'ready' });
|
||||
for (const target of [f.owner, f.observer]) {
|
||||
assert.equal(player(ready, target).tanks, 0);
|
||||
const chosen = act(ready, f.owner, { ...f.special, target });
|
||||
assert.equal(chosen.revivalPrevention?.player, target);
|
||||
assert.deepEqual(chosen.ready, ready.ready);
|
||||
assert.equal(player(chosen, target).tanks, 0);
|
||||
}
|
||||
});
|
||||
|
||||
void test('completed paid and free returns retain their quota, pieces and native Tleilaxu income', () => {
|
||||
const f = fixture();
|
||||
const completed = revive(f.game, f, 3).game;
|
||||
assert.equal(player(completed, f.target).tanks, 9);
|
||||
assert.equal(player(completed, f.target).revived, 3);
|
||||
assert.ok(player(completed, f.target).spice < player(f.game, f.target).spice);
|
||||
assert.ok(player(completed, f.owner).spice > player(f.game, f.owner).spice);
|
||||
const target = structuredClone(player(completed, f.target));
|
||||
const income = player(completed, f.owner).spice;
|
||||
const stopped = act(completed, f.owner, f.special);
|
||||
assert.deepEqual(player(stopped, f.target), target);
|
||||
assert.equal(player(stopped, f.owner).spice, income);
|
||||
assert.equal(viewGame(stopped, f.target).revival.prevented, true);
|
||||
rejected(stopped, f.target, f.ordinaryRevival);
|
||||
rejected(stopped, f.target, f.gholaAction, /prevented.*Ghola/);
|
||||
assert.deepEqual(inventory(stopped), f.inventory);
|
||||
});
|
||||
|
||||
void test('the pending normal request remains target-bound and can be declined before a later quiet use', () => {
|
||||
const f = fixture();
|
||||
const pending = revive(f.game, f).requested;
|
||||
const quote = viewGame(pending, f.owner).tleilaxuSpecialKarama;
|
||||
assert.equal(quote?.pending, true);
|
||||
assert.deepEqual(quote?.targets.map(t => t.player), [f.target]);
|
||||
rejected(pending, f.owner, { ...f.special, target: f.observer }, /currently reviving/);
|
||||
const stopped = act(pending, f.owner, { type: 'card', card: f.karama, mode: 'special' });
|
||||
assert.deepEqual(player(stopped, f.target), player(f.game, f.target));
|
||||
assert.equal(stopped.pendingRevival, null);
|
||||
const declined = pass(act(pending, f.owner, { type: 'decision', decline: true }));
|
||||
assert.equal(player(declined, f.target).tanks, 11);
|
||||
assert.equal(viewGame(declined, f.owner).tleilaxuSpecialKarama?.blocked, null);
|
||||
assert.equal(act(declined, f.owner, f.special).revivalPrevention?.player, f.target);
|
||||
});
|
||||
|
||||
void test('quiet intent cannot spend after a new pending declaration replaces its source', () => {
|
||||
const f = fixture();
|
||||
const quiet = prepareSpecialKaramaIntent(f.game, f.owner, f.special);
|
||||
assert.equal(quiet.kind, 'tleilaxu');
|
||||
assert.equal(quiet.kind === 'tleilaxu' && quiet.revival, null);
|
||||
const pending = revive(f.game, f).requested;
|
||||
const before = structuredClone(pending);
|
||||
assert.throws(() => executeSpecialKaramaIntent(pending, quiet), /original revival declaration/);
|
||||
assert.deepEqual(pending, before);
|
||||
const intent = prepareSpecialKaramaIntent(pending, f.owner, f.special);
|
||||
assert.ok(intent.kind === 'tleilaxu' && intent.revival);
|
||||
const disposable = structuredClone(pending);
|
||||
executeSpecialKaramaIntent(disposable, intent);
|
||||
assert.equal(disposable.revivalPrevention?.player, f.target);
|
||||
});
|
||||
|
||||
void test('the real end of Revival expires prevention and permits five original Ghola counters without normal quota', () => {
|
||||
const f = fixture({ priorRevival: true });
|
||||
const stopped = act(f.game, f.owner, f.special);
|
||||
rejected(stopped, f.target, f.gholaAction, /prevented.*Ghola/);
|
||||
const shipment = finish(stopped);
|
||||
assert.equal(shipment.phase, 5);
|
||||
assert.equal(viewGame(shipment, f.owner).tleilaxuSpecialKarama, null);
|
||||
const prior = structuredClone(player(shipment, f.target));
|
||||
const returned = pass(act(shipment, f.target, f.gholaAction));
|
||||
assert.equal(player(returned, f.target).tanks, prior.tanks - 5);
|
||||
assert.equal(player(returned, f.target).reserves, prior.reserves + 5);
|
||||
assert.equal(player(returned, f.target).spice, prior.spice);
|
||||
assert.equal(player(returned, f.target).revived, prior.revived);
|
||||
assert.equal(returned.discard.filter(c => c.id === f.ghola).length, 1);
|
||||
assert.deepEqual(inventory(returned), f.inventory);
|
||||
});
|
||||
|
||||
void test('four existing profiles propose legal quiet prevention without a new strategic policy', () => {
|
||||
const f = fixture();
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const source = structuredClone(f.game);
|
||||
player(source, f.owner).bot = difficulty;
|
||||
const action = botActions(viewGame(source, f.owner))[0];
|
||||
assert.deepEqual(action, f.special);
|
||||
const stopped = act(source, f.owner, action);
|
||||
rejected(stopped, f.target, f.ordinaryRevival, /prevented/);
|
||||
assert.equal(finish(stopped).phase, 5);
|
||||
assert.deepEqual(inventory(stopped), f.inventory);
|
||||
}
|
||||
});
|
||||
@@ -136,7 +136,7 @@ void test('special Karama consumes one card once, aborts payment and prevents re
|
||||
});
|
||||
void test('ownership, actual Karama, window, target and decline validation reject immutably', () => {
|
||||
const idle = beforeRevival();
|
||||
assert.throws(() => prevent(idle), /declaration/);
|
||||
assert.throws(() => prevent(idle), /Expected a string/);
|
||||
const g = declare(idle);
|
||||
const snapshot = structuredClone(g);
|
||||
assert.throws(
|
||||
@@ -245,11 +245,13 @@ void test('allowing Emperor-funded revival preserves separate quota, correct pay
|
||||
assert.equal(g.players[0].revived, 0);
|
||||
assert.equal(g.players[0].tanks, 5);
|
||||
});
|
||||
void test('Ghola treachery and Emperor special Karama remain separate from blocked normal revivals', () => {
|
||||
let g = prevent(declare(beforeRevival()));
|
||||
void test('Ghola is prevented during Revival while Emperor special Karama retains printed immunity', () => {
|
||||
const g = prevent(declare(beforeRevival()));
|
||||
placeFixtureHand(g, 0, [ghola]);
|
||||
g = allow(applyAction(g, 'a', { type: 'card', card: ghola.id, amount: 5 }));
|
||||
assert.equal(g.players[0].tanks, 3);
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => applyAction(g, 'a', { type: 'card', card: ghola.id, amount: 5 }), /prevented.*Ghola/);
|
||||
assert.deepEqual(g, before);
|
||||
assert.equal(g.players[0].tanks, 8);
|
||||
assert.equal(g.players[0].revived, 0);
|
||||
let emperor = beforeRevival();
|
||||
emperor.players[1].tanks = 5;
|
||||
|
||||
Reference in new issue
Block a user