Prototype charity Truthtrance and coalition rule foundations
Verify and publish checkpoint container / container (push) Successful in 10m14s
Verify and publish checkpoint container / container (push) Successful in 10m14s
This commit is contained in:
1 parent
e896570f07
commit
cea310b030
17 files changed
+1024
-35
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user