Honor allied Fremen victory and prevent deferred shipment deadlocks

Reciprocal allied Ecaz and Fremen with fighters in Sietch Tabr now satisfy that part of the Fremen final-turn victory, as explicitly clarified by the GF9 Ecaz/Moritani p15 FAQ. One public board quote drives automatic victory and the table guide; the chronicle explains the result. Other blockers, ordinary stronghold/BG prediction precedence, Guild fallback, advisor posture and concealed marker privacy remain intact. Habbanya symmetry and full mode/publication acceptance remain unfinished.

A genuine Advanced sample also exposed a pre-existing accepted Guild shipment which could never finish: allowance opened BG accompaniment and then hit the Ambassador arrival guard. Prior HEAD replay reproduced the exact 90-action failure. Fresh shipment preflight now rejects unsupported arrival combinations before accepting their pending windows. A valid legacy ordinary Guild allowance can return its unspent declaration to choose another destination; special parent/card receipts retain their separate gates. The change does not invent simultaneous reaction order or refund spent cards.

Independent rules/privacy/persistence review and focused engine/controls tests pass. Seventeen Fremen/Ecaz tests include all four profiles and authenticated final readiness/restart/concurrent writes. Shipment regressions cover pre-declaration immutability, supported arrival, malformed saved declarations and authenticated unspent return/replacement/reconnect/concurrency. All 4716 offline tests, types, lint, production build and 43 HTTP tests passed against unchanged source.

The original fixed seeds were repeated after repair and the exact failed snapshot resumed with documented new continuation seed 202609147: resume-advanced-202609147: 842 accepted, 109 guarded rejections, 22 periodic JSON restores; genuine-basic-202609145: 563 accepted, 14 guarded rejections, 15 periodic JSON restores; genuine-advanced-202609146: 806 accepted, 81 guarded rejections, 21 periodic JSON restores. All three finish and preserve physical custody/private views. These samples do not establish naturally reached allied Fremen special wins; a separate conserved browser final-turn fixture verifies the actual Ready-to-win transition, public guide/log, private hand and pending/finished refresh.

All 835 opening games and original seat/recovery records remain unchanged (902 after checks). All 869 rooms and records present after the reported outage also remain unchanged; database integrity and foreign keys pass. The healthy server and finished browser seat recovered without restart, reset or recurring automation. Full rules coverage and release gates remain open work.

Verified source tree: 1d9fcf30b54132f66be5f994f410dec6ec019683ee66b24fd4efb7a6aa7f7bbd
This commit is contained in:
Quintine committed 2026-09-14 02:59:50 +10:00
1 parent 58ae84f8e2
commit 9ca17a66a0
19 files changed
+1333 -31

No files matched your search

@@ -0,0 +1,134 @@
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import type { DatabaseSync } from 'node:sqlite';
import test from 'node:test';
import { viewGame, type Game } from '../game/engine';
import type { RoomsClock, SeatAuth } from '../db/rooms';
import { unitStore } from './fixture-nexus-room-store';
import { legacyGuildArrival } from './fixture-deferred-shipment-arrival';
const clock: RoomsClock = { now: () => 49000, sleep: async () => {} };
const allow = { type: 'decision', allow: true };
const hash = (s: string) => createHash('sha256').update(s).digest('hex');
const rows = (db: DatabaseSync) => ({
rooms: db.prepare('SELECT * FROM rooms ORDER BY code').all(),
seats: db.prepare('SELECT * FROM seats ORDER BY player_id').all(),
});
const custody = (g: Game) => ({
players: g.players,
deck: g.deck,
discard: g.discard,
richeseCache: g.richeseCache,
spice: g.spice,
traitorReserve: g.traitorReserve,
ambassadors: g.ecazAmbassadors,
terror: g.moritaniTerror,
nexus: g.nexusCards,
});
async function fixture(t: test.TestContext) {
const store = unitStore();
t.after(() => store.sqlite.close());
const pending = legacyGuildArrival();
for (const player of pending.players) delete player.bot;
pending.version = 31;
const code = pending.code;
store.sqlite.prepare('INSERT INTO rooms(code,state,version,updated_at) VALUES(?,?,?,?)')
.run(code, JSON.stringify(pending), pending.version, 48000);
const tokens: Record<string, string> = {};
const auths: Record<string, SeatAuth> = {};
for (const player of pending.players) {
tokens[player.id] = hash('deferred-shipment:' + player.id);
store.sqlite.prepare('INSERT INTO seats(token_hash,room_code,player_id) VALUES(?,?,?)')
.run(hash(tokens[player.id]), code, player.id);
auths[player.id] = await store.rooms.authenticate(code, tokens[player.id]);
}
const guild = pending.players.find((p) => p.faction === 'guild')!.id;
return { ...store, pending, code, tokens, auths, guild };
}
async function restoredViews(f: Awaited<ReturnType<typeof fixture>>, game: Game) {
const before = rows(f.sqlite);
for (const [id, token] of Object.entries(f.tokens)) {
const rooms = f.restart();
const auth = await rooms.authenticate(f.code, token);
assert.equal(auth.playerId, id);
const view = await rooms.readSeatView(f.code, auth);
assert.deepEqual(view, viewGame(game, id));
assert.equal('pendingShipment' in view, false);
for (const other of view.players.filter((p) => p.id !== id))
for (const field of ['hand', 'traitors', 'spice', 'prediction'])
assert.equal(field in other, false);
}
assert.deepEqual(rows(f.sqlite), before);
}
void test('authenticated legacy Guild allowance returns an unsupported arrival once and keeps the private seat and shipment available after restart', async (t) => {
const f = await fixture(t);
const original = rows(f.sqlite), resources = structuredClone(custody(f.pending));
await restoredViews(f, f.pending);
const other = f.pending.players.find((p) => p.id !== f.guild)!.id;
await assert.rejects(f.rooms.act(f.code, f.auths[other], 31, allow, clock));
await assert.rejects(f.rooms.act(f.code, f.auths[f.guild], 30, allow, clock));
await assert.rejects(f.rooms.act(f.code, f.auths[f.guild], 31, { type: 'decision', allow: false }, clock));
assert.deepEqual(rows(f.sqlite), original);
assert.equal(f.writes.length, 0);
await f.restart().act(f.code, f.auths[f.guild], 31, allow, clock);
const returned = await f.restart().readRoom(f.code);
assert.equal(returned.version, 32);
assert.equal(returned.decision, null);
assert.equal(returned.pendingShipment, null);
assert.equal(returned.active, f.pending.active);
assert.deepEqual(custody(returned), resources);
assert.match(returned.log.at(-1)!.text, /uncommitted shipment.*returned/i);
assert.equal(f.writes.length, 1);
await restoredViews(f, returned);
const saved = rows(f.sqlite);
await assert.rejects(f.restart().act(f.code, f.auths[f.guild], 31, allow, clock));
await assert.rejects(f.restart().act(f.code, f.auths[f.guild], 32, allow, clock));
assert.deepEqual(rows(f.sqlite), saved);
await f.restart().act(f.code, f.auths[f.pending.active!], 32,
{ type: 'ship', territory: 'polar_sink', sector: 0, amount: 1 }, clock);
const alternative = await f.restart().readRoom(f.code);
assert.equal(alternative.decision?.kind, 'guildShipment');
assert.equal(alternative.pendingShipment?.territory, 'polar_sink');
await f.restart().act(f.code, f.auths[f.guild], alternative.version, allow, clock);
const arrived = await f.restart().readRoom(f.code);
assert.equal(arrived.pendingShipment, null);
assert.equal(arrived.players.find((p) => p.id === f.pending.active)!.shipped, true);
assert.equal(arrived.players.find((p) => p.id === f.pending.active)!.forces['polar_sink:0'], 1);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(), original.seats);
await restoredViews(f, arrived);
});
void test('concurrent saved Guild allowances persist exactly one unspent return', async (t) => {
const f = await fixture(t);
let entered = 0, release!: () => void;
const both = new Promise<void>((resolve) => { release = resolve; });
f.hooks.beforeWrite = async () => { if (++entered === 2) release(); await both; };
const results = await Promise.allSettled([f.rooms, f.restart()].map((rooms) =>
rooms.act(f.code, f.auths[f.guild], 31, allow, clock)));
delete f.hooks.beforeWrite;
assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
assert.deepEqual(f.writes.map((w) => w.changes).sort((a, b) => a - b), [0, 1]);
const returned = await f.restart().readRoom(f.code);
assert.equal(returned.version, 32);
assert.equal(returned.decision, null);
assert.equal(returned.pendingShipment, null);
assert.deepEqual(custody(returned), custody(f.pending));
assert.equal(returned.log.filter((entry) => /uncommitted shipment.*returned/i.test(entry.text)).length, 1);
await restoredViews(f, returned);
});
void test('saved declaration corruption is rejected without returning or rewriting the pending shipment', async (t) => {
const f = await fixture(t);
const corrupt = structuredClone(f.pending);
corrupt.pendingShipment!.amount++;
f.sqlite.prepare('UPDATE rooms SET state=? WHERE code=?').run(JSON.stringify(corrupt), f.code);
const before = rows(f.sqlite);
await assert.rejects(f.restart().act(f.code, f.auths[f.guild], 31, allow, clock), /does not match/);
assert.deepEqual(rows(f.sqlite), before);
assert.equal(f.writes.length, 0);
});
+125
View File
@@ -0,0 +1,125 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, type Action, type Game } from '../game/engine';
import {
addShipmentArrivalAmbassador,
legacyGuildArrival,
richeseNoFieldArrivalAction,
shipmentArrivalAction,
shipmentArrivalGame,
shipmentArrivalPlayer,
shipmentArrivalReload,
shipmentArrivalResources,
} from './fixture-deferred-shipment-arrival';
const overlap = /Ambassadors combined with another arrival reaction/;
function reject(g: Game, player: string, action: Action, pattern: RegExp) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, player, action), pattern);
assert.deepEqual(g, before);
}
void test('an ordinary Guild shipment rejects a known Ambassador and advisor overlap before declaration or payment', (t) => {
const g = shipmentArrivalGame();
addShipmentArrivalAmbassador(g);
const resources = structuredClone(shipmentArrivalResources(g));
const uuid = t.mock.method(crypto, 'randomUUID', () => {
throw Error('Unsupported shipment allocated an event');
});
reject(g, 'guild', shipmentArrivalAction, overlap);
assert.deepEqual(shipmentArrivalResources(g), resources);
assert.equal(g.pendingShipment, null);
assert.equal(g.decision, null);
assert.equal(uuid.mock.callCount(), 0);
});
void test('an own Richese No-Field rejects the unsupported arrival before saving its private cancellation response', () => {
const g = shipmentArrivalGame();
g.active = 'richese';
shipmentArrivalPlayer(g, 'richese').shipped = false;
addShipmentArrivalAmbassador(g);
const action = richeseNoFieldArrivalAction(g);
const before = structuredClone(g);
assert.throws(() => applyAction(g, 'richese', action), overlap);
assert.deepEqual(g, before);
assert.equal(g.pendingShipment, null);
assert.equal(g.response, null);
assert.equal(g.decision, null);
assert.equal(shipmentArrivalPlayer(g, 'richese').noField!.deployed, null);
});
void test('a saved Guild allowance returns only the uncommitted ordinary shipment and permits a safe replacement', () => {
const g = legacyGuildArrival();
const before = structuredClone(g);
const resources = structuredClone(shipmentArrivalResources(g));
const returned = applyAction(shipmentArrivalReload(g), 'guild', {
type: 'decision',
allow: true,
});
assert.deepEqual(g, before);
assert.deepEqual(shipmentArrivalResources(returned), resources);
assert.equal(returned.pendingShipment, null);
assert.equal(returned.decision, null);
assert.equal(shipmentArrivalPlayer(returned, 'guild').shipped, false);
assert.deepEqual(shipmentArrivalPlayer(returned, 'guild').forces, {});
assert.match(returned.log.at(-1)!.text, overlap);
assert.match(
returned.log.at(-1)!.text,
/No forces, spice, cards or shipment allowance were spent/,
);
const retried = applyAction(returned, 'guild', {
...shipmentArrivalAction,
territory: 'polar_sink',
sector: 0,
});
assert.equal(retried.decision?.kind, 'guildShipment');
assert.equal(retried.pendingShipment?.territory, 'polar_sink');
});
void test('a supported Guild allowance still commits once and opens the advisor choice', () => {
let g = shipmentArrivalGame();
g = applyAction(g, 'guild', shipmentArrivalAction);
assert.equal(g.decision?.kind, 'guildShipment');
g = applyAction(shipmentArrivalReload(g), 'guild', {
type: 'decision',
allow: true,
});
assert.deepEqual(shipmentArrivalPlayer(g, 'guild').forces, {
'carthag:11': 4,
});
assert.equal(shipmentArrivalPlayer(g, 'guild').reserves, 16);
assert.equal(shipmentArrivalPlayer(g, 'guild').shipped, true);
assert.equal(g.pendingShipment, null);
assert.equal(g.decision?.kind, 'advisor');
assert.equal(g.decision?.player, 'bg');
});
void test('a malformed saved shipment remains rejected and is not treated as a recoverable arrival overlap', () => {
const g = legacyGuildArrival();
g.pendingShipment!.cost++;
reject(
g,
'guild',
{ type: 'decision', allow: true },
/declared shipment price is no longer current/,
);
});
void test('forged or null special receipts cannot enter the ordinary legacy return path', () => {
for (const corrupt of [
(g: Game) => {
g.pendingShipment!.ambassadorEvent = 'forged';
},
(g: Game) => {
g.pendingShipment!.guildSecretEvent = null as unknown as string;
},
]) {
const g = legacyGuildArrival();
corrupt(g);
reject(g, 'guild', { type: 'decision', allow: true }, overlap);
assert.ok(g.pendingShipment);
assert.equal(g.decision?.kind, 'guildShipment');
}
});
+168
View File
@@ -0,0 +1,168 @@
import assert from 'node:assert/strict';
import {
applyAction,
createGame,
initializeFactionExpansionsGameForAudit,
joinGame,
newPlayer,
viewGame,
type Action,
type Game,
} from '../game/engine';
import { botActions } from '../game/bots';
import { createAmbassadors, placeAmbassador } from '../game/ecaz-ambassadors';
import {
arrivalPlayer,
arrivalReload,
} from './deferred-movement-arrival-fixture';
export const shipmentArrivalAction: Action = {
type: 'ship',
territory: 'carthag',
sector: 11,
amount: 4,
elite: 0,
allyPayment: 0,
};
export const shipmentArrivalReload = arrivalReload;
export const shipmentArrivalPlayer = arrivalPlayer;
export function addShipmentArrivalAmbassador(g: Game) {
const state = createAmbassadors(() => 0.2);
const token = state.tokens.find((candidate) => candidate.effect === 'emperor');
assert.ok(token);
state.cohort = [
token.id,
...state.tokens
.filter((candidate) => candidate.effect !== 'ecaz' && candidate.id !== token.id)
.slice(0, 4)
.map((candidate) => candidate.id),
];
for (const candidate of state.tokens) {
candidate.zone =
candidate.effect === 'ecaz' || state.cohort.includes(candidate.id)
? 'supply'
: 'pool';
candidate.location = null;
}
g.ecazAmbassadors = placeAmbassador(state, token.id, {
turn: g.turn,
availableSpice: 20,
destination: {
id: 'carthag',
stronghold: true,
inStorm: false,
allowed: true,
},
}).state;
}
/**
* Real expansion inventories reduced to the saved Advanced Guild arrival case.
* `legacyGuildArrival` adds the second arrival reaction only after the ordinary
* Guild decision has been persisted, matching the preflight-free save shape.
*/
export function shipmentArrivalGame(): Game {
let g = createGame('SHIPARRY', newPlayer('ecaz', 'Ecaz', 'ecaz'), true, [
'ecaz',
'choam',
]);
for (const [id, name, faction] of [
['guild', 'Guild', 'guild'],
['bg', 'Bene Gesserit', 'beneGesserit'],
['moritani', 'Moritani', 'moritani'],
['richese', 'Richese', 'richese'],
] as const)
joinGame(g, newPlayer(id, name, faction));
for (const player of g.players) g = applyAction(g, player.id, { type: 'ready' });
g = initializeFactionExpansionsGameForAudit(g);
for (let step = 0; g.status === 'setup' && step < 30; step++) {
let next: Game | undefined;
for (const player of g.players) {
const view = viewGame(g, player.id);
view.players.find((candidate) => candidate.id === view.me)!.bot = 'Medium';
const action = botActions(view)[0];
if (action) {
next = applyAction(g, player.id, action);
break;
}
}
assert.ok(next);
g = next;
}
assert.equal(g.status, 'playing');
Object.assign(g, {
turn: 3,
phase: 5,
active: 'guild',
storm: 18,
ready: [],
decision: null,
response: null,
phaseOpening: null,
stormPending: null,
});
for (const player of g.players) {
g.deck.push(...player.hand);
player.hand = [];
player.forces = {};
player.reserves = 20;
player.tanks = 0;
player.spice = 20;
player.shipped = player.id !== 'guild';
player.moved = 0;
player.bot = undefined;
if (player.elites) {
player.elites.forces = {};
player.elites.reserves = 3;
player.elites.tanks = 0;
}
}
const bg = arrivalPlayer(g, 'bg');
bg.advisors = {};
const guild = arrivalPlayer(g, 'guild');
g.active = guild.id;
g.order = g.players.map((player) => player.id);
g.movementRemaining = [...g.order];
g.decision = null;
g.response = null;
g.pendingShipment = null;
return g;
}
export function legacyGuildArrival(): Game {
let g = shipmentArrivalGame();
g = applyAction(g, 'guild', shipmentArrivalAction);
assert.equal(g.decision?.kind, 'guildShipment');
assert.ok(g.pendingShipment);
addShipmentArrivalAmbassador(g);
return shipmentArrivalReload(g);
}
export function richeseNoFieldArrivalAction(g: Game): Action {
const richese = arrivalPlayer(g, 'richese');
assert.ok(richese.noField);
return {
type: 'ship',
noField: richese.noField.tokens[0].id,
event: richese.noFieldEvent,
territory: 'carthag',
sector: 11,
};
}
export function shipmentArrivalResources(g: Game) {
return {
players: g.players,
deck: g.deck,
discard: g.discard,
spice: g.spice,
cache: g.richeseCache,
ambassadors: g.ecazAmbassadors,
terror: g.moritaniTerror,
phase: g.phase,
turn: g.turn,
active: g.active,
};
}
+67
View File
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import { applyAction, createGame, initializeFactionExpansionsGameForAudit, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import type { Difficulty } from '../game/bot-profiles';
/** Genuine setup followed by a conserved, explicitly staged final Collection
* position. This proves the endgame path, not naturally reached co-occupation. */
export function fremenEcazFinalTurn(advanced = true, ids = ['ec', 'fr', 'gu', 'bg']) {
let g = createGame('FREMENQA', newPlayer(ids[0], 'Ecaz', 'ecaz'), advanced, ['ecaz']);
for (const [index, faction] of (['fremen', 'guild', 'beneGesserit'] as const).entries())
joinGame(g, newPlayer(ids[index + 1], faction, faction));
for (const p of g.players) p.ready = true;
g = initializeFactionExpansionsGameForAudit(g);
for (let i = 0; g.status === 'setup' && i < 80; i++) {
const next = g.players.flatMap((p) => {
const view = viewGame(g, p.id);
view.players.find((other) => other.id === p.id)!.bot = 'Medium';
return botActions(view).map((action) => ({ id: p.id, action }));
})[0];
assert.ok(next, 'Genuine setup must provide a legal action');
g = applyAction(g, next.id, next.action);
}
assert.equal(g.status, 'playing');
for (const p of g.players) {
p.reserves += Object.values(p.forces).reduce((a, b) => a + b, 0);
p.forces = {};
if (p.elites) {
p.elites.reserves += Object.values(p.elites.forces).reduce((a, b) => a + b, 0);
p.elites.forces = {};
}
if (p.advisors) p.advisors = {};
}
for (const p of g.players.slice(0, 2)) {
p.forces['sietch_tabr:14'] = 3;
p.reserves -= 3;
p.ally = g.players.find((other) => other.id !== p.id && ['ecaz', 'fremen'].includes(other.faction))!.id;
p.allySinceTurn = 9;
}
g.turn = 10; g.phase = 7; g.storm = 18; g.ready = []; g.active = null;
g.order = g.players.map((p) => p.id); g.phaseOpening = null;
g.players[3].prediction = { faction: 'fremen', turn: 10 };
for (const p of g.players) viewGame(g, p.id);
return g;
}
export function finishFremenEcazTurn(state: Game, profile: Difficulty = 'Medium') {
let g = JSON.parse(JSON.stringify(state)) as Game;
for (let i = 0; g.status === 'playing' && i < 30; i++) {
const next = g.players.flatMap((p) => {
const view = viewGame(g, p.id);
view.players.find((other) => other.id === p.id)!.bot = profile;
return botActions(view).map((action) => ({ id: p.id, action }));
})[0];
assert.ok(next, 'Final-turn continuation must have a legal action');
g = applyAction(JSON.parse(JSON.stringify(g)), next.id, next.action);
}
assert.equal(g.status, 'finished');
return g;
}
export function fremenEcazCustody(g: Game) {
return {
cards: [...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)].map((c) => c.id).sort(),
traitors: [...(g.traitorReserve ?? []), ...g.players.flatMap((p) => p.traitors)].sort(),
forces: g.players.map((p) => ({ id: p.id, reserves: p.reserves, tanks: p.tanks, forces: p.forces, elites: p.elites })),
};
}
+58
View File
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame } from '../game/engine';
import { quoteVictory } from '../game/victory-quote';
import { fremenEcazFinalTurn, finishFremenEcazTurn, fremenEcazCustody } from './fixture-fremen-ecaz-victory';
for (const advanced of [false, true])
void test(`${advanced ? 'Advanced' : 'Basic'} final-turn co-occupation wins through real readiness without BG prediction or Guild stealing it`, () => {
const g = fremenEcazFinalTurn(advanced), before = structuredClone(g);
const done = finishFremenEcazTurn(g);
assert.deepEqual(done.winner, ['fr', 'ec']);
assert.deepEqual(g, before);
assert.deepEqual(fremenEcazCustody(done), fremenEcazCustody(g));
assert.match(done.log.at(-1)!.text, /Fremen special victory.*Allied Ecaz and Fremen/);
for (const p of g.players) {
const view = viewGame(done, p.id);
assert.equal(view.fremenVictory?.qualifies, true);
assert.deepEqual(viewGame(JSON.parse(JSON.stringify(done)), p.id), view);
for (const other of view.players.filter((r) => r.id !== p.id))
for (const field of ['spice', 'hand', 'traitors', 'prediction']) assert.equal(field in other, false);
}
assert.throws(() => applyAction(done, 'fr', { type: 'ready' }));
});
void test('all four AI profiles complete the same settled qualifying board without changing cards or forces', () => {
const g = fremenEcazFinalTurn();
for (const profile of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
const done = finishFremenEcazTurn(g, profile);
assert.deepEqual(done.winner, ['fr', 'ec']);
assert.deepEqual(fremenEcazCustody(done), fremenEcazCustody(g));
}
});
void test('prospective guide does not award an early victory and a normal winner retains precedence', () => {
const g = fremenEcazFinalTurn();
g.turn = 9;
assert.equal(viewGame(g, 'ec').fremenVictory?.qualifies, true);
assert.deepEqual(quoteVictory(g).winner, []);
g.turn = 10;
const guild = g.players[2];
guild.forces = { 'arrakeen:10': 1, 'carthag:11': 1, 'tueks_sietch:5': 1 };
guild.reserves -= 3;
assert.deepEqual(finishFremenEcazTurn(g).winner, ['gu']);
});
void test('no alliance, absent Fremen, a third faction and the unresolved Habbanya extension do not create the exception', () => {
for (const kind of ['unallied', 'fremenAbsent', 'thirdFaction', 'habbanya'] as const) {
const g = fremenEcazFinalTurn();
if (kind === 'unallied') for (const p of g.players.slice(0, 2)) p.ally = null;
if (kind === 'fremenAbsent') { g.players[1].reserves += 3; g.players[1].forces = {}; }
if (kind === 'thirdFaction') { g.players[2].reserves--; g.players[2].forces['sietch_tabr:14'] = 1; }
if (kind === 'habbanya') for (const p of g.players.slice(0, 2)) p.forces = { 'habbanya_ridge_sietch:17': 3 };
assert.equal(viewGame(g, 'ec').fremenVictory?.qualifies, false);
const done = finishFremenEcazTurn(g);
assert.deepEqual(done.winner, ['gu']);
assert.doesNotMatch(done.log.at(-1)!.text, /Fremen special victory/);
}
});
@@ -0,0 +1,74 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { DatabaseSync } from 'node:sqlite';
import { viewGame, type Game } from '../game/engine';
import type { RoomsClock } from '../db/rooms';
import { unitStore } from './fixture-nexus-room-store';
import { fremenEcazFinalTurn, fremenEcazCustody } from './fixture-fremen-ecaz-victory';
const clock: RoomsClock = { now: () => 42000, sleep: async () => {} };
const savedRows = (sqlite: DatabaseSync) => sqlite.prepare('SELECT state, version FROM rooms').all();
async function fixture(t: test.TestContext) {
const store = unitStore(); t.after(() => store.sqlite.close());
const made = await store.rooms.createRoom('Ecaz victory QA', 'ecaz', false, ['ecaz']);
const code = made.view.code, tokens = [made.token];
for (const faction of ['fremen', 'guild', 'beneGesserit'] as const) tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
const auths = await Promise.all(tokens.map((token) => store.rooms.authenticate(code, token)));
const g = fremenEcazFinalTurn(true, auths.map((a) => a.playerId));
g.code = code; g.version = (await store.rooms.readRoom(code)).version;
store.sqlite.prepare('UPDATE rooms SET state=?, version=? WHERE code=?').run(JSON.stringify(g), g.version, code);
for (const auth of auths.slice(0, -1)) {
const current = await store.rooms.readRoom(code);
await store.rooms.act(code, auth, current.version, { type: 'ready' }, clock);
}
const pending = await store.rooms.readRoom(code);
assert.equal(pending.phase, 7); assert.equal(pending.status, 'playing');
store.writes.length = 0;
return { ...store, code, tokens, auths, pending };
}
void test('restored final readiness awards the same allied win once and preserves private seats and physical custody', async (t) => {
const f = await fixture(t), before = savedRows(f.sqlite);
const seatRows = f.sqlite.prepare('SELECT * FROM seats').all();
for (const [i, token] of f.tokens.entries()) {
const rooms = f.restart(), auth = await rooms.authenticate(f.code, token);
assert.deepEqual(await rooms.readSeatView(f.code, auth), viewGame(f.pending, f.auths[i].playerId));
}
assert.deepEqual(savedRows(f.sqlite), before);
const rooms = f.restart(), auth = await rooms.authenticate(f.code, f.tokens[3]);
await rooms.act(f.code, auth, f.pending.version, { type: 'ready' }, clock);
const done = await f.restart().readRoom(f.code);
assert.equal(done.status, 'finished');
assert.deepEqual(done.winner, [f.auths[1].playerId, f.auths[0].playerId]);
assert.deepEqual(fremenEcazCustody(done), fremenEcazCustody(f.pending));
assert.equal(done.log.filter((entry) => entry.text.includes('Fremen special victory')).length, 1);
const resolvedRows = savedRows(f.sqlite);
for (const [i, token] of f.tokens.entries()) {
const restored = f.restart(), seat = await restored.authenticate(f.code, token);
const view = await restored.readSeatView(f.code, seat);
assert.deepEqual(view, viewGame(done, f.auths[i].playerId));
assert.deepEqual(viewGame(JSON.parse(JSON.stringify(done)) as Game, seat.playerId), view);
for (const rival of view.players.filter((p) => p.id !== seat.playerId))
for (const key of ['hand', 'traitors', 'spice', 'prediction']) assert.equal(key in rival, false);
}
await f.restart().continueRoomAutomatic(f.code, clock);
await assert.rejects(f.restart().act(f.code, auth, f.pending.version, { type: 'ready' }, clock));
await assert.rejects(f.restart().act(f.code, auth, done.version, { type: 'ready' }, clock));
assert.deepEqual(savedRows(f.sqlite), resolvedRows);
assert.deepEqual(f.sqlite.prepare('SELECT * FROM seats').all(), seatRows);
});
void test('competing final-readiness writes cannot duplicate victory settlement', async (t) => {
const f = await fixture(t);
let entered = 0; let release!: () => void;
const both = new Promise<void>((resolve) => { release = resolve; });
f.hooks.beforeWrite = async () => { if (++entered === 2) release(); await both; };
const results = await Promise.allSettled([0, 1].map(() => f.rooms.act(f.code, f.auths[3], f.pending.version, { type: 'ready' }, clock)));
f.hooks.beforeWrite = undefined;
assert.equal(results.filter((r) => r.status === 'fulfilled').length, 1);
assert.deepEqual(f.writes.map((w) => w.changes).sort((a, b) => a - b), [0, 1]);
const done = await f.restart().readRoom(f.code);
assert.equal(done.version, f.pending.version + 1);
assert.equal(done.log.filter((entry) => entry.text.includes('Fremen special victory')).length, 1);
assert.deepEqual(fremenEcazCustody(done), fremenEcazCustody(f.pending));
});
+27
View File
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { VictoryProgress } from '../components/victory-progress';
import { viewGame } from '../game/engine';
import { fremenEcazFinalTurn } from './fixture-fremen-ecaz-victory';
void test('public final-turn guide explains shared Tabr and keeps the victory checkpoint explicit', () => {
const g = fremenEcazFinalTurn();
const views = g.players.map((p) => viewGame(g, p.id));
const html = views.map((v) => renderToStaticMarkup(<VictoryProgress progress={v.victoryProgress} players={v.players} fremen={v.fremenVictory} />));
assert.ok(html.every((page) => page === html[0]));
assert.match(html[0], /permitted allied Ecaz and Fremen co-occupation/);
assert.match(html[0], /only at the final-turn check/);
assert.match(html[0], /A Bene Gesserit prediction cannot replace/);
assert.doesNotMatch(html[0], /<button/);
});
void test('public guide identifies a blocking faction without presenting an ineligible board as a win', () => {
const g = fremenEcazFinalTurn();
g.players[2].forces = { 'habbanya_ridge_sietch:17': 1 }; g.players[2].reserves--;
const v = viewGame(g, 'ec');
const html = renderToStaticMarkup(<VictoryProgress progress={v.victoryProgress} players={v.players} fremen={v.fremenVictory} />);
assert.match(html, /blocked by Spacing Guild/);
assert.match(html, /special conditions are not met/);
});
+168
View File
@@ -0,0 +1,168 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import type { BoardContext } from '../game/board-resolution-quote';
import { fremenSpecialVictory } from '../game/fremen-victory';
import { VictoryProgressError } from '../game/victory-progress';
function fixture(advanced = false): BoardContext {
return {
advanced,
storm: 18,
order: ['x', 'f', 'e', 'r'],
players: [
{ id: 'f', faction: 'fremen', ally: 'e', forces: {} },
{ id: 'e', faction: 'ecaz', ally: 'f', forces: {} },
{ id: 'x', faction: 'beneGesserit', ally: null, forces: {} },
{ id: 'r', faction: 'richese', ally: null, forces: {} },
],
};
}
function seat(g: BoardContext, id: string) {
return g.players.find((player) => player.id === id)!;
}
function place(g: BoardContext, id: string, key: string) {
const player = seat(g, id);
player.forces = { ...player.forces, [key]: 1 };
}
void test('allied Ecaz co-occupation is ignored only at Sietch Tabr in Basic and Advanced', () => {
for (const advanced of [false, true]) {
const g = fixture(advanced);
place(g, 'f', 'sietch_tabr:14');
place(g, 'e', 'sietch_tabr:14');
const result = fremenSpecialVictory(g)!;
assert.equal(result.player, 'f');
assert.deepEqual(result.members, ['f', 'e']);
assert.equal(result.qualifies, true);
assert.deepEqual(result.sietches, [
{
territory: 'sietch_tabr',
blockers: [],
ecazCooccupation: true,
},
{
territory: 'habbanya_ridge_sietch',
blockers: [],
ecazCooccupation: false,
},
]);
assert.deepEqual(result.tueksBlockers, []);
}
});
void test('a valid board without Fremen has no special-victory prospect', () => {
const g = fixture();
seat(g, 'f').faction = 'atreides';
assert.equal(fremenSpecialVictory(g), null);
});
void test('solitary, nonallied and third-party Ecaz presence do not receive the Tabr exception', () => {
const solitary = fixture();
place(solitary, 'e', 'sietch_tabr:14');
assert.deepEqual(fremenSpecialVictory(solitary)!.sietches[0].blockers, ['e']);
const nonallied = fixture();
seat(nonallied, 'f').ally = null;
seat(nonallied, 'e').ally = null;
place(nonallied, 'f', 'sietch_tabr:14');
place(nonallied, 'e', 'sietch_tabr:14');
assert.deepEqual(fremenSpecialVictory(nonallied)!.sietches[0].blockers, [
'e',
]);
const third = fixture();
place(third, 'f', 'sietch_tabr:14');
place(third, 'e', 'sietch_tabr:14');
place(third, 'r', 'sietch_tabr:14');
assert.deepEqual(fremenSpecialVictory(third)!.sietches[0].blockers, ['r']);
assert.equal(fremenSpecialVictory(third)!.qualifies, false);
});
void test('Ecaz co-occupation at Habbanya remains a blocker', () => {
const g = fixture();
place(g, 'f', 'habbanya_ridge_sietch:17');
place(g, 'e', 'habbanya_ridge_sietch:17');
const result = fremenSpecialVictory(g)!;
assert.equal(result.sietches[1].ecazCooccupation, true);
assert.deepEqual(result.sietches[1].blockers, ['e']);
assert.equal(result.qualifies, false);
});
void test('accompanied BG advisors do not block, while mandatory lone-advisor release restores fighter presence', () => {
const accompanied = fixture(true);
place(accompanied, 'f', 'habbanya_ridge_sietch:17');
place(accompanied, 'x', 'habbanya_ridge_sietch:17');
seat(accompanied, 'x').advisors = { habbanya_ridge_sietch: {} };
assert.deepEqual(fremenSpecialVictory(accompanied)!.sietches[1].blockers, []);
const released = fixture(true);
place(released, 'x', 'sietch_tabr:14');
seat(released, 'x').advisors = { sietch_tabr: {} };
const before = structuredClone(released);
assert.deepEqual(fremenSpecialVictory(released)!.sietches[0].blockers, ['x']);
assert.deepEqual(released, before);
});
void test('Tuek blockers preserve Basic and Advanced faction scope and never inspect marker secrets', () => {
const basic = fixture(false);
place(basic, 'r', 'tueks_sietch:5');
assert.deepEqual(fremenSpecialVictory(basic)!.tueksBlockers, []);
const advanced = fixture(true);
seat(advanced, 'r').noField = {
deployed: { location: { territory: 'tueks_sietch', sector: 5 } },
};
Object.defineProperty(seat(advanced, 'r').noField!.deployed, 'value', {
get() {
throw new Error('Hidden marker denomination read');
},
});
for (const player of advanced.players)
for (const key of ['hand', 'spice', 'leaders', 'prediction'])
Object.defineProperty(player, key, {
get() {
throw new Error(`Private field read: ${key}`);
},
});
const result = fremenSpecialVictory(advanced)!;
assert.deepEqual(result.tueksBlockers, ['r']);
assert.equal(result.qualifies, false);
});
void test('members and blockers follow public order rather than player-array order', () => {
const g = fixture();
g.order = ['r', 'e', 'x', 'f'];
place(g, 'f', 'sietch_tabr:14');
place(g, 'e', 'sietch_tabr:14');
place(g, 'r', 'habbanya_ridge_sietch:17');
place(g, 'x', 'habbanya_ridge_sietch:17');
assert.deepEqual(fremenSpecialVictory(g), {
player: 'f',
members: ['e', 'f'],
qualifies: false,
sietches: [
{
territory: 'sietch_tabr',
blockers: [],
ecazCooccupation: true,
},
{
territory: 'habbanya_ridge_sietch',
blockers: ['r', 'x'],
ecazCooccupation: false,
},
],
tueksBlockers: [],
});
});
void test('invalid shared board and alliance state is reported through VictoryProgressError', () => {
const invalid = fixture();
seat(invalid, 'e').ally = null;
assert.throws(() => fremenSpecialVictory(invalid), VictoryProgressError);
const malformed = fixture();
seat(malformed, 'x').forces = { 'sietch_tabr:99': 1 };
assert.throws(() => fremenSpecialVictory(malformed), VictoryProgressError);
});