Files
Dune/tests/kwisatz.test.ts
T
Quintine 66c41b2af5 Add Kwisatz Haderach component inspection
Connect an original companion face and enlarged rules to the private Atreides panel and battle composer, authorized full-plan inspection, public revealed plans and an internal reference topic. Private loss progress and availability come only from the existing owner projection; plan components receive only inclusion. Rules calculations, save shape, AI strategy and mode gates remain unchanged.

Add focused projection, sealed/revealed rendering, JSON restoration, private-status and asset regressions. All 4950 offline tests, types, lint and production build pass. Independent privacy/rules review found no material findings. Browser checks verify reference opening, loaded artwork, Escape/Close focus return, mobile bounds and keyboard scrolling to the final rule. The focusable scroll region has a documented narrow lint exception. Earlier image-convention and focusability lint failures are retained in private evidence. HTTP tests are unnecessary for this presentation-only change.

All 1373 rooms, 2883 seats and original auxiliary rows remain unchanged; SQLite integrity and foreign keys pass. Existing server reused without restart. Screenshot capture, complete live Advanced browser acceptance and final manufactured-component/art verification remain open. No rules gate is opened by this checkpoint.

Private evidence: 20260921-kwisatz-inspection.
Verified source tree: 1181471eed47b86390882999a2ffce0db6a44b1379c7e8f7fc406bef39a94179
2026-09-21 09:00:51 +10:00

414 lines
16 KiB
TypeScript
Raw 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, 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);
});