Honor group alliances in Truthtrance
Verify and publish checkpoint container / container (push) Successful in 11m22s

This commit is contained in:
Quintine committed 2026-10-29 00:41:56 +11:00
1 parent 9cb65a21f5
commit 3e0fa2a8a2
11 files changed
+592 -24

No files matched your search

+30 -7
View File
@@ -2,15 +2,25 @@
import type { Action, GameView } from '@/game/engine';
import { allianceClaimText, alliancePromiseModeSupported } from '@/game/alliance-promises';
import { evaluateAllianceExpression } from '@/game/alliance-expression-promises';
import { evaluateAllianceExpression, type AllianceExpression } from '@/game/alliance-expression-promises';
import { coalitionAllies, coalitionOf } from '@/game/coalition-alliance';
import { quoteNexusAlliance } from '@/game/nexus-alliance';
import { Button } from './ui/button';
type AlliancePromiseProps = { game: GameView; busy: boolean; send: (action: Action) => void };
const publicContext = (game: GameView) => ({
players: game.players.map(player => ({ id: player.id, ally: player.ally })), offers: game.allianceOffers,
players: game.players.map(player => ({ id: player.id, ally: player.ally, allySinceTurn: player.allySinceTurn })),
offers: game.allianceOffers, coalitionState: game.coalitionState,
});
/** Group membership is public. Evaluate only pure expressions here: mixed
* questions retain their privately compiled facts on the server. */
function expressionMatchesMembers(expression: AllianceExpression, allies: readonly string[]): boolean {
if (expression.kind === 'partner') return allies.includes(expression.partner);
return expression.kind === 'and' ? expression.terms.every(term => expressionMatchesMembers(term, allies))
: expression.terms.some(term => expressionMatchesMembers(term, allies));
}
/** A guide may end a pair, accept real consent or confirm current membership.
* It never sends a proposal on another player's behalf. */
export function AlliancePromiseGuideButton(props: AlliancePromiseProps) {
@@ -29,15 +39,27 @@ export function AlliancePromiseGuideButton(props: AlliancePromiseProps) {
!promise.fulfilled && !promise.released);
// Pure membership is visible. The server alone knows a mixed question's
// frozen facts and reduced future obligation; never reconstruct it here.
const matches = (ally: string | null) => live.every(promise => (ally === promise.partner) === promise.answer) &&
pure.every(promise => evaluateAllianceExpression(promise.claim, ally) === promise.answer);
let ownedAction = action.type === 'ready' && !!me && matches(me.ally);
const matches = (allies: readonly string[]) => live.every(promise => allies.includes(promise.partner) === promise.answer) &&
pure.every(promise => (allies.length <= 1 ? evaluateAllianceExpression(promise.claim, allies[0] ?? null)
: expressionMatchesMembers(promise.claim, allies)) === promise.answer);
let group: ReturnType<typeof coalitionOf> = null, allies = me?.ally ? [me.ally] : [];
try {
if (current.coalitionState && me) {
group = coalitionOf(current.players, current.coalitionState, actor);
allies = coalitionAllies(current.players, current.coalitionState, actor);
}
} catch { return false; }
let ownedAction = action.type === 'ready' && !!me && matches(allies);
if (action.type === 'coalitionLeave')
ownedAction = !!group && group.members.length >= 3 && !!current.coalitionState &&
current.coalitionState.proposal === null && action.expectedRevision === current.coalitionState.revision && matches([]);
if (action.type === 'alliance') {
if (action.target === null) ownedAction = true;
if ((group?.members.length ?? 0) >= 3) ownedAction = false;
else if (action.target === null) ownedAction = true;
else if (typeof action.target === 'string' && action.target !== actor && current.players.some(player => player.id === action.target)) {
try {
const quote = quoteNexusAlliance({ ...publicContext(current), actor, target: action.target });
ownedAction = !!quote.formed && matches(quote.allies[actor]);
ownedAction = !!quote.formed && matches(quote.allies[actor] ? [quote.allies[actor]!] : []);
} catch { ownedAction = false; }
}
}
@@ -55,6 +77,7 @@ export function AlliancePromiseGuideButton(props: AlliancePromiseProps) {
return <Button className="game-action min-h-11 whitespace-normal" data-testid="alliance-promise-guide"
disabled={!canAct()} onClick={() => { if (canAct()) props.send(action); }}>
{action.type === 'ready' ? 'Confirm Ready for this Nexus while honoring your answers'
: action.type === 'coalitionLeave' ? 'Leave your group alliance while honoring your answers'
: action.target === null ? me?.ally ? 'Break your current alliance while honoring your answers'
: 'Withdraw your alliance offer while honoring your answers'
: `Accept ${partner}’s alliance invitation while honoring your answers`}
+12 -1
View File
@@ -17,6 +17,17 @@ open mode gates.
## Current checkpoint and work
**Staged group Truthtrance and larger worm decisions — 29 October. Prototyped / Partial.**
Alliance answers and expressions now read all group members rather than the
single-partner field; the respondent's owned leave can satisfy a No. Bribe
questions reject every current group ally. Group changes with live bribe or
whole-turn commitments remain fenced. Larger worm response sequences have
action-level tests with explicitly staged forces. Group lobby entry and complete
interactions remain unfinished. Eighteen related files pass 185 cases,
including the real-action group questions, rendered guides and staged worm
responses; typecheck, lint and the production build pass.
[Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-truthtrance-and-larger-worm-decisions--29-october-2026).
**Staged unlimited alliances — 29 October. Prototyped / Partial.** An explicit
`coalitionState.variant` of `unlimited` permits up to six faction members;
absence preserves the existing three-member prototype. Real staged actions
@@ -24,7 +35,7 @@ cover four-member unanimous admission, three-donor funding, departure and
rejoining, a three-stronghold win, Emperor aid, bot admission and actual table
controls. Membership and selected faction helper cases cover up to six seats.
Fourteen related files pass 120 cases, with an additional pair-target case;
typecheck, lint and the production build pass. Live commitments, other profiles,
typecheck, lint and the production build pass. Live bribe and whole-turn commitments, other profiles,
full interactions and lobby entry remain unfinished.
[Scope](OPTIONAL_ALLIANCE_VICTORY.md#unlimited-alliance-prototype--29-october-2026).
+21 -2
View File
@@ -1,5 +1,24 @@
# Optional three-stronghold alliance victory
## Group Truthtrance and larger worm decisions — 29 October 2026
**Staged Basic prototype, not a selectable game mode.** Final-Nexus
Truthtrance questions now evaluate every member of a group, including grouped
and mixed expressions. A respondent can stay when their answers match, or leave
their group when that owned action satisfies them; the guide does not invent
another player's consent. Another member may leave, with the existing
reconciliation rules releasing an answer only if it becomes impossible. Bribe
questions reject every current group ally as a recipient. Whole-turn alliance
leaves, group changes with live bribe or whole-turn commitments, and wider
combinations remain unfinished.
Action-level worm tests additionally exercise three and four threatened group
allies in order, with separate allow, cancel or decline outcomes and the
Fremen survival response. Those fixtures stage forces together to exercise the
response sequence; they do not establish a naturally reachable full-game
position. The membership, departure and multi-ally protection procedures remain
provisional. Group games still cannot be started from the lobby.
## Unlimited alliance prototype — 29 October 2026
**Staged Basic prototype, not a selectable game mode.** The optional
@@ -19,7 +38,7 @@ pairs any number of allies with three strongholds. Unanimous admission,
departure handling, donor-specific escrow and extension of single-ally powers
are provisional online procedures, not additional publisher rulings. Tests use
an explicitly staged Basic classic-faction ledger, not a normal lobby start.
Advanced, expansions, optional modules, live Truthtrance commitments and
Advanced, expansions, optional modules, live bribe and whole-turn commitments and
complete group interactions remain unfinished. No lobby creates this ledger;
the prototype does not certify or open a complete rules mode.
@@ -193,7 +212,7 @@ The staged table renders the group controls and minimal bots can answer.
Unanimity, cancellation, the pending-Nexus hold and the surviving pair are
provisional online procedures, not publisher rulings. This scope excludes
expansion decks/factions, Advanced, optional modules, live Truthtrance
expansion decks/factions, Advanced, optional modules, live bribe or whole-turn
commitments and pledged aid. No lobby creates the group ledger: faction ally
benefits, group funding and other interactions must be connected before any
player can start the printed larger-alliance option. The later unlimited
+7
View File
@@ -1,5 +1,12 @@
# Rule decision index
## Group Truthtrance and larger worm decisions — 29 October 2026
Staged Basic group alliance answers use actual group membership, with a
stay-or-owned-leave witness and no assumed outside consent. Group bribe
questions exclude each current ally. The multi-ally worm order and separate
responses remain provisional. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-truthtrance-and-larger-worm-decisions--29-october-2026).
## Unlimited alliances — 29 October 2026
The staged Basic prototype models the publisher's any-size alliance and
+45 -6
View File
@@ -47,7 +47,7 @@ import { TravelPromiseError, liveTravelPromises, travelBranches, recordTravelShi
import { mixedTraitorRequirement, type MixedTraitorClaim } from './mixed-traitor-question';
import type { MixedBattleActionClaim, MixedBattleClaim } from './mixed-battle-question';
import { AllianceExpressionPromiseError, allianceExpressionOutcomes, findAllianceExpressionPlans,
allianceExpressionPromisesMatch, evaluateAllianceExpression, validateAllianceExpressionPromises,
allianceExpressionPromisesMatch, validateAllianceExpressionPromises,
type AllianceExpression, type AllianceExpressionClaim, type AllianceExpressionPromise } from './alliance-expression-promises';
import type { AllianceActionClaim, MixedAllianceClaim } from './mixed-alliance-question';
import { mixedBidMinimum, type MixedBidClaim } from './mixed-bid-question';
@@ -16049,8 +16049,7 @@ function coalitionNexusAvailable(g: Game): boolean {
g.expansions.length === 0 && g.players.every(seat =>
['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(seat.faction)) &&
Object.values(g.aid).every(credit => credit.amount === 0) &&
![g.alliancePromises, g.allianceExpressionPromises, g.bribePromises,
g.bribeExpressionPromises, g.turnActionPromises].some(unsettledPromises);
![g.bribePromises, g.bribeExpressionPromises, g.turnActionPromises].some(unsettledPromises);
}
function coalitionRule<T>(work: () => T): T {
try { return work(); }
@@ -16139,6 +16138,12 @@ function coalitionBidPaymentQuote(g: Game, p: Player, cost: number, contribution
}
/** The original native reciprocal quote owns identity and pairing legality. */
function normalNexusAllianceQuote(g: Game, p: Player, target: string | null) {
if (g.coalitionState) {
const own = coalitionRule(() => coalitionOf(g.players, g.coalitionState, p.id));
const other = target ? coalitionRule(() => coalitionOf(g.players, g.coalitionState, target)) : null;
requireRule(!(own && own.members.length >= 3) && !(other && other.members.length >= 3),
'Group members must use the group alliance controls.');
}
if (target !== null) {
const other = getPlayer(g, target);
requireRule(other.id !== p.id, 'Choose another player.');
@@ -16167,10 +16172,45 @@ function normalNexusAllianceQuote(g: Game, p: Player, target: string | null) {
return nexusRule(() => quoteNexusAlliance({ players: g.players.map(player => ({ id: player.id, ally: player.ally })),
offers: g.allianceOffers, actor: p.id, target }));
}
/** A larger alliance has multiple allies, so final-Nexus answers must read
* membership rather than the legacy single-partner scalar. */
function allianceMember(g: Game, player: Player, partner: string): boolean {
return g.coalitionState
? coalitionRule(() => coalitionAllies(g.players, g.coalitionState, player.id)).includes(partner)
: player.ally === partner;
}
function allianceExpressionValue(g: Game, player: Player, expression: AllianceExpression): boolean {
if (expression.kind === 'partner') return allianceMember(g, player, expression.partner);
return expression.kind === 'and'
? expression.terms.every(term => allianceExpressionValue(g, player, term))
: expression.terms.some(term => allianceExpressionValue(g, player, term));
}
function groupAllianceAnswersMatch(g: Game, player: Player, opportunity: AllianceOpportunity,
promises: readonly AlliancePromise[], expressions: readonly AllianceExpressionPromise[]): boolean {
const live = (row: AllianceCommitment) => row.player === player.id && row.turn === opportunity.turn &&
row.source === opportunity.source && !row.fulfilled && !row.released;
return promises.filter(live).every(row => allianceMember(g, player, row.partner) === row.answer) &&
expressions.filter(live).every(row => allianceExpressionValue(g, player, row.claim) === row.answer);
}
function alliancePromisePlan(g: Game, p: Player, promises = g.alliancePromises ?? [],
expressions = g.allianceExpressionPromises ?? []) {
const opportunity = allianceOpportunity(g);
if (!opportunity) return null;
const group = g.coalitionState && coalitionRule(() => coalitionOf(g.players, g.coalitionState, p.id));
if (group && group.members.length >= 3) {
if (groupAllianceAnswersMatch(g, p, opportunity, promises, expressions))
return {actions: [] as Action[], bribePreparation:false};
if (g.coalitionState!.proposal || [g.bribePromises, g.bribeExpressionPromises, g.turnActionPromises].some(unsettledPromises))
return null;
const trial = structuredClone(g);
try {
applyCoalitionTransition(trial, coalitionRule(() => leaveCoalition(trial.players, trial.coalitionState,
{actor:p.id, expectedRevision:trial.coalitionState!.revision})));
if (groupAllianceAnswersMatch(trial, getPlayer(trial,p.id), opportunity, promises, expressions))
return {actions: [{type:'coalitionLeave', expectedRevision:g.coalitionState!.revision} as Action], bribePreparation:false};
} catch (error) { if (!(error instanceof RuleError)) throw error; }
return null;
}
const plans = findAllianceExpressionPlans(promises, expressions, p.id, opportunity,
{ players: g.players, offers: g.allianceOffers });
// Try every consent-supported final outcome. A financial restriction on one
@@ -16336,7 +16376,7 @@ function reconcileAlliancePromises(g: Game, voluntary?: { actor: string; action:
if (promise.fulfilled || promise.released) continue;
const same = opportunity && promise.turn === opportunity.turn && promise.source === opportunity.source;
const player = getPlayer(g, promise.player), whole = 'claim' in promise;
const matches = (whole ? evaluateAllianceExpression(promise.claim, player.ally) : player.ally === promise.partner) === promise.answer;
const matches = (whole ? allianceExpressionValue(g, player, promise.claim) : allianceMember(g, player, promise.partner)) === promise.answer;
const scalars = whole ? kept : [...kept, promise], groups = whole ? [...expressions, promise] : expressions;
const possible = same && alliancePromisePlan(g, player, scalars, groups);
if (!same && matches) { promise.fulfilled = true; continue; }
@@ -16353,8 +16393,7 @@ function reconcileAlliancePromises(g: Game, voluntary?: { actor: string; action:
function guardAlliancePromiseReady(g: Game, p: Player) {
const opportunity = allianceOpportunity(g);
if (!opportunity) return;
requireRule(alliancePromisesMatch(g.alliancePromises ?? [], p.id, opportunity, p.ally) &&
allianceExpressionPromisesMatch(g.allianceExpressionPromises ?? [], p.id, opportunity, p.ally),
requireRule(groupAllianceAnswersMatch(g, p, opportunity, g.alliancePromises ?? [], g.allianceExpressionPromises ?? []),
'Match your Truthtrance alliance answers before finishing this Nexus.');
if (liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn)
.some(promise => promise.alliance?.source === opportunity.source && !promise.alliance.closed))
+1 -1
View File
@@ -4505,7 +4505,7 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area: 'Player controls', status: 'Partial', detail: 'The lobby offers the pair option. A staged group Emperor can choose either ally for spice and extra revival; group games cannot yet be started from the lobby.', evidence: ['components/game-table.tsx', 'components/coalition-emperor-aid.tsx', 'tests/short-alliance-victory-controls.test.tsx'] },
{ area: 'AI', status: 'Partial', detail: 'Legal alliance and victory paths use the shared target; staged group Emperor bots can offer a targeted revival. Strategic tuning remains deferred.', evidence: ['game/bots.ts', 'game/victory-progress.ts'] },
{ area: 'Documentation', status: 'Partial', detail: 'The printed option and provisional two-controller precedence are recorded.', evidence: ['docs/OPTIONAL_ALLIANCE_VICTORY.md'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused pair and staged group evidence includes real Nexus actions, four-member unlimited admission, funding and victory, staged larger-group faction helpers, funding after settled Truthtrance records, Guild transport, Emperor and Fremen aid, worm protection, battle preparation and allied traitor votes; complete group play and combinations remain open.', evidence: ['tests/alliance-victory-option.test.ts', 'tests/short-alliance-victory-runtime.test.ts', 'tests/coalition-unlimited-engine.test.ts', 'tests/coalition-unlimited-powers.test.ts', 'tests/coalition-unlimited-emperor.test.ts', 'tests/coalition-nexus-engine.test.ts', 'tests/coalition-shipment-funding.test.ts', 'tests/coalition-bid-funding.test.ts', 'tests/coalition-guild-shipment.test.ts', 'tests/coalition-guild-transport.test.ts', 'tests/coalition-fremen-worm.test.ts', 'tests/coalition-emperor-aid.test.ts', 'tests/coalition-fremen-revival.test.ts', 'tests/coalition-battle-preparation.test.ts', 'tests/coalition-battle-power-question.test.ts', 'tests/coalition-harkonnen-traitor.test.ts', 'tests/coalition-traitor-declarations.test.ts'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused pair and staged group evidence includes real Nexus actions, four-member unlimited admission, funding and victory, staged larger-group faction helpers and alliance Truthtrance, funding after settled Truthtrance records, Guild transport, Emperor and Fremen aid, worm protection, battle preparation and allied traitor votes; complete group play and combinations remain open.', evidence: ['tests/alliance-victory-option.test.ts', 'tests/short-alliance-victory-runtime.test.ts', 'tests/coalition-unlimited-engine.test.ts', 'tests/coalition-unlimited-powers.test.ts', 'tests/coalition-unlimited-emperor.test.ts', 'tests/coalition-truth-alliance.test.ts', 'tests/coalition-truth-alliance-controls.test.tsx', 'tests/coalition-truth-bribe-targets.test.ts', 'tests/coalition-unlimited-worm-engine.test.ts', 'tests/coalition-nexus-engine.test.ts', 'tests/coalition-shipment-funding.test.ts', 'tests/coalition-bid-funding.test.ts', 'tests/coalition-guild-shipment.test.ts', 'tests/coalition-guild-transport.test.ts', 'tests/coalition-fremen-worm.test.ts', 'tests/coalition-emperor-aid.test.ts', 'tests/coalition-fremen-revival.test.ts', 'tests/coalition-battle-preparation.test.ts', 'tests/coalition-battle-power-question.test.ts', 'tests/coalition-harkonnen-traitor.test.ts', 'tests/coalition-traitor-declarations.test.ts'] },
],
related: [
'mentat',
+13 -7
View File
@@ -62,6 +62,7 @@ import { leaders } from './cards';
import { combatLocationName } from './combat-location';
import { parseForceCountFact, forceCountFactMatches, forceCountFactText, forceCountCounterError, type ForceCountFact, type ForceCountPlayer } from './truthtrance-force-count';
import type { Action, Game, Player } from './engine';
import { coalitionAllies } from './coalition-alliance';
import { TRUTH_CARD_NAMES } from './truthtrance-card-names';
import { CHEAP_HERO_TRAITOR } from './traitors';
import {
@@ -258,6 +259,11 @@ function battleLeaderIdentities(g: Game) {
...(g.dukeVidal?.leader.id === DUKE_VIDAL_ID ? [g.dukeVidal.leader] : []),
];
}
function nonAlliedBribeRecipient(g: Game, respondent: Player, recipient: string): boolean {
return recipient !== respondent.id && recipient !== respondent.ally &&
g.players.some(player => player.id === recipient) &&
(!g.coalitionState || !coalitionAllies(g.players, g.coalitionState, respondent.id).includes(recipient));
}
function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
const v = record(value);
check(
@@ -341,8 +347,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
const respondent = g.players.find(player => player.id === v.target)!;
const mixed = parseMixedActionExpression(v.mixed, fact => parseFact(g, fact, respondent), input => {
const action = parseBribePaymentClaim(input);
check(action.recipient !== respondent.id && action.recipient !== respondent.ally &&
g.players.some(player => player.id === action.recipient), 'Choose another current non-allied ordinary bribe recipient.');
check(nonAlliedBribeRecipient(g, respondent, action.recipient), 'Choose another current non-allied ordinary bribe recipient.');
return action;
});
return { kind: 'mixedBribe', target: v.target as string, turn: g.turn, mixed };
@@ -354,7 +359,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
const respondent = g.players.find(player => player.id === v.target)!;
check(bribePromiseModeSupported(g) && g.status === 'playing' && g.phase >= 0 && g.phase <= 8 &&
claim.turn === g.turn && bribeExpressionRecipients(claim.claim).every(recipient =>
recipient !== respondent.id && recipient !== respondent.ally && g.players.some(player => player.id === recipient)) &&
nonAlliedBribeRecipient(g, respondent, recipient)) &&
!g.response && !g.decision && !g.phaseOpening && !g.pendingTreacheryDiscard && !g.pendingKarama,
'Choose current-turn ordinary bribe statements to non-allied participants in a supported table.');
return { kind: 'bribeExpression', target: v.target, ...claim };
@@ -364,8 +369,8 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
'Supply the current turn, named bribe recipient, minimum and respondent.');
const claim = parseBribeClaim(v), respondent = g.players.find(player => player.id === v.target)!;
check(bribePromiseModeSupported(g) && g.status === 'playing' && g.phase >= 0 && g.phase <= 8 &&
claim.turn === g.turn && claim.recipient !== respondent.id && claim.recipient !== respondent.ally &&
g.players.some(player => player.id === claim.recipient) && !g.response && !g.decision && !g.phaseOpening &&
claim.turn === g.turn && nonAlliedBribeRecipient(g, respondent, claim.recipient) &&
!g.response && !g.decision && !g.phaseOpening &&
!g.pendingTreacheryDiscard && !g.pendingKarama,
'Choose a current-turn ordinary bribe to another non-allied participant in a supported table.');
return { kind: 'bribe', target: v.target, ...claim };
@@ -482,8 +487,8 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
'Choose the original owned native Voice or Prescience declaration opportunity.');
}
}
if (action.kind === 'payment') check(action.recipient !== respondent.id && action.recipient !== respondent.ally &&
g.players.some(player => player.id === action.recipient), 'Choose another current non-allied ordinary bribe recipient.');
if (action.kind === 'payment') check(nonAlliedBribeRecipient(g, respondent, action.recipient),
'Choose another current non-allied ordinary bribe recipient.');
if (action.kind === 'bid') check(bidPromiseModeSupported(g) && g.phase === 3 && g.auction &&
!g.currentAuctionSale && !g.richeseAuction && !g.ixAuction && action.lot === g.auction.index + 1,
'Choose the current ordinary bid lot in a supported native auction.');
@@ -491,6 +496,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
action.leader === 'kwisatz' && g.advanced && respondent.faction === 'atreides',
'Choose a personal native faction leader, or the Advanced Atreides Kwisatz Haderach.');
if (action.kind === 'alliance') check(alliancePromiseModeSupported(g) && g.phase === 1 && g.nexus &&
!g.coalitionState?.groups.some(group => group.members.length >= 3) &&
!g.spiceWindow && !g.spiceResolution && action.partner !== respondent.id &&
g.players.some(player => player.id === action.partner),
'Choose another seated final alliance partner in the current quiet native Nexus.');
@@ -0,0 +1,92 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { AlliancePromiseGuideButton } from '../components/alliance-promises';
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game, type GameView } from '../game/engine';
const send = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
/** Real public pair/group formation with controlled promise and guide
* projections. These cases exercise client dispatch and stale guards. */
function projected(kind: 'yes' | 'no' | 'expression' | 'pair'): GameView {
let game = createGame('GROUPTRUTHCONTROLS', newPlayer('a', 'Atreides', 'atreides'), false);
game.players.push(newPlayer('b', 'Emperor', 'emperor'), newPlayer('c', 'Fremen', 'fremen'),
newPlayer('d', 'Harkonnen', 'harkonnen'));
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:['a','b','c','d'], ready:[]});
game.coalitionState = emptyCoalitionState('unlimited');
game = send(game, 'a', {type:'alliance', target:'b'});
game = send(game, 'b', {type:'alliance', target:'a'});
if (kind !== 'pair') {
const original = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(original);
game = send(game, 'a', {type:'coalitionPropose', groupId:original.id, entrant:'c',
expectedRevision:game.coalitionState!.revision});
for (const actor of ['b','c']) {
const offer = game.coalitionState!.proposal!;
game = send(game, actor, {type:'coalitionAnswer', event:offer.event, nexus:offer.nexus,
turn:offer.turn, accept:true, expectedRevision:game.coalitionState!.revision});
}
}
const view = viewGame(game, 'a');
view.alliancePromiseNexus = {turn:2};
view.alliancePromises = kind === 'expression' ? [] : [{event:'controlled', player:'a', asker:'d',
turn:2, partner:'b', answer:kind !== 'no'}];
view.allianceExpressionPromises = kind !== 'expression' ? [] : [{event:'controlled', player:'a', asker:'d',
turn:2, claim:{kind:'and', terms:[{kind:'partner',partner:'b'}, {kind:'partner',partner:'c'}]}, answer:true}];
view.alliancePromiseGuide = {action:kind === 'no'
? {type:'coalitionLeave', expectedRevision:view.coalitionState!.revision, alliancePromiseEvent:'controlled'}
: {type:'ready', alliancePromiseEvent:'controlled'}};
return view;
}
function button(game: GameView, sent: Action[], busy = false) {
const result = AlliancePromiseGuideButton({game, busy, send(action) { sent.push(action); }});
assert.ok(result); return result;
}
void test('group Yes and whole-AND guides render an enabled Ready action', () => {
for (const kind of ['yes', 'expression'] as const) {
const game = projected(kind), sent:Action[] = [];
const control = button(game, sent);
assert.equal(control.props.disabled, false);
assert.match(renderToStaticMarkup(createElement(AlliancePromiseGuideButton, {game, busy:false, send() {}})),
/Confirm Ready for this Nexus/);
control.props.onClick();
assert.deepEqual(sent, [game.alliancePromiseGuide!.action]);
}
});
void test('group No guide renders and dispatches its exact event-bound leave action', () => {
const game = projected('no'), sent:Action[] = [];
const control = button(game, sent);
assert.equal(control.props.disabled, false);
assert.match(renderToStaticMarkup(createElement(AlliancePromiseGuideButton, {game, busy:false, send() {}})),
/Leave your group alliance while honoring your answers/);
control.props.onClick();
assert.deepEqual(sent, [game.alliancePromiseGuide!.action]);
});
void test('group guide refuses stale revision, membership, event, and quiet-window changes', () => {
const mutations: ((game: GameView) => void)[] = [
game => { game.coalitionState!.revision += 1; },
game => { game.coalitionState!.groups[0].members.pop(); },
game => { game.players = game.players.filter(player => player.id !== 'b'); },
game => { game.alliancePromises[0].event = 'changed'; },
game => { game.alliancePromiseGuide!.action.expectedRevision = -1; },
game => { game.ready.push('a'); },
game => { game.nexus = false; },
game => { game.automaticContinuationPending = true; },
];
for (const mutate of mutations) {
const game = projected('no'), sent:Action[] = [], control = button(game, sent);
mutate(game); control.props.onClick(); assert.deepEqual(sent, []);
}
});
void test('reciprocal pair guidance remains usable with a staged coalition ledger', () => {
const game = projected('pair'), sent:Action[] = [];
const control = button(game, sent);
assert.equal(control.props.disabled, false); control.props.onClick();
assert.deepEqual(sent, [game.alliancePromiseGuide!.action]);
});
+164
View File
@@ -0,0 +1,164 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { baseDeck } from '../game/cards';
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
const send = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
/** The quiet Nexus and physical Truthtrance card are staged; every alliance,
* invitation, question, answer and later group decision is a real action. */
function group(): Game {
let game = createGame('GROUPTRUTH', newPlayer('a', 'Atreides', 'atreides'), false);
game.players.push(newPlayer('b', 'Emperor', 'emperor'), newPlayer('c', 'Fremen', 'fremen'),
newPlayer('d', 'Harkonnen', 'harkonnen'), newPlayer('e', 'Guild', 'guild'));
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:['a','b','c','d','e'],
ready:[], deck:baseDeck()});
game.coalitionState = emptyCoalitionState('unlimited');
game = send(game, 'a', {type:'alliance', target:'b'});
game = send(game, 'b', {type:'alliance', target:'a'});
for (const entrant of ['c','d']) {
const original = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(original);
game = send(game, 'a', {type:'coalitionPropose', groupId:original.id, entrant,
expectedRevision:game.coalitionState!.revision});
for (const actor of [...original.members.filter(id => id !== 'a'), entrant]) {
const offer = game.coalitionState!.proposal!;
game = send(game, actor, {type:'coalitionAnswer', event:offer.event, nexus:offer.nexus,
turn:offer.turn, accept:true, expectedRevision:game.coalitionState!.revision});
}
}
const index = game.deck.findIndex(card => card.effect === 'truthtrance'); assert.ok(index >= 0);
const [card] = game.deck.splice(index, 1);
game.players.find(player => player.id === 'e')!.hand.push(card);
return game;
}
function openTruth(game: Game): Game {
const card = game.players.find(player => player.id === 'e')!.hand.find(row => row.effect === 'truthtrance')!;
game = send(game, 'e', {type:'card', card:card.id});
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
game = send(game, actor.id, {type:'truthPass'});
}
return game;
}
function ask(game: Game, partner: string): Game {
game = openTruth(game);
return send(game, 'e', {type:'truthAsk', question:{kind:'alliance', target:'a', turn:game.turn, partner}});
}
function leave(game: Game, actor: string): Game {
return send(game, actor, {type:'coalitionLeave', expectedRevision:game.coalitionState!.revision});
}
function rejected(game: Game, actor: string, action: Action) {
const original = structuredClone(game);
assert.throws(() => send(game, actor, action));
assert.deepEqual(game, original);
}
void test('a group member can truthfully answer Yes or No about an existing ally', () => {
const game = ask(group(), 'b');
const answers = viewGame(game, 'a').truthAllianceAnswers;
assert.ok(answers?.includes('yes'), 'The respondent is already allied to the named partner.');
assert.ok(answers?.includes('no'), 'The respondent can leave the alliance before the Nexus closes.');
});
void test('a Yes preserves the named alliance while allowing an unrelated member to leave', () => {
let game = ask(group(), 'b');
game = send(game, 'a', {type:'truthAnswer', answer:'yes'});
rejected(game, 'a', {type:'coalitionLeave', expectedRevision:game.coalitionState!.revision});
game = leave(game, 'c');
assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'a')!.members, ['a','b','d']);
assert.equal(game.alliancePromises!.find(row => row.player === 'a')!.released ?? false, false);
const guide = viewGame(game, 'a').alliancePromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, 'ready');
game = send(game, 'a', guide.action);
assert.ok(game.ready.includes('a'));
});
void test('a named partner leaving externally releases the now-unfulfillable Yes', () => {
let game = ask(group(), 'b');
game = send(game, 'a', {type:'truthAnswer', answer:'yes'});
game = leave(game, 'b');
assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'a')!.members, ['a','c','d']);
assert.equal(coalitionOf(game.players, game.coalitionState, 'b'), null);
assert.equal(game.alliancePromises!.find(row => row.player === 'a')!.released, true);
assert.equal(viewGame(game, 'a').alliancePromiseGuide, null);
});
void test('a No requires the respondent to leave, with a legal bot guide', () => {
let game = ask(group(), 'b');
game = send(game, 'a', {type:'truthAnswer', answer:'no'});
rejected(game, 'a', {type:'ready'});
const guide = viewGame(game, 'a').alliancePromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, 'coalitionLeave');
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, 'a');
view.players.find(player => player.id === 'a')!.bot = difficulty;
assert.deepEqual(botActions(view)[0], guide.action);
assert.doesNotThrow(() => send(game, 'a', guide.action));
}
game = send(game, 'a', guide.action);
assert.equal(coalitionOf(game.players, game.coalitionState, 'a'), null);
assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'b')!.members, ['b','c','d']);
game = send(game, 'a', {type:'ready'});
assert.ok(game.ready.includes('a'));
});
void test('an outsider cannot be promised as an ally without their consent', () => {
const game = ask(group(), 'e');
const answers = viewGame(game, 'a').truthAllianceAnswers;
assert.ok(answers?.includes('no'));
assert.ok(answers?.includes('unknown'));
assert.ok(!answers?.includes('yes'));
rejected(game, 'a', {type:'truthAnswer', answer:'yes'});
});
const bothAllies = {kind:'and', terms:[{kind:'partner', partner:'b'}, {kind:'partner', partner:'c'}]};
const factualAndAllies = {kind:'and', terms:[{kind:'fact', fact:{kind:'spice', compare:'gte', value:0}},
{kind:'action', action:{partner:'b'}}, {kind:'action', action:{partner:'c'}}]};
for (const answer of ['yes', 'no'] as const) void test(`group alliance AND can answer ${answer} while evaluating both named allies`, () => {
let game = openTruth(group());
game = send(game, 'e', {type:'truthAsk', question:{kind:'allianceExpression', target:'a',
turn:game.turn, claim:bothAllies}});
const answers = viewGame(game, 'a').truthAllianceExpressionAnswers;
assert.ok(answers?.includes('yes')); assert.ok(answers?.includes('no'));
game = send(game, 'a', {type:'truthAnswer', answer});
const guide = viewGame(game, 'a').alliancePromiseGuide; assert.ok(guide);
if (answer === 'yes') {
assert.equal(guide.action.type, 'ready');
rejected(game, 'a', {type:'coalitionLeave', expectedRevision:game.coalitionState!.revision});
game = send(game, 'a', guide.action);
} else {
rejected(game, 'a', {type:'ready'});
assert.equal(guide.action.type, 'coalitionLeave');
game = send(game, 'a', guide.action);
assert.equal(coalitionOf(game.players, game.coalitionState, 'a'), null);
game = send(game, 'a', {type:'ready'});
}
assert.ok(game.ready.includes('a'));
});
for (const answer of ['yes', 'no'] as const) void test(`mixed factual and group-alliance question retains the ${answer} answer`, () => {
let game = openTruth(group());
game = send(game, 'e', {type:'truthAsk', question:{kind:'mixedAlliance', target:'a',
turn:game.turn, mixed:factualAndAllies}});
const answers = viewGame(game, 'a').truthMixedAllianceAnswers;
assert.ok(answers?.includes('yes')); assert.ok(answers?.includes('no'));
game = send(game, 'a', {type:'truthAnswer', answer});
const guide = viewGame(game, 'a').alliancePromiseGuide; assert.ok(guide);
assert.equal(guide.action.type, answer === 'yes' ? 'ready' : 'coalitionLeave');
game = send(game, 'a', guide.action);
if (answer === 'no') {
assert.equal(coalitionOf(game.players, game.coalitionState, 'a'), null);
game = send(game, 'a', {type:'ready'});
}
assert.ok(game.ready.includes('a'));
});
void test('whole-turn alliance leaves remain fenced while a group is active', () => {
const game = openTruth(group());
rejected(game, 'e', {type:'truthAsk', question:{kind:'turnActions', target:'a', turn:game.turn,
mixed:{kind:'action', action:{kind:'alliance', partner:'b'}}}});
});
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { baseDeck } from '../game/cards';
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
import { applyAction, createGame, newPlayer, type Action, type Game } from '../game/engine';
const send = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
/** Stage the Nexus, spendable purse and physical card. The alliance and Truthtrance
* priority are real actions; b and c are a's group allies, d and e outsiders. */
function questionWindow(): Game {
let game = createGame('GROUPBRIBETRUTH', newPlayer('a', 'Atreides', 'atreides'), false);
game.players.push(newPlayer('b', 'Emperor', 'emperor'), newPlayer('c', 'Fremen', 'fremen'),
newPlayer('d', 'Harkonnen', 'harkonnen'), newPlayer('e', 'Guild', 'guild'));
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true,
order:['a','b','c','d','e'], ready:[], deck:baseDeck()});
game.coalitionState = emptyCoalitionState('unlimited');
game.players.find(player => player.id === 'a')!.spice = 5;
game = send(game, 'a', {type:'alliance', target:'b'});
game = send(game, 'b', {type:'alliance', target:'a'});
const group = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(group);
game = send(game, 'a', {type:'coalitionPropose', groupId:group.id, entrant:'c',
expectedRevision:game.coalitionState!.revision});
for (const actor of ['b', 'c']) {
const offer = game.coalitionState!.proposal!;
game = send(game, actor, {type:'coalitionAnswer', event:offer.event, nexus:offer.nexus,
turn:offer.turn, accept:true, expectedRevision:game.coalitionState!.revision});
}
const index = game.deck.findIndex(card => card.effect === 'truthtrance'); assert.ok(index >= 0);
const [card] = game.deck.splice(index, 1); game.players.find(player => player.id === 'e')!.hand.push(card);
game = send(game, 'e', {type:'card', card:card.id});
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
game = send(game, actor.id, {type:'truthPass'});
}
return game;
}
const fact = {kind:'fact', fact:{kind:'spice', compare:'gte', value:0}};
function question(family: 'bribe' | 'bribeExpression' | 'mixedBribe' | 'turnActions', recipient: string) {
const base = {kind:family, target:'a', turn:2};
if (family === 'bribe') return {...base, recipient, minimum:1};
if (family === 'bribeExpression') return {...base, claim:{kind:'and', terms:[
{kind:'payment', recipient, minimum:1}, {kind:'payment', recipient:'d', minimum:1},
]}};
if (family === 'mixedBribe') return {...base, mixed:{kind:'or', terms:[fact,
{kind:'action', action:{recipient, minimum:1}},
]}};
return {...base, mixed:{kind:'or', terms:[fact,
{kind:'action', action:{kind:'payment', recipient, minimum:1}},
]}};
}
for (const family of ['bribe', 'bribeExpression', 'mixedBribe', 'turnActions'] as const)
void test(`${family} question rejects an allied group payment and accepts an outsider`, () => {
const game = questionWindow(), before = structuredClone(game);
assert.throws(() => send(game, 'e', {type:'truthAsk', question:question(family, 'b')}));
assert.deepEqual(game, before, 'A rejected allied-payment question retains its Truthtrance window.');
const accepted = send(game, 'e', {type:'truthAsk', question:question(family, 'e')});
assert.equal(accepted.truthtrance?.question?.kind, family);
});
@@ -0,0 +1,146 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { baseDeck, spiceDeck } from '../game/cards';
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
const act = (game: Game, id: string, action: Action) => applyAction(game, id, action);
const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!;
const location = 'red_chasm:7';
/** The unlimited ledger, Nexus and later Spice Blow are staged. All pair and
* group admissions, the worm draw and its choices/responses are actual engine
* actions. Physical forces and the actual Karama cards are conserved. */
function source(entrantIds: readonly string[], fremenPresent = false) {
let game = createGame('UNLIMITEDWORM', newPlayer('f', 'Fremen', 'fremen'), false);
game.players.push(newPlayer('e', 'Emperor', 'emperor'), newPlayer('a', 'Atreides', 'atreides'),
newPlayer('h', 'Harkonnen', 'harkonnen'), newPlayer('g', 'Guild', 'guild'),
newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true,
order:['f','e','a','h','g','b'], ready:[], response:null, decision:null,
phaseOpening:null, storm:18, deck:baseDeck()});
game.coalitionState = emptyCoalitionState('unlimited');
game = act(game, 'f', {type:'alliance', target:'e'});
game = act(game, 'e', {type:'alliance', target:'f'});
for (const entrant of entrantIds) {
const group = coalitionOf(game.players, game.coalitionState, 'f'); assert.ok(group);
game = act(game, 'f', {type:'coalitionPropose', groupId:group.id, entrant,
expectedRevision:game.coalitionState!.revision});
for (const id of [...group.members.filter(member => member !== 'f'), entrant]) {
const proposal = game.coalitionState!.proposal!;
game = act(game, id, {type:'coalitionAnswer', event:proposal.event, nexus:proposal.nexus,
turn:proposal.turn, accept:true, expectedRevision:game.coalitionState!.revision});
}
}
Object.assign(game, {phase:1, turn:3, nexus:false, ready:[], response:null, decision:null,
phaseOpening:null, storm:18, active:null, stormPending:null});
for (const player of game.players) {
player.hand = []; player.forces = {}; player.reserves = 20; player.tanks = 0;
player.traitors = []; player.traitorChoices = [];
}
const group = coalitionOf(game.players, game.coalitionState, 'f'); assert.ok(group);
const outsider = game.players.find(player => !group.members.includes(player.id)); assert.ok(outsider);
for (const id of [...group.members.filter(member => member !== 'f'), outsider.id,
...(fremenPresent ? ['f'] : [])]) {
seat(game,id).forces = {[location]:3}; seat(game,id).reserves = 17;
}
const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
const [karama] = game.deck.splice(index,1); outsider.hand.push(karama);
// Keep a distinct physical counter available for the later survival
// response after the outsider spends the first Karama on one ally.
let survivalKarama: typeof karama | undefined;
if (fremenPresent) {
const counter = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(counter >= 0);
[survivalKarama] = game.deck.splice(counter,1);
seat(game,'b').hand.push(survivalKarama);
}
const spice = spiceDeck();
const land = spice.find(card => 'territory' in card && card.territory === 'red_chasm');
const worm = spice.find(card => 'worm' in card); assert.ok(land && worm);
game.spiceDiscard = [[land], []];
game.spiceDeck = [worm, ...spice.filter(card => card !== land && card !== worm && 'territory' in card)];
game.spice = {[location]:8};
for (const player of game.players) game = act(game, player.id, {type:'ready'});
assert.equal(game.decision?.kind,'wormProtection');
return {game, karama, survivalKarama, outsider:outsider.id};
}
function pass(game: Game, kind: string): Game {
for (let count = 0; game.response?.kind === kind && count < 32; count++) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id));
assert.ok(actor); game = act(game, actor.id, {type:'passResponse'});
}
assert.notEqual(game.response?.kind, kind);
return game;
}
function choose(game: Game, accept: boolean): Game {
assert.equal(game.decision?.kind,'wormProtection'); assert.ok(game.decision.event);
return act(game,'f',{type:'decision',accept,event:game.decision.event,ally:game.decision.ally});
}
function conserved(game: Game) {
for (const player of game.players)
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((a,b) => a+b,0),20);
}
void test('four-member group resolves three distinct worm protections before Fremen survival', () => {
const initial = source(['a','h'], true);
let {game} = initial;
assert.deepEqual(game.coalitionWormProtection?.order,['e','a','h']);
assert.equal(game.decision?.kind,'wormProtection'); assert.equal(game.decision.ally,'e');
game = pass(choose(game,true),'wormAllyProtection');
assert.equal(game.decision?.kind,'wormProtection'); assert.equal(game.decision.ally,'a');
game = choose(game,true);
assert.equal(game.response?.kind,'wormAllyProtection'); assert.equal(game.response.recipient,'a');
game = act(game, initial.outsider, {type:'card',mode:'cancel',card:initial.karama.id});
game = pass(game,'wormAllyProtection');
assert.equal(game.decision?.kind,'wormProtection'); assert.equal(game.decision.ally,'h');
game = choose(game,false);
assert.equal(game.response?.kind,'wormSurvival');
assert.deepEqual(game.coalitionWormProtection?.outcomes,[
{ally:'e',outcome:'allowed'}, {ally:'a',outcome:'canceled'}, {ally:'h',outcome:'declined'},
]);
game = pass(game,'wormSurvival');
for (const id of ['e','f']) assert.equal(seat(game,id).forces[location],3);
for (const id of ['a','h',initial.outsider]) {
assert.equal(seat(game,id).forces[location] ?? 0,0); assert.equal(seat(game,id).tanks,3);
}
assert.equal(game.discard.filter(card => card.id === initial.karama.id).length,1);
assert.ok(initial.survivalKarama && seat(game,'b').hand.some(card => card.id === initial.survivalKarama!.id));
assert.equal(game.spice[location],undefined);
assert.deepEqual(game.wormRides,['red_chasm']);
assert.equal(game.coalitionWormProtection,undefined);
conserved(game);
});
void test('five-member group offers every threatened ally in order with legal bot choices', () => {
const initial = source(['a','h','g']);
let game = initial.game;
const {outsider} = initial;
assert.deepEqual(game.coalitionWormProtection?.order,['e','a','h','g']);
const seen:string[] = [];
let first:Action | undefined;
while (game.decision?.kind === 'wormProtection') {
const {ally,event} = game.decision; assert.ok(event); seen.push(ally);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game,'f');
view.players.find(player => player.id === 'f')!.bot = difficulty;
const action = botActions(view).find(candidate => candidate.type === 'decision' && typeof candidate.accept === 'boolean');
assert.ok(action, difficulty); assert.equal(action.event,event); assert.equal(action.ally,ally);
assert.doesNotThrow(() => act(game,'f',action));
}
if (first) {
const before = structuredClone(game);
assert.throws(() => act(game,'f',first!)); assert.deepEqual(game,before);
} else first = {type:'decision',accept:false,event,ally};
game = choose(game,ally === 'g');
game = pass(game,'wormAllyProtection');
}
assert.deepEqual(seen,['e','a','h','g']);
assert.equal(seat(game,'g').forces[location],3);
for (const id of ['e','a','h',outsider]) assert.equal(seat(game,id).tanks,3);
assert.equal(game.spice[location],undefined);
assert.equal(game.nexus,true);
conserved(game);
});