Enable Basic Kull by default and suspend Sapho turns

This commit is contained in:
Quintine committed 2026-10-27 21:51:43 +11:00
1 parent f96183f350
commit e9154a90f1
20 files changed
+669 -27

No files matched your search

+65
View File
@@ -0,0 +1,65 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import { actKullPurpose, advanceKullPurpose, kullPurposeClean, kullPurposePlayer, type KullPurposeStep } from './fixture-kull-purpose';
export const act = actKullPurpose;
export const advance = advanceKullPurpose;
export const clean = kullPurposeClean;
export const player = kullPurposePlayer;
/** Treachery cards in the physical zones exercised by this native programme. */
export function defaultKullInventory(g: Game): string[] {
const cards = [...g.deck, ...g.discard, ...g.players.flatMap(p => p.hand),
...(g.auction?.cards.slice(g.auction.index) ?? []), ...(g.richeseCache ?? [])].map(card => card.id).sort();
assert.equal(new Set(cards).size, cards.length, 'Every Treachery card is in one physical zone');
return cards;
}
/** Normal public Basic CHOAM/Emperor/Guild lobby. The host really starts, then
* no Kull opt-in is submitted. We select only an unopened original starting deal and Spice prefix.
* Everything after that uses native setup, auction and movement actions. */
export function createDefaultChoamKullFixture() {
let game = createGame('PUBLICKULL', newPlayer('choam', 'CHOAM', 'choam'), false, ['choam', 'ix']);
for (const faction of ['emperor', 'guild'] as const) joinGame(game, newPlayer(faction, faction, faction));
const actions: KullPurposeStep[] = [];
const send = (actor: string, action: Action) => { game = act(game,actor,action,actions); };
const reach = (predicate: (g: Game) => boolean) => { game = advance(game,predicate,actions); };
for (const p of game.players) send(p.id,{type:'ready'});
const lobby = structuredClone(game);
send(game.host,{type:'start'});
const setup = structuredClone(game);
assert.equal(game.status,'setup');
assert.equal(game.kullPreview,true,'A supported fresh Basic start enables printed Kull without a separate opt-in');
assert.ok(game.players.every(p => p.hand.length === 0));
const originalCards = game.deck.map(card => card.id).sort();
const selected: Game['deck'] = [];
for (const name of ['kull','karama','karama']) {
const i = game.deck.findIndex(card => name === 'kull' ? card.id === 'ix-kull-wahad' : card.effect === 'karama');
assert.ok(i >= 0); selected.push(game.deck.splice(i,1)[0]);
}
const unopenedDeal = selected.map((card,index) => ({player:game.players[index].id,card:card.id}));
game.deck.unshift(...selected);
const beforeDeal = structuredClone(game);
reach(g => g.status === 'playing');
const afterSetup = structuredClone(game);
const [kull,karama,counter] = selected.map(card => card.id);
assert.ok(player(game,'choam').hand.some(card => card.id === kull));
assert.ok(player(game,'emperor').hand.some(card => card.id === karama));
assert.ok(player(game,'guild').hand.some(card => card.id === counter));
assert.deepEqual(defaultKullInventory(game),originalCards);
const i = game.spiceDeck.findIndex(card => 'territory' in card && !card.discovery); assert.ok(i >= 0);
game.spiceDeck.unshift(game.spiceDeck.splice(i,1)[0]);
reach(g => g.phase === 3 && !!g.auction && g.auction.active === 'emperor' && clean(g));
const auction = structuredClone(game);
assert.deepEqual(defaultKullInventory(auction),originalCards);
reach(g => g.phase === 5 && g.active === 'choam' && clean(g));
send('choam',{type:'endMovement'});
reach(g => g.phase === 5 && g.active === 'emperor' && clean(g));
assert.deepEqual(defaultKullInventory(game),originalCards);
return {game,lobby,setup,beforeDeal,afterSetup,auction,actions,originalCards,unopenedDeal,
kull,karama,counter,actor:'emperor',reactor:'choam',counterOwner:'guild',
staging:'No Kull opt-in is submitted. Selected only original unopened starting cards and a safe unopened Spice prefix; all setup, auction passes and turn changes use real native actions.'};
}
export type DefaultChoamKullFixture = ReturnType<typeof createDefaultChoamKullFixture>;
export const defaultKullAttempt = (f: DefaultChoamKullFixture): Action =>
({type:'card',mode:'shipment',card:f.karama,target:f.actor});
+76
View File
@@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { kullDefaultEligible, kullDefaultProfile, type KullDefaultProfile } from '../game/kull-default-profile';
const lobby: KullDefaultProfile = {
status: 'lobby', advanced: false, expansions: ['choam', 'ix'],
players: [{ faction: 'choam' }, { faction: 'atreides' }],
};
void test('fresh Basic CHOAM with the supported selected decks has native Kull', () => {
assert.deepEqual(kullDefaultProfile(lobby), { eligible: true, reason: null });
assert.equal(kullDefaultEligible(lobby), true);
assert.equal(kullDefaultEligible({ ...lobby, players: [{ faction: 'choam' }] }), true,
'show the choice before other seats join; the start enforces its own minimum');
for (const faction of ['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen',
'ixians', 'tleilaxu', 'richese'])
assert.equal(kullDefaultEligible({ ...lobby, players: [{ faction: 'choam' }, { faction }] }), true, faction);
for (const faction of ['ecaz', 'moritani']) {
assert.equal(kullDefaultEligible({ ...lobby, players: [{ faction: 'choam' }, { faction }] }), false, faction);
assert.equal(kullDefaultEligible({ ...lobby, expansions: ['ix', 'choam', 'ecaz'],
players: [{ faction: 'choam' }, { faction }] }), true, faction);
}
});
void test('ongoing games, Advanced rules, absent CHOAM and unsupported deck selections do not qualify', () => {
for (const profile of [
{ ...lobby, status: 'playing' }, { ...lobby, status: 'setup' }, { ...lobby, advanced: true },
{ ...lobby, players: [{ faction: 'richese' }, { faction: 'atreides' }] },
{ ...lobby, expansions: ['choam'] }, { ...lobby, expansions: ['ix'] },
{ ...lobby, expansions: ['choam', 'ix', 'unknown'] },
{ ...lobby, expansions: ['choam', 'ix', 'ix'] },
{ ...lobby, players: [{ faction: 'choam' }, { faction: 'unknown' }] },
{ ...lobby, players: [] },
{ ...lobby, players: [{ faction: 'choam' }, { faction: 'choam' }] },
{ ...lobby, players: ['choam', 'atreides', 'fremen', 'guild', 'emperor', 'harkonnen', 'ixians']
.map(faction => ({ faction })) },
]) {
const result = kullDefaultProfile(profile);
assert.equal(result.eligible, false); assert.ok(result.reason);
}
});
void test('each optional module blocks automatic entry from both engine and public lobby shapes', () => {
for (const option of [
{ lobbyOptions: { leaderSkills: true } }, { lobbyOptions: { ecazTreachery: true } },
{ lobbyModules: { leaderSkills: true } }, { lobbyModules: { ecazTreachery: true } },
{ lobbyModules: { nexusCards: true } }, { lobbyModules: { discoveries: true } },
{ leaderSkills: {} }, { ecazTreachery: true }, { nexusCards: {} }, { discoveryEnabled: true },
{ discoveries: {} }, { discoveryStash: {} }, { greatMaker: {} },
{ homeworlds: {} }, { techTokens: {} }, { strongholdCards: {} },
]) {
assert.equal(kullDefaultEligible({ ...lobby, ...option }), false);
}
assert.equal(kullDefaultEligible({ ...lobby,
lobbyModules: { leaderSkills: false, ecazTreachery: false, nexusCards: false, discoveries: false },
lobbyOptions: { leaderSkills: false, ecazTreachery: false } }), true);
});
void test('public eligibility does not change when the host chooses an opt-out', () => {
// The profile quote only needs to understand module prerequisites, not the
// key representing the host's choice. The caller interprets that choice.
const selection = { leaderSkills: false, kullDisabled: true };
const selectedOn = { ...selection, kullDisabled: false };
assert.equal(kullDefaultEligible({ ...lobby, lobbyOptions: selection }), true);
assert.equal(kullDefaultEligible({ ...lobby, lobbyOptions: selectedOn }), true);
});
void test('profile reads only public faction and configuration data, never hidden hands or spice', () => {
const player = { faction: 'choam', get hand() { throw Error('private hand read'); },
get spice() { throw Error('private spice read'); } };
const rival = { faction: 'ixians', get hand() { throw Error('private rival hand read'); } };
const profile = { ...lobby, players: [player, rival] };
assert.equal(kullDefaultEligible(profile), true);
assert.deepEqual(profile.expansions, ['choam', 'ix']);
assert.equal(profile.status, 'lobby');
});
+65
View File
@@ -0,0 +1,65 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
import { act, advance, clean, createDefaultChoamKullFixture, defaultKullAttempt } from './fixture-default-choam-kull';
function lobby(): Game {
const game = createGame('KULLDEFAULT', newPlayer('choam', 'CHOAM', 'choam'), false, ['choam', 'ix']);
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
return game;
}
function start(game: Game): Game {
for (const seat of game.players) game = act(game, seat.id, { type: 'ready' });
return act(game, game.host, { type: 'start' });
}
void test('a genuine fresh Basic lobby enables printed Kull and a real interception by default', () => {
const f = createDefaultChoamKullFixture();
const view = viewGame(f.lobby, f.reactor);
assert.equal(view.lobbyModules?.kullDefault, true);
assert.equal(view.lobbyModules?.kullPreview, true);
assert.equal(f.setup.kullPreview, true);
assert.equal(f.setup.kullDefaultPolicy, undefined);
assert.equal(f.setup.lobbyOptions, undefined);
let game = act(f.game, f.actor, defaultKullAttempt(f));
const reaction = viewGame(game, f.reactor).kullReaction; assert.ok(reaction);
assert.ok(reaction.plays.some(play => play.source === 'printed' && play.card.id === f.kull));
game = act(game, f.reactor, { type: 'kullDecision', event: reaction.event, source: 'printed', card: f.kull });
game = advance(game, clean);
assert.ok(viewGame(game, f.actor).karamaBlocked);
assert.equal(game.discard.filter(card => card.id === f.kull).length, 1);
});
void test('the host can opt out of the new default and then opt back in', () => {
let game = lobby();
assert.equal(viewGame(game, game.host).lobbyModules?.kullPreview, true);
for (const seat of game.players) game = act(game, seat.id, { type: 'ready' });
game = act(game, game.host, { type: 'moduleOption', module: 'kullPreview', enabled: false });
assert.ok(game.players.every(seat => !seat.ready));
assert.equal(viewGame(game, game.host).lobbyModules?.kullPreview, false);
assert.equal(game.lobbyOptions?.kullOptOut, true);
assert.equal(start(structuredClone(game)).kullPreview ?? false, false);
game = act(game, game.host, { type: 'moduleOption', module: 'kullPreview', enabled: true });
assert.equal(viewGame(game, game.host).lobbyModules?.kullPreview, true);
assert.equal(game.lobbyOptions?.kullOptOut, undefined);
assert.equal(game.lobbyOptions?.kullPreview, undefined, 'Re-enabling returns to the automatic policy');
const changed = act(structuredClone(game), game.host, { type: 'moduleOption', module: 'leaderSkills', enabled: true });
assert.equal(viewGame(changed, changed.host).lobbyModules?.kullDefault, false);
assert.equal(viewGame(changed, changed.host).lobbyModules?.kullPreview, false);
assert.ok(changed.players.every(seat => !seat.ready));
assert.equal(start(game).kullPreview, true);
});
void test('saved pre-default lobbies retain their original opt-in selection', () => {
const old = lobby(); delete old.kullDefaultPolicy;
assert.equal(viewGame(old, old.host).lobbyModules?.kullPreview, false);
assert.equal(start(old).kullPreview ?? false, false);
});
void test('an unsupported configuration does not silently enable the preview', () => {
const game = createGame('KULLUNSUPPORTED', newPlayer('choam', 'CHOAM', 'choam'), false, ['choam']);
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
assert.equal(viewGame(game, game.host).lobbyModules?.kullDefault, false);
assert.equal(viewGame(game, game.host).lobbyModules?.kullPreview, false);
assert.equal(start(game).kullPreview ?? false, false);
});
@@ -30,6 +30,7 @@ void test('public host lobby shows the explicitly selected Kull preview and its
const f = fixture(), host = viewGame(f.lobby,f.lobby.host), html = table(host);
assert.equal(host.lobbyModules?.kullPreview,true);
assert.match(html,/Kull Wahad · Basic development preview/);
assert.match(html,/Printed Kull is enabled by default/);
assert.match(html,/does not support phase-wide bid or combined turn-action Truthtrance questions/);
assert.match(html,/defers unfunded non-CHOAM Karama-dependent bids/);
assert.match(html,/When Richese is seated, structured bid, alliance, Revival and bribe commitments are unavailable/);
@@ -37,6 +38,7 @@ void test('public host lobby shows the explicitly selected Kull preview and its
const unselected = viewGame(off,off.host);
assert.equal(unselected.lobbyModules?.kullPreview,false);
assert.match(table(unselected),/Kull Wahad · Basic development preview/);
assert.match(table(unselected),/The host has turned off the printed Kull preview/);
assert.doesNotMatch(table(unselected),/does not support phase-wide bid or combined turn-action Truthtrance questions/);
const nonhost = table(viewGame(f.lobby,f.actor));
assert.match(nonhost,/disabled=""[^>]*type="checkbox"|type="checkbox"[^>]*disabled=""/);
+5 -4
View File
@@ -26,7 +26,7 @@ function declare(g: Game,kull: string) {
return act(g,'choam',{type:'kullDecision',event:reaction.event,source:'printed',card:kull});
}
void test('Kull is a host-selected lobby preview and default Basic Truth promises retain their existing scope', () => {
void test('Kull is a host-configurable Basic lobby preview with supported Truth promises', () => {
let lobby = createLobby();
assert.equal(lobby.lobbyOptions?.kullPreview,undefined);
assert.equal(bribePromiseModeSupported(lobby),true);
@@ -34,14 +34,15 @@ void test('Kull is a host-selected lobby preview and default Basic Truth promise
reject(lobby,'choam',{type:'moduleOption',module:'kullPreview',enabled:'true'});
reject(lobby,'choam',{type:'moduleOption',module:'kullPreview',enabled:true,other:true});
const baseline = act(ready(lobby),'choam',{type:'start'});
assert.equal(baseline.kullPreview ?? false,false);
assert.equal(baseline.kullPreview,true);
assert.equal(bribePromiseModeSupported(baseline),true);
lobby = ready(lobby);
lobby = act(lobby,'choam',{type:'moduleOption',module:'kullPreview',enabled:true});
assert.ok(lobby.players.every(seat => !seat.ready));
assert.equal(lobby.lobbyOptions?.kullPreview,true);
assert.equal(lobby.lobbyOptions?.kullPreview,undefined);
lobby = act(lobby,'choam',{type:'moduleOption',module:'kullPreview',enabled:false});
assert.equal(lobby.lobbyOptions?.kullPreview,undefined);
assert.equal(lobby.lobbyOptions?.kullOptOut,true);
const f = fixture();
assert.equal(f.setup.kullPreview,true);
assert.equal(bribePromiseModeSupported(f.setup),true,'Basic printed Kull now supports the structured bribe family');
@@ -55,8 +56,8 @@ void test('incompatible opt-in starts reject without modifying their original lo
{advanced:false,expansions:['choam','ix'],last:'guild' as const,module:true},
]) {
let g = createLobby(config.advanced,config.expansions,config.last);
g = act(g,'choam',{type:'moduleOption',module:'kullPreview',enabled:true});
if (config.module) g = act(g,'choam',{type:'moduleOption',module:'leaderSkills',enabled:true});
g = act(g,'choam',{type:'moduleOption',module:'kullPreview',enabled:true});
g = ready(g);
reject(g,'choam',config.advanced ? {type:'start',advancedPreview:true} : {type:'start'});
}
+87
View File
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
reorderSaphoMovementOrder, saphoMovementOrderEvent, SaphoMovementOrderError,
type SaphoMovementOrderContext,
} from '../game/sapho-movement-order';
function context(): SaphoMovementOrderContext {
return {
queue: {
event: 'movement:3', eligible: ['done', 'active', 'later', 'last'],
remaining: ['active', 'later', 'last'], completed: ['done'],
current: 'active', currentStarted: true, protectedLast: null,
},
started: ['done', 'active'], guild: null, allowStartedLast: true,
};
}
function request(input: SaphoMovementOrderContext, player = 'active', mode: 'first' | 'last' = 'last') {
return reorderSaphoMovementOrder(input, { event: saphoMovementOrderEvent(input), player, mode });
}
function reject(input: SaphoMovementOrderContext, player = 'active', mode: 'first' | 'last' = 'last', event?: string) {
const before = structuredClone(input);
assert.throws(() => reorderSaphoMovementOrder(input, {
event: event ?? saphoMovementOrderEvent(input), player, mode,
}), SaphoMovementOrderError);
assert.deepEqual(input, before);
}
void test('a Basic started actor suspends the same opportunity at a protected last position', () => {
const input = context(), before = structuredClone(input), event = saphoMovementOrderEvent(input);
const result = request(input);
assert.equal(result.event, event);
assert.equal(result.afterCurrent, null);
assert.equal(result.suspended, 'active');
assert.deepEqual(result.next, {
event: 'movement:3', eligible: ['done', 'active', 'later', 'last'],
remaining: ['later', 'last', 'active'], completed: ['done'],
current: 'later', currentStarted: false, protectedLast: 'active',
});
assert.deepEqual(input, before);
assert.notEqual(result.next.eligible, input.queue.eligible);
assert.notEqual(result.next.completed, input.queue.completed);
assert.notEqual(result.next.remaining, input.queue.remaining);
});
void test('started self-last needs explicit Basic admission and cannot create a first or duplicate turn', () => {
const input = context();
reject({ ...input, allowStartedLast: undefined });
reject({ ...input, allowStartedLast: false });
reject(input, 'active', 'first');
reject(input, 'done');
const alone = context();
alone.queue = { ...alone.queue, eligible: ['done', 'active'], remaining: ['active'], completed: ['done'] };
reject(alone);
const advanced = context();
advanced.guild = { player: 'last', granted: false, locked: false };
reject(advanced);
const proposed = request(input);
const already = { ...input, queue: proposed.next, started: ['done', 'active'] };
assert.throws(() => saphoMovementOrderEvent(already), SaphoMovementOrderError);
});
void test('a started actor is bound to its current phase, actor and whole remaining tail', () => {
const input = context(), event = saphoMovementOrderEvent(input);
assert.notEqual(event, input.queue.event);
const changed = context();
changed.queue = { ...changed.queue, remaining: ['active', 'last', 'later'] };
reject(changed, 'active', 'last', event);
const nextPhase = context(); nextPhase.queue = { ...nextPhase.queue, event: 'movement:4' };
reject(nextPhase, 'active', 'last', event);
const changedActor = context();
changedActor.queue = { ...changedActor.queue, remaining: ['later', 'active', 'last'], current: 'later' };
changedActor.started = ['done', 'later'];
reject(changedActor, 'later', 'last', event);
const invalid = context(); invalid.started = ['active', 'later'];
assert.throws(() => saphoMovementOrderEvent(invalid), SaphoMovementOrderError);
});
void test('explicit admission does not change another player’s existing queued-turn reorder', () => {
const input = context(), before = structuredClone(input);
const result = request(input, 'last', 'first');
assert.equal(result.afterCurrent, 'active');
assert.equal(result.suspended, undefined);
assert.deepEqual(result.next.remaining, ['active', 'last', 'later']);
assert.equal(result.next.currentStarted, true);
assert.deepEqual(input, before);
});
+155
View File
@@ -0,0 +1,155 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { TERRITORIES } from '../game/board';
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
import { holdSemutaHomeworldChoamCard, reactSemutaHomeworldChoam } from './fixture-semuta-homeworld-choam';
import {
actSaphoStartedMovement as act,
advanceSaphoStartedMovement as advance,
createSaphoStartedMovementFixture as fixture,
saphoStartedMovementAction as saphoAction,
saphoStartedMovementClean as clean,
saphoStartedMovementInventory as inventory,
saphoStartedMovementPlayer as player,
} from './fixture-sapho-started-movement';
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
/** Reach Emperor's actual turn after Richese's original paid shipment and
* completed earlier turns. Only the inherited unopened setup/deck choices and
* genuine public actions are used; no post-deal player or queue is staged. */
function source() {
const f = fixture();
let game = advance(structuredClone(f.game), state =>
state.phase === 5 && clean(state) && state.active === f.owner);
assert.equal(player(game, f.owner).shipped, false);
assert.equal(player(game, f.owner).moved, 0);
assert.ok(game.movementRemaining?.some(id => id !== f.owner));
const prior = saphoAction(game, 'last', f.owner);
const beforeShipment = structuredClone(game);
const declared = act(game, f.owner, {
type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0,
});
game = advance(declared, clean);
assert.equal(game.active, f.owner);
assert.equal(player(game, f.owner).shipped, true);
assert.equal(player(game, f.owner).moved, 0);
assert.equal(player(beforeShipment, f.owner).reserves - player(game, f.owner).reserves, 1);
assert.equal(player(beforeShipment, f.owner).spice - player(game, f.owner).spice, 2);
assert.equal(player(game, f.owner).forces['polar_sink:0'], 1);
assert.deepEqual(inventory(game), f.originalInventory);
return { f, game, prior, beforeShipment, declared };
}
void test('Basic Sapho last suspends the holder after a real paid shipment and resumes its movement once', () => {
const { f, game: original, prior } = source();
const option = viewGame(original, f.owner).saphoOptions.find(row => row.scope === 'movement' && row.mode === 'last');
assert.ok(option, 'The holder can put its already-started turn last after the shipment finishes');
const proposed: Action = { type: 'card', card: f.card, scope: option.scope, mode: option.mode, event: option.event };
assert.notEqual(option.event, prior.event);
reject(original, f.owner, prior);
const before = structuredClone(player(original, f.owner));
let game = advance(act(original, f.owner, proposed), clean);
assert.equal(game.active, f.guild, 'The original next Guild turn precedes the suspended owner');
assert.ok(game.movementRemaining?.includes(f.owner));
assert.equal(game.saphoMovementLast?.player, f.owner);
assert.equal(player(game, f.owner).spice, before.spice);
assert.equal(player(game, f.owner).reserves, before.reserves);
assert.deepEqual(player(game, f.owner).forces, before.forces);
assert.equal(player(game, f.owner).shipped, true);
assert.equal(player(game, f.owner).moved, 0);
assert.equal(game.discard.filter(card => card.id === f.card).length, 1);
assert.deepEqual(inventory(game), f.originalInventory);
game = advance(game, state => state.phase === 5 && clean(state) && state.active === f.owner);
assert.equal(player(game, f.owner).shipped, true);
assert.equal(player(game, f.owner).moved, 0);
assert.equal(player(game, f.owner).forces['polar_sink:0'], 1);
reject(game, f.owner, { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 });
game = act(game, f.owner, { type: 'endMovement' });
assert.notEqual(game.phase, 5);
assert.deepEqual(inventory(game), f.originalInventory);
});
void test('Basic own-last cannot be played while the original shipment is awaiting an owned continuation', () => {
const { f, prior, declared } = source();
assert.ok(!clean(declared), 'The actual paid shipment opens an original response');
assert.equal(viewGame(declared, f.owner).saphoOptions.some(row => row.scope === 'movement'), false);
reject(declared, f.owner, prior);
assert.ok(player(declared, f.owner).hand.some(card => card.id === f.card));
assert.deepEqual(inventory(declared), f.originalInventory);
});
void test('Basic own-last preserves a completed ground movement without granting another', () => {
const { f, game: original } = source();
let moved: Game | undefined;
let destination: string | undefined;
for (const place of TERRITORIES) {
for (const sector of place.sectors) {
try {
moved = act(original, f.owner, { type: 'move', from: 'polar_sink:0', territory: place.id,
sector, amount: 1, elite: 0 });
destination = `${place.id}:${sector}`;
break;
} catch { /* Try another physical destination using the native validator. */ }
}
if (moved) break;
}
assert.ok(moved && destination, 'The real force has at least one legal movement');
let game = advance(moved, clean);
assert.equal(player(game, f.owner).moved, 1);
const action = saphoAction(game, 'last', f.owner);
game = advance(act(game, f.owner, action), clean);
game = advance(game, state => state.phase === 5 && clean(state) && state.active === f.owner);
assert.equal(player(game, f.owner).moved, 1);
assert.equal(player(game, f.owner).forces[destination], 1);
reject(game, f.owner, { type: 'move', from: destination, territory: 'polar_sink', sector: 0, amount: 1, elite: 0 });
game = act(game, f.owner, { type: 'endMovement' });
assert.notEqual(game.phase, 5);
assert.deepEqual(inventory(game), f.originalInventory);
});
void test('the minimal bot can use the actual Basic own-last opportunity', () => {
const { f, game } = source();
const view = viewGame(game, f.owner);
view.players.find(seat => seat.id === f.owner)!.bot = 'Easy';
const action = botActions(view).find(candidate => candidate.type === 'card' && candidate.card === f.card &&
candidate.scope === 'movement' && candidate.mode === 'last');
assert.ok(action);
const next = advance(act(structuredClone(game), f.owner, action), clean);
assert.notEqual(next.active, f.owner);
assert.equal(player(next, f.owner).shipped, true);
assert.deepEqual(inventory(next), f.originalInventory);
});
for (const claim of [false, true]) {
void test(`a conserved physical Semuta ${claim ? 'claim' : 'pass'} resumes the same suspended Basic turn`, () => {
const { f, game: original } = source();
const game = structuredClone(original);
// Controlled supplemental custody: move the original drug to Richese;
// no physical card is minted, and the paid shipment remains a real action.
holdSemutaHomeworldChoamCard(game, 'richese', 'Semuta Drug');
assert.deepEqual(inventory(game), f.originalInventory);
const action = saphoAction(game, 'last', f.owner);
const offered = act(game, f.owner, action);
assert.ok(offered.pendingTreacheryDiscard);
assert.equal(offered.active, f.owner);
assert.equal(viewGame(offered, 'richese').semutaReaction?.canCommit, true);
const resumed = reactSemutaHomeworldChoam(offered, 'richese', claim);
assert.equal(resumed.pendingTreacheryDiscard, null);
assert.equal(resumed.active, f.guild);
assert.equal(player(resumed, f.owner).shipped, true);
assert.equal(player(resumed, 'richese').hand.some(card => card.id === f.card), claim);
assert.equal(resumed.discard.some(card => card.id === SEMUTA_DRUG_ID), claim);
assert.deepEqual(inventory(resumed), f.originalInventory);
const returned = advance(resumed, state => state.phase === 5 && clean(state) && state.active === f.owner);
assert.equal(player(returned, f.owner).shipped, true);
reject(returned, f.owner, { type: 'ship', territory: 'polar_sink', sector: 0, amount: 1, elite: 0, allyPayment: 0 });
});
}
+12 -1
View File
@@ -103,13 +103,24 @@ void test('clean movement options keep their existing description and exact acti
}
});
void test('own-started Basic last explains the paused turn and submits only the native action', () => {
const game = projected('last', false, false);
game.saphoOptions = [{ scope: 'movement', event: 'started-owner-event', mode: 'last', resumeOwn: true }];
const html = panel(game);
assert.match(html, /Resume your movement last and discard Juice of Sapho/);
assert.match(html, /Pause your started turn and resume it last/);
assert.match(html, /Shipment and movement already used stay spent/);
assert.deepEqual(press(game.saphoOptions[0]), [{ type: 'card', card: sapho.id,
scope: 'movement', event: 'started-owner-event', mode: 'last' }]);
});
void test('unavailable movement guidance covers clean and queued windows without splitting turns or preparations', () => {
const game = projected();
game.saphoOptions = [];
const html = panel(game);
assert.match(html, /clean combined-turn boundaries and queuing behind a started active turn/);
assert.match(html, /Finish pending interactions and preparations first/);
assert.match(html, /Sapho never splits an active combined shipment and movement turn/);
assert.match(html, /your own started turn may be paused at a clean boundary and resumed last/);
assert.match(html, /last remains after the Guild/);
assert.doesNotMatch(html, /Take your movement turn (first|last) and discard/);
game.players.find(player => player.id === game.me)!.hand = [];
+1 -1
View File
@@ -135,7 +135,7 @@ for (const advanced of [false, true]) {
assert.deepEqual(inventory(game), f.originalInventory);
});
void test(`${label} an actual active Sapho holder cannot split its own already paid combined turn`, () => {
if (advanced) void test(`${label} an actual active Sapho holder cannot split its own already paid combined turn`, () => {
const f = fixture({ advanced });
let game = advance(structuredClone(f.game), state => clean(state) && state.active === f.owner);
const proposed = action(game, 'last');