140 lines
8.4 KiB
TypeScript
140 lines
8.4 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { viewGame, type Action, type Game } from '../game/engine';
|
|
import {
|
|
createSemutaGholaIncomeFixture as fixture, actSemutaGholaIncome as act,
|
|
semutaGholaIncomePlayer as player, semutaGholaIncomeInventory as inventory,
|
|
passSemutaGholaIncome as pass, claimSemutaGholaIncome as claim,
|
|
allowSemutaGholaIncome as allow,
|
|
} from './fixture-semuta-ghola-income';
|
|
|
|
const quotas = (game: Game, actor: string) => {
|
|
const p = player(game, actor);
|
|
return { revived: p.revived, freeForcesRevived: p.freeForcesRevived,
|
|
leaderRevived: p.leaderRevived, elitesRevived: p.elites?.revived };
|
|
};
|
|
function rejected(game: Game, actor: string, action: Action) {
|
|
const before = structuredClone(game);
|
|
assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
|
|
}
|
|
|
|
void test('a real Ghola return offers Semuta before its original conditional Tleilaxu income', () => {
|
|
const f = fixture(), source = structuredClone(f.game), beforeIncome = player(source, f.incomeOwner).spice;
|
|
const game = act(source, f.owner, f.gholaAction), pending = game.pendingTreacheryDiscard;
|
|
assert.equal(pending?.reaction?.stage, 'offer');
|
|
assert.equal(pending.continuation.kind, 'ordinaryCardDiscard');
|
|
if (pending.continuation.kind !== 'ordinaryCardDiscard') assert.fail('Retain the original ordinary-card parent');
|
|
assert.equal(pending.continuation.effect, 'ghola');
|
|
assert.deepEqual(pending.continuation.resume.response,
|
|
{ kind: 'revivalIncome', owner: f.incomeOwner, recipient: f.owner, amount: 1, passed: [] });
|
|
assert.equal(game.response, null);
|
|
assert.equal(player(game, f.owner).reserves, 10); assert.equal(player(game, f.owner).tanks, 0);
|
|
assert.equal(player(game, f.incomeOwner).spice, beforeIncome);
|
|
assert.deepEqual(quotas(game, f.owner), quotas(source, f.owner));
|
|
for (const p of game.players) {
|
|
const offer = viewGame(game, p.id).semutaReaction!;
|
|
assert.deepEqual(offer.candidates, []); assert.equal(offer.canCommit, p.id === f.claimant);
|
|
}
|
|
assert.deepEqual(source, f.game); assert.deepEqual(inventory(game), f.originalInventory);
|
|
});
|
|
|
|
for (const claims of [false, true]) for (const canceled of [false, true])
|
|
void test('Ghola income ' + (claims ? 'claim' : 'pass') + ' keeps physical returns through native ' +
|
|
(canceled ? 'cancellation' : 'allowance'), () => {
|
|
const f = fixture(), beforeIncome = player(f.game, f.incomeOwner).spice;
|
|
const pending = act(f.game, f.owner, f.gholaAction);
|
|
const originalResponse = pending.pendingTreacheryDiscard?.continuation;
|
|
assert.ok(originalResponse?.kind === 'ordinaryCardDiscard');
|
|
let game = claims ? claim(pending, f.claimant, f.ghola) : pass(pending);
|
|
assert.equal(game.pendingTreacheryDiscard, null);
|
|
assert.deepEqual(game.response, originalResponse.resume.response,
|
|
'Claim/pass restores the same actual income frame before any counter or payment');
|
|
assert.equal(player(game, f.incomeOwner).spice, beforeIncome);
|
|
assert.equal(player(game, f.owner).reserves, 10); assert.equal(player(game, f.owner).tanks, 0);
|
|
assert.deepEqual(quotas(game, f.owner), quotas(f.game, f.owner));
|
|
assert.equal(player(game, f.claimant).hand.some(card => card.id === f.ghola), claims);
|
|
assert.equal(game.discard.some(card => card.id === f.semuta), claims);
|
|
assert.equal(game.discard.some(card => card.id === f.ghola), !claims);
|
|
game = canceled
|
|
? act(game, f.counterOwner, { type: 'card', card: f.counter, mode: 'cancel' })
|
|
: allow(game);
|
|
if (canceled) {
|
|
assert.equal(game.pendingTreacheryDiscard?.continuation.kind, 'karamaIncomeDiscard');
|
|
game = pass(game);
|
|
assert.equal(game.pendingTreacheryDiscard, null);
|
|
}
|
|
assert.equal(game.response, null);
|
|
assert.equal(player(game, f.incomeOwner).spice, beforeIncome + Number(!canceled));
|
|
assert.equal(player(game, f.owner).spice, 11, 'Ghola and a counter do not refund or repay the Box');
|
|
assert.equal(player(game, f.owner).reserves, 10); assert.equal(player(game, f.owner).tanks, 0);
|
|
assert.deepEqual(quotas(game, f.owner), quotas(f.game, f.owner));
|
|
assert.deepEqual(inventory(game), f.originalInventory);
|
|
});
|
|
|
|
void test('a full-hand Semuta claimant exchanges its physical cost before the unchanged Ghola income resumes', () => {
|
|
const f = fixture({ fullHand: true }), pending = act(f.game, f.owner, f.gholaAction);
|
|
assert.equal(player(pending, f.claimant).hand.length, 4);
|
|
const game = claim(pending, f.claimant, f.ghola);
|
|
assert.equal(player(game, f.claimant).hand.length, 4);
|
|
assert.ok(player(game, f.claimant).hand.some(card => card.id === f.ghola));
|
|
assert.ok(!player(game, f.claimant).hand.some(card => card.id === f.semuta));
|
|
assert.equal(game.discard.filter(card => card.id === f.semuta).length, 1);
|
|
assert.equal(game.response?.kind, 'revivalIncome');
|
|
assert.deepEqual(game.response.passed, []);
|
|
assert.deepEqual(inventory(game), f.originalInventory);
|
|
});
|
|
|
|
void test('a controlled own Tleilaxu Ghola return retains the one-spice self-income through Semuta recovery', () => {
|
|
const f = fixture(), source = structuredClone(f.game), native = player(source, f.incomeOwner);
|
|
// Explicit conserved prior acquisition/casualty, not a natural-source claim.
|
|
const holder = player(source, f.owner), index = holder.hand.findIndex(card => card.id === f.ghola);
|
|
assert.ok(index >= 0); native.hand.push(holder.hand.splice(index, 1)[0]);
|
|
assert.ok(native.reserves > 0); native.reserves--; native.tanks++;
|
|
const reserves = native.reserves, income = native.spice, originalQuotas = quotas(source, native.id);
|
|
let game = act(source, native.id, f.gholaAction);
|
|
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
|
const continuation = game.pendingTreacheryDiscard!.continuation;
|
|
assert.ok(continuation.kind === 'ordinaryCardDiscard');
|
|
assert.deepEqual(continuation.resume.response,
|
|
{ kind: 'revivalIncome', owner: native.id, recipient: native.id, amount: 1, passed: [] });
|
|
game = claim(game, f.claimant, f.ghola);
|
|
assert.equal(player(game, native.id).spice, income);
|
|
assert.equal(player(game, native.id).reserves, reserves + 1); assert.equal(player(game, native.id).tanks, 0);
|
|
game = allow(game);
|
|
assert.equal(player(game, native.id).spice, income + 1);
|
|
assert.deepEqual(quotas(game, native.id), originalQuotas);
|
|
assert.deepEqual(inventory(game), f.originalInventory);
|
|
});
|
|
|
|
void test('a controlled native leader Ghola can be recovered before income without returning the separate Tank', () => {
|
|
const f = fixture(), source = structuredClone(f.game), owner = player(source, f.owner);
|
|
// Explicit prior leader death; the original physical disc stays with its owner.
|
|
const leader = owner.leaders[0]; leader.dead = true; leader.deaths = 1;
|
|
const income = player(source, f.incomeOwner).spice, beforeQuotas = quotas(source, f.owner);
|
|
let game = act(source, f.owner, { type: 'card', card: f.ghola, leader: leader.id });
|
|
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'offer');
|
|
assert.equal(player(game, f.owner).leaders.find(disc => disc.id === leader.id)!.dead, false);
|
|
assert.equal(player(game, f.owner).reserves, 9); assert.equal(player(game, f.owner).tanks, 1);
|
|
game = claim(game, f.claimant, f.ghola);
|
|
assert.equal(game.response?.kind, 'revivalIncome'); assert.equal(player(game, f.incomeOwner).spice, income);
|
|
game = allow(game);
|
|
assert.equal(player(game, f.incomeOwner).spice, income + 1);
|
|
assert.deepEqual(quotas(game, f.owner), beforeQuotas);
|
|
assert.deepEqual(inventory(game), f.originalInventory);
|
|
});
|
|
|
|
void test('stale fresh events and altered Ghola-income parents reject before card custody or income changes', () => {
|
|
const f = fixture(), pending = act(f.game, f.owner, f.gholaAction);
|
|
const event = pending.pendingTreacheryDiscard!.batch.event;
|
|
rejected(pending, f.claimant, { type: 'semutaCommit', event: 'stale-ghola-discard' });
|
|
rejected(pending, f.claimant, { type: 'semutaSelect', event, card: f.ghola });
|
|
const changed = structuredClone(pending), continuation = changed.pendingTreacheryDiscard!.continuation;
|
|
assert.equal(continuation.kind, 'ordinaryCardDiscard');
|
|
if (continuation.kind !== 'ordinaryCardDiscard') assert.fail('Expected the real Ghola parent');
|
|
assert.ok(continuation.resume.response?.kind === 'revivalIncome');
|
|
continuation.resume.response.amount = 2;
|
|
const before = structuredClone(changed);
|
|
assert.throws(() => viewGame(changed, f.claimant)); assert.deepEqual(changed, before);
|
|
rejected(changed, f.claimant, { type: 'semutaCommit', event });
|
|
});
|