From 6524ed3bf2b297530b80bc09c304305749dd258f Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:54:36 +1100 Subject: [PATCH] Allow group admission with compatible commitments --- components/coalition-nexus.tsx | 13 +- docs/CURRENT_STATUS.md | 24 ++- docs/OPTIONAL_ALLIANCE_VICTORY.md | 37 ++++- docs/RULE_DECISIONS.md | 9 +- game/bots.ts | 23 +-- game/engine.ts | 98 +++++++++--- game/reference.ts | 2 +- ...oalition-admission-bribe-controls.test.tsx | 69 +++++++++ tests/coalition-admission-deal-truth.test.ts | 112 ++++++++++++++ ...lition-admission-joint-commitments.test.ts | 66 +++++++++ ...ion-alliance-admission-commitments.test.ts | 125 ++++++++++++++++ tests/coalition-bribe-admission.test.ts | 140 ++++++++++++++++++ tests/coalition-bribe-departure.test.tsx | 6 +- tests/coalition-nexus-engine.test.ts | 8 +- 14 files changed, 679 insertions(+), 53 deletions(-) create mode 100644 tests/coalition-admission-bribe-controls.test.tsx create mode 100644 tests/coalition-admission-deal-truth.test.ts create mode 100644 tests/coalition-admission-joint-commitments.test.ts create mode 100644 tests/coalition-alliance-admission-commitments.test.ts create mode 100644 tests/coalition-bribe-admission.test.ts diff --git a/components/coalition-nexus.tsx b/components/coalition-nexus.tsx index d2a3bd3a..6099073b 100644 --- a/components/coalition-nexus.tsx +++ b/components/coalition-nexus.tsx @@ -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 }: {
  • {names(group.members)}
  • )} :

    There are no alliances yet.

    } {proposal ?
    {view.admissionBlocked &&

    Acceptance is unavailable while a bribe commitment remains unresolved. You may decline this invitation.

    } + {!view.admissionBlocked && view.acceptBlocked &&

    Acceptance is unavailable while it would conflict with your commitments. You may decline this invitation.

    }

    {name(proposal.proposer)} proposes adding {name(proposal.entrant)} to {names(proposal.original.members)}.

    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 }: {

    :
    {view.admissionBlocked &&

    Invitations are unavailable while a bribe commitment remains unresolved. Finish the commitment or wait until its binding period ends.

    } + {!view.admissionBlocked && !!view.blockedInvitations?.length &&

    Some invitations are unavailable while they would conflict with your commitments.

    } {options.invitations.length > 0 &&

    {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.'}

    } diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 8a7f22b0..e39a4234 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -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). diff --git a/docs/OPTIONAL_ALLIANCE_VICTORY.md b/docs/OPTIONAL_ALLIANCE_VICTORY.md index fd277a05..7dbc6478 100644 --- a/docs/OPTIONAL_ALLIANCE_VICTORY.md +++ b/docs/OPTIONAL_ALLIANCE_VICTORY.md @@ -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. diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 0ad11433..4837408d 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -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). diff --git a/game/bots.ts b/game/bots.ts index dee388e5..e8f8e357 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -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}]; } diff --git a/game/engine.ts b/game/engine.ts index 786e55c5..1d0d8e75 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -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(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: diff --git a/game/reference.ts b/game/reference.ts index 35951966..c05a4fe7 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -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', diff --git a/tests/coalition-admission-bribe-controls.test.tsx b/tests/coalition-admission-bribe-controls.test.tsx new file mode 100644 index 00000000..b2948a81 --- /dev/null +++ b/tests/coalition-admission-bribe-controls.test.tsx @@ -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); +}); diff --git a/tests/coalition-admission-deal-truth.test.ts b/tests/coalition-admission-deal-truth.test.ts new file mode 100644 index 00000000..ad50cc42 --- /dev/null +++ b/tests/coalition-admission-deal-truth.test.ts @@ -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); +}); diff --git a/tests/coalition-admission-joint-commitments.test.ts b/tests/coalition-admission-joint-commitments.test.ts new file mode 100644 index 00000000..1053abde --- /dev/null +++ b/tests/coalition-admission-joint-commitments.test.ts @@ -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); +}); diff --git a/tests/coalition-alliance-admission-commitments.test.ts b/tests/coalition-alliance-admission-commitments.test.ts new file mode 100644 index 00000000..84360248 --- /dev/null +++ b/tests/coalition-alliance-admission-commitments.test.ts @@ -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')); +}); diff --git a/tests/coalition-bribe-admission.test.ts b/tests/coalition-bribe-admission.test.ts new file mode 100644 index 00000000..bb7275c8 --- /dev/null +++ b/tests/coalition-bribe-admission.test.ts @@ -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); +}); diff --git a/tests/coalition-bribe-departure.test.tsx b/tests/coalition-bribe-departure.test.tsx index 930d3977..dbf4296e 100644 --- a/tests/coalition-bribe-departure.test.tsx +++ b/tests/coalition-bribe-departure.test.tsx @@ -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); diff --git a/tests/coalition-nexus-engine.test.ts b/tests/coalition-nexus-engine.test.ts index 6cbf9ef7..6bc056fe 100644 --- a/tests/coalition-nexus-engine.test.ts +++ b/tests/coalition-nexus-engine.test.ts @@ -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}); });