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
|
||||
* 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
@@ -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).
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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'));
|
||||
});
|
||||
@@ -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');
|
||||
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);
|
||||
|
||||
@@ -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});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user