207 lines
12 KiB
TypeScript
207 lines
12 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
|
import { botActions } from '../game/bots';
|
|
import { DIFFICULTIES } from '../game/bot-profiles';
|
|
import { actTruthMovementNativeEarly as act, nextTruthMovementNativeEarlyStep } from './fixture-truth-movement-native-early';
|
|
|
|
const owner = 'richese', observer = 'emperor', choam = 'choam';
|
|
const seat = (game: Game, id: string) => {
|
|
const player = game.players.find(row => row.id === id); assert.ok(player); return player;
|
|
};
|
|
const clean = (game: Game) => !game.response && !game.decision && !game.phaseOpening &&
|
|
!game.truthtrance && !game.pendingTreacheryDiscard;
|
|
function next(game: Game) {
|
|
// The inherited native helper intentionally leaves Emperor's own move to
|
|
// callers. This programme completes the original turn without a shipment.
|
|
if (game.phase === 5 && game.active === observer && clean(game))
|
|
return {actor:observer, action:{type:'endMovement'} satisfies Action};
|
|
return nextTruthMovementNativeEarlyStep(game, {
|
|
position:'first', method:'onceAround', direction:'clockwise', keepUnbid:false,
|
|
});
|
|
}
|
|
function reach(game: Game, until: (state: Game) => boolean) {
|
|
for (let count = 0; count < 1800; count++) {
|
|
if (until(game)) return game;
|
|
assert.notEqual(game.status, 'finished', 'Native game ended before the requested window');
|
|
const step = next(game); assert.ok(step, `Supply native action at ${game.turn}/${game.phase}/${game.decision?.kind}`);
|
|
game = act(game, step.actor, step.action);
|
|
}
|
|
throw Error('Native No-Field programme did not reach the requested window');
|
|
}
|
|
|
|
/** Genuine public Basic Richese/CHOAM/Emperor, both selected native decks.
|
|
* Select only original unopened starting-card/Spice positions. Real setup,
|
|
* auction passes, one paid concealed shipment and native phase controls lead
|
|
* to the next turn. Polar Sink keeps the marker safe from Storm and battles. */
|
|
function nativeSource() {
|
|
let game = createGame('RICHESEINTERRUPT', newPlayer(owner, 'Richese', 'richese'), false, ['choam','ix']);
|
|
joinGame(game, newPlayer(choam, 'CHOAM', 'choam'));
|
|
joinGame(game, newPlayer(observer, 'Emperor', 'emperor'));
|
|
for (const p of game.players) game = act(game, p.id, {type:'ready'});
|
|
game = act(game, game.host, {type:'start'});
|
|
assert.equal(game.status, 'setup'); assert.deepEqual(game.expansions, ['choam','ix']);
|
|
assert.ok(game.players.every(p => p.hand.length === 0));
|
|
const ordinary = game.deck.filter(card => ['projectile','poison','shield','snooper'].includes(card.kind)).slice(0, 3);
|
|
assert.equal(ordinary.length, 3);
|
|
for (const [position, card] of ordinary.entries()) {
|
|
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
|
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
|
}
|
|
game = reach(game, state => state.status === 'playing');
|
|
for (const position of [0,1]) {
|
|
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
|
|
assert.ok(from >= position);
|
|
[game.spiceDeck[position], game.spiceDeck[from]] = [game.spiceDeck[from], game.spiceDeck[position]];
|
|
}
|
|
game = reach(game, state => state.turn === 1 && state.phase === 5 && state.active === owner && clean(state));
|
|
const richese = seat(game, owner), token = richese.noField!.tokens.find(item => item.value === 3); assert.ok(token);
|
|
const shipmentEvent = richese.noFieldEvent; assert.ok(shipmentEvent);
|
|
const beforeSpice = richese.spice;
|
|
game = act(game, owner, {type:'ship', noField:token.id, event:shipmentEvent,
|
|
territory:'polar_sink', sector:0, allyPayment:0});
|
|
game = reach(game, clean);
|
|
assert.equal(seat(game, owner).spice, beforeSpice - 2);
|
|
assert.equal(seat(game, owner).noField?.deployed?.tokenId, token.id);
|
|
assert.equal(seat(game, owner).reserves, 20);
|
|
assert.equal(seat(game, owner).forces['polar_sink:0'] ?? 0, 0);
|
|
game = act(game, owner, {type:'endMovement'});
|
|
game = reach(game, state => state.turn === 2 && state.phase === 0 && !!state.phaseOpening &&
|
|
!state.response && !state.decision && !state.truthtrance);
|
|
assert.equal(seat(game, owner).noField?.deployed?.tokenId, token.id);
|
|
const event = seat(game, owner).noFieldEvent; assert.ok(event);
|
|
return {game, token:token.id, event};
|
|
}
|
|
let saved: ReturnType<typeof nativeSource> | undefined;
|
|
const source = () => saved ??= nativeSource();
|
|
const reveal = (game: Game, token: string, event: string) =>
|
|
applyAction(game, owner, {type:'revealNoField', token, event});
|
|
function reject(game: Game, id: string, action: Action) {
|
|
const before = structuredClone(game);
|
|
assert.throws(() => applyAction(game, id, action));
|
|
assert.deepEqual(game, before);
|
|
}
|
|
function verifyRevealed(before: Game, after: Game, token: string, event: string) {
|
|
assert.equal(seat(after, owner).noField?.deployed, null);
|
|
assert.notEqual(seat(after, owner).noFieldEvent, event);
|
|
assert.equal(seat(after, owner).noField?.lastShipped, token);
|
|
assert.equal(seat(after, owner).reserves, seat(before, owner).reserves - 3);
|
|
assert.equal(seat(after, owner).forces['polar_sink:0'], (seat(before, owner).forces['polar_sink:0'] ?? 0) + 3);
|
|
assert.equal(seat(after, owner).tanks, seat(before, owner).tanks);
|
|
assert.equal(seat(after, owner).spice, seat(before, owner).spice);
|
|
reject(after, owner, {type:'revealNoField', token, event});
|
|
}
|
|
|
|
void test('Basic native Ix opening keeps prior passes and resumes after a voluntary marker reveal', () => {
|
|
const {token,event} = source();
|
|
let game = structuredClone(source().game);
|
|
game = act(game, owner, {type:'ready'});
|
|
assert.ok(game.phaseOpening!.passed.includes(owner));
|
|
const parent = structuredClone(game.phaseOpening);
|
|
assert.equal(viewGame(game, owner).richeseNoField?.canReveal, true);
|
|
reject(game, observer, {type:'revealNoField', token, event});
|
|
reject(game, owner, {type:'revealNoField', token, event:'stale'});
|
|
reject(game, owner, {type:'revealNoField', token:'wrong-token', event});
|
|
const before = structuredClone(game), after = reveal(game, token, event);
|
|
verifyRevealed(before, after, token, event);
|
|
assert.deepEqual(after.phaseOpening, parent);
|
|
assert.equal(after.phase, before.phase); assert.equal(after.turn, before.turn);
|
|
const continued = reach(after, state => !state.phaseOpening);
|
|
assert.equal(seat(continued, owner).forces['polar_sink:0'], 3);
|
|
});
|
|
|
|
for (const kind of ['choamMarket','richeseDeclaration','richeseCache','richeseUnbid'] as const) void test(
|
|
`Basic native ${kind} remains intact while Richese reveals, then its original decision continues`, () => {
|
|
const {token,event} = source();
|
|
const before = reach(structuredClone(source().game), state =>
|
|
state.turn === 2 && state.decision?.kind === kind && !state.response && !state.phaseOpening);
|
|
const parent = structuredClone(before.decision), market = structuredClone(before.choamMarket ?? null),
|
|
bidding = structuredClone(before.richeseBidding ?? null), auction = structuredClone(before.auction ?? null);
|
|
assert.equal(viewGame(before, owner).richeseNoField?.canReveal, true);
|
|
const after = reveal(before, token, event);
|
|
verifyRevealed(before, after, token, event);
|
|
assert.deepEqual(after.decision, parent); assert.deepEqual(after.choamMarket ?? null, market);
|
|
assert.deepEqual(after.richeseBidding ?? null, bidding); assert.deepEqual(after.auction ?? null, auction);
|
|
const step = next(after); assert.ok(step);
|
|
assert.equal(step.actor, parent!.player);
|
|
const continued = act(after, step.actor, step.action);
|
|
assert.notDeepEqual(continued.decision, parent);
|
|
assert.equal(seat(continued, owner).forces['polar_sink:0'], 3);
|
|
},
|
|
);
|
|
|
|
/** Reorder only an unopened turn-two Spice card, then form a real CHOAM /
|
|
* Emperor alliance at its Nexus and initiate their ordinary market trade. */
|
|
function nativeTradeReply() {
|
|
let game = structuredClone(source().game);
|
|
const worm = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
|
|
assert.ok(worm >= 0);
|
|
game.spiceDeck.unshift(game.spiceDeck.splice(worm, 1)[0]);
|
|
game = reach(game, state => state.turn === 2 && state.phase === 1 && !!state.nexus &&
|
|
!state.spiceWindow && !state.spiceResolution && clean(state));
|
|
game = act(game, choam, {type:'alliance', target:observer});
|
|
game = act(game, observer, {type:'alliance', target:choam});
|
|
assert.equal(seat(game, choam).ally, observer);
|
|
assert.equal(seat(game, observer).ally, choam);
|
|
game = reach(game, state => state.turn === 2 && state.phase === 1 &&
|
|
state.decision?.kind === 'choamMarket' && !state.response && !state.phaseOpening);
|
|
const offered = seat(game, choam).hand[0]; assert.ok(offered);
|
|
const returned = seat(game, observer).hand[0]; assert.ok(returned);
|
|
game = act(game, choam, {type:'decision', mode:'trade', card:offered.id});
|
|
assert.equal(game.decision?.kind, 'choamTradeReply');
|
|
assert.equal(game.decision.player, observer);
|
|
return {game, offered:offered.id, returned:returned.id};
|
|
}
|
|
let tradeFixture: ReturnType<typeof nativeTradeReply> | undefined;
|
|
const tradeSource = () => tradeFixture ??= nativeTradeReply();
|
|
|
|
void test('Basic CHOAM trade reply retains its genuine alliance, offered card and exact continuation', () => {
|
|
const {token,event} = source(), {offered,returned} = tradeSource();
|
|
const before = structuredClone(tradeSource().game);
|
|
const parent = structuredClone(before.decision), market = structuredClone(before.choamMarket);
|
|
assert.equal(viewGame(before, owner).richeseNoField?.canReveal, true);
|
|
const after = reveal(before, token, event);
|
|
verifyRevealed(before, after, token, event);
|
|
assert.deepEqual(after.decision, parent); assert.deepEqual(after.choamMarket, market);
|
|
let continued = act(after, observer, {type:'decision', card:returned});
|
|
assert.equal(continued.decision?.kind, 'choamTradeConfirm');
|
|
assert.equal(continued.decision.player, choam);
|
|
continued = act(continued, choam, {type:'decision', accept:true});
|
|
assert.equal(continued.decision?.kind, 'choamMarket');
|
|
assert.ok(seat(continued, choam).hand.some(card => card.id === returned));
|
|
assert.ok(seat(continued, observer).hand.some(card => card.id === offered));
|
|
assert.equal(seat(continued, owner).forces['polar_sink:0'], 3);
|
|
});
|
|
|
|
void test('Basic CHOAM trade confirmation remains pending through reveal and exchanges the actual cards', () => {
|
|
const {token,event} = source(), f = tradeSource();
|
|
const before = act(structuredClone(f.game), observer, {type:'decision', card:f.returned});
|
|
assert.equal(before.decision?.kind, 'choamTradeConfirm');
|
|
const parent = structuredClone(before.decision), market = structuredClone(before.choamMarket);
|
|
assert.equal(viewGame(before, owner).richeseNoField?.canReveal, true);
|
|
const after = reveal(before, token, event);
|
|
verifyRevealed(before, after, token, event);
|
|
assert.deepEqual(after.decision, parent); assert.deepEqual(after.choamMarket, market);
|
|
const continued = act(after, choam, {type:'decision', accept:true});
|
|
assert.equal(continued.decision?.kind, 'choamMarket');
|
|
assert.ok(seat(continued, choam).hand.some(card => card.id === f.returned));
|
|
assert.ok(seat(continued, observer).hand.some(card => card.id === f.offered));
|
|
assert.equal(seat(continued, owner).forces['polar_sink:0'], 3);
|
|
});
|
|
|
|
void test('all four minimal bot profiles can answer the preserved native Richese economic decision', () => {
|
|
const {token,event} = source();
|
|
const before = reach(structuredClone(source().game), state => state.turn === 2 &&
|
|
state.decision?.kind === 'richeseDeclaration' && !state.response && !state.phaseOpening);
|
|
const after = reveal(before, token, event);
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const view = viewGame(after, owner);
|
|
view.players.find(player => player.id === owner)!.bot = difficulty;
|
|
const choice = botActions(view).find(action => action.type === 'decision');
|
|
assert.ok(choice, `${difficulty} needs an original legal declaration`);
|
|
const continued = applyAction(structuredClone(after), owner, choice);
|
|
assert.notDeepEqual(continued.decision, after.decision);
|
|
assert.equal(seat(continued, owner).forces['polar_sink:0'], 3);
|
|
}
|
|
});
|