Enable Basic Kull by default and suspend Sapho turns
This commit is contained in:
1 parent
f96183f350
commit
e9154a90f1
20 files changed
+669
-27
No files matched your search
@@ -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});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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=""/);
|
||||
|
||||
@@ -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'});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
}
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in new issue
Block a user