Allow group admission with compatible commitments
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
0faa7923b0
commit
6524ed3bf2
14 files changed
+679
-53
No files matched your search
@@ -15,6 +15,10 @@ export type CoalitionNexusView = {
|
|||||||
/** Until group admission can reconcile a live bribe commitment, only
|
/** Until group admission can reconcile a live bribe commitment, only
|
||||||
* departure remains available in this Nexus. */
|
* departure remains available in this Nexus. */
|
||||||
admissionBlocked?: boolean;
|
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 =
|
export type CoalitionNexusIntent =
|
||||||
@@ -35,7 +39,9 @@ export function coalitionNexusOptions(view: CoalitionNexusView): {
|
|||||||
const invitations = !seated || view.proposal || view.admissionBlocked ? [] : 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
|
(group.members.length < 2 || group.members.length >= (view.variant === 'unlimited' ? 6 : 3)) ? [] : view.players
|
||||||
.filter(player => !occupied.has(player.id) &&
|
.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 })));
|
.map(player => ({ groupId: group.id, entrant: player.id, members: group.members })));
|
||||||
const participants = view.proposal
|
const participants = view.proposal
|
||||||
? [...view.proposal.original.members, view.proposal.entrant] : [];
|
? [...view.proposal.original.members, view.proposal.entrant] : [];
|
||||||
@@ -43,7 +49,8 @@ export function coalitionNexusOptions(view: CoalitionNexusView): {
|
|||||||
return {
|
return {
|
||||||
invitations,
|
invitations,
|
||||||
mayLeave: seated && !!ownGroup && !view.proposal,
|
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,
|
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>}
|
<li key={group.id}>{names(group.members)}</li>)}</ul> : <p>There are no alliances yet.</p>}
|
||||||
{proposal ? <div aria-live="polite">
|
{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 && <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>{name(proposal.proposer)} proposes adding {name(proposal.entrant)} to {names(proposal.original.members)}.</p>
|
||||||
<p>Approved: {names(proposal.approvals)}. Waiting for: {names(
|
<p>Approved: {names(proposal.approvals)}. Waiting for: {names(
|
||||||
[...proposal.original.members, proposal.entrant].filter(id => !proposal.approvals.includes(id)),
|
[...proposal.original.members, proposal.entrant].filter(id => !proposal.approvals.includes(id)),
|
||||||
@@ -87,6 +95,7 @@ export function CoalitionNexus({ view, disabled = false, onIntent }: {
|
|||||||
</div> : <div>
|
</div> : <div>
|
||||||
{view.admissionBlocked && <p>Invitations are unavailable while a bribe commitment remains unresolved.
|
{view.admissionBlocked && <p>Invitations are unavailable while a bribe commitment remains unresolved.
|
||||||
Finish the commitment or wait until its binding period ends.</p>}
|
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'
|
{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 alliance, or ask to join one.'
|
||||||
: 'Invite an unallied player to an existing pair, or ask to join a pair.'}</p>}
|
: 'Invite an unallied player to an existing pair, or ask to join a pair.'}</p>}
|
||||||
|
|||||||
+18
-6
@@ -17,11 +17,23 @@ open mode gates.
|
|||||||
|
|
||||||
## Current checkpoint and work
|
## 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.**
|
**Staged group departures and unrelated commitments — 29 October. Prototyped / Partial.**
|
||||||
Group members can leave while bribe answers remain binding; invitations stay
|
Group members can leave while bribe answers remain binding; invitations were
|
||||||
blocked until those commitments resolve. Group funding remains available when
|
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
|
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
|
funding remain unfinished. Twelve related files pass 123 cases, including
|
||||||
real staged group actions, bribe answers, donor payments and rendered controls;
|
real staged group actions, bribe answers, donor payments and rendered controls;
|
||||||
a separate simultaneous-guide case passes. Typecheck, lint and the production
|
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.**
|
**Staged group Truthtrance and larger worm decisions — 29 October. Prototyped / Partial.**
|
||||||
Alliance answers and expressions now read all group members rather than the
|
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
|
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
|
questions reject every current group ally. Changes with whole-turn commitments
|
||||||
changes with whole-turn commitments remain fenced. Larger worm response sequences have
|
remain fenced. Larger worm response sequences have
|
||||||
action-level tests with explicitly staged forces. Group lobby entry and complete
|
action-level tests with explicitly staged forces. Group lobby entry and complete
|
||||||
interactions remain unfinished. Eighteen related files pass 185 cases,
|
interactions remain unfinished. Eighteen related files pass 185 cases,
|
||||||
including the real-action group questions, rendered guides and staged worm
|
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
|
rejoining, a three-stronghold win, Emperor aid, bot admission and actual table
|
||||||
controls. Membership and selected faction helper cases cover up to six seats.
|
controls. Membership and selected faction helper cases cover up to six seats.
|
||||||
Fourteen related files pass 120 cases, with an additional pair-target case;
|
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.
|
full interactions and lobby entry remain unfinished.
|
||||||
[Scope](OPTIONAL_ALLIANCE_VICTORY.md#unlimited-alliance-prototype--29-october-2026).
|
[Scope](OPTIONAL_ALLIANCE_VICTORY.md#unlimited-alliance-prototype--29-october-2026).
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,41 @@
|
|||||||
# Optional three-stronghold alliance victory
|
# 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
|
## Group departures and unrelated commitments — 29 October 2026
|
||||||
|
|
||||||
**Staged Basic prototype, not a selectable game mode.** An alliance member
|
**Staged Basic prototype, not a selectable game mode.** An alliance member
|
||||||
may leave during a Nexus while ordinary bribe Truthtrance answers remain live.
|
may leave during a Nexus while ordinary bribe Truthtrance answers remain live.
|
||||||
The original payment guide and answer remain binding, and existing
|
The original payment guide and answer remain binding, and existing
|
||||||
reconciliation still checks the acting player's commitments. New group
|
reconciliation still checks the acting player's commitments. New group
|
||||||
invitations and acceptance remain unavailable while any bribe commitment is
|
invitations and acceptance initially remained unavailable while any bribe
|
||||||
unresolved; a pending invitation can still be declined. The table explains
|
commitment was unresolved; the later admission prototype above narrows that fence.
|
||||||
this limit and bots avoid blocked admissions.
|
|
||||||
|
|
||||||
Group bid and physical-shipment funding now ignores unfinished commitments
|
Group bid and physical-shipment funding now ignores unfinished commitments
|
||||||
belonging solely to nonmembers, because those payments do not debit outsiders.
|
belonging solely to nonmembers, because those payments do not debit outsiders.
|
||||||
An unfinished supported commitment belonging to any group member still fences
|
An unfinished supported commitment belonging to any group member still fences
|
||||||
the entire funding path, including donor pledges. Whole-turn commitments still
|
the entire funding path, including donor pledges. Whole-turn commitments still
|
||||||
block group Nexus changes. Admission with live bribes and funding when a member
|
block group Nexus changes. Funding when a member has a commitment needs
|
||||||
has a commitment need further payment and consent preflight; broader profiles
|
further payment and consent preflight; broader profiles
|
||||||
and lobby entry remain unfinished.
|
and lobby entry remain unfinished.
|
||||||
|
|
||||||
## Group Truthtrance and larger worm decisions — 29 October 2026
|
## 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
|
another player's consent. Another member may leave, with the existing
|
||||||
reconciliation rules releasing an answer only if it becomes impossible. Bribe
|
reconciliation rules releasing an answer only if it becomes impossible. Bribe
|
||||||
questions reject every current group ally as a recipient. Whole-turn alliance
|
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.
|
combinations remain unfinished.
|
||||||
|
|
||||||
Action-level worm tests additionally exercise three and four threatened group
|
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
|
Unanimity, cancellation, the pending-Nexus hold and the surviving pair are
|
||||||
provisional online procedures, not publisher rulings. This scope excludes
|
provisional online procedures, not publisher rulings. This scope excludes
|
||||||
expansion decks/factions, Advanced, optional modules, live bribe admission or
|
expansion decks/factions, Advanced, optional modules, whole-turn commitments
|
||||||
whole-turn commitments and pledged aid. No lobby creates the group ledger: faction ally
|
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
|
benefits, group funding and other interactions must be connected before any
|
||||||
player can start the printed larger-alliance option. The later unlimited
|
player can start the printed larger-alliance option. The later unlimited
|
||||||
prototype above extends this staged groundwork.
|
prototype above extends this staged groundwork.
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
# Rule decision index
|
# 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
|
## Group departures and unrelated commitments — 29 October 2026
|
||||||
|
|
||||||
Staged Basic group departure remains available with live bribe answers;
|
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
|
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).
|
blocks it. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-departures-and-unrelated-commitments--29-october-2026).
|
||||||
|
|
||||||
|
|||||||
+13
-10
@@ -1321,6 +1321,16 @@ function policyActions(g: GameView): Action[] {
|
|||||||
? [{ type: 'decision', event: search.event, card: ordered[0].id }]
|
? [{ 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.truthCharityGuide) return [g.truthCharityGuide.action];
|
||||||
if (g.bribePromiseGuide) return [g.bribePromiseGuide.action];
|
if (g.bribePromiseGuide) return [g.bribePromiseGuide.action];
|
||||||
if (g.alliancePromiseGuide) return [g.alliancePromiseGuide.action];
|
if (g.alliancePromiseGuide) return [g.alliancePromiseGuide.action];
|
||||||
@@ -3184,14 +3194,6 @@ function policyActions(g: GameView): Action[] {
|
|||||||
}
|
}
|
||||||
if (g.phase === 1) {
|
if (g.phase === 1) {
|
||||||
const group = g.coalitionNexus;
|
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 thumper = me.hand?.find((c) => c.effect === 'thumper');
|
||||||
const prior = g.spiceDiscardTop[0];
|
const prior = g.spiceDiscardTop[0];
|
||||||
if (
|
if (
|
||||||
@@ -3226,9 +3228,10 @@ function policyActions(g: GameView): Action[] {
|
|||||||
const card = me.hand?.find((c) => c.effect === 'harvester');
|
const card = me.hand?.find((c) => c.effect === 'harvester');
|
||||||
if (card) return [{ type: 'card', card: card.id }];
|
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))) {
|
!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,
|
if (alliance) return [{type:'coalitionPropose', groupId:alliance.id, entrant:me.id,
|
||||||
expectedRevision:group.revision}];
|
expectedRevision:group.revision}];
|
||||||
}
|
}
|
||||||
|
|||||||
+80
-18
@@ -16036,9 +16036,6 @@ function allianceQuestionWindow(g: Game): boolean {
|
|||||||
function unsettledPromises(records: readonly {fulfilled?: boolean; released?: boolean}[] | undefined): boolean {
|
function unsettledPromises(records: readonly {fulfilled?: boolean; released?: boolean}[] | undefined): boolean {
|
||||||
return !!records?.some(record => !record.fulfilled && !record.released);
|
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
|
/** Group negotiation is deliberately unexposed until its other alliance
|
||||||
* mechanics are integrated. This narrow profile keeps existing pair games
|
* mechanics are integrated. This narrow profile keeps existing pair games
|
||||||
* and unrelated commitments on their original paths. */
|
* and unrelated commitments on their original paths. */
|
||||||
@@ -16058,6 +16055,54 @@ function coalitionRule<T>(work: () => T): T {
|
|||||||
try { return work(); }
|
try { return work(); }
|
||||||
catch (error) { if (error instanceof CoalitionAllianceError || error instanceof CoalitionFundingError) throw new RuleError(error.message); throw error; }
|
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
|
/** Preserve legacy one-sided ally restrictions while recognizing every
|
||||||
* member of an explicitly staged larger alliance. */
|
* member of an explicitly staged larger alliance. */
|
||||||
function bribeAllied(g: Game, owner: Player, recipient: string): boolean {
|
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);
|
const ordered = orderedAllianceCommitments(g);
|
||||||
if (!ordered.some(promise => !promise.fulfilled && !promise.released)) return;
|
if (!ordered.some(promise => !promise.fulfilled && !promise.released)) return;
|
||||||
alliancePromiseIntegrity(g);
|
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;
|
g.pendingTreacheryDiscard || g.pendingKarama || g.pendingKull || g.pendingRicheseBetrayal) return;
|
||||||
const opportunity = allianceOpportunity(g), kept: AlliancePromise[] = [], expressions: AllianceExpressionPromise[] = [];
|
const opportunity = allianceOpportunity(g), kept: AlliancePromise[] = [], expressions: AllianceExpressionPromise[] = [];
|
||||||
for (const promise of ordered) {
|
for (const promise of ordered) {
|
||||||
@@ -16431,7 +16476,7 @@ function projectedAlliancePromises(g: Game, player: string) {
|
|||||||
}
|
}
|
||||||
function projectedAlliancePromiseGuide(g: Game, player: string): { action: Action } | null {
|
function projectedAlliancePromiseGuide(g: Game, player: string): { action: Action } | null {
|
||||||
const opportunity = allianceOpportunity(g);
|
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 &&
|
const promise = orderedAllianceCommitments(g).find(record => record.player === player && record.turn === opportunity.turn &&
|
||||||
record.source === opportunity.source && !record.fulfilled && !record.released);
|
record.source === opportunity.source && !record.fulfilled && !record.released);
|
||||||
if (!promise) return null;
|
if (!promise) return null;
|
||||||
@@ -16745,7 +16790,7 @@ function ordinaryPaymentActionAvailable(g: Game, player: Player, action: Action)
|
|||||||
action.amount > 0 && action.amount <= uncommittedSpice(g, player);
|
action.amount > 0 && action.amount <= uncommittedSpice(g, player);
|
||||||
}
|
}
|
||||||
function projectedBribePromiseGuide(g: Game, player: string): { action: Action } | null {
|
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
|
const joint = liveTurnActionPromises(g.turnActionPromises ?? [], player, g.turn).length
|
||||||
? turnActionCompletion(g, player) : null;
|
? turnActionCompletion(g, player) : null;
|
||||||
if (joint?.paymentsAfterPreparations && joint.cardPreparations?.length) return null;
|
if (joint?.paymentsAfterPreparations && joint.cardPreparations?.length) return null;
|
||||||
@@ -44565,11 +44610,10 @@ function applyActionInner(
|
|||||||
}
|
}
|
||||||
if (t === 'coalitionPropose' || t === 'coalitionAnswer' || t === 'coalitionLeave') {
|
if (t === 'coalitionPropose' || t === 'coalitionAnswer' || t === 'coalitionLeave') {
|
||||||
requireRule(coalitionNexusAvailable(g), 'Group negotiations are unavailable in this Nexus.');
|
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');
|
const revision = integer(action.expectedRevision, 0, Number.MAX_SAFE_INTEGER, 'Coalition revision');
|
||||||
if (t === 'coalitionPropose') {
|
if (t === 'coalitionPropose') {
|
||||||
|
requireRule(coalitionAdmissionAllowed(g, id, stringField(action.groupId), stringField(action.entrant)),
|
||||||
|
'Honor your existing commitments before proposing this alliance.');
|
||||||
initializeCoalitionEscrow(g);
|
initializeCoalitionEscrow(g);
|
||||||
const result = coalitionRule(() => beginCoalitionAdmission(g.players, g.coalitionState, {
|
const result = coalitionRule(() => beginCoalitionAdmission(g.players, g.coalitionState, {
|
||||||
event: crypto.randomUUID(), nexus: crypto.randomUUID(), turn: g.turn,
|
event: crypto.randomUUID(), nexus: crypto.randomUUID(), turn: g.turn,
|
||||||
@@ -44581,6 +44625,14 @@ function applyActionInner(
|
|||||||
} else if (t === 'coalitionAnswer') {
|
} else if (t === 'coalitionAnswer') {
|
||||||
requireRule(typeof action.accept === 'boolean', 'Accept or decline this group invitation.');
|
requireRule(typeof action.accept === 'boolean', 'Accept or decline this group invitation.');
|
||||||
const offer = g.coalitionState!.proposal;
|
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, {
|
const result = coalitionRule(() => answerCoalitionAdmission(g.players, g.coalitionState, {
|
||||||
event: stringField(action.event), nexus: stringField(action.nexus),
|
event: stringField(action.event), nexus: stringField(action.nexus),
|
||||||
turn: integer(action.turn, 1, Number.MAX_SAFE_INTEGER, 'Invitation turn'),
|
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.
|
// are not needed by board rules in the player view.
|
||||||
coalitionState: g.coalitionState ? {...g.coalitionState,
|
coalitionState: g.coalitionState ? {...g.coalitionState,
|
||||||
usedEvents:g.coalitionState.proposal ? [g.coalitionState.proposal.event] : []} : undefined,
|
usedEvents:g.coalitionState.proposal ? [g.coalitionState.proposal.event] : []} : undefined,
|
||||||
coalitionNexus: coalitionNexusAvailable(g) ? {
|
coalitionNexus: coalitionNexusAvailable(g) ? (() => {
|
||||||
me: id,
|
const groups = coalitionRule(() => coalitionGroups(g.players, g.coalitionState));
|
||||||
players: g.players.map(({id: seatId, name}) => ({id: seatId, name})),
|
const proposal = g.coalitionState!.proposal;
|
||||||
groups: coalitionRule(() => coalitionGroups(g.players, g.coalitionState)),
|
const occupied = new Set(groups.flatMap(group => group.members));
|
||||||
proposal: g.coalitionState!.proposal,
|
const limit = g.coalitionState!.variant === 'unlimited' ? 6 : 3;
|
||||||
revision: g.coalitionState!.revision,
|
const blockedInvitations = proposal ? [] : groups.flatMap(group => group.members.length >= limit ? [] :
|
||||||
...(coalitionAdmissionBlocked(g) ? {admissionBlocked:true} : {}),
|
g.players.filter(seat => !occupied.has(seat.id) &&
|
||||||
...(g.coalitionState!.variant ? {variant: g.coalitionState!.variant} : {}),
|
(seat.id === id || group.members.includes(id)) &&
|
||||||
} : null,
|
!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)
|
homeworldAllianceBlocks: Object.fromEntries(g.players.filter((p) => p.id !== id)
|
||||||
.flatMap((p) => { const reason = homeworldAllianceReason(g, id, p.id); return reason ? [[p.id, reason]] : []; })),
|
.flatMap((p) => { const reason = homeworldAllianceReason(g, id, p.id); return reason ? [[p.id, reason]] : []; })),
|
||||||
ecazSpice:
|
ecazSpice:
|
||||||
|
|||||||
+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: '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: '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: '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: [
|
related: [
|
||||||
'mentat',
|
'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'));
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -67,9 +67,10 @@ void test('a positive bribe answer allows owned group departure and remains paya
|
|||||||
let game = ask('yes');
|
let game = ask('yes');
|
||||||
const projected = viewGame(game, 'a').coalitionNexus; assert.ok(projected);
|
const projected = viewGame(game, 'a').coalitionNexus; assert.ok(projected);
|
||||||
const options = coalitionNexusOptions(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:()=>{}}));
|
const markup = renderToStaticMarkup(createElement(CoalitionNexus, {view:projected, onIntent:()=>{}}));
|
||||||
assert.match(markup, /Leave/); assert.doesNotMatch(markup, /Invite .* to /);
|
assert.match(markup, /Leave/);
|
||||||
blockedProposal(game);
|
blockedProposal(game);
|
||||||
game = leave(game, 'a');
|
game = leave(game, 'a');
|
||||||
assert.equal(coalitionOf(game.players, game.coalitionState, 'a'), null);
|
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', () => {
|
void test('a negative bribe answer survives another member departing and retains its cap', () => {
|
||||||
let game = ask('no');
|
let game = ask('no');
|
||||||
blockedProposal(game);
|
|
||||||
game = leave(game, 'b');
|
game = leave(game, 'b');
|
||||||
assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'a')!.members, ['a','c']);
|
assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'a')!.members, ['a','c']);
|
||||||
const before = structuredClone(game);
|
const before = structuredClone(game);
|
||||||
|
|||||||
@@ -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', () => {
|
void test('an unresolved prior-turn Truthtrance record still blocks group Nexus', () => {
|
||||||
const game = originalPair();
|
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}];
|
recipient:'c', minimum:1, answer:true, progress:0}];
|
||||||
assert.equal(viewGame(game, 'a').coalitionNexus?.admissionBlocked, true);
|
|
||||||
const pair = coalitionOf(game.players, game.coalitionState, 'a')!;
|
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',
|
rejected(game, 'a', {type:'coalitionPropose', groupId:pair.id, entrant:'c',
|
||||||
expectedRevision:game.coalitionState!.revision});
|
expectedRevision:game.coalitionState!.revision});
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('a current unanswered group-incompatible Truthtrance commitment still blocks group Nexus', () => {
|
void test('a current unanswered group-incompatible Truthtrance commitment still blocks group Nexus', () => {
|
||||||
const game = originalPair();
|
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}];
|
recipient:'c', minimum:1, answer:true, progress:0}];
|
||||||
assert.equal(viewGame(game, 'a').coalitionNexus?.admissionBlocked, true);
|
|
||||||
const pair = coalitionOf(game.players, game.coalitionState, 'a')!;
|
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',
|
rejected(game, 'a', {type:'coalitionPropose', groupId:pair.id, entrant:'c',
|
||||||
expectedRevision:game.coalitionState!.revision});
|
expectedRevision:game.coalitionState!.revision});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user