Files
Dune/tests/completed-action-events.test.ts
T
Quintine a3bc0ac837 Show completed core actions and summarize notice bursts
Tag committed ordinary movement, shipping, revival, collection, battle,
native Guild transport and HMS relocation for the existing house notices
and optional sounds. Keep pending and canceled actions free of false
completion notices and preserve richer Smuggler and card-specific labels.

Bound feedback to three 1.5-second notices including the active notice.
Summarize older pending events with an exact count and retain individual
chronicle entries. Consume stale, duplicate, muted and hidden updates
without replay; clear visible and queued feedback when the page hides.
These changes add log metadata and cosmetic presentation only.

Validation: types, lint, 5137 offline tests and production build passed.
Independent review resolved one duplicate Smuggler notice. Nine engine
regressions and six queue/cursor/component tests cover the new behavior.
Two Basic/Advanced four-player games completed with 821 legal actions,
21 JSON restores and zero rejected actions; no AI tuning was done.

Browser QA 76CMLL8Z used genuine Basic setup and private real-action batches.
A seven-event burst displayed Revival plus four earlier actions, then
drained. Chronicle navigation and silent toggle/refresh were checked;
refresh kept the saved row identical. Phone notice bounds fit without
overflow. Transient phone text and actual background visibility were not
captured; screenshot and full visual acceptance remain open.
All 1,585 prior rooms, 3,353 seats and auxiliary records stayed unchanged.
One QA room and human seat were added; no reset, restart or automation.
2026-09-21 16:06:35 +10:00

240 lines
12 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyAction, createGame, initializeBaseGameForAudit, joinGame, newPlayer,
normalizeAutomaticGame, viewGame, type Action, type Game,
} from '../game/engine';
import { mobileRoutes } from '../game/board';
import { completedChoamSkillsGame, assertChoamSkillsCustody } from './choam-skills-fixture';
import { completedIxSkillsGame, assertIxSkillsCustody } from './ix-skills-fixture';
import { smugglerShipmentGame } from './smuggler-shipment-fixture';
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
const player = (game: Game, id: string) => game.players.find(candidate => candidate.id === id)!;
const act = (game: Game, owner: string, action: Action) => reload(applyAction(reload(game), owner, action));
const lastSeq = (game: Game) => game.log.at(-1)?.seq ?? 0;
const notices = (game: Game, name: string, since = 0) =>
game.log.filter(entry => entry.seq > since && entry.automatic?.name === name);
/** Genuine setup; tests stage only later conserved positions and eligible Tanks. */
function base(advanced = false) {
let game = createGame('ACTIONNOTICE', newPlayer('a', 'Atreides', 'atreides'), advanced);
joinGame(game, newPlayer('g', 'Guild', 'guild'));
for (const p of game.players) game = act(game, p.id, { type: 'ready' });
game = initializeBaseGameForAudit(game);
for (const p of game.players)
game = act(game, p.id, { type: 'traitor', leader: player(game, p.id).traitorChoices[0] });
assert.equal(game.status, 'playing');
return game;
}
function phase(game: Game, phase: number, active: string | null = null) {
Object.assign(game, { phase, active, storm: 18, ready: [], response: null, decision: null,
phaseOpening: null, movementRemaining: active ? [active, ...game.order.filter(id => id !== active)] : null });
for (const p of game.players) {
game.deck.push(...p.hand); p.hand = []; p.spice = 20;
p.shipped = false; p.moved = 0;
}
return game;
}
function hold(game: Game, owner: string, effect: string) {
const index = game.deck.findIndex(card => card.effect === effect);
assert.ok(index >= 0);
const card = game.deck.splice(index, 1)[0];
player(game, owner).hand.push(card);
return card.id;
}
function allow(game: Game) {
for (let step = 0; game.response && step < 20; step++) {
const voter = game.players.find(candidate => !game.response!.passed.includes(candidate.id))!;
game = act(game, voter.id, { type: 'passResponse' });
}
assert.equal(game.response, null);
return game;
}
function stable(game: Game) {
assert.deepEqual(reload(normalizeAutomaticGame(reload(game))), game);
for (const p of game.players)
assert.equal(p.reserves + p.tanks + Object.values(p.forces).reduce((sum, n) => sum + n, 0), 20);
}
function published(game: Game, name: string, faction: string, since: number) {
const entries = notices(game, name, since);
assert.equal(entries.length, 1, `${name} is emitted once for the committed action.`);
assert.deepEqual(entries[0].automatic, { faction, name });
for (const p of game.players) {
const view = viewGame(game, p.id);
assert.deepEqual(view.log.find(entry => entry.seq === entries[0].seq)?.automatic, entries[0].automatic);
for (const other of view.players.filter(candidate => candidate.id !== p.id))
assert.equal(other.hand, undefined);
}
stable(game);
assert.equal(notices(reload(normalizeAutomaticGame(reload(game))), name, since).length, 1);
}
void test('ordinary shipment completion waits for Guild interception and a stopped shipment has no completion notice', () => {
const initial = phase(base(true), 5, 'a');
const karama = hold(initial, 'g', 'karama');
const start = lastSeq(initial);
const pending = act(initial, 'a', { type: 'ship', territory: 'imperial_basin', sector: 10, amount: 2 });
assert.equal(pending.decision?.kind, 'guildShipment');
assert.equal(notices(pending, 'Shipment', start).length, 0);
assert.equal(player(pending, 'a').forces['imperial_basin:10'] ?? 0, 0);
stable(pending);
const committed = act(pending, 'g', { type: 'decision', allow: true });
assert.equal(player(committed, 'a').forces['imperial_basin:10'], 2);
published(committed, 'Shipment', 'atreides', start);
const stopped = act(pending, 'g', { type: 'card', card: karama, mode: 'special' });
assert.equal(notices(stopped, 'Shipment', start).length, 0);
assert.equal(player(stopped, 'a').forces['imperial_basin:10'] ?? 0, 0);
stable(stopped);
});
void test('ordinary movement and native Guild transport publish their committed faction without private detail', () => {
const initial = phase(base(), 5, 'a');
Object.assign(player(initial, 'a'), { forces: { 'imperial_basin:10': 3 }, reserves: 17, tanks: 0 });
const start = lastSeq(initial);
const moved = act(initial, 'a', { type: 'move', forces: { 'imperial_basin:10': 2 }, territory: 'arrakeen', sector: 10 });
assert.equal(player(moved, 'a').forces['arrakeen:10'], 2);
published(moved, 'Movement', 'atreides', start);
const guild = phase(base(), 5, 'g');
Object.assign(player(guild, 'g'), { forces: { 'imperial_basin:10': 3 }, reserves: 17, tanks: 0 });
const guildStart = lastSeq(guild);
const transported = act(guild, 'g', { type: 'guildShip', forces: { 'imperial_basin:10': 2 }, territory: 'reserves' });
assert.equal(player(transported, 'g').reserves, 19);
published(transported, 'Guild transport', 'guild', guildStart);
});
void test('successful force, ordinary leader and Kwisatz revivals each publish one completion notice', () => {
for (const kind of ['forces', 'leader', 'kwisatz'] as const) {
const initial = phase(base(kind === 'kwisatz'), 4);
const owner = player(initial, 'a');
if (kind === 'forces') Object.assign(owner, { reserves: 17, tanks: 3, forces: {} });
else {
for (const leader of owner.leaders) { leader.dead = true; leader.deaths = 1; }
if (kind === 'kwisatz') owner.kwisatz = { dead: true, revivalCycle: 1 };
}
const start = lastSeq(initial);
const action: Action = kind === 'forces' ? { type: 'revive', amount: 2 }
: kind === 'leader' ? { type: 'reviveLeader', leader: owner.leaders[0].id }
: { type: 'reviveKwisatz' };
const revived = act(initial, 'a', action);
if (kind === 'forces') assert.equal(player(revived, 'a').tanks, 1);
else if (kind === 'leader') assert.equal(player(revived, 'a').leaders[0].dead, false);
else assert.equal(player(revived, 'a').kwisatz!.dead, false);
published(revived, 'Revival', 'atreides', start);
}
});
void test('an interruptible CHOAM revival announces only commitment and not an unaffordable canceled benefit', () => {
const initial = phase(completedChoamSkillsGame({ requestedSkill: 'warmaster' }), 4);
Object.assign(player(initial, 'c'), { forces: {}, reserves: 15, tanks: 5, spice: 3 });
const karama = hold(initial, 'e', 'karama');
const start = lastSeq(initial);
const pending = act(initial, 'c', { type: 'revive', amount: 3 });
assert.equal(pending.response?.kind, 'choamRevival');
assert.equal(notices(pending, 'Revival', start).length, 0);
assert.equal(player(pending, 'c').tanks, 5);
stable(pending);
const committed = allow(pending);
assert.equal(player(committed, 'c').tanks, 2);
published(committed, 'Revival', 'choam', start);
const canceled = act(pending, 'e', { type: 'card', card: karama, mode: 'cancel' });
assert.equal(player(canceled, 'c').tanks, 5);
assert.equal(player(canceled, 'c').spice, 3);
assert.equal(notices(canceled, 'Revival', start).length, 0);
assertChoamSkillsCustody(canceled);
stable(canceled);
});
void test('ordinary collection emits only for an actual positive desert collection', () => {
for (const amount of [0, 5]) {
let game = phase(base(), 5, 'a');
for (const p of game.players) Object.assign(p, { forces: {}, reserves: 20, tanks: 0 });
Object.assign(player(game, 'a'), { forces: { 'wind_pass:14': 2 }, reserves: 18 });
game.spice = amount ? { 'wind_pass:14': amount } : {};
// Finish the real movement turns; the empty Battle phase enters collection.
const start = lastSeq(game);
for (let step = 0; game.phase === 5 && step < 10; step++) {
if (game.decision?.kind === 'guildTiming')
game = act(game, game.decision.player, { type: 'decision', take: true });
else game = act(game, game.active!, { type: 'endMovement' });
}
assert.equal(game.phase, 7);
assert.equal(notices(game, 'Movement', start).length, 0);
if (amount) {
assert.equal(player(game, 'a').spice, 24);
published(game, 'Spice collection', 'atreides', start);
} else {
assert.equal(notices(game, 'Spice collection', start).length, 0);
stable(game);
}
}
});
void test('ordinary battle result emits once after both Traitor calls and names only the winning faction', () => {
let game = phase(base(), 6, 'a');
for (const p of game.players) Object.assign(p, { forces: { 'wind_pass:14': 6 }, reserves: 14, tanks: 0 });
game.spice = {};
const start = lastSeq(game);
game = act(game, 'a', { type: 'chooseBattle', territory: 'wind_pass', target: 'g' });
for (let step = 0; game.battle?.preparation && step < 10; step++)
game = act(game, game.battle.preparation.owner, { type: 'declineBattlePower' });
game = act(game, 'a', { type: 'battlePlan', dial: 2, leader: player(game, 'a').leaders[0].id });
game = act(game, 'g', { type: 'battlePlan', dial: 0, leader: player(game, 'g').leaders[4].id });
game = act(game, 'a', { type: 'traitorCall', call: false });
assert.equal(notices(game, 'Battle', start).length, 0);
game = act(game, 'g', { type: 'traitorCall', call: false });
assert.equal(game.lastBattleContext!.winner, 'a');
published(game, 'Battle', 'atreides', start);
});
void test('native HMS relocation announces only the allowed move and not its Karama declaration or cancellation', () => {
let game = phase(completedIxSkillsGame({ requestedSkill: 'warmaster' }), 8);
const karama = hold(game, 'e', 'karama');
for (const p of game.players) game = act(game, p.id, { type: 'ready' });
while (game.phaseOpening) {
const voter = game.players.find(candidate => !game.phaseOpening!.passed.includes(candidate.id))!;
game = act(game, voter.id, { type: 'ready' });
}
assert.equal(game.decision?.kind, 'mobileStronghold');
const route = mobileRoutes(game, 3).find(candidate => candidate.length > 1)!;
assert.ok(route);
const start = lastSeq(game);
const pending = act(game, 'i', { type: 'decision', route });
assert.equal(pending.response?.kind, 'mobileStronghold');
assert.equal(notices(pending, 'Hidden Mobile Stronghold', start).length, 0);
const moved = allow(pending);
assert.equal(moved.mobileStronghold!.location, route.at(-1));
published(moved, 'Hidden Mobile Stronghold', 'ixians', start);
const canceled = act(pending, 'e', { type: 'card', card: karama, mode: 'cancel' });
assert.equal(canceled.mobileStronghold!.location, route[0]);
assert.equal(notices(canceled, 'Hidden Mobile Stronghold', start).length, 0);
assertIxSkillsCustody(canceled);
stable(canceled);
});
void test('an existing named Ghola completion retains its richer notice rather than receiving generic revival metadata', () => {
const initial = phase(base(), 4);
const owner = player(initial, 'a');
owner.leaders[0].dead = true; owner.leaders[0].deaths = 1;
const card = hold(initial, 'a', 'ghola');
const start = lastSeq(initial);
const revived = act(initial, 'a', { type: 'card', card, leader: owner.leaders[0].id });
published(revived, 'Ghola revival', 'atreides', start);
assert.equal(notices(revived, 'Revival', start).length, 0);
});
void test('Smuggler shipment retains its richer single completion notice without an additional generic shipment', () => {
const initial = smugglerShipmentGame();
const start = lastSeq(initial);
const shipped = act(initial, 'p', { type: 'ship', territory: 'arrakeen', sector: 10, amount: 3, smuggler: true });
assert.equal(player(shipped, 'p').reserves, 17);
assert.equal(player(shipped, 'p').forces['arrakeen:10'], 3);
published(shipped, 'Smuggler shipment', 'emperor', start);
assert.equal(notices(shipped, 'Shipment', start).length, 0);
});