Allow group admission with compatible commitments
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 00:54:36 +11:00
1 parent 0faa7923b0
commit 6524ed3bf2
14 files changed
+679 -53

No files matched your search

+11 -2
View File
@@ -15,6 +15,10 @@ export type CoalitionNexusView = {
/** Until group admission can reconcile a live bribe commitment, only
* departure remains available in this Nexus. */
admissionBlocked?: boolean;
/** Invitation choices that this actor cannot make while commitments apply. */
blockedInvitations?: readonly { groupId: string; entrant: string }[];
/** Whether this actor is unable to accept the pending invitation. */
acceptBlocked?: boolean;
};
export type CoalitionNexusIntent =
@@ -35,7 +39,9 @@ export function coalitionNexusOptions(view: CoalitionNexusView): {
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)))
(player.id === view.me || group.members.includes(view.me)) &&
!view.blockedInvitations?.some(blocked =>
blocked.groupId === group.id && blocked.entrant === player.id))
.map(player => ({ groupId: group.id, entrant: player.id, members: group.members })));
const participants = view.proposal
? [...view.proposal.original.members, view.proposal.entrant] : [];
@@ -43,7 +49,8 @@ export function coalitionNexusOptions(view: CoalitionNexusView): {
return {
invitations,
mayLeave: seated && !!ownGroup && !view.proposal,
mayAccept: mayDecline && !view.admissionBlocked && !view.proposal!.approvals.includes(view.me),
mayAccept: mayDecline && !view.admissionBlocked && !view.acceptBlocked &&
!view.proposal!.approvals.includes(view.me),
mayDecline,
};
}
@@ -70,6 +77,7 @@ export function CoalitionNexus({ view, disabled = false, onIntent }: {
<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>}
{!view.admissionBlocked && view.acceptBlocked && <p>Acceptance is unavailable while it would conflict with your commitments. 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)),
@@ -87,6 +95,7 @@ export function CoalitionNexus({ view, disabled = false, onIntent }: {
</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>}
{!view.admissionBlocked && !!view.blockedInvitations?.length && <p>Some invitations are unavailable while they would conflict with your commitments.</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>}
+18 -6
View File
@@ -17,11 +17,23 @@ open mode gates.
## Current checkpoint and work
**Staged group admission with live commitments — 29 October. Prototyped / Partial.**
Each proposer and accepting member is checked against their own bribe and
alliance answers; unrelated participants can still negotiate and bots decline
blocked consent. Pending invitations preserve alliance answers. Admission is
fenced when the older whole-turn planner could misread combined alliance and
payment/travel answers as a pair-only state. Fourteen related files pass 82
cases, and the joint-planner regression passes separately. Typecheck, lint and
the production build pass. Group funding with
member commitments, whole-turn Nexus changes, other profiles and lobby entry
remain unfinished. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-admission-with-live-commitments--29-october-2026).
**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
Group members can leave while bribe answers remain binding; invitations were
initially blocked until those commitments resolved (see the later prototype
above). 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
fence it. Whole-turn Nexus changes 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
@@ -30,8 +42,8 @@ build pass. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-departures-and-unrelated-
**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 admissions with live bribes and
changes with whole-turn commitments remain fenced. Larger worm response sequences have
questions reject every current group ally. 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
@@ -45,7 +57,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 admissions and whole-turn commitments, other profiles,
typecheck, lint and the production build pass. Whole-turn commitments, other profiles,
full interactions and lobby entry remain unfinished.
[Scope](OPTIONAL_ALLIANCE_VICTORY.md#unlimited-alliance-prototype--29-october-2026).
+29 -8
View File
@@ -1,21 +1,41 @@
# Optional three-stronghold alliance victory
## Group admission with live commitments — 29 October 2026
**Staged Basic prototype, not a selectable game mode.** A proposer or
accepting participant may consent only when their own unresolved bribe and
alliance answers remain possible after admission. A positive payment answer
can therefore prevent its respondent from joining the intended recipient;
an alternative branch of an expression can still make admission possible.
Negative answers and commitments belonging only to outsiders need not block
unrelated admission. The server checks each consenting participant; the table
shows only the current player's blocked invitations and acceptance. Bots
decline when their own acceptance is blocked. The pending invitation can be
declined, and alliance answers are not released merely because it temporarily
prevents departure. Unanimous consent remains a provisional online procedure.
Admission stays fenced for a participant with both an alliance answer and an
unresolved payment or travel-family answer when the older whole-turn planner
is present: that planner still assumes pairs. Group funding with a member's
commitment, whole-turn Nexus changes, wider profiles and lobby entry remain
unfinished. These focused staged cases do not certify complete group play or
expansion combinations.
## 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.
invitations and acceptance initially remained unavailable while any bribe
commitment was unresolved; the later admission prototype above narrows that fence.
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
block group Nexus changes. Funding when a member has a commitment needs
further payment and consent preflight; broader profiles
and lobby entry remain unfinished.
## Group Truthtrance and larger worm decisions — 29 October 2026
@@ -27,7 +47,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 admissions with live bribes or changes with whole-turn commitments, and wider
leaves, changes with whole-turn commitments, and wider
combinations remain unfinished.
Action-level worm tests additionally exercise three and four threatened group
@@ -230,8 +250,9 @@ 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 admission or
whole-turn commitments and pledged aid. No lobby creates the group ledger: faction ally
expansion decks/factions, Advanced, optional modules, whole-turn commitments
and pledged aid. Later sections above extend live-bribe admission and group
funding in staged profiles. 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.
+8 -1
View File
@@ -1,9 +1,16 @@
# Rule decision index
## Group admission with live commitments — 29 October 2026
Staged Basic group admission checks each proposer's or accepting participant's
own bribe and alliance answers against the proposed group. Consent, pending
answer handling and its online timing remain provisional.
[Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-admission-with-live-commitments--29-october-2026).
## 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
invitations were initially fenced (see the later admission prototype above). 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).
+13 -10
View File
@@ -1321,6 +1321,16 @@ function policyActions(g: GameView): Action[] {
? [{ type: 'decision', event: search.event, card: ordered[0].id }]
: [];
}
const pendingGroup = g.coalitionNexus;
if (pendingGroup?.proposal) {
const proposal = pendingGroup.proposal;
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: !pendingGroup.acceptBlocked,
expectedRevision: pendingGroup.revision}]
: [];
}
if (g.truthCharityGuide) return [g.truthCharityGuide.action];
if (g.bribePromiseGuide) return [g.bribePromiseGuide.action];
if (g.alliancePromiseGuide) return [g.alliancePromiseGuide.action];
@@ -3184,14 +3194,6 @@ function policyActions(g: GameView): Action[] {
}
if (g.phase === 1) {
const group = g.coalitionNexus;
if (group?.proposal) {
const proposal = group.proposal;
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: !group.admissionBlocked, expectedRevision: group.revision}]
: [];
}
const thumper = me.hand?.find((c) => c.effect === 'thumper');
const prior = g.spiceDiscardTop[0];
if (
@@ -3226,9 +3228,10 @@ 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' && !group.admissionBlocked &&
if (g.nexus && !g.spiceWindow && !g.ready.includes(me.id) && group?.variant === 'unlimited' &&
!group.groups.some(alliance => alliance.members.includes(me.id))) {
const alliance = group.groups.find(candidate => candidate.members.length >= 2 && candidate.members.length < 6);
const alliance = group.groups.find(candidate => candidate.members.length >= 2 && candidate.members.length < 6 &&
!group.blockedInvitations?.some(blocked => blocked.groupId === candidate.id && blocked.entrant === me.id));
if (alliance) return [{type:'coalitionPropose', groupId:alliance.id, entrant:me.id,
expectedRevision:group.revision}];
}
+80 -18
View File
@@ -16036,9 +16036,6 @@ 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. */
@@ -16058,6 +16055,54 @@ function coalitionRule<T>(work: () => T): T {
try { return work(); }
catch (error) { if (error instanceof CoalitionAllianceError || error instanceof CoalitionFundingError) throw new RuleError(error.message); throw error; }
}
/** Quote only this participant's owned commitments against the completed
* proposed group. Synthetic approvals and escrow epochs stay on the clone. */
function coalitionAdmissionAllowed(g: Game, actor: string, groupId: string, entrant: string): boolean {
const bribes = [...(g.bribePromises ?? []), ...(g.bribeExpressionPromises ?? [])];
const alliances = [...(g.alliancePromises ?? []), ...(g.allianceExpressionPromises ?? [])];
const unresolved = (row: {player:string; turn:number; fulfilled?:boolean; released?:boolean}) =>
row.player === actor && !row.fulfilled && !row.released;
if ([...bribes, ...alliances].some(row => unresolved(row) && row.turn !== g.turn)) return false;
// The joint turn-action reconciler still models alliances as pairs. Do not
// let it release another accepted commitment after this player joins a
// group until that planner can represent group membership and departure.
if (g.turnActionPromises && alliances.some(unresolved) &&
[g.bribePromises, g.bribeExpressionPromises, g.travelPromises, g.shipmentPromises,
g.movementPromises, g.movementExpressionPromises].some(rows => rows?.some(unresolved))) return false;
if (!bribes.some(row => unresolved(row) && row.answer) && !alliances.some(unresolved)) return true;
try {
const trial = structuredClone(g);
initializeCoalitionEscrow(trial);
if (!trial.coalitionState!.proposal) {
const result = coalitionRule(() => beginCoalitionAdmission(trial.players, trial.coalitionState, {
event:crypto.randomUUID(), nexus:crypto.randomUUID(), turn:trial.turn, groupId, entrant,
proposer:actor, expectedRevision:trial.coalitionState!.revision,
}));
applyCoalitionTransition(trial, result);
}
const original = trial.coalitionState!.proposal;
requireRule(original && original.groupId === groupId && original.entrant === entrant,
'The original group invitation changed.');
for (const voter of [...original.original.members, original.entrant]) {
const offer = trial.coalitionState!.proposal;
if (!offer || offer.approvals.includes(voter)) continue;
const result = coalitionRule(() => answerCoalitionAdmission(trial.players, trial.coalitionState, {
event:offer.event, nexus:offer.nexus, turn:offer.turn, actor:voter, accept:true,
expectedRevision:trial.coalitionState!.revision,
}));
applyCoalitionTransition(trial, result);
}
requireRule(!trial.coalitionState!.proposal, 'The proposed group did not finish.');
trial.truthtrance = null;
const player = getPlayer(trial, actor);
return (!hasBribePromises(trial, actor, true) || !!bribePromiseCompletion(trial, player)) &&
(!alliances.some(unresolved) || !!alliancePromisePlan(trial, player)) &&
(!publicDealCompletion(g, getPlayer(g, actor)) || publicDealCompletion(trial, player));
} catch (error) {
if (error instanceof RuleError) return false;
throw error;
}
}
/** Preserve legacy one-sided ally restrictions while recognizing every
* member of an explicitly staged larger alliance. */
function bribeAllied(g: Game, owner: Player, recipient: string): boolean {
@@ -16374,7 +16419,7 @@ function reconcileAlliancePromises(g: Game, voluntary?: { actor: string; action:
const ordered = orderedAllianceCommitments(g);
if (!ordered.some(promise => !promise.fulfilled && !promise.released)) return;
alliancePromiseIntegrity(g);
if (g.truthtrance || g.response || g.decision || g.phaseOpening || g.spiceWindow || g.spiceResolution ||
if (g.coalitionState?.proposal || g.truthtrance || g.response || g.decision || g.phaseOpening || g.spiceWindow || g.spiceResolution ||
g.pendingTreacheryDiscard || g.pendingKarama || g.pendingKull || g.pendingRicheseBetrayal) return;
const opportunity = allianceOpportunity(g), kept: AlliancePromise[] = [], expressions: AllianceExpressionPromise[] = [];
for (const promise of ordered) {
@@ -16431,7 +16476,7 @@ function projectedAlliancePromises(g: Game, player: string) {
}
function projectedAlliancePromiseGuide(g: Game, player: string): { action: Action } | null {
const opportunity = allianceOpportunity(g);
if (!opportunity || !allianceQuestionWindow(g) || g.truthtrance || g.ready.includes(player)) return null;
if (!opportunity || !allianceQuestionWindow(g) || g.coalitionState?.proposal || g.truthtrance || g.ready.includes(player)) return null;
const promise = orderedAllianceCommitments(g).find(record => record.player === player && record.turn === opportunity.turn &&
record.source === opportunity.source && !record.fulfilled && !record.released);
if (!promise) return null;
@@ -16745,7 +16790,7 @@ function ordinaryPaymentActionAvailable(g: Game, player: Player, action: Action)
action.amount > 0 && action.amount <= uncommittedSpice(g, player);
}
function projectedBribePromiseGuide(g: Game, player: string): { action: Action } | null {
if (!bribeQuestionWindow(g) || g.truthtrance) return null;
if (!bribeQuestionWindow(g) || g.coalitionState?.proposal || g.truthtrance) return null;
const joint = liveTurnActionPromises(g.turnActionPromises ?? [], player, g.turn).length
? turnActionCompletion(g, player) : null;
if (joint?.paymentsAfterPreparations && joint.cardPreparations?.length) return null;
@@ -44565,11 +44610,10 @@ 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') {
requireRule(coalitionAdmissionAllowed(g, id, stringField(action.groupId), stringField(action.entrant)),
'Honor your existing commitments before proposing this alliance.');
initializeCoalitionEscrow(g);
const result = coalitionRule(() => beginCoalitionAdmission(g.players, g.coalitionState, {
event: crypto.randomUUID(), nexus: crypto.randomUUID(), turn: g.turn,
@@ -44581,6 +44625,14 @@ function applyActionInner(
} else if (t === 'coalitionAnswer') {
requireRule(typeof action.accept === 'boolean', 'Accept or decline this group invitation.');
const offer = g.coalitionState!.proposal;
if (action.accept && offer) {
requireRule(coalitionAdmissionAllowed(g, id, offer.groupId, offer.entrant),
'Honor your existing commitments before accepting this alliance.');
if (offer.approvals.length + 1 === offer.original.members.length + 1)
for (const participant of offer.approvals)
requireRule(coalitionAdmissionAllowed(g, participant, offer.groupId, offer.entrant),
'An earlier approval no longer preserves its commitments.');
}
const result = coalitionRule(() => answerCoalitionAdmission(g.players, g.coalitionState, {
event: stringField(action.event), nexus: stringField(action.nexus),
turn: integer(action.turn, 1, Number.MAX_SAFE_INTEGER, 'Invitation turn'),
@@ -46866,15 +46918,25 @@ export function viewGame(state: Game, id: string, now?: number) {
// are not needed by board rules in the player view.
coalitionState: g.coalitionState ? {...g.coalitionState,
usedEvents:g.coalitionState.proposal ? [g.coalitionState.proposal.event] : []} : undefined,
coalitionNexus: coalitionNexusAvailable(g) ? {
me: id,
players: g.players.map(({id: seatId, name}) => ({id: seatId, name})),
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,
coalitionNexus: coalitionNexusAvailable(g) ? (() => {
const groups = coalitionRule(() => coalitionGroups(g.players, g.coalitionState));
const proposal = g.coalitionState!.proposal;
const occupied = new Set(groups.flatMap(group => group.members));
const limit = g.coalitionState!.variant === 'unlimited' ? 6 : 3;
const blockedInvitations = proposal ? [] : groups.flatMap(group => group.members.length >= limit ? [] :
g.players.filter(seat => !occupied.has(seat.id) &&
(seat.id === id || group.members.includes(id)) &&
!coalitionAdmissionAllowed(g, id, group.id, seat.id))
.map(seat => ({groupId:group.id, entrant:seat.id})));
const participants = proposal ? [...proposal.original.members, proposal.entrant] : [];
const acceptBlocked = !!proposal && participants.includes(id) && !proposal.approvals.includes(id) &&
!coalitionAdmissionAllowed(g, id, proposal.groupId, proposal.entrant);
return {me:id, players:g.players.map(({id:seatId,name}) => ({id:seatId,name})), groups, proposal,
revision:g.coalitionState!.revision,
...(blockedInvitations.length ? {blockedInvitations} : {}),
...(acceptBlocked ? {acceptBlocked:true} : {}),
...(g.coalitionState!.variant ? {variant:g.coalitionState!.variant} : {})};
})() : null,
homeworldAllianceBlocks: Object.fromEntries(g.players.filter((p) => p.id !== id)
.flatMap((p) => { const reason = homeworldAllianceReason(g, id, p.id); return reason ? [[p.id, reason]] : []; })),
ecazSpice:
+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 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'] },
{ area: 'Verification', status: 'Partial', detail: 'Focused pair and staged group evidence includes real Nexus actions, admission with live bribe and alliance answers, four-member unlimited admission, funding and victory, staged larger-group faction helpers, funding with outsider-only commitments, Guild transport, Emperor and Fremen aid, worm protection, battle preparation and allied traitor votes; complete group play and combinations remain open.', evidence: ['tests/coalition-bribe-admission.test.ts', 'tests/coalition-admission-deal-truth.test.ts', 'tests/coalition-admission-joint-commitments.test.ts', 'tests/coalition-alliance-admission-commitments.test.ts', 'tests/coalition-admission-bribe-controls.test.tsx', '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,69 @@
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', 'e'].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): CoalitionNexusView => ({
me, players, groups: [group], proposal: null, revision: 3, variant: 'unlimited',
});
const render = (value: CoalitionNexusView) => renderToStaticMarkup(createElement(CoalitionNexus, {
view: value, onIntent() {},
}));
void test('only the specified group and entrant invitation is blocked', () => {
const member = view('a');
member.blockedInvitations = [{groupId: 'group', entrant: 'd'}, {groupId: 'other', entrant: 'e'}];
const choices = coalitionNexusOptions(member);
assert.deepEqual(choices.invitations.map(row => row.entrant), ['e']);
assert.equal(choices.mayLeave, true);
const html = render(member);
assert.doesNotMatch(html, /Invite D to/);
assert.match(html, /Invite E to A, B, C/);
assert.match(html, /conflict with your commitments/);
assert.match(html, /Leave A, B, C/);
const entrant = view('d');
entrant.blockedInvitations = [{groupId: 'group', entrant: 'e'}];
assert.match(render(entrant), /Ask to join A, B, C/);
entrant.blockedInvitations = [{groupId: 'group', entrant: 'd'}];
assert.doesNotMatch(render(entrant), /Ask to join/);
});
void test('a blocked pending acceptance can still be declined or withdrawn', () => {
const pending = view('d');
pending.proposal = {event: 'event', nexus: 'nexus', turn: 2, groupId: group.id,
original: group, entrant: 'd', proposer: 'a', approvals: ['a', 'b']};
pending.acceptBlocked = true;
assert.equal(coalitionNexusOptions(pending).mayAccept, false);
assert.equal(coalitionNexusOptions(pending).mayDecline, true);
assert.doesNotMatch(render(pending), /Accept invitation/);
assert.match(render(pending), /conflict with your commitments/);
assert.match(render(pending), /Decline and cancel invitation/);
pending.me = 'b';
assert.match(render(pending), /Withdraw approval and cancel invitation/);
pending.me = 'd';
pending.acceptBlocked = false;
assert.equal(coalitionNexusOptions(pending).mayAccept, true);
assert.match(render(pending), /Accept invitation/);
});
void test('legacy admissionBlocked still blocks all invitations and acceptance', () => {
const member = view('a');
member.admissionBlocked = true;
assert.deepEqual(coalitionNexusOptions(member).invitations, []);
assert.equal(coalitionNexusOptions(member).mayLeave, true);
const pending = view('d');
pending.admissionBlocked = 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);
});
@@ -0,0 +1,112 @@
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, actor: string, action: Action) => applyAction(game, actor, action);
const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!;
/** The unfinished group mode is staged in a quiet Basic Nexus, along with
* wallets and one physical Truthtrance card. Group formation, deal consent,
* the question, admission answers and both payments are real actions. */
function fixture(): Game {
let game = createGame('GROUPDEALTRUTH', newPlayer('a','Atreides','atreides'), false);
game.players.push(newPlayer('b','Emperor','emperor'), newPlayer('c','Fremen','fremen'),
newPlayer('d','Harkonnen','harkonnen'), newPlayer('e','Guild','guild'),
newPlayer('f','Bene Gesserit','beneGesserit'));
Object.assign(game,{status:'playing',phase:1,turn:2,nexus:true,
order:['a','b','c','d','e','f'],ready:[],deck:baseDeck()});
game.coalitionState = emptyCoalitionState('unlimited');
for (const player of game.players) player.spice = 8;
game = act(game,'a',{type:'alliance',target:'b'});
game = act(game,'b',{type:'alliance',target:'a'});
const pair = coalitionOf(game.players,game.coalitionState,'a'); assert.ok(pair);
game = act(game,'a',{type:'coalitionPropose',groupId:pair.id,entrant:'c',
expectedRevision:game.coalitionState!.revision});
game = answer(game,'b',true);
game = answer(game,'c',true);
const index = game.deck.findIndex(card => card.effect === 'truthtrance'); assert.ok(index >= 0);
const [card] = game.deck.splice(index,1); seat(game,'f').hand.push(card);
return game;
}
function answer(game: Game, actor: string, accept: boolean): Game {
const proposal = game.coalitionState!.proposal; assert.ok(proposal);
return act(game,actor,{type:'coalitionAnswer',event:proposal.event,nexus:proposal.nexus,
turn:proposal.turn,accept,expectedRevision:game.coalitionState!.revision});
}
function propose(game: Game, actor: string): Game {
const group = coalitionOf(game.players,game.coalitionState,'a'); assert.ok(group);
return act(game,actor,{type:'coalitionPropose',groupId:group.id,entrant:'d',
expectedRevision:game.coalitionState!.revision});
}
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(()=>act(game,actor,action));
assert.deepEqual(game,before,'A rejected action must leave the source and obligations intact.');
}
function acceptAction(game: Game): Action {
const proposal = game.coalitionState!.proposal; assert.ok(proposal);
return {type:'coalitionAnswer',event:proposal.event,nexus:proposal.nexus,
turn:proposal.turn,accept:true,expectedRevision:game.coalitionState!.revision};
}
function offerDeal(game: Game): {game: Game; id: string} {
game = act(game,'e',{type:'dealOffer',target:'a',terms:'Pay for information this turn.',
payment:{kind:'deferredBribe',payer:'a',recipient:'e',amount:2,dueTurn:game.turn}});
const id = game.deals!.at(-1)!.id;
game = act(game,'a',{type:'dealRespond',deal:id,accept:true});
assert.equal(game.deals!.find(deal=>deal.id===id)!.paymentStatus,'pending');
return {game,id};
}
function promise(game: Game, recipient: string): Game {
const card = seat(game,'f').hand.find(candidate=>candidate.effect==='truthtrance'); assert.ok(card);
game = act(game,'f',{type:'card',card:card.id});
while (game.truthtrance?.stage === 'priority') {
const next = game.players.find(player=>!game.truthtrance!.passed.includes(player.id)); assert.ok(next);
game = act(game,next.id,{type:'truthPass'});
}
game = act(game,'f',{type:'truthAsk',question:{kind:'bribe',target:'a',turn:game.turn,recipient,minimum:2}});
assert.ok(viewGame(game,'a').truthBribeAnswers?.includes('yes'));
return act(game,'a',{type:'truthAnswer',answer:'yes'});
}
function admit(game: Game): Game {
game = propose(game,'b');
for (const actor of ['a','c','d']) game = answer(game,actor,true);
assert.deepEqual(coalitionOf(game.players,game.coalitionState,'a')!.members,['a','b','c','d']);
return game;
}
void test('an outsider deal does not hide a separate Yes that admission would make unpayable',()=>{
const offered = offerDeal(fixture());
let game = promise(offered.game,'d');
const group = coalitionOf(game.players,game.coalitionState,'a'); assert.ok(group);
reject(game,'a',{type:'coalitionPropose',groupId:group.id,entrant:'d',
expectedRevision:game.coalitionState!.revision});
game = propose(game,'b');
reject(game,'a',acceptAction(game));
assert.equal(game.deals!.find(deal=>deal.id===offered.id)!.paymentStatus,'pending');
assert.ok(game.bribePromises!.some(row=>row.player==='a' && !row.fulfilled && !row.released));
game = answer(game,'a',false);
game = act(game,'a',{type:'dealPay',deal:offered.id});
assert.equal(game.deals!.find(deal=>deal.id===offered.id)!.paymentStatus,'paid');
assert.ok(game.bribePromises!.some(row=>row.player==='a' && !row.fulfilled && !row.released));
game = act(game,'a',{type:'bribe',target:'d',amount:2});
assert.ok(game.bribePromises!.some(row=>row.player==='a' && row.fulfilled));
game = admit(game);
assert.equal(seat(game,'e').bribes,2);
assert.equal(seat(game,'d').bribes,2);
});
void test('admission preserves an outsider deal that also fulfills the matching Yes',()=>{
const offered = offerDeal(fixture());
let game = promise(offered.game,'e');
game = admit(game);
assert.equal(game.deals!.find(deal=>deal.id===offered.id)!.paymentStatus,'pending');
assert.ok(game.bribePromises!.some(row=>row.player==='a' && !row.fulfilled && !row.released));
assert.equal(viewGame(game,'a').deferredDealPaymentOptions[offered.id]?.blocked,null);
game = act(game,'a',{type:'dealPay',deal:offered.id});
assert.equal(game.deals!.find(deal=>deal.id===offered.id)!.paymentStatus,'paid');
assert.ok(game.bribePromises!.some(row=>row.player==='a' && row.fulfilled));
assert.equal(seat(game,'a').spice,6);
assert.equal(seat(game,'e').bribes,2);
});
@@ -0,0 +1,66 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
import { applyAction, createGame, newPlayer, type Action, type Game } from '../game/engine';
const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
/** A player's retained partner and unrelated bribe remain jointly feasible.
* The empty joint ledger is significant: it delegates accepted-order
* reconciliation to the scalar alliance planner. */
function fixture(): Game {
let game = createGame('ADMISSIONJOINT', 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:[]});
game.coalitionState = emptyCoalitionState('unlimited');
game.players.find(player => player.id === 'a')!.spice = 8;
game = act(game, 'a', {type:'alliance', target:'b'});
game = act(game, 'b', {type:'alliance', target:'a'});
const source = JSON.stringify(['ordinaryNexus', game.turn, game.spiceDiscard.map(pile => pile.length),
game.spiceSequence?.pile ?? null, game.spiceSequence?.skipped?.length ?? 0]);
game.alliancePromises = [{event:'alliance', source, player:'a', asker:'e', turn:game.turn,
partner:'b', answer:true}];
game.bribePromises = [{event:'bribe', player:'a', asker:'e', turn:game.turn,
recipient:'e', minimum:1, answer:true, progress:0}];
game.turnActionPromises = [];
return game;
}
function proposal(game: Game) {
const group = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(group);
return {type:'coalitionPropose', groupId:group.id, entrant:'c',
expectedRevision:game.coalitionState!.revision};
}
function answer(game: Game, accept: boolean) {
const offer = game.coalitionState!.proposal; assert.ok(offer);
return {type:'coalitionAnswer', event:offer.event, nexus:offer.nexus, turn:offer.turn,
accept, expectedRevision:game.coalitionState!.revision};
}
function commitmentsLive(game: Game) {
assert.equal(game.bribePromises![0].released ?? false, false);
assert.equal(game.alliancePromises![0].released ?? false, false);
}
void test('joint accepted-order promises cannot be released by group proposal or approval', () => {
let game = fixture();
const before = structuredClone(game);
assert.throws(() => act(game, 'a', proposal(game)));
assert.deepEqual(game, before);
commitmentsLive(game);
// Another member may propose; the affected player can still decline, but
// cannot approve a group while the scalar reconciliation is unsupported.
game = act(game, 'b', proposal(game));
commitmentsLive(game);
game = act(game, 'c', answer(game, true));
commitmentsLive(game);
const pending = structuredClone(game);
assert.throws(() => act(game, 'a', answer(game, true)));
assert.deepEqual(game, pending);
commitmentsLive(game);
game = act(game, 'a', answer(game, false));
commitmentsLive(game);
assert.equal(game.coalitionState!.proposal, null);
});
@@ -0,0 +1,125 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { baseDeck } from '../game/cards';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
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)!;
/** Only the quiet Basic Nexus and two physical Truthtrance cards are staged.
* Pair/group formation, each question, consent and departure use real actions. */
function group(): {game:Game; cards:string[]} {
let game = createGame('GROUPALLIANCEADMIT',newPlayer('a','Atreides','atreides'),false);
game.players.push(newPlayer('b','Emperor','emperor'),newPlayer('c','Fremen','fremen'),
newPlayer('d','Harkonnen','harkonnen'),newPlayer('e','Guild','guild'),
newPlayer('f','Bene Gesserit','beneGesserit'));
Object.assign(game,{status:'playing',phase:1,turn:2,nexus:true,
order:['a','b','c','d','e','f'],ready:[],deck:baseDeck()});
game.coalitionState = emptyCoalitionState('unlimited');
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']) game = answer(game,actor,true);
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,'f').hand.push(card);
}
return {game,cards};
}
function ask(game:Game, card:string, partner:string, choice:'yes'|'no'):Game {
game = send(game,'f',{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,'f',{type:'truthAsk',question:{kind:'alliance',target:'a',turn:game.turn,partner}});
assert.ok(viewGame(game,'a').truthAllianceAnswers?.includes(choice));
return send(game,'a',{type:'truthAnswer',answer:choice});
}
function propose(game:Game, proposer='c'):Game {
const current = coalitionOf(game.players,game.coalitionState,'a'); assert.ok(current);
return send(game,proposer,{type:'coalitionPropose',groupId:current.id,entrant:'d',
expectedRevision:game.coalitionState!.revision});
}
function answer(game:Game, actor:string, accept:boolean):Game {
const offer = game.coalitionState!.proposal; assert.ok(offer);
return send(game,actor,{type:'coalitionAnswer',event:offer.event,nexus:offer.nexus,
turn:offer.turn,accept,expectedRevision:game.coalitionState!.revision});
}
function rejectedAcceptance(game:Game, actor:string) {
const offer = game.coalitionState!.proposal; assert.ok(offer);
const before = structuredClone(game);
assert.throws(()=>send(game,actor,{type:'coalitionAnswer',event:offer.event,nexus:offer.nexus,
turn:offer.turn,accept:true,expectedRevision:game.coalitionState!.revision}));
assert.deepEqual(game,before,'Rejected consent must preserve the offer and every answer.');
}
function botDeclines(game:Game, actor:string) {
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game,actor);
view.players.find(player=>player.id===actor)!.bot = difficulty;
const action = botActions(view)[0]; assert.ok(action,difficulty);
assert.equal(action.type,'coalitionAnswer',difficulty);
assert.equal(action.accept,false,difficulty);
assert.doesNotThrow(()=>send(game,actor,action),difficulty);
}
}
void test('an unmet group No survives a pending invitation the respondent can decline',()=>{
const fixture=group();
let game=ask(fixture.game,fixture.cards[0],'b','no');
const event=game.alliancePromises!.find(row=>row.player==='a')!.event;
assert.equal(viewGame(game,'a').alliancePromiseGuide?.action.type,'coalitionLeave');
game=propose(game,'c');
assert.ok(game.coalitionState!.proposal);
assert.ok(!game.alliancePromises!.find(row=>row.event===event)!.released,
'The respondent can decline the invitation and then leave.');
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game,'a');
view.players.find(player=>player.id==='a')!.bot = difficulty;
const action = botActions(view)[0]; assert.ok(action,difficulty);
assert.equal(action.type,'coalitionAnswer',difficulty);
assert.equal(action.accept,true,difficulty);
assert.doesNotThrow(()=>send(game,'a',action),difficulty);
}
game=answer(game,'a',false);
assert.equal(game.coalitionState!.proposal,null);
assert.ok(!game.alliancePromises!.find(row=>row.event===event)!.released);
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===event)!.released);
game=send(game,'a',{type:'ready'});
assert.ok(game.ready.includes('a'));
});
void test('Yes to an existing ally and No to the entrant constrain only their respondent’s consent',()=>{
const fixture=group();
let game=ask(fixture.game,fixture.cards[0],'b','yes');
game=ask(game,fixture.cards[1],'d','no');
const events=game.alliancePromises!.filter(row=>row.player==='a').map(row=>row.event);
assert.equal(events.length,2);
game=propose(game,'c');
rejectedAcceptance(game,'a');
// Other participants may approve: the promise is A's, and those approvals
// neither constitute A's consent nor complete the larger alliance.
game=answer(game,'b',true);
game=answer(game,'d',true);
assert.ok(game.coalitionState!.proposal);
assert.deepEqual(coalitionOf(game.players,game.coalitionState,'a')!.members,['a','b','c']);
rejectedAcceptance(game,'a');
assert.ok(events.every(event=>!game.alliancePromises!.find(row=>row.event===event)!.released));
botDeclines(game,'a');
game=answer(game,'a',false);
assert.equal(game.coalitionState!.proposal,null);
assert.ok(events.every(event=>!game.alliancePromises!.find(row=>row.event===event)!.released));
game=send(game,'a',{type:'ready'});
assert.ok(game.ready.includes('a'));
});
+140
View File
@@ -0,0 +1,140 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { baseDeck } from '../game/cards';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
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)!;
/** Only a quiet Basic Nexus, the six wallets and one physical Truthtrance card
* are staged. Pair/group formation, answers, consent and payments are actual
* game actions. No lobby exposes this group variant yet. */
function group(): Game {
let game = createGame('GROUPBRIBEADMIT', newPlayer('a','Atreides','atreides'), false);
game.players.push(newPlayer('b','Emperor','emperor'), newPlayer('c','Fremen','fremen'),
newPlayer('d','Harkonnen','harkonnen'), newPlayer('e','Guild','guild'),
newPlayer('f','Bene Gesserit','beneGesserit'));
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true,
order:['a','b','c','d','e','f'], ready:[], deck:baseDeck()});
game.coalitionState = emptyCoalitionState('unlimited');
for (const player of game.players) player.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']) game = answer(game,actor,true);
const index = game.deck.findIndex(card => card.effect === 'truthtrance'); assert.ok(index >= 0);
const [card] = game.deck.splice(index,1); seat(game,'f').hand.push(card);
return game;
}
function ask(game:Game, target:string, recipient:string, answerValue:'yes'|'no', expression=false):Game {
const card = seat(game,'f').hand.find(candidate => candidate.effect === 'truthtrance'); assert.ok(card);
game = send(game,'f',{type:'card',card:card.id});
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'});
}
const question = expression ? {kind:'bribeExpression',target,turn:game.turn,claim:{kind:'or',terms:[
{kind:'payment',recipient,minimum:2}, {kind:'payment',recipient:'e',minimum:1}]}} :
{kind:'bribe',target,turn:game.turn,recipient,minimum:2};
game = send(game,'f',{type:'truthAsk',question});
const view = viewGame(game,target);
const answers = expression ? view.truthBribeExpressionAnswers : view.truthBribeAnswers;
assert.ok(answers?.includes(answerValue));
return send(game,target,{type:'truthAnswer',answer:answerValue});
}
function propose(game:Game, actor:string):Game {
const original = coalitionOf(game.players,game.coalitionState,'a'); assert.ok(original);
return send(game,actor,{type:'coalitionPropose',groupId:original.id,entrant:'d',
expectedRevision:game.coalitionState!.revision});
}
function answer(game:Game, actor:string, accept:boolean):Game {
const offer = game.coalitionState!.proposal; assert.ok(offer);
return send(game,actor,{type:'coalitionAnswer',event:offer.event,nexus:offer.nexus,
turn:offer.turn,accept,expectedRevision:game.coalitionState!.revision});
}
function rejected(game:Game, actor:string, action:Action) {
const before = structuredClone(game);
assert.throws(()=>send(game,actor,action));
assert.deepEqual(game,before,'Rejected consent must not modify the invitation or commitment.');
}
function rejectedProposal(game:Game, actor:string) {
const original = coalitionOf(game.players,game.coalitionState,'a'); assert.ok(original);
rejected(game,actor,{type:'coalitionPropose',groupId:original.id,entrant:'d',
expectedRevision:game.coalitionState!.revision});
}
function rejectedAcceptance(game:Game, actor:string) {
const offer = game.coalitionState!.proposal; assert.ok(offer);
rejected(game,actor,{type:'coalitionAnswer',event:offer.event,nexus:offer.nexus,
turn:offer.turn,accept:true,expectedRevision:game.coalitionState!.revision});
}
function admit(game:Game, proposer='a'):Game {
game = propose(game,proposer);
for (const actor of ['a','b','c','d'].filter(id=>id!==proposer)) game = answer(game,actor,true);
assert.deepEqual(coalitionOf(game.players,game.coalitionState,'a')!.members,['a','b','c','d']);
return game;
}
for (const debtor of ['a','b','c','d']) void test(`${debtor} cannot consent to an admission that prevents their own Yes bribe`,()=>{
const recipient = debtor === 'd' ? 'a' : 'd';
let game = ask(group(),debtor,recipient,'yes');
// An initial proposal is the proposer's own consent, including when the
// entrant proposes to join the existing group.
rejectedProposal(game,debtor);
const proposer = debtor === 'a' ? 'b' : 'a';
game = propose(game,proposer);
rejectedAcceptance(game,debtor);
// A bot must decline rather than try to pay while the pending invitation
// locks all actions except the participants' admission answers.
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game,debtor);
view.players.find(player => player.id === debtor)!.bot = difficulty;
const action = botActions(view)[0]; assert.ok(action);
assert.equal(action.type,'coalitionAnswer'); assert.equal(action.accept,false);
assert.doesNotThrow(()=>send(game,debtor,action));
}
game = answer(game,debtor,false);
const guide = viewGame(game,debtor).bribePromiseGuide; assert.ok(guide);
assert.equal(guide.action.type,'bribe'); assert.equal(guide.action.target,recipient);
game = send(game,debtor,guide.action);
assert.equal(seat(game,recipient).bribes,2);
assert.ok(game.bribePromises!.some(row=>row.player===debtor && row.fulfilled));
game = admit(game);
assert.equal(game.coalitionState!.proposal,null);
});
void test('an outsider Yes to another outsider survives admission and remains payable',()=>{
let game = ask(group(),'e','f','yes');
const commitment = game.bribePromises!.find(row=>row.player==='e')!;
game = admit(game);
assert.ok(!game.bribePromises!.find(row=>row.event===commitment.event)!.released);
const guide = viewGame(game,'e').bribePromiseGuide; assert.ok(guide);
assert.equal(guide.action.target,'f');
game = send(game,'e',guide.action);
assert.equal(game.bribePromises!.find(row=>row.event===commitment.event)!.fulfilled,true);
assert.equal(seat(game,'f').bribes,2);
});
void test('a No about the entrant survives joining and still prevents that payment',()=>{
let game = ask(group(),'a','d','no');
const commitment = game.bribePromises!.find(row=>row.player==='a')!;
game = admit(game);
assert.ok(!game.bribePromises!.find(row=>row.event===commitment.event)!.released);
rejected(game,'a',{type:'bribe',target:'d',amount:2});
});
void test('a whole bribe Yes may admit one named recipient when another valid branch remains',()=>{
let game = ask(group(),'a','d','yes',true);
const commitment = game.bribeExpressionPromises!.find(row=>row.player==='a')!;
game = admit(game);
assert.ok(!game.bribeExpressionPromises!.find(row=>row.event===commitment.event)!.released);
const guide = viewGame(game,'a').bribePromiseGuide; assert.ok(guide);
assert.equal(guide.action.type,'bribe'); assert.equal(guide.action.target,'e');
game = send(game,'a',guide.action);
assert.equal(game.bribeExpressionPromises!.find(row=>row.event===commitment.event)!.fulfilled,true);
assert.equal(seat(game,'e').bribes,1);
});
+3 -3
View File
@@ -67,9 +67,10 @@ void test('a positive bribe answer allows owned group departure and remains paya
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);
assert.equal(options.mayLeave, true);
assert.ok(options.invitations.every(invitation => invitation.entrant !== 'd'));
const markup = renderToStaticMarkup(createElement(CoalitionNexus, {view:projected, onIntent:()=>{}}));
assert.match(markup, /Leave/); assert.doesNotMatch(markup, /Invite .* to /);
assert.match(markup, /Leave/);
blockedProposal(game);
game = leave(game, 'a');
assert.equal(coalitionOf(game.players, game.coalitionState, 'a'), null);
@@ -85,7 +86,6 @@ void test('a positive bribe answer allows owned group departure and remains paya
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);
+4 -4
View File
@@ -86,20 +86,20 @@ void test('fulfilled and released Truthtrance records do not prevent group Nexus
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,
game.bribePromises = [{event:'unreconciled-bribe', player:'a', asker:'d', turn:game.turn - 1,
recipient:'c', minimum:1, answer:true, progress:0}];
assert.equal(viewGame(game, 'a').coalitionNexus?.admissionBlocked, true);
const pair = coalitionOf(game.players, game.coalitionState, 'a')!;
assert.ok(viewGame(game, 'a').coalitionNexus?.blockedInvitations?.some(row => row.groupId === pair.id && row.entrant === 'c'));
rejected(game, 'a', {type:'coalitionPropose', groupId:pair.id, entrant:'c',
expectedRevision:game.coalitionState!.revision});
});
void test('a current unanswered group-incompatible Truthtrance commitment still blocks group Nexus', () => {
const game = originalPair();
game.bribePromises = [{event:'live-bribe', player:'d', asker:'a', turn:game.turn,
game.bribePromises = [{event:'live-bribe', player:'a', asker:'d', turn:game.turn,
recipient:'c', minimum:1, answer:true, progress:0}];
assert.equal(viewGame(game, 'a').coalitionNexus?.admissionBlocked, true);
const pair = coalitionOf(game.players, game.coalitionState, 'a')!;
assert.ok(viewGame(game, 'a').coalitionNexus?.blockedInvitations?.some(row => row.groupId === pair.id && row.entrant === 'c'));
rejected(game, 'a', {type:'coalitionPropose', groupId:pair.id, entrant:'c',
expectedRevision:game.coalitionState!.revision});
});