Allow group departures with bribe commitments
Verify and publish checkpoint container / container (push) Successful in 10m51s
Verify and publish checkpoint container / container (push) Successful in 10m51s
This commit is contained in:
1 parent
3e0fa2a8a2
commit
0faa7923b0
13 files changed
+437
-37
No files matched your search
@@ -12,6 +12,9 @@ export type CoalitionNexusView = {
|
||||
revision: number;
|
||||
/** Absent preserves the original, at-most-three alliance prototype. */
|
||||
variant?: 'unlimited';
|
||||
/** Until group admission can reconcile a live bribe commitment, only
|
||||
* departure remains available in this Nexus. */
|
||||
admissionBlocked?: boolean;
|
||||
};
|
||||
|
||||
export type CoalitionNexusIntent =
|
||||
@@ -29,7 +32,7 @@ export function coalitionNexusOptions(view: CoalitionNexusView): {
|
||||
const seated = view.players.some(player => player.id === view.me);
|
||||
const ownGroup = view.groups.find(group => group.members.includes(view.me));
|
||||
const occupied = new Set(view.groups.flatMap(group => group.members));
|
||||
const invitations = !seated || view.proposal ? [] : view.groups.flatMap(group =>
|
||||
const invitations = !seated || view.proposal || view.admissionBlocked ? [] : view.groups.flatMap(group =>
|
||||
(group.members.length < 2 || group.members.length >= (view.variant === 'unlimited' ? 6 : 3)) ? [] : view.players
|
||||
.filter(player => !occupied.has(player.id) &&
|
||||
(player.id === view.me || group.members.includes(view.me)))
|
||||
@@ -40,7 +43,7 @@ export function coalitionNexusOptions(view: CoalitionNexusView): {
|
||||
return {
|
||||
invitations,
|
||||
mayLeave: seated && !!ownGroup && !view.proposal,
|
||||
mayAccept: mayDecline && !view.proposal!.approvals.includes(view.me),
|
||||
mayAccept: mayDecline && !view.admissionBlocked && !view.proposal!.approvals.includes(view.me),
|
||||
mayDecline,
|
||||
};
|
||||
}
|
||||
@@ -66,6 +69,7 @@ export function CoalitionNexus({ view, disabled = false, onIntent }: {
|
||||
{view.groups.length ? <ul>{view.groups.map(group =>
|
||||
<li key={group.id}>{names(group.members)}</li>)}</ul> : <p>There are no alliances yet.</p>}
|
||||
{proposal ? <div aria-live="polite">
|
||||
{view.admissionBlocked && <p>Acceptance is unavailable while a bribe commitment remains unresolved. You may decline this invitation.</p>}
|
||||
<p>{name(proposal.proposer)} proposes adding {name(proposal.entrant)} to {names(proposal.original.members)}.</p>
|
||||
<p>Approved: {names(proposal.approvals)}. Waiting for: {names(
|
||||
[...proposal.original.members, proposal.entrant].filter(id => !proposal.approvals.includes(id)),
|
||||
@@ -81,6 +85,8 @@ export function CoalitionNexus({ view, disabled = false, onIntent }: {
|
||||
proposal.approvals.includes(view.me) ? 'Withdraw approval and cancel invitation' :
|
||||
'Decline and cancel invitation'}</button>}
|
||||
</div> : <div>
|
||||
{view.admissionBlocked && <p>Invitations are unavailable while a bribe commitment remains unresolved.
|
||||
Finish the commitment or wait until its binding period ends.</p>}
|
||||
{options.invitations.length > 0 && <p>{view.variant === 'unlimited'
|
||||
? 'Invite an unallied player to an existing alliance, or ask to join one.'
|
||||
: 'Invite an unallied player to an existing pair, or ask to join a pair.'}</p>}
|
||||
|
||||
+15
-5
@@ -17,11 +17,21 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Staged group departures and unrelated commitments — 29 October. Prototyped / Partial.**
|
||||
Group members can leave while bribe answers remain binding; invitations stay
|
||||
blocked until those commitments resolve. Group funding remains available when
|
||||
only nonmembers have unfinished commitments, while member commitments still
|
||||
fence it. Whole-turn Nexus changes, live-bribe admission and member-commitment
|
||||
funding remain unfinished. Twelve related files pass 123 cases, including
|
||||
real staged group actions, bribe answers, donor payments and rendered controls;
|
||||
a separate simultaneous-guide case passes. Typecheck, lint and the production
|
||||
build pass. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-departures-and-unrelated-commitments--29-october-2026).
|
||||
|
||||
**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
|
||||
questions reject every current group ally. Group admissions with live bribes and
|
||||
changes with 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
|
||||
@@ -35,7 +45,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 bribe and whole-turn commitments, other profiles,
|
||||
typecheck, lint and the production build pass. Live-bribe admissions and whole-turn commitments, other profiles,
|
||||
full interactions and lobby entry remain unfinished.
|
||||
[Scope](OPTIONAL_ALLIANCE_VICTORY.md#unlimited-alliance-prototype--29-october-2026).
|
||||
|
||||
@@ -89,8 +99,8 @@ real group formation/transport actions.
|
||||
|
||||
**Staged group actions after Truthtrance history — 29 October. Prototyped / Partial.**
|
||||
Settled commitment records no longer permanently disable group Nexus and
|
||||
funding. Unresolved records remain blocked until reconciliation, and active
|
||||
bribe-expression and revival variants are also fenced. Group play is still
|
||||
funding. The later scope above allows certain actions with unresolved outsider
|
||||
or bribe records; member-funded obligations remain fenced. Group play is still
|
||||
unavailable from the lobby. Five related files pass 60 cases; typecheck, lint
|
||||
and the production build pass. The new cases stage commitment histories and exercise real group
|
||||
actions, rather than a full Truthtrance journey. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-actions-after-truthtrance-history--29-october-2026).
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
# Optional three-stronghold alliance victory
|
||||
|
||||
## Group departures and unrelated commitments — 29 October 2026
|
||||
|
||||
**Staged Basic prototype, not a selectable game mode.** An alliance member
|
||||
may leave during a Nexus while ordinary bribe Truthtrance answers remain live.
|
||||
The original payment guide and answer remain binding, and existing
|
||||
reconciliation still checks the acting player's commitments. New group
|
||||
invitations and acceptance remain unavailable while any bribe commitment is
|
||||
unresolved; a pending invitation can still be declined. The table explains
|
||||
this limit and bots avoid blocked admissions.
|
||||
|
||||
Group bid and physical-shipment funding now ignores unfinished commitments
|
||||
belonging solely to nonmembers, because those payments do not debit outsiders.
|
||||
An unfinished supported commitment belonging to any group member still fences
|
||||
the entire funding path, including donor pledges. Whole-turn commitments still
|
||||
block group Nexus changes. Admission with live bribes and funding when a member
|
||||
has a commitment need further payment and consent preflight; broader profiles
|
||||
and lobby entry remain unfinished.
|
||||
|
||||
## Group Truthtrance and larger worm decisions — 29 October 2026
|
||||
|
||||
**Staged Basic prototype, not a selectable game mode.** Final-Nexus
|
||||
@@ -9,7 +27,7 @@ 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
|
||||
leaves, group admissions with live bribes or changes with whole-turn commitments, and wider
|
||||
combinations remain unfinished.
|
||||
|
||||
Action-level worm tests additionally exercise three and four threatened group
|
||||
@@ -38,7 +56,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 bribe and whole-turn commitments and
|
||||
Advanced, expansions, optional modules, some 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.
|
||||
|
||||
@@ -46,7 +64,7 @@ the prototype does not certify or open a complete rules mode.
|
||||
|
||||
**Staged Basic prototype, not a selectable game mode.** The public roster
|
||||
names both members of a player's group outside the Nexus. A Guild group ally
|
||||
can use its self-funded native transport even when an unrelated unresolved
|
||||
can use its self-funded native transport even when an unresolved group-member
|
||||
Truthtrance commitment hides group-funding controls. The original permission
|
||||
and native rate cancellation windows remain in force; group-funded transport
|
||||
continues to use its separate commitment restrictions. Applying the pair
|
||||
@@ -57,11 +75,11 @@ entry remain unfinished.
|
||||
|
||||
**Staged Basic prototype, not a selectable game mode.** Fulfilled and released
|
||||
Truthtrance records no longer disable later group Nexus negotiations or group
|
||||
funding. Unresolved records remain fenced until their native reconciliation
|
||||
closes them, including an unresolved older record; funding also fences live
|
||||
bribe-expression and leader/expression revival commitments. The existing
|
||||
committed-bid settlement exception is retained. Combining group actions with
|
||||
unresolved commitments remains unfinished, and no lobby exposes group play.
|
||||
funding. The later scope above allows departures with live bribe answers and
|
||||
funding with outsider-only commitments; group-member funding with unresolved
|
||||
records remains fenced until reconciliation. The existing committed-bid
|
||||
settlement exception is retained. Other combinations remain unfinished, and no
|
||||
lobby exposes group play.
|
||||
|
||||
## Group funding with Karama shipment pricing — 29 October 2026
|
||||
|
||||
@@ -212,8 +230,8 @@ 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 bribe or whole-turn
|
||||
commitments and pledged aid. No lobby creates the group ledger: faction ally
|
||||
expansion decks/factions, Advanced, optional modules, live bribe admission 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
|
||||
prototype above extends this staged groundwork.
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# Rule decision index
|
||||
|
||||
## Group departures and unrelated commitments — 29 October 2026
|
||||
|
||||
Staged Basic group departure remains available with live bribe answers;
|
||||
invitations remain fenced. Funding can proceed when only an outsider has an
|
||||
unfinished commitment, while any group member's unfinished commitment still
|
||||
blocks it. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-departures-and-unrelated-commitments--29-october-2026).
|
||||
|
||||
## Group Truthtrance and larger worm decisions — 29 October 2026
|
||||
|
||||
Staged Basic group alliance answers use actual group membership, with a
|
||||
@@ -52,8 +59,8 @@ funding gate. The pair privilege's extension to two allies is provisional.
|
||||
## Group actions after Truthtrance history — 29 October 2026
|
||||
|
||||
Settled commitment records no longer prevent staged group negotiation and
|
||||
funding. Unresolved records continue to require native reconciliation before
|
||||
these unsupported combinations can proceed. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-actions-after-truthtrance-history--29-october-2026).
|
||||
funding. The later scope above permits selected actions with unresolved records;
|
||||
other combinations still require native reconciliation. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-actions-after-truthtrance-history--29-october-2026).
|
||||
|
||||
## Group funding with Karama shipment pricing — 29 October 2026
|
||||
|
||||
|
||||
+2
-2
@@ -3189,7 +3189,7 @@ function policyActions(g: GameView): Action[] {
|
||||
const participants = [...proposal.original.members, proposal.entrant];
|
||||
return participants.includes(me.id) && !proposal.approvals.includes(me.id)
|
||||
? [{type: 'coalitionAnswer', event: proposal.event, nexus: proposal.nexus,
|
||||
turn: proposal.turn, accept: true, expectedRevision: group.revision}]
|
||||
turn: proposal.turn, accept: !group.admissionBlocked, expectedRevision: group.revision}]
|
||||
: [];
|
||||
}
|
||||
const thumper = me.hand?.find((c) => c.effect === 'thumper');
|
||||
@@ -3226,7 +3226,7 @@ function policyActions(g: GameView): Action[] {
|
||||
const card = me.hand?.find((c) => c.effect === 'harvester');
|
||||
if (card) return [{ type: 'card', card: card.id }];
|
||||
}
|
||||
if (g.nexus && !g.spiceWindow && !g.ready.includes(me.id) && group?.variant === 'unlimited' &&
|
||||
if (g.nexus && !g.spiceWindow && !g.ready.includes(me.id) && group?.variant === 'unlimited' && !group.admissionBlocked &&
|
||||
!group.groups.some(alliance => alliance.members.includes(me.id))) {
|
||||
const alliance = group.groups.find(candidate => candidate.members.length >= 2 && candidate.members.length < 6);
|
||||
if (alliance) return [{type:'coalitionPropose', groupId:alliance.id, entrant:me.id,
|
||||
|
||||
+16
-7
@@ -16036,6 +16036,9 @@ function allianceQuestionWindow(g: Game): boolean {
|
||||
function unsettledPromises(records: readonly {fulfilled?: boolean; released?: boolean}[] | undefined): boolean {
|
||||
return !!records?.some(record => !record.fulfilled && !record.released);
|
||||
}
|
||||
function coalitionAdmissionBlocked(g: Game): boolean {
|
||||
return [g.bribePromises, g.bribeExpressionPromises].some(unsettledPromises);
|
||||
}
|
||||
/** Group negotiation is deliberately unexposed until its other alliance
|
||||
* mechanics are integrated. This narrow profile keeps existing pair games
|
||||
* and unrelated commitments on their original paths. */
|
||||
@@ -16049,7 +16052,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.bribePromises, g.bribeExpressionPromises, g.turnActionPromises].some(unsettledPromises);
|
||||
!unsettledPromises(g.turnActionPromises);
|
||||
}
|
||||
function coalitionRule<T>(work: () => T): T {
|
||||
try { return work(); }
|
||||
@@ -16106,12 +16109,14 @@ function coalitionFundingAvailable(g: Game, p: Player, committedBid = false): bo
|
||||
if (!g.coalitionEscrow || !g.coalitionState || g.status !== 'playing' || g.advanced || ![3, 5].includes(g.phase) ||
|
||||
g.expansions.length || g.homeworlds || g.nexusCards || g.leaderSkills || g.techTokens ||
|
||||
g.strongholdCards || g.ecazTreachery || g.discoveryEnabled ||
|
||||
g.spiceBankerIncomePreview || g.kullPreview || g.richeseAuction || g.ixAuction ||
|
||||
(!committedBid && [g.bidPromises, g.phaseBidPromises, g.turnActionPromises,
|
||||
g.revivalPromises, g.leaderRevivalPromises, g.revivalExpressionPromises,
|
||||
g.bribePromises, g.bribeExpressionPromises].some(unsettledPromises))) return false;
|
||||
g.spiceBankerIncomePreview || g.kullPreview || g.richeseAuction || g.ixAuction) return false;
|
||||
const group = coalitionRule(() => coalitionOf(g.players, g.coalitionState, p.id));
|
||||
return !!group && group.members.length >= 3;
|
||||
if (!group || group.members.length < 3) return false;
|
||||
const groupHasUnsettled = (records: readonly {player: string; fulfilled?: boolean; released?: boolean}[] | undefined) =>
|
||||
!!records?.some(record => group.members.includes(record.player) && !record.fulfilled && !record.released);
|
||||
return committedBid || ![g.bidPromises, g.phaseBidPromises, g.turnActionPromises,
|
||||
g.revivalPromises, g.leaderRevivalPromises, g.revivalExpressionPromises,
|
||||
g.bribePromises, g.bribeExpressionPromises].some(groupHasUnsettled);
|
||||
}
|
||||
function coalitionEmperorGroup(g: Game, p: Player) {
|
||||
if (!g.coalitionState || p.faction !== 'emperor' || g.status !== 'playing' || g.advanced ||
|
||||
@@ -16200,7 +16205,7 @@ function alliancePromisePlan(g: Game, p: Player, promises = g.alliancePromises ?
|
||||
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))
|
||||
if (g.coalitionState!.proposal || unsettledPromises(g.turnActionPromises))
|
||||
return null;
|
||||
const trial = structuredClone(g);
|
||||
try {
|
||||
@@ -44560,6 +44565,9 @@ function applyActionInner(
|
||||
}
|
||||
if (t === 'coalitionPropose' || t === 'coalitionAnswer' || t === 'coalitionLeave') {
|
||||
requireRule(coalitionNexusAvailable(g), 'Group negotiations are unavailable in this Nexus.');
|
||||
if (t === 'coalitionPropose' || t === 'coalitionAnswer' && action.accept !== false)
|
||||
requireRule(!coalitionAdmissionBlocked(g),
|
||||
'Resolve current bribe commitments before inviting an ally.');
|
||||
const revision = integer(action.expectedRevision, 0, Number.MAX_SAFE_INTEGER, 'Coalition revision');
|
||||
if (t === 'coalitionPropose') {
|
||||
initializeCoalitionEscrow(g);
|
||||
@@ -46864,6 +46872,7 @@ export function viewGame(state: Game, id: string, now?: number) {
|
||||
groups: coalitionRule(() => coalitionGroups(g.players, g.coalitionState)),
|
||||
proposal: g.coalitionState!.proposal,
|
||||
revision: g.coalitionState!.revision,
|
||||
...(coalitionAdmissionBlocked(g) ? {admissionBlocked:true} : {}),
|
||||
...(g.coalitionState!.variant ? {variant: g.coalitionState!.variant} : {}),
|
||||
} : null,
|
||||
homeworldAllianceBlocks: Object.fromEntries(g.players.filter((p) => p.id !== id)
|
||||
|
||||
+1
-1
@@ -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 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'] },
|
||||
{ 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 with outsider-only commitments, group departures with live bribes, 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-bribe-departure.test.tsx', 'tests/coalition-bribe-alliance-guides.test.ts', 'tests/coalition-outsider-commitment-funding.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',
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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';
|
||||
|
||||
const send = (game:Game, actor:string, action:Action) => applyAction(game, actor, action);
|
||||
const seat = (game:Game, id:string) => game.players.find(player => player.id === id)!;
|
||||
|
||||
/** Stage the Nexus, respondent's wallet and the two physical Truthtrance
|
||||
* cards. Alliance formation, both questions and all guided actions are real. */
|
||||
function initial(): {game:Game; cards:string[]} {
|
||||
let game = createGame('GROUPBOTH', 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,ready:[],
|
||||
order:['a','b','c','d','e'],deck:baseDeck()});
|
||||
game.coalitionState = emptyCoalitionState('unlimited'); seat(game,'a').spice = 8;
|
||||
game = send(game,'a',{type:'alliance',target:'b'});
|
||||
game = send(game,'b',{type:'alliance',target:'a'});
|
||||
const pair = coalitionOf(game.players,game.coalitionState,'a'); assert.ok(pair);
|
||||
game = send(game,'a',{type:'coalitionPropose',groupId:pair.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 cards:string[] = [];
|
||||
for (let count=0;count<2;count++) {
|
||||
const index = game.deck.findIndex(card => card.effect === 'truthtrance'); assert.ok(index >= 0);
|
||||
const [card] = game.deck.splice(index,1); cards.push(card.id); seat(game,'e').hand.push(card);
|
||||
}
|
||||
return {game,cards};
|
||||
}
|
||||
function ask(game:Game, card:string, question:unknown, answer:'yes'|'no'):Game {
|
||||
game = send(game,'e',{type:'card',card});
|
||||
while(game.truthtrance?.stage === 'priority') {
|
||||
const next = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(next);
|
||||
game = send(game,next.id,{type:'truthPass'});
|
||||
}
|
||||
game = send(game,'e',{type:'truthAsk',question});
|
||||
const view = viewGame(game,'a');
|
||||
const answers = game.truthtrance?.question?.kind === 'bribe' ? view.truthBribeAnswers : view.truthAllianceAnswers;
|
||||
assert.ok(answers?.includes(answer), `The ${answer} answer must be legal with both commitments.`);
|
||||
return send(game,'a',{type:'truthAnswer',answer});
|
||||
}
|
||||
|
||||
void test('a group alliance No and positive bribe answer retain both legal guides',()=>{
|
||||
const fixture = initial();
|
||||
let game = ask(fixture.game,fixture.cards[0],
|
||||
{kind:'bribe',target:'a',turn:fixture.game.turn,recipient:'d',minimum:2},'yes');
|
||||
game = ask(game,fixture.cards[1],
|
||||
{kind:'alliance',target:'a',turn:game.turn,partner:'b'},'no');
|
||||
const bribe = game.bribePromises!.find(row=>row.player === 'a')!;
|
||||
const alliance = game.alliancePromises!.find(row=>row.player === 'a')!;
|
||||
assert.ok(!bribe.released && !bribe.fulfilled && !alliance.released && !alliance.fulfilled);
|
||||
const beforeReady = structuredClone(game);
|
||||
assert.throws(()=>send(game,'a',{type:'ready'}));
|
||||
assert.deepEqual(game,beforeReady);
|
||||
const guide = viewGame(game,'a').alliancePromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type,'coalitionLeave');
|
||||
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']);
|
||||
assert.ok(!game.alliancePromises!.find(row=>row.event === alliance.event)!.released);
|
||||
assert.ok(!game.bribePromises!.find(row=>row.event === bribe.event)!.released);
|
||||
const payment = viewGame(game,'a').bribePromiseGuide; assert.ok(payment);
|
||||
assert.equal(payment.action.type,'bribe'); assert.equal(payment.action.target,'d');
|
||||
game = send(game,'a',payment.action);
|
||||
assert.equal(game.bribePromises!.find(row=>row.event === bribe.event)!.fulfilled,true);
|
||||
assert.equal(seat(game,'d').bribes,2);
|
||||
game = send(game,'a',{type:'ready'});
|
||||
assert.ok(game.ready.includes('a'));
|
||||
for(const card of fixture.cards) assert.equal(game.discard.filter(row=>row.id === card).length,1);
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
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 { CoalitionNexus, coalitionNexusOptions } from '../components/coalition-nexus';
|
||||
|
||||
const send = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
|
||||
const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!;
|
||||
|
||||
/** The quiet Nexus, wallet and physical Truthtrance are staged. Pair/group
|
||||
* formation, question, answer, departure and payment are actual actions. */
|
||||
function group(): Game {
|
||||
let game = createGame('GROUPBRIBE', 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');
|
||||
seat(game, 'a').spice = 8;
|
||||
game = send(game, 'a', {type:'alliance', target:'b'});
|
||||
game = send(game, 'b', {type:'alliance', target:'a'});
|
||||
const pair = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(pair);
|
||||
game = send(game, 'a', {type:'coalitionPropose', groupId:pair.id, entrant:'c',
|
||||
expectedRevision:game.coalitionState!.revision});
|
||||
for (const id of ['b','c']) {
|
||||
const offer = game.coalitionState!.proposal!;
|
||||
game = send(game, id, {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); seat(game, 'e').hand.push(card);
|
||||
return game;
|
||||
}
|
||||
function ask(answer: 'yes' | 'no', expression = false): Game {
|
||||
let game = group();
|
||||
const card = seat(game, 'e').hand.find(row => row.effect === 'truthtrance')!;
|
||||
game = send(game, 'e', {type:'card', card:card.id});
|
||||
while (game.truthtrance?.stage === 'priority') {
|
||||
const passer = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(passer);
|
||||
game = send(game, passer.id, {type:'truthPass'});
|
||||
}
|
||||
const question = expression
|
||||
? {kind:'bribeExpression', target:'a', turn:game.turn, claim:{kind:'and', terms:[
|
||||
{kind:'payment', recipient:'d', minimum:2}, {kind:'payment', recipient:'e', minimum:1}]}}
|
||||
: {kind:'bribe', target:'a', turn:game.turn, recipient:'d', minimum:2};
|
||||
game = send(game, 'e', {type:'truthAsk', question});
|
||||
const view = viewGame(game, 'a');
|
||||
const answers = expression ? view.truthBribeExpressionAnswers : view.truthBribeAnswers;
|
||||
assert.ok(answers?.includes(answer));
|
||||
return send(game, 'a', {type:'truthAnswer', answer});
|
||||
}
|
||||
function leave(game:Game, actor:string):Game {
|
||||
return send(game, actor, {type:'coalitionLeave', expectedRevision:game.coalitionState!.revision});
|
||||
}
|
||||
function blockedProposal(game:Game) {
|
||||
const alliance = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(alliance);
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => send(game, 'a', {type:'coalitionPropose', groupId:alliance.id,
|
||||
entrant:'d', expectedRevision:game.coalitionState!.revision}));
|
||||
assert.deepEqual(game, before);
|
||||
}
|
||||
|
||||
void test('a positive bribe answer allows owned group departure and remains payable', () => {
|
||||
let game = ask('yes');
|
||||
const projected = viewGame(game, 'a').coalitionNexus; assert.ok(projected);
|
||||
const options = coalitionNexusOptions(projected);
|
||||
assert.equal(options.mayLeave, true); assert.equal(options.invitations.length, 0);
|
||||
const markup = renderToStaticMarkup(createElement(CoalitionNexus, {view:projected, onIntent:()=>{}}));
|
||||
assert.match(markup, /Leave/); assert.doesNotMatch(markup, /Invite .* to /);
|
||||
blockedProposal(game);
|
||||
game = leave(game, 'a');
|
||||
assert.equal(coalitionOf(game.players, game.coalitionState, 'a'), null);
|
||||
assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'b')!.members, ['b','c']);
|
||||
const promise = game.bribePromises!.find(row => row.player === 'a')!;
|
||||
assert.equal(promise.released ?? false, false);
|
||||
const guide = viewGame(game, 'a').bribePromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'bribe'); assert.equal(guide.action.target, 'd');
|
||||
game = send(game, 'a', guide.action);
|
||||
assert.equal(game.bribePromises!.find(row => row.event === promise.event)!.fulfilled, true);
|
||||
assert.equal(seat(game, 'd').bribes, 2);
|
||||
});
|
||||
|
||||
void test('a negative bribe answer survives another member departing and retains its cap', () => {
|
||||
let game = ask('no');
|
||||
blockedProposal(game);
|
||||
game = leave(game, 'b');
|
||||
assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'a')!.members, ['a','c']);
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => send(game, 'a', {type:'bribe', target:'d', amount:2}));
|
||||
assert.deepEqual(game, before);
|
||||
game = send(game, 'a', {type:'bribe', target:'d', amount:1});
|
||||
assert.equal(seat(game, 'd').bribes, 1);
|
||||
assert.equal(game.bribePromises!.find(row => row.player === 'a')!.released ?? false, false);
|
||||
});
|
||||
|
||||
void test('a positive bribe expression remains binding and payable after departure', () => {
|
||||
let game = ask('yes', true);
|
||||
blockedProposal(game);
|
||||
game = leave(game, 'a');
|
||||
const promise = game.bribeExpressionPromises!.find(row => row.player === 'a')!;
|
||||
assert.equal(promise.released ?? false, false);
|
||||
for (let step = 0; step < 2; step++) {
|
||||
const guide = viewGame(game, 'a').bribePromiseGuide; assert.ok(guide);
|
||||
game = send(game, 'a', guide.action);
|
||||
}
|
||||
assert.equal(game.bribeExpressionPromises!.find(row => row.event === promise.event)!.fulfilled, true);
|
||||
assert.equal(seat(game, 'd').bribes, 2); assert.equal(seat(game, 'e').bribes, 1);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { CoalitionNexus, coalitionNexusOptions, type CoalitionNexusView } from '../components/coalition-nexus';
|
||||
import type { CoalitionGroup } from '../game/coalition-alliance';
|
||||
|
||||
const players = ['a', 'b', 'c', 'd'].map(id => ({id, name:id.toUpperCase()}));
|
||||
const group: CoalitionGroup = {id:'group', members:['a','b','c'], formedTurn:1, edges:[
|
||||
{members:['a','b'], formedTurn:1}, {members:['a','c'], formedTurn:1},
|
||||
{members:['b','c'], formedTurn:1},
|
||||
]};
|
||||
const view = (me:string, admissionBlocked = false): CoalitionNexusView => ({
|
||||
me, players, groups:[group], proposal:null, revision:3, variant:'unlimited', admissionBlocked,
|
||||
});
|
||||
const render = (value:CoalitionNexusView) => renderToStaticMarkup(createElement(CoalitionNexus, {
|
||||
view:value, onIntent() {},
|
||||
}));
|
||||
|
||||
void test('a live bribe commitment hides new invitations while retaining the owned leave control', () => {
|
||||
const member = view('a', true), choices = coalitionNexusOptions(member), html = render(member);
|
||||
assert.deepEqual(choices.invitations, []);
|
||||
assert.equal(choices.mayLeave, true);
|
||||
assert.match(html, /Invitations are unavailable while a bribe commitment remains unresolved/);
|
||||
assert.match(html, /Leave A, B, C/);
|
||||
assert.doesNotMatch(html, /Invite D to/);
|
||||
|
||||
const entrant = view('d', true);
|
||||
assert.deepEqual(coalitionNexusOptions(entrant).invitations, []);
|
||||
assert.equal(coalitionNexusOptions(entrant).mayLeave, false);
|
||||
assert.doesNotMatch(render(entrant), /Ask to join/);
|
||||
});
|
||||
|
||||
void test('invitations return after the block clears; a blocked pending offer can be declined', () => {
|
||||
const member = view('a');
|
||||
assert.deepEqual(coalitionNexusOptions(member).invitations.map(row => row.entrant), ['d']);
|
||||
assert.match(render(member), /Invite D to A, B, C/);
|
||||
assert.doesNotMatch(render(member), /bribe commitment remains unresolved/);
|
||||
|
||||
const pending = view('d', true);
|
||||
pending.proposal = {event:'event', nexus:'nexus', turn:2, groupId:group.id,
|
||||
original:group, entrant:'d', proposer:'a', approvals:['a']};
|
||||
assert.equal(coalitionNexusOptions(pending).mayAccept, false);
|
||||
assert.equal(coalitionNexusOptions(pending).mayDecline, true);
|
||||
assert.doesNotMatch(render(pending), /Accept invitation/);
|
||||
assert.match(render(pending), /may decline this invitation/);
|
||||
});
|
||||
@@ -88,7 +88,7 @@ void test('an unresolved prior-turn Truthtrance record still blocks group Nexus'
|
||||
const game = originalPair();
|
||||
game.bribePromises = [{event:'unreconciled-bribe', player:'d', asker:'a', turn:game.turn - 1,
|
||||
recipient:'c', minimum:1, answer:true, progress:0}];
|
||||
assert.equal(viewGame(game, 'a').coalitionNexus, null);
|
||||
assert.equal(viewGame(game, 'a').coalitionNexus?.admissionBlocked, true);
|
||||
const pair = coalitionOf(game.players, game.coalitionState, 'a')!;
|
||||
rejected(game, 'a', {type:'coalitionPropose', groupId:pair.id, entrant:'c',
|
||||
expectedRevision:game.coalitionState!.revision});
|
||||
@@ -98,7 +98,7 @@ void test('a current unanswered group-incompatible Truthtrance commitment still
|
||||
const game = originalPair();
|
||||
game.bribePromises = [{event:'live-bribe', player:'d', asker:'a', turn:game.turn,
|
||||
recipient:'c', minimum:1, answer:true, progress:0}];
|
||||
assert.equal(viewGame(game, 'a').coalitionNexus, null);
|
||||
assert.equal(viewGame(game, 'a').coalitionNexus?.admissionBlocked, true);
|
||||
const pair = coalitionOf(game.players, game.coalitionState, 'a')!;
|
||||
rejected(game, 'a', {type:'coalitionPropose', groupId:pair.id, entrant:'c',
|
||||
expectedRevision:game.coalitionState!.revision});
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
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';
|
||||
|
||||
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)!;
|
||||
|
||||
/** The Basic Nexus/ledger, phase boundary and wallets are staged; the pair,
|
||||
* admission, held physical Truthtrance and every question/payment are real. */
|
||||
function fixture(phase: 'bidding' | 'movement'):Game {
|
||||
let game=createGame('OUTSIDERFUND',newPlayer('a','Atreides','atreides'),false);
|
||||
game.players.push(newPlayer('b','Emperor','emperor'),newPlayer('c','Fremen','fremen'),
|
||||
newPlayer('d','Guild','guild'),newPlayer('e','Harkonnen','harkonnen'));
|
||||
Object.assign(game,{status:'playing',phase:1,turn:2,nexus:true,order:['a','b','c','d','e'],
|
||||
ready:[],storm:18,deck:baseDeck(),discard:[],response:null,decision:null,phaseOpening:null});
|
||||
game.coalitionState=emptyCoalitionState('unlimited');
|
||||
game=act(game,'a',{type:'alliance',target:'b'});
|
||||
game=act(game,'b',{type:'alliance',target:'a'});
|
||||
const group=coalitionOf(game.players,game.coalitionState,'a'); assert.ok(group);
|
||||
game=act(game,'a',{type:'coalitionPropose',groupId:group.id,entrant:'c',
|
||||
expectedRevision:game.coalitionState!.revision});
|
||||
for(const id of ['b','c']) {
|
||||
const offer=game.coalitionState!.proposal!;
|
||||
game=act(game,id,{type:'coalitionAnswer',event:offer.event,nexus:offer.nexus,
|
||||
turn:offer.turn,accept:true,expectedRevision:game.coalitionState!.revision});
|
||||
}
|
||||
for(const player of game.players) {
|
||||
player.spice=10; player.hand=[]; player.forces={}; player.reserves=20; player.tanks=0;
|
||||
player.traitors=[]; player.traitorChoices=[]; player.shipped=false; player.moved=0;
|
||||
}
|
||||
seat(game,'a').spice=2;
|
||||
if(phase==='bidding') {
|
||||
Object.assign(game,{phase:2,nexus:false,active:null,ready:[]});
|
||||
for(const player of game.players) game=act(game,player.id,{type:'ready'});
|
||||
assert.equal(game.phase,3); assert.equal(game.auction?.active,'a');
|
||||
} else Object.assign(game,{phase:5,nexus:false,active:'a',ready:[],
|
||||
movementRemaining:['a','b','c','d','e'],guildTimingGranted:false,guildTimingLocked:false});
|
||||
const index=game.deck.findIndex(card=>card.effect==='truthtrance'); assert.ok(index>=0);
|
||||
seat(game,'a').hand.push(game.deck.splice(index,1)[0]);
|
||||
return game;
|
||||
}
|
||||
function ask(game:Game,question:Record<string,unknown>):Game {
|
||||
const card=seat(game,'a').hand.find(row=>row.effect==='truthtrance'); assert.ok(card);
|
||||
game=act(game,'a',{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=act(game,actor.id,{type:'truthPass'});
|
||||
}
|
||||
return act(game,'a',{type:'truthAsk',question});
|
||||
}
|
||||
function pledge(game:Game):Game {
|
||||
game=act(game,'b',{type:'coalitionPledge',recipient:'a',amount:4});
|
||||
return act(game,'c',{type:'coalitionPledge',recipient:'a',amount:3});
|
||||
}
|
||||
function passResponses(game:Game):Game {
|
||||
for(let count=0;game.response && count<30;count++) {
|
||||
const actor=game.players.find(player=>!game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game=act(game,actor.id,{type:'passResponse'});
|
||||
}
|
||||
assert.equal(game.response ?? null,null); return game;
|
||||
}
|
||||
const shipment:Action={type:'ship',territory:'imperial_basin',sector:9,amount:3,elite:0,allyPayment:0,
|
||||
coalitionContributions:[{donor:'b',amount:3},{donor:'c',amount:2}]};
|
||||
|
||||
void test('an unrelated outsider bribe No leaves group shipment funding available',()=>{
|
||||
let game=fixture('movement');
|
||||
game=ask(game,{kind:'bribe',target:'d',turn:game.turn,recipient:'e',minimum:1});
|
||||
assert.ok(viewGame(game,'d').truthBribeAnswers?.includes('no'));
|
||||
game=act(game,'d',{type:'truthAnswer',answer:'no'});
|
||||
const promise=game.bribePromises!.find(row=>row.player==='d')!;
|
||||
assert.equal(promise.fulfilled ?? false,false);
|
||||
game=pledge(game);
|
||||
assert.equal(viewGame(game,'a').coalitionFunding?.incoming.length,2);
|
||||
game=passResponses(act(game,'a',shipment));
|
||||
assert.equal(seat(game,'a').forces['imperial_basin:9'],3);
|
||||
assert.equal(seat(game,'d').spice,16);
|
||||
assert.ok(game.bribePromises?.some(row=>row.event===promise.event && !row.fulfilled && !row.released));
|
||||
});
|
||||
|
||||
void test('an unrelated outsider whole-turn payment No leaves group shipment funding available',()=>{
|
||||
let game=fixture('movement');
|
||||
game=ask(game,{kind:'turnActions',target:'d',turn:game.turn,
|
||||
mixed:{kind:'action',action:{kind:'payment',recipient:'e',minimum:1}}});
|
||||
assert.ok(viewGame(game,'d').truthTurnActionAnswers?.includes('no'));
|
||||
game=act(game,'d',{type:'truthAnswer',answer:'no'});
|
||||
const promise=game.turnActionPromises!.find(row=>row.player==='d')!;
|
||||
game=pledge(game); game=passResponses(act(game,'a',shipment));
|
||||
assert.equal(seat(game,'a').forces['imperial_basin:9'],3);
|
||||
assert.ok(game.turnActionPromises?.some(row=>row.event===promise.event && !row.fulfilled && !row.released));
|
||||
});
|
||||
|
||||
void test('an unrelated outsider phase-bid No leaves group bid funding available',()=>{
|
||||
let game=fixture('bidding');
|
||||
game=ask(game,{kind:'phaseBid',target:'d',turn:game.turn,minimum:10});
|
||||
assert.ok(viewGame(game,'d').truthPhaseBidAnswers?.includes('no'));
|
||||
game=act(game,'d',{type:'truthAnswer',answer:'no'});
|
||||
const promise=game.phaseBidPromises!.find(row=>row.player==='d')!;
|
||||
game=pledge(game);
|
||||
game=act(game,'a',{type:'bid',amount:6,allyPayment:0,
|
||||
coalitionContributions:[{donor:'b',amount:3},{donor:'c',amount:2}]});
|
||||
assert.equal(game.auction?.bidder,'a');
|
||||
assert.ok(game.phaseBidPromises?.some(row=>row.event===promise.event && !row.fulfilled && !row.released));
|
||||
});
|
||||
|
||||
void test('a group member commitment still fences the entire group funding path',()=>{
|
||||
let game=fixture('movement');
|
||||
game=ask(game,{kind:'bribe',target:'b',turn:game.turn,recipient:'e',minimum:1});
|
||||
assert.ok(viewGame(game,'b').truthBribeAnswers?.includes('no'));
|
||||
game=act(game,'b',{type:'truthAnswer',answer:'no'});
|
||||
assert.equal(viewGame(game,'a').coalitionFunding,null);
|
||||
const before=structuredClone(game);
|
||||
assert.throws(()=>act(game,'c',{type:'coalitionPledge',recipient:'a',amount:1}));
|
||||
assert.deepEqual(game,before);
|
||||
});
|
||||
@@ -121,8 +121,8 @@ void test('fulfilled and released Truthtrance records leave group shipment fundi
|
||||
|
||||
void test('an unreconciled prior-turn commitment blocks group shipment funding', () => {
|
||||
const game = fixture();
|
||||
game.bribePromises = [{event:'unreconciled-bribe', player:'d', asker:'a', turn:game.turn - 1,
|
||||
recipient:'c', minimum:1, answer:true, progress:0}];
|
||||
game.bribePromises = [{event:'unreconciled-bribe', player:'b', asker:'a', turn:game.turn - 1,
|
||||
recipient:'d', minimum:1, answer:true, progress:0}];
|
||||
assert.equal(viewGame(game, 'a').coalitionFunding, null);
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => send(game, 'b', {type:'coalitionPledge', recipient:'a', amount:4}));
|
||||
@@ -131,8 +131,8 @@ void test('an unreconciled prior-turn commitment blocks group shipment funding',
|
||||
|
||||
void test('a live No bribe-expression commitment blocks group shipment funding', () => {
|
||||
const game = fixture();
|
||||
game.bribeExpressionPromises = [{event:'live-expression-no', player:'d', asker:'a',
|
||||
turn:game.turn, claim:{kind:'payment', recipient:'c', minimum:1}, answer:false, progress:[]}];
|
||||
game.bribeExpressionPromises = [{event:'live-expression-no', player:'b', asker:'a',
|
||||
turn:game.turn, claim:{kind:'payment', recipient:'d', minimum:1}, answer:false, progress:[]}];
|
||||
assert.equal(viewGame(game, 'a').coalitionFunding, null);
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => send(game, 'b', {type:'coalitionPledge', recipient:'a', amount:4}));
|
||||
@@ -141,8 +141,8 @@ void test('a live No bribe-expression commitment blocks group shipment funding',
|
||||
|
||||
void test('a current unsupported Truthtrance commitment still blocks group shipment funding', () => {
|
||||
const game = fixture();
|
||||
game.bribePromises = [{event:'live-bribe', player:'d', asker:'a', turn:game.turn,
|
||||
recipient:'c', minimum:1, answer:true, progress:0}];
|
||||
game.bribePromises = [{event:'live-bribe', player:'b', asker:'a', turn:game.turn,
|
||||
recipient:'d', minimum:1, answer:true, progress:0}];
|
||||
assert.equal(viewGame(game, 'a').coalitionFunding, null);
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => send(game, 'b', {type:'coalitionPledge', recipient:'a', amount:4}));
|
||||
|
||||
Reference in new issue
Block a user