154 lines
9.9 KiB
TypeScript
154 lines
9.9 KiB
TypeScript
import test from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||
import { botActions } from '../game/bots';
|
||
import {
|
||
commitSemutaNexusBetrayal, createSemutaNexusBetrayalFixture,
|
||
nextSemutaNexusBetrayalStep, reactSemutaNexusBetrayal,
|
||
semutaNexusBetrayalInventory, semutaNexusBetrayalPlayer,
|
||
} from './fixture-semuta-nexus-betrayal';
|
||
|
||
for (const advanced of [false, true]) {
|
||
for (const claim of [false, true]) void test(
|
||
`${advanced ? 'Advanced' : 'Basic'} Semuta ${claim ? 'claims' : 'passes'} the committed random CHOAM Betrayal discard before its receipt completes`, () => {
|
||
const f = createSemutaNexusBetrayalFixture({ advanced });
|
||
assert.equal(f.setup.status, 'setup');
|
||
assert.ok(Object.values(f.setup.nexusCards!.cards!.hands).every(card => card === null));
|
||
assert.equal(f.drawBefore.phase, 1);
|
||
assert.equal(f.drawBefore.turn, 2);
|
||
assert.equal(f.drawBefore.nexusCards!.phase!.occurred, true);
|
||
assert.equal(f.drawBefore.nexusCards!.phase!.stage, 'drawing');
|
||
assert.equal(semutaNexusBetrayalPlayer(f.drawBefore, f.owner).ally, f.target);
|
||
assert.equal(semutaNexusBetrayalPlayer(f.drawBefore, f.target).ally, f.owner);
|
||
assert.ok(!semutaNexusBetrayalPlayer(f.drawBefore, f.spender).ally);
|
||
assert.equal(f.drawAfter.nexusCards!.phase!.stage, 'complete');
|
||
assert.ok(f.drawAfter.nexusCards!.cards!.hands[f.spender]);
|
||
assert.equal(f.drawAfter.nexusCards!.cards!.deck.length, 10);
|
||
assert.equal(viewGame(f.game, f.spender).nexusChoamBetrayal!.target.id, f.target);
|
||
assert.equal(viewGame(f.game, f.spender).nexusChoamBetrayal!.blocked, null);
|
||
const wallets = f.game.players.map(p => p.spice);
|
||
const targetBefore = structuredClone(semutaNexusBetrayalPlayer(f.game, f.target).hand);
|
||
const logStart = f.game.log.length;
|
||
const pending = commitSemutaNexusBetrayal(f);
|
||
const batch = pending.pendingTreacheryDiscard!.batch;
|
||
const receipt = pending.nexusChoamBetrayals!.at(-1)!;
|
||
const fresh = targetBefore.filter(card => !semutaNexusBetrayalPlayer(pending, f.target).hand.some(c => c.id === card.id));
|
||
assert.equal(fresh.length, 1, 'The server must randomly remove exactly one actual held card');
|
||
assert.deepEqual(semutaNexusBetrayalPlayer(pending, f.target).hand,
|
||
targetBefore.filter(card => card.id !== fresh[0].id));
|
||
assert.equal(batch.cause, 'nexus:choamBetrayal');
|
||
assert.deepEqual(batch.entries, [{ card: fresh[0], discardedBy: f.target, publicFace: false }]);
|
||
assert.equal(pending.pendingTreacheryDiscard!.reaction?.stage, 'offer');
|
||
assert.equal(receipt.stage, 'discard');
|
||
assert.equal(receipt.card, fresh[0].id);
|
||
assert.equal(pending.discard.filter(c => c.id === fresh[0].id).length, 1);
|
||
assert.equal(pending.nexusCards!.cards!.hands[f.spender], null);
|
||
assert.equal(pending.nexusCards!.cards!.discard.filter(c => c === 'choam').length, 1);
|
||
assert.deepEqual(pending.players.map(p => p.spice), wallets, 'Random discard gives no compensation');
|
||
const offer = viewGame(pending, f.owner).semutaReaction!;
|
||
assert.equal(offer.canCommit, true, 'Semuta may recover another player’s card even from its own ally');
|
||
assert.deepEqual(offer.candidates, [], 'The discard offer precedes any committed inspection');
|
||
assert.equal(pending.log.slice(logStart).some(row => row.text.includes(fresh[0].name)), false);
|
||
const done = reactSemutaNexusBetrayal(pending, f, claim);
|
||
assert.equal(done.pendingTreacheryDiscard, null);
|
||
assert.equal(done.resolvedTreacheryDiscardSequence, pending.pendingTreacheryDiscard!.sequence);
|
||
assert.equal(done.nexusChoamBetrayals!.length, 1);
|
||
assert.equal(done.nexusChoamBetrayals![0].stage, 'complete');
|
||
assert.equal(done.nexusChoamBetrayals![0].card, fresh[0].id, 'The original random identity remains committed');
|
||
assert.equal(semutaNexusBetrayalPlayer(done, f.owner).hand.filter(c => c.id === fresh[0].id).length, Number(claim));
|
||
assert.equal(semutaNexusBetrayalPlayer(done, f.owner).hand.some(c => c.id === f.semuta), !claim);
|
||
assert.equal(done.discard.filter(c => c.id === fresh[0].id).length, Number(!claim));
|
||
assert.equal(done.discard.filter(c => c.id === f.semuta).length, Number(claim));
|
||
assert.deepEqual(semutaNexusBetrayalPlayer(done, f.target).hand,
|
||
semutaNexusBetrayalPlayer(pending, f.target).hand, 'Reaction cannot resample or repeat the discard');
|
||
assert.deepEqual(done.players.map(p => p.spice), wallets);
|
||
assert.equal(done.nexusCards!.cards!.discard.filter(c => c === 'choam').length, 1);
|
||
assert.deepEqual(semutaNexusBetrayalInventory(done), f.cards);
|
||
assert.equal(viewGame(done, f.owner).semutaReaction, null);
|
||
assert.equal(viewGame(done, f.spender).nexusChoamBetrayal, null);
|
||
assert.throws(() => applyAction(done, f.spender, f.action));
|
||
assert.throws(() => applyAction(done, f.owner, { type: 'semutaCommit', event: batch.event }));
|
||
const next = nextSemutaNexusBetrayalStep(done);
|
||
assert.equal(next.action.type, 'ready', 'The original Charity acknowledgement is available after retirement');
|
||
const resumed = applyAction(done, next.actor, next.action);
|
||
assert.ok(resumed.ready.includes(next.actor));
|
||
assert.equal(resumed.nexusChoamBetrayals!.length, 1);
|
||
assert.equal(resumed.nexusChoamBetrayals![0].stage, 'complete');
|
||
assert.deepEqual(semutaNexusBetrayalPlayer(resumed, f.target).hand,
|
||
semutaNexusBetrayalPlayer(done, f.target).hand);
|
||
assert.deepEqual(semutaNexusBetrayalInventory(resumed), f.cards);
|
||
},
|
||
);
|
||
|
||
void test(`${advanced ? 'Advanced' : 'Basic'} full-hand Semuta exchanges the exact random Betrayal discard without overflow or payment`, () => {
|
||
const f = createSemutaNexusBetrayalFixture({ advanced, fullHand: true });
|
||
assert.equal(semutaNexusBetrayalPlayer(f.game, f.owner).hand.length, 4);
|
||
const pending = commitSemutaNexusBetrayal(f), fresh = pending.pendingTreacheryDiscard!.batch.entries[0].card;
|
||
assert.equal(viewGame(pending, f.owner).semutaReaction!.canCommit, true);
|
||
const done = reactSemutaNexusBetrayal(pending, f, true);
|
||
assert.equal(semutaNexusBetrayalPlayer(done, f.owner).hand.length, 4);
|
||
assert.ok(semutaNexusBetrayalPlayer(done, f.owner).hand.some(c => c.id === fresh.id));
|
||
assert.ok(!semutaNexusBetrayalPlayer(done, f.owner).hand.some(c => c.id === f.semuta));
|
||
assert.equal(done.decision, null);
|
||
assert.deepEqual(done.players.map(p => p.spice), pending.players.map(p => p.spice));
|
||
assert.deepEqual(semutaNexusBetrayalInventory(done), f.cards);
|
||
});
|
||
}
|
||
|
||
void test('CHOAM Betrayal rejects a client-picked card, and its pending Semuta reaction blocks original phase actions until resolved', () => {
|
||
const f = createSemutaNexusBetrayalFixture();
|
||
assert.throws(() => applyAction(f.game, f.spender, { ...f.action, card: f.targetCards[0].id }));
|
||
assert.equal(f.game.nexusCards!.cards!.hands[f.spender], 'choam');
|
||
assert.deepEqual(semutaNexusBetrayalPlayer(f.game, f.target).hand, f.targetCards);
|
||
const pending = commitSemutaNexusBetrayal(f), event = pending.pendingTreacheryDiscard!.batch.event;
|
||
assert.throws(() => applyAction(pending, f.spender, { type: 'ready' }));
|
||
assert.throws(() => applyAction(pending, f.owner, { type: 'semutaSelect', event, card: f.targetCards[0].id }));
|
||
assert.equal(pending.nexusChoamBetrayals![0].stage, 'discard');
|
||
const done = reactSemutaNexusBetrayal(pending, f, false);
|
||
assert.equal(done.nexusChoamBetrayals![0].stage, 'complete');
|
||
assert.deepEqual(semutaNexusBetrayalInventory(done), f.cards);
|
||
});
|
||
|
||
void test('existing minimal bot reactions settle the random Betrayal claim and neutral passes through the original receipt', () => {
|
||
const f = createSemutaNexusBetrayalFixture();
|
||
const pending = commitSemutaNexusBetrayal(f), fresh = pending.pendingTreacheryDiscard!.batch.entries[0].card.id;
|
||
for (const difficulty of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||
const own = viewGame(pending, f.owner);
|
||
own.players.find(p => p.id === f.owner)!.bot = difficulty;
|
||
const action = botActions(own)[0];
|
||
assert.ok(action);
|
||
assert.equal(action.type, 'semutaCommit');
|
||
const claimed = applyAction(pending, f.owner, action);
|
||
assert.ok(semutaNexusBetrayalPlayer(claimed, f.owner).hand.some(c => c.id === fresh));
|
||
assert.equal(claimed.nexusChoamBetrayals![0].stage, 'complete');
|
||
assert.deepEqual(semutaNexusBetrayalInventory(claimed), f.cards);
|
||
}
|
||
let passed: Game = pending;
|
||
for (const seat of pending.players) {
|
||
const own = viewGame(passed, seat.id);
|
||
own.players.find(p => p.id === seat.id)!.bot = 'Easy';
|
||
const action = seat.id === f.owner
|
||
? { type: 'semutaPass', event: passed.pendingTreacheryDiscard!.batch.event }
|
||
: botActions(own)[0];
|
||
assert.ok(action);
|
||
assert.equal(action.type, 'semutaPass');
|
||
passed = applyAction(passed, seat.id, action);
|
||
}
|
||
assert.equal(passed.nexusChoamBetrayals![0].stage, 'complete');
|
||
assert.equal(passed.discard.filter(c => c.id === fresh).length, 1);
|
||
assert.deepEqual(semutaNexusBetrayalInventory(passed), f.cards);
|
||
});
|
||
|
||
for (const options of [
|
||
{ advanced: false, tech: true },
|
||
{ advanced: true, tech: true, strongholds: true, skills: true },
|
||
]) void test(`Semuta random Betrayal remains playable with original ${options.skills ? 'Advanced Skills/Tech/Strongholds' : 'Basic Tech'} setup`, () => {
|
||
const f = createSemutaNexusBetrayalFixture(options);
|
||
assert.ok(Object.values(f.game.techTokens!).every(token => f.game.players.some(p => p.id === token.owner)));
|
||
const pending = commitSemutaNexusBetrayal(f), fresh = pending.pendingTreacheryDiscard!.batch.entries[0].card.id;
|
||
const done = reactSemutaNexusBetrayal(pending, f, true);
|
||
assert.ok(semutaNexusBetrayalPlayer(done, f.owner).hand.some(c => c.id === fresh));
|
||
assert.equal(done.nexusChoamBetrayals![0].stage, 'complete');
|
||
assert.deepEqual(semutaNexusBetrayalInventory(done), f.cards);
|
||
});
|