Support group departures in joint commitment plans
Verify and publish checkpoint container / container (push) Successful in 11m20s
Verify and publish checkpoint container / container (push) Successful in 11m20s
This commit is contained in:
1 parent
6524ed3bf2
commit
52d932002b
12 files changed
+552
-39
No files matched your search
+16
-3
@@ -17,12 +17,25 @@ open mode gates.
|
|||||||
|
|
||||||
## Current checkpoint and work
|
## Current checkpoint and work
|
||||||
|
|
||||||
|
**Staged joint group commitments — 29 October. Prototyped / Partial.**
|
||||||
|
The joint planner can retain matching group membership or quote a member's own
|
||||||
|
departure, including payment to a former ally after leaving. An invitation
|
||||||
|
pauses joint reconciliation; compatible admission checks combined payment and
|
||||||
|
travel costs against the same purse. Thirteen related files pass 58 cases,
|
||||||
|
including real answers, admission, departure, payments and a constrained shared
|
||||||
|
wallet. Typecheck, lint and the production build pass. Two older whole-turn
|
||||||
|
affordability cases were corrected: in their fixture, an 11-spice payment can
|
||||||
|
use an earlier payment and later Charity, while 13 cannot. Actual
|
||||||
|
whole-turn group changes, member-commitment funding, other profiles and lobby
|
||||||
|
entry remain unfinished. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-admission-with-joint-commitments--29-october-2026).
|
||||||
|
|
||||||
**Staged group admission with live commitments — 29 October. Prototyped / Partial.**
|
**Staged group admission with live commitments — 29 October. Prototyped / Partial.**
|
||||||
Each proposer and accepting member is checked against their own bribe and
|
Each proposer and accepting member is checked against their own bribe and
|
||||||
alliance answers; unrelated participants can still negotiate and bots decline
|
alliance answers; unrelated participants can still negotiate and bots decline
|
||||||
blocked consent. Pending invitations preserve alliance answers. Admission is
|
blocked consent. Pending invitations preserve alliance answers. Admission
|
||||||
fenced when the older whole-turn planner could misread combined alliance and
|
was initially fenced when the older whole-turn planner could misread combined
|
||||||
payment/travel answers as a pair-only state. Fourteen related files pass 82
|
alliance and payment/travel answers as a pair-only state; the later prototype
|
||||||
|
above narrows that fence. Fourteen related files pass 82
|
||||||
cases, and the joint-planner regression passes separately. Typecheck, lint and
|
cases, and the joint-planner regression passes separately. Typecheck, lint and
|
||||||
the production build pass. Group funding with
|
the production build pass. Group funding with
|
||||||
member commitments, whole-turn Nexus changes, other profiles and lobby entry
|
member commitments, whole-turn Nexus changes, other profiles and lobby entry
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
# Optional three-stronghold alliance victory
|
# Optional three-stronghold alliance victory
|
||||||
|
|
||||||
|
## Group admission with joint commitments — 29 October 2026
|
||||||
|
|
||||||
|
**Staged Basic prototype, not a selectable game mode.** The joint
|
||||||
|
turn-action planner recognizes an existing group and can quote the member's
|
||||||
|
own departure. Pending invitations pause joint reconciliation while departure
|
||||||
|
is unavailable. Admission checks a detached completed group against the
|
||||||
|
participant's combined accepted payment and travel answers, so commitments
|
||||||
|
continue to share the actual resources. Admission requiring departure remains
|
||||||
|
available when its joint witness can quote that owned departure. Payment to a
|
||||||
|
former ally can be quoted after leaving. Actual whole-turn group changes,
|
||||||
|
group funding with member commitments, wider
|
||||||
|
profiles and lobby entry remain unfinished.
|
||||||
|
|
||||||
## Group admission with live commitments — 29 October 2026
|
## Group admission with live commitments — 29 October 2026
|
||||||
|
|
||||||
**Staged Basic prototype, not a selectable game mode.** A proposer or
|
**Staged Basic prototype, not a selectable game mode.** A proposer or
|
||||||
@@ -14,12 +27,11 @@ decline when their own acceptance is blocked. The pending invitation can be
|
|||||||
declined, and alliance answers are not released merely because it temporarily
|
declined, and alliance answers are not released merely because it temporarily
|
||||||
prevents departure. Unanimous consent remains a provisional online procedure.
|
prevents departure. Unanimous consent remains a provisional online procedure.
|
||||||
|
|
||||||
Admission stays fenced for a participant with both an alliance answer and an
|
Admission originally stayed fenced for a participant with both an alliance
|
||||||
unresolved payment or travel-family answer when the older whole-turn planner
|
answer and an unresolved payment or travel-family answer when the older
|
||||||
is present: that planner still assumes pairs. Group funding with a member's
|
whole-turn planner was present; the later group-aware route above narrows
|
||||||
commitment, whole-turn Nexus changes, wider profiles and lobby entry remain
|
that fence. These staged cases do not certify complete group play or expansion
|
||||||
unfinished. These focused staged cases do not certify complete group play or
|
combinations.
|
||||||
expansion combinations.
|
|
||||||
|
|
||||||
## Group departures and unrelated commitments — 29 October 2026
|
## Group departures and unrelated commitments — 29 October 2026
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# Rule decision index
|
# Rule decision index
|
||||||
|
|
||||||
|
## Group admission with joint commitments — 29 October 2026
|
||||||
|
|
||||||
|
Staged Basic admission can retain a matching group alliance answer alongside
|
||||||
|
jointly feasible payment or travel answers. Joint planning can quote owned
|
||||||
|
group departure outside a pending invitation, including a later payment to a
|
||||||
|
former ally. Whole-turn group changes remain fenced.
|
||||||
|
[Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-admission-with-joint-commitments--29-october-2026).
|
||||||
|
|
||||||
## Group admission with live commitments — 29 October 2026
|
## Group admission with live commitments — 29 October 2026
|
||||||
|
|
||||||
Staged Basic group admission checks each proposer's or accepting participant's
|
Staged Basic group admission checks each proposer's or accepting participant's
|
||||||
|
|||||||
+47
-13
@@ -16063,12 +16063,6 @@ function coalitionAdmissionAllowed(g: Game, actor: string, groupId: string, entr
|
|||||||
const unresolved = (row: {player:string; turn:number; fulfilled?:boolean; released?:boolean}) =>
|
const unresolved = (row: {player:string; turn:number; fulfilled?:boolean; released?:boolean}) =>
|
||||||
row.player === actor && !row.fulfilled && !row.released;
|
row.player === actor && !row.fulfilled && !row.released;
|
||||||
if ([...bribes, ...alliances].some(row => unresolved(row) && row.turn !== g.turn)) return false;
|
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;
|
if (!bribes.some(row => unresolved(row) && row.answer) && !alliances.some(unresolved)) return true;
|
||||||
try {
|
try {
|
||||||
const trial = structuredClone(g);
|
const trial = structuredClone(g);
|
||||||
@@ -16095,6 +16089,10 @@ function coalitionAdmissionAllowed(g: Game, actor: string, groupId: string, entr
|
|||||||
requireRule(!trial.coalitionState!.proposal, 'The proposed group did not finish.');
|
requireRule(!trial.coalitionState!.proposal, 'The proposed group did not finish.');
|
||||||
trial.truthtrance = null;
|
trial.truthtrance = null;
|
||||||
const player = getPlayer(trial, actor);
|
const player = getPlayer(trial, actor);
|
||||||
|
const joint = !!g.turnActionPromises && alliances.some(unresolved) &&
|
||||||
|
[g.bribePromises, g.bribeExpressionPromises, g.travelPromises, g.shipmentPromises,
|
||||||
|
g.movementPromises, g.movementExpressionPromises].some(rows => rows?.some(unresolved));
|
||||||
|
if (joint && !turnActionCompletion(trial, actor)) return false;
|
||||||
return (!hasBribePromises(trial, actor, true) || !!bribePromiseCompletion(trial, player)) &&
|
return (!hasBribePromises(trial, actor, true) || !!bribePromiseCompletion(trial, player)) &&
|
||||||
(!alliances.some(unresolved) || !!alliancePromisePlan(trial, player)) &&
|
(!alliances.some(unresolved) || !!alliancePromisePlan(trial, player)) &&
|
||||||
(!publicDealCompletion(g, getPlayer(g, actor)) || publicDealCompletion(trial, player));
|
(!publicDealCompletion(g, getPlayer(g, actor)) || publicDealCompletion(trial, player));
|
||||||
@@ -21663,8 +21661,10 @@ function initializeTurnActionOrder(g: Game) {
|
|||||||
}
|
}
|
||||||
/** All admissible final native partners use the original reciprocal quote.
|
/** All admissible final native partners use the original reciprocal quote.
|
||||||
* Exact outcome rows live only in this witness; no route is persisted. */
|
* Exact outcome rows live only in this witness; no route is persisted. */
|
||||||
type TurnActionAllianceRoute = { steps: { player: string; target: string | null }[]; uncertain: boolean };
|
type TurnActionAllianceRoute = { steps: ({ player: string; target: string | null; kind?: 'pair' } |
|
||||||
function turnActionAlliancePlans(g: Game, actor: string, includeUnknown = false): (TurnActionAllianceRoute | undefined)[] {
|
{ player: string; kind: 'groupLeave'; revision: number })[]; uncertain: boolean };
|
||||||
|
function turnActionAlliancePlans(g: Game, actor: string, includeUnknown = false,
|
||||||
|
allowGroupLeave = true): (TurnActionAllianceRoute | undefined)[] {
|
||||||
const scalars = g.alliancePromises ?? [], groups = g.allianceExpressionPromises ?? [];
|
const scalars = g.alliancePromises ?? [], groups = g.allianceExpressionPromises ?? [];
|
||||||
const active = [...scalars, ...groups].some(promise => promise.player === actor &&
|
const active = [...scalars, ...groups].some(promise => promise.player === actor &&
|
||||||
promise.turn === g.turn && !promise.fulfilled && !promise.released);
|
promise.turn === g.turn && !promise.fulfilled && !promise.released);
|
||||||
@@ -21673,6 +21673,21 @@ function turnActionAlliancePlans(g: Game, actor: string, includeUnknown = false)
|
|||||||
if (!opportunity) return [];
|
if (!opportunity) return [];
|
||||||
if ([...scalars, ...groups].some(promise => promise.player === actor && promise.turn === g.turn &&
|
if ([...scalars, ...groups].some(promise => promise.player === actor && promise.turn === g.turn &&
|
||||||
!promise.fulfilled && !promise.released && promise.source !== opportunity.source)) return [];
|
!promise.fulfilled && !promise.released && promise.source !== opportunity.source)) return [];
|
||||||
|
const member = getPlayer(g, actor);
|
||||||
|
const coalition = g.coalitionState && coalitionRule(() => coalitionOf(g.players, g.coalitionState, actor));
|
||||||
|
if (coalition && coalition.members.length >= 3) {
|
||||||
|
if (groupAllianceAnswersMatch(g, member, opportunity, scalars, groups))
|
||||||
|
return [{steps: [], uncertain: false}];
|
||||||
|
if (g.coalitionState!.proposal || !allowGroupLeave) return [];
|
||||||
|
const trial = structuredClone(g);
|
||||||
|
try {
|
||||||
|
applyCoalitionTransition(trial, coalitionRule(() => leaveCoalition(trial.players, trial.coalitionState,
|
||||||
|
{actor, expectedRevision:trial.coalitionState!.revision})));
|
||||||
|
return groupAllianceAnswersMatch(trial, getPlayer(trial, actor), opportunity, scalars, groups)
|
||||||
|
? [{steps: [{player: actor, kind: 'groupLeave', revision: g.coalitionState!.revision}], uncertain: false}]
|
||||||
|
: [];
|
||||||
|
} catch (error) { if (error instanceof RuleError) return []; throw error; }
|
||||||
|
}
|
||||||
const outcomes = allianceExpressionOutcomes(scalars, groups, actor, opportunity,
|
const outcomes = allianceExpressionOutcomes(scalars, groups, actor, opportunity,
|
||||||
{ players: g.players, offers: g.allianceOffers });
|
{ players: g.players, offers: g.allianceOffers });
|
||||||
const plans: TurnActionAllianceRoute[] = [], seen = new Set<string>();
|
const plans: TurnActionAllianceRoute[] = [], seen = new Set<string>();
|
||||||
@@ -21964,7 +21979,8 @@ function turnActionCompletionAfterFreshDiscard(source: Game, actor: string, unkn
|
|||||||
const power = turnActionPowerCompletion(trial, actor,
|
const power = turnActionPowerCompletion(trial, actor,
|
||||||
leaves.filter((leaf): leaf is TurnActionPowerRequirement => leaf.kind === 'battlePower'));
|
leaves.filter((leaf): leaf is TurnActionPowerRequirement => leaf.kind === 'battlePower'));
|
||||||
if (!power) return null;
|
if (!power) return null;
|
||||||
const alliancePlans = turnActionAlliancePlans(trial, actor, !!unknownConsent);
|
const alliancePlans = turnActionAlliancePlans(trial, actor, !!unknownConsent,
|
||||||
|
!unsettledPromises(source.turnActionPromises));
|
||||||
for (const required of bribePromiseRule(() => bribeCompletionCandidates(
|
for (const required of bribePromiseRule(() => bribeCompletionCandidates(
|
||||||
trial.bribePromises!, trial.bribeExpressionPromises ?? [], actor, source.turn))) {
|
trial.bribePromises!, trial.bribeExpressionPromises ?? [], actor, source.turn))) {
|
||||||
// A quoted payment follows only a currently owned modal continuation;
|
// A quoted payment follows only a currently owned modal continuation;
|
||||||
@@ -21977,12 +21993,28 @@ function turnActionCompletionAfterFreshDiscard(source: Game, actor: string, unkn
|
|||||||
if (required.length && bribeTimingBlock(paymentWindow)) continue;
|
if (required.length && bribeTimingBlock(paymentWindow)) continue;
|
||||||
const total = required.reduce((sum, payment) => sum + payment.amount, 0);
|
const total = required.reduce((sum, payment) => sum + payment.amount, 0);
|
||||||
if (!Number.isSafeInteger(total) ||
|
if (!Number.isSafeInteger(total) ||
|
||||||
required.some(payment => payment.recipient === actor || bribeAllied(trial, owner, payment.recipient) ||
|
required.some(payment => payment.recipient === actor ||
|
||||||
!trial.players.some(player => player.id === payment.recipient))) continue;
|
!trial.players.some(player => player.id === payment.recipient))) continue;
|
||||||
for (const alliancePlan of alliancePlans) {
|
for (const alliancePlan of alliancePlans) {
|
||||||
try {
|
try {
|
||||||
|
const departure = alliancePlan?.steps.find(step => step.kind === 'groupLeave');
|
||||||
|
const leaveFirst = required.some(payment => bribeAllied(trial, owner, payment.recipient));
|
||||||
|
if (leaveFirst && !departure) continue;
|
||||||
|
const paymentSource = leaveFirst ? structuredClone(trial) : trial;
|
||||||
|
if (leaveFirst && departure) {
|
||||||
|
applyCoalitionTransition(paymentSource, coalitionRule(() => leaveCoalition(
|
||||||
|
paymentSource.players, paymentSource.coalitionState,
|
||||||
|
{actor:departure.player, expectedRevision:departure.revision})));
|
||||||
|
if (required.some(payment => bribeAllied(paymentSource, getPlayer(paymentSource, actor), payment.recipient))) continue;
|
||||||
|
}
|
||||||
const prepareAlliance = (paid: Game) => {
|
const prepareAlliance = (paid: Game) => {
|
||||||
for (const action of alliancePlan?.steps ?? []) {
|
for (const action of alliancePlan?.steps ?? []) {
|
||||||
|
if (action.kind === 'groupLeave') {
|
||||||
|
if (leaveFirst) continue;
|
||||||
|
applyCoalitionTransition(paid, coalitionRule(() => leaveCoalition(paid.players, paid.coalitionState,
|
||||||
|
{actor:action.player, expectedRevision:action.revision})));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const quote = normalNexusAllianceQuote(paid, getPlayer(paid, action.player), action.target);
|
const quote = normalNexusAllianceQuote(paid, getPlayer(paid, action.player), action.target);
|
||||||
for (const player of paid.players) player.ally = quote.allies[player.id];
|
for (const player of paid.players) player.ally = quote.allies[player.id];
|
||||||
paid.allianceOffers = quote.offers;
|
paid.allianceOffers = quote.offers;
|
||||||
@@ -22000,7 +22032,7 @@ function turnActionCompletionAfterFreshDiscard(source: Game, actor: string, unkn
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const voluntary = { actor, action: { type: 'bribe' } };
|
const voluntary = { actor, action: { type: 'bribe' } };
|
||||||
for (const cardSeed of turnActionFundedPreparations(trial, actor, required, cardRequirements,
|
for (const cardSeed of turnActionFundedPreparations(paymentSource, actor, required, cardRequirements,
|
||||||
source.phaseOpening ? windowRequirements.filter(required => required.effect !== 'amal') : windowRequirements, prepareAlliance)) {
|
source.phaseOpening ? windowRequirements.filter(required => required.effect !== 'amal') : windowRequirements, prepareAlliance)) {
|
||||||
const funded = cardSeed.game, payer = getPlayer(funded, actor);
|
const funded = cardSeed.game, payer = getPlayer(funded, actor);
|
||||||
try {
|
try {
|
||||||
@@ -22051,7 +22083,9 @@ function turnActionCompletionAfterFreshDiscard(source: Game, actor: string, unkn
|
|||||||
...(cardSeed.cardsFirst ? { paymentsAfterPreparations: true } : {}), ...(bid ? { bid } : {}), ...(revival ? { revival } : {}),
|
...(cardSeed.cardsFirst ? { paymentsAfterPreparations: true } : {}), ...(bid ? { bid } : {}), ...(revival ? { revival } : {}),
|
||||||
...(battlePlan ? { battlePlan } : {}), ...(traitor ? { traitor } : {}),
|
...(battlePlan ? { battlePlan } : {}), ...(traitor ? { traitor } : {}),
|
||||||
...(power.action ? { battlePower: power.action } : {}),
|
...(power.action ? { battlePower: power.action } : {}),
|
||||||
...(alliancePlan ? { alliance: alliancePlan.steps.map(step => ({ type: 'alliance', target: step.target })) } : {}),
|
...(alliancePlan ? { alliance: alliancePlan.steps.map(step => step.kind === 'groupLeave'
|
||||||
|
? {type: 'coalitionLeave', expectedRevision: step.revision}
|
||||||
|
: { type: 'alliance', target: step.target }) } : {}),
|
||||||
payments: cardSeed.payments };
|
payments: cardSeed.payments };
|
||||||
}
|
}
|
||||||
} catch (error) { if (!(error instanceof RuleError)) throw error; }
|
} catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||||
@@ -22259,7 +22293,7 @@ function reconcileTurnActionPromises(g: Game, actor?: string, action?: Action, s
|
|||||||
requireRule(actor !== promise.player || !promise.answer, 'You cannot voluntarily evade your Truthtrance bribe answer.');
|
requireRule(actor !== promise.player || !promise.answer, 'You cannot voluntarily evade your Truthtrance bribe answer.');
|
||||||
if (promise.answer) promise.released = true; else promise.fulfilled = true;
|
if (promise.answer) promise.released = true; else promise.fulfilled = true;
|
||||||
}
|
}
|
||||||
if (!turnActionPromiseTurn(g) || g.truthtrance) return;
|
if (!turnActionPromiseTurn(g) || g.truthtrance || g.coalitionState?.proposal) return;
|
||||||
const nexus = allianceOpportunity(g);
|
const nexus = allianceOpportunity(g);
|
||||||
if (nexus && action?.type === 'bribe' && g.turnActionPromises!.some(promise =>
|
if (nexus && action?.type === 'bribe' && g.turnActionPromises!.some(promise =>
|
||||||
promise.player === actor && promise.turn === g.turn && promise.alliance?.source === nexus.source))
|
promise.player === actor && promise.turn === g.turn && promise.alliance?.source === nexus.source))
|
||||||
|
|||||||
+1
-1
@@ -4505,7 +4505,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
{ area: 'Player controls', status: 'Partial', detail: 'The lobby offers the pair option. A staged group Emperor can choose either ally for spice and extra revival; group games cannot yet be started from the lobby.', evidence: ['components/game-table.tsx', 'components/coalition-emperor-aid.tsx', 'tests/short-alliance-victory-controls.test.tsx'] },
|
{ area: 'Player controls', status: 'Partial', detail: 'The lobby offers the pair option. A staged group Emperor can choose either ally for spice and extra revival; group games cannot yet be started from the lobby.', evidence: ['components/game-table.tsx', 'components/coalition-emperor-aid.tsx', 'tests/short-alliance-victory-controls.test.tsx'] },
|
||||||
{ area: 'AI', status: 'Partial', detail: 'Legal alliance and victory paths use the shared target; staged group Emperor bots can offer a targeted revival. Strategic tuning remains deferred.', evidence: ['game/bots.ts', 'game/victory-progress.ts'] },
|
{ area: 'AI', status: 'Partial', detail: 'Legal alliance and victory paths use the shared target; staged group Emperor bots can offer a targeted revival. Strategic tuning remains deferred.', evidence: ['game/bots.ts', 'game/victory-progress.ts'] },
|
||||||
{ area: 'Documentation', status: 'Partial', detail: 'The printed option and provisional two-controller precedence are recorded.', evidence: ['docs/OPTIONAL_ALLIANCE_VICTORY.md'] },
|
{ area: 'Documentation', status: 'Partial', detail: 'The printed option and provisional two-controller precedence are recorded.', evidence: ['docs/OPTIONAL_ALLIANCE_VICTORY.md'] },
|
||||||
{ area: 'Verification', status: 'Partial', detail: 'Focused pair and staged group evidence includes real Nexus actions, 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'] },
|
{ area: 'Verification', status: 'Partial', detail: 'Focused pair and staged group evidence includes real Nexus actions, admission with live bribe and alliance answers including matching joint commitments, 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-admission-joint-stay.test.ts', 'tests/coalition-admission-joint-leave.test.ts', 'tests/coalition-pending-joint-leave.test.ts', 'tests/coalition-joint-leave-payment-order.test.ts', 'tests/coalition-whole-turn-leave-guard.test.ts', 'tests/coalition-alliance-admission-commitments.test.ts', 'tests/coalition-admission-bribe-controls.test.tsx', 'tests/alliance-victory-option.test.ts', 'tests/short-alliance-victory-runtime.test.ts', 'tests/coalition-unlimited-engine.test.ts', 'tests/coalition-unlimited-powers.test.ts', 'tests/coalition-unlimited-emperor.test.ts', 'tests/coalition-truth-alliance.test.ts', 'tests/coalition-truth-alliance-controls.test.tsx', 'tests/coalition-truth-bribe-targets.test.ts', 'tests/coalition-bribe-departure.test.tsx', 'tests/coalition-bribe-alliance-guides.test.ts', 'tests/coalition-outsider-commitment-funding.test.ts', 'tests/coalition-unlimited-worm-engine.test.ts', 'tests/coalition-nexus-engine.test.ts', 'tests/coalition-shipment-funding.test.ts', 'tests/coalition-bid-funding.test.ts', 'tests/coalition-guild-shipment.test.ts', 'tests/coalition-guild-transport.test.ts', 'tests/coalition-fremen-worm.test.ts', 'tests/coalition-emperor-aid.test.ts', 'tests/coalition-fremen-revival.test.ts', 'tests/coalition-battle-preparation.test.ts', 'tests/coalition-battle-power-question.test.ts', 'tests/coalition-harkonnen-traitor.test.ts', 'tests/coalition-traitor-declarations.test.ts'] },
|
||||||
],
|
],
|
||||||
related: [
|
related: [
|
||||||
'mentat',
|
'mentat',
|
||||||
|
|||||||
@@ -43,24 +43,17 @@ function commitmentsLive(game: Game) {
|
|||||||
assert.equal(game.alliancePromises![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', () => {
|
void test('joint accepted-order promises remain binding after group admission', () => {
|
||||||
let game = fixture();
|
let game = fixture();
|
||||||
const before = structuredClone(game);
|
game = act(game, 'a', proposal(game));
|
||||||
assert.throws(() => act(game, 'a', proposal(game)));
|
|
||||||
assert.deepEqual(game, before);
|
|
||||||
commitmentsLive(game);
|
commitmentsLive(game);
|
||||||
|
game = act(game, 'b', answer(game, true));
|
||||||
// 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);
|
commitmentsLive(game);
|
||||||
game = act(game, 'c', answer(game, true));
|
game = act(game, 'c', answer(game, true));
|
||||||
commitmentsLive(game);
|
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);
|
assert.equal(game.coalitionState!.proposal, null);
|
||||||
|
assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'a')!.members, ['a','b','c']);
|
||||||
|
game = act(game, 'a', {type:'bribe', target:'e', amount:1});
|
||||||
|
assert.equal(game.bribePromises![0].fulfilled, true);
|
||||||
|
assert.equal(game.alliancePromises![0].released ?? false, false);
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
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)!;
|
||||||
|
|
||||||
|
/** A quiet Basic Nexus, wallets and physical Truthtrances are staged; pair
|
||||||
|
* formation, questions, unanimous admission, departure and bribe are actions. */
|
||||||
|
function fixture():{game:Game; cards:string[]} {
|
||||||
|
let game=createGame('GROUPADMITLEAVE',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');
|
||||||
|
seat(game,'a').spice=8;
|
||||||
|
game=act(game,'a',{type:'alliance',target:'b'});
|
||||||
|
game=act(game,'b',{type:'alliance',target:'a'});
|
||||||
|
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, question:Record<string,unknown>, value:'yes'|'no'):Game {
|
||||||
|
game=act(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=act(game,next.id,{type:'truthPass'});
|
||||||
|
}
|
||||||
|
game=act(game,'f',{type:'truthAsk',question});
|
||||||
|
return act(game,'a',{type:'truthAnswer',answer:value});
|
||||||
|
}
|
||||||
|
function answer(game:Game, actor:string, accept:boolean):Game {
|
||||||
|
const offer=game.coalitionState!.proposal; assert.ok(offer);
|
||||||
|
return act(game,actor,{type:'coalitionAnswer',event:offer.event,nexus:offer.nexus,
|
||||||
|
turn:offer.turn,accept,expectedRevision:game.coalitionState!.revision});
|
||||||
|
}
|
||||||
|
function stillBinding(game:Game) {
|
||||||
|
assert.equal(game.alliancePromises!.find(row=>row.player==='a')!.released ?? false,false);
|
||||||
|
assert.equal(game.bribePromises!.find(row=>row.player==='a')!.released ?? false,false);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const proposer of ['a','b']) void test(`a ${proposer === 'a' ? 'proposer' : 'final voter'} can admit, leave and pay without releasing their answers`,()=>{
|
||||||
|
const {game:initial,cards}=fixture();
|
||||||
|
let game=ask(initial,cards[0],{kind:'alliance',target:'a',turn:initial.turn,partner:'c'},'no');
|
||||||
|
game=ask(game,cards[1],{kind:'bribe',target:'a',turn:game.turn,recipient:'e',minimum:2},'yes');
|
||||||
|
// Empty joint ledger invokes the whole accepted-order planner.
|
||||||
|
game.turnActionPromises=[];
|
||||||
|
const pair=coalitionOf(game.players,game.coalitionState,'a'); assert.ok(pair);
|
||||||
|
game=act(game,proposer,{type:'coalitionPropose',groupId:pair.id,entrant:'c',
|
||||||
|
expectedRevision:game.coalitionState!.revision});
|
||||||
|
if (proposer==='a') game=answer(game,'b',true);
|
||||||
|
game=answer(game,'c',true);
|
||||||
|
if (proposer==='b') game=answer(game,'a',true);
|
||||||
|
assert.deepEqual(coalitionOf(game.players,game.coalitionState,'a')!.members,['a','b','c']);
|
||||||
|
stillBinding(game);
|
||||||
|
const leave=viewGame(game,'a').alliancePromiseGuide; assert.ok(leave);
|
||||||
|
assert.equal(leave.action.type,'coalitionLeave');
|
||||||
|
game=act(game,'a',leave.action);
|
||||||
|
assert.equal(coalitionOf(game.players,game.coalitionState,'a'),null);
|
||||||
|
assert.deepEqual(coalitionOf(game.players,game.coalitionState,'b')!.members,['b','c']);
|
||||||
|
stillBinding(game);
|
||||||
|
const payment=viewGame(game,'a').bribePromiseGuide; assert.ok(payment);
|
||||||
|
assert.equal(payment.action.type,'bribe'); assert.equal(payment.action.target,'e');
|
||||||
|
game=act(game,'a',payment.action);
|
||||||
|
assert.equal(seat(game,'e').bribes,2);
|
||||||
|
assert.ok(game.bribePromises!.find(row=>row.player==='a')!.fulfilled);
|
||||||
|
stillBinding(game);
|
||||||
|
game=act(game,'a',{type:'ready'});
|
||||||
|
assert.ok(game.ready.includes('a'));
|
||||||
|
});
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
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 act = (game:Game, actor:string, action:Action) => applyAction(game,actor,action);
|
||||||
|
const seat = (game:Game, id:string) => game.players.find(player=>player.id===id)!;
|
||||||
|
|
||||||
|
/** Stage a quiet Basic Nexus, six wallets and two physical Truthtrance cards.
|
||||||
|
* Pair formation, questions, admission answers and payments are real actions. */
|
||||||
|
function fixture(): {game:Game; cards:string[]} {
|
||||||
|
let game = createGame('GROUPJOINTSTAY',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');
|
||||||
|
seat(game,'a').spice = 8;
|
||||||
|
game = act(game,'a',{type:'alliance',target:'b'});
|
||||||
|
game = act(game,'b',{type:'alliance',target:'a'});
|
||||||
|
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, question:Record<string,unknown>, value:'yes'|'no'):Game {
|
||||||
|
game = act(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=act(game,next.id,{type:'truthPass'});
|
||||||
|
}
|
||||||
|
game=act(game,'f',{type:'truthAsk',question});
|
||||||
|
return act(game,'a',{type:'truthAnswer',answer:value});
|
||||||
|
}
|
||||||
|
function propose(game:Game, actor:string):Game {
|
||||||
|
const pair = coalitionOf(game.players,game.coalitionState,'a'); assert.ok(pair);
|
||||||
|
return act(game,actor,{type:'coalitionPropose',groupId:pair.id,entrant:'c',
|
||||||
|
expectedRevision:game.coalitionState!.revision});
|
||||||
|
}
|
||||||
|
function answer(game:Game, actor:string, accept:boolean):Game {
|
||||||
|
const offer=game.coalitionState!.proposal; assert.ok(offer);
|
||||||
|
return act(game,actor,{type:'coalitionAnswer',event:offer.event,nexus:offer.nexus,
|
||||||
|
turn:offer.turn,accept,expectedRevision:game.coalitionState!.revision});
|
||||||
|
}
|
||||||
|
function live(game:Game) {
|
||||||
|
assert.ok(game.alliancePromises!.every(row=>!row.released));
|
||||||
|
assert.ok(game.bribePromises!.every(row=>!row.released));
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('a jointly feasible Yes to an existing ally and outsider bribe survives group admission and payment',()=>{
|
||||||
|
const {cards,game:initial}=fixture();
|
||||||
|
let game=ask(initial,cards[0],{kind:'alliance',target:'a',turn:initial.turn,partner:'b'},'yes');
|
||||||
|
game=ask(game,cards[1],{kind:'bribe',target:'a',turn:game.turn,recipient:'e',minimum:2},'yes');
|
||||||
|
// Even an empty ledger switches accepted-order reconciliation to the whole
|
||||||
|
// turn planner; both previously accepted answers must remain binding.
|
||||||
|
game.turnActionPromises=[];
|
||||||
|
game=propose(game,'a');
|
||||||
|
game=answer(game,'b',true);
|
||||||
|
game=answer(game,'c',true);
|
||||||
|
assert.deepEqual(coalitionOf(game.players,game.coalitionState,'a')!.members,['a','b','c']);
|
||||||
|
live(game);
|
||||||
|
const guide=viewGame(game,'a').bribePromiseGuide; assert.ok(guide);
|
||||||
|
assert.equal(guide.action.type,'bribe'); assert.equal(guide.action.target,'e');
|
||||||
|
game=act(game,'a',guide.action);
|
||||||
|
assert.equal(seat(game,'e').bribes,2);
|
||||||
|
assert.ok(game.bribePromises!.find(row=>row.player==='a')!.fulfilled);
|
||||||
|
live(game);
|
||||||
|
game=act(game,'a',{type:'ready'});
|
||||||
|
assert.ok(game.ready.includes('a'));
|
||||||
|
live(game);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('the respondent can decline admission that conflicts with simultaneous Yes and No',()=>{
|
||||||
|
const {cards,game:initial}=fixture();
|
||||||
|
let game=ask(initial,cards[0],{kind:'alliance',target:'a',turn:initial.turn,partner:'b'},'yes');
|
||||||
|
game=ask(game,cards[1],{kind:'alliance',target:'a',turn:game.turn,partner:'c'},'no');
|
||||||
|
// Stage an independently accepted ordinary bribe to exercise the joint
|
||||||
|
// reconciler without manufacturing a third physical Truthtrance card.
|
||||||
|
game.bribePromises=[{event:'joint-payment',player:'a',asker:'f',turn:game.turn,
|
||||||
|
recipient:'e',minimum:2,answer:true,progress:0}];
|
||||||
|
game.turnActionPromises=[];
|
||||||
|
game=propose(game,'b');
|
||||||
|
game=answer(game,'c',true);
|
||||||
|
const before=structuredClone(game);
|
||||||
|
assert.throws(()=>answer(game,'a',true));
|
||||||
|
assert.deepEqual(game,before);
|
||||||
|
live(game);
|
||||||
|
for (const difficulty of DIFFICULTIES) {
|
||||||
|
const view=viewGame(game,'a'); seatView(view,'a').bot=difficulty;
|
||||||
|
const action=botActions(view)[0]; assert.ok(action);
|
||||||
|
assert.equal(action.type,'coalitionAnswer'); assert.equal(action.accept,false);
|
||||||
|
assert.doesNotThrow(()=>act(game,'a',action));
|
||||||
|
}
|
||||||
|
game=answer(game,'a',false);
|
||||||
|
assert.equal(game.coalitionState!.proposal,null);
|
||||||
|
live(game);
|
||||||
|
});
|
||||||
|
|
||||||
|
function seatView(view:ReturnType<typeof viewGame>, id:string) {
|
||||||
|
const player=view.players.find(player=>player.id===id); assert.ok(player); return player;
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('admission checks a matching alliance, bribe and shipment against one physical purse',()=>{
|
||||||
|
const {cards,game:initial}=fixture();
|
||||||
|
let game=ask(initial,cards[0],{kind:'alliance',target:'a',turn:initial.turn,partner:'b'},'yes');
|
||||||
|
game=ask(game,cards[1],{kind:'bribe',target:'a',turn:game.turn,recipient:'e',minimum:2},'yes');
|
||||||
|
// Stage an already accepted shipment, its respondent's purse and a spent
|
||||||
|
// charity opportunity; the available physical reserves come from newPlayer.
|
||||||
|
game.shipmentPromises=[{player:'a',asker:'f',turn:game.turn,territory:'carthag',minimum:3,answer:true}];
|
||||||
|
game.turnActionPromises=[];
|
||||||
|
seat(game,'a').charityTurn=game.turn;
|
||||||
|
seat(game,'a').spice=4; // Two spice for the bribe plus three to ship to Carthag.
|
||||||
|
const before=structuredClone(game);
|
||||||
|
assert.throws(()=>propose(game,'a'));
|
||||||
|
assert.deepEqual(game,before);
|
||||||
|
live(game);
|
||||||
|
assert.ok(!game.shipmentPromises![0].released);
|
||||||
|
|
||||||
|
seat(game,'a').spice=5;
|
||||||
|
game=propose(game,'a');
|
||||||
|
game=answer(game,'b',true);
|
||||||
|
game=answer(game,'c',true);
|
||||||
|
live(game);
|
||||||
|
assert.ok(!game.shipmentPromises![0].released);
|
||||||
|
const guide=viewGame(game,'a').bribePromiseGuide; assert.ok(guide);
|
||||||
|
game=act(game,'a',guide.action);
|
||||||
|
assert.equal(seat(game,'a').spice,3);
|
||||||
|
assert.equal(seat(game,'e').bribes,2);
|
||||||
|
assert.ok(game.bribePromises![0].fulfilled);
|
||||||
|
assert.ok(!game.shipmentPromises![0].released);
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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)!;
|
||||||
|
|
||||||
|
/** Only a quiet Basic Nexus, wallets, an exhausted charity opportunity and
|
||||||
|
* two physical Truthtrances are staged. A later purse reduction represents a
|
||||||
|
* foreign loss. All group and Truthtrance actions and both bribes are real. */
|
||||||
|
function fixture():{game:Game;cards:string[]} {
|
||||||
|
let game=createGame('GROUPPAYORDER',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');
|
||||||
|
seat(game,'a').spice=3; seat(game,'a').charityTurn=game.turn;
|
||||||
|
seat(game,'d').spice=8;
|
||||||
|
game=act(game,'a',{type:'alliance',target:'b'});
|
||||||
|
game=act(game,'b',{type:'alliance',target:'a'});
|
||||||
|
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,question:Record<string,unknown>,value:'yes'|'no'):Game {
|
||||||
|
game=act(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=act(game,next.id,{type:'truthPass'});
|
||||||
|
}
|
||||||
|
game=act(game,'f',{type:'truthAsk',question});
|
||||||
|
return act(game,'a',{type:'truthAnswer',answer:value});
|
||||||
|
}
|
||||||
|
function answer(game:Game,actor:string):Game {
|
||||||
|
const offer=game.coalitionState!.proposal; assert.ok(offer);
|
||||||
|
return act(game,actor,{type:'coalitionAnswer',event:offer.event,nexus:offer.nexus,
|
||||||
|
turn:offer.turn,accept:true,expectedRevision:game.coalitionState!.revision});
|
||||||
|
}
|
||||||
|
function stillBinding(game:Game) {
|
||||||
|
assert.equal(game.alliancePromises!.find(row=>row.player==='a')!.released ?? false,false);
|
||||||
|
assert.equal(game.bribeExpressionPromises!.find(row=>row.player==='a')!.released ?? false,false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('owned departure can precede payment to the former ally when it is the only affordable OR branch',()=>{
|
||||||
|
const {game:initial,cards}=fixture();
|
||||||
|
let game=ask(initial,cards[0],{kind:'alliance',target:'a',turn:initial.turn,partner:'d'},'no');
|
||||||
|
game=ask(game,cards[1],{kind:'bribeExpression',target:'a',turn:game.turn,claim:{kind:'or',terms:[
|
||||||
|
{kind:'payment',recipient:'d',minimum:1},{kind:'payment',recipient:'e',minimum:3}]}},'yes');
|
||||||
|
// An empty ledger activates joint reconciliation. The three-spice outsider
|
||||||
|
// branch is available when A consents to the group including D.
|
||||||
|
game.turnActionPromises=[];
|
||||||
|
const pair=coalitionOf(game.players,game.coalitionState,'a'); assert.ok(pair);
|
||||||
|
game=act(game,'a',{type:'coalitionPropose',groupId:pair.id,entrant:'d',
|
||||||
|
expectedRevision:game.coalitionState!.revision});
|
||||||
|
game=answer(game,'b'); game=answer(game,'d');
|
||||||
|
assert.deepEqual(coalitionOf(game.players,game.coalitionState,'a')!.members,['a','b','d']);
|
||||||
|
stillBinding(game);
|
||||||
|
// Stage a loss after admission. E's branch now costs too much, but A can
|
||||||
|
// satisfy both answers by leaving and paying the former ally D one spice.
|
||||||
|
seat(game,'a').spice=1;
|
||||||
|
game=act(game,'d',{type:'bribe',target:'e',amount:1});
|
||||||
|
stillBinding(game);
|
||||||
|
const leave=viewGame(game,'a').alliancePromiseGuide; assert.ok(leave);
|
||||||
|
assert.equal(leave.action.type,'coalitionLeave');
|
||||||
|
game=act(game,'a',leave.action);
|
||||||
|
assert.equal(coalitionOf(game.players,game.coalitionState,'a'),null);
|
||||||
|
assert.deepEqual(coalitionOf(game.players,game.coalitionState,'b')!.members,['b','d']);
|
||||||
|
stillBinding(game);
|
||||||
|
const payment=viewGame(game,'a').bribePromiseGuide; assert.ok(payment);
|
||||||
|
assert.equal(payment.action.type,'bribe'); assert.equal(payment.action.target,'d');
|
||||||
|
game=act(game,'a',payment.action);
|
||||||
|
assert.equal(seat(game,'d').bribes,1);
|
||||||
|
assert.ok(game.bribeExpressionPromises!.find(row=>row.player==='a')!.fulfilled);
|
||||||
|
stillBinding(game);
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
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 quiet Basic Nexus, wallets and physical Truthtrances are staged. Group
|
||||||
|
* formation, both answers, the invitation, departure and bribes are actions. */
|
||||||
|
function fixture():{game:Game; cards:string[]} {
|
||||||
|
let game=createGame('GROUPPENDINGLEAVE',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 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, question:Record<string,unknown>, value:'yes'|'no'):Game {
|
||||||
|
game=act(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=act(game,next.id,{type:'truthPass'});
|
||||||
|
}
|
||||||
|
game=act(game,'f',{type:'truthAsk',question});
|
||||||
|
return act(game,'a',{type:'truthAnswer',answer:value});
|
||||||
|
}
|
||||||
|
function answer(game:Game, actor:string, accept:boolean):Game {
|
||||||
|
const offer=game.coalitionState!.proposal; assert.ok(offer);
|
||||||
|
return act(game,actor,{type:'coalitionAnswer',event:offer.event,nexus:offer.nexus,
|
||||||
|
turn:offer.turn,accept,expectedRevision:game.coalitionState!.revision});
|
||||||
|
}
|
||||||
|
function stillBinding(game:Game) {
|
||||||
|
assert.equal(game.alliancePromises!.find(row=>row.player==='a')!.released ?? false,false);
|
||||||
|
assert.equal(game.bribePromises!.find(row=>row.player==='a')!.released ?? false,false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('a pending group invitation preserves an owned leave and joint bribe through unrelated actions',()=>{
|
||||||
|
const {game:initial,cards}=fixture();
|
||||||
|
let game=ask(initial,cards[0],{kind:'alliance',target:'a',turn:initial.turn,partner:'b'},'no');
|
||||||
|
game=ask(game,cards[1],{kind:'bribe',target:'a',turn:game.turn,recipient:'e',minimum:2},'yes');
|
||||||
|
// An empty ledger invokes the joint accepted-order reconciler.
|
||||||
|
game.turnActionPromises=[];
|
||||||
|
assert.equal(viewGame(game,'a').alliancePromiseGuide?.action.type,'coalitionLeave');
|
||||||
|
const group=coalitionOf(game.players,game.coalitionState,'a'); assert.ok(group);
|
||||||
|
game=act(game,'c',{type:'coalitionPropose',groupId:group.id,entrant:'d',
|
||||||
|
expectedRevision:game.coalitionState!.revision});
|
||||||
|
assert.ok(game.coalitionState!.proposal);
|
||||||
|
stillBinding(game);
|
||||||
|
game=answer(game,'d',true);
|
||||||
|
stillBinding(game);
|
||||||
|
game=answer(game,'a',false);
|
||||||
|
assert.equal(game.coalitionState!.proposal,null);
|
||||||
|
stillBinding(game);
|
||||||
|
game=act(game,'d',{type:'bribe',target:'f',amount:1});
|
||||||
|
stillBinding(game);
|
||||||
|
const leave=viewGame(game,'a').alliancePromiseGuide; assert.ok(leave);
|
||||||
|
assert.equal(leave.action.type,'coalitionLeave');
|
||||||
|
game=act(game,'a',leave.action);
|
||||||
|
assert.equal(coalitionOf(game.players,game.coalitionState,'a'),null);
|
||||||
|
assert.deepEqual(coalitionOf(game.players,game.coalitionState,'b')!.members,['b','c']);
|
||||||
|
stillBinding(game);
|
||||||
|
const payment=viewGame(game,'a').bribePromiseGuide; assert.ok(payment);
|
||||||
|
assert.equal(payment.action.type,'bribe'); assert.equal(payment.action.target,'e');
|
||||||
|
game=act(game,'a',payment.action);
|
||||||
|
assert.equal(seat(game,'e').bribes,2);
|
||||||
|
assert.ok(game.bribePromises!.find(row=>row.player==='a')!.fulfilled);
|
||||||
|
assert.equal(game.alliancePromises!.find(row=>row.player==='a')!.released ?? false,false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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);
|
||||||
|
|
||||||
|
/** The quiet Nexus, wallet, Truthtrance and the later conflicting scalar
|
||||||
|
* answer are staged. Formation, the whole question and payment are real. */
|
||||||
|
function fixture(): Game {
|
||||||
|
let game = createGame('GROUPWHOLELEAVE',newPlayer('a','Atreides','atreides'),false);
|
||||||
|
game.players.push(newPlayer('b','Emperor','emperor'),newPlayer('c','Fremen','fremen'),
|
||||||
|
newPlayer('d','Harkonnen','harkonnen'),newPlayer('e','Guild','guild'));
|
||||||
|
Object.assign(game,{status:'playing',phase:1,turn:2,nexus:true,
|
||||||
|
order:['a','b','c','d','e'],ready:[],deck:baseDeck()});
|
||||||
|
game.coalitionState=emptyCoalitionState('unlimited');
|
||||||
|
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 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});
|
||||||
|
for (const actor of ['b','c']) {
|
||||||
|
const offer=game.coalitionState!.proposal!;
|
||||||
|
game=act(game,actor,{type:'coalitionAnswer',event:offer.event,nexus:offer.nexus,
|
||||||
|
turn:offer.turn,accept:true,expectedRevision:game.coalitionState!.revision});
|
||||||
|
}
|
||||||
|
const index=game.deck.findIndex(card=>card.effect==='truthtrance'); assert.ok(index>=0);
|
||||||
|
const [card]=game.deck.splice(index,1);
|
||||||
|
game.players.find(player=>player.id==='e')!.hand.push(card);
|
||||||
|
game=act(game,'e',{type:'card',card:card.id});
|
||||||
|
while (game.truthtrance?.stage==='priority') {
|
||||||
|
const actor=game.players.find(player=>!game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
|
||||||
|
game=act(game,actor.id,{type:'truthPass'});
|
||||||
|
}
|
||||||
|
game=act(game,'e',{type:'truthAsk',question:{kind:'turnActions',target:'a',turn:game.turn,
|
||||||
|
mixed:{kind:'and',terms:[{kind:'action',action:{kind:'payment',recipient:'d',minimum:2}},
|
||||||
|
{kind:'action',action:{kind:'ship',territory:'polar_sink',minimum:1}}]}}});
|
||||||
|
game=act(game,'a',{type:'truthAnswer',answer:'yes'});
|
||||||
|
game=act(game,'a',{type:'bribe',target:'d',amount:2});
|
||||||
|
assert.ok(game.turnActionPromises?.some(row=>!row.fulfilled && !row.released));
|
||||||
|
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:'staged-no',source,player:'a',asker:'e',turn:game.turn,
|
||||||
|
partner:'b',answer:false}];
|
||||||
|
return game;
|
||||||
|
}
|
||||||
|
|
||||||
|
void test('a live whole-turn promise does not expose a forbidden group-leave guide',()=>{
|
||||||
|
const game=fixture();
|
||||||
|
const guide=viewGame(game,'a').turnActionGuide;
|
||||||
|
assert.notEqual(guide?.action.type,'coalitionLeave');
|
||||||
|
if (guide) assert.doesNotThrow(()=>act(game,'a',guide.action));
|
||||||
|
});
|
||||||
@@ -147,7 +147,7 @@ void test('native missing reciprocal consent never permits a definite combined f
|
|||||||
});
|
});
|
||||||
|
|
||||||
void test('native known unaffordable payment with existing real consent offers No rather than treating finance as unknown consent', () => {
|
void test('native known unaffordable payment with existing real consent offers No rather than treating finance as unknown consent', () => {
|
||||||
const f = fixture(), mixed = group('and', partner(f.partner), payment(f.recipient, 11));
|
const f = fixture(), mixed = group('and', partner(f.partner), payment(f.recipient, 13));
|
||||||
const asked = ask(declare(f).game, f.asker, f.target, mixed);
|
const asked = ask(declare(f).game, f.asker, f.target, mixed);
|
||||||
assert.equal(asked.allianceOffers[f.partner], f.target); assert.equal(player(asked, f.target).spice, 10);
|
assert.equal(asked.allianceOffers[f.partner], f.target); assert.equal(player(asked, f.target).spice, 10);
|
||||||
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']);
|
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']);
|
||||||
@@ -156,7 +156,7 @@ void test('native known unaffordable payment with existing real consent offers N
|
|||||||
});
|
});
|
||||||
|
|
||||||
void test('native missing consent cannot conceal a known unaffordable whole payment branch as Unknown', () => {
|
void test('native missing consent cannot conceal a known unaffordable whole payment branch as Unknown', () => {
|
||||||
const f = fixture({ consent: 'none' }), mixed = group('and', partner(f.partner), payment(f.recipient, 11));
|
const f = fixture({ consent: 'none' }), mixed = group('and', partner(f.partner), payment(f.recipient, 13));
|
||||||
const game = ask(declare(f).game, f.asker, f.target, mixed);
|
const game = ask(declare(f).game, f.asker, f.target, mixed);
|
||||||
assert.equal(player(game, f.target).spice, 10);
|
assert.equal(player(game, f.target).spice, 10);
|
||||||
assert.deepEqual(viewGame(game, f.target).truthTurnActionAnswers, ['no']);
|
assert.deepEqual(viewGame(game, f.target).truthTurnActionAnswers, ['no']);
|
||||||
@@ -166,6 +166,24 @@ void test('native missing consent cannot conceal a known unaffordable whole paym
|
|||||||
assert.deepEqual(inventory(game), f.originalCards);
|
assert.deepEqual(inventory(game), f.originalCards);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('native final-partner answer can fund eleven through a real payment and later Charity', () => {
|
||||||
|
const f = fixture(), mixed = group('and', partner(f.partner), payment(f.recipient, 11));
|
||||||
|
const asked = ask(declare(f).game, f.asker, f.target, mixed);
|
||||||
|
assert.ok(viewGame(asked, f.target).truthTurnActionAnswers?.includes('yes'));
|
||||||
|
let game = answer(asked, f.target, 'yes');
|
||||||
|
game = act(game, f.target, { type:'bribe', target:f.recipient, amount:10 });
|
||||||
|
assert.equal(player(game, f.target).spice, 0);
|
||||||
|
game = act(game, f.target, f.allianceAction);
|
||||||
|
game = close(game);
|
||||||
|
game = advance(game, state => state.phase === 2 && clean(state));
|
||||||
|
game = act(game, f.target, { type:'charity' });
|
||||||
|
assert.equal(player(game, f.target).spice, 2);
|
||||||
|
game = act(game, f.target, { type:'bribe', target:f.recipient, amount:1 });
|
||||||
|
assert.equal(player(game, f.recipient).bribes, 11);
|
||||||
|
assert.equal(promise(game, f.target).fulfilled, true);
|
||||||
|
physical(game, f);
|
||||||
|
});
|
||||||
|
|
||||||
void test('native payment after own Nexus Ready uniformly reopens readiness and preserves a required later break', () => {
|
void test('native payment after own Nexus Ready uniformly reopens readiness and preserves a required later break', () => {
|
||||||
const f = fixture({ consent: 'allied' }), mixed = group('and', partner(f.partner), payment(f.recipient));
|
const f = fixture({ consent: 'allied' }), mixed = group('and', partner(f.partner), payment(f.recipient));
|
||||||
let game = bind(f, 'no', mixed).game;
|
let game = bind(f, 'no', mixed).game;
|
||||||
|
|||||||
Reference in new issue
Block a user