Files
Dune/tests/kwisatz.test.ts
Quintine e7e16024b7 Show revealed battle components in one shared table area
Present public plans in physical attacker/defender order with exact force dials,
played leader portraits and printed strength, readable used-card faces and direct
inspectors. Keep Cheap Heroes, printed Worthless category, empty slots and late
rescue defense distinct. Preserve Banker, native bonus, KH, Stone Burner,
Poison Tooth and Diplomat details rather than treating printed values as scores.

Mount one shared display after the main grid so every viewer retains access
while another seat owns a post-reveal choice. Keyboard links focus the display
and pending controls. Render nothing before public revelation, select exact
used identities from the authorized projection, and consume no private insight
or hands. No game rule, saved field, endpoint, AI strategy or release gate changes.

Five new cases cover genuine sealed/revealed actions, stable order, used-card
privacy, JSON immutability, fractional dials, hero/Worthless/late/empty slots
and both seats during a real Diplomat decision. All28 related focused cases
passed before final rule-link adapter changes; types, lint, all 5017 offline
tests and production build pass on the final stable source. First broad run
caught plain rules-page anchors; next/link and test aliases correct this.
Independent privacy/layout review and final follow-up found no material issues.
No API/auth/persistence changes; HTTP tests and full game samples not repeated.

Browser QA creates a fresh human Emperor/Easy Harkonnen lobby with genuine
setup, then explicitly stages a conserved battle. Normal engine actions choose
battle and seal the opponent plan; human browser action seals dial2, Captain
Aramsham, Slip Tip and Shield. Public display absent beforehand and shows correct
both plans afterward. Leader/opponent-card inspection, Escape/focus return,
internal help, keyboard section links and refresh preserve the pending decision.
At390x844, client/scrollWidth375, stacked panels299px, rules16px and controls44px;
normal viewport restores1265px and two560.5px plans without horizontal overflow.
Screenshot capture timed out; full visual/screen-reader acceptance remains open.

All 1579 opening games, 3347 saved seats and auxiliary rows
remain identical. One isolated QA game adds one seat and one entry receipt.
Its room remains unchanged after human seal through inspection/help/refresh,
and its saved seat remains unchanged. SQLite integrity and foreign keys pass.
No server restart, migration, reset, recurring automation or calibration.

Record Basic Moritani plus the full Leader Skills deck as the next concrete
integration target, requiring coordinated profile guards and real Terror/death/
revival continuations. Existing rulings and mode gates stay explicit; the later
Tleilaxu revival-offer ordering issue remains a gated integration dependency.

Private evidence: 20260921-revealed-plans.
Verified source tree: 8d5496cdea0076aa1cdab4590e95fcaa677c9d2d8513190c2ce9ac6c90bb9196
2026-09-21 13:16:39 +10:00

414 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { placeFixtureHand } from './fixture-hand';
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createGame,
newPlayer,
joinGame,
applyAction,
viewGame,
type Game,
} from '../game/engine';
import { baseDeck } from '../game/cards';
import { registerHooks } from 'node:module';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
const aliases = registerHooks({ resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
} });
const { RevealedBattle } = await import('../components/revealed-battle');
const { PrivateBattlePlan } = await import('../components/private-battle-plan');
const { BattleComponentInspection } = await import('../components/battle-component-inspection');
const { KwisatzPrivateStatus } = await import('../components/kwisatz-inspector');
aliases.deregister();
void test('KH component inspection follows real sealed and revealed projections without exposing private status', () => {
let g = prepared(fixture(true));
g.players[0].ally = 'g';
g.players[2].ally = 'a';
g = applyAction(g, 'a', { type: 'battlePlan', dial: 0, leader: 'atreides-0', kwisatz: true });
for (const id of ['a', 'e', 'g']) {
const v = viewGame(g, id);
assert.equal(renderToStaticMarkup(createElement(RevealedBattle, { game: v })), '');
assert.equal(Boolean(v.players[0].kwisatz), id === 'a');
}
g = applyAction(g, 'e', { type: 'battlePlan', dial: 0, leader: 'emperor-0' });
const before = JSON.stringify(g);
for (const id of ['a', 'e', 'g']) {
const v = viewGame(g, id);
const html = renderToStaticMarkup(createElement(RevealedBattle, { game: v }));
assert.match(html, /Inspect Kwisatz Haderach · plan component/);
assert.doesNotMatch(html, /Your private status|<progress|of 7 battle losses/);
assert.equal(renderToStaticMarkup(createElement(RevealedBattle, { game: viewGame(JSON.parse(before), id) })), html);
}
const own = viewGame(g, 'a').players[0].kwisatz!;
assert.match(renderToStaticMarkup(createElement(KwisatzPrivateStatus, { state: own })), /7 of 7 battle losses/);
assert.equal(JSON.stringify(g), before);
const basic = structuredClone(g);
basic.advanced = false;
assert.equal(viewGame(basic, 'a').players[0].kwisatz, undefined);
});
void test('authorized full-plan KH inspection needs only inclusion and never accepts another viewer', () => {
const v = viewGame(prepared(fixture(true)), 'a');
const plan = { dial: 0, support: 0, leader: null, weapon: null, defense: null, kwisatz: true };
// A constructed authorized view isolates the component boundary; timing is covered elsewhere.
const inspected = { ...v, battle: { ...v.battle!, fullPlan: { owner: 'a', target: 'a', stage: 'inspected' as const }, fullPlanInsight: { target: 'a', plan, cards: [] } } };
const html = renderToStaticMarkup(createElement(PrivateBattlePlan, { game: inspected }));
assert.match(html, /Inspect Kwisatz Haderach · plan component/);
assert.doesNotMatch(html, /Your private status|<progress|of 7 battle losses/);
assert.equal(renderToStaticMarkup(createElement(PrivateBattlePlan, { game: { ...inspected, me: 'g' } })), '');
assert.equal(renderToStaticMarkup(createElement(PrivateBattlePlan, { game: { ...inspected, battle: { ...inspected.battle, revealed: true } } })), '');
assert.equal(renderToStaticMarkup(createElement(BattleComponentInspection, { cards: [], playerName: 'Atreides', kwisatz: false })), '');
});
/** Transfer fixture cards from the actual deck; never duplicate another seat's hand. */
function setBattleHand(g: Game, id: string, ids: string[]) {
const owner = g.players.find((p) => p.id === id)!;
g.deck.push(...owner.hand);
owner.hand = [];
owner.hand = ids.map((cardId) => {
assert.ok(
!g.players.some((p) => p.hand.some((card) => card.id === cardId)),
`Fixture card ${cardId} is already held by another seat`,
);
const index = g.deck.findIndex((card) => card.id === cardId);
assert.ok(
index >= 0,
`Physical fixture card ${cardId} must be in the deck`,
);
return g.deck.splice(index, 1)[0];
});
}
function fixture(spectator = false) {
let g = createGame('KWISATZ2', newPlayer('a', 'Atreides', 'atreides'));
joinGame(g, newPlayer('e', 'Emperor', 'emperor'));
if (spectator) joinGame(g, newPlayer('g', 'Guild', 'guild'));
g.players.forEach((p) => (p.ready = true));
g = applyAction(g, 'a', { type: 'start' });
for (const p of g.players)
g = applyAction(g, p.id, { type: 'traitor', leader: p.traitorChoices[0] });
g.advanced = true;
g.phase = 6;
g.order = ['e', 'a', ...(spectator ? ['g'] : [])];
g.active = 'e';
g.storm = 18;
g.players[0].battleLosses = 7;
g.players[1].reserves = 10;
g.players[1].forces = { 'arrakeen:10': 10 };
g.players.forEach((p) => {
g.deck.push(...p.hand);
p.hand = [];
p.traitors = [];
});
return g;
}
function allow(state: Game) {
let g = state;
while (g.response)
g = applyAction(
g,
g.players.find((p) => !g.response!.passed.includes(p.id))!.id,
{ type: 'passResponse' },
);
return g;
}
function prepared(state: Game, territory = 'arrakeen') {
let g = applyAction(state, state.active!, {
type: 'chooseBattle',
territory,
target: state.active === 'a' ? 'e' : 'a',
});
g = allow(g);
while (g.battle?.preparation)
g = applyAction(g, g.battle.preparation.owner, {
type: 'declineBattlePower',
});
if (g.decision?.kind === 'fullPlanOffer')
g = applyAction(g, g.decision.player, { type: 'decision', decline: true });
return g;
}
function plans(state: Game, atreides: object = {}, emperor: object = {}) {
let g = applyAction(state, 'a', {
type: 'battlePlan',
dial: 0,
leader: 'atreides-0',
kwisatz: true,
...atreides,
});
g = applyAction(g, 'e', {
type: 'battlePlan',
dial: 0,
leader: 'emperor-0',
...emperor,
});
return g;
}
function resolve(state: Game) {
let g = state;
for (const id of ['a', 'e'])
g = applyAction(g, id, { type: 'traitorCall', call: false });
while (g.decision) {
const d = g.decision;
g = applyAction(
g,
d.player,
d.kind === 'battleLosses'
? { type: 'decision', choice: 0 }
: { type: 'decision', discard: [] },
);
}
return g;
}
void test('KH adds two to a surviving leader while its loss counter and sealed selection stay private', () => {
let g = prepared(fixture());
assert.equal(viewGame(g, 'a').players[0].kwisatz?.losses, 7);
assert.equal(viewGame(g, 'e').players[0].kwisatz, undefined);
g = applyAction(g, 'a', {
type: 'battlePlan',
dial: 0,
leader: 'atreides-0',
kwisatz: true,
});
assert.equal(viewGame(g, 'e').battle!.plans.a, undefined);
assert.equal(viewGame(g, 'a').battle!.plans.a.kwisatz, true);
g = applyAction(g, 'e', { type: 'battlePlan', dial: 0, leader: 'emperor-0' });
assert.equal(viewGame(g, 'e').battle!.plans.a.kwisatz, true);
g = resolve(g);
assert.equal(g.players[1].tanks, 10);
assert.equal(g.players[0].tanks, 0);
assert.equal(g.players[0].kwisatz?.usedAt, 'arrakeen');
assert.ok(g.log.some((l) => l.text.includes('(6–7')));
});
void test('KH requires seven battle losses, a leader or hero, and the same territory during a turn', () => {
let g = fixture();
g.players[0].battleLosses = 6;
g = prepared(g);
const action = {
type: 'battlePlan',
dial: 0,
leader: 'atreides-0',
kwisatz: true,
};
assert.throws(() => applyAction(g, 'a', action), /not available/);
g.players[0].battleLosses = 7;
g.players[0].kwisatz = { dead: false, usedAt: 'carthag' };
assert.throws(() => applyAction(g, 'a', action), /not available/);
g.players[0].kwisatz!.usedAt = 'arrakeen';
assert.equal(applyAction(g, 'a', action).battle!.plans.a.kwisatz, true);
assert.throws(
() => applyAction(g, 'a', { ...action, leader: null }),
/must accompany/,
);
const hero = baseDeck().find((c) => c.kind === 'hero')!;
setBattleHand(g, 'a', [hero.id]);
g = resolve(plans(g, { leader: hero.id }, { dial: 0, leader: 'emperor-4' }));
assert.ok(g.log.some((l) => l.text.includes('(2–2')));
assert.equal(g.players[0].kwisatz!.dead, false);
});
void test('a killed accompanying leader gives no KH bonus and does not kill KH or increase the bounty', () => {
let g = fixture();
const poison = baseDeck().find((c) => c.kind === 'poison')!;
setBattleHand(g, 'e', [poison.id]);
const before = g.players[1].spice;
g = resolve(plans(prepared(g), {}, { weapon: poison.id }));
assert.equal(g.players[0].leaders[0].dead, true);
assert.equal(g.players[0].kwisatz?.dead, false);
assert.equal(g.players[1].spice, before + 5 + 2); // leader bounty + city income
assert.ok(g.log.some((l) => l.text.includes('(6–0')));
});
void test('KH prevents traitor calls and Karama can remove both benefits before either plan is sealed', () => {
let g = fixture();
g.players[1].traitors = ['atreides-0'];
const protectedPlan = plans(prepared(g));
assert.throws(
() => applyAction(protectedPlan, 'e', { type: 'traitorCall', call: true }),
/prevents/,
);
g.players[1].hand = [baseDeck().find((c) => c.effect === 'karama')!];
g = applyAction(g, 'e', {
type: 'chooseBattle',
territory: 'arrakeen',
target: 'a',
});
assert.equal(g.response?.kind, 'kwisatz');
const karama = baseDeck().find((c) => c.effect === 'karama')!;
g.players[1].hand = [karama];
g = applyAction(g, 'e', { type: 'card', card: karama.id, mode: 'cancel' });
g = applyAction(g, 'a', { type: 'declineBattlePower' });
g = applyAction(g, 'a', { type: 'decision', decline: true });
assert.throws(() => plans(g), /not available/);
g = plans(g, { kwisatz: false });
g = applyAction(g, 'e', { type: 'traitorCall', call: true });
g = applyAction(g, 'a', { type: 'traitorCall', call: false });
assert.equal(g.players[0].tanks, 10);
assert.equal(g.players[0].kwisatz, undefined);
});
void test('only an actual lasgun/shield explosion kills a played KH; a traitor win overrides the explosion', () => {
let g = fixture();
const shield = baseDeck().find((c) => c.kind === 'shield')!,
lasgun = baseDeck().find((c) => c.kind === 'lasgun')!;
setBattleHand(g, 'a', [shield.id]);
setBattleHand(g, 'e', [lasgun.id]);
g.players[0].traitors = ['emperor-0'];
g = plans(prepared(g), { defense: shield.id }, { weapon: lasgun.id });
const explosion = resolve(g);
assert.equal(explosion.players[0].kwisatz?.dead, true);
assert.equal(explosion.players[0].tanks, 10);
g = applyAction(g, 'a', { type: 'traitorCall', call: true });
g = applyAction(g, 'e', { type: 'traitorCall', call: false });
assert.equal(g.players[0].kwisatz?.dead, false);
assert.equal(g.players[0].tanks, 0);
});
void test('KH revival shares the leader quota, waits for the Atreides cycle, and never blocks ordinary leaders', () => {
let g = fixture();
g.phase = 4;
g.players[0].kwisatz = { dead: true, revivalCycle: 1 };
assert.throws(() => applyAction(g, 'a', { type: 'reviveKwisatz' }), /cycle/);
g.players[0].leaders.forEach((l) => {
l.dead = true;
l.deaths = 1;
});
const before = g.players[0].spice;
const leaderFirst = applyAction(g, 'a', {
type: 'reviveLeader',
leader: 'atreides-0',
});
assert.equal(leaderFirst.players[0].leaders[0].dead, false);
assert.throws(
() => applyAction(leaderFirst, 'a', { type: 'reviveKwisatz' }),
/one leader/,
);
g = applyAction(g, 'a', { type: 'reviveKwisatz' });
assert.equal(g.players[0].spice, before - 2);
assert.equal(g.players[0].kwisatz!.dead, false);
assert.throws(
() => applyAction(g, 'a', { type: 'reviveLeader', leader: 'atreides-0' }),
/one leader/,
);
g.players[0].leaderRevived = false;
g.players[0].kwisatz!.dead = true;
assert.throws(() => applyAction(g, 'a', { type: 'reviveKwisatz' }), /cycle/);
g.players[0].leaders.forEach((l) => (l.deaths = 2));
g = applyAction(g, 'a', { type: 'reviveKwisatz' });
assert.equal(g.players[0].kwisatz!.dead, false);
});
void test('Ghola can revive KH without waiting for the normal leader cycle or spending its quota', () => {
let g = fixture();
g.phase = 4;
g.players[0].kwisatz = { dead: true };
const ghola = baseDeck().find((c) => c.effect === 'ghola')!;
placeFixtureHand(g, 0, [ghola]);
const before = g.players[0].spice;
g = applyAction(g, 'a', { type: 'card', card: ghola.id, leader: 'kwisatz' });
assert.equal(g.players[0].kwisatz!.dead, false);
assert.equal(g.players[0].leaderRevived, false);
assert.equal(g.players[0].spice, before);
assert.equal(g.players[0].hand.length, 0);
});
void test('advanced stronghold income stacks by occupied stronghold and is paid once independently of gathered spice', () => {
for (const advanced of [false, true]) {
let g = fixture();
g.advanced = advanced;
g.phase = 5;
g.active = 'e';
g.players[1].forces = {};
g.players[1].reserves = 20;
g.players[0].forces = {
'arrakeen:10': 1,
'carthag:11': 1,
'tueks_sietch:5': 1,
'red_chasm:7': 1,
};
g.players[0].reserves = 16;
g.spice = { 'red_chasm:7': 8 };
const before = g.players[0].spice;
g = applyAction(g, 'e', { type: 'endMovement' });
g = applyAction(g, 'a', { type: 'endMovement' });
assert.equal(g.phase, 7);
assert.equal(g.players[0].spice, before + 3 + (advanced ? 5 : 0));
assert.equal(g.spice['red_chasm:7'], 5);
}
});
void test('the seventh battle loss unlocks KH immediately for another battle in the same turn', () => {
let g = fixture();
g.players[0].battleLosses = 6;
g.players[0].forces = { 'arrakeen:10': 1, 'carthag:11': 9 };
g.players[1].forces = { 'arrakeen:10': 10, 'carthag:11': 1 };
g.players[1].reserves = 9;
g = resolve(
plans(prepared(g), {
kwisatz: false,
leader: 'atreides-4',
dial: 1,
support: 1,
}),
);
assert.equal(g.players[0].battleLosses, 7);
assert.equal(g.phase, 6);
assert.equal(viewGame(g, 'a').players[0].kwisatz?.active, true);
g = prepared(g, 'carthag');
g = applyAction(g, 'a', {
type: 'battlePlan',
dial: 0,
leader: 'atreides-0',
kwisatz: true,
});
assert.equal(g.battle!.plans.a.kwisatz, true);
});
void test('KH territory restriction resets with the next turn and a cancellation window leaks no activation status', () => {
let g = fixture();
const before = applyAction(g, 'e', {
type: 'chooseBattle',
territory: 'arrakeen',
target: 'a',
});
g.players[0].battleLosses = 0;
const inactive = applyAction(g, 'e', {
type: 'chooseBattle',
territory: 'arrakeen',
target: 'a',
});
// Independent battles have different public event IDs, unrelated to KH state.
inactive.battle!.event = before.battle!.event;
assert.deepEqual(viewGame(before, 'e'), viewGame(inactive, 'e'));
g.players[0].kwisatz = { dead: false, usedAt: 'arrakeen' };
g.phase = 8;
g.ready = [];
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
assert.equal(g.turn, 2);
assert.equal(g.players[0].kwisatz!.usedAt, undefined);
});
void test('revealed plans provide played card labels to every seat without revealing unplayed cards', () => {
let g = fixture(true);
const shield = baseDeck().find((c) => c.kind === 'shield')!,
lasgun = baseDeck().find((c) => c.kind === 'lasgun')!,
poison = baseDeck().find((c) => c.kind === 'poison')!;
setBattleHand(g, 'a', [shield.id, poison.id]);
setBattleHand(g, 'e', [lasgun.id]);
g = prepared(g);
g = applyAction(g, 'a', {
type: 'battlePlan',
dial: 0,
leader: 'atreides-0',
defense: shield.id,
kwisatz: true,
});
assert.deepEqual(viewGame(g, 'e').battle!.cards, []);
g = applyAction(g, 'e', {
type: 'battlePlan',
dial: 0,
leader: 'emperor-0',
weapon: lasgun.id,
});
const cards = viewGame(g, 'g').battle!.cards;
assert.deepEqual(viewGame(g, 'e').battle!.cards, cards);
assert.deepEqual(viewGame(g, 'a').battle!.cards, cards);
assert.deepEqual(
new Set(cards.map((c) => c.id)),
new Set([shield.id, lasgun.id]),
);
assert.equal(viewGame(g, 'e').players[0].hand, undefined);
});