181 lines
11 KiB
TypeScript
181 lines
11 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';
|
|
import { truthMovementInventory as inventory } from './fixture-truth-movement';
|
|
|
|
const owner = 'richese', choam = 'choam', observer = 'emperor';
|
|
const player = (game: Game, id: string) => {
|
|
const found = game.players.find(row => row.id === id); assert.ok(found); return found;
|
|
};
|
|
const clean = (game: Game) => !game.response && !game.decision && !game.phaseOpening &&
|
|
!game.truthtrance && !game.pendingTreacheryDiscard;
|
|
function next(game: Game) {
|
|
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): Game {
|
|
for (let count = 0; count < 1800; count++) {
|
|
if (until(game)) return game;
|
|
assert.notEqual(game.status, 'finished', 'The native game ended before its response.');
|
|
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('The native response was not reached.');
|
|
}
|
|
|
|
/** Genuine Basic setup with CHOAM and Ix decks, a paid first-turn No-Field
|
|
* shipment and the original next-turn Charity response. Only unopened starting
|
|
* deal and Spice card positions are selected; the physical Karama is dealt to
|
|
* Emperor and held through the intervening native turns. */
|
|
function nativeCharitySource(withAtreides = false) {
|
|
let game = createGame('RICHESECHARITY', newPlayer(owner, 'Richese', 'richese'), false, ['choam','ix']);
|
|
joinGame(game, newPlayer(choam, 'CHOAM', 'choam'));
|
|
if (withAtreides) joinGame(game, newPlayer('atreides', 'Atreides', 'atreides'));
|
|
joinGame(game, newPlayer(observer, 'Emperor', 'emperor'));
|
|
for (const seat of game.players) game = act(game, seat.id, {type:'ready'});
|
|
game = act(game, game.host, {type:'start'});
|
|
assert.equal(game.status, 'setup');
|
|
const ordinaryCount = withAtreides ? 3 : 2;
|
|
const ordinary = game.deck.filter(card => ['projectile','poison','shield','snooper'].includes(card.kind)).slice(0, ordinaryCount);
|
|
const karama = game.deck.find(card => card.effect === 'karama');
|
|
assert.equal(ordinary.length, ordinaryCount); assert.ok(karama);
|
|
for (const [position, card] of [...ordinary, karama].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');
|
|
assert.ok(player(game, observer).hand.some(card => card.id === karama.id));
|
|
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 token = player(game, owner).noField!.tokens.find(item => item.value === 3); assert.ok(token);
|
|
const shipmentEvent = player(game, owner).noFieldEvent; assert.ok(shipmentEvent);
|
|
game = act(game, owner, {type:'ship', noField:token.id, event:shipmentEvent,
|
|
territory:'polar_sink', sector:0, allyPayment:0});
|
|
game = reach(game, clean);
|
|
assert.equal(player(game, owner).noField?.deployed?.tokenId, token.id);
|
|
game = act(game, owner, {type:'endMovement'});
|
|
game = reach(game, state => state.turn === 2 && state.phase === 2 && state.response?.kind === 'choamCharity');
|
|
assert.equal(game.response?.owner, choam);
|
|
assert.ok(player(game, observer).hand.some(card => card.id === karama.id));
|
|
const event = player(game, owner).noFieldEvent; assert.ok(event);
|
|
return {game, token:token.id, event, karama:karama.id, cards:inventory(game)};
|
|
}
|
|
let saved: ReturnType<typeof nativeCharitySource> | undefined;
|
|
const source = () => saved ??= nativeCharitySource();
|
|
let savedAtreides: ReturnType<typeof nativeCharitySource> | undefined;
|
|
const atreidesSource = () => savedAtreides ??= nativeCharitySource(true);
|
|
function reject(game: Game, actor: string, action: Action) {
|
|
const before = structuredClone(game);
|
|
assert.throws(() => applyAction(game, actor, action));
|
|
assert.deepEqual(game, before);
|
|
}
|
|
|
|
void test('Richese reveals during the real CHOAM Charity response while its original cancel opportunity and continuation remain', () => {
|
|
const {token,event,karama,cards} = source();
|
|
let before = structuredClone(source().game);
|
|
before = act(before, owner, {type:'passResponse'});
|
|
assert.equal(before.response?.kind, 'choamCharity');
|
|
assert.ok(before.response.passed.includes(owner));
|
|
const response = structuredClone(before.response), cash = player(before, choam).spice,
|
|
reserves = player(before, owner).reserves, field = player(before, owner).forces['polar_sink:0'] ?? 0;
|
|
assert.equal(viewGame(before, owner).richeseNoField?.canReveal, true);
|
|
reject(before, observer, {type:'revealNoField', token, event});
|
|
reject(before, owner, {type:'revealNoField', token, event:'stale'});
|
|
reject(before, owner, {type:'revealNoField', token:'stale', event});
|
|
let after = act(before, owner, {type:'revealNoField', token, event});
|
|
assert.deepEqual(after.response, response);
|
|
assert.equal(after.turn, before.turn); assert.equal(after.phase, before.phase);
|
|
assert.equal(player(after, owner).noField?.deployed, null);
|
|
assert.equal(player(after, owner).noField?.lastShipped, token);
|
|
assert.notEqual(player(after, owner).noFieldEvent, event);
|
|
assert.equal(player(after, owner).reserves, reserves - 3);
|
|
assert.equal(player(after, owner).forces['polar_sink:0'], field + 3);
|
|
assert.equal(player(after, choam).spice, cash);
|
|
assert.ok(player(after, observer).hand.some(card => card.id === karama));
|
|
assert.deepEqual(inventory(after), cards);
|
|
reject(after, owner, {type:'revealNoField', token, event});
|
|
after = act(after, observer, {type:'passResponse'});
|
|
assert.notEqual(after.response?.kind, 'choamCharity');
|
|
assert.equal(player(after, choam).spice, cash + 2 * before.players.length);
|
|
assert.equal(player(after, owner).forces['polar_sink:0'], field + 3);
|
|
assert.deepEqual(inventory(after), cards);
|
|
});
|
|
|
|
void test('all minimal bot profiles can continue the preserved CHOAM Charity response', () => {
|
|
const {game, token, event} = source();
|
|
const revealed = act(structuredClone(game), owner, {type:'revealNoField', token, event});
|
|
for (const difficulty of DIFFICULTIES) {
|
|
const automated = structuredClone(revealed);
|
|
player(automated, observer).bot = difficulty;
|
|
const choice = botActions(viewGame(automated, observer)).find(action =>
|
|
action.type === 'passResponse' || action.type === 'card' && action.mode === 'cancel');
|
|
assert.ok(choice, `${difficulty} needs a legal response`);
|
|
const continued = applyAction(automated, observer, choice);
|
|
assert.ok(continued.response?.kind !== 'choamCharity' ||
|
|
continued.response.passed.includes(observer) || continued.pendingKarama || continued.pendingKull ||
|
|
continued.pendingTreacheryDiscard,
|
|
`${difficulty} must advance the original response: ${JSON.stringify({choice, response:continued.response,
|
|
decision:continued.decision, discard:!!continued.pendingTreacheryDiscard})}`);
|
|
assert.equal(player(continued, owner).forces['polar_sink:0'], 3);
|
|
}
|
|
});
|
|
|
|
void test('Richese reveals during the real Atreides normal-auction inspection response and preserves its lot', () => {
|
|
const {token,event,karama,cards} = atreidesSource();
|
|
const before = reach(structuredClone(atreidesSource().game), state => state.turn === 2 && state.phase === 3 &&
|
|
state.response?.kind === 'atreidesAuction' && !!state.auction && !state.richeseAuction);
|
|
assert.equal(before.response?.owner, 'atreides');
|
|
assert.ok(player(before, observer).hand.some(card => card.id === karama));
|
|
const response = structuredClone(before.response), lot = structuredClone(before.auction),
|
|
reserves = player(before, owner).reserves, field = player(before, owner).forces['polar_sink:0'] ?? 0;
|
|
assert.equal(viewGame(before, owner).richeseNoField?.canReveal, true);
|
|
const after = act(before, owner, {type:'revealNoField', token, event});
|
|
assert.deepEqual(after.response, response); assert.deepEqual(after.auction, lot);
|
|
assert.equal(player(after, owner).noField?.deployed, null);
|
|
assert.equal(player(after, owner).reserves, reserves - 3);
|
|
assert.equal(player(after, owner).forces['polar_sink:0'], field + 3);
|
|
assert.deepEqual(inventory(after), cards);
|
|
const control = act(before, observer, {type:'passResponse'});
|
|
const continued = act(after, observer, {type:'passResponse'});
|
|
assert.notEqual(continued.response?.kind, 'atreidesAuction');
|
|
assert.deepEqual(continued.auction, control.auction);
|
|
assert.equal(player(continued, owner).forces['polar_sink:0'], field + 3);
|
|
assert.deepEqual(inventory(continued), cards);
|
|
});
|
|
|
|
void test('Richese reveals during the real cache-auction prevention response without replaying its declaration', () => {
|
|
const {token,event,karama,cards} = source();
|
|
const before = reach(structuredClone(source().game), state => state.turn === 2 && state.phase === 3 &&
|
|
state.response?.kind === 'richeseAuction');
|
|
assert.equal(before.response?.owner, owner);
|
|
assert.ok(player(before, observer).hand.some(card => card.id === karama));
|
|
const response = structuredClone(before.response), round = structuredClone(before.richeseBidding),
|
|
reserves = player(before, owner).reserves, field = player(before, owner).forces['polar_sink:0'] ?? 0;
|
|
assert.equal(viewGame(before, owner).richeseNoField?.canReveal, true);
|
|
const after = act(before, owner, {type:'revealNoField', token, event});
|
|
assert.deepEqual(after.response, response); assert.deepEqual(after.richeseBidding, round);
|
|
assert.equal(player(after, owner).noField?.deployed, null);
|
|
assert.equal(player(after, owner).reserves, reserves - 3);
|
|
assert.equal(player(after, owner).forces['polar_sink:0'], field + 3);
|
|
assert.deepEqual(inventory(after), cards);
|
|
const control = act(before, observer, {type:'passResponse'});
|
|
const continued = act(after, observer, {type:'passResponse'});
|
|
assert.notEqual(continued.response?.kind, 'richeseAuction');
|
|
assert.ok(continued.richeseBidding?.event);
|
|
assert.deepEqual({...continued.richeseBidding, event:undefined}, {...control.richeseBidding, event:undefined});
|
|
assert.deepEqual(continued.richeseAuction, control.richeseAuction);
|
|
assert.deepEqual({...continued.decision, event:undefined}, {...control.decision, event:undefined});
|
|
assert.equal(player(continued, owner).forces['polar_sink:0'], field + 3);
|
|
assert.deepEqual(inventory(continued), cards);
|
|
});
|