Fresh Richese-roster battles support clean aggressor changes after plans and revelation. Both combatants explicitly finish a public final opportunity regardless of held cards or result; an actual Sapho use clears both marks with a fresh event. Close priority before physical Stone allocation and other outcome settlement. Preserve plans, votes, participants, Habbanya priority and reserved physical cards. Keep old unstamped timing and discard signature bytes. Route all final vote exits through native Homeworld defense first. Add persistent controls and finite minimal bot readiness. Validation:16-file177/177, types/lint/build and eight original-action/rendered-table programmes. Actual cache1 and paid forces use40 Basic/47 Advanced source actions; genuine Stone1 turn1/Sapho1 turn2 uses86. Ordinary and mixed count2 ties change through sealed-plan/final-window Sapho, explicit revised acknowledgements and native losses. Sixteen minimal proposals are legal; controlled reservation and pure window/control cases are separate. Bounded review fixed the Harkonnen Nexus final-vote route; frozen feedback corrected four historical fixture assumptions. All checks then passed together. Final-window protocol is provisional. Direct late Semuta claim/capture, combined World/Nexus/Sapho, wider combinations, full modes, final assurance and browser/deployed acceptance remain open. No saved-room/database/reset/restart/deployment operation.
254 lines
7.7 KiB
TypeScript
254 lines
7.7 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import {
|
|
applyAction,
|
|
createGame,
|
|
initializeFactionExpansionsGameForAudit,
|
|
initializeSemutaGameForAudit,
|
|
joinGame,
|
|
newPlayer,
|
|
viewGame,
|
|
type Action,
|
|
type Game,
|
|
} from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { treacheryDeck } from '../game/cards';
|
|
import { richeseCards } from '../game/richese-cards';
|
|
import { traitorDeck } from '../game/traitors';
|
|
|
|
export const SAPHO_BATTLE_CARD = 'richese-juice-of-sapho';
|
|
export function takeSaphoBattleCard(
|
|
g: Game,
|
|
owner: string,
|
|
card = SAPHO_BATTLE_CARD,
|
|
) {
|
|
for (const zone of [
|
|
g.deck,
|
|
g.discard,
|
|
g.richeseCache ?? [],
|
|
...g.players.map((p) => p.hand),
|
|
]) {
|
|
const at = zone.findIndex((c) => c.id === card);
|
|
if (at < 0) continue;
|
|
g.players.find((p) => p.id === owner)!.hand.push(zone.splice(at, 1)[0]);
|
|
return;
|
|
}
|
|
throw new Error(`Missing physical fixture card ${card}`);
|
|
}
|
|
/** Genuine faction setup, followed by explicitly conserved hands and multi-battle topology. */
|
|
export function saphoBattleOrderGame({
|
|
advanced = false,
|
|
holder = 'c',
|
|
seatIds = ['a', 'b', 'c', 'r'] as readonly [string, string, string, string],
|
|
geometry = 'shared' as 'shared' | 'separate',
|
|
semutaPreview = false,
|
|
} = {}): Game {
|
|
const factions = ['emperor', 'guild', 'atreides', 'richese'] as const;
|
|
let g = createGame(
|
|
'SAPHOBAT',
|
|
newPlayer(seatIds[0], 'Emperor', factions[0]),
|
|
advanced,
|
|
['choam'],
|
|
);
|
|
for (let i = 1; i < seatIds.length; i++)
|
|
joinGame(g, newPlayer(seatIds[i], factions[i], factions[i]));
|
|
for (const p of g.players) g = applyAction(g, p.id, { type: 'ready' });
|
|
g = semutaPreview ? initializeSemutaGameForAudit(g) : initializeFactionExpansionsGameForAudit(g);
|
|
for (let step = 0; g.status === 'setup' && step < 100; step++) {
|
|
const next = nextSaphoBattleAction(g);
|
|
assert.ok(next, 'Genuine setup must have an owned action.');
|
|
g = applyAction(g, next.player, next.action);
|
|
}
|
|
assert.equal(g.status, 'playing');
|
|
const shared: Record<string, number>[] = [
|
|
{ 'arrakeen:10': 3, 'carthag:11': 3 },
|
|
{ 'arrakeen:10': 3, 'carthag:11': 3, 'imperial_basin:9': 3 },
|
|
{ 'arrakeen:10': 3, 'imperial_basin:9': 3 },
|
|
{},
|
|
];
|
|
const separate: Record<string, number>[] = [
|
|
{ 'carthag:11': 4 },
|
|
{ 'carthag:11': 4 },
|
|
{ 'arrakeen:10': 4 },
|
|
{ 'arrakeen:10': 4 },
|
|
];
|
|
for (const [index, p] of g.players.entries()) {
|
|
g.deck.push(...p.hand);
|
|
p.hand = [];
|
|
p.forces = { ...(geometry === 'shared' ? shared[index] : separate[index]) };
|
|
p.reserves = 20 - Object.values(p.forces).reduce((a, b) => a + b, 0);
|
|
p.tanks = 0;
|
|
p.spice = 30;
|
|
p.shipped = false;
|
|
p.moved = 0;
|
|
if (p.elites) p.elites = { reserves: 5, tanks: 0, forces: {}, revived: 0 };
|
|
}
|
|
const index = ['a', 'b', 'c', 'r'].indexOf(holder);
|
|
const owner = index >= 0 ? seatIds[index] : holder;
|
|
takeSaphoBattleCard(g, owner);
|
|
// Starting from a clean combined-turn boundary avoids inventing completed battles.
|
|
Object.assign(g, {
|
|
turn: 2,
|
|
phase: 5,
|
|
storm: 18,
|
|
order: [...seatIds],
|
|
active: seatIds[0],
|
|
movementRemaining: [...seatIds],
|
|
guildTimingLocked: true,
|
|
guildTimingGranted: false,
|
|
ready: [],
|
|
response: null,
|
|
decision: null,
|
|
phaseOpening: null,
|
|
});
|
|
while (g.phase === 5) g = applyAction(g, g.active!, { type: 'endMovement' });
|
|
assert.equal(g.phase, 6);
|
|
saphoBattleCustody(g);
|
|
return g;
|
|
}
|
|
export function saphoBattleOrderAction(
|
|
g: Game,
|
|
owner: string,
|
|
mode: 'first' | 'last',
|
|
): Action {
|
|
const option = viewGame(g, owner).saphoOptions.find(
|
|
(o) => o.scope === 'battleOrder' && o.mode === mode,
|
|
);
|
|
assert.ok(option, `Missing ${mode} battle-order option for ${owner}.`);
|
|
return { type: 'card', card: SAPHO_BATTLE_CARD, ...option };
|
|
}
|
|
export function chooseSaphoBattleAction(g: Game): {
|
|
player: string;
|
|
action: Action;
|
|
} {
|
|
assert.ok(g.active && !g.battle);
|
|
const choice = viewGame(g, g.active).battleChoices.find(
|
|
(c) => c.chooser === g.active,
|
|
);
|
|
assert.ok(choice);
|
|
return {
|
|
player: g.active,
|
|
action: {
|
|
type: 'chooseBattle',
|
|
territory: choice.territory,
|
|
target: choice.attacker === g.active ? choice.defender : choice.attacker,
|
|
},
|
|
};
|
|
}
|
|
/** One real owner action, suitable for either applyAction or authenticated room.act. */
|
|
export function nextSaphoBattleAction(
|
|
g: Game,
|
|
keepSapho = false,
|
|
): { player: string; action: Action } | null {
|
|
if (keepSapho && g.battle && !g.response && !g.decision && !g.truthtrance && !g.phaseOpening) {
|
|
const b = g.battle;
|
|
const window = b.outcomeWindow ?? b.preLeader;
|
|
if (window && !window.closed) {
|
|
const player = [b.attacker, b.defender].find(id => !window.ready.includes(id));
|
|
if (player) return {player, action: {type: b.outcomeWindow ? 'battleOutcomeReady' : 'battlePreparationReady', event: window.event}};
|
|
}
|
|
if (b.revealed) {
|
|
const view = viewGame(g, b.attacker).battle!;
|
|
const player = view.traitorVoters.find(id => !view.traitorSubmitted.includes(id));
|
|
if (player) return {player, action: {type: 'traitorCall', call: false}};
|
|
}
|
|
}
|
|
if (
|
|
!g.response &&
|
|
!g.decision &&
|
|
g.battle?.preparation &&
|
|
(!g.battle.preLeader || g.battle.preLeader.closed) &&
|
|
['voice', 'prescience'].includes(g.battle.preparation.kind)
|
|
)
|
|
return {
|
|
player: g.battle.preparation.owner,
|
|
action: { type: 'declineBattlePower' },
|
|
};
|
|
for (const p of g.players) {
|
|
const view = viewGame(g, p.id);
|
|
view.players.find((player) => player.id === p.id)!.bot = 'Medium';
|
|
if (
|
|
g.battle &&
|
|
!g.response &&
|
|
!g.decision &&
|
|
!g.battle.preparation &&
|
|
(!g.battle.preLeader || g.battle.preLeader.closed) &&
|
|
!g.battle.prescience &&
|
|
!g.battle.nexusInspection &&
|
|
!g.battle.voice &&
|
|
[g.battle.attacker, g.battle.defender].includes(p.id) &&
|
|
!g.battle.plans[p.id]
|
|
) {
|
|
const leader = p.leaders.find(
|
|
(l) =>
|
|
!l.dead &&
|
|
!l.capturedBy &&
|
|
(!l.usedAt || l.usedAt === g.battle!.territory),
|
|
);
|
|
if (leader)
|
|
return {
|
|
player: p.id,
|
|
action: {
|
|
type: 'battlePlan',
|
|
dial: 0,
|
|
support: 0,
|
|
leader: leader.id,
|
|
},
|
|
};
|
|
}
|
|
const action = botActions(view)[0];
|
|
if (action) return { player: p.id, action };
|
|
}
|
|
return null;
|
|
}
|
|
export function resolveSaphoBattle(state: Game, keepSapho = false): Game {
|
|
let g = state;
|
|
assert.ok(g.battle);
|
|
for (let step = 0; step < 120; step++) {
|
|
if (
|
|
!g.battle &&
|
|
(g.phase !== 6 || viewGame(g, g.players[0].id).battleOrder?.current)
|
|
)
|
|
return g;
|
|
const next = nextSaphoBattleAction(g, keepSapho);
|
|
assert.ok(
|
|
next,
|
|
'The selected battle must retain a finite owned continuation.',
|
|
);
|
|
g = applyAction(g, next.player, next.action);
|
|
}
|
|
throw new Error('Battle continuation exceeded its fixture limit.');
|
|
}
|
|
export function saphoBattleCustody(g: Game) {
|
|
const physical = [
|
|
...g.deck,
|
|
...g.discard,
|
|
...(g.richeseCache ?? []),
|
|
...(g.richeseRemoved ?? []),
|
|
...g.players.flatMap((p) => p.hand),
|
|
];
|
|
assert.deepEqual(
|
|
physical.map((c) => c.id).sort(),
|
|
[...treacheryDeck(g.expansions), ...richeseCards()].map((c) => c.id).sort(),
|
|
);
|
|
assert.deepEqual(
|
|
[
|
|
...(g.traitorReserve ?? []),
|
|
...g.players.flatMap((p) => p.traitors),
|
|
].sort(),
|
|
traitorDeck(g.players, false).sort(),
|
|
);
|
|
for (const p of g.players) {
|
|
assert.equal(
|
|
p.reserves + p.tanks + Object.values(p.forces).reduce((a, b) => a + b, 0),
|
|
20,
|
|
);
|
|
if (p.elites)
|
|
assert.equal(
|
|
p.elites.reserves +
|
|
p.elites.tanks +
|
|
Object.values(p.elites.forces).reduce((a, b) => a + b, 0),
|
|
5,
|
|
);
|
|
}
|
|
}
|