Honor group alliances in Truthtrance
Verify and publish checkpoint container / container (push) Successful in 11m22s
Verify and publish checkpoint container / container (push) Successful in 11m22s
This commit is contained in:
1 parent
9cb65a21f5
commit
3e0fa2a8a2
11 files changed
+592
-24
No files matched your search
@@ -2,15 +2,25 @@
|
||||
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { allianceClaimText, alliancePromiseModeSupported } from '@/game/alliance-promises';
|
||||
import { evaluateAllianceExpression } from '@/game/alliance-expression-promises';
|
||||
import { evaluateAllianceExpression, type AllianceExpression } from '@/game/alliance-expression-promises';
|
||||
import { coalitionAllies, coalitionOf } from '@/game/coalition-alliance';
|
||||
import { quoteNexusAlliance } from '@/game/nexus-alliance';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
type AlliancePromiseProps = { game: GameView; busy: boolean; send: (action: Action) => void };
|
||||
const publicContext = (game: GameView) => ({
|
||||
players: game.players.map(player => ({ id: player.id, ally: player.ally })), offers: game.allianceOffers,
|
||||
players: game.players.map(player => ({ id: player.id, ally: player.ally, allySinceTurn: player.allySinceTurn })),
|
||||
offers: game.allianceOffers, coalitionState: game.coalitionState,
|
||||
});
|
||||
|
||||
/** Group membership is public. Evaluate only pure expressions here: mixed
|
||||
* questions retain their privately compiled facts on the server. */
|
||||
function expressionMatchesMembers(expression: AllianceExpression, allies: readonly string[]): boolean {
|
||||
if (expression.kind === 'partner') return allies.includes(expression.partner);
|
||||
return expression.kind === 'and' ? expression.terms.every(term => expressionMatchesMembers(term, allies))
|
||||
: expression.terms.some(term => expressionMatchesMembers(term, allies));
|
||||
}
|
||||
|
||||
/** A guide may end a pair, accept real consent or confirm current membership.
|
||||
* It never sends a proposal on another player's behalf. */
|
||||
export function AlliancePromiseGuideButton(props: AlliancePromiseProps) {
|
||||
@@ -29,15 +39,27 @@ export function AlliancePromiseGuideButton(props: AlliancePromiseProps) {
|
||||
!promise.fulfilled && !promise.released);
|
||||
// Pure membership is visible. The server alone knows a mixed question's
|
||||
// frozen facts and reduced future obligation; never reconstruct it here.
|
||||
const matches = (ally: string | null) => live.every(promise => (ally === promise.partner) === promise.answer) &&
|
||||
pure.every(promise => evaluateAllianceExpression(promise.claim, ally) === promise.answer);
|
||||
let ownedAction = action.type === 'ready' && !!me && matches(me.ally);
|
||||
const matches = (allies: readonly string[]) => live.every(promise => allies.includes(promise.partner) === promise.answer) &&
|
||||
pure.every(promise => (allies.length <= 1 ? evaluateAllianceExpression(promise.claim, allies[0] ?? null)
|
||||
: expressionMatchesMembers(promise.claim, allies)) === promise.answer);
|
||||
let group: ReturnType<typeof coalitionOf> = null, allies = me?.ally ? [me.ally] : [];
|
||||
try {
|
||||
if (current.coalitionState && me) {
|
||||
group = coalitionOf(current.players, current.coalitionState, actor);
|
||||
allies = coalitionAllies(current.players, current.coalitionState, actor);
|
||||
}
|
||||
} catch { return false; }
|
||||
let ownedAction = action.type === 'ready' && !!me && matches(allies);
|
||||
if (action.type === 'coalitionLeave')
|
||||
ownedAction = !!group && group.members.length >= 3 && !!current.coalitionState &&
|
||||
current.coalitionState.proposal === null && action.expectedRevision === current.coalitionState.revision && matches([]);
|
||||
if (action.type === 'alliance') {
|
||||
if (action.target === null) ownedAction = true;
|
||||
if ((group?.members.length ?? 0) >= 3) ownedAction = false;
|
||||
else if (action.target === null) ownedAction = true;
|
||||
else if (typeof action.target === 'string' && action.target !== actor && current.players.some(player => player.id === action.target)) {
|
||||
try {
|
||||
const quote = quoteNexusAlliance({ ...publicContext(current), actor, target: action.target });
|
||||
ownedAction = !!quote.formed && matches(quote.allies[actor]);
|
||||
ownedAction = !!quote.formed && matches(quote.allies[actor] ? [quote.allies[actor]!] : []);
|
||||
} catch { ownedAction = false; }
|
||||
}
|
||||
}
|
||||
@@ -55,6 +77,7 @@ export function AlliancePromiseGuideButton(props: AlliancePromiseProps) {
|
||||
return <Button className="game-action min-h-11 whitespace-normal" data-testid="alliance-promise-guide"
|
||||
disabled={!canAct()} onClick={() => { if (canAct()) props.send(action); }}>
|
||||
{action.type === 'ready' ? 'Confirm Ready for this Nexus while honoring your answers'
|
||||
: action.type === 'coalitionLeave' ? 'Leave your group alliance while honoring your answers'
|
||||
: action.target === null ? me?.ally ? 'Break your current alliance while honoring your answers'
|
||||
: 'Withdraw your alliance offer while honoring your answers'
|
||||
: `Accept ${partner}’s alliance invitation while honoring your answers`}
|
||||
|
||||
+12
-1
@@ -17,6 +17,17 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Staged group Truthtrance and larger worm decisions — 29 October. Prototyped / Partial.**
|
||||
Alliance answers and expressions now read all group members rather than the
|
||||
single-partner field; the respondent's owned leave can satisfy a No. Bribe
|
||||
questions reject every current group ally. Group changes with live bribe or
|
||||
whole-turn commitments remain fenced. Larger worm response sequences have
|
||||
action-level tests with explicitly staged forces. Group lobby entry and complete
|
||||
interactions remain unfinished. Eighteen related files pass 185 cases,
|
||||
including the real-action group questions, rendered guides and staged worm
|
||||
responses; typecheck, lint and the production build pass.
|
||||
[Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-truthtrance-and-larger-worm-decisions--29-october-2026).
|
||||
|
||||
**Staged unlimited alliances — 29 October. Prototyped / Partial.** An explicit
|
||||
`coalitionState.variant` of `unlimited` permits up to six faction members;
|
||||
absence preserves the existing three-member prototype. Real staged actions
|
||||
@@ -24,7 +35,7 @@ cover four-member unanimous admission, three-donor funding, departure and
|
||||
rejoining, a three-stronghold win, Emperor aid, bot admission and actual table
|
||||
controls. Membership and selected faction helper cases cover up to six seats.
|
||||
Fourteen related files pass 120 cases, with an additional pair-target case;
|
||||
typecheck, lint and the production build pass. Live commitments, other profiles,
|
||||
typecheck, lint and the production build pass. Live bribe and whole-turn commitments, other profiles,
|
||||
full interactions and lobby entry remain unfinished.
|
||||
[Scope](OPTIONAL_ALLIANCE_VICTORY.md#unlimited-alliance-prototype--29-october-2026).
|
||||
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
# Optional three-stronghold alliance victory
|
||||
|
||||
## Group Truthtrance and larger worm decisions — 29 October 2026
|
||||
|
||||
**Staged Basic prototype, not a selectable game mode.** Final-Nexus
|
||||
Truthtrance questions now evaluate every member of a group, including grouped
|
||||
and mixed expressions. A respondent can stay when their answers match, or leave
|
||||
their group when that owned action satisfies them; the guide does not invent
|
||||
another player's consent. Another member may leave, with the existing
|
||||
reconciliation rules releasing an answer only if it becomes impossible. Bribe
|
||||
questions reject every current group ally as a recipient. Whole-turn alliance
|
||||
leaves, group changes with live bribe or whole-turn commitments, and wider
|
||||
combinations remain unfinished.
|
||||
|
||||
Action-level worm tests additionally exercise three and four threatened group
|
||||
allies in order, with separate allow, cancel or decline outcomes and the
|
||||
Fremen survival response. Those fixtures stage forces together to exercise the
|
||||
response sequence; they do not establish a naturally reachable full-game
|
||||
position. The membership, departure and multi-ally protection procedures remain
|
||||
provisional. Group games still cannot be started from the lobby.
|
||||
|
||||
## Unlimited alliance prototype — 29 October 2026
|
||||
|
||||
**Staged Basic prototype, not a selectable game mode.** The optional
|
||||
@@ -19,7 +38,7 @@ pairs any number of allies with three strongholds. Unanimous admission,
|
||||
departure handling, donor-specific escrow and extension of single-ally powers
|
||||
are provisional online procedures, not additional publisher rulings. Tests use
|
||||
an explicitly staged Basic classic-faction ledger, not a normal lobby start.
|
||||
Advanced, expansions, optional modules, live Truthtrance commitments and
|
||||
Advanced, expansions, optional modules, live bribe and whole-turn commitments and
|
||||
complete group interactions remain unfinished. No lobby creates this ledger;
|
||||
the prototype does not certify or open a complete rules mode.
|
||||
|
||||
@@ -193,7 +212,7 @@ The staged table renders the group controls and minimal bots can answer.
|
||||
|
||||
Unanimity, cancellation, the pending-Nexus hold and the surviving pair are
|
||||
provisional online procedures, not publisher rulings. This scope excludes
|
||||
expansion decks/factions, Advanced, optional modules, live Truthtrance
|
||||
expansion decks/factions, Advanced, optional modules, live bribe or whole-turn
|
||||
commitments and pledged aid. No lobby creates the group ledger: faction ally
|
||||
benefits, group funding and other interactions must be connected before any
|
||||
player can start the printed larger-alliance option. The later unlimited
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# Rule decision index
|
||||
|
||||
## Group Truthtrance and larger worm decisions — 29 October 2026
|
||||
|
||||
Staged Basic group alliance answers use actual group membership, with a
|
||||
stay-or-owned-leave witness and no assumed outside consent. Group bribe
|
||||
questions exclude each current ally. The multi-ally worm order and separate
|
||||
responses remain provisional. [Scope](OPTIONAL_ALLIANCE_VICTORY.md#group-truthtrance-and-larger-worm-decisions--29-october-2026).
|
||||
|
||||
## Unlimited alliances — 29 October 2026
|
||||
|
||||
The staged Basic prototype models the publisher's any-size alliance and
|
||||
|
||||
+45
-6
@@ -47,7 +47,7 @@ import { TravelPromiseError, liveTravelPromises, travelBranches, recordTravelShi
|
||||
import { mixedTraitorRequirement, type MixedTraitorClaim } from './mixed-traitor-question';
|
||||
import type { MixedBattleActionClaim, MixedBattleClaim } from './mixed-battle-question';
|
||||
import { AllianceExpressionPromiseError, allianceExpressionOutcomes, findAllianceExpressionPlans,
|
||||
allianceExpressionPromisesMatch, evaluateAllianceExpression, validateAllianceExpressionPromises,
|
||||
allianceExpressionPromisesMatch, validateAllianceExpressionPromises,
|
||||
type AllianceExpression, type AllianceExpressionClaim, type AllianceExpressionPromise } from './alliance-expression-promises';
|
||||
import type { AllianceActionClaim, MixedAllianceClaim } from './mixed-alliance-question';
|
||||
import { mixedBidMinimum, type MixedBidClaim } from './mixed-bid-question';
|
||||
@@ -16049,8 +16049,7 @@ function coalitionNexusAvailable(g: Game): boolean {
|
||||
g.expansions.length === 0 && g.players.every(seat =>
|
||||
['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(seat.faction)) &&
|
||||
Object.values(g.aid).every(credit => credit.amount === 0) &&
|
||||
![g.alliancePromises, g.allianceExpressionPromises, g.bribePromises,
|
||||
g.bribeExpressionPromises, g.turnActionPromises].some(unsettledPromises);
|
||||
![g.bribePromises, g.bribeExpressionPromises, g.turnActionPromises].some(unsettledPromises);
|
||||
}
|
||||
function coalitionRule<T>(work: () => T): T {
|
||||
try { return work(); }
|
||||
@@ -16139,6 +16138,12 @@ function coalitionBidPaymentQuote(g: Game, p: Player, cost: number, contribution
|
||||
}
|
||||
/** The original native reciprocal quote owns identity and pairing legality. */
|
||||
function normalNexusAllianceQuote(g: Game, p: Player, target: string | null) {
|
||||
if (g.coalitionState) {
|
||||
const own = coalitionRule(() => coalitionOf(g.players, g.coalitionState, p.id));
|
||||
const other = target ? coalitionRule(() => coalitionOf(g.players, g.coalitionState, target)) : null;
|
||||
requireRule(!(own && own.members.length >= 3) && !(other && other.members.length >= 3),
|
||||
'Group members must use the group alliance controls.');
|
||||
}
|
||||
if (target !== null) {
|
||||
const other = getPlayer(g, target);
|
||||
requireRule(other.id !== p.id, 'Choose another player.');
|
||||
@@ -16167,10 +16172,45 @@ function normalNexusAllianceQuote(g: Game, p: Player, target: string | null) {
|
||||
return nexusRule(() => quoteNexusAlliance({ players: g.players.map(player => ({ id: player.id, ally: player.ally })),
|
||||
offers: g.allianceOffers, actor: p.id, target }));
|
||||
}
|
||||
/** A larger alliance has multiple allies, so final-Nexus answers must read
|
||||
* membership rather than the legacy single-partner scalar. */
|
||||
function allianceMember(g: Game, player: Player, partner: string): boolean {
|
||||
return g.coalitionState
|
||||
? coalitionRule(() => coalitionAllies(g.players, g.coalitionState, player.id)).includes(partner)
|
||||
: player.ally === partner;
|
||||
}
|
||||
function allianceExpressionValue(g: Game, player: Player, expression: AllianceExpression): boolean {
|
||||
if (expression.kind === 'partner') return allianceMember(g, player, expression.partner);
|
||||
return expression.kind === 'and'
|
||||
? expression.terms.every(term => allianceExpressionValue(g, player, term))
|
||||
: expression.terms.some(term => allianceExpressionValue(g, player, term));
|
||||
}
|
||||
function groupAllianceAnswersMatch(g: Game, player: Player, opportunity: AllianceOpportunity,
|
||||
promises: readonly AlliancePromise[], expressions: readonly AllianceExpressionPromise[]): boolean {
|
||||
const live = (row: AllianceCommitment) => row.player === player.id && row.turn === opportunity.turn &&
|
||||
row.source === opportunity.source && !row.fulfilled && !row.released;
|
||||
return promises.filter(live).every(row => allianceMember(g, player, row.partner) === row.answer) &&
|
||||
expressions.filter(live).every(row => allianceExpressionValue(g, player, row.claim) === row.answer);
|
||||
}
|
||||
function alliancePromisePlan(g: Game, p: Player, promises = g.alliancePromises ?? [],
|
||||
expressions = g.allianceExpressionPromises ?? []) {
|
||||
const opportunity = allianceOpportunity(g);
|
||||
if (!opportunity) return null;
|
||||
const group = g.coalitionState && coalitionRule(() => coalitionOf(g.players, g.coalitionState, p.id));
|
||||
if (group && group.members.length >= 3) {
|
||||
if (groupAllianceAnswersMatch(g, p, opportunity, promises, expressions))
|
||||
return {actions: [] as Action[], bribePreparation:false};
|
||||
if (g.coalitionState!.proposal || [g.bribePromises, g.bribeExpressionPromises, g.turnActionPromises].some(unsettledPromises))
|
||||
return null;
|
||||
const trial = structuredClone(g);
|
||||
try {
|
||||
applyCoalitionTransition(trial, coalitionRule(() => leaveCoalition(trial.players, trial.coalitionState,
|
||||
{actor:p.id, expectedRevision:trial.coalitionState!.revision})));
|
||||
if (groupAllianceAnswersMatch(trial, getPlayer(trial,p.id), opportunity, promises, expressions))
|
||||
return {actions: [{type:'coalitionLeave', expectedRevision:g.coalitionState!.revision} as Action], bribePreparation:false};
|
||||
} catch (error) { if (!(error instanceof RuleError)) throw error; }
|
||||
return null;
|
||||
}
|
||||
const plans = findAllianceExpressionPlans(promises, expressions, p.id, opportunity,
|
||||
{ players: g.players, offers: g.allianceOffers });
|
||||
// Try every consent-supported final outcome. A financial restriction on one
|
||||
@@ -16336,7 +16376,7 @@ function reconcileAlliancePromises(g: Game, voluntary?: { actor: string; action:
|
||||
if (promise.fulfilled || promise.released) continue;
|
||||
const same = opportunity && promise.turn === opportunity.turn && promise.source === opportunity.source;
|
||||
const player = getPlayer(g, promise.player), whole = 'claim' in promise;
|
||||
const matches = (whole ? evaluateAllianceExpression(promise.claim, player.ally) : player.ally === promise.partner) === promise.answer;
|
||||
const matches = (whole ? allianceExpressionValue(g, player, promise.claim) : allianceMember(g, player, promise.partner)) === promise.answer;
|
||||
const scalars = whole ? kept : [...kept, promise], groups = whole ? [...expressions, promise] : expressions;
|
||||
const possible = same && alliancePromisePlan(g, player, scalars, groups);
|
||||
if (!same && matches) { promise.fulfilled = true; continue; }
|
||||
@@ -16353,8 +16393,7 @@ function reconcileAlliancePromises(g: Game, voluntary?: { actor: string; action:
|
||||
function guardAlliancePromiseReady(g: Game, p: Player) {
|
||||
const opportunity = allianceOpportunity(g);
|
||||
if (!opportunity) return;
|
||||
requireRule(alliancePromisesMatch(g.alliancePromises ?? [], p.id, opportunity, p.ally) &&
|
||||
allianceExpressionPromisesMatch(g.allianceExpressionPromises ?? [], p.id, opportunity, p.ally),
|
||||
requireRule(groupAllianceAnswersMatch(g, p, opportunity, g.alliancePromises ?? [], g.allianceExpressionPromises ?? []),
|
||||
'Match your Truthtrance alliance answers before finishing this Nexus.');
|
||||
if (liveTurnActionPromises(g.turnActionPromises ?? [], p.id, g.turn)
|
||||
.some(promise => promise.alliance?.source === opportunity.source && !promise.alliance.closed))
|
||||
|
||||
+1
-1
@@ -4505,7 +4505,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'The lobby offers the pair option. A staged group Emperor can choose either ally for spice and extra revival; group games cannot yet be started from the lobby.', evidence: ['components/game-table.tsx', 'components/coalition-emperor-aid.tsx', 'tests/short-alliance-victory-controls.test.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Legal alliance and victory paths use the shared target; staged group Emperor bots can offer a targeted revival. Strategic tuning remains deferred.', evidence: ['game/bots.ts', 'game/victory-progress.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'The printed option and provisional two-controller precedence are recorded.', evidence: ['docs/OPTIONAL_ALLIANCE_VICTORY.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Focused pair and staged group evidence includes real Nexus actions, four-member unlimited admission, funding and victory, staged larger-group faction helpers, funding after settled Truthtrance records, Guild transport, Emperor and Fremen aid, worm protection, battle preparation and allied traitor votes; complete group play and combinations remain open.', evidence: ['tests/alliance-victory-option.test.ts', 'tests/short-alliance-victory-runtime.test.ts', 'tests/coalition-unlimited-engine.test.ts', 'tests/coalition-unlimited-powers.test.ts', 'tests/coalition-unlimited-emperor.test.ts', 'tests/coalition-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, four-member unlimited admission, funding and victory, staged larger-group faction helpers and alliance Truthtrance, funding after settled Truthtrance records, Guild transport, Emperor and Fremen aid, worm protection, battle preparation and allied traitor votes; complete group play and combinations remain open.', evidence: ['tests/alliance-victory-option.test.ts', 'tests/short-alliance-victory-runtime.test.ts', 'tests/coalition-unlimited-engine.test.ts', 'tests/coalition-unlimited-powers.test.ts', 'tests/coalition-unlimited-emperor.test.ts', 'tests/coalition-truth-alliance.test.ts', 'tests/coalition-truth-alliance-controls.test.tsx', 'tests/coalition-truth-bribe-targets.test.ts', 'tests/coalition-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',
|
||||
|
||||
+13
-7
@@ -62,6 +62,7 @@ import { leaders } from './cards';
|
||||
import { combatLocationName } from './combat-location';
|
||||
import { parseForceCountFact, forceCountFactMatches, forceCountFactText, forceCountCounterError, type ForceCountFact, type ForceCountPlayer } from './truthtrance-force-count';
|
||||
import type { Action, Game, Player } from './engine';
|
||||
import { coalitionAllies } from './coalition-alliance';
|
||||
import { TRUTH_CARD_NAMES } from './truthtrance-card-names';
|
||||
import { CHEAP_HERO_TRAITOR } from './traitors';
|
||||
import {
|
||||
@@ -258,6 +259,11 @@ function battleLeaderIdentities(g: Game) {
|
||||
...(g.dukeVidal?.leader.id === DUKE_VIDAL_ID ? [g.dukeVidal.leader] : []),
|
||||
];
|
||||
}
|
||||
function nonAlliedBribeRecipient(g: Game, respondent: Player, recipient: string): boolean {
|
||||
return recipient !== respondent.id && recipient !== respondent.ally &&
|
||||
g.players.some(player => player.id === recipient) &&
|
||||
(!g.coalitionState || !coalitionAllies(g.players, g.coalitionState, respondent.id).includes(recipient));
|
||||
}
|
||||
function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
const v = record(value);
|
||||
check(
|
||||
@@ -341,8 +347,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
const respondent = g.players.find(player => player.id === v.target)!;
|
||||
const mixed = parseMixedActionExpression(v.mixed, fact => parseFact(g, fact, respondent), input => {
|
||||
const action = parseBribePaymentClaim(input);
|
||||
check(action.recipient !== respondent.id && action.recipient !== respondent.ally &&
|
||||
g.players.some(player => player.id === action.recipient), 'Choose another current non-allied ordinary bribe recipient.');
|
||||
check(nonAlliedBribeRecipient(g, respondent, action.recipient), 'Choose another current non-allied ordinary bribe recipient.');
|
||||
return action;
|
||||
});
|
||||
return { kind: 'mixedBribe', target: v.target as string, turn: g.turn, mixed };
|
||||
@@ -354,7 +359,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
const respondent = g.players.find(player => player.id === v.target)!;
|
||||
check(bribePromiseModeSupported(g) && g.status === 'playing' && g.phase >= 0 && g.phase <= 8 &&
|
||||
claim.turn === g.turn && bribeExpressionRecipients(claim.claim).every(recipient =>
|
||||
recipient !== respondent.id && recipient !== respondent.ally && g.players.some(player => player.id === recipient)) &&
|
||||
nonAlliedBribeRecipient(g, respondent, recipient)) &&
|
||||
!g.response && !g.decision && !g.phaseOpening && !g.pendingTreacheryDiscard && !g.pendingKarama,
|
||||
'Choose current-turn ordinary bribe statements to non-allied participants in a supported table.');
|
||||
return { kind: 'bribeExpression', target: v.target, ...claim };
|
||||
@@ -364,8 +369,8 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
'Supply the current turn, named bribe recipient, minimum and respondent.');
|
||||
const claim = parseBribeClaim(v), respondent = g.players.find(player => player.id === v.target)!;
|
||||
check(bribePromiseModeSupported(g) && g.status === 'playing' && g.phase >= 0 && g.phase <= 8 &&
|
||||
claim.turn === g.turn && claim.recipient !== respondent.id && claim.recipient !== respondent.ally &&
|
||||
g.players.some(player => player.id === claim.recipient) && !g.response && !g.decision && !g.phaseOpening &&
|
||||
claim.turn === g.turn && nonAlliedBribeRecipient(g, respondent, claim.recipient) &&
|
||||
!g.response && !g.decision && !g.phaseOpening &&
|
||||
!g.pendingTreacheryDiscard && !g.pendingKarama,
|
||||
'Choose a current-turn ordinary bribe to another non-allied participant in a supported table.');
|
||||
return { kind: 'bribe', target: v.target, ...claim };
|
||||
@@ -482,8 +487,8 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
'Choose the original owned native Voice or Prescience declaration opportunity.');
|
||||
}
|
||||
}
|
||||
if (action.kind === 'payment') check(action.recipient !== respondent.id && action.recipient !== respondent.ally &&
|
||||
g.players.some(player => player.id === action.recipient), 'Choose another current non-allied ordinary bribe recipient.');
|
||||
if (action.kind === 'payment') check(nonAlliedBribeRecipient(g, respondent, action.recipient),
|
||||
'Choose another current non-allied ordinary bribe recipient.');
|
||||
if (action.kind === 'bid') check(bidPromiseModeSupported(g) && g.phase === 3 && g.auction &&
|
||||
!g.currentAuctionSale && !g.richeseAuction && !g.ixAuction && action.lot === g.auction.index + 1,
|
||||
'Choose the current ordinary bid lot in a supported native auction.');
|
||||
@@ -491,6 +496,7 @@ function parseQuestion(g: Game, asker: string, value: unknown): TruthQuestion {
|
||||
action.leader === 'kwisatz' && g.advanced && respondent.faction === 'atreides',
|
||||
'Choose a personal native faction leader, or the Advanced Atreides Kwisatz Haderach.');
|
||||
if (action.kind === 'alliance') check(alliancePromiseModeSupported(g) && g.phase === 1 && g.nexus &&
|
||||
!g.coalitionState?.groups.some(group => group.members.length >= 3) &&
|
||||
!g.spiceWindow && !g.spiceResolution && action.partner !== respondent.id &&
|
||||
g.players.some(player => player.id === action.partner),
|
||||
'Choose another seated final alliance partner in the current quiet native Nexus.');
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { AlliancePromiseGuideButton } from '../components/alliance-promises';
|
||||
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
|
||||
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game, type GameView } from '../game/engine';
|
||||
|
||||
const send = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
|
||||
|
||||
/** Real public pair/group formation with controlled promise and guide
|
||||
* projections. These cases exercise client dispatch and stale guards. */
|
||||
function projected(kind: 'yes' | 'no' | 'expression' | 'pair'): GameView {
|
||||
let game = createGame('GROUPTRUTHCONTROLS', newPlayer('a', 'Atreides', 'atreides'), false);
|
||||
game.players.push(newPlayer('b', 'Emperor', 'emperor'), newPlayer('c', 'Fremen', 'fremen'),
|
||||
newPlayer('d', 'Harkonnen', 'harkonnen'));
|
||||
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:['a','b','c','d'], ready:[]});
|
||||
game.coalitionState = emptyCoalitionState('unlimited');
|
||||
game = send(game, 'a', {type:'alliance', target:'b'});
|
||||
game = send(game, 'b', {type:'alliance', target:'a'});
|
||||
if (kind !== 'pair') {
|
||||
const original = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(original);
|
||||
game = send(game, 'a', {type:'coalitionPropose', groupId:original.id, entrant:'c',
|
||||
expectedRevision:game.coalitionState!.revision});
|
||||
for (const actor of ['b','c']) {
|
||||
const offer = game.coalitionState!.proposal!;
|
||||
game = send(game, actor, {type:'coalitionAnswer', event:offer.event, nexus:offer.nexus,
|
||||
turn:offer.turn, accept:true, expectedRevision:game.coalitionState!.revision});
|
||||
}
|
||||
}
|
||||
const view = viewGame(game, 'a');
|
||||
view.alliancePromiseNexus = {turn:2};
|
||||
view.alliancePromises = kind === 'expression' ? [] : [{event:'controlled', player:'a', asker:'d',
|
||||
turn:2, partner:'b', answer:kind !== 'no'}];
|
||||
view.allianceExpressionPromises = kind !== 'expression' ? [] : [{event:'controlled', player:'a', asker:'d',
|
||||
turn:2, claim:{kind:'and', terms:[{kind:'partner',partner:'b'}, {kind:'partner',partner:'c'}]}, answer:true}];
|
||||
view.alliancePromiseGuide = {action:kind === 'no'
|
||||
? {type:'coalitionLeave', expectedRevision:view.coalitionState!.revision, alliancePromiseEvent:'controlled'}
|
||||
: {type:'ready', alliancePromiseEvent:'controlled'}};
|
||||
return view;
|
||||
}
|
||||
|
||||
function button(game: GameView, sent: Action[], busy = false) {
|
||||
const result = AlliancePromiseGuideButton({game, busy, send(action) { sent.push(action); }});
|
||||
assert.ok(result); return result;
|
||||
}
|
||||
|
||||
void test('group Yes and whole-AND guides render an enabled Ready action', () => {
|
||||
for (const kind of ['yes', 'expression'] as const) {
|
||||
const game = projected(kind), sent:Action[] = [];
|
||||
const control = button(game, sent);
|
||||
assert.equal(control.props.disabled, false);
|
||||
assert.match(renderToStaticMarkup(createElement(AlliancePromiseGuideButton, {game, busy:false, send() {}})),
|
||||
/Confirm Ready for this Nexus/);
|
||||
control.props.onClick();
|
||||
assert.deepEqual(sent, [game.alliancePromiseGuide!.action]);
|
||||
}
|
||||
});
|
||||
|
||||
void test('group No guide renders and dispatches its exact event-bound leave action', () => {
|
||||
const game = projected('no'), sent:Action[] = [];
|
||||
const control = button(game, sent);
|
||||
assert.equal(control.props.disabled, false);
|
||||
assert.match(renderToStaticMarkup(createElement(AlliancePromiseGuideButton, {game, busy:false, send() {}})),
|
||||
/Leave your group alliance while honoring your answers/);
|
||||
control.props.onClick();
|
||||
assert.deepEqual(sent, [game.alliancePromiseGuide!.action]);
|
||||
});
|
||||
|
||||
void test('group guide refuses stale revision, membership, event, and quiet-window changes', () => {
|
||||
const mutations: ((game: GameView) => void)[] = [
|
||||
game => { game.coalitionState!.revision += 1; },
|
||||
game => { game.coalitionState!.groups[0].members.pop(); },
|
||||
game => { game.players = game.players.filter(player => player.id !== 'b'); },
|
||||
game => { game.alliancePromises[0].event = 'changed'; },
|
||||
game => { game.alliancePromiseGuide!.action.expectedRevision = -1; },
|
||||
game => { game.ready.push('a'); },
|
||||
game => { game.nexus = false; },
|
||||
game => { game.automaticContinuationPending = true; },
|
||||
];
|
||||
for (const mutate of mutations) {
|
||||
const game = projected('no'), sent:Action[] = [], control = button(game, sent);
|
||||
mutate(game); control.props.onClick(); assert.deepEqual(sent, []);
|
||||
}
|
||||
});
|
||||
|
||||
void test('reciprocal pair guidance remains usable with a staged coalition ledger', () => {
|
||||
const game = projected('pair'), sent:Action[] = [];
|
||||
const control = button(game, sent);
|
||||
assert.equal(control.props.disabled, false); control.props.onClick();
|
||||
assert.deepEqual(sent, [game.alliancePromiseGuide!.action]);
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
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';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
|
||||
const send = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
|
||||
|
||||
/** The quiet Nexus and physical Truthtrance card are staged; every alliance,
|
||||
* invitation, question, answer and later group decision is a real action. */
|
||||
function group(): Game {
|
||||
let game = createGame('GROUPTRUTH', 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 = send(game, 'a', {type:'alliance', target:'b'});
|
||||
game = send(game, 'b', {type:'alliance', target:'a'});
|
||||
for (const entrant of ['c','d']) {
|
||||
const original = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(original);
|
||||
game = send(game, 'a', {type:'coalitionPropose', groupId:original.id, entrant,
|
||||
expectedRevision:game.coalitionState!.revision});
|
||||
for (const actor of [...original.members.filter(id => id !== 'a'), entrant]) {
|
||||
const offer = game.coalitionState!.proposal!;
|
||||
game = send(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);
|
||||
return game;
|
||||
}
|
||||
function openTruth(game: Game): Game {
|
||||
const card = game.players.find(player => player.id === 'e')!.hand.find(row => row.effect === 'truthtrance')!;
|
||||
game = send(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 = send(game, actor.id, {type:'truthPass'});
|
||||
}
|
||||
return game;
|
||||
}
|
||||
function ask(game: Game, partner: string): Game {
|
||||
game = openTruth(game);
|
||||
return send(game, 'e', {type:'truthAsk', question:{kind:'alliance', target:'a', turn:game.turn, partner}});
|
||||
}
|
||||
function leave(game: Game, actor: string): Game {
|
||||
return send(game, actor, {type:'coalitionLeave', expectedRevision:game.coalitionState!.revision});
|
||||
}
|
||||
function rejected(game: Game, actor: string, action: Action) {
|
||||
const original = structuredClone(game);
|
||||
assert.throws(() => send(game, actor, action));
|
||||
assert.deepEqual(game, original);
|
||||
}
|
||||
|
||||
void test('a group member can truthfully answer Yes or No about an existing ally', () => {
|
||||
const game = ask(group(), 'b');
|
||||
const answers = viewGame(game, 'a').truthAllianceAnswers;
|
||||
assert.ok(answers?.includes('yes'), 'The respondent is already allied to the named partner.');
|
||||
assert.ok(answers?.includes('no'), 'The respondent can leave the alliance before the Nexus closes.');
|
||||
});
|
||||
|
||||
void test('a Yes preserves the named alliance while allowing an unrelated member to leave', () => {
|
||||
let game = ask(group(), 'b');
|
||||
game = send(game, 'a', {type:'truthAnswer', answer:'yes'});
|
||||
rejected(game, 'a', {type:'coalitionLeave', expectedRevision:game.coalitionState!.revision});
|
||||
game = leave(game, 'c');
|
||||
assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'a')!.members, ['a','b','d']);
|
||||
assert.equal(game.alliancePromises!.find(row => row.player === 'a')!.released ?? false, false);
|
||||
const guide = viewGame(game, 'a').alliancePromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'ready');
|
||||
game = send(game, 'a', guide.action);
|
||||
assert.ok(game.ready.includes('a'));
|
||||
});
|
||||
|
||||
void test('a named partner leaving externally releases the now-unfulfillable Yes', () => {
|
||||
let game = ask(group(), 'b');
|
||||
game = send(game, 'a', {type:'truthAnswer', answer:'yes'});
|
||||
game = leave(game, 'b');
|
||||
assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'a')!.members, ['a','c','d']);
|
||||
assert.equal(coalitionOf(game.players, game.coalitionState, 'b'), null);
|
||||
assert.equal(game.alliancePromises!.find(row => row.player === 'a')!.released, true);
|
||||
assert.equal(viewGame(game, 'a').alliancePromiseGuide, null);
|
||||
});
|
||||
|
||||
void test('a No requires the respondent to leave, with a legal bot guide', () => {
|
||||
let game = ask(group(), 'b');
|
||||
game = send(game, 'a', {type:'truthAnswer', answer:'no'});
|
||||
rejected(game, 'a', {type:'ready'});
|
||||
const guide = viewGame(game, 'a').alliancePromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, 'coalitionLeave');
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game, 'a');
|
||||
view.players.find(player => player.id === 'a')!.bot = difficulty;
|
||||
assert.deepEqual(botActions(view)[0], guide.action);
|
||||
assert.doesNotThrow(() => send(game, 'a', guide.action));
|
||||
}
|
||||
game = send(game, 'a', guide.action);
|
||||
assert.equal(coalitionOf(game.players, game.coalitionState, 'a'), null);
|
||||
assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'b')!.members, ['b','c','d']);
|
||||
game = send(game, 'a', {type:'ready'});
|
||||
assert.ok(game.ready.includes('a'));
|
||||
});
|
||||
|
||||
void test('an outsider cannot be promised as an ally without their consent', () => {
|
||||
const game = ask(group(), 'e');
|
||||
const answers = viewGame(game, 'a').truthAllianceAnswers;
|
||||
assert.ok(answers?.includes('no'));
|
||||
assert.ok(answers?.includes('unknown'));
|
||||
assert.ok(!answers?.includes('yes'));
|
||||
rejected(game, 'a', {type:'truthAnswer', answer:'yes'});
|
||||
});
|
||||
|
||||
const bothAllies = {kind:'and', terms:[{kind:'partner', partner:'b'}, {kind:'partner', partner:'c'}]};
|
||||
const factualAndAllies = {kind:'and', terms:[{kind:'fact', fact:{kind:'spice', compare:'gte', value:0}},
|
||||
{kind:'action', action:{partner:'b'}}, {kind:'action', action:{partner:'c'}}]};
|
||||
|
||||
for (const answer of ['yes', 'no'] as const) void test(`group alliance AND can answer ${answer} while evaluating both named allies`, () => {
|
||||
let game = openTruth(group());
|
||||
game = send(game, 'e', {type:'truthAsk', question:{kind:'allianceExpression', target:'a',
|
||||
turn:game.turn, claim:bothAllies}});
|
||||
const answers = viewGame(game, 'a').truthAllianceExpressionAnswers;
|
||||
assert.ok(answers?.includes('yes')); assert.ok(answers?.includes('no'));
|
||||
game = send(game, 'a', {type:'truthAnswer', answer});
|
||||
const guide = viewGame(game, 'a').alliancePromiseGuide; assert.ok(guide);
|
||||
if (answer === 'yes') {
|
||||
assert.equal(guide.action.type, 'ready');
|
||||
rejected(game, 'a', {type:'coalitionLeave', expectedRevision:game.coalitionState!.revision});
|
||||
game = send(game, 'a', guide.action);
|
||||
} else {
|
||||
rejected(game, 'a', {type:'ready'});
|
||||
assert.equal(guide.action.type, 'coalitionLeave');
|
||||
game = send(game, 'a', guide.action);
|
||||
assert.equal(coalitionOf(game.players, game.coalitionState, 'a'), null);
|
||||
game = send(game, 'a', {type:'ready'});
|
||||
}
|
||||
assert.ok(game.ready.includes('a'));
|
||||
});
|
||||
|
||||
for (const answer of ['yes', 'no'] as const) void test(`mixed factual and group-alliance question retains the ${answer} answer`, () => {
|
||||
let game = openTruth(group());
|
||||
game = send(game, 'e', {type:'truthAsk', question:{kind:'mixedAlliance', target:'a',
|
||||
turn:game.turn, mixed:factualAndAllies}});
|
||||
const answers = viewGame(game, 'a').truthMixedAllianceAnswers;
|
||||
assert.ok(answers?.includes('yes')); assert.ok(answers?.includes('no'));
|
||||
game = send(game, 'a', {type:'truthAnswer', answer});
|
||||
const guide = viewGame(game, 'a').alliancePromiseGuide; assert.ok(guide);
|
||||
assert.equal(guide.action.type, answer === 'yes' ? 'ready' : 'coalitionLeave');
|
||||
game = send(game, 'a', guide.action);
|
||||
if (answer === 'no') {
|
||||
assert.equal(coalitionOf(game.players, game.coalitionState, 'a'), null);
|
||||
game = send(game, 'a', {type:'ready'});
|
||||
}
|
||||
assert.ok(game.ready.includes('a'));
|
||||
});
|
||||
|
||||
void test('whole-turn alliance leaves remain fenced while a group is active', () => {
|
||||
const game = openTruth(group());
|
||||
rejected(game, 'e', {type:'truthAsk', question:{kind:'turnActions', target:'a', turn:game.turn,
|
||||
mixed:{kind:'action', action:{kind:'alliance', partner:'b'}}}});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
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, type Action, type Game } from '../game/engine';
|
||||
|
||||
const send = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
|
||||
|
||||
/** Stage the Nexus, spendable purse and physical card. The alliance and Truthtrance
|
||||
* priority are real actions; b and c are a's group allies, d and e outsiders. */
|
||||
function questionWindow(): Game {
|
||||
let game = createGame('GROUPBRIBETRUTH', 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 = 5;
|
||||
game = send(game, 'a', {type:'alliance', target:'b'});
|
||||
game = send(game, 'b', {type:'alliance', target:'a'});
|
||||
const group = coalitionOf(game.players, game.coalitionState, 'a'); assert.ok(group);
|
||||
game = send(game, 'a', {type:'coalitionPropose', groupId:group.id, entrant:'c',
|
||||
expectedRevision:game.coalitionState!.revision});
|
||||
for (const actor of ['b', 'c']) {
|
||||
const offer = game.coalitionState!.proposal!;
|
||||
game = send(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 = send(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 = send(game, actor.id, {type:'truthPass'});
|
||||
}
|
||||
return game;
|
||||
}
|
||||
|
||||
const fact = {kind:'fact', fact:{kind:'spice', compare:'gte', value:0}};
|
||||
function question(family: 'bribe' | 'bribeExpression' | 'mixedBribe' | 'turnActions', recipient: string) {
|
||||
const base = {kind:family, target:'a', turn:2};
|
||||
if (family === 'bribe') return {...base, recipient, minimum:1};
|
||||
if (family === 'bribeExpression') return {...base, claim:{kind:'and', terms:[
|
||||
{kind:'payment', recipient, minimum:1}, {kind:'payment', recipient:'d', minimum:1},
|
||||
]}};
|
||||
if (family === 'mixedBribe') return {...base, mixed:{kind:'or', terms:[fact,
|
||||
{kind:'action', action:{recipient, minimum:1}},
|
||||
]}};
|
||||
return {...base, mixed:{kind:'or', terms:[fact,
|
||||
{kind:'action', action:{kind:'payment', recipient, minimum:1}},
|
||||
]}};
|
||||
}
|
||||
|
||||
for (const family of ['bribe', 'bribeExpression', 'mixedBribe', 'turnActions'] as const)
|
||||
void test(`${family} question rejects an allied group payment and accepts an outsider`, () => {
|
||||
const game = questionWindow(), before = structuredClone(game);
|
||||
assert.throws(() => send(game, 'e', {type:'truthAsk', question:question(family, 'b')}));
|
||||
assert.deepEqual(game, before, 'A rejected allied-payment question retains its Truthtrance window.');
|
||||
const accepted = send(game, 'e', {type:'truthAsk', question:question(family, 'e')});
|
||||
assert.equal(accepted.truthtrance?.question?.kind, family);
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { baseDeck, spiceDeck } from '../game/cards';
|
||||
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
|
||||
const act = (game: Game, id: string, action: Action) => applyAction(game, id, action);
|
||||
const seat = (game: Game, id: string) => game.players.find(player => player.id === id)!;
|
||||
const location = 'red_chasm:7';
|
||||
|
||||
/** The unlimited ledger, Nexus and later Spice Blow are staged. All pair and
|
||||
* group admissions, the worm draw and its choices/responses are actual engine
|
||||
* actions. Physical forces and the actual Karama cards are conserved. */
|
||||
function source(entrantIds: readonly string[], fremenPresent = false) {
|
||||
let game = createGame('UNLIMITEDWORM', newPlayer('f', 'Fremen', 'fremen'), false);
|
||||
game.players.push(newPlayer('e', 'Emperor', 'emperor'), newPlayer('a', 'Atreides', 'atreides'),
|
||||
newPlayer('h', 'Harkonnen', 'harkonnen'), newPlayer('g', 'Guild', 'guild'),
|
||||
newPlayer('b', 'Bene Gesserit', 'beneGesserit'));
|
||||
Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true,
|
||||
order:['f','e','a','h','g','b'], ready:[], response:null, decision:null,
|
||||
phaseOpening:null, storm:18, deck:baseDeck()});
|
||||
game.coalitionState = emptyCoalitionState('unlimited');
|
||||
game = act(game, 'f', {type:'alliance', target:'e'});
|
||||
game = act(game, 'e', {type:'alliance', target:'f'});
|
||||
for (const entrant of entrantIds) {
|
||||
const group = coalitionOf(game.players, game.coalitionState, 'f'); assert.ok(group);
|
||||
game = act(game, 'f', {type:'coalitionPropose', groupId:group.id, entrant,
|
||||
expectedRevision:game.coalitionState!.revision});
|
||||
for (const id of [...group.members.filter(member => member !== 'f'), entrant]) {
|
||||
const proposal = game.coalitionState!.proposal!;
|
||||
game = act(game, id, {type:'coalitionAnswer', event:proposal.event, nexus:proposal.nexus,
|
||||
turn:proposal.turn, accept:true, expectedRevision:game.coalitionState!.revision});
|
||||
}
|
||||
}
|
||||
Object.assign(game, {phase:1, turn:3, nexus:false, ready:[], response:null, decision:null,
|
||||
phaseOpening:null, storm:18, active:null, stormPending:null});
|
||||
for (const player of game.players) {
|
||||
player.hand = []; player.forces = {}; player.reserves = 20; player.tanks = 0;
|
||||
player.traitors = []; player.traitorChoices = [];
|
||||
}
|
||||
const group = coalitionOf(game.players, game.coalitionState, 'f'); assert.ok(group);
|
||||
const outsider = game.players.find(player => !group.members.includes(player.id)); assert.ok(outsider);
|
||||
for (const id of [...group.members.filter(member => member !== 'f'), outsider.id,
|
||||
...(fremenPresent ? ['f'] : [])]) {
|
||||
seat(game,id).forces = {[location]:3}; seat(game,id).reserves = 17;
|
||||
}
|
||||
const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
|
||||
const [karama] = game.deck.splice(index,1); outsider.hand.push(karama);
|
||||
// Keep a distinct physical counter available for the later survival
|
||||
// response after the outsider spends the first Karama on one ally.
|
||||
let survivalKarama: typeof karama | undefined;
|
||||
if (fremenPresent) {
|
||||
const counter = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(counter >= 0);
|
||||
[survivalKarama] = game.deck.splice(counter,1);
|
||||
seat(game,'b').hand.push(survivalKarama);
|
||||
}
|
||||
const spice = spiceDeck();
|
||||
const land = spice.find(card => 'territory' in card && card.territory === 'red_chasm');
|
||||
const worm = spice.find(card => 'worm' in card); assert.ok(land && worm);
|
||||
game.spiceDiscard = [[land], []];
|
||||
game.spiceDeck = [worm, ...spice.filter(card => card !== land && card !== worm && 'territory' in card)];
|
||||
game.spice = {[location]:8};
|
||||
for (const player of game.players) game = act(game, player.id, {type:'ready'});
|
||||
assert.equal(game.decision?.kind,'wormProtection');
|
||||
return {game, karama, survivalKarama, outsider:outsider.id};
|
||||
}
|
||||
|
||||
function pass(game: Game, kind: string): Game {
|
||||
for (let count = 0; game.response?.kind === kind && count < 32; count++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id));
|
||||
assert.ok(actor); game = act(game, actor.id, {type:'passResponse'});
|
||||
}
|
||||
assert.notEqual(game.response?.kind, kind);
|
||||
return game;
|
||||
}
|
||||
function choose(game: Game, accept: boolean): Game {
|
||||
assert.equal(game.decision?.kind,'wormProtection'); assert.ok(game.decision.event);
|
||||
return act(game,'f',{type:'decision',accept,event:game.decision.event,ally:game.decision.ally});
|
||||
}
|
||||
function conserved(game: Game) {
|
||||
for (const player of game.players)
|
||||
assert.equal(player.reserves + player.tanks + Object.values(player.forces).reduce((a,b) => a+b,0),20);
|
||||
}
|
||||
|
||||
void test('four-member group resolves three distinct worm protections before Fremen survival', () => {
|
||||
const initial = source(['a','h'], true);
|
||||
let {game} = initial;
|
||||
assert.deepEqual(game.coalitionWormProtection?.order,['e','a','h']);
|
||||
assert.equal(game.decision?.kind,'wormProtection'); assert.equal(game.decision.ally,'e');
|
||||
game = pass(choose(game,true),'wormAllyProtection');
|
||||
assert.equal(game.decision?.kind,'wormProtection'); assert.equal(game.decision.ally,'a');
|
||||
game = choose(game,true);
|
||||
assert.equal(game.response?.kind,'wormAllyProtection'); assert.equal(game.response.recipient,'a');
|
||||
game = act(game, initial.outsider, {type:'card',mode:'cancel',card:initial.karama.id});
|
||||
game = pass(game,'wormAllyProtection');
|
||||
assert.equal(game.decision?.kind,'wormProtection'); assert.equal(game.decision.ally,'h');
|
||||
game = choose(game,false);
|
||||
assert.equal(game.response?.kind,'wormSurvival');
|
||||
assert.deepEqual(game.coalitionWormProtection?.outcomes,[
|
||||
{ally:'e',outcome:'allowed'}, {ally:'a',outcome:'canceled'}, {ally:'h',outcome:'declined'},
|
||||
]);
|
||||
game = pass(game,'wormSurvival');
|
||||
for (const id of ['e','f']) assert.equal(seat(game,id).forces[location],3);
|
||||
for (const id of ['a','h',initial.outsider]) {
|
||||
assert.equal(seat(game,id).forces[location] ?? 0,0); assert.equal(seat(game,id).tanks,3);
|
||||
}
|
||||
assert.equal(game.discard.filter(card => card.id === initial.karama.id).length,1);
|
||||
assert.ok(initial.survivalKarama && seat(game,'b').hand.some(card => card.id === initial.survivalKarama!.id));
|
||||
assert.equal(game.spice[location],undefined);
|
||||
assert.deepEqual(game.wormRides,['red_chasm']);
|
||||
assert.equal(game.coalitionWormProtection,undefined);
|
||||
conserved(game);
|
||||
});
|
||||
|
||||
void test('five-member group offers every threatened ally in order with legal bot choices', () => {
|
||||
const initial = source(['a','h','g']);
|
||||
let game = initial.game;
|
||||
const {outsider} = initial;
|
||||
assert.deepEqual(game.coalitionWormProtection?.order,['e','a','h','g']);
|
||||
const seen:string[] = [];
|
||||
let first:Action | undefined;
|
||||
while (game.decision?.kind === 'wormProtection') {
|
||||
const {ally,event} = game.decision; assert.ok(event); seen.push(ally);
|
||||
for (const difficulty of DIFFICULTIES) {
|
||||
const view = viewGame(game,'f');
|
||||
view.players.find(player => player.id === 'f')!.bot = difficulty;
|
||||
const action = botActions(view).find(candidate => candidate.type === 'decision' && typeof candidate.accept === 'boolean');
|
||||
assert.ok(action, difficulty); assert.equal(action.event,event); assert.equal(action.ally,ally);
|
||||
assert.doesNotThrow(() => act(game,'f',action));
|
||||
}
|
||||
if (first) {
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game,'f',first!)); assert.deepEqual(game,before);
|
||||
} else first = {type:'decision',accept:false,event,ally};
|
||||
game = choose(game,ally === 'g');
|
||||
game = pass(game,'wormAllyProtection');
|
||||
}
|
||||
assert.deepEqual(seen,['e','a','h','g']);
|
||||
assert.equal(seat(game,'g').forces[location],3);
|
||||
for (const id of ['e','a','h',outsider]) assert.equal(seat(game,id).tanks,3);
|
||||
assert.equal(game.spice[location],undefined);
|
||||
assert.equal(game.nexus,true);
|
||||
conserved(game);
|
||||
});
|
||||
Reference in new issue
Block a user