Add three-member short alliance prototype
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
cdc47ed2e1
commit
ef92ddf2b9
12 files changed
+343
-12
No files matched your search
@@ -0,0 +1,99 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { CoalitionNexus, coalitionNexusOptions } from '../components/coalition-nexus';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
|
||||
import { applyAction, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
|
||||
import { quoteVictory } from '../game/victory-quote';
|
||||
import { advanceShortAlliance, shortAllianceCollection, shortAllianceLobby, type ShortAllianceStep } from './fixture-short-alliance-victory';
|
||||
|
||||
/** Real four-seat Basic deal, first turn, normal worm and Nexus; the empty
|
||||
* preview ledger and unopened worm order are the only staged inputs here. */
|
||||
function nexus(): Game {
|
||||
let game = shortAllianceLobby();
|
||||
joinGame(game, newPlayer('ha', 'Harkonnen', 'harkonnen'));
|
||||
for (const player of game.players) game = applyAction(game, player.id, {type:'ready'});
|
||||
game = applyAction(game, 'at', {type:'start'});
|
||||
const actions: ShortAllianceStep[] = [];
|
||||
game = advanceShortAlliance(game, state => state.status === 'playing' && state.turn === 2 && state.phase === 0, actions);
|
||||
const index = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
|
||||
assert.ok(index >= 0);
|
||||
game.spiceDeck.unshift(game.spiceDeck.splice(index, 1)[0]);
|
||||
game = advanceShortAlliance(game, state => state.nexus && !state.spiceWindow && !state.spiceResolution &&
|
||||
!state.response && !state.decision && !state.truthtrance && !state.phaseOpening, actions);
|
||||
assert.ok(game.players.every(player => !player.ally));
|
||||
game.coalitionState = emptyCoalitionState('three-short');
|
||||
game = applyAction(game, 'at', {type:'alliance', target:'em'});
|
||||
return applyAction(game, 'em', {type:'alliance', target:'at'});
|
||||
}
|
||||
|
||||
function accept(game: Game, actor: string): Game {
|
||||
const offer = game.coalitionState!.proposal; assert.ok(offer);
|
||||
return applyAction(game, actor, {type:'coalitionAnswer', event:offer.event, nexus:offer.nexus,
|
||||
turn:offer.turn, accept:true, expectedRevision:game.coalitionState!.revision});
|
||||
}
|
||||
|
||||
void test('three-short pair invites a third with unanimous native consent and each bot can propose', () => {
|
||||
const game = nexus();
|
||||
const pair = coalitionOf(game.players, game.coalitionState, 'at'); assert.ok(pair);
|
||||
assert.deepEqual(new Set(pair.members), new Set(['at','em']));
|
||||
const controls = viewGame(game, 'gu').coalitionNexus; assert.ok(controls);
|
||||
assert.equal(controls.variant, 'three-short');
|
||||
assert.deepEqual(coalitionNexusOptions(controls).invitations.map(row => row.entrant), ['gu']);
|
||||
const markup = renderToStaticMarkup(createElement(CoalitionNexus, {view:controls, onIntent(){}}));
|
||||
assert.match(markup, /up to three members and need three strongholds/);
|
||||
assert.match(markup, /Ask to join Atreides, Emperor/);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, 'gu'); view.players.find(player => player.id === 'gu')!.bot = difficulty;
|
||||
const action = botActions(view).find(row => row.type === 'coalitionPropose');
|
||||
assert.ok(action, difficulty);
|
||||
let pending = applyAction(game, 'gu', action);
|
||||
assert.deepEqual(pending.coalitionState!.proposal!.approvals, ['gu']);
|
||||
assert.equal(viewGame(pending, 'em').coalitionNexus!.proposal!.entrant, 'gu');
|
||||
pending = accept(pending, 'at');
|
||||
assert.deepEqual(new Set(coalitionOf(pending.players, pending.coalitionState, 'at')!.members), new Set(['at','em']));
|
||||
pending = accept(pending, 'em');
|
||||
assert.deepEqual(new Set(coalitionOf(pending.players, pending.coalitionState, 'at')!.members), new Set(['at','em','gu']));
|
||||
const full = viewGame(pending, 'ha').coalitionNexus; assert.ok(full);
|
||||
assert.deepEqual(coalitionNexusOptions(full).invitations, []);
|
||||
const before = structuredClone(pending);
|
||||
assert.throws(() => applyAction(pending, 'ha', {type:'coalitionPropose', groupId:pair.id,
|
||||
entrant:'ha', expectedRevision:pending.coalitionState!.revision}));
|
||||
assert.deepEqual(pending, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('three-short pair and triple share the three-stronghold target and native win', () => {
|
||||
const pair = nexus();
|
||||
const group = coalitionOf(pair.players, pair.coalitionState, 'at'); assert.ok(group);
|
||||
let triple = applyAction(pair, 'at', {type:'coalitionPropose', groupId:group.id, entrant:'gu',
|
||||
expectedRevision:pair.coalitionState!.revision});
|
||||
triple = accept(triple, 'em');
|
||||
triple = accept(triple, 'gu');
|
||||
for (const [source, members] of [[pair, ['at','em']], [triple, ['at','em','gu']]] as const) {
|
||||
let closed = source;
|
||||
for (const player of source.players) {
|
||||
if (closed.nexus && !closed.ready.includes(player.id))
|
||||
closed = applyAction(closed, player.id, {type:'ready'});
|
||||
}
|
||||
closed = advanceShortAlliance(closed, state => !state.nexus && state.phase >= 2, []);
|
||||
// Preserve the actual alliance; only the Collection boundary and original
|
||||
// physical forces are staged by the common stronghold fixture.
|
||||
const game = shortAllianceCollection(closed);
|
||||
const row = viewGame(game, 'at').victoryProgress.find(progress => progress.player === 'at'); assert.ok(row);
|
||||
assert.equal(row.target, 3);
|
||||
assert.equal(row.qualifies, true);
|
||||
assert.equal(row.strongholds.length, 3);
|
||||
assert.deepEqual(new Set(quoteVictory(game).winner), new Set(members));
|
||||
let current = game;
|
||||
for (const player of game.players) {
|
||||
if (current.status === 'finished') break;
|
||||
current = applyAction(current, player.id, {type:'ready'});
|
||||
}
|
||||
assert.equal(current.status, 'finished');
|
||||
assert.deepEqual(new Set(current.winner), new Set(members));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
|
||||
import { applyAction, createGame, newPlayer, type Game } from '../game/engine';
|
||||
import { strongholdProgress } from '../game/victory-progress';
|
||||
import { quoteVictory } from '../game/victory-quote';
|
||||
|
||||
/** The ledger and Nexus are staged because the larger-alliance mode is not
|
||||
* selectable. Pair formation and, when requested, unanimous group admission
|
||||
* use real engine actions; the later public stronghold position is staged. */
|
||||
function board(variant: 'three-short' | 'unlimited' | undefined, group = true): Game {
|
||||
let game = createGame('THREESHORT', newPlayer('a', 'Atreides', 'atreides'), false);
|
||||
game.players.push(newPlayer('b', 'Emperor', 'emperor'), newPlayer('c', 'Fremen', 'fremen'),
|
||||
newPlayer('d', 'Harkonnen', 'harkonnen'));
|
||||
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, storm:18,
|
||||
order:['a','b','c','d'], ready:[], deck:baseDeck()});
|
||||
game.coalitionState = emptyCoalitionState(variant);
|
||||
game = applyAction(game, 'a', {type:'alliance', target:'b'});
|
||||
game = applyAction(game, 'b', {type:'alliance', target:'a'});
|
||||
if (group) {
|
||||
const pair = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(pair);
|
||||
game = applyAction(game, 'a', {type:'coalitionPropose', groupId:pair.id, entrant:'c',
|
||||
expectedRevision:game.coalitionState!.revision});
|
||||
for (const actor of ['b', 'c']) {
|
||||
const offer = game.coalitionState!.proposal!;
|
||||
game = applyAction(game, actor, {type:'coalitionAnswer', event:offer.event, nexus:offer.nexus,
|
||||
turn:offer.turn, accept:true, expectedRevision:game.coalitionState!.revision});
|
||||
}
|
||||
}
|
||||
game.phase = 8;
|
||||
game.nexus = false;
|
||||
for (const player of game.players) { player.forces = {}; player.reserves = 20; player.tanks = 0; }
|
||||
const placements: [string, string][] = group
|
||||
? [['a','arrakeen:10'], ['b','carthag:11'], ['c','sietch_tabr:14']]
|
||||
: [['a','arrakeen:10'], ['b','carthag:11'], ['b','sietch_tabr:14']];
|
||||
for (const [id, location] of placements) {
|
||||
const player = game.players.find(p => p.id === id)!;
|
||||
player.reserves--;
|
||||
player.forces[location] = 1;
|
||||
}
|
||||
return game;
|
||||
}
|
||||
const progress = (game: Game, actor = 'a') => strongholdProgress(game).progress.find(row => row.player === actor)!;
|
||||
|
||||
for (const group of [false, true])
|
||||
void test(`three-short gives ${group ? 'three allied members' : 'an allied pair'} the official three-stronghold target`, () => {
|
||||
const game = board('three-short', group);
|
||||
const row = progress(game);
|
||||
assert.equal(row.target, 3);
|
||||
assert.equal(row.qualifies, true);
|
||||
assert.deepEqual(row.members, group ? ['a','b','c'] : ['a','b']);
|
||||
assert.deepEqual(quoteVictory(game).winner, row.members);
|
||||
assert.equal(progress(game, 'd').target, 3, 'Solo victory retains its own target.');
|
||||
const before = structuredClone(game);
|
||||
const enemy = game.players.find(p => p.id === 'd')!;
|
||||
enemy.reserves--;
|
||||
enemy.forces['arrakeen:10'] = 1;
|
||||
assert.equal(progress(game).qualifies, false, 'A contested stronghold is not controlled by the alliance.');
|
||||
assert.deepEqual(quoteVictory(game).winner, []);
|
||||
assert.deepEqual(before.players.find(p => p.id === 'a')!.forces, {'arrakeen:10':1});
|
||||
});
|
||||
|
||||
void test('three-short remains distinct from the prior longer three-member and unlimited variants', () => {
|
||||
const legacy = board(undefined), longer = progress(legacy);
|
||||
assert.equal(longer.target, 4);
|
||||
assert.equal(longer.qualifies, false);
|
||||
assert.deepEqual(quoteVictory(legacy).winner, []);
|
||||
const unlimited = board('unlimited');
|
||||
assert.equal(progress(unlimited).target, 3);
|
||||
assert.deepEqual(quoteVictory(unlimited).winner, ['a','b','c']);
|
||||
const short = board('three-short');
|
||||
short.victoryExtraStrongholds = 1;
|
||||
assert.equal(progress(short).target, 4, 'The separately agreed increment remains additive.');
|
||||
assert.equal(progress(short).qualifies, false);
|
||||
});
|
||||
|
||||
void test('two physical controllers retain the existing four-stronghold precedence', () => {
|
||||
const game = board('three-short', false);
|
||||
game.twoPlayerControllers = {version:1, controllers:[
|
||||
{id:'a', seats:['a','c']}, {id:'b', seats:['b','d']},
|
||||
]};
|
||||
assert.equal(progress(game).target, 4);
|
||||
assert.equal(progress(game).qualifies, false);
|
||||
assert.deepEqual(quoteVictory(game).winner, []);
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
answerCoalitionAdmission, beginCoalitionAdmission, 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 result = { ...player };
|
||||
if (Object.hasOwn(transition.allyUpdates, player.id)) result.ally = transition.allyUpdates[player.id];
|
||||
if (Object.hasOwn(transition.allySinceUpdates, player.id)) {
|
||||
const turn = transition.allySinceUpdates[player.id];
|
||||
if (turn === null) delete result.allySinceTurn;
|
||||
else result.allySinceTurn = turn;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
function begin(players: CoalitionSeat[], state: CoalitionState, entrant: string, event: string): CoalitionTransition {
|
||||
const group = coalitionOf(players, state, 'a'); assert.ok(group);
|
||||
return beginCoalitionAdmission(players, state, { event, nexus: 'nexus', turn: 5,
|
||||
groupId: group.id, entrant, proposer: 'a', expectedRevision: state.revision });
|
||||
}
|
||||
|
||||
function answer(players: CoalitionSeat[], state: CoalitionState, actor: string, event: string): CoalitionTransition {
|
||||
return answerCoalitionAdmission(players, state, { event, nexus: 'nexus', turn: 5,
|
||||
actor, accept: true, expectedRevision: state.revision });
|
||||
}
|
||||
|
||||
function triple(variant: CoalitionState['variant']) {
|
||||
const players = seats(), original = emptyCoalitionState(variant);
|
||||
const offered = begin(players, original, 'c', 'first');
|
||||
const approved = answer(players, offered.state, 'b', 'first');
|
||||
const completed = answer(players, approved.state, 'c', 'first');
|
||||
return { players: apply(players, completed), state: completed.state, original, offered, approved };
|
||||
}
|
||||
|
||||
void test('three-short preserves its saved variant and the actual invitation revision across unanimous admission', () => {
|
||||
const { players, state, original, offered, approved } = triple('three-short');
|
||||
assert.deepEqual(original, { version: 1, variant: 'three-short', revision: 0, groups: [], proposal: null, usedEvents: [] });
|
||||
assert.equal(offered.state.variant, 'three-short');
|
||||
assert.deepEqual(offered.state.proposal?.approvals, ['a']);
|
||||
assert.equal(approved.state.variant, 'three-short');
|
||||
assert.deepEqual(approved.state.proposal?.approvals, ['a', 'b']);
|
||||
assert.equal(state.variant, 'three-short');
|
||||
assert.equal(state.revision, 3);
|
||||
assert.deepEqual(state.usedEvents, ['first']);
|
||||
assert.deepEqual(coalitionOf(players, JSON.parse(JSON.stringify(state)) as CoalitionState, 'a')?.members, ['a', 'b', 'c']);
|
||||
assert.equal(state.proposal, null);
|
||||
});
|
||||
|
||||
void test('three-short refuses a fourth member while unlimited preserves its separate six-member capacity', () => {
|
||||
for (const variant of [undefined, 'three-short'] as const) {
|
||||
const { players, state } = triple(variant), before = structuredClone({ players, state });
|
||||
assert.throws(() => begin(players, state, 'd', 'second'));
|
||||
assert.deepEqual({ players, state }, before);
|
||||
}
|
||||
const { players, state } = triple('unlimited');
|
||||
const offer = begin(players, state, 'd', 'second');
|
||||
let next = answer(players, offer.state, 'b', 'second');
|
||||
next = answer(players, next.state, 'c', 'second');
|
||||
next = answer(players, next.state, 'd', 'second');
|
||||
assert.equal(next.state.variant, 'unlimited');
|
||||
assert.deepEqual(coalitionOf(apply(players, next), next.state, 'a')?.members, ['a', 'b', 'c', 'd']);
|
||||
});
|
||||
|
||||
void test('three-short departure retains variant and rejects a stale invitation revision or replayed event', () => {
|
||||
const { players, state } = triple('three-short');
|
||||
const before = structuredClone({ players, state });
|
||||
const left = leaveCoalition(players, state, { actor: 'c', expectedRevision: state.revision });
|
||||
const reduced = apply(players, left);
|
||||
assert.equal(left.state.variant, 'three-short');
|
||||
assert.equal(left.state.revision, state.revision + 1);
|
||||
const group = coalitionOf(reduced, left.state, 'a'); assert.ok(group);
|
||||
const request = { event: 'first', nexus: 'nexus', turn: 5, groupId: group.id,
|
||||
entrant: 'c', proposer: 'a', expectedRevision: left.state.revision };
|
||||
assert.throws(() => beginCoalitionAdmission(reduced, left.state, request));
|
||||
assert.throws(() => beginCoalitionAdmission(reduced, left.state, {
|
||||
...request, event: 'fresh', expectedRevision: state.revision,
|
||||
}));
|
||||
const again = beginCoalitionAdmission(reduced, left.state, { ...request, event: 'fresh' });
|
||||
assert.equal(again.state.variant, 'three-short');
|
||||
assert.deepEqual(again.state.usedEvents, ['first', 'fresh']);
|
||||
assert.deepEqual({ players, state }, before);
|
||||
});
|
||||
|
||||
void test('older missing variants keep their exact shape and invalid variant strings are rejected', () => {
|
||||
const legacy = emptyCoalitionState();
|
||||
assert.equal(Object.hasOwn(legacy, 'variant'), false);
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(legacy)), legacy);
|
||||
const invalid = { ...legacy, variant: 'short' } as unknown as CoalitionState;
|
||||
assert.throws(() => coalitionOf(seats(), invalid, 'a'));
|
||||
});
|
||||
Reference in new issue
Block a user