Allow early Advanced Truthtrance shipment promises

This commit is contained in:
Quintine committed 2026-10-26 05:57:10 +11:00
1 parent 99d442fc68
commit 8053e1fb26
13 files changed
+926 -12

No files matched your search

+5 -4
View File
@@ -384,13 +384,14 @@ export function Truthtrance({
before delivery. A stopped shipment can release an
impossible answer; an unfinished response does not fulfill it.
</p>
{g.phase < 5 && <p className="notice">Your shipment comes later this turn. Answers use the current board and preparations you can make yourself. A change outside your control can release an answer that becomes impossible. Keep playing the current phase; shipping waits for your turn.</p>}
{!shipmentAvailable && (
<p className="notice">
Automatic enforcement supports a later shipment this turn
in Basic base rules without optional modules. During
Shipment and Movement it also supports unused active or
waiting turns in Advanced base rules, including the Guild,
or a classic payer in the Richese Semuta profile. Select
in classic Basic or Advanced rules without optional modules,
including the Guild. Active or waiting turns are eligible
during Shipment and Movement; the Richese Semuta profile
also supports classic payers at that time. Select
an eligible player and finish pending decisions first.
Native No-Field shipments and other rules configurations
remain unfinished.
+40
View File
@@ -16,6 +16,46 @@ open mode gates.
## Current checkpoint and work
**Earlier Advanced Truthtrance shipment promises — 26 October. Prototyped / Partial.**
Classic Advanced respondents can now answer this-turn reserve-shipment questions
during phases0–4, using the existing current-board/owned-stage policy. Shipment
still waits for the actual turn and native Guild controls. The shared witness
now includes wealthy BG's allowed charity payment through native `payCharity`;
the actual counter stays live and a denial may release an impossible answer.
Typed revival preserves current-turn elite usage. Existing readiness reopening,
voluntary-cost checks, guidance and minimal legal bots serve the timing. Earlier
expansion/module envelopes and public mode gates stay unchanged.
Two disjoint source lanes, bounded preparation review and root integration pass
**15 files,120/120**, types/lint/build and **eleven native engine/GameTable SSR
journeys with actual current-preparation/rate/shipment callbacks** on unchanged
source. Seventeen new cases cover Storm/Charity/Bidding questions, Yes/No,
actual purchase2/delivery8, native Guild TAKE/WAIT/income, forced Storm release,
wealthy BG Charity and denial, typed revival, readiness, legal controls and four
minimal profiles. Public Advanced sources retain all33 original cards; no played
hand, force, wallet, clock, Tank, elite or promise is installed.
Storm/Charity/Bidding prefixes use8/26/33 actions. E really buys its ordinary
weapon for2, retains8 and delivers8; a No can deliver7. Recorded Storm distance
does not release a current-board promise before actual movement closes the
route. BG's24-action source needs its real5→7 charity; E's original Karama
cancellation leaves5 and releases the old promise before a legal5 replacement.
The94-action elite source earns A's actual bid4/E income4, paid two-Sardaukar
shipment and next-Storm losses. One free Sardaukar returns19 reserves; the held
Karama pays10 for19 actual counters, including four elites, leaving one in Tanks.
Bounded core/Discovery/card/Skill lookups found no wholly absent standalone
primitive in those sets; that is not complete-rule certification. The concrete
early-Advanced guard drove this change. Shared-witness review identifies the BG
payment omission and confirms elite reset belongs to Storm, not Revival. The
first frozen proof passes without source repairs. Private report/logs:
`/home/quintine/.local/share/dune/checkpoints/20261026-truthtrance-early-advanced-proof/`;
journeys/HTML: adjacent `20261026-truthtrance-early-advanced-runtime/`.
Broader promises/modules, full rules and final assurance remain open. No saved-
room/D1/server, reset/restart, deployment or recovery operation occurs; DOM/
hydration, browser/mobile and deployed acceptance remain due.
[Source and scope](TRUTHTRANCE_SHIPMENT_PROMISES.md#earlier-classic-advanced-phases--26-october-2026).
**Semuta on Guild Ambassador and Revival Spiritual grants — 26 October. Prototyped / Partial.**
Existing terminal/advisor cost continuations now retain the original Guild
Ambassador child or Tleilax SEND return through Spiritual shipping, matching and
+38
View File
@@ -1,5 +1,43 @@
# Dune implementation status
## 26 October 2026 — Earlier Advanced Truthtrance shipment promises
**Prototyped / Partial.** Extend current-turn reserve-shipment questions to bare
classic Advanced phases0–4. The existing current-board/owned-stage policy remains
provisional; it predicts no future Storm result, rival gift or unknown purchase.
Actual shipment/Guild timing stays later. The shared detached witness now applies
wealthy BG's allowed charity payment with native `payCharity`, while preserving
the live counter/denial. Current elite usage is retained; its reset belongs to
Storm opening. Existing readiness, preparation, voluntary-cost and minimal legal
policies are reused. Earlier modules and public mode gates remain unchanged.
[Source and scope](TRUTHTRANCE_SHIPMENT_PROMISES.md#earlier-classic-advanced-phases--26-october-2026).
Two disjoint source lanes, bounded preparation review and root integration pass
**15 files,120/120**, types/lint/build and **eleven native engine/GameTable SSR
journeys with actual current-preparation/rate/shipment callbacks** on unchanged
source. Seventeen new cases cover three earlier phases, Yes/No, real purchase/
delivery, Guild TAKE/WAIT/income, actual Storm release, wealthy BG allowance/
cancellation, typed revival, readiness, legal controls and four profiles. All33
original cards remain physical; no played hand, force, wallet, clock, Tank, elite
or promise is installed. Existing controlled fixtures remain separate evidence.
Storm/Charity/Bidding8/26/33 earns E's real purchase2→eight-force shipment;
No can deliver7. BG24 earns actual5→7 charity or original E Karama cancellation
and affordable5 delivery after release. Elite94 earns actual A bid4/E income4,
paid two-Sardaukar Hagga shipment and next-Storm loss. One free elite return,
owned Karama and native bank invoice10 deliver19 counters/four elites while
retaining the last elite in Tanks. No last preparation can be skipped voluntarily.
Bounded gap lookups find no absent standalone primitive in the inspected core,
Discovery/card/Skill sets, not full completion. Current code verifies the real
early-Advanced timing exclusion and the BG forecast-payment omission; no unrelated
combination campaign is added. First frozen proof passes. Private report/logs:
`/home/quintine/.local/share/dune/checkpoints/20261026-truthtrance-early-advanced-proof/`;
scripts/journeys/HTML: adjacent `20261026-truthtrance-early-advanced-runtime/`.
Wider promises/modules and final rules/recovery/privacy/custody assurance stay
open. SSR/component callbacks do not certify DOM/hydration, browser/mobile or
deployment. No saved-room/D1/server, reset/restart or recovery operation occurs.
## 26 October 2026 — Semuta on Guild Ambassador and Revival Spiritual grants
**Prototyped / Partial.** Existing terminal/advisor cost continuations now retain
+13
View File
@@ -1,5 +1,18 @@
# Rule decision index
## Earlier Advanced shipment commitments — 26 October 2026
Extend the selected current-board/owned-stage shipment-promise policy to bare
classic Advanced phases0–4. The FAQ already supplies current-turn binding and
release after genuine impossibility; under the 14 October recommended-policy
authority this uses owned Charity/Revival preparations rather than predicting
unresolved Storm movement or future rival funding. Wealthy BG's allowed charity
payment may witness feasibility, while its real counter stays live; actual
denial may release an impossible answer. Preserve the current one-elite revival
limit and actual later Guild timing. This is provisional online composition,
not a new publisher worked example or public mode gate.
[Source, implementation and limits](TRUTHTRANCE_SHIPMENT_PROMISES.md#earlier-classic-advanced-phases--26-october-2026).
## Sapho after a started combined turn — 25 October 2026
Under the 14 October choose-recommendation authority, select the source review's
+92 -1
View File
@@ -4,7 +4,98 @@ Original checkpoint, 7 September 2026; Advanced follow-up, 13 September 2026. Th
The former Advanced Guild exclusion is superseded by the
[18 October native Guild composition](#advanced-guild-interception-and-rates--18-october-2026).
The [24 October waiting-turn follow-up](#same-phase-waiting-respondents--24-october-2026) supersedes the active-only timing boundary. The [earlier Basic phase follow-up](#earlier-basic-phases--24-october-2026) extends current-turn timing before Movement. Earlier scope descriptions and verification totals remain historical.
The [24 October waiting-turn follow-up](#same-phase-waiting-respondents--24-october-2026) supersedes the active-only timing boundary. The [earlier Basic phase follow-up](#earlier-basic-phases--24-october-2026) extends current-turn timing before Movement; the [26 October Advanced extension](#earlier-classic-advanced-phases--26-october-2026) adds bare classic Advanced while retaining earlier module fences. Earlier scope descriptions and verification totals remain historical.
## Earlier classic Advanced phases — 26 October 2026
**Prototyped / Partial.** Bare classic Advanced now accepts the same structured
this-turn reserve-shipment and mixed current-fact questions during phases0–4.
Actual shipment still waits for the respondent's real turn, preparations and
native Guild decisions. Earlier expansion and optional-module configurations
remain outside this envelope. Public Advanced/mode gates are unchanged.
The existing GF9 FAQ supplies current-turn commitment and release after genuine
impossibility. Under the 14 October recommended-policy authority, extend the
earlier Basic current-board/owned-stage interpretation to these Advanced roles.
This is explicit provisional online composition, not a new publisher example.
Do not predict unresolved Storm movement, vacated destinations, unknown purchases
or future rival gifts/income. An actual opposing/forced change may release the
answer only after it makes the route impossible. A recorded future Storm distance
alone does not become an already changed board. Existing voluntary spending,
auction liabilities, distinct card costs and expiring escrow remain binding.
The shared [mode helper](../game/shipment-promises.ts) now admits bare classic
Advanced before Movement. Existing early Charity/Revival/payment witnesses,
private answers, readiness reopening and future-turn obligations remain shared.
One concrete omission is corrected: wealthy Advanced BG's native charity claim
opens `bgCharity` before its fixed two spice is paid. The detached witness now
uses native `payCharity` for that owned allowed outcome. It neither credits the
real table nor bypasses its counter. Actual allowance pays once; cancellation
can leave no legal route and release the original promise. The same seed is used
by early ground-movement reachability.
Keep physical counter counts separate from combat strength. Native typed stock,
free/ordinary quotas and the current one-elite-per-turn limit stay authoritative.
Elite usage resets at native Storm opening, not when forecasting Revival; a
current use is never erased. Before Movement, only an available current Charity
or Revival action is executable. Price/ship controls wait for the real turn.
The question editor explains current-board preparation and later shipment; the
promise guide and all four profiles reuse the same minimal legal policy.
Two disjoint [earlier-phase](../tests/truthtrance-early-advanced-shipment-engine.test.ts)
and [preparation](../tests/truthtrance-advanced-shipment-preparation-engine.test.ts)
source lanes, bounded preparation review and root integration pass
**15 files,120/120**, types/lint/build and **eleven native engine/GameTable SSR
journeys with actual current-preparation/rate/shipment callbacks** on unchanged
source. Seventeen new cases cover native Storm/Charity/Bidding questions,
Yes/No, paid purchase/delivery, Guild TAKE/WAIT/income, actual forced release,
wealthy BG payment/counter, typed revival, readiness, legal controls and four
minimal profiles. Existing controlled preparation/engine and UI fixtures are
separate evidence; this does not claim saved-game or browser certification.
The public Advanced A/E/H/G source stops after8/26/33 actions in Storm/Charity/
Bidding. Original setup really deals A Truthtrance, E Baliset, H Karama+Shield
and G Snooper; an unopened ordinary weapon remains the actual first lot. A real
eight-force promise retains E's printed10 cash/20 reserves, permits its actual
purchase2 and delivers8 after real Guild TAKE or WAIT and interception/income.
No can deliver7 instead. Another original first-Storm source records distance10
without releasing, then actual movement to sector11 closes Carthag's route and
releases once. No clock, promise or physical source is installed.
The24-action BG source uses actual public A/BG/E Advanced setup, with A's original
Truthtrance, BG's ordinary card and E's original Karama. BG starts5 cash/19
reserves with its original Arrakeen advisor beside A10. A seven-city-counter
promise needs its real charity2. The current guide's actual callback claims it;
the live counter keeps BG5 until allowance makes7. E may instead spend its real
Karama, keeping BG5 and releasing the now-impossible promise before an actual
affordable5 shipment. Eight remains infeasible without an owned funding source.
The94-action elite source uses public Advanced A/E/H/G with original A Truthtrance
and E Karama. A really bids4 for an original ordinary card, giving E income4;
E then pays4 to ship two Sardaukar to Hagga13 after dials11+0 reach12. The next
native StormCard1 reaches13 and kills both. At turn2 Revival, E has10 cash,
18 reserves and two elite-only Tanks. One actual free Sardaukar returns19
reserves/four elites. The owned Karama buys the actual10-spice bank tariff for
19 city counters; the second elite remains in Tanks. Neither a twentieth force
nor another elite return is forecast. Actual preparation/rate/shipment callbacks
and native controls deliver that original group once.
All33 original Treachery Cards remain physical. Only original unopened deck/
Spice positions and native scalar lotteries are selected; no played hand, wallet,
force, Tank, elite, clock, promise or response is installed. The first frozen
proof passes without source repairs and binds2934 files to
`f78252b76d0f53433bb8d1db6db008c05b4df7ad5763ff0430406c633ff8c82b`.
Private report/logs:
`/home/quintine/.local/share/dune/checkpoints/20261026-truthtrance-early-advanced-proof/`;
scripts/journeys/HTML: adjacent `20261026-truthtrance-early-advanced-runtime/`.
Bounded core/Discovery/card/Skill lookups found no absent standalone primitive
in those sets; that is not a complete-rule audit. The concrete timing guard and
BG witness omission drove this work. Earlier modules, other future actions,
arbitrary promise enforcement and final rules/recovery/privacy/custody assurance
remain open. SSR/callback results do not certify DOM/hydration, browser/mobile or
deployed acceptance. No saved-room, D1, server, reset/restart, deployment or
recovery operation occurs.
## Authority and supported meaning
+10 -1
View File
@@ -25928,7 +25928,16 @@ function earlyShipmentCompletionSeeds(initial: Game, source: Game, owner: string
if (source.phase < 2 && byFaction(g, 'choam') && g.choamCharity?.turn !== g.turn) {
if (p.faction === 'choam') continue;
p.spice += charityQuote(g, p).total; p.charityTurn = g.turn; next = g;
} else next = applyActionInner(g, owner, action, 'shipmentPreparation');
} else {
next = applyActionInner(g, owner, action, 'shipmentPreparation');
// Wealthy Advanced BG claims before its counter pays. Witness only
// that owned allowed outcome; the actual table keeps every response.
const response = next.response;
if (response?.kind === 'bgCharity' && response.owner === owner) {
next.response = null;
payCharity(next,getPlayer(next,owner),response.amount ?? 2,response.charityHomeworld ?? 0);
}
}
charitySeeds.push({ game: next, actions: [action], early: [{ phase: 2, action }] });
} catch (error) { if (!(error instanceof RuleError)) throw error; }
}
+8 -4
View File
@@ -3172,13 +3172,13 @@ export const RULE_TOPICS: RuleTopic[] = [
summary:
'Bind this turn’s reserve shipment to a destination and physical minimum, including earlier Basic phases and players whose shipment turn is still ahead.',
steps: [
'Declare Truthtrance, select an eligible player and choose Bind a shipment from reserves. Bare Basic rules allow the question during Storm through Revival as well as before that player’s shipment. During Shipment and Movement, base Basic and Advanced, including Guild, also support an unused active or waiting turn. Finish pending decisions first. Choose a printed destination and minimum of one to twenty forces, or combine conditions using AND or OR; each describes the same shipment. Answering never grants an early shipment. Earlier Advanced, expansion and optional configurations keep their existing boundaries.',
'Declare Truthtrance, select an eligible player and choose Bind a shipment from reserves. Bare classic Basic and Advanced rules allow the question during Storm through Revival as well as before that player’s shipment. During Shipment and Movement, base Basic and Advanced, including Guild, support an unused active or waiting turn. Finish pending decisions first. Choose a printed destination and minimum of one to twenty forces, or combine conditions using AND or OR; each describes the same shipment. Answering never grants an early shipment. Earlier expansion and optional configurations keep their existing boundaries.',
'Yes requires the complete statement to match while possible: all AND conditions or at least one OR condition. No requires the whole statement to be false: at least one AND condition false, or every OR condition false. A smaller count, different destination, transport of forces already on the board or no shipment may satisfy No. Forces already in the destination and ground movement do not satisfy the question. Fremen reinforcements and Guild transport from southern reserves do count.',
'The target privately sees feasible answers based on the current board, owned resources and all earlier promises. Before Movement, include remaining owned Charity and normal Revival, the current winning invoice and unused outgoing Bidding refunds; incoming Bidding aid expires. Auction payment and shipping discounts need distinct physical Karamas. At shipment time, available Ghola, Karama and current pledged aid retain their normal rules. Unknown purchases, unresolved Storm outcomes, rival gifts and future income are not assumed.',
'Accepted whole answers are public and constrain the event together. A new answer reopens your Charity or Revival readiness when needed. Claim or revive before marking Ready if that is your last legal route; bids, gifts and paid revivals cannot spend it away. Honor a required shipment before ground movement or finishing. An unresolved foreign bid and its income responses finish before release. A completed opposing effect that makes compliance impossible releases it publicly.',
'Guild interception and rate or transport-permission responses finish before delivery. An opposing stop consumes the shipment without payment or arrival: answers matching no shipment fulfill and impossible answers release. You cannot stop your own shipment to evade your answer. An unaffordable canceled rate leaves shipment available; a retained owned discount or ordinary Fremen reinforcement may still provide a legal route.',
'Choose Mixed current fact and shipment for nested AND/OR groups of supported current facts and this-turn reserve shipments. Current facts freeze privately when answered: Yes OR a true current fact can allow skipping even after later losing that holding; No AND a false current fact can allow shipping even after that fact changes. Unknown is not false. Only the whole question and answer are public.',
'Your promises retain the original grouped question without exposing private compiled facts. Earlier phases show later-this-turn guidance and only an available current Charity or Revival preparation. Shipment actions wait for your actual phase5 turn and finished controls. The owned-route/readiness protocol is a provisional interpretation. All four profiles retain a minimal legal path; answers allowing a skip retain ordinary legal payment choices. Earlier Advanced/modules, expansion shipment modes, other future actions and arbitrary prose enforcement remain unfinished.',
'Your promises retain the original grouped question without exposing private compiled facts. Earlier phases show later-this-turn guidance and only an available current Charity or Revival preparation. Shipment actions wait for your actual phase5 turn and finished controls. The owned-route/readiness protocol is a provisional interpretation. All four profiles retain a minimal legal path; answers allowing a skip retain ordinary legal payment choices. Earlier modules, expansion shipment modes, other future actions and arbitrary prose enforcement remain unfinished.',
],
example:
'Yes to at least six forces to Carthag and No to at least eight there leaves a shipment of six or seven. Moving existing forces into Carthag does not fulfill either shipment event.',
@@ -3188,7 +3188,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Implementation',
status: 'Partial',
detail:
'Bare Basic phases0–4 use explicit provisional current-board/owned-stage feasibility, auction liabilities, distinct card costs, expiring aid and readiness reopening. Voluntary bids/payments/closure preserve a route; foreign invoices await their actual outcome. Existing phase5 waiting/native Guild interception, permission and rates remain. Earlier Advanced/modules and wider future actions remain unfinished.',
'Bare classic Basic/Advanced phases0–4 use explicit provisional current-board/owned-stage feasibility, auction liabilities, distinct card costs, expiring aid and readiness reopening. Wealthy Advanced BG Charity retains its native counter; the detached witness uses its allowed fixed payment. Typed revival keeps the current one-elite limit. Voluntary bids/payments/closure preserve a route; foreign invoices await their actual outcome. Existing phase5 waiting/native Guild interception, permission and rates remain. Earlier modules and wider future actions remain unfinished.',
},
{
area: 'Player controls',
@@ -3212,7 +3212,7 @@ export const RULE_TOPICS: RuleTopic[] = [
area: 'Verification',
status: 'Partial',
detail:
'Current early-phase batch passes18 files131/131, types/lint/build, two original Storm/Charity question programmes with actual purchase2 and shipment8, rendered table guidance and four finite legal profiles. Controlled preparation, readiness, physical cards, invoice allow/cancel and No overbids remain separate. Earlier persistence/privacy evidence is historical; full Truthtrance, modes, assurance and deployed acceptance remain open.',
'Source programmes cover original Basic and Advanced early-phase questions, actual purchase/payment and delivery, wealthy BG charity/counter, typed revival, real forced Storm release, current preparation controls and four minimal legal profiles. Current frozen totals and limits are in the source contract/current checkpoint; earlier persistence/privacy evidence remains historical. Full Truthtrance, modes, assurance and deployed acceptance remain open.',
evidence: [
'tests/shipment-promises.test.ts',
'tests/shipment-promises-bots.test.ts',
@@ -3226,6 +3226,10 @@ export const RULE_TOPICS: RuleTopic[] = [
'tests/truthtrance-early-shipment-engine.test.ts',
'tests/truthtrance-early-shipment-controls.test.tsx',
'tests/fixture-truthtrance-early-shipment.ts',
'tests/fixture-truthtrance-early-advanced-shipment.ts',
'tests/truthtrance-early-advanced-shipment-engine.test.ts',
'tests/fixture-truthtrance-advanced-shipment-preparation.ts',
'tests/truthtrance-advanced-shipment-preparation-engine.test.ts',
'components/shipment-promises.tsx',
'components/truthtrance.tsx',
'components/game-table.tsx',
+1 -1
View File
@@ -163,7 +163,7 @@ export function shipmentPromiseModeSupported(game: {
ecazTreachery?: unknown;
}, respondent = game.active): boolean {
if (game.phase !== undefined && game.phase < 5 &&
(game.advanced || game.expansions.length || game.techTokens || game.strongholdCards ||
(game.expansions.length || game.techTokens || game.strongholdCards ||
game.nexusCards || game.homeworlds || game.leaderSkills || game.discoveryEnabled || game.ecazTreachery)) return false;
if (game.semutaPreview === true && game.expansions.length === 1 &&
game.expansions[0] === 'choam' && !game.techTokens &&
@@ -0,0 +1,180 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import type { TruthQuestion } from '../game/truthtrance';
import { isAdvisor } from '../game/advisors';
import { actFutureShipment, nextFutureShipmentStep, futureShipmentClean, futureShipmentPlayer } from './fixture-truthtrance-future-shipment';
import { truthMovementInventory } from './fixture-truth-movement';
export type TruthtranceAdvancedShipmentPreparationKind = 'bgCharity' | 'elite';
export type TruthtranceAdvancedShipmentPreparationStep = { actor: string; action: Action };
export const actTruthtranceAdvancedShipmentPreparation = actFutureShipment;
export const truthtranceAdvancedShipmentPreparationPlayer = futureShipmentPlayer;
export const truthtranceAdvancedShipmentPreparationInventory = truthMovementInventory;
export const truthtranceAdvancedShipmentPreparationClean = futureShipmentClean;
/** Actual public setup/readiness controls. An earned owned preparation stays
* caller-controlled instead of being skipped by a generic Ready action. */
export function nextTruthtranceAdvancedShipmentPreparation(game: Game): TruthtranceAdvancedShipmentPreparationStep | null {
if (!game.response && !game.phaseOpening && !game.truthtrance && !game.pendingTreacheryDiscard) {
if (game.status === 'setup' && game.setupStage === 'prediction')
return { actor: 'beneGesserit', action: { type: 'predict', faction: 'emperor', turn: 10 } };
const bg = game.players.find(p => p.faction === 'beneGesserit');
if (game.status === 'setup' && game.setupStage === 'forces' && bg && !bg.advisorSetup &&
viewGame(game, bg.id).setupPending.includes(bg.id))
return { actor: bg.id, action: { type: 'advisorSetup', territory: 'arrakeen', sector: 10 } };
if (game.decision?.kind === 'advisorBattle' || game.decision?.kind === 'advisor' || game.decision?.kind === 'intrusion')
return { actor: game.decision.player, action: { type: 'decision', accept: false } };
if (game.status === 'playing' && game.phase === 0 && game.stormPending === null) {
const actor = game.stormDialers.find(id => game.stormDials[id] === undefined);
if (actor) return { actor, action: { type: 'stormDial',
amount: game.turn === 1 ? actor === game.stormDialers[0] ? 11 : 0 : 1 } };
}
if ([2, 4].includes(game.phase) && game.players.some(p => viewGame(game, p.id).shipmentPreparation?.actions.length)) return null;
}
return nextFutureShipmentStep(game);
}
export const nextTruthtranceAdvancedShipmentPreparationStep = nextTruthtranceAdvancedShipmentPreparation;
export function advanceTruthtranceAdvancedShipmentPreparation(game: Game, until: (state: Game) => boolean,
actions: TruthtranceAdvancedShipmentPreparationStep[] = []): Game {
for (let count = 0; count < 2000; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished');
const step = nextTruthtranceAdvancedShipmentPreparation(game);
assert.ok(step, `Choose the original question, preparation or shipment at ${game.turn}/${game.phase}/${game.decision?.kind}`);
game = actTruthtranceAdvancedShipmentPreparation(game, step.actor, step.action, actions);
}
throw Error('The original Advanced shipment-preparation chronology did not reach its finite boundary');
}
/** Public Advanced-only sources. BG's real wealthy Charity and Emperor's
* real elite-only Tanks are earned without assigning played resource state. */
export function createTruthtranceAdvancedShipmentPreparationFixture(options: { kind: TruthtranceAdvancedShipmentPreparationKind }) {
const { kind } = options, asker = 'atreides', target = kind === 'bgCharity' ? 'beneGesserit' : 'emperor';
const roster = kind === 'bgCharity' ? ['beneGesserit', 'emperor'] as const : ['emperor', 'harkonnen', 'guild'] as const;
let game = createGame('TRUTHADVANCEDSHIPMENTPREPARATION', newPlayer(asker, 'Atreides', 'atreides'), false);
for (const faction of roster) joinGame(game, newPlayer(faction, faction, faction));
const initial = structuredClone(game), actions: TruthtranceAdvancedShipmentPreparationStep[] = [];
const act = (actor: string, action: Action) => { game = actTruthtranceAdvancedShipmentPreparation(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceTruthtranceAdvancedShipmentPreparation(game, until, actions); };
const transition = (run: () => void) => {
const before = structuredClone(game), at = actions.length; run();
return { before, steps: structuredClone(actions.slice(at)), after: structuredClone(game) };
};
const rules = transition(() => { act(game.host, { type: 'rules', advanced: true }); });
for (const p of game.players) act(p.id, { type: 'ready' });
const lobby = structuredClone(game); act(game.host, { type: 'start', advancedPreview: true });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.equal(game.advancedPreview, true);
assert.deepEqual(game.expansions, []); assert.ok(game.players.every(p => p.hand.length === 0));
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens && !game.strongholdCards &&
!game.discoveryEnabled && !game.ecazTreachery);
const originalInventory = truthtranceAdvancedShipmentPreparationInventory(game); assert.equal(originalInventory.length, 33);
const truth = game.deck.find(card => card.effect === 'truthtrance');
const printed = game.deck.find(card => card.effect === 'karama');
const shield = game.deck.find(card => card.kind === 'shield'), snooper = game.deck.find(card => card.kind === 'snooper');
const ordinary = game.deck.find(card => card.kind === 'projectile'), auctionFace = game.deck.find(card => card.name === 'Baliset');
assert.ok(truth && printed && shield && snooper && ordinary && auctionFace);
const selected = kind === 'bgCharity' ? [truth, shield, printed] : [truth, printed, shield, snooper, ordinary];
const deckSelection = [...selected, ...(kind === 'elite' ? [auctionFace] : [])];
const dealOrder = deckSelection.map((card, position) => {
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: card.id };
});
const beforeStartingDeal = structuredClone(game);
let advisorSetup: { before: Game; steps: TruthtranceAdvancedShipmentPreparationStep[]; after: Game } | null = null;
for (let count = 0; game.status === 'setup' && count < 100; count++) {
const step = nextTruthtranceAdvancedShipmentPreparation(game); assert.ok(step);
const before = step.action.type === 'advisorSetup' ? structuredClone(game) : null;
act(step.actor, step.action);
if (before) advisorSetup = { before, steps: [structuredClone(step)], after: structuredClone(game) };
}
assert.equal(game.status, 'playing'); const afterSetup = structuredClone(game);
assert.deepEqual(game.players.flatMap(p => p.hand.map(card => card.id)), selected.map(card => card.id));
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder: { position: number; from: number; card: Game['spiceDeck'][number] }[] = [];
for (let position = 0; position < (kind === 'elite' ? 4 : 2); position++) {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery &&
!['hagga_basin', 'carthag', 'arrakeen', 'tueks_sietch'].includes(card.territory));
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0]; game.spiceDeck.splice(position, 0, card);
spiceOrder.push({ position, from, card: structuredClone(card) });
}
const firstStorm = transition(() => { reach(state => state.turn === 1 && state.phase === 1); });
assert.equal(game.storm, 12);
let purchase: ReturnType<typeof transition> | null = null, beforeBid: Game | null = null;
let shipment: ReturnType<typeof transition> | null = null, beforeStormMovement: Game | null = null;
let stormLoss: ReturnType<typeof transition> | null = null, originalStormSource: Game['stormMovementSource'] | null = null;
let auctionCard: string | null = null;
if (kind === 'elite') {
reach(state => state.turn === 1 && state.phase === 3 && truthtranceAdvancedShipmentPreparationClean(state) &&
!!state.auction && state.auction.active === asker && state.auction.bid === 0);
beforeBid = structuredClone(game); auctionCard = game.auction!.cards[game.auction!.index].id;
assert.equal(auctionCard, auctionFace.id);
purchase = transition(() => {
act(asker, { type: 'bid', amount: 4 });
reach(state => state.turn === 1 && state.phase === 4 && truthtranceAdvancedShipmentPreparationClean(state));
});
assert.equal(truthtranceAdvancedShipmentPreparationPlayer(game, 'emperor').spice, 14);
assert.equal(truthtranceAdvancedShipmentPreparationPlayer(game, asker).spice, 6);
assert.ok(truthtranceAdvancedShipmentPreparationPlayer(game, asker).hand.some(card => card.id === auctionCard));
reach(state => state.turn === 1 && state.phase === 5 && state.active === target && truthtranceAdvancedShipmentPreparationClean(state));
shipment = transition(() => {
act(target, { type: 'ship', territory: 'hagga_basin', sector: 13, amount: 2, elite: 2, allyPayment: 0 });
reach(truthtranceAdvancedShipmentPreparationClean);
});
assert.equal(truthtranceAdvancedShipmentPreparationPlayer(shipment.before, target).spice - truthtranceAdvancedShipmentPreparationPlayer(game, target).spice, 4);
assert.equal(truthtranceAdvancedShipmentPreparationPlayer(game, target).spice, 10);
assert.equal(truthtranceAdvancedShipmentPreparationPlayer(game, target).elites?.forces['hagga_basin:13'], 2);
reach(state => state.turn === 2 && state.phase === 0 && !state.response && !state.phaseOpening && state.stormPending === 1);
beforeStormMovement = structuredClone(game); originalStormSource = structuredClone(game.stormMovementSource);
stormLoss = transition(() => { reach(state => state.turn === 2 && state.phase === 1); });
assert.equal(game.storm, 13); assert.equal(truthtranceAdvancedShipmentPreparationPlayer(game, target).elites?.tanks, 2);
reach(state => state.turn === 2 && state.phase === 4 && truthtranceAdvancedShipmentPreparationClean(state));
const p = truthtranceAdvancedShipmentPreparationPlayer(game, target);
assert.equal(p.spice, 10); assert.equal(p.reserves, 18); assert.equal(p.tanks, 2);
assert.equal(p.elites?.reserves, 3); assert.equal(p.elites?.tanks, 2); assert.equal(p.elites?.revived, 0);
assert.ok(p.hand.some(card => card.id === printed.id));
} else {
reach(state => state.turn === 1 && state.phase === 2 && truthtranceAdvancedShipmentPreparationClean(state));
const p = truthtranceAdvancedShipmentPreparationPlayer(game, target); assert.ok(advisorSetup);
assert.equal(p.spice, 5); assert.equal(p.reserves, 19); assert.equal(p.tanks, 0);
assert.equal(p.forces['arrakeen:10'], 1); assert.equal(isAdvisor(p, 'arrakeen'), true);
assert.ok(!p.hand.some(card => card.effect === 'karama'));
}
const source = structuredClone(game), minimum = kind === 'bgCharity' ? 7 : 19;
const question: TruthQuestion = { kind: 'shipment', target, territory: 'carthag', minimum };
const declarationStep: TruthtranceAdvancedShipmentPreparationStep = { actor: asker, action: { type: 'card', card: truth.id } };
const askStep: TruthtranceAdvancedShipmentPreparationStep = { actor: asker, action: { type: 'truthAsk', question } };
const charityAction: Action = { type: 'charity' }, revivalAction: Action = { type: 'revive', amount: 1, elite: 1, freeElite: 1 };
const rateAction: Action | null = kind === 'elite' ? { type: 'card', card: printed.id, mode: 'shipment', target } : null;
const shipmentAction: Action = { type: 'ship', territory: 'carthag', sector: 11, amount: minimum,
elite: kind === 'elite' ? 4 : 0, allyPayment: 0 };
assert.ok(truthtranceAdvancedShipmentPreparationPlayer(game, asker).hand.some(card => card.id === truth.id));
assert.deepEqual(truthtranceAdvancedShipmentPreparationInventory(game), originalInventory);
return { game, source, beforeQuestion: source, advanced: true as const, kind, asker, target, emperor: 'emperor', guild: kind === 'elite' ? 'guild' : null,
card: truth.id, truth: truth.id, truthtrance: truth.id, karama: printed.id,
targetKarama: kind === 'elite' ? printed.id : null, counterHolder: kind === 'bgCharity' ? 'emperor' : null,
question, minimum, impossibleMinimum: minimum + 1, territory: 'carthag', sector: 11, location: 'carthag:11',
declarationStep, askStep, charityAction, revivalAction, rateAction, shipmentAction,
initial, rules, lobby, setup, beforeStartingDeal, afterSetup, advisorSetup,
deal: selected.map(card => card.id), dealOrder, originalInventory, originalCards: originalInventory, originalSpice, spiceOrder,
firstStorm, beforeBid, auctionCard, purchase, shipment, beforeStormMovement, originalStormSource, stormLoss,
balances: Object.fromEntries(game.players.map(p => [p.id, p.spice])),
actions, steps: actions, sourceActionCount: actions.length,
staging: [
'Actual public classic lobby rules advanced:true, real Ready and start advancedPreview:true, original base33 and no modules. Only original unopened starting/auction deck positions, safe original Spice positions and native scalar lotteries are selected. No played hand, wallet, phase, force, Tank, elite or promise is installed.',
kind === 'bgCharity'
? 'Original A/BG/E deal gives A Truthtrance, BG an ordinary card and Emperor printed Karama. BG really places its starting advisor beside native A10 Arrakeen, retaining five spice and nineteen reserves. Stop at original first Charity, before wealthy Advanced BG actually claims two. The seven-force city question needs that real owned preparation; no Karama is held by its target. The independently held original Emperor card can actually cancel that Charity.'
: 'Original A/E/H/G deal gives A Truthtrance, E printed Karama, H its two ordinary cards and G an ordinary card. An actual A bid4 buys the selected original ordinary first auction lot and pays E four through native seller income. E then really pays four for two Sardaukar at Hagga13 after first dials11+0→12. Original next StormCard1 reaches13 and kills both. Native turn2 phases/auction passes reach Revival with E10 cash, eighteen reserves and two actual elite-only Tanks. One free elite return plus the held original Karama can fund nineteen city counters; the one-elite limit prevents a twentieth.',
],
};
}
export type TruthtranceAdvancedShipmentPreparationFixture = ReturnType<typeof createTruthtranceAdvancedShipmentPreparationFixture>;
export function askTruthtranceAdvancedShipmentPreparation(game: Game, fixture: TruthtranceAdvancedShipmentPreparationFixture,
minimum = fixture.minimum, actions: TruthtranceAdvancedShipmentPreparationStep[] = []): Game {
game = actTruthtranceAdvancedShipmentPreparation(game, fixture.asker, fixture.declarationStep.action, actions);
game = advanceTruthtranceAdvancedShipmentPreparation(game, state => state.truthtrance?.stage === 'ask', actions);
return actTruthtranceAdvancedShipmentPreparation(game, fixture.asker,
{ type: 'truthAsk', question: { ...fixture.question, minimum } }, actions);
}
@@ -0,0 +1,186 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, type Action, type Game } from '../game/engine';
import type { TruthQuestion } from '../game/truthtrance';
import {
actFutureShipment, advanceFutureShipment, nextFutureShipmentStep, futureShipmentClean, futureShipmentPlayer,
type FutureShipmentStep,
} from './fixture-truthtrance-future-shipment';
export type TruthtranceEarlyAdvancedShipmentPhase = 'storm' | 'charity' | 'bidding';
export type TruthtranceEarlyAdvancedShipmentStep = FutureShipmentStep;
export const actTruthtranceEarlyAdvancedShipment = actFutureShipment;
export const nextTruthtranceEarlyAdvancedShipmentStep = nextFutureShipmentStep;
export const nextTruthtranceEarlyAdvancedShipment = nextFutureShipmentStep;
export const advanceTruthtranceEarlyAdvancedShipment = advanceFutureShipment;
export const truthtranceEarlyAdvancedShipmentPlayer = futureShipmentPlayer;
export const truthtranceEarlyAdvancedShipmentClean = futureShipmentClean;
export function truthtranceEarlyAdvancedShipmentInventory(game: Game) {
const ids = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
...(game.auction?.cards.slice(game.auction.index + Number(game.currentAuctionSale?.origin === 'normal')) ?? [])]
.map(card => card.id).sort();
assert.equal(new Set(ids).size, ids.length, 'Every original base33 Treachery Card retains one physical slot'); return ids;
}
export const truthtranceEarlyAdvancedShipmentPhysical = (game: Game) => ({
turn: game.turn, phase: game.phase, storm: game.storm, order: game.order, active: game.active,
ready: game.ready, movementRemaining: game.movementRemaining,
players: game.players.map(player => ({ id: player.id, spice: player.spice, forces: player.forces,
elites: player.elites, reserves: player.reserves, tanks: player.tanks, shipped: player.shipped, moved: player.moved })),
});
/** Actual public Advanced classic start and original five-card deal. Only
* unopened starting/first-auction/dual-Spice positions and scalar lotteries
* are selected. Stop before Truthtrance; no played state is installed. */
export function createTruthtranceEarlyAdvancedShipmentFixture(options: {
phase?: TruthtranceEarlyAdvancedShipmentPhase;
} = {}) {
const phase = options.phase ?? 'charity', phaseNumber = phase === 'storm' ? 0 : phase === 'charity' ? 2 : 3;
const asker = 'atreides', target = 'emperor', counterHolder = 'harkonnen', guild = 'guild';
let game = createGame('TRUTHEARLYADVANCEDSHIPMENT', newPlayer(asker, 'Atreides', 'atreides'), true);
for (const faction of [target, counterHolder, guild] as const) joinGame(game, newPlayer(faction, faction, faction));
const initial = structuredClone(game), actions: TruthtranceEarlyAdvancedShipmentStep[] = [];
const act = (actor: string, action: Action) => { game = actTruthtranceEarlyAdvancedShipment(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceTruthtranceEarlyAdvancedShipment(game, until, actions); };
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game);
act(game.host, { type: 'start', advancedPreview: true });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.equal(game.advanced, true); assert.deepEqual(game.expansions, []);
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
!game.strongholdCards && !game.discoveryEnabled && !game.ecazTreachery);
assert.ok(game.players.every(player => player.hand.length === 0));
const originalInventory = truthtranceEarlyAdvancedShipmentInventory(game); assert.equal(originalInventory.length, 33);
const truth = game.deck.find(card => card.effect === 'truthtrance'), karama = game.deck.find(card => card.effect === 'karama');
const baliset = game.deck.find(card => card.name === 'Baliset'), shield = game.deck.find(card => card.kind === 'shield'),
snooper = game.deck.find(card => card.kind === 'snooper');
assert.ok(truth && karama && baliset && shield && snooper);
const deal = [truth.id, baliset.id, karama.id, shield.id, snooper.id];
const dealOrder = deal.map((id, position) => {
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: id };
});
// A genuinely bought ordinary weapon cannot introduce a second Karama
// coupon into the respondent's later funding witness.
const ordinaryLot = game.deck.find(card => !deal.includes(card.id) && card.kind === 'projectile' && !card.effect);
assert.ok(ordinaryLot);
const auctionFrom = game.deck.findIndex(card => card.id === ordinaryLot.id); assert.ok(auctionFrom >= deal.length);
[game.deck[deal.length], game.deck[auctionFrom]] = [game.deck[auctionFrom], game.deck[deal.length]];
const auctionOrder = { position: deal.length, from: auctionFrom, card: ordinaryLot.id };
const beforeStartingDeal = structuredClone(game);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game), afterSetupActionCount = actions.length;
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), deal);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort(), beforeSpiceOrder = structuredClone(game);
const spiceOrder = [0, 1].map(position => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery);
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
});
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
reach(state => state.turn === 1 && state.phase === phaseNumber && futureShipmentClean(state) &&
(phaseNumber !== 3 || !!state.auction));
const respondent = futureShipmentPlayer(game, target);
assert.equal(respondent.spice, 10); assert.equal(respondent.reserves, 20); assert.equal(respondent.tanks, 0);
assert.deepEqual(respondent.forces, {}); assert.equal(respondent.shipped, false); assert.equal(respondent.moved, 0);
assert.ok(futureShipmentPlayer(game, asker).hand.some(card => card.id === truth.id));
assert.ok(futureShipmentPlayer(game, counterHolder).hand.some(card => card.id === karama.id));
assert.ok(respondent.hand.some(card => card.id === baliset.id));
assert.equal(game.truthtrance ?? null, null); assert.equal(game.shipmentPromises, undefined);
assert.deepEqual(truthtranceEarlyAdvancedShipmentInventory(game), originalInventory);
const question: TruthQuestion = { kind: 'shipment', target, territory: 'carthag', minimum: 8 };
const declarationStep: TruthtranceEarlyAdvancedShipmentStep = { actor: asker, action: { type: 'card', card: truth.id } };
const askStep: TruthtranceEarlyAdvancedShipmentStep = { actor: asker, action: { type: 'truthAsk', question } };
const shipment: Action = { type: 'ship', territory: 'carthag', sector: 11, amount: 8, elite: 0, allyPayment: 0 };
return { game, source: structuredClone(game), phase, phaseNumber, advanced: true as const,
asker, target, counterHolder, guild, card: truth.id, truth: truth.id, karama: karama.id, baliset: baliset.id,
question, declarationStep, askStep, territory: 'carthag', sector: 11, location: 'carthag:11', minimum: 8,
shipment, bid: { type: 'bid', amount: 2, allyPayment: 0 } satisfies Action,
bigBid: { type: 'bid', amount: 3, allyPayment: 0 } satisfies Action,
bigBribe: { type: 'bribe', target: asker, amount: 3 } satisfies Action,
purchaseCost: 2, shipmentCost: 8, purchaseCard: ordinaryLot.id,
initial, lobby, setup, beforeStartingDeal, afterSetup, afterSetupActionCount,
beforeSpiceOrder, spiceOrder, deal, dealOrder, auctionOrder, originalSpice,
originalInventory, originalTreachery: originalInventory,
physical: structuredClone(truthtranceEarlyAdvancedShipmentPhysical(game)),
originalPhysical: structuredClone(truthtranceEarlyAdvancedShipmentPhysical(game)),
actions, sourceActions: actions, sourceActionCount: actions.length,
staging: [
'Actual public Advanced A/E/H/G ready/start with advancedPreview:true, original base33 and no expansion or optional module. Every private Traitor and original setup control is an actual native action.',
'Select five original unopened starting-card positions: A Truthtrance, E Baliset, H printed Karama plus Shield, G Snooper. Select one still-unopened ordinary first-auction weapon and two original ordinary Spice positions; physical inventory remains all33 originals.',
phase === 'storm'
? 'Stop at clean original first Storm before its actual secret dials. The proposed commitment uses the current board, not an assumed future Storm outcome.'
: phase === 'charity'
? 'Actually complete first Storm dials and both Advanced Spice blows, then stop at clean original Charity before Bidding.'
: 'Actually complete first Storm, both Advanced Spice blows and Charity plus native auction-peek responses, then stop at the clean first ordinary lot before any bid.',
'E retains its actual printed ten spice, twenty native reserves, no board forces and unused shipment. Truthtrance declaration and eight-force Carthag question are returned unplayed. No played hand, wallet, army, clock, promise or response is installed.',
'The optional continuation genuinely buys the original weapon for two, uses actual Guild TAKE or WAIT/allowance controls, and ships at E actual native turn. It resolves original Guild interception/income and ends only E original turn, without a fabricated early shipment.',
],
};
}
export type TruthtranceEarlyAdvancedShipmentFixture = ReturnType<typeof createTruthtranceEarlyAdvancedShipmentFixture>;
/** Native paid purchase, original Guild timing, and exact reserve delivery.
* The caller has already asked/answered; this helper creates no promise. */
export function finishTruthtranceEarlyAdvancedShipment(f: TruthtranceEarlyAdvancedShipmentFixture, source: Game,
options: { guildTake?: boolean; amount?: number } = {}) {
const amount = options.amount ?? 8, take = options.guildTake ?? false;
let game = source;
const actions: TruthtranceEarlyAdvancedShipmentStep[] = [];
const act = (actor: string, action: Action) => { game = actTruthtranceEarlyAdvancedShipment(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceTruthtranceEarlyAdvancedShipment(game, until, actions); };
const transition = (run: () => void) => {
const before = structuredClone(game), start = actions.length; run();
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
};
assert.equal(game.truthtrance ?? null, null);
reach(state => state.phase === 3 && futureShipmentClean(state) && state.auction?.active === f.target);
assert.equal(game.auction?.index, 0); assert.equal(game.auction?.cards[0].id, f.purchaseCard);
const purchase = transition(() => {
act(f.target, f.bid);
reach(state => state.phase === 4 && futureShipmentClean(state));
});
assert.equal(futureShipmentPlayer(purchase.before, f.target).spice, 10);
assert.equal(futureShipmentPlayer(game, f.target).spice, 8);
assert.ok(futureShipmentPlayer(game, f.target).hand.some(card => card.id === f.purchaseCard));
reach(state => state.phase === 5 && !state.response && !state.phaseOpening && state.decision?.kind === 'guildTiming');
const timing: ReturnType<typeof transition>[] = [];
const guildTurn: ReturnType<typeof transition>[] = [];
const chooseTiming = (choice: boolean) => {
assert.equal(game.decision?.kind, 'guildTiming');
timing.push(transition(() => {
act(f.guild, { type: 'decision', take: choice });
reach(state => futureShipmentClean(state));
}));
};
chooseTiming(take);
if (take) {
assert.equal(game.active, f.guild);
guildTurn.push(transition(() => { act(f.guild, { type: 'endMovement' }); }));
}
reach(state => state.phase === 5 && futureShipmentClean(state) && state.active === f.asker);
const askerEnd = transition(() => { act(f.asker, { type: 'endMovement' }); });
if (!take) {
reach(state => state.phase === 5 && !state.response && !state.phaseOpening && state.decision?.kind === 'guildTiming');
chooseTiming(false);
}
reach(state => state.phase === 5 && futureShipmentClean(state) && state.active === f.target);
const beforeShipment = structuredClone(game), shipmentAction: Action = { ...f.shipment, amount };
const start = actions.length;
act(f.target, shipmentAction);
const declared = structuredClone(game), originalControls: Game[] = [];
for (let count = 0; !futureShipmentClean(game) || !!game.pendingShipment; count++) {
assert.ok(count < 100, 'Resolve only the original shipment permission/payment/income');
originalControls.push(structuredClone(game));
const step = nextFutureShipmentStep(game); assert.ok(step);
act(step.actor, step.action);
}
const delivered = structuredClone(game), shipment = { before: beforeShipment, action: shipmentAction,
declared, controls: originalControls, steps: structuredClone(actions.slice(start)), after: delivered };
assert.equal(futureShipmentPlayer(game, f.target).forces[f.location], amount);
assert.equal(futureShipmentPlayer(game, f.target).reserves, 20 - amount);
assert.equal(futureShipmentPlayer(game, f.target).spice, 8 - amount);
const end = transition(() => { act(f.target, { type: 'endMovement' }); });
assert.deepEqual(truthtranceEarlyAdvancedShipmentInventory(game), f.originalInventory);
return { game, purchase, timing, guildTurn, askerEnd, beforeShipment, shipmentAction, shipment, declared,
delivered, end, actions, amount, guildTake: take };
}
@@ -0,0 +1,193 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { isAdvisor } from '../game/advisors';
import {
createTruthtranceAdvancedShipmentPreparationFixture as fixture,
askTruthtranceAdvancedShipmentPreparation as ask, actTruthtranceAdvancedShipmentPreparation as act,
advanceTruthtranceAdvancedShipmentPreparation as advance, truthtranceAdvancedShipmentPreparationClean as clean,
truthtranceAdvancedShipmentPreparationPlayer as player, truthtranceAdvancedShipmentPreparationInventory as inventory,
type TruthtranceAdvancedShipmentPreparationFixture,
} from './fixture-truthtrance-advanced-shipment-preparation';
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
function nativeSource(f: TruthtranceAdvancedShipmentPreparationFixture) {
assert.equal(f.initial.advanced, false); assert.notEqual(f.initial.status, 'playing');
assert.deepEqual(f.rules.steps, [{ actor: f.asker, action: { type: 'rules', advanced: true } }]);
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.equal(f.setup.advancedPreview, true);
assert.deepEqual(f.setup.expansions, []); assert.ok(f.beforeStartingDeal.players.every(p => p.hand.length === 0));
assert.deepEqual(f.afterSetup.players.flatMap(p => p.hand.map(card => card.id)), f.deal);
assert.equal(player(f.afterSetup, f.asker).hand[0].id, f.truthtrance);
assert.ok(player(f.afterSetup, f.emperor).hand.some(card => card.id === f.karama && card.effect === 'karama'));
assert.deepEqual(f.firstStorm.steps.filter(step => step.action.type === 'stormDial').map(step => step.action.amount), [11, 0]);
assert.equal(f.firstStorm.after.storm, 12); assert.equal(f.game.advanced, true);
assert.equal(f.game.truthtrance ?? null, null); assert.equal(f.game.shipmentPromises?.length ?? 0, 0);
if (f.kind === 'bgCharity') {
assert.ok(f.advisorSetup); assert.deepEqual(f.advisorSetup.steps[0].action, { type: 'advisorSetup', territory: 'arrakeen', sector: 10 });
assert.equal(f.game.turn, 1); assert.equal(f.game.phase, 2); assert.equal(f.counterHolder, f.emperor);
assert.equal(player(f.game, f.target).spice, 5); assert.equal(player(f.game, f.target).reserves, 19);
assert.equal(player(f.game, f.target).forces['arrakeen:10'], 1); assert.equal(isAdvisor(player(f.game, f.target), 'arrakeen'), true);
assert.equal(player(f.game, f.asker).forces['arrakeen:10'], 10); assert.equal(f.targetKarama, null);
assert.equal(player(f.game, f.target).hand.some(card => card.effect === 'karama'), false);
} else {
assert.ok(f.purchase && f.beforeBid && f.shipment && f.beforeStormMovement && f.stormLoss && f.guild);
assert.deepEqual(f.purchase.steps[0], { actor: f.asker, action: { type: 'bid', amount: 4 } });
assert.equal(player(f.purchase.before, f.emperor).spice, 10); assert.equal(player(f.purchase.after, f.emperor).spice, 14);
assert.equal(player(f.purchase.after, f.asker).spice, 6);
assert.ok(player(f.purchase.after, f.asker).hand.some(card => card.id === f.auctionCard));
assert.deepEqual(f.shipment.steps[0], { actor: f.target,
action: { type: 'ship', territory: 'hagga_basin', sector: 13, amount: 2, elite: 2, allyPayment: 0 } });
assert.equal(player(f.shipment.before, f.target).spice - player(f.shipment.after, f.target).spice, 4);
assert.equal(player(f.shipment.after, f.target).elites?.forces['hagga_basin:13'], 2);
assert.equal(f.beforeStormMovement.storm, 12); assert.equal(f.beforeStormMovement.stormPending, 1);
assert.equal(f.stormLoss.after.storm, 13); assert.equal(player(f.stormLoss.after, f.target).elites?.tanks, 2);
assert.equal(f.game.turn, 2); assert.equal(f.game.phase, 4);
const p = player(f.game, f.target); assert.equal(p.spice, 10); assert.equal(p.reserves, 18); assert.equal(p.tanks, 2);
assert.equal(p.elites?.reserves, 3); assert.equal(p.elites?.tanks, 2); assert.equal(p.elites?.revived, 0);
assert.equal(p.revived, 0); assert.equal(p.freeForcesRevived, 0); assert.equal(f.targetKarama, f.karama);
}
assert.equal(f.originalInventory.length, 33); assert.deepEqual(inventory(f.game), f.originalInventory);
assert.equal(f.sourceActionCount, f.actions.length);
}
function bound(f: TruthtranceAdvancedShipmentPreparationFixture, game = structuredClone(f.game)) {
game = ask(game, f);
assert.ok(viewGame(game, f.asker).truthShipmentTargets.includes(f.target));
assert.deepEqual(viewGame(game, f.target).truthShipmentAnswers, ['yes', 'no']);
game = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
assert.equal(game.truthtrance ?? null, null); assert.equal(game.shipmentPromises?.length, 1);
assert.equal(game.shipmentPromises![0].answer, true); assert.equal(game.shipmentPromises![0].released, undefined);
return game;
}
function prepare(game: Game, f: TruthtranceAdvancedShipmentPreparationFixture, action?: Action) {
const guide = viewGame(game, f.target).shipmentPreparation; assert.ok(guide?.actions.length);
assert.equal(guide.actions[0].type, f.kind === 'bgCharity' ? 'charity' : 'revive');
reject(game, f.target, { type: 'ready' });
if (f.kind === 'elite') {
assert.equal(guide.actions[0].amount, 1);
reject(game, f.target, { type: 'revive', amount: 2, elite: 2, freeElite: 1 });
}
game = act(game, f.target, action ?? guide.actions[0]);
if (f.kind === 'bgCharity') {
assert.equal(game.response?.kind, 'bgCharity'); assert.equal(player(game, f.target).spice, 5);
}
game = advance(game, clean);
assert.equal(viewGame(game, f.target).shipmentPreparation, null);
assert.equal(game.shipmentPromises![0].released, undefined);
if (f.kind === 'bgCharity') assert.equal(player(game, f.target).spice, 7);
else {
const p = player(game, f.target); assert.equal(p.spice, 10); assert.equal(p.reserves, 19); assert.equal(p.tanks, 1);
assert.equal(p.elites?.reserves, 4); assert.equal(p.elites?.tanks, 1); assert.equal(p.elites?.revived, 1);
assert.equal(p.revived, 1); assert.equal(p.freeForcesRevived, 1);
reject(game, f.target, { type: 'revive', amount: 1, elite: 1, freeElite: 0 });
}
return game;
}
function ship(game: Game, f: TruthtranceAdvancedShipmentPreparationFixture) {
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state));
assert.equal(game.shipmentPromises![0].released, undefined); assert.equal(game.shipmentPromises![0].fulfilled, undefined);
const before = structuredClone(game), guildBefore = f.guild ? player(game, f.guild).spice : null;
if (f.kind === 'elite') {
const rate = viewGame(game, f.target).shipmentCompletion?.actions[0]; assert.ok(rate);
assert.equal(rate.type, 'card'); assert.equal(rate.card, f.karama); assert.equal(rate.mode, 'shipment');
assert.ok(f.rateAction); game = act(game, f.target, f.rateAction);
assert.equal(player(game, f.target).spice, 10); assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
}
const completion = viewGame(game, f.target).shipmentCompletion; assert.ok(completion);
const shipment = completion.actions.find(action => action.type === 'ship'); assert.ok(shipment);
assert.equal(shipment.amount, f.minimum); assert.equal(shipment.territory, f.territory);
if (f.kind === 'elite') assert.equal(shipment.elite, 4);
reject(game, f.target, { ...f.shipmentAction, amount: f.minimum - 1 });
game = act(game, f.target, f.shipmentAction); game = advance(game, clean);
const p = player(game, f.target); assert.equal(p.spice, 0); assert.equal(p.forces[f.location], f.minimum);
assert.equal(player(before, f.target).spice - p.spice, f.kind === 'elite' ? 10 : 7);
assert.equal(p.shipped, true); assert.equal(p.moved, 0); assert.equal(game.shipmentPromises![0].fulfilled, true);
assert.equal(game.shipmentPromises![0].released, undefined);
assert.equal(p.reserves, player(before, f.target).reserves - f.minimum);
if (f.kind === 'elite') {
assert.equal(p.elites?.forces[f.location], 4); assert.equal(p.elites?.reserves, 0); assert.equal(p.elites?.tanks, 1);
assert.equal(player(game, f.guild!).spice, guildBefore, 'The real purchased rate sends its invoice to the bank');
} else {
assert.equal(p.forces['arrakeen:10'], 1); assert.equal(isAdvisor(p, 'arrakeen'), true); assert.equal(isAdvisor(p, 'carthag'), false);
assert.equal(p.reserves, 12);
}
assert.deepEqual(inventory(game), f.originalInventory); reject(game, f.target, f.shipmentAction); return game;
}
void test('native Advanced wealthy BG Charity prepares and pays the real seven-counter city commitment', () => {
const f = fixture({ kind: 'bgCharity' }); nativeSource(f); ship(prepare(bound(f), f), f);
});
void test('native Advanced original Emperor Karama prevents wealthy BG Charity and releases the otherwise impossible shipment once', () => {
const f = fixture({ kind: 'bgCharity' }); nativeSource(f);
let game = bound(f); reject(game, f.target, { type: 'ready' });
game = act(game, f.target, f.charityAction); assert.equal(game.response?.kind, 'bgCharity');
assert.equal(player(game, f.target).spice, 5); assert.ok(f.counterHolder);
game = act(game, f.counterHolder, { type: 'card', card: f.karama, mode: 'cancel' }); game = advance(game, clean);
assert.equal(player(game, f.target).spice, 5); assert.equal(game.shipmentPromises![0].released, true);
assert.equal(game.shipmentPromises![0].fulfilled, undefined); assert.equal(viewGame(game, f.target).shipmentPreparation, null);
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
const released = structuredClone(game.shipmentPromises); game = act(game, f.target, { type: 'ready' });
game = advance(game, state => state.phase === 5 && state.active === f.target && clean(state));
reject(game, f.target, f.shipmentAction);
game = act(game, f.target, { ...f.shipmentAction, amount: 5 }); game = advance(game, clean);
assert.equal(player(game, f.target).forces[f.location], 5); assert.equal(player(game, f.target).spice, 0);
assert.deepEqual(game.shipmentPromises, released); assert.deepEqual(inventory(game), f.originalInventory);
});
void test('native Advanced wealthy BG source cannot promise eight without an owned funding source', () => {
const f = fixture({ kind: 'bgCharity' }), game = ask(structuredClone(f.game), f, 8);
assert.deepEqual(viewGame(game, f.target).truthShipmentAnswers, ['no']);
reject(game, f.target, { type: 'truthAnswer', answer: 'yes' });
assert.doesNotThrow(() => act(game, f.target, { type: 'truthAnswer', answer: 'no' }));
});
void test('native Advanced elite-only Storm losses prepare exactly one Sardaukar then original Karama pays nineteen city counters', () => {
const f = fixture({ kind: 'elite' }); nativeSource(f); ship(prepare(bound(f), f), f);
});
void test('native Advanced elite-only source cannot promise twenty or revive its second Sardaukar this turn', () => {
const f = fixture({ kind: 'elite' }); nativeSource(f);
const game = ask(structuredClone(f.game), f, 20); assert.deepEqual(viewGame(game, f.target).truthShipmentAnswers, ['no']);
reject(game, f.target, { type: 'truthAnswer', answer: 'yes' });
reject(f.game, f.target, { type: 'revive', amount: 2, elite: 2, freeElite: 1 });
const prepared = prepare(bound(f), f); assert.equal(player(prepared, f.target).elites?.revived, 1);
reject(prepared, f.target, { type: 'revive', amount: 1, elite: 1 });
});
void test('a genuine earlier Advanced Ready is reopened by either required owned preparation answer', () => {
for (const kind of ['bgCharity', 'elite'] as const) {
const f = fixture({ kind }); let game = act(structuredClone(f.game), f.target, { type: 'ready' });
assert.equal(game.ready.includes(f.target), true); game = bound(f, game);
assert.equal(game.ready.includes(f.target), false); assert.ok(viewGame(game, f.target).shipmentPreparation?.actions.length);
prepare(game, f);
}
});
void test('native Advanced pending shipment questions reject foreign answers and premature shipment or phase skipping', () => {
for (const kind of ['bgCharity', 'elite'] as const) {
const f = fixture({ kind }), game = ask(structuredClone(f.game), f);
reject(game, f.asker, { type: 'truthAnswer', answer: 'yes' }); reject(game, f.target, f.shipmentAction);
reject(game, f.target, { type: 'ready' }); reject(game, f.asker, f.askStep.action);
const done = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
reject(done, f.target, { type: 'truthAnswer', answer: 'yes' }); assert.deepEqual(inventory(done), f.originalInventory);
}
});
void test('all four minimal profiles choose legal original Advanced answers and execute the required owned preparation', () => {
for (const kind of ['bgCharity', 'elite'] as const) {
const f = fixture({ kind }), asked = ask(structuredClone(f.game), f), promised = bound(f);
for (const difficulty of DIFFICULTIES) {
const answering = viewGame(asked, f.target); answering.players.find(p => p.id === f.target)!.bot = difficulty;
const answer = botActions(answering)[0]; assert.ok(answer); assert.equal(answer.type, 'truthAnswer');
assert.ok(answer.answer === 'yes' || answer.answer === 'no'); assert.doesNotThrow(() => act(asked, f.target, answer));
const own = viewGame(promised, f.target); own.players.find(p => p.id === f.target)!.bot = difficulty;
const proposed = botActions(own)[0]; assert.ok(proposed);
assert.equal(proposed.type, kind === 'bgCharity' ? 'charity' : 'revive');
ship(prepare(structuredClone(promised), f, proposed), f);
}
}
});
@@ -0,0 +1,158 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
createTruthtranceEarlyAdvancedShipmentFixture as fixture, actTruthtranceEarlyAdvancedShipment as act,
advanceTruthtranceEarlyAdvancedShipment as advance, finishTruthtranceEarlyAdvancedShipment as finish,
truthtranceEarlyAdvancedShipmentClean as clean, truthtranceEarlyAdvancedShipmentPlayer as player,
truthtranceEarlyAdvancedShipmentInventory as inventory, truthtranceEarlyAdvancedShipmentPhysical as physical,
type TruthtranceEarlyAdvancedShipmentFixture,
} from './fixture-truthtrance-early-advanced-shipment';
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => applyAction(game, actor, action)); assert.deepEqual(game, before);
}
function nativeSource(f: TruthtranceEarlyAdvancedShipmentFixture) {
assert.equal(f.setup.status, 'setup'); assert.equal(f.setup.advanced, true); assert.deepEqual(f.setup.expansions, []);
assert.ok(!f.setup.homeworlds && !f.setup.nexusCards && !f.setup.leaderSkills && !f.setup.techTokens &&
!f.setup.strongholdCards && !f.setup.discoveryEnabled && !f.setup.ecazTreachery);
assert.ok(f.actions.some(step => step.actor === f.lobby.host && step.action.type === 'start' && step.action.advancedPreview === true));
assert.ok(f.beforeStartingDeal.players.every(player => player.hand.length === 0));
assert.deepEqual(f.afterSetup.players.flatMap(player => player.hand.map(card => card.id)), f.deal);
assert.ok(player(f.afterSetup, f.asker).hand.some(card => card.id === f.card && card.effect === 'truthtrance'));
assert.ok(player(f.afterSetup, f.counterHolder).hand.some(card => card.id === f.karama && card.effect === 'karama'));
assert.ok(player(f.afterSetup, f.target).hand.some(card => card.id === f.baliset && card.name === 'Baliset'));
assert.equal(player(f.game, f.target).spice, 10); assert.equal(player(f.game, f.target).reserves, 20);
assert.deepEqual(player(f.game, f.target).forces, {}); assert.equal(player(f.game, f.target).tanks, 0);
assert.equal(player(f.game, f.target).shipped, false); assert.equal(player(f.game, f.target).moved, 0);
assert.equal(f.game.turn, 1); assert.equal(f.game.phase, f.phaseNumber); assert.ok(clean(f.game));
assert.equal(f.game.truthtrance ?? null, null); assert.equal(f.game.shipmentPromises, undefined);
assert.equal(f.originalInventory.length, 33); assert.deepEqual(inventory(f.game), f.originalInventory);
assert.deepEqual(physical(f.game), f.originalPhysical); assert.equal(f.sourceActionCount, f.actions.length);
if (f.phase === 'storm') assert.deepEqual(f.game.stormDials, {});
else {
assert.equal(f.actions.filter(step => step.action.type === 'stormDial').length, 2);
assert.equal(f.game.spiceSequence ?? null, null);
assert.ok(f.game.spiceDiscard[0].some(card => 'territory' in card));
assert.ok(f.game.spiceDiscard[1].some(card => 'territory' in card));
}
}
function asked(f: TruthtranceEarlyAdvancedShipmentFixture) {
const actions: { actor: string; action: Action }[] = [];
let game = act(structuredClone(f.game), f.declarationStep.actor, f.declarationStep.action, actions);
const declared = structuredClone(game);
game = advance(game, state => state.truthtrance?.stage === 'ask', actions);
assert.ok(viewGame(game, f.asker).truthShipmentTargets.includes(f.target));
const beforeQuestion = structuredClone(game);
game = act(game, f.askStep.actor, f.askStep.action, actions);
assert.equal(game.truthtrance?.stage, 'answer');
assert.ok(viewGame(game, f.target).truthShipmentAnswers?.includes('yes'));
assert.ok(viewGame(game, f.target).truthShipmentAnswers?.includes('no'));
return { game, declared, beforeQuestion, actions };
}
function bound(f: TruthtranceEarlyAdvancedShipmentFixture, answer: 'yes' | 'no' = 'yes') {
const question = asked(f), game = act(question.game, f.target, { type: 'truthAnswer', answer }, question.actions);
assert.equal(game.truthtrance ?? null, null); assert.equal(game.shipmentPromises?.length, 1);
assert.equal(game.shipmentPromises![0].player, f.target);
assert.equal(game.shipmentPromises![0].answer, answer === 'yes');
assert.equal(game.shipmentPromises![0].released, undefined);
assert.equal(game.shipmentPromises![0].fulfilled, undefined);
assert.equal(game.phase, f.phaseNumber); assert.deepEqual(physical(game), f.originalPhysical);
assert.deepEqual(inventory(game), f.originalInventory); return game;
}
for (const phase of ['storm', 'charity', 'bidding'] as const) void test(
`public Advanced ${phase} Truthtrance binds an eight-force future shipment without granting an early action`, () => {
const f = fixture({ phase }); nativeSource(f); const game = bound(f);
const view = viewGame(game, f.target);
assert.equal(view.shipmentCompletion, null); assert.equal(view.shipmentPreparation, null);
reject(game, f.target, f.shipment); reject(game, f.target, { type: 'endMovement' });
reject(game, f.target, f.bigBribe);
assert.equal(game.shipmentPromises![0].released, undefined);
});
for (const guildTake of [false, true]) void test(
`public Advanced early shipment Yes retains purchase2 and native Guild ${guildTake ? 'TAKE' : 'WAIT'} through actual eight-force delivery`, () => {
const f = fixture(); nativeSource(f); const game = bound(f);
const completed = finish(f, game, { guildTake });
reject(completed.purchase.before, f.target, f.bigBid);
assert.equal(player(completed.purchase.before, f.target).spice, 10);
assert.equal(player(completed.purchase.after, f.target).spice, 8);
assert.ok(player(completed.purchase.after, f.target).hand.some(card => card.id === f.purchaseCard));
assert.equal(completed.purchase.after.shipmentPromises![0].released, undefined);
assert.ok(completed.timing.some(record => record.steps.some(step => step.actor === f.guild &&
step.action.type === 'decision' && step.action.take === guildTake)));
if (guildTake) {
assert.equal(completed.guildTurn.length, 1);
assert.deepEqual(completed.guildTurn[0].steps, [{ actor: f.guild, action: { type: 'endMovement' } }]);
} else assert.equal(completed.guildTurn.length, 0);
assert.equal(completed.beforeShipment.active, f.target);
assert.equal(viewGame(completed.beforeShipment, f.target).shipmentCompletion?.actions.at(-1)?.amount, 8);
assert.ok(completed.shipment.controls.some(control => control.decision?.kind === 'guildShipment'));
assert.ok(completed.shipment.controls.some(control => control.response?.kind === 'guildIncome'));
assert.equal(player(completed.delivered, f.guild).spice, player(completed.beforeShipment, f.guild).spice + 8);
assert.equal(player(completed.delivered, f.target).spice, 0);
assert.equal(player(completed.delivered, f.target).forces[f.location], 8);
assert.equal(player(completed.delivered, f.target).reserves, 12);
assert.equal(completed.delivered.shipmentPromises![0].fulfilled, true);
assert.equal(completed.delivered.shipmentPromises![0].released, undefined);
assert.ok(player(completed.game, f.counterHolder).hand.some(card => card.id === f.karama));
assert.equal(completed.game.discard.filter(card => card.id === f.card).length, 1);
reject(completed.delivered, f.target, f.shipment);
assert.deepEqual(inventory(completed.game), f.originalInventory);
});
void test('public Advanced early shipment No allows voluntary costs and a real smaller Carthag shipment', () => {
const f = fixture({ phase: 'bidding' }); nativeSource(f); const game = bound(f, 'no');
const voluntary = act(structuredClone(game), f.target, f.bigBribe);
assert.equal(player(voluntary, f.target).spice, 7); assert.equal(voluntary.shipmentPromises![0].released, undefined);
const completed = finish(f, game, { amount: 7, guildTake: true });
reject(completed.beforeShipment, f.target, f.shipment);
assert.equal(player(completed.delivered, f.target).forces[f.location], 7);
assert.equal(player(completed.delivered, f.target).spice, 1);
assert.equal(completed.delivered.shipmentPromises![0].fulfilled, true);
assert.equal(completed.delivered.shipmentPromises![0].released, undefined);
assert.deepEqual(inventory(completed.game), f.originalInventory);
});
void test('an actual first Storm releases an early Advanced shipment only after its current-board route becomes unavailable', () => {
const f = fixture({ phase: 'storm' }); nativeSource(f); let game = bound(f);
const initialStorm = game.storm, distance = (f.sector - initialStorm + 18) % 18;
assert.ok(distance > 0 && distance <= 20); assert.equal(game.stormDialers.length, 2);
for (const [index, actor] of game.stormDialers.entries())
game = act(game, actor, { type: 'stormDial', amount: index === 0 ? distance : 0 });
assert.equal(game.storm, initialStorm); assert.equal(game.stormPending, distance);
assert.equal(game.shipmentPromises![0].released, undefined, 'Recorded future distance is not a future-board promise prediction');
game = advance(game, state => state.phase === 2 && clean(state));
assert.equal(game.storm, f.sector); assert.equal(game.shipmentPromises![0].released, true);
const spent = act(game, f.target, f.bigBribe);
assert.equal(player(spent, f.target).spice, 7);
assert.deepEqual(inventory(spent), f.originalInventory);
});
void test('the genuine early Advanced Truth question rejects foreign and underlying replies before its native answer', () => {
const f = fixture(), question = asked(f), game = question.game;
reject(game, f.asker, { type: 'truthAnswer', answer: 'yes' });
reject(game, f.target, { type: 'ready' }); reject(game, f.target, f.shipment);
reject(game, f.target, f.bigBribe); reject(game, f.asker, f.askStep.action);
const done = act(game, f.target, { type: 'truthAnswer', answer: 'yes' });
reject(done, f.target, { type: 'truthAnswer', answer: 'yes' });
reject(done, f.asker, f.askStep.action);
assert.deepEqual(physical(done), f.originalPhysical); assert.deepEqual(inventory(done), f.originalInventory);
});
void test('all four minimal policies participate legally in the actual early Advanced bid while retaining shipment funds', () => {
const f = fixture(), boundGame = bound(f);
const game = advance(boundGame, state => state.phase === 3 && clean(state) && state.auction?.active === f.target);
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, f.target); view.players.find(player => player.id === f.target)!.bot = difficulty;
const before = structuredClone(view), action = botActions(view)[0]; assert.ok(action, difficulty);
assert.deepEqual(view, before);
const done = act(structuredClone(game), f.target, action);
assert.equal(done.shipmentPromises![0].released, undefined);
assert.ok(player(done, f.target).spice >= 8);
assert.deepEqual(inventory(done), f.originalInventory);
}
});
+2 -1
View File
@@ -76,7 +76,8 @@ void test('current-turn target timing distinguishes earlier phases from unused m
assert.equal(shipmentPromiseTurnAvailable({ ...g, phase: 2 }, 'p', false), true,
'A prior-turn shipped flag does not consume the future current-turn opportunity');
assert.equal(shipmentPromiseModeSupported({ ...g, phase: 4 }, 'p'), true);
assert.equal(shipmentPromiseModeSupported({ ...g, phase: 4, advanced: true }, 'p'), false);
assert.equal(shipmentPromiseModeSupported({ ...g, phase: 4, advanced: true }, 'p'), true);
assert.equal(shipmentPromiseModeSupported({ ...g, phase: 4, advanced: true, homeworlds: {} }, 'p'), false);
assert.equal(shipmentPromiseModeSupported({ ...g, phase: 2, techTokens: {} }, 'p'), false);
const mode = { ...g, advanced: true, active: 'r', expansions: ['choam'], semutaPreview: true,
players: [{ id: 'r', faction: 'richese' }, { id: 'p', faction: 'emperor' }] };