Prototype unanimous Advanced victory continuation

This commit is contained in:
Quintine committed 2026-10-27 23:22:58 +11:00
1 parent 3c62c284a7
commit a3af6b755d
16 files changed
+740 -20

No files matched your search

@@ -87,7 +87,12 @@ void test('a qualifying alliance and solo still share an Advanced win regardless
holds(g, 'ix', 'tueks_sietch', 'habbanya_ridge_sietch');
g.players[0].forces['hidden_mobile_stronghold:0'] = 1;
assert.deepEqual(quoteVictory(g).winner, ['ix', 'at', 'em']);
assert.deepEqual(actual(g).winner, ['ix', 'at', 'em']);
const offered = actual(g);
assert.ok(offered.victoryContinuation);
const settled = applyAction(offered, offered.victoryContinuation.voters[0], {
type: 'victoryContinuation', event: offered.victoryContinuation.event, mode: 'finish',
});
assert.deepEqual(settled.winner, ['ix', 'at', 'em']);
});
void test('Bene Gesserit prediction uses the actual higher solo winner, not the lower qualifier', () => {
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { VictoryContinuation, VictoryContinuationButton } from '../components/victory-continuation';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
/** Controlled public projections exercise the actual panel and callbacks;
* they do not assert an authoritative voting or victory transition. */
function projected(viewer: 'a' | 'b' | 'c', extra: number | null, actor: 'a' | 'b' = 'a'): GameView {
const source = createGame('VOTEUI', newPlayer('a', 'Atreides', 'atreides'), true);
joinGame(source, newPlayer('b', 'Emperor', 'emperor'));
joinGame(source, newPlayer('c', 'Guild', 'guild'));
const view = viewGame(source, viewer);
view.victoryContinuation = {
event: 'public-vote-event', actor, voters: ['a', 'b'], sides: [['a'], ['b', 'c']], extra, maxExtra: 20,
};
return view;
}
function html(game: GameView, busy = false): string {
return renderToStaticMarkup(createElement(VictoryContinuation, { game, busy, act() {} }));
}
function click(mode: 'finish' | 'continue' | 'accept', busy = false, extra?: number): Action[] {
const actions: Action[] = [];
VictoryContinuationButton({ event: 'public-vote-event', mode, extra, busy,
act(action) { actions.push(action); }, children: 'Choose' }).props.onClick();
return actions;
}
void test('proposal controls display the public sides and a selectable increase', () => {
const view = projected('a', null), before = structuredClone(view), output = html(view);
assert.match(output, /aria-label="Simultaneous victory decision"/);
assert.match(output, /Atreides; Emperor and Guild/);
assert.match(output, /unanimously agree to play another turn/);
assert.match(output, /Increase the stronghold targets by/);
assert.match(output, /type="number" min="0" max="20"/);
assert.match(output, /Choose zero to keep the existing targets/);
assert.match(output, /Propose another turn/);
assert.match(output, /Finish the game/);
assert.deepEqual(view, before);
});
void test('proposal acceptance explains exact increase and non-actors wait', () => {
const responder = html(projected('b', 2, 'b'));
assert.match(responder, /Proposal: Continue and increase the stronghold targets by 2/);
assert.match(responder, /Agree to continue/);
assert.match(responder, /Finish the game/);
assert.doesNotMatch(responder, /Propose another turn|type="number"/);
const waiting = html(projected('a', 0, 'b'));
assert.match(waiting, /Continue with the current stronghold targets/);
assert.match(waiting, /Waiting for Emperor to decide/);
assert.doesNotMatch(waiting, /<button/);
});
void test('buttons send only event-bound choices and respect busy state', () => {
assert.deepEqual(click('continue', false, 3), [{ type: 'victoryContinuation', event: 'public-vote-event', mode: 'continue', extra: 3 }]);
assert.deepEqual(click('accept'), [{ type: 'victoryContinuation', event: 'public-vote-event', mode: 'accept' }]);
assert.deepEqual(click('finish'), [{ type: 'victoryContinuation', event: 'public-vote-event', mode: 'finish' }]);
for (const mode of ['finish', 'continue', 'accept'] as const) assert.deepEqual(click(mode, true, 2), []);
assert.match(html(projected('a', null), true), /disabled=""/);
});
void test('the panel is absent when no continuation vote is projected', () => {
const view = projected('a', null);
view.victoryContinuation = null;
assert.equal(html(view), '');
});
+169
View File
@@ -0,0 +1,169 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { territory } from '../game/board';
import { baseDeck } from '../game/cards';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { createTechTokens } from '../game/tech-tokens';
/** Controlled late-game board: original public strongholds and Tech tokens are
* placed directly to reach the rare simultaneous qualified result. Actions
* from Collection to Mentat, each vote, and the subsequent phase are native.
* This is not a naturally completed game or saved-room/deployment proof. */
function position(options: { advanced?: boolean; turn?: number; choam?: boolean; onlyAlliance?: boolean; prediction?: boolean } = {}): Game {
const { advanced = true, turn = 2, choam = false, onlyAlliance = false, prediction = false } = options;
const g = createGame('VICTORYVOTE', newPlayer('ix', 'Ixians', 'ixians'), advanced, choam ? ['choam'] : []);
g.players.push(newPlayer('at', 'Atreides', 'atreides'));
g.players.push(newPlayer('em', 'Emperor', 'emperor'));
g.players.push(newPlayer('bg', 'Bene Gesserit', 'beneGesserit'));
if (choam) g.players.push(newPlayer('ch', 'CHOAM', 'choam'));
Object.assign(g, { status: 'playing', phase: 7, turn, storm: 18,
order: g.players.map(p => p.id), ready: [], phaseOpening: null,
deck: baseDeck(), active: null, mobileStronghold: { location: 'red_chasm:7' },
techTokens: createTechTokens() });
for (const p of g.players) Object.assign(p, { hand: [], forces: {},
reserves: 20, spice: 20, traitors: [], traitorChoices: [] });
const holds = (id: string, ...names: string[]) => {
const p = g.players.find(row => row.id === id)!;
p.forces = Object.fromEntries(names.map(name => [`${name}:${territory(name).sectors[0]}`, 1]));
p.reserves -= names.length;
};
g.players[1].ally = 'em'; g.players[2].ally = 'at';
holds('at', 'arrakeen', 'carthag');
holds('em', 'sietch_tabr');
for (const token of Object.values(g.techTokens!)) token.owner = 'at';
if (!onlyAlliance) {
holds('ix', 'tueks_sietch', 'habbanya_ridge_sietch');
g.players[0].forces['hidden_mobile_stronghold:0'] = 1;
g.players[0].reserves -= 1;
}
if (prediction) g.players[3].prediction = { faction: 'atreides', turn };
return g;
}
function reachMentat(game: Game): Game {
let g = structuredClone(game);
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
if (g.phase === 7 && g.decision?.kind === 'choamMarket')
g = applyAction(g, g.decision.player, { type: 'decision', done: true });
return g;
}
function offer(game: Game): NonNullable<Game['victoryContinuation']> {
const pending = game.victoryContinuation;
assert.ok(pending, 'The original Collection and Mentat transition offers a public agreement');
return pending;
}
function act(game: Game, player: string, mode: 'finish' | 'continue' | 'accept', extra?: number): Game {
const pending = offer(game);
return applyAction(game, player, { type: 'victoryContinuation', event: pending.event, mode,
...(extra === undefined ? {} : { extra }) });
}
function reject(game: Game, player: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, player, action));
assert.deepEqual(game, before);
}
function completeVotes(game: Game, extra: number): Game {
let g = game;
const pending = offer(g), voters = [...pending.voters];
g = act(g, voters[0], 'continue', extra);
for (const voter of voters.slice(1)) g = act(g, voter, 'accept');
return g;
}
void test('Advanced public simultaneous victory waits for each qualifying participant and rejects other actions', () => {
const game = reachMentat(position());
assert.equal(game.phase, 8); assert.equal(game.status, 'playing'); assert.deepEqual(game.winner, []);
const pending = offer(game);
assert.equal(pending.resume, 'mentat');
assert.deepEqual(pending.sides, [['ix'], ['at', 'em']]);
assert.deepEqual(pending.voters, ['ix', 'at', 'em']);
assert.deepEqual(pending.accepted, []);
const projected = viewGame(game, 'bg').victoryContinuation;
assert.ok(projected); assert.equal(projected.actor, 'ix');
assert.deepEqual(projected.sides, pending.sides); assert.equal(projected.extra, null);
reject(game, 'bg', { type: 'victoryContinuation', event: pending.event, mode: 'finish' });
reject(game, 'at', { type: 'victoryContinuation', event: pending.event, mode: 'continue', extra: 1 });
reject(game, 'ix', { type: 'victoryContinuation', event: 'stale-event', mode: 'continue', extra: 1 });
reject(game, 'ix', { type: 'victoryContinuation', event: pending.event, mode: 'continue', extra: -1 });
reject(game, 'ix', { type: 'ready' });
assert.deepEqual(offer(game), pending);
});
void test('declining the common proposal settles the original winners and correct private prediction', () => {
const game = reachMentat(position({ prediction: true }));
const vote = offer(game);
assert.deepEqual(game.winner, []);
const publicIx = viewGame(game, 'ix').victoryContinuation;
const publicOther = viewGame(game, 'em').victoryContinuation;
assert.deepEqual(publicIx, publicOther, 'The public vote does not expose the private prediction');
let next = act(game, 'ix', 'continue', 1);
assert.deepEqual(offer(next).accepted, ['ix']);
reject(next, 'at', { type: 'victoryContinuation', event: vote.event, mode: 'accept', extra: 2 });
next = act(next, 'at', 'finish');
assert.equal(next.status, 'finished'); assert.deepEqual(next.winner, ['bg']);
assert.ok(!next.victoryContinuation);
});
void test('unanimous continuation raises the ordinary solo and alliance targets without losing Mentat', () => {
const original = reachMentat(position());
const beforeForces = original.players.map(player => ({ id: player.id, forces: player.forces, reserves: player.reserves }));
const next = completeVotes(original, 2);
assert.equal(next.status, 'playing'); assert.equal(next.phase, 8); assert.equal(next.turn, original.turn);
assert.deepEqual(next.winner, []); assert.ok(!next.victoryContinuation);
assert.equal(next.victoryExtraStrongholds, 2);
assert.deepEqual(next.players.map(player => ({ id: player.id, forces: player.forces, reserves: player.reserves })), beforeForces);
const progress = viewGame(next, 'ix').victoryProgress;
assert.equal(progress.find(row => row.player === 'ix')?.target, 5);
assert.equal(progress.find(row => row.player === 'at')?.target, 6);
assert.equal(progress.find(row => row.player === 'at')?.qualifies, false);
let completed = next;
for (const p of completed.players) completed = applyAction(completed, p.id, { type: 'ready' });
assert.equal(completed.turn, original.turn + 1);
assert.equal(completed.victoryExtraStrongholds, 2);
});
void test('zero-increase agreement preserves ordinary targets for the next turn', () => {
const next = completeVotes(reachMentat(position()), 0);
assert.equal(next.status, 'playing'); assert.equal(next.phase, 8);
assert.equal(next.victoryExtraStrongholds ?? 0, 0);
const progress = viewGame(next, 'ix').victoryProgress;
assert.equal(progress.find(row => row.player === 'ix')?.target, 3);
assert.equal(progress.find(row => row.player === 'at')?.target, 4);
});
void test('Basic, one qualifying side and the turn-ten boundary retain their native victory outcome', () => {
for (const options of [{ advanced: false }, { onlyAlliance: true }, { turn: 10 }] as const) {
const game = reachMentat(position(options));
assert.equal(game.status, 'finished'); assert.ok(game.winner.length > 0);
assert.ok(!game.victoryContinuation);
}
});
void test('all four minimal bot profiles produce a legal response for the public continuation', () => {
const game = reachMentat(position());
for (const level of DIFFICULTIES) {
const view = viewGame(game, 'ix');
view.players.find(player => player.id === 'ix')!.bot = level;
// A pending native agreement takes priority over unrelated action guides.
view.turnActionGuide = { event: 'controlled-guide', action: { type: 'ready' } };
const action = botActions(view).find(row => row.type === 'victoryContinuation');
assert.ok(action, `${level} resolves its own public agreement opportunity`);
const next = applyAction(game, 'ix', action);
assert.ok(next.status === 'finished' || next.victoryContinuation);
}
});
void test('CHOAM final Mentat completion waits for unanimous votes before advancing', () => {
let game = reachMentat(position({ choam: true }));
assert.equal(game.phase, 8); assert.ok(!game.victoryContinuation);
for (const p of game.players) game = applyAction(game, p.id, { type: 'ready' });
if (game.decision?.kind === 'choamMarket') game = applyAction(game, 'ch', { type: 'decision', done: true });
assert.equal(game.decision?.kind, 'choamMentat');
game = applyAction(game, 'ch', { type: 'decision', done: true });
assert.equal(offer(game).resume, 'advance');
assert.equal(game.phase, 8); assert.equal(game.turn, 2);
game = completeVotes(game, 1);
assert.equal(game.status, 'playing'); assert.equal(game.turn, 3);
assert.equal(game.victoryExtraStrongholds, 1); assert.ok(!game.victoryContinuation);
});
+92
View File
@@ -0,0 +1,92 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createVictoryContinuation, validateVictoryContinuation, voteVictoryContinuation,
MAX_VICTORY_EXTRA_STRONGHOLDS, VictoryContinuationError,
type VictoryContinuation, type VictoryContinuationChoice,
} from '../game/victory-continuation';
const source = () => createVictoryContinuation({ event: 'victory:5', turn: 5, resume: 'mentat',
order: ['a', 'b', 'c', 'd', 'e'], sides: [['c', 'a'], ['a', 'c'], ['d']] });
function reject(frame: VictoryContinuation, choice: VictoryContinuationChoice) {
const before = structuredClone(frame);
assert.throws(() => voteVictoryContinuation(frame, choice), VictoryContinuationError);
assert.deepEqual(frame, before);
}
void test('allied rows are deduplicated and every qualifying member votes in public order', () => {
const frame = source();
assert.deepEqual(frame.sides, [['a', 'c'], ['d']]);
assert.deepEqual(frame.voters, ['a', 'c', 'd']);
assert.deepEqual(frame.accepted, []);
assert.equal(frame.extraStrongholds, null);
validateVictoryContinuation(frame);
});
for (const extra of [0, 3, MAX_VICTORY_EXTRA_STRONGHOLDS]) void test(
`the exact shared proposal of ${extra} extra strongholds requires every qualifier's acceptance`, () => {
const initial = source(), original = structuredClone(initial);
const first = voteVictoryContinuation(initial, { event: initial.event, player: 'a', choice: 'continue', extraStrongholds: extra });
assert.equal(first.outcome, 'pending');
if (first.outcome !== 'pending') return;
assert.deepEqual(initial, original);
assert.deepEqual(first.frame.accepted, ['a']);
assert.equal(first.frame.extraStrongholds, extra);
const restored = JSON.parse(JSON.stringify(first.frame)) as VictoryContinuation;
validateVictoryContinuation(restored);
const second = voteVictoryContinuation(restored, { event: initial.event, player: 'c', choice: 'continue' });
assert.equal(second.outcome, 'pending');
if (second.outcome !== 'pending') return;
assert.deepEqual(second.frame.accepted, ['a', 'c']);
assert.deepEqual(voteVictoryContinuation(second.frame, { event: initial.event, player: 'd', choice: 'continue' }),
{ outcome: 'continue', extraStrongholds: extra });
},
);
void test('any qualifying participant may choose the original victory instead', () => {
const frame = source();
assert.deepEqual(voteVictoryContinuation(frame, { event: frame.event, player: 'a', choice: 'finish' }), { outcome: 'finish' });
const first = voteVictoryContinuation(frame, { event: frame.event, player: 'a', choice: 'continue', extraStrongholds: 1 });
assert.equal(first.outcome, 'pending');
if (first.outcome !== 'pending') return;
assert.deepEqual(voteVictoryContinuation(first.frame, { event: frame.event, player: 'c', choice: 'finish' }), { outcome: 'finish' });
});
void test('stale events, non-voters and unauthorized proposal changes reject without changing the receipt', () => {
const frame = source();
for (const choice of [
{ event: 'old', player: 'a', choice: 'continue', extraStrongholds: 0 },
{ event: frame.event, player: 'b', choice: 'continue', extraStrongholds: 0 },
{ event: frame.event, player: 'c', choice: 'continue', extraStrongholds: 0 },
{ event: frame.event, player: 'a', choice: 'continue' },
{ event: frame.event, player: 'a', choice: 'continue', extraStrongholds: -1 },
{ event: frame.event, player: 'a', choice: 'continue', extraStrongholds: 21 },
{ event: frame.event, player: 'a', choice: 'continue', extraStrongholds: 0.5 },
{ event: frame.event, player: 'a', choice: 'finish', extraStrongholds: 1 },
] as VictoryContinuationChoice[]) reject(frame, choice);
const first = voteVictoryContinuation(frame, { event: frame.event, player: 'a', choice: 'continue', extraStrongholds: 2 });
assert.equal(first.outcome, 'pending');
if (first.outcome !== 'pending') return;
reject(first.frame, { event: frame.event, player: 'a', choice: 'continue' });
reject(first.frame, { event: frame.event, player: 'c', choice: 'continue', extraStrongholds: 3 });
});
void test('unsupported source profiles and forged saved receipts are rejected', () => {
const frame = source(), snapshot = structuredClone(frame);
assert.throws(() => createVictoryContinuation({ event: 'turn10', turn: 10, resume: 'mentat',
order: ['a', 'b', 'c'], sides: [['a', 'b'], ['c']] }), VictoryContinuationError);
assert.throws(() => createVictoryContinuation({ event: 'solos', turn: 2, resume: 'mentat',
order: ['a', 'b'], sides: [['a'], ['b']] }), VictoryContinuationError);
assert.throws(() => createVictoryContinuation({ event: 'overlap', turn: 2, resume: 'mentat',
order: ['a', 'b', 'c'], sides: [['a', 'b'], ['b', 'c']] }), VictoryContinuationError);
for (const forged of [
{ ...frame, extraStrongholds: 0 },
{ ...frame, accepted: ['c'], extraStrongholds: 0 },
{ ...frame, voters: ['a', 'd'] },
{ ...frame, sides: [['a', 'c'], ['a', 'd']] },
{ ...frame, turn: 10 },
{ ...frame, accepted: ['a', 'c', 'd'], extraStrongholds: 0 },
{ ...frame, extra: true },
]) assert.throws(() => validateVictoryContinuation(forged as VictoryContinuation), VictoryContinuationError);
assert.deepEqual(frame, snapshot);
});