Prototype charity Truthtrance and coalition rule foundations
Verify and publish checkpoint container / container (push) Successful in 10m14s

This commit is contained in:
Quintine committed 2026-10-28 21:58:30 +11:00
1 parent e896570f07
commit cea310b030
17 files changed
+1024 -35

No files matched your search

+253
View File
@@ -0,0 +1,253 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
answerCoalitionAdmission, areCoalitionAllies, beginCoalitionAdmission, coalitionAllies,
coalitionEdgeTurn, coalitionGroups, coalitionOf, emptyCoalitionState, leaveCoalition,
type CoalitionSeat, type CoalitionState, type CoalitionTransition,
} from '../game/coalition-alliance';
function seats(): CoalitionSeat[] {
return [
{id: 'a', ally: 'b', allySinceTurn: 2},
{id: 'b', ally: 'a', allySinceTurn: 2},
{id: 'c', ally: null},
{id: 'd', ally: null},
];
}
function apply(players: CoalitionSeat[], transition: CoalitionTransition): CoalitionSeat[] {
return players.map(player => {
const next = {...player};
if (Object.hasOwn(transition.allyUpdates, player.id)) next.ally = transition.allyUpdates[player.id];
if (Object.hasOwn(transition.allySinceUpdates, player.id)) {
const value = transition.allySinceUpdates[player.id];
if (value === null) delete next.allySinceTurn;
else next.allySinceTurn = value;
}
return next;
});
}
function begin(players = seats(), proposer = 'a') {
const state = emptyCoalitionState();
const group = coalitionOf(players, state, 'a')!;
return beginCoalitionAdmission(players, state, {event: 'invite-1', nexus: 'nexus-1', turn: 5,
groupId: group.id, entrant: 'c', proposer, expectedRevision: 0});
}
function answer(players: CoalitionSeat[], state: CoalitionState, actor: string, accept = true) {
return answerCoalitionAdmission(players, state, {event: 'invite-1', nexus: 'nexus-1', turn: 5,
actor, accept, expectedRevision: state.revision});
}
function triple(players = seats()) {
const first = begin(players);
const second = answer(players, first.state, 'b');
const accepted = answer(players, second.state, 'c');
return {players: apply(players, accepted), transition: accepted, original: players};
}
void test('legacy reciprocal pairs are derived without inventing an unknown age', () => {
const original = seats(), before = structuredClone(original);
const group = coalitionOf(original, undefined, 'a'); assert.ok(group);
assert.deepEqual(group.members, ['a', 'b']);
assert.equal(group.formedTurn, 2); assert.equal(coalitionEdgeTurn(original, undefined, 'a', 'b'), 2);
assert.deepEqual(coalitionAllies(original, undefined, 'a'), ['b']);
assert.equal(areCoalitionAllies(original, undefined, 'a', 'b'), true);
assert.equal(areCoalitionAllies(original, undefined, 'a', 'c'), false);
assert.equal(areCoalitionAllies(original, undefined, 'a', 'a'), false);
assert.deepEqual(original, before);
delete original[1].allySinceTurn;
assert.equal(coalitionEdgeTurn(original, undefined, 'a', 'b'), null);
assert.equal(areCoalitionAllies(original, undefined, 'a', 'b'), true);
assert.equal(coalitionOf(original, undefined, 'c'), null);
});
void test('a real pair and free entrant need all three distinct approvals', () => {
const players = seats(), original = structuredClone(players);
const first = begin(players); assert.deepEqual(first.state.proposal?.approvals, ['a']);
assert.deepEqual(first.allyUpdates, {}); assert.deepEqual(players, original);
const second = answer(players, first.state, 'b');
assert.deepEqual(second.state.proposal?.approvals, ['a', 'b']);
assert.deepEqual(second.allyUpdates, {});
const accepted = answer(players, second.state, 'c');
assert.equal(accepted.state.proposal, null);
assert.deepEqual(accepted.allyUpdates, {a: null, b: null, c: null});
const next = apply(players, accepted);
assert.deepEqual(coalitionAllies(next, accepted.state, 'a'), ['b', 'c']);
assert.deepEqual(coalitionAllies(next, accepted.state, 'b'), ['a', 'c']);
assert.equal(coalitionEdgeTurn(next, accepted.state, 'a', 'b'), 2);
assert.equal(coalitionEdgeTurn(next, accepted.state, 'a', 'c'), 5);
assert.equal(coalitionEdgeTurn(next, accepted.state, 'b', 'c'), 5);
assert.deepEqual(players, original);
});
void test('the free entrant may propose, but both existing members must independently approve', () => {
const players = seats(), first = begin(players, 'c');
const second = answer(players, first.state, 'a');
assert.ok(second.state.proposal); assert.deepEqual(second.state.proposal.approvals, ['c', 'a']);
const accepted = answer(players, second.state, 'b');
assert.equal(accepted.state.proposal, null);
assert.deepEqual(coalitionOf(apply(players, accepted), accepted.state, 'c')?.members, ['a', 'b', 'c']);
});
void test('any participant can decline before commitment; event replay and duplicate consent are rejected', () => {
const players = seats(), first = begin(players), snapshot = structuredClone(first.state);
assert.throws(() => answer(players, first.state, 'a'));
assert.throws(() => answer(players, first.state, 'd'));
assert.throws(() => answerCoalitionAdmission(players, first.state, {event: 'wrong', nexus: 'nexus-1',
turn: 5, actor: 'b', accept: true, expectedRevision: first.state.revision}));
assert.deepEqual(first.state, snapshot);
const declined = answer(players, first.state, 'a', false);
assert.equal(declined.state.proposal, null); assert.deepEqual(declined.allyUpdates, {});
assert.throws(() => answer(players, declined.state, 'b'));
assert.throws(() => beginCoalitionAdmission(players, declined.state, {event: 'invite-1', nexus: 'nexus-1', turn: 5,
groupId: coalitionOf(players, declined.state, 'a')!.id, entrant: 'c', proposer: 'a', expectedRevision: declined.state.revision}));
assert.deepEqual(players, seats());
});
void test('stale revision, changed Nexus and changed original membership never change their inputs', () => {
const players = seats(), first = begin(players), before = structuredClone({players, state: first.state});
for (const update of [{expectedRevision: 0}, {nexus: 'different'}, {turn: 6}])
assert.throws(() => answerCoalitionAdmission(players, first.state, {event: 'invite-1', nexus: 'nexus-1', turn: 5,
actor: 'b', accept: true, expectedRevision: first.state.revision, ...update}));
const changed = structuredClone(players); changed[0].ally = null; changed[1].ally = null;
assert.throws(() => answer(changed, first.state, 'b'));
assert.deepEqual({players, state: first.state}, before);
});
void test('leaving a triple preserves the remaining pair, stable ID and exact pairwise formation turn', () => {
for (const actor of ['a', 'b', 'c']) {
const {players, transition} = triple();
const before = structuredClone({players, state: transition.state});
const original = coalitionOf(players, transition.state, actor)!;
const remaining = original.members.filter(member => member !== actor);
const expectedAge = coalitionEdgeTurn(players, transition.state, remaining[0], remaining[1]);
const left = leaveCoalition(players, transition.state, {actor, expectedRevision: transition.state.revision});
const after = apply(players, left);
const pair = coalitionOf(after, left.state, remaining[0]); assert.ok(pair);
assert.equal(pair.id, original.id); assert.deepEqual(pair.members, remaining);
assert.equal(coalitionEdgeTurn(after, left.state, remaining[0], remaining[1]), expectedAge);
assert.equal(after.find(seat => seat.id === remaining[0])!.ally, remaining[1]);
assert.equal(after.find(seat => seat.id === remaining[1])!.ally, remaining[0]);
assert.equal(coalitionOf(after, left.state, actor), null);
assert.deepEqual({players, state: transition.state}, before);
}
});
void test('leaving a legacy pair dissolves it without changing unrelated groups', () => {
const players: CoalitionSeat[] = [...seats(), {id: 'e', ally: 'f', allySinceTurn: 3},
{id: 'f', ally: 'e', allySinceTurn: 3}];
const left = leaveCoalition(players, undefined, {actor: 'a', expectedRevision: 0});
const after = apply(players, left);
assert.equal(coalitionOf(after, left.state, 'a'), null);
assert.equal(coalitionOf(after, left.state, 'b'), null);
assert.equal(coalitionEdgeTurn(after, left.state, 'e', 'f'), 3);
});
void test('a missing legacy edge date stays unknown when the old pair is restored', () => {
const initial = seats(); delete initial[0].allySinceTurn; delete initial[1].allySinceTurn;
const {players, transition} = triple(initial);
assert.equal(coalitionEdgeTurn(players, transition.state, 'a', 'b'), null);
assert.equal(coalitionEdgeTurn(players, transition.state, 'a', 'c'), 5);
const left = leaveCoalition(players, transition.state, {actor: 'c', expectedRevision: transition.state.revision});
assert.equal(left.allySinceUpdates.a, null); assert.equal(left.allySinceUpdates.b, null);
const restored = apply(players, left);
assert.equal(coalitionEdgeTurn(restored, left.state, 'a', 'b'), null);
assert.equal(areCoalitionAllies(restored, left.state, 'a', 'b'), true);
});
void test('invalid saved membership, malformed edges and fake scalar triple pairs are rejected', () => {
const initial = seats();
const dangling = structuredClone(initial); dangling[1].ally = null;
assert.throws(() => coalitionGroups(dangling, undefined));
const {players, transition} = triple();
assert.throws(() => coalitionGroups(initial, transition.state));
const duplicate = structuredClone(transition.state);
duplicate.groups.push({...structuredClone(duplicate.groups[0]), id: 'duplicate'});
assert.throws(() => coalitionGroups(players, duplicate));
const missing = structuredClone(transition.state); missing.groups[0].edges.pop();
assert.throws(() => coalitionGroups(players, missing));
const wrongAge = structuredClone(transition.state); wrongAge.groups[0].edges[0].formedTurn = -1;
assert.throws(() => coalitionGroups(players, wrongAge));
});
void test('saved invitations reject unrelated seats, duplicate approvals and state changed under the offer', () => {
const players = seats(), first = begin(players);
for (const corrupt of [
(state: CoalitionState) => { state.proposal!.approvals.push('d'); },
(state: CoalitionState) => { state.proposal!.approvals.push('a'); },
(state: CoalitionState) => { state.proposal!.original.edges[0].formedTurn = 4; },
(state: CoalitionState) => { state.usedEvents = []; },
]) {
const broken = structuredClone(first.state); corrupt(broken);
assert.throws(() => coalitionGroups(players, broken));
assert.throws(() => answer(players, broken, 'b'));
}
});
void test('unequal legacy scalar ages survive joining and restoring the original pair', () => {
for (const dates of [[2, undefined], [undefined, 3], [2, 3]] as const) {
for (const persistedPair of [false, true]) {
const initial = seats();
for (const [index, date] of dates.entries()) {
if (date === undefined) delete initial[index].allySinceTurn;
else initial[index].allySinceTurn = date;
}
const derived = coalitionOf(initial, undefined, 'a')!;
assert.equal(derived.edges[0].formedTurn, null);
// An older persisted pair may predate the per-seat metadata.
const oldPair = structuredClone(derived); delete oldPair.edges[0].legacySince;
const state = {...emptyCoalitionState(), groups: persistedPair ? [oldPair] : []};
const first = beginCoalitionAdmission(initial, state, {event: 'invite-1', nexus: 'nexus-1', turn: 5,
groupId: derived.id, entrant: 'c', proposer: 'a', expectedRevision: 0});
const second = answer(initial, first.state, 'b');
const accepted = answer(initial, second.state, 'c');
const players = apply(initial, accepted);
assert.equal(coalitionEdgeTurn(players, accepted.state, 'a', 'b'), null);
const left = leaveCoalition(players, accepted.state, {actor: 'c', expectedRevision: accepted.state.revision});
const restored = apply(players, left);
assert.equal(restored[0].allySinceTurn, dates[0]);
assert.equal(restored[1].allySinceTurn, dates[1]);
assert.equal(Object.hasOwn(restored[0], 'allySinceTurn'), dates[0] !== undefined);
assert.equal(Object.hasOwn(restored[1], 'allySinceTurn'), dates[1] !== undefined);
assert.equal(coalitionEdgeTurn(restored, left.state, 'a', 'b'), null);
assert.equal(coalitionOf(restored, left.state, 'a')!.id, derived.id);
}
}
});
void test('maximum-length seat IDs with escaped characters can be admitted and persisted', () => {
const a = '\u0001'.repeat(256), b = '\u0002'.repeat(256);
const players: CoalitionSeat[] = [{id: a, ally: b, allySinceTurn: 2},
{id: b, ally: a, allySinceTurn: 2}, {id: 'entrant', ally: null}];
const group = coalitionOf(players, undefined, a)!;
assert.ok(group.id.length > 256);
const first = beginCoalitionAdmission(players, undefined, {event: 'long-id', nexus: 'nexus', turn: 5,
groupId: group.id, entrant: 'entrant', proposer: a, expectedRevision: 0});
const second = answerCoalitionAdmission(players, first.state, {event: 'long-id', nexus: 'nexus', turn: 5,
actor: b, accept: true, expectedRevision: first.state.revision});
const accepted = answerCoalitionAdmission(players, second.state, {event: 'long-id', nexus: 'nexus', turn: 5,
actor: 'entrant', accept: true, expectedRevision: second.state.revision});
const joined = apply(players, accepted);
assert.equal(coalitionOf(joined, accepted.state, a)!.id, group.id);
const left = leaveCoalition(joined, accepted.state, {actor: 'entrant', expectedRevision: accepted.state.revision});
assert.equal(coalitionOf(apply(joined, left), left.state, a)!.id, group.id);
});
void test('an older unknown edge retains matching saved scalar dates without inventing a shared date', () => {
const initial = seats();
initial[0].allySinceTurn = 2; initial[1].allySinceTurn = 2;
const oldPair = structuredClone(coalitionOf(initial, undefined, 'a')!);
oldPair.formedTurn = null; oldPair.edges[0].formedTurn = null;
const state = {...emptyCoalitionState(), groups: [oldPair]};
const first = beginCoalitionAdmission(initial, state, {event: 'old-pair', nexus: 'nexus-1', turn: 5,
groupId: oldPair.id, entrant: 'c', proposer: 'a', expectedRevision: 0});
const second = answerCoalitionAdmission(initial, first.state, {event: 'old-pair', nexus: 'nexus-1', turn: 5,
actor: 'b', accept: true, expectedRevision: first.state.revision});
const accepted = answerCoalitionAdmission(initial, second.state, {event: 'old-pair', nexus: 'nexus-1', turn: 5,
actor: 'c', accept: true, expectedRevision: second.state.revision});
const joined = apply(initial, accepted);
assert.equal(coalitionEdgeTurn(joined, accepted.state, 'a', 'b'), null);
const left = leaveCoalition(joined, accepted.state, {actor: 'c', expectedRevision: accepted.state.revision});
const restored = apply(joined, left);
assert.equal(restored[0].allySinceTurn, 2);
assert.equal(restored[1].allySinceTurn, 2);
assert.equal(coalitionEdgeTurn(restored, left.state, 'a', 'b'), null);
});
+92
View File
@@ -0,0 +1,92 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createGame, newPlayer } from '../game/engine';
import { quoteBattleBoard, quoteCoalitionAlliedSeparation } from '../game/board-resolution-quote';
import { territoryEntryBlock } from '../game/occupancy';
import type { CoalitionState } from '../game/coalition-alliance';
function fixture() {
const g = createGame('COALBOARD', newPlayer('a', 'Atreides', 'atreides'), false);
g.players.push(newPlayer('b', 'Emperor', 'emperor'), newPlayer('c', 'Fremen', 'fremen'),
newPlayer('d', 'Harkonnen', 'harkonnen'));
Object.assign(g, { advanced: false, turn: 4, storm: 18, order: ['a', 'b', 'c', 'd'] });
for (const p of g.players) { p.forces = {}; p.ally = null; }
const coalitionState: CoalitionState = { version: 1, revision: 1, proposal: null, usedEvents: [],
groups: [{ id: 'abc', members: ['a', 'b', 'c'], formedTurn: 2, edges: [
{ members: ['a', 'b'], formedTurn: 2 },
{ members: ['a', 'c'], formedTurn: 4 },
{ members: ['b', 'c'], formedTurn: 4 },
] }] };
return { g, coalitionState };
}
void test('coalition battles exclude every ally while preserving separate battles against outsiders', () => {
const { g, coalitionState } = fixture();
for (const p of g.players) p.forces = { 'red_chasm:7': 1 };
const before = structuredClone({ g, coalitionState });
assert.deepEqual(quoteBattleBoard({ ...g, coalitionState }).battles, [
{ territory: 'red_chasm', attacker: 'a', defender: 'd' },
{ territory: 'red_chasm', attacker: 'b', defender: 'd' },
{ territory: 'red_chasm', attacker: 'c', defender: 'd' },
]);
assert.equal(quoteBattleBoard(g).battles.length, 6);
assert.deepEqual({ g, coalitionState }, before);
});
void test('entry checks each coalition ally and retains polar, temporary shipment and capacity rules', () => {
const { g, coalitionState } = fixture();
g.players[1].forces = { 'red_chasm:7': 1 };
g.players[2].forces = { 'arrakeen:10': 1 };
const block = 'You cannot enter a territory occupied by your ally.';
assert.equal(territoryEntryBlock(g.players, 'a', 'red_chasm', false, false, coalitionState), block);
assert.equal(territoryEntryBlock(g.players, 'a', 'arrakeen', false, false, coalitionState), block);
assert.equal(territoryEntryBlock(g.players, 'a', 'red_chasm'), null);
assert.equal(territoryEntryBlock(g.players, 'a', 'red_chasm', false, true, coalitionState), null);
g.players[1].forces['polar_sink:0'] = 1;
assert.equal(territoryEntryBlock(g.players, 'a', 'polar_sink', false, false, coalitionState), null);
g.players[3].forces = { 'arrakeen:10': 1 };
assert.equal(territoryEntryBlock(g.players, 'a', 'arrakeen', false, true, coalitionState),
'A stronghold cannot contain three occupying factions.');
});
void test('Basic separation follows each alliance edge and preserves old formation age', () => {
const { g, coalitionState } = fixture();
g.players[0].forces = { 'red_chasm:7': 1, 'arrakeen:10': 1 };
g.players[1].forces = { 'red_chasm:7': 1 };
g.players[2].forces = { 'arrakeen:10': 1 };
const context = { ...g, coalitionState, remaining: ['a'] };
const before = structuredClone(context);
assert.deepEqual(quoteCoalitionAlliedSeparation(context, 'a'), ['red_chasm']);
assert.deepEqual(quoteCoalitionAlliedSeparation({ ...context, remaining: ['a', 'b'] }, 'a'), []);
assert.deepEqual(new Set(quoteCoalitionAlliedSeparation({ ...context, turn: 5 }, 'a')),
new Set(['red_chasm', 'arrakeen']));
assert.deepEqual(context, before);
coalitionState.groups[0].edges[0].formedTurn = null;
assert.deepEqual(quoteCoalitionAlliedSeparation(context, 'a'), ['red_chasm']);
});
void test('legacy pair timing remains unchanged and unsupported coalition combinations fail closed', () => {
const { g, coalitionState } = fixture();
g.players[0].forces = { 'red_chasm:7': 1 };
g.players[1].forces = { 'red_chasm:7': 1 };
g.players[0].ally = 'b'; g.players[1].ally = 'a';
g.players[0].allySinceTurn = 4; g.players[1].allySinceTurn = 4;
assert.deepEqual(quoteCoalitionAlliedSeparation({ ...g, remaining: ['a'] }, 'a'), []);
g.players[1].allySinceTurn = 2;
assert.deepEqual(quoteCoalitionAlliedSeparation({ ...g, remaining: ['a'] }, 'a'), ['red_chasm']);
assert.equal(territoryEntryBlock(g.players, 'a', 'red_chasm'),
'You cannot enter a territory occupied by your ally.');
g.players[0].ally = null; g.players[1].ally = null;
assert.throws(() => quoteBattleBoard({ ...g, advanced: true, coalitionState }), /Basic play without Ecaz/);
g.players[2] = newPlayer('c', 'Ecaz', 'ecaz');
assert.throws(() => territoryEntryBlock(g.players, 'a', 'red_chasm', false, false, coalitionState), /do not yet support Ecaz/);
});
void test('a malformed null coalition ledger cannot turn a group into enemies', () => {
const { g } = fixture();
for (const p of g.players) p.forces = { 'red_chasm:7': 1 };
const invalid = null as unknown as CoalitionState;
assert.throws(() => quoteBattleBoard({ ...g, coalitionState: invalid }), /ledger cannot be null/);
assert.throws(() => territoryEntryBlock(g.players, 'a', 'red_chasm', false, false, invalid), /ledger cannot be null/);
assert.throws(() => territoryEntryBlock(g.players, 'a', 'red_chasm', true, false, invalid), /ledger cannot be null/);
});
+124
View File
@@ -0,0 +1,124 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { beginCoalitionAdmission, answerCoalitionAdmission, coalitionOf, type CoalitionState } from '../game/coalition-alliance';
import { createGame, newPlayer, type Game } from '../game/engine';
import type { FactionId } from '../game/catalog';
import { strongholdProgress, VictoryProgressError } from '../game/victory-progress';
import { quoteVictory, VictoryQuoteError } from '../game/victory-quote';
import { fremenSpecialVictory } from '../game/fremen-victory';
import { createVictoryContinuation, voteVictoryContinuation } from '../game/victory-continuation';
import { createTechTokens } from '../game/tech-tokens';
type CoalitionGame = Game & {coalitionState: CoalitionState};
function game(factions: FactionId[] = ['atreides', 'emperor', 'harkonnen', 'beneGesserit']): CoalitionGame {
const g = createGame('COALITIONVICTORY', newPlayer('p0', factions[0], factions[0]), false);
factions.slice(1).forEach((faction, i) => g.players.push(newPlayer(`p${i + 1}`, faction, faction)));
Object.assign(g, { status: 'playing', phase: 8, turn: 2, storm: 18,
order: g.players.map(player => player.id) });
for (const p of g.players) Object.assign(p, {forces: {}, ally: null});
g.players[0].ally = 'p1'; g.players[1].ally = 'p0';
const group = coalitionOf(g.players, undefined, 'p0')!;
const start = beginCoalitionAdmission(g.players, undefined, {event: 'invite', nexus: 'nexus', turn: 2,
groupId: group.id, entrant: 'p2', proposer: 'p0', expectedRevision: 0});
const second = answerCoalitionAdmission(g.players, start.state, {event: 'invite', nexus: 'nexus',
turn: 2, actor: 'p1', accept: true, expectedRevision: start.state.revision});
const joined = answerCoalitionAdmission(g.players, second.state, {event: 'invite', nexus: 'nexus',
turn: 2, actor: 'p2', accept: true, expectedRevision: second.state.revision});
for (const p of g.players) if (Object.hasOwn(joined.allyUpdates, p.id)) p.ally = joined.allyUpdates[p.id];
return Object.assign(g, {coalitionState: joined.state});
}
function hold(g: CoalitionGame, player: number, ...sites: string[]) {
g.players[player].forces = Object.fromEntries(sites.map(site => [site, 1]));
}
const a = 'arrakeen:10', b = 'carthag:11', c = 'sietch_tabr:14', d = 'habbanya_ridge_sietch:17';
void test('three members share a distinct uncontested stronghold union with ordered members', () => {
const g = game();
g.order = ['p2', 'p0', 'p3', 'p1'];
hold(g, 0, a, b); hold(g, 1, b, c); hold(g, 2, d);
const before = structuredClone(g);
const rows = strongholdProgress(g).progress.filter(row => row.members.length === 3);
assert.equal(rows.length, 3);
for (const row of rows) {
assert.deepEqual(row.members, ['p2', 'p0', 'p1']);
assert.equal(row.strongholds.length, 4); assert.equal(row.target, 4); assert.equal(row.qualifies, true);
}
assert.deepEqual(quoteVictory(g).winner, ['p2', 'p0', 'p1']);
assert.deepEqual(g, before);
hold(g, 3, d);
assert.equal(strongholdProgress(g).progress.find(row => row.player === 'p0')!.qualifies, false);
});
void test('two-member shortened target does not apply to three-member alliances', () => {
const g = game(); hold(g, 0, a); hold(g, 1, b); hold(g, 2, c);
let row = strongholdProgress(g).progress[0];
assert.equal(row.target, 4); assert.equal(row.qualifies, false);
g.shortAllianceVictory = true;
row = strongholdProgress(g).progress[0]; assert.equal(row.target, 4); assert.equal(row.qualifies, false);
g.victoryExtraStrongholds = 1;
row = strongholdProgress(g).progress[0]; assert.equal(row.target, 5); assert.equal(row.qualifies, false);
g.victoryExtraStrongholds = 0;
g.techTokens = createTechTokens();
for (const token of Object.values(g.techTokens)) token.owner = 'p2';
row = strongholdProgress(g).progress[0]; assert.equal(row.techStronghold, true); assert.equal(row.qualifies, true);
g.techTokens.production.owner = 'p1';
assert.equal(strongholdProgress(g).progress[0].techStronghold, false);
g.twoPlayerControllers = {version: 1, controllers: [
{id: 'p0', seats: ['p0', 'p1']}, {id: 'p2', seats: ['p2', 'p3']},
]};
assert.equal(strongholdProgress(g).progress[0].target, 4);
});
void test('ordinary prediction checks every coalition winner while final Fremen special still wins', () => {
const g = game(); hold(g, 0, a, b); hold(g, 1, c); hold(g, 2, d);
g.players[3].prediction = {faction: 'harkonnen', turn: 2};
assert.deepEqual(quoteVictory(g).winner, ['p3']);
g.players[3].prediction.turn = 3;
assert.deepEqual(quoteVictory(g).winner, ['p0', 'p1', 'p2']);
const f = game(['fremen', 'atreides', 'emperor', 'beneGesserit']);
f.turn = 10; f.players[3].prediction = {faction: 'atreides', turn: 10};
assert.deepEqual(quoteVictory(f).winner, ['p0', 'p1', 'p2']);
});
void test('Fremen, Guild and no-Guild Fremen final branches award the full alliance', () => {
const fremen = game(['fremen', 'atreides', 'emperor', 'guild']); fremen.turn = 10;
assert.deepEqual(fremenSpecialVictory(fremen)!.members, ['p0', 'p1', 'p2']);
assert.deepEqual(quoteVictory(fremen).winner, ['p0', 'p1', 'p2']);
hold(fremen, 1, c);
assert.equal(fremenSpecialVictory(fremen)!.qualifies, false);
assert.deepEqual(quoteVictory(fremen).winner, ['p3']);
const guild = game(['guild', 'atreides', 'emperor', 'beneGesserit']); guild.turn = 10;
assert.deepEqual(quoteVictory(guild).winner, ['p0', 'p1', 'p2']);
const noGuild = game(['fremen', 'atreides', 'emperor', 'harkonnen']); noGuild.turn = 10;
hold(noGuild, 1, c);
assert.equal(fremenSpecialVictory(noGuild)!.qualifies, false);
assert.deepEqual(quoteVictory(noGuild).winner, ['p0', 'p1', 'p2']);
});
void test('unsupported triples and malformed coalition records fail closed across quotes', () => {
const g = game(); g.advanced = true;
assert.throws(() => strongholdProgress(g), VictoryProgressError);
assert.throws(() => fremenSpecialVictory(g), VictoryProgressError);
assert.throws(() => quoteVictory(g), VictoryQuoteError);
g.advanced = false; g.players[3].faction = 'ecaz';
assert.throws(() => strongholdProgress(g), VictoryProgressError);
assert.throws(() => quoteVictory(g), VictoryQuoteError);
g.players[3].faction = 'beneGesserit'; g.coalitionState.groups[0].edges.pop();
assert.throws(() => strongholdProgress(g), VictoryProgressError);
assert.throws(() => quoteVictory(g), VictoryQuoteError);
});
void test('a continuation deduplicates a triple side and requires every participant to consent', () => {
let frame = createVictoryContinuation({event: 'continue', turn: 2, resume: 'mentat',
order: ['p2', 'p0', 'p3', 'p1'], sides: [['p0', 'p1', 'p2'], ['p2', 'p0', 'p1'], ['p3']]});
assert.deepEqual(frame.sides, [['p2', 'p0', 'p1'], ['p3']]);
assert.deepEqual(frame.voters, ['p2', 'p0', 'p3', 'p1']);
for (const [index, player] of frame.voters.entries()) {
const vote = voteVictoryContinuation(frame, {event: 'continue', player, choice: 'continue',
...(index === 0 ? {extraStrongholds: 1} : {})});
if (vote.outcome === 'pending') frame = vote.frame;
else { assert.equal(index, 3); assert.deepEqual(vote, {outcome: 'continue', extraStrongholds: 1}); }
}
assert.throws(() => createVictoryContinuation({event: 'x', turn: 2, resume: 'mentat',
order: ['p0', 'p1', 'p2', 'p3'], sides: [['p0', 'p1', 'p2'], ['p2', 'p3']]}));
});
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import { type Action, type Game } from '../game/engine';
import { advanceTruthRevival, truthRevivalClean, truthRevivalInventory, truthRevivalPlayer } from './fixture-truth-revival';
import {
actFutureBattleRevival, askFutureBattleRevival, createFutureBattleRevivalFixture,
futureBattleRevivalRecords, reachFutureBattleRevivalBattle, reachFutureBattleRevivalMovement,
} from './fixture-truth-future-battle-revival';
export const actFutureBattleCharity = actFutureBattleRevival;
export const askFutureBattleCharity = askFutureBattleRevival;
export const futureBattleCharityRecords = futureBattleRevivalRecords;
export const futureBattleCharityInventory = truthRevivalInventory;
export const reachFutureBattleCharityBattle = reachFutureBattleRevivalBattle;
export const reachFutureBattleCharityMovement = reachFutureBattleRevivalMovement;
/** Start from the original public classic setup and actual turn-two Storm,
* then advance the source's real native actions to a quiet Charity window. */
export function createFutureBattleCharityFixture() {
const origin = createFutureBattleRevivalFixture();
const game = advanceTruthRevival(structuredClone(origin.base.storm.after),
state => state.turn === origin.game.turn && state.phase === 2 && truthRevivalClean(state));
assert.equal(game.status, 'playing');
assert.equal(game.phase, 2); assert.equal(game.advanced, false); assert.deepEqual(game.expansions, []);
assert.ok(truthRevivalClean(game));
assert.ok(truthRevivalPlayer(game, origin.asker).hand.some(card => card.id === origin.truth));
assert.deepEqual(truthRevivalInventory(game), origin.originalCards);
const target = truthRevivalPlayer(game, origin.target);
const available = target.leaders.filter(leader => !leader.dead && !leader.capturedBy && !leader.gholaBy && !leader.usedAt);
assert.ok(available.length >= 2);
const leader = available[0].id, alternative = available[1].id;
const question = { kind: 'futureBattleLeader', target: origin.target, turn: game.turn, leader };
return { ...origin, game, source: structuredClone(game), leader, alternative, question,
ask: { type: 'truthAsk', question } as unknown as Action };
}
export type FutureBattleCharityFixture = ReturnType<typeof createFutureBattleCharityFixture>;
/** Drive the real, earlier phase source to the original target movement turn. */
export function reachFutureBattleCharityWindow(game: Game, fixture: FutureBattleCharityFixture) {
return reachFutureBattleCharityMovement(game, fixture);
}
+70
View File
@@ -0,0 +1,70 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { type Action, type Game } from '../game/engine';
import { nextTruthRevival, truthRevivalClean, truthRevivalPlayer } from './fixture-truth-revival';
import {
actFutureBattleCharity as act, askFutureBattleCharity as ask,
createFutureBattleCharityFixture as fixture, futureBattleCharityInventory as inventory,
futureBattleCharityRecords as records, reachFutureBattleCharityBattle as reachBattle,
reachFutureBattleCharityWindow as reachMovement,
} from './fixture-truth-future-battle-charity';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => act(game, actor, action));
assert.deepEqual(game, before);
}
for (const answer of ['yes', 'no'] as const) {
void test(`Basic Charity future-battle leader ${answer} binds the first actual later battle`, () => {
const f = fixture(), asked = ask(f);
assert.equal(asked.phase, 2); assert.deepEqual(asked.truthtrance?.question, f.question);
let game = act(asked, f.target, {type: 'truthAnswer', answer});
assert.equal(records(game).length, 1); assert.equal(records(game)[0].status, 'waiting');
game = reachMovement(game, f);
assert.equal(records(game)[0].status, 'waiting');
game = reachBattle(game, f);
assert.equal(records(game)[0].status, 'bound');
assert.equal(records(game)[0].battle, game.battle!.event);
const plan = (leader: string): Action => ({type: 'battlePlan', leader, dial: 0, support: 0, weapon: null, defense: null});
rejected(game, f.target, plan(answer === 'yes' ? f.alternative : f.leader));
game = act(game, f.target, plan(answer === 'yes' ? f.leader : f.alternative));
assert.equal(records(game)[0].status, 'bound');
const opponent = game.battle!.attacker === f.target ? game.battle!.defender : game.battle!.attacker;
const other = truthRevivalPlayer(game, opponent).leaders.find(leader =>
!leader.dead && !leader.capturedBy && !leader.gholaBy && !leader.usedAt);
assert.ok(other);
game = act(game, opponent, plan(other.id));
assert.ok(game.battle?.revealed); assert.equal(records(game)[0].status, 'settled');
assert.equal(records(game).length, 1);
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
assert.deepEqual(inventory(game), f.originalCards);
});
}
void test('Basic Charity conditional leader answer expires if the respondent never fights', () => {
const f = fixture();
for (const answer of ['yes', 'no'] as const) {
let game = act(ask(f), f.target, {type: 'truthAnswer', answer});
game = reachMovement(game, f);
assert.ok(Object.values(truthRevivalPlayer(game, f.target).forces).every(amount => amount === 0));
for (let count = 0; count < 1200 && game.turn === f.game.turn && game.phase <= 6 && game.status === 'playing'; count++) {
if (game.phase === 5 && game.active && truthRevivalClean(game)) {
game = act(game, game.active, {type: 'endMovement'}); continue;
}
const next = nextTruthRevival(game); assert.ok(next, 'Finish the real turn without inventing a target battle.');
game = act(game, next.actor, next.action);
}
assert.equal(records(game).length, 1); assert.equal(records(game)[0].status, 'expired');
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
assert.deepEqual(inventory(game), f.originalCards);
}
});
void test('a pending Truthtrance priority must finish before its Charity-phase question can be asked', () => {
const f = fixture();
const game = act(f.game, f.asker, {type: 'card', card: f.truth});
assert.equal(game.truthtrance?.stage, 'priority');
rejected(game, f.asker, f.ask);
assert.deepEqual(inventory(game), f.originalCards);
});