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
|
||||
|
||||
**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.**
|
||||
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
|
||||
blocked consent. Pending invitations preserve alliance answers. Admission
|
||||
was initially fenced when the older whole-turn planner could misread combined
|
||||
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
|
||||
the production build pass. Group funding with
|
||||
member commitments, whole-turn Nexus changes, other profiles and lobby entry
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# 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
|
||||
|
||||
**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
|
||||
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.
|
||||
Admission originally stayed fenced for a participant with both an alliance
|
||||
answer and an unresolved payment or travel-family answer when the older
|
||||
whole-turn planner was present; the later group-aware route above narrows
|
||||
that fence. These staged cases do not certify complete group play or expansion
|
||||
combinations.
|
||||
|
||||
## Group departures and unrelated commitments — 29 October 2026
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# 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
|
||||
|
||||
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}) =>
|
||||
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);
|
||||
@@ -16095,6 +16089,10 @@ function coalitionAdmissionAllowed(g: Game, actor: string, groupId: string, entr
|
||||
requireRule(!trial.coalitionState!.proposal, 'The proposed group did not finish.');
|
||||
trial.truthtrance = null;
|
||||
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)) &&
|
||||
(!alliances.some(unresolved) || !!alliancePromisePlan(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.
|
||||
* Exact outcome rows live only in this witness; no route is persisted. */
|
||||
type TurnActionAllianceRoute = { steps: { player: string; target: string | null }[]; uncertain: boolean };
|
||||
function turnActionAlliancePlans(g: Game, actor: string, includeUnknown = false): (TurnActionAllianceRoute | undefined)[] {
|
||||
type TurnActionAllianceRoute = { steps: ({ player: string; target: string | null; kind?: 'pair' } |
|
||||
{ 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 active = [...scalars, ...groups].some(promise => promise.player === actor &&
|
||||
promise.turn === g.turn && !promise.fulfilled && !promise.released);
|
||||
@@ -21673,6 +21673,21 @@ function turnActionAlliancePlans(g: Game, actor: string, includeUnknown = false)
|
||||
if (!opportunity) return [];
|
||||
if ([...scalars, ...groups].some(promise => promise.player === actor && promise.turn === g.turn &&
|
||||
!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,
|
||||
{ players: g.players, offers: g.allianceOffers });
|
||||
const plans: TurnActionAllianceRoute[] = [], seen = new Set<string>();
|
||||
@@ -21964,7 +21979,8 @@ function turnActionCompletionAfterFreshDiscard(source: Game, actor: string, unkn
|
||||
const power = turnActionPowerCompletion(trial, actor,
|
||||
leaves.filter((leaf): leaf is TurnActionPowerRequirement => leaf.kind === 'battlePower'));
|
||||
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(
|
||||
trial.bribePromises!, trial.bribeExpressionPromises ?? [], actor, source.turn))) {
|
||||
// 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;
|
||||
const total = required.reduce((sum, payment) => sum + payment.amount, 0);
|
||||
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;
|
||||
for (const alliancePlan of alliancePlans) {
|
||||
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) => {
|
||||
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);
|
||||
for (const player of paid.players) player.ally = quote.allies[player.id];
|
||||
paid.allianceOffers = quote.offers;
|
||||
@@ -22000,7 +22032,7 @@ function turnActionCompletionAfterFreshDiscard(source: Game, actor: string, unkn
|
||||
}
|
||||
};
|
||||
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)) {
|
||||
const funded = cardSeed.game, payer = getPlayer(funded, actor);
|
||||
try {
|
||||
@@ -22051,7 +22083,9 @@ function turnActionCompletionAfterFreshDiscard(source: Game, actor: string, unkn
|
||||
...(cardSeed.cardsFirst ? { paymentsAfterPreparations: true } : {}), ...(bid ? { bid } : {}), ...(revival ? { revival } : {}),
|
||||
...(battlePlan ? { battlePlan } : {}), ...(traitor ? { traitor } : {}),
|
||||
...(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 };
|
||||
}
|
||||
} 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.');
|
||||
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);
|
||||
if (nexus && action?.type === 'bribe' && g.turnActionPromises!.some(promise =>
|
||||
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: '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, 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: [
|
||||
'mentat',
|
||||
|
||||
@@ -43,24 +43,17 @@ function commitmentsLive(game: Game) {
|
||||
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();
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, 'a', proposal(game)));
|
||||
assert.deepEqual(game, before);
|
||||
game = act(game, 'a', proposal(game));
|
||||
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));
|
||||
game = act(game, 'b', answer(game, true));
|
||||
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);
|
||||
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', () => {
|
||||
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);
|
||||
assert.equal(asked.allianceOffers[f.partner], f.target); assert.equal(player(asked, f.target).spice, 10);
|
||||
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', () => {
|
||||
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);
|
||||
assert.equal(player(game, f.target).spice, 10);
|
||||
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);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
const f = fixture({ consent: 'allied' }), mixed = group('and', partner(f.partner), payment(f.recipient));
|
||||
let game = bind(f, 'no', mixed).game;
|
||||
|
||||
Reference in new issue
Block a user