Support agreed extended turns in Basic and Advanced

This commit is contained in:
Quintine committed 2026-10-27 23:29:04 +11:00
1 parent a3af6b755d
commit fcd93ca252
18 files changed
+506 -66

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { adminSeatAiCoreShape } from '../game/admin-seat-ai-shape';
import { applyAction, createGame, joinGame, newPlayer, type Game } from '../game/engine';
/** Genuine public Advanced setup; only the calendar fields below are staged. */
function source(advanced = true): Game {
let game = createGame('SEATEXTENDED', newPlayer('a', 'Atreides', 'atreides'), advanced);
joinGame(game, newPlayer('e', 'Emperor', 'emperor'));
for (const player of game.players) game = applyAction(game, player.id, { type: 'ready' });
return applyAction(game, game.host, { type: 'start', ...(advanced ? { advancedPreview: true } : {}) });
}
void test('seat AI shape accepts existing turns and explicitly bounded extended Basic and Advanced turns', () => {
const original = source();
for (const turn of [1, 2, 10]) {
const game = structuredClone(original);
game.turn = turn;
assert.equal(adminSeatAiCoreShape(game), true, `existing turn ${turn}`);
}
for (const [turn, finalTurn] of [[10, 11], [11, 11], [11, 12], [12, 12], [11, Number.MAX_SAFE_INTEGER]]) {
const game = structuredClone(original) as Game & { victoryFinalTurn: number };
game.turn = turn;
game.victoryFinalTurn = finalTurn;
const before = structuredClone(game);
assert.equal(adminSeatAiCoreShape(game), true, `turn ${turn} / final ${finalTurn}`);
assert.deepEqual(game, before);
}
const basic = source(false) as Game & { victoryFinalTurn: number };
basic.turn = 11;
basic.victoryFinalTurn = 11;
assert.equal(adminSeatAiCoreShape(basic), true);
});
void test('seat AI shape rejects absent or malformed extended-turn bounds without repair', () => {
const original = source();
for (const finalTurn of [undefined, null, 9, 10, -1, 11.5, '11', NaN, Infinity]) {
const game = structuredClone(original) as unknown as Record<string, unknown>;
game.turn = 11;
if (finalTurn !== undefined) game.victoryFinalTurn = finalTurn;
const before = structuredClone(game);
assert.equal(adminSeatAiCoreShape(game), false, `turn 11 / bound ${String(finalTurn)}`);
assert.deepEqual(game, before);
}
const invalidEarly = structuredClone(original) as unknown as Record<string, unknown>;
invalidEarly.victoryFinalTurn = 9;
assert.equal(adminSeatAiCoreShape(invalidEarly), false);
});
@@ -55,7 +55,27 @@ void test('equal qualified Advanced solo stronghold counts still share the win',
g.players[0].forces['hidden_mobile_stronghold:0'] = 1;
holds(g, 'at', 'sietch_tabr', 'tueks_sietch', 'habbanya_ridge_sietch');
assert.deepEqual(quoteVictory(g).winner, ['ix', 'at']);
assert.deepEqual(actual(g).winner, ['ix', 'at']);
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']);
});
void test('equally ranked Advanced solos may unanimously continue instead of sharing victory', () => {
const g = position();
holds(g, 'ix', 'arrakeen', 'carthag');
g.players[0].forces['hidden_mobile_stronghold:0'] = 1;
holds(g, 'at', 'sietch_tabr', 'tueks_sietch', 'habbanya_ridge_sietch');
let offered = actual(g);
assert.deepEqual(offered.victoryContinuation?.voters, ['ix', 'at']);
const event = offered.victoryContinuation!.event;
offered = applyAction(offered, 'ix', { type: 'victoryContinuation', event, mode: 'continue', extra: 1 });
offered = applyAction(offered, 'at', { type: 'victoryContinuation', event, mode: 'accept' });
assert.equal(offered.status, 'playing');
assert.equal(offered.victoryExtraStrongholds, 1);
assert.deepEqual(offered.winner, []);
});
void test('the complete Tech set is one extra stronghold for comparing qualified solos', () => {
@@ -75,7 +95,12 @@ void test('Basic retains its existing shared-win policy for unequal qualified so
holds(g, 'at', 'tueks_sietch', 'habbanya_ridge_sietch');
tech(g, 'at');
assert.deepEqual(quoteVictory(g).winner, ['ix', 'at']);
assert.deepEqual(actual(g).winner, ['ix', 'at']);
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']);
});
void test('a qualifying alliance and solo still share an Advanced win regardless of their totals', () => {
+38
View File
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { continueVictoryCalendar, victoryFinalTurn, VictoryCalendarError } from '../game/victory-calendar';
void test('an unstamped game retains turn ten as its final turn without mutation', () => {
const game = { turn: 4 };
assert.equal(victoryFinalTurn(game), 10);
assert.equal(continueVictoryCalendar(game), 10);
assert.deepEqual(game, { turn: 4 });
});
void test('unanimous continuation at the final turn moves the fallback one full turn', () => {
assert.equal(continueVictoryCalendar({ turn: 10 }), 11);
assert.equal(victoryFinalTurn({ turn: 11, victoryFinalTurn: 11 }), 11);
assert.equal(continueVictoryCalendar({ turn: 11, victoryFinalTurn: 11 }), 12);
assert.equal(continueVictoryCalendar({ turn: 10, victoryFinalTurn: 11 }), 11);
});
void test('an extended final-turn stamp survives an ordinary JSON continuation', () => {
const saved = JSON.parse(JSON.stringify({ turn: 11, victoryFinalTurn: 12 }));
assert.equal(victoryFinalTurn(saved), 12);
assert.equal(continueVictoryCalendar(saved), 12);
});
void test('invalid or expired calendars reject rather than silently extending', () => {
for (const game of [
{ turn: 0 }, { turn: 1.5 }, { turn: 11 },
{ turn: 10, victoryFinalTurn: 9 }, { turn: 11, victoryFinalTurn: 10 },
{ turn: 11, victoryFinalTurn: 11.5 }, { turn: NaN, victoryFinalTurn: 10 },
]) assert.throws(() => victoryFinalTurn(game), VictoryCalendarError);
assert.throws(() => victoryFinalTurn({ turn: 1, victoryFinalTurn: null } as never), VictoryCalendarError);
});
void test('extension rejects safe-integer overflow without changing the saved calendar', () => {
const game = { turn: Number.MAX_SAFE_INTEGER, victoryFinalTurn: Number.MAX_SAFE_INTEGER };
assert.throws(() => continueVictoryCalendar(game), VictoryCalendarError);
assert.equal(game.victoryFinalTurn, Number.MAX_SAFE_INTEGER);
});
+9 -6
View File
@@ -132,12 +132,15 @@ void test('zero-increase agreement preserves ordinary targets for the next turn'
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('Basic multiple qualifiers can agree while one qualifying side still wins directly', () => {
const basic = reachMentat(position({ advanced: false }));
assert.equal(basic.status, 'playing');
assert.ok(basic.victoryContinuation);
const settled = act(basic, basic.victoryContinuation.voters[0], 'finish');
assert.equal(settled.status, 'finished');
const single = reachMentat(position({ onlyAlliance: true }));
assert.equal(single.status, 'finished'); assert.ok(single.winner.length > 0);
assert.ok(!single.victoryContinuation);
});
void test('all four minimal bot profiles produce a legal response for the public continuation', () => {
+18 -4
View File
@@ -23,6 +23,20 @@ void test('allied rows are deduplicated and every qualifying member votes in pub
validateVictoryContinuation(frame);
});
void test('distinct solo sides may agree in turn ten and in a later extended turn', () => {
for (const turn of [10, 11]) {
const frame = createVictoryContinuation({ event: `victory:${turn}`, turn, resume: 'advance',
order: ['a', 'b', 'c'], sides: [['c'], ['a']] });
assert.deepEqual(frame.sides, [['a'], ['c']]);
assert.deepEqual(frame.voters, ['a', 'c']);
const first = voteVictoryContinuation(frame, { event: frame.event, player: 'a', choice: 'continue', extraStrongholds: 1 });
assert.equal(first.outcome, 'pending');
if (first.outcome === 'pending')
assert.deepEqual(voteVictoryContinuation(first.frame, { event: frame.event, player: 'c', choice: 'continue' }),
{ outcome: 'continue', extraStrongholds: 1 });
}
});
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);
@@ -73,10 +87,10 @@ void test('stale events, non-voters and unauthorized proposal changes reject wit
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',
assert.throws(() => createVictoryContinuation({ event: 'invalid', turn: 0, 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: 'single', turn: 2, resume: 'mentat',
order: ['a', 'b'], sides: [['a']] }), VictoryContinuationError);
assert.throws(() => createVictoryContinuation({ event: 'overlap', turn: 2, resume: 'mentat',
order: ['a', 'b', 'c'], sides: [['a', 'b'], ['b', 'c']] }), VictoryContinuationError);
for (const forged of [
@@ -84,7 +98,7 @@ void test('unsupported source profiles and forged saved receipts are rejected',
{ ...frame, accepted: ['c'], extraStrongholds: 0 },
{ ...frame, voters: ['a', 'd'] },
{ ...frame, sides: [['a', 'c'], ['a', 'd']] },
{ ...frame, turn: 10 },
{ ...frame, turn: 0 },
{ ...frame, accepted: ['a', 'c', 'd'], extraStrongholds: 0 },
{ ...frame, extra: true },
]) assert.throws(() => validateVictoryContinuation(forged as VictoryContinuation), VictoryContinuationError);
+212
View File
@@ -0,0 +1,212 @@
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, spiceDeck } from '../game/cards';
import { createTechTokens } from '../game/tech-tokens';
import type { FactionId } from '../game/catalog';
import { actAdvancedRevivalOrder, nextAdvancedRevivalOrder } from './fixture-advanced-revival-order';
/** Controlled, conserved late-game position. Original strongholds and Tech
* ownership are directly arranged to expose the rare simultaneous qualifying
* result. Collection, Mentat and agreement actions use the live engine; this
* is not an earned ten-turn game or a saved-room test. */
function position(options: { advanced?: boolean; guild?: boolean; fremen?: boolean; choam?: boolean; prediction?: boolean } = {}): Game {
const g = createGame('EXTENDEDVICTORY', newPlayer('ix', 'Ixians', 'ixians'), options.advanced ?? true, options.choam ? ['choam'] : []);
const seats: [string, string, FactionId][] = [
['at', 'Atreides', 'atreides'], ['em', 'Emperor', 'emperor'], ['bg', 'Bene Gesserit', 'beneGesserit'],
];
if (options.guild) seats.push(['gu', 'Guild', 'guild']);
if (options.fremen) seats.push(['fr', 'Fremen', 'fremen']);
if (options.choam) seats.push(['ch', 'CHOAM', 'choam']);
for (const [id, name, faction] of seats) g.players.push(newPlayer(id, name, faction));
assert.ok(g.players.length <= 6);
Object.assign(g, { status: 'playing', phase: 7, turn: 10, storm: 18,
order: g.players.map(player => player.id), ready: [], phaseOpening: null,
decision: null, response: null, active: null, deck: baseDeck(), spiceDeck: spiceDeck(),
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 hold = (id: string, ...names: string[]) => {
const p = g.players.find(player => player.id === id)!;
for (const name of names) p.forces[`${name}:${territory(name).sectors[0]}`] = 1;
p.reserves -= names.length;
};
g.players[1].ally = 'em'; g.players[2].ally = 'at';
hold('at', 'arrakeen', 'carthag'); hold('em', 'sietch_tabr');
hold('ix', 'tueks_sietch', 'habbanya_ridge_sietch');
g.players[0].forces['hidden_mobile_stronghold:0'] = 1; g.players[0].reserves--;
for (const token of Object.values(g.techTokens!)) token.owner = 'at';
if (options.prediction) g.players[3].prediction = { faction: 'atreides', turn: 10 };
return g;
}
function enterMentat(source: Game): Game {
let g = structuredClone(source);
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 });
assert.equal(g.phase, 8);
if (g.players.some(player => player.faction === 'choam')) {
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
if (g.decision?.kind === 'choamMarket')
g = applyAction(g, g.decision.player, { type: 'decision', done: true });
assert.equal(g.decision?.kind, 'choamMentat');
g = applyAction(g, g.decision.player, { type: 'decision', done: true });
}
return g;
}
function offer(g: Game) {
assert.ok(g.victoryContinuation, 'The actual Mentat check offers the qualifying sides a vote');
return g.victoryContinuation;
}
function vote(g: Game, id: string, mode: 'finish' | 'continue' | 'accept', extra?: number): Game {
return applyAction(g, id, { type: 'victoryContinuation', event: offer(g).event, mode,
...(extra === undefined ? {} : { extra }) });
}
function accept(g: Game, extra = 0): Game {
const voters = [...offer(g).voters];
g = vote(g, voters[0], 'continue', extra);
for (const id of voters.slice(1)) g = vote(g, id, 'accept');
return g;
}
function finishMentat(g: Game): Game {
if (g.phase !== 8) return g;
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
return g;
}
function reject(g: Game, player: string, action: Action) {
const before = structuredClone(g);
assert.throws(() => applyAction(g, player, action));
assert.deepEqual(g, before);
}
function startEleventh(source = position(), extra = 0): Game {
let g = accept(enterMentat(source), extra);
assert.equal(g.victoryFinalTurn, 11);
if (g.phase === 8) g = finishMentat(g);
assert.equal(g.status, 'playing'); assert.equal(g.turn, 11); assert.equal(g.phase, 0);
return g;
}
/** Controlled follow-up: retain the actual extended-turn state, but advance
* its phase/ready boundary directly to Collection. Tests below do not claim a
* full natural turn from Storm to Collection; all later checks and votes are
* native engine actions on the labelled board. */
function stagedCollection(source: Game): Game {
const g = structuredClone(source);
Object.assign(g, { phase: 7, ready: [], active: null, decision: null, response: null,
phaseOpening: null, stormPending: null, stormResolution: null });
return g;
}
function clearForces(g: Game) {
for (const p of g.players) {
p.reserves += Object.values(p.forces).reduce((total, amount) => total + amount, 0);
p.forces = {};
}
}
void test('unanimous turn-ten agreement opens real turn eleven without replaying the prior board', () => {
const original = position();
const opening = enterMentat(original);
const before = opening.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves }));
const agreed = accept(opening, 1);
assert.equal(agreed.turn, 10); assert.equal(agreed.phase, 8);
assert.equal(agreed.victoryFinalTurn, 11); assert.equal(agreed.victoryExtraStrongholds, 1);
assert.deepEqual(agreed.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves })), before);
const next = finishMentat(agreed);
assert.equal(next.status, 'playing'); assert.equal(next.turn, 11); assert.equal(next.phase, 0);
assert.equal(next.victoryFinalTurn, 11); assert.equal(viewGame(next, 'ix').turn, 11);
assert.deepEqual(next.players.map(p => ({ id: p.id, forces: p.forces, reserves: p.reserves })), before);
});
void test('Basic qualifying sides may also agree to play turn eleven', () => {
const next = startEleventh(position({ advanced: false }));
assert.equal(next.advanced, false);
assert.equal(next.victoryFinalTurn, 11);
});
void test('accepted turn eleven follows native phases through its next Mentat check', () => {
const source = position();
// The turn-ten public board is controlled as above. Select two existing,
// unopened ordinary Spice cards to avoid an unrelated worm negotiation;
// this is not a claim about their naturally shuffled original order.
const originalSpice = source.spiceDeck.map(card => JSON.stringify(card)).sort();
const front: Game['spiceDeck'] = [];
for (let i = 0; i < 2; i++) {
const index = source.spiceDeck.findIndex(card => 'territory' in card && !card.discovery);
assert.ok(index >= 0);
front.push(source.spiceDeck.splice(index, 1)[0]);
}
source.spiceDeck.unshift(...front);
assert.deepEqual(source.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
let game = startEleventh(source, 1);
const phases = new Set<number>();
const actions: { actor: string; action: Action }[] = [];
for (let count = 0; count < 1500 && game.status === 'playing' && game.phase !== 8; count++) {
assert.equal(game.turn, 11);
phases.add(game.phase);
const next = nextAdvancedRevivalOrder(game);
assert.ok(next, `Native turn-11 control unavailable at phase ${game.phase}, decision ${game.decision?.kind ?? 'none'}`);
game = actAdvancedRevivalOrder(game, next.actor, next.action, actions);
}
assert.equal(game.turn, 11);
assert.equal(game.phase, 8, 'The original phase sequence reaches the next Mentat check');
assert.ok(game.status === 'finished' || game.victoryContinuation,
'The extended final-turn check settles a winner or offers the next agreement');
assert.equal(game.victoryFinalTurn, 11);
assert.ok(actions.length > 0);
for (const phase of [0, 1, 2, 3, 4, 5, 7])
assert.ok(phases.has(phase), `Native phase ${phase} was not exercised`);
});
void test('a turn-ten refusal settles the original result and private prediction', () => {
const g = enterMentat(position({ prediction: true })), pending = offer(g);
reject(g, 'bg', { type: 'victoryContinuation', event: pending.event, mode: 'finish' });
const settled = vote(g, pending.voters[0], 'finish');
assert.equal(settled.status, 'finished'); assert.equal(settled.turn, 10);
assert.deepEqual(settled.winner, ['bg']); assert.ok(!settled.victoryContinuation);
});
void test('a second unanimous agreement at turn eleven extends the final turn again', () => {
const controlled = stagedCollection(startEleventh()), opening = enterMentat(controlled);
assert.equal(opening.turn, 11); assert.equal(offer(opening).turn, 11);
const agreed = accept(opening);
assert.equal(agreed.victoryFinalTurn, 12);
const next = finishMentat(agreed);
assert.equal(next.status, 'playing'); assert.equal(next.turn, 12); assert.equal(next.phase, 0);
assert.equal(next.victoryFinalTurn, 12);
});
void test('extended final-turn fallback applies Fremen, Guild and absent-faction outcomes', () => {
for (const [options, expected] of [
[{ guild: true, fremen: true }, 'fr'], [{ guild: true }, 'gu'], [{}, 'ix'],
] as const) {
const controlled = stagedCollection(startEleventh(position(options), 1));
clearForces(controlled);
// The Atreides-owned Tech set gives it one hold in the no-Guild fallback.
// Shift that physical set to Ix for an unambiguous highest-count winner.
for (const token of Object.values(controlled.techTokens!)) token.owner = 'ix';
const settled = enterMentat(controlled);
assert.equal(settled.turn, 11); assert.equal(settled.status, 'finished');
assert.deepEqual(settled.winner, [expected]);
}
});
void test('a sole qualifying stronghold winner precedes fallback on the extended final turn', () => {
const controlled = stagedCollection(startEleventh());
// Controlled conserved removal prevents the alliance from qualifying while
// leaving the original Ixian three-stronghold group intact.
const emperor = controlled.players.find(p => p.id === 'em')!;
emperor.reserves += Object.values(emperor.forces).reduce((sum, count) => sum + count, 0);
emperor.forces = {};
const settled = enterMentat(controlled);
assert.equal(settled.status, 'finished'); assert.deepEqual(settled.winner, ['ix']);
});
void test('CHOAM Mentat continuation advances exactly once from turn ten to eleven', () => {
const opening = enterMentat(position({ choam: true }));
assert.equal(offer(opening).resume, 'advance');
const next = accept(opening, 1);
assert.equal(next.status, 'playing'); assert.equal(next.turn, 11); assert.equal(next.phase, 0);
assert.equal(next.victoryFinalTurn, 11); assert.equal(next.victoryExtraStrongholds, 1);
});