Allow Semuta after Basic Moritani Atomics
Verify and publish checkpoint container / container (push) Successful in 11m15s
Verify and publish checkpoint container / container (push) Successful in 11m15s
This commit is contained in:
1 parent
4f990db52e
commit
a9b67a89b3
6 files changed
+247
-1
No files matched your search
@@ -17,6 +17,16 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Basic Semuta after Moritani Atomics — 29 October. Prototyped / Partial.**
|
||||
Clean Basic shipment and movement arrivals offer Semuta the private random
|
||||
discard batch without repeating casualties, hand penalties or the original
|
||||
arrival. Claim/pass, private multi-owner selection, older no-offer batches and
|
||||
minimal legal bot reactions pass in staged physical journeys. Sixty-nine
|
||||
related unit cases, TypeScript, lint and the production build pass. Advanced
|
||||
and special or overlapping arrival parents remain outside this slice; full
|
||||
modes and deployed acceptance remain open.
|
||||
[Scope](SEMUTA_DISCARD_CONTINUATIONS.md#basic-moritani-atomics--29-october-2026).
|
||||
|
||||
**Basic Semuta after Thumper and Amal — 29 October. Prototyped / Partial.**
|
||||
The clean public-discard continuations now include the existing independent
|
||||
Ecaz-card/Ix-deck Basic profile. Claim/pass preserves the first worm/blow or
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# Rule decision index
|
||||
|
||||
## Semuta after Moritani Atomics — 29 October 2026
|
||||
|
||||
The simultaneous random excess-card discards caused by a clean Basic Atomics
|
||||
arrival form one private Semuta batch. This is a provisional online timing
|
||||
interpretation; the printed rules do not resolve the interaction.
|
||||
[Scope](SEMUTA_DISCARD_CONTINUATIONS.md#basic-moritani-atomics--29-october-2026).
|
||||
|
||||
## Basic negative Truthtrance facts — 29 October 2026
|
||||
|
||||
Bounded newly asked negative hand questions use zero-count predicates. The
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# Semuta discard producers and continuation inventory
|
||||
|
||||
## Basic Moritani Atomics — 29 October 2026
|
||||
|
||||
In supported Basic Richese/Moritani tables, a clean shipment or ground move
|
||||
that reveals Atomics can offer Semuta after the random excess-card discards.
|
||||
The losses, reduced hand limits and Aftermath are committed once before the
|
||||
offer; claim or pass resumes the original entry without rerolling. Multiple
|
||||
affected players' discards form one private fresh batch. Under the existing
|
||||
provisional Semuta policy, only the committed claimant sees eligible faces and
|
||||
can take one card discarded by another player. This batching is an online
|
||||
timing interpretation, not a publisher ruling. Older pending batches without
|
||||
an offer retain their original continuation; Advanced and overlapping or
|
||||
special arrival parents remain outside this slice.
|
||||
[Runtime cases](../tests/semuta-terror-atomics.test.ts) use a public Basic
|
||||
start with staged physical hands, alliance and arrival.
|
||||
|
||||
## Basic independent-card Thumper and Amal — 29 October 2026
|
||||
|
||||
In the supported Basic Richese profile with the Ix deck and independent Ecaz
|
||||
|
||||
+14
-1
@@ -9169,6 +9169,9 @@ function treacheryDiscardIntegrity(g: Game) {
|
||||
: entry.resume === 'none' && (entry.cause !== 'homeworldRevival' || !!entry.revivalArrival)) &&
|
||||
['shipment', 'movement', 'guildTransport', 'advisor', 'wormRide', 'homeworldRevival', 'caladanReinforcement', 'ambassador'].includes(entry.cause) &&
|
||||
entry.entrant !== owner?.id && entry.territory === aftermath.territory &&
|
||||
!!g.moritaniAtomics &&
|
||||
(['territory', 'turn', 'moritaniId', 'allyAtActivation', 'alliancePolicy', 'nexusPlacement'] as const)
|
||||
.every(key => aftermath[key] === g.moritaniAtomics![key]) &&
|
||||
terrorEntryLocationAllowed(g,entry) && validGameLocation(g, entry.territory, entry.sector) &&
|
||||
token?.kind === 'atomics' && token.status === 'removed' &&
|
||||
token.location === null &&
|
||||
@@ -9616,6 +9619,7 @@ function semutaOfferSupported(
|
||||
continuation.kind !== 'nullentropyDiscard' &&
|
||||
continuation.kind !== 'distransDiscard' &&
|
||||
continuation.kind !== 'terrorDiscard' &&
|
||||
continuation.kind !== 'terrorAtomics' &&
|
||||
continuation.kind !== 'nexusChoamTrade' &&
|
||||
continuation.kind !== 'nexusChoamBetrayal' &&
|
||||
continuation.kind !== 'saphoTruthOrderDiscard' &&
|
||||
@@ -9743,6 +9747,13 @@ function semutaOfferSupported(
|
||||
g.pendingChoamMove || g.pendingIxMove ||
|
||||
g.pendingFremenMove || g.ornithopter || g.summonedWorm ||
|
||||
g.wormRides.length > 0)) return false;
|
||||
if (continuation.kind === 'terrorAtomics' &&
|
||||
(g.advanced || g.phase !== 5 || continuation.entry.resume !== 'none' ||
|
||||
!['shipment', 'movement'].includes(continuation.entry.cause) ||
|
||||
!!g.pendingArrivalOverlap || !!g.pendingAmbassador ||
|
||||
!!g.pendingChoamMove || !!g.pendingIxMove || !!g.pendingFremenMove ||
|
||||
!!g.ornithopter || !!g.summonedWorm || g.wormRides.length > 0 ||
|
||||
!!g.pendingTreacheryDiscard && !g.pendingTreacheryDiscard.reaction)) return false;
|
||||
const allianceNexusDiscard = continuation.kind === 'truthtranceDiscard' && !!continuation.allianceBinding &&
|
||||
g.phase === 1 && g.nexus && bareBasicSemutaProfile(g) && alliancePromiseModeSupported(g);
|
||||
if ((continuation.kind === 'distransDiscard' ||
|
||||
@@ -9771,6 +9782,7 @@ function semutaOfferSupported(
|
||||
// without a reaction. Keep its original automatic continuation; newly
|
||||
// staged offers and already-saved reactions retain the current rules.
|
||||
const independentNativeDiscard = ambassadorDiscard || continuation.kind === 'terrorDiscard' ||
|
||||
continuation.kind === 'terrorAtomics' ||
|
||||
continuation.kind === 'choamSaleDiscard' || continuation.kind === 'choamKulonDiscard' ||
|
||||
continuation.kind === 'choamLaLaLaDiscard' || continuation.kind === 'choamBalisetDiscard' ||
|
||||
continuation.kind === 'choamGamontDiscard' || continuation.kind === 'choamJubbaDiscard' ||
|
||||
@@ -9819,7 +9831,7 @@ function semutaOfferSupported(
|
||||
g.status === 'playing' &&
|
||||
g.players.some(p => p.faction === 'richese') &&
|
||||
entries.length > 0 &&
|
||||
(ambassadorDiscard || continuation.kind === 'kaitainDiscard' || continuation.kind === 'tupileCleanupDiscard' ||
|
||||
(ambassadorDiscard || continuation.kind === 'terrorAtomics' || continuation.kind === 'kaitainDiscard' || continuation.kind === 'tupileCleanupDiscard' ||
|
||||
(continuation.kind === 'specialKaramaDiscard' && continuation.stage === 'cashout')
|
||||
? entries.every(entry => !entry.publicFace)
|
||||
: continuation.kind === 'ixAllyCard' || continuation.kind === 'terrorDiscard' ||
|
||||
@@ -9896,6 +9908,7 @@ function semutaOfferSupported(
|
||||
continuation.kind === 'tupileCleanupDiscard' ||
|
||||
continuation.kind === 'ixAllyCard' ||
|
||||
continuation.kind === 'terrorDiscard' ||
|
||||
continuation.kind === 'terrorAtomics' ||
|
||||
continuation.kind === 'nexusChoamTrade' ||
|
||||
continuation.kind === 'nexusChoamBetrayal' ||
|
||||
continuation.kind === 'choamSaleDiscard' ||
|
||||
|
||||
@@ -1342,6 +1342,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
? 'In the explicit Richese development preview, every seat sees a neutral response to supported clean discards. A CHOAM & Richese game may include the Ix deck: Thumper pauses before worm/spice draw; Amal pauses after halving spice before opening resumes. Other public sources include ordinary cards, paid Box, Ornithopter, Distrans, definite ordinary Truthtrance answers including queued questions, supported shipment promises and battle answers before or after plan submission/reveal, four clean Sapho scopes, pre-leader Residual Poison, printed Karama canceling clean CHOAM Charity, Inflation or Bene Gesserit Charity, a clean printed Karama normal-auction purchase or winning-bid payment before lot settlement, a completed CHOAM card sale after its bank income, clean Kulon Worthless disposal before its movement bonus, proactive or reactive La La La disposal before blocking free revival, proactive or reactive Baliset before restricting movement, proactive Trip to Gamont before its ordinary/elite return or native No-Field reveal and capped recall, accepted Jubba Cloak before storm protection, and three battle batches. A private normal-auction or Black Market Ixian ally replacement also pauses before drawing, without showing its face until a holder commits; original payment and seller income settle once. Cache purchases cannot use native Ixian allied replacement. Ordinary definite Truthtrance answers pause even when another question stays reserved or the answer bound a supported shipment or unsubmitted battle-plan promise. Submitted/revealed plans bind no new promise, but their answers still offer Semuta without changing the plan. Under the user-selected exchange ruling, Commit may replace Semuta even at your faction hand limit: acquisition and Semuta disposal settle atomically, without increasing your completed hand. Reserved incoming cards still need room. For multiple eligible cards, inspect and select exactly one after commitment. Saved effects resume once. Other private discards, Karama uses, other reactive Worthless uses, inspected Residual Poison battles, nested responses, other pending rewards and wider interactions remain unfinished.'
|
||||
: 'Reference component only: this Richese card’s game actions are not enabled.',
|
||||
...(definition.card.effect === 'semutaDrug' ? [
|
||||
'In supported Basic Moritani arrivals, Atomics may discard excess cards from Moritani and its ally. Semuta can privately select one card discarded by another player from that fresh batch. Losses, hand limits and Aftermath stay committed; the arrival resumes after claim or pass.',
|
||||
'In fresh Basic games with Richese, classic opponents, the CHOAM deck and Tech Tokens, the existing Semuta preview is also enabled. Other module combinations remain separate; existing started games are not retrofitted.',
|
||||
'Fresh Advanced Richese preview games with only the CHOAM expansion deck and classic or CHOAM opponents enable Semuta reactions for supported ordinary Hajr, Ghola, Harvester and Weather Control discards, completed Box and Distrans, simple definite Truthtrance answers, clean Residual Poison, completed CHOAM sales, supported printed CHOAM Worthless costs and supported battle cleanup. Other discard parents and optional-module combinations remain unfinished. Existing games are not retrofitted.',
|
||||
'Mandatory and winner battle discards also pause before native Advanced Harkonnen capture. Claim or Continue completes the original casualties and card cleanup before the capture offer; it does not sample a captive early or repeat capture. Other pending rewards remain outside this bounded path.',
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, createGame, joinGame, newPlayer, normalizeAutomaticGame, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { placeTerror } from '../game/moritani-terror';
|
||||
import { SEMUTA_DRUG_ID } from '../game/semuta-drug';
|
||||
import type { Card } from '../game/cards';
|
||||
|
||||
const act = (game: Game, id: string, action: Action) => applyAction(game, id, action);
|
||||
const seat = (game: Game, id: string) => {
|
||||
const player = game.players.find(row => row.id === id); assert.ok(player); return player;
|
||||
};
|
||||
const inventory = (game: Game) => {
|
||||
const ids = [...game.deck, ...game.discard, ...(game.richeseCache ?? []),
|
||||
...(game.richeseRemoved ?? []), ...game.players.flatMap(player => player.hand)].map(card => card.id).sort();
|
||||
assert.equal(new Set(ids).size, ids.length); return ids;
|
||||
};
|
||||
const take = (game: Game, predicate: (card: Card) => boolean): Card => {
|
||||
for (const zone of [game.deck, game.discard, game.richeseCache ?? []]) {
|
||||
const index = zone.findIndex(predicate);
|
||||
if (index >= 0) return zone.splice(index, 1)[0];
|
||||
}
|
||||
throw new Error('An original physical card is unavailable.');
|
||||
};
|
||||
const ordinaryCard = (game: Game) => take(game, card =>
|
||||
['projectile', 'poison', 'shield', 'snooper', 'worthless'].includes(card.kind));
|
||||
|
||||
/** Control only Atomics' two scalar draws; UUID and all other entropy remain
|
||||
* native. The original random choice always takes each staged hand's first card. */
|
||||
function withAtomicsRolls<T>(run: () => T): T {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const original = crypto.getRandomValues.bind(crypto); let draws = 0;
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1) { array[0] = 0; draws++; }
|
||||
else Reflect.apply(original, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { const result = run(); assert.equal(draws, 2); return result; }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
/** Genuine Basic selected-faction start. The alliance, phase, card custody,
|
||||
* force positions and original Atomics token placement are staged with
|
||||
* conserved physical components; shipment and detonation use actual actions. */
|
||||
function fixture(holder: 'ally' | 'third' = 'ally') {
|
||||
let game = createGame('SEMUTAATOMICS', newPlayer('m', 'Moritani', 'moritani'), false, ['choam', 'ecaz']);
|
||||
joinGame(game, newPlayer('r', 'Richese', 'richese'));
|
||||
joinGame(game, newPlayer('a', 'Atreides', 'atreides'));
|
||||
game = act(game, 'm', {type:'moduleOption', module:'ecazTreachery', enabled:true});
|
||||
for (const player of game.players) game = act(game, player.id, {type:'ready'});
|
||||
game = act(game, 'm', {type:'start'});
|
||||
assert.equal(game.status, 'setup');
|
||||
assert.equal(game.semutaRecruitsPreview, true);
|
||||
assert.equal(game.semutaPreview, undefined);
|
||||
for (let count = 0; game.status === 'setup' && count < 160; count++) {
|
||||
let next: {id:string; action:Action} | undefined;
|
||||
for (const player of game.players) {
|
||||
const view = viewGame(game, player.id);
|
||||
view.players.find(row => row.id === player.id)!.bot = 'Medium';
|
||||
const action = botActions(view)[0];
|
||||
if (action) { next = {id:player.id, action}; break; }
|
||||
}
|
||||
assert.ok(next, 'Native setup must have a legal action.');
|
||||
game = act(game, next.id, next.action);
|
||||
}
|
||||
assert.equal(game.status, 'playing');
|
||||
const original = inventory(game);
|
||||
game.deck.push(...game.players.flatMap(player => player.hand));
|
||||
for (const player of game.players) {
|
||||
player.hand = [];
|
||||
player.reserves += player.tanks + Object.values(player.forces).reduce((sum, count) => sum + count, 0);
|
||||
player.forces = {}; player.tanks = 0; player.spice = 20;
|
||||
player.shipped = false; player.moved = 0;
|
||||
assert.equal(player.reserves, 20);
|
||||
}
|
||||
const owner = seat(game, 'm'), ally = seat(game, 'r');
|
||||
owner.ally = ally.id; ally.ally = owner.id;
|
||||
owner.forces['carthag:11'] = 2; owner.reserves -= 2;
|
||||
for (let i = 0; i < 4; i++) owner.hand.push(ordinaryCard(game));
|
||||
for (let i = 0; i < 3; i++) ally.hand.push(ordinaryCard(game));
|
||||
const semuta = take(game, card => card.id === SEMUTA_DRUG_ID);
|
||||
if (holder === 'ally') ally.hand.push(semuta);
|
||||
else { ally.hand.push(ordinaryCard(game)); seat(game, 'a').hand.push(semuta); }
|
||||
const otherDiscard = owner.hand[0].id, ownDiscard = ally.hand[0].id;
|
||||
const token = game.moritaniTerror!.tokens.find(row => row.kind === 'atomics'); assert.ok(token);
|
||||
game.moritaniTerror = placeTerror(game.moritaniTerror!, token.id, 'carthag', 1);
|
||||
Object.assign(game, {turn:2, phase:5, storm:18, active:'a', order:['a','m','r'],
|
||||
movementRemaining:['a','m','r'], ready:[], response:null, decision:null, phaseOpening:null});
|
||||
assert.deepEqual(inventory(game), original);
|
||||
game = act(game, 'a', {type:'ship', territory:'carthag', sector:11, amount:2, elite:0, allyPayment:0});
|
||||
assert.equal(game.pendingTerrorEntry?.stage, 'offer');
|
||||
game = withAtomicsRolls(() => act(game, 'm', {type:'decision', reveal:true}));
|
||||
return {game, original, token:token.id, otherDiscard, ownDiscard};
|
||||
}
|
||||
|
||||
for (const chosenOwner of ['m', 'r'] as const) {
|
||||
void test(`A third-party Semuta holder privately chooses the ${chosenOwner === 'm' ? 'Moritani' : 'allied Richese'} Atomics discard`, () => {
|
||||
const f = fixture('third'), frame = f.game.pendingTreacheryDiscard; assert.ok(frame?.reaction);
|
||||
assert.deepEqual(frame.batch.entries.map(entry => [entry.card.id, entry.discardedBy, entry.publicFace]),
|
||||
[[f.otherDiscard, 'm', false], [f.ownDiscard, 'r', false]]);
|
||||
for (const player of f.game.players)
|
||||
assert.deepEqual(viewGame(f.game, player.id).semutaReaction?.candidates, []);
|
||||
const event = frame.batch.event;
|
||||
let game = act(f.game, 'a', {type:'semutaCommit', event});
|
||||
assert.equal(game.pendingTreacheryDiscard?.reaction?.stage, 'select');
|
||||
assert.deepEqual(viewGame(game, 'a').semutaReaction?.candidates.map(card => card.id),
|
||||
[f.otherDiscard, f.ownDiscard]);
|
||||
for (const id of ['m', 'r']) assert.deepEqual(viewGame(game, id).semutaReaction?.candidates, []);
|
||||
const chosen = chosenOwner === 'm' ? f.otherDiscard : f.ownDiscard;
|
||||
const other = chosenOwner === 'm' ? f.ownDiscard : f.otherDiscard;
|
||||
game = act(game, 'a', {type:'semutaSelect', event, card:chosen});
|
||||
assert.equal(game.pendingTreacheryDiscard, null);
|
||||
assert.equal(seat(game, 'a').hand.some(card => card.id === chosen), true);
|
||||
assert.equal(game.discard.some(card => card.id === chosen), false);
|
||||
assert.equal(game.discard.some(card => card.id === other), true);
|
||||
assert.equal(game.discard.some(card => card.id === SEMUTA_DRUG_ID), true);
|
||||
assert.equal(seat(game, 'm').tanks, 2);
|
||||
assert.equal(seat(game, 'a').tanks, 2);
|
||||
assert.equal(game.moritaniAtomics?.territory, 'carthag');
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
});
|
||||
}
|
||||
|
||||
void test('a saved Atomics frame without a Semuta reaction finishes without adding a new offer', () => {
|
||||
const f = fixture(), legacy = structuredClone(f.game);
|
||||
assert.ok(legacy.pendingTreacheryDiscard?.reaction);
|
||||
delete legacy.pendingTreacheryDiscard.reaction;
|
||||
const resumed = normalizeAutomaticGame(legacy);
|
||||
assert.equal(resumed.pendingTreacheryDiscard, null);
|
||||
assert.equal(resumed.pendingTerrorEntry ?? null, null);
|
||||
assert.equal(seat(resumed, 'm').tanks, 2);
|
||||
assert.equal(seat(resumed, 'a').tanks, 2);
|
||||
assert.equal(resumed.discard.some(card => card.id === f.otherDiscard), true);
|
||||
assert.equal(resumed.discard.some(card => card.id === f.ownDiscard), true);
|
||||
assert.equal(seat(resumed, 'r').hand.some(card => card.id === SEMUTA_DRUG_ID), true);
|
||||
assert.deepEqual(inventory(resumed), f.original);
|
||||
});
|
||||
|
||||
void test('a saved Atomics continuation cannot replace its authoritative Aftermath', () => {
|
||||
const f = fixture(), damaged = structuredClone(f.game);
|
||||
const continuation = damaged.pendingTreacheryDiscard?.continuation;
|
||||
assert.equal(continuation?.kind, 'terrorAtomics');
|
||||
if (continuation?.kind !== 'terrorAtomics') throw new Error('Expected original Atomics continuation.');
|
||||
continuation.aftermath = {...continuation.aftermath, turn: continuation.aftermath.turn + 1};
|
||||
assert.throws(() => normalizeAutomaticGame(damaged));
|
||||
});
|
||||
|
||||
for (const claimed of [false, true]) {
|
||||
void test(`Basic Moritani Atomics random private discards offer Semuta ${claimed ? 'claim' : 'pass'}`, () => {
|
||||
const f = fixture(), pending = f.game.pendingTreacheryDiscard; assert.ok(pending?.reaction);
|
||||
assert.equal(pending.continuation.kind, 'terrorAtomics');
|
||||
assert.equal(pending.batch.cause, 'terror:atomics');
|
||||
assert.deepEqual(pending.batch.entries.map(row => [row.card.id, row.discardedBy, row.publicFace]),
|
||||
[[f.otherDiscard, 'm', false], [f.ownDiscard, 'r', false]]);
|
||||
assert.equal(seat(f.game, 'm').tanks, 2);
|
||||
assert.equal(seat(f.game, 'a').tanks, 2);
|
||||
assert.equal(f.game.moritaniAtomics?.territory, 'carthag');
|
||||
assert.equal(f.game.moritaniAtomics?.allyAtActivation, 'r');
|
||||
assert.equal(f.game.moritaniTerror!.tokens.find(row => row.id === f.token)!.status, 'removed');
|
||||
for (const player of f.game.players) assert.deepEqual(viewGame(f.game, player.id).semutaReaction?.candidates, []);
|
||||
assert.equal(viewGame(f.game, 'r').semutaReaction?.canCommit, true);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(f.game, 'r'); view.players.find(row => row.id === 'r')!.bot = difficulty;
|
||||
const action = botActions(view).find(item => item.type === 'semutaCommit' || item.type === 'semutaPass');
|
||||
assert.ok(action); assert.doesNotThrow(() => act(f.game, 'r', action));
|
||||
}
|
||||
const event = pending.batch.event;
|
||||
let game = f.game;
|
||||
if (claimed) {
|
||||
game = act(game, 'r', {type:'semutaCommit', event});
|
||||
if (game.pendingTreacheryDiscard?.reaction?.stage === 'select') {
|
||||
assert.deepEqual(viewGame(game, 'r').semutaReaction?.candidates.map(card => card.id), [f.otherDiscard]);
|
||||
assert.deepEqual(viewGame(game, 'm').semutaReaction?.candidates, []);
|
||||
assert.throws(() => act(game, 'r', {type:'semutaSelect', event, card:f.ownDiscard}));
|
||||
game = act(game, 'r', {type:'semutaSelect', event, card:f.otherDiscard});
|
||||
}
|
||||
} else {
|
||||
for (const player of game.players) game = act(game, player.id, {type:'semutaPass', event});
|
||||
}
|
||||
assert.equal(game.pendingTreacheryDiscard, null);
|
||||
assert.equal(game.pendingTerrorEntry ?? null, null);
|
||||
assert.equal(seat(game, 'm').tanks, 2);
|
||||
assert.equal(seat(game, 'a').tanks, 2);
|
||||
assert.equal(seat(game, 'a').forces['carthag:11'], undefined);
|
||||
assert.equal(game.moritaniAtomics?.territory, 'carthag');
|
||||
assert.equal(game.moritaniTerror!.tokens.find(row => row.id === f.token)!.status, 'removed');
|
||||
assert.equal(seat(game, 'r').hand.some(card => card.id === f.otherDiscard), claimed);
|
||||
assert.equal(game.discard.some(card => card.id === f.otherDiscard), !claimed);
|
||||
assert.equal(game.discard.some(card => card.id === f.ownDiscard), true);
|
||||
assert.equal(game.discard.some(card => card.id === SEMUTA_DRUG_ID), claimed);
|
||||
assert.equal(seat(game, 'm').hand.length, 3);
|
||||
assert.equal(seat(game, 'r').hand.length, 3);
|
||||
assert.throws(() => act(game, 'r', {type:'semutaCommit', event}));
|
||||
assert.deepEqual(inventory(game), f.original);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user