Connect revived force arrivals to Bene Gesserit responses

This commit is contained in:
Quintine committed 2026-10-25 21:36:05 +11:00
1 parent 33523d1926
commit ab69f97cfa
22 files changed
+1269 -121

No files matched your search

+5 -1
View File
@@ -2544,6 +2544,7 @@ export function GameTable({
{g.players.find((player) => player.id === g.response?.owner)?.name} declared {g.response.amount ?? 1} free {(g.response.amount ?? 1) === 1 ? 'force' : 'forces'} to {territory(splitLocation(g.response.location ?? 'polar_sink:0').territory).name}.
{' '}Allow the arrival or cancel Spiritual Advisors. Canceling prevents the whole declared group.
{g.response.spiritualAdvisorSource&&<> Matching or an alone conversion may follow separately. The other faction’s shipment is already committed.</>}
{g.response.spiritualAdvisorSource?.revivalEvent&&<> The revived group stays placed; this response does not cancel its revival.</>}
</p>
)}
{g.response.kind === 'moritaniRetention' &&
@@ -3755,7 +3756,10 @@ export function GameTable({
) : g.decision.kind === 'advisor' ? (
<>
<p className="muted">
Another faction has shipped from off-planet. You may send
{g.decision.revivalEvent
? 'Tleilaxu’s free-revived group has arrived from off-planet. You may send'
: 'Another faction has shipped from off-planet. You may send'}
{' '}
up to {g.homeworldMobility?.advisorSinkMaximum ?? 1} reserve forces to the Polar Sink for free.
</p>
{g.advanced&&<p className="notice">Retained fighters outside the Polar Sink require Polar Sink or decline, unless Intrusion already changed them to advisors. Polar matching and an alone conversion have separate responses.</p>}
@@ -54,6 +54,13 @@ function DeploymentChoice({ game, act, busy }: Props) {
<p className="fine">
{offer.normal} ordinary · {offer.elite} starred · {amount} total
</p>
{game.players.some(player => player.faction === 'beneGesserit') && <p className="fine">
{offer.kind === 'tleilax'
? 'Arrakis placement is followed by the normal Bene Gesserit free-force choice.'
: 'In Advanced play, entry into Bene Gesserit fighters permits Intrusion.'}
{' '}The revived group is already placed before those responses. Preventing
a Bene Gesserit power does not undo your revival or use your ordinary shipment.
</p>}
<label className="block space-y-1">
<span>Revival destination</span>
<select
+29
View File
@@ -16,6 +16,35 @@ open mode gates.
## Current checkpoint and work
**Revived force BG arrival responses — 25 October. Prototyped / Partial.**
Actual revived placement now permits native Advanced Intrusion. Off-planet
Tleilax→Arrakis also earns ordinary BG Spiritual accompaniment under an explicit
provisional SEND composition; Southern remains on-planet. The original group
stays placed through independent BG counters, matching and alone conversion.
Original income waits for Spiritual, queued entry and Ambassador work, then
finishes without another human action. Phase4 packets and Intrusion cancellation
bind their exact return; actual Guild child destinations retain their own source.
Persistent controls and existing minimal policies are connected. Terror's
revival-entry continuation remains unfinished and fenced.
[Source, current policy and limits](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md#revival-arrival-responses--25-october-2026).
Three disjoint helper/arrival/native lanes, bounded review and root integration
pass **19 files, 246/246**, types/lint/build and eight native engine/GameTable SSR
journeys. Public Basic68/Advanced71 actions earn actual High19/Tanks1 through
paid sand shipment and native Storm13, then ordinary free revive1. Decline,
High Wallach two to Polar, Advanced accompaniment, resident Intrusion and both
independent original-Karama counters preserve the placed force and original
bank income1. Actual resident branch72 earns BG's paid fighter first. Four
existing profiles act legally; strategic tuning remains deferred. Controlled
source/arrival cases supplement native paths, including separate Southern
cancellation and Fremen Ambassador relocation; no native combined Ecaz game is
claimed. The old illegal retained-fighter accompaniment assertion now tests
rejection and legal Polar fallback. Cancellation-phase and queued-completion
bugs are repaired; the fresh Basic Guild child now carries its revival tag.
No native-source hand/wallet/force/phase/stance is installed. No saved-room/D1/
server/reset/restart/deployment operation occurred. Browser/mobile/deployed,
hydration, full modes and comprehensive assurance remain open.
**Low Ecaz opponent-owned battle lead — 25 October. Prototyped / Partial.**
The original combined-battle opener now applies the printed Low penalty:
at native population zero through six, the opponent chooses Ecaz or its ally
@@ -2,6 +2,90 @@
Primary-source audit, 10 September 2026. This is a source and implementation-boundary contract, not a runtime completion claim. The coordinator has already asked about threshold restoration and splitting several revived Fedaykin; those answers remain pending.
## Revival arrival responses — 25 October 2026
**Prototyped / Partial.** The original positive revived group now opens native
Advanced BG Intrusion at a destination containing BG fighters. The authorized
supplied revision's physical page28 F uses entry, so this real physical arrival
meets its condition. On Tleilax's off-planet Free Revival deployment to Arrakis,
BG additionally receives its ordinary Spiritual Advisor choice. The face says
**send**, not **ship**: classifying that transfer as qualifying off-planet
shipping for this independent BG advantage is an explicit provisional
composition under the 14 October recommended-choice instruction, consistent
with the earlier Moritani Sneak Attack SEND composition. It is not a new
publisher FAQ. Southern's on-planet group earns no Spiritual accompaniment;
a Homeworld destination earns no board-entry response.
The original group is already physically placed. Resolve Intrusion and its
ordinary Karama response, then the eligible Spiritual choice and its independent
whole-packet response, matching and mandatory-alone conversion. Preventing a BG
advantage never reverses the revival, its committed placement or ordinary quota.
The remaining original entry waits for those children and reaches its actual
Ambassador once, without replaying forces. Tleilax's original free-revival bank
income remains held until arrival finishes and settles once; it is not an extra
shipping fee or a second free-revival event. Add no Guild special-stop window,
ordinary shipment/movement use, purchased-rate consumption or Revival-phase
Heighliner income.
Phase-four Spiritual sources name the exact original revival event and current
board destination, or an actual owned Guild Ambassador child of that return.
No unrelated phase-four packet is admitted. Existing source formats and signature
bytes remain unchanged outside the new provenance. The original return waits
for pending Spiritual, queued entry and Ambassador work. An independent BG
Ambassador root carries its actual committed public packet; old Guild suffixes
retain their original parent links. This is continuation support, not a new
power or a second placement. Persistent guides describe the existing group and
independent counter consequences; minimal policies use the same native choices.
The old blanket Spiritual and fighter-Intrusion destination fences are removed.
**Revival entry into an eligible Terror token remains gated** while its original
entry/overlap continuation is connected: existing overlap/discard code assumes
Shipment and Movement. Its classification policy is selected, but that native
consumer is unfinished, not silently certified by this BG checkpoint. Group/
quantity, Southern storm, Hidden Mobile Stronghold and other existing destination
restrictions remain. Comprehensive assurance and complete modes remain open.
Evidence paths: [destination quote](../game/homeworld-revival-destinations.ts),
[Spiritual source](../game/spiritual-advisor.ts),
[automatic conversion](../game/advisor-automatic-flip.ts),
[original arrival](../game/homeworld-arrival.ts),
[native source](../tests/fixture-revival-arrival-reactions.ts),
[native cases](../tests/revival-arrival-reactions-runtime.test.ts),
[source cases](../tests/spiritual-advisor-revival.test.ts) and
[controlled arrival-root cases](../tests/homeworld-revival-spiritual-arrival.test.ts).
This dated section supersedes the older BG classification fences below;
quantity/splitting and other pending source boundaries are not resolved here.
Frozen **19 files, 246/246**, types/lint/build and eight native engine/GameTable
SSR journeys pass. Public Basic68/Advanced71 source actions earn original E1
setup/47 cards, a paid one-counter sand shipment, its real next-Storm13 loss and
High19/Tanks1 before ordinary Free Revival. Resident72 first earns BG's actual
paid Carthag fighter. Native choices cover decline, High Wallach two to Polar,
Advanced accompaniment, Intrusion and independent original Karama against each
BG advantage. Original Tleilax free-revival bank income1 settles once after the
children; expecting unchanged final spice was an incorrect first fixture
assertion, not a new charge. All four minimal policies propose legal actions.
Eight source and eight controlled arrival-root cases supplement those native
programmes. Separate conserved-board cases cover Southern Intrusion cancellation
without Spiritual and Tleilax entry into an actual Fremen Ambassador relocation.
An older scenario expected forbidden accompaniment into retained BG fighters;
it now asserts rejection, legal Polar fallback and mandatory alone conversion.
No illegal BG suffix is retained merely to keep a historical test green.
The original Fremen relocation remains separately exercised. Native combined
Ecaz, visual/browser/deployed and parent hydration are not claimed.
Runtime exposes and repairs the phase5-only Intrusion cancellation preflight;
its new branch validates the original revived arrival before card cost. A
no-followup Southern case also repairs completion immediately after deferred
entry drains, rather than requiring another human action. Bounded review adds
the revival tag to a fresh Basic Guild advisor child. After the cancellation repair, runtime
passes before the related stale assertion fails; final frozen proof includes
all repairs on identical source. Private source-bound logs/HTML:
`/home/quintine/.local/share/dune/checkpoints/20261025-revival-bg-arrival/`.
No saved-room/D1/server/reset/restart/deployment operation occurred. Terror and
other named boundaries, complete modes and final assurance remain open.
## Printed effects and provenance
The high faces were visually reread in the original 3024×4032 component photograph, locally `/tmp/dune-card-economy-homeworlds.jpg`, associated with [BGG image 7767034](https://boardgamegeek.com/image/7767034/dune-ecaz-and-moritani). See the [component audit](HOMEWORLD_COMPONENT_AUDIT.md) for provenance. The photographed publisher components are evidence; community explanations are not substituted for rules.
@@ -18,6 +18,17 @@ room migration is needed. The subsequent
[Caladan checkpoint](HOMEWORLD_VICTORY_REINFORCEMENT_RUNTIME.md) and implementation
status record the combined verification.
## BG arrival continuation — 25 October 2026
High revived placement now uses native Advanced Intrusion and, for off-planet
Tleilax→Arrakis, an independent BG Spiritual choice. The original group and
revival-income response wait through those native counters and the deferred
entry. Phase4 packets bind the actual return or owned Guild child; stale and
foreign parents do not become shipment authority. Persistent guides and minimal
policies use the same original controls.
[Source, explicit SEND composition and remaining Terror fence](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md#revival-arrival-responses--25-october-2026).
## Supported return and placement
Southern selects only the newly revived physical Fedaykin from a successful
+51
View File
@@ -1,5 +1,56 @@
# Dune implementation status
## 25 October 2026 — Revival placement reaches BG arrival responses and deferred entry
High revived placement now opens native Advanced Intrusion; Tleilax off-planet
Free Revival onto Arrakis provisionally earns normal Spiritual accompaniment.
The supplied p28 entry wording supports Intrusion, while SEND→Spiritual remains
explicit composition under the14 October instruction. Southern is on-planet;
Homeworld destinations remain separate. The original group stays committed
through independent Karama, matching and alone conversion. No fee, ordinary
allowance, Guild stop, purchased-rate use or phase4 industry is introduced.
[Canonical source and remaining Terror fence](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md#revival-arrival-responses--25-october-2026).
Spiritual phase4 sources bind the original return and its board destination,
or an actual current Guild Ambassador child. Other-phase formats remain exact
and old signature bytes stay unchanged. Intrusion cancellation separately binds
its original returned group/location and optional Tleilax followup before card
cost. The return waits for Spiritual, queued entry and original Ambassador work.
Draining a no-reaction deferred entry now completes that parent immediately;
its original revival-income response resumes once without another human action.
Fresh Basic Guild children carry the same source tag. Public guides and four
minimal policies use the native controls; strategy remains deferred.
Three disjoint helper/arrival/native lanes, bounded read-only review and root
integration pass **19 files, 246/246**, types/lint/build and eight native engine/
GameTable SSR journeys. Actual public Basic68/Advanced71 source actions earn
original 47-card E1 setup, native one-counter sand payment, real next-Storm13
loss and High19/Tanks1. Ordinary free revive1 then reaches actual placement,
BG decline/Polar2, Advanced accompaniment, resident Intrusion and independent
original Karama against each BG advantage. Resident72 first earns a paid BG
fighter in Carthag. Original Tleilax bank income1 is held then paid once. No
native-source played hand/wallet/forces/phase/stance is installed. Native policy
paths, eight source cases and eight controlled arrival-root cases remain scoped
evidence, not strategic calibration or native combined-Ecaz certification.
Feedback corrects the native fixture's expected final income, the phase5-only
Intrusion preflight and deferred-parent completion. One related old scenario
expected accompaniment into retained BG fighters, contrary to the adopted
Advanced rule; it now rejects that action and uses legal Polar/alone conversion.
Separate controlled conserved-army cases retain real phase4 Fremen Ambassador
relocation and Southern cancellation without inventing Spiritual. No rule or
source requirement is weakened. The initial list also catches an incorrect
victory-test fragment before the final valid selection; no empty suite is
counted as success. Private proof/HTML:
`/home/quintine/.local/share/dune/checkpoints/20261025-revival-bg-arrival/`.
`frozen-parent-fix/report.json` passes identical tree
`d34e5d39322ab56739eb1c82244c8e866ff87007231187ac743dbb54d6620a61`,
2,817 files, base `33523d1`. Final prose-only evidence overlay is hash/link checked.
Terror entry/overlap remains explicitly unfinished; other source boundaries,
full modes, comprehensive assurance and browser/mobile/deployed acceptance remain
open. SSR is not DOM callbacks or parent hydration. No saved-room/D1/server/
reset/restart/deployment operation occurred.
## 25 October 2026 — Low Ecaz gives the allied battle choice to its opponent
The native Homeworld Low face's printed opponent-choice penalty now connects
+2 -1
View File
@@ -1306,7 +1306,8 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| Ordinary Guild cancellation | Unaffordable repricing, replacement/retry/decline, consumed opportunity, contributor adjustment and ordering with special shipment-stop. The affordability question was already sent. | [Material timing](GUILD_RATE_KARAMA_RULES.md#material-unresolved-timing) |
| Repeat Karama on a canceled compulsory cache lot | After one Karama cancels the compulsory Richese cache auction and restores the ordinary pool, the engine currently still accepts a second Karama against the resumed window and re-derives the ordinary count from the frozen pre-cache field. Whether a repeat cancellation should be refused is not settled; the invariant that a canceled lot never restores more than its frozen pre-cache count does hold. | [Richese auction source](RICHESE_AUCTION_RULES.md), [exhausted-cache runtime](IMPLEMENTATION_STATUS.md) |
| Base special Karama | Unfunded auction recovery, stopped-shipment settlement and Atreides full-plan commitment order remain provisional. Supplied physical27 F now sets its before-public-reveal deadline, including quiet later use after declining the initial offer. Ordinary preparation priority and target-first sequencing remain implementation compositions, with sealed plans retained and no added reading acknowledgement. | [Explicit base interpretations](BASE_ADVANCED_READINESS_20260907.md#explicit-unresolved-base-interpretations), [current full-plan timing](ATREIDES_FULL_PLAN_TIMING.md#before-reveal-window--24-october-2026) |
| Homeworld high-threshold revival deployment | The publisher does not state whether a return that itself first reaches the high threshold may deploy on that same return. The content-first prototype evaluates the resulting population, so the crossing return qualifies; this is a visibly provisional composition, not a located timing ruling. The Tleilaxu low-income start-condition snapshot is unchanged, and partial groups, split destinations, storm placement, allied-Homeworld limits and shipment-classified entry effects remain guarded. | [Benefits contract](HOMEWORLD_BENEFITS_RULES.md#provisional-high-threshold-revival-deployment--9-october-2026), [resumption record](IMPLEMENTATION_STATUS.md#9-october-2026--gated-captures-resumed-under-the-basic-occupation-and-odd-force-occupy-prototypes) |
| Homeworld high-threshold revival deployment | The publisher does not state whether a return that itself first reaches the high threshold may deploy on that same return. The content-first prototype evaluates the resulting population, so the crossing return qualifies; this is a visibly provisional composition, not a located timing ruling. The Tleilaxu low-income start-condition snapshot is unchanged, and partial groups, split destinations, storm placement, allied-Homeworld limits and other entry effects retain their documented boundaries. | [Benefits contract](HOMEWORLD_BENEFITS_RULES.md#provisional-high-threshold-revival-deployment--9-october-2026), [resumption record](IMPLEMENTATION_STATUS.md#9-october-2026--gated-captures-resumed-under-the-basic-occupation-and-odd-force-occupy-prototypes) |
| Revived force arrival / BG | Advanced Intrusion follows supplied p28 entry wording. Provisionally treat off-planet Tleilax SEND to Arrakis as earning ordinary BG Spiritual accompaniment; Southern is on-planet and Homeworld destinations are separate. Keep committed return/placement, ordinary counters, original income and one deferred Ambassador entry; add no fee/ordinary allowance/Guild stop/phase4 industry. Phase4 Spiritual binds the exact return or owned Guild child. Terror continuation remains explicitly unfinished. | [Current source and policy](HOMEWORLD_REVIVAL_DEPLOYMENT_RULES.md#revival-arrival-responses--25-october-2026) |
| Occupied Homeworlds | The publisher question about retention after departure, replacement or contest, unique-power composition and shared income remains open; the first-occupier/current-occupier question was already sent. The content-first Basic benefit selector now uses the visibly provisional current-sole policy above (Tupile excepted), which is an implementation choice, not an answer to that question. | [Occupation questions](HOMEWORLD_OCCUPATION_RULES.md#questions-the-retrieved-sources-do-not-settle), [lifecycle audit](HOMEWORLD_OCCUPATION_LIFECYCLE_AUDIT.md), [Basic policy](HOMEWORLD_OCCUPATION_RULES.md#basic-current-sole-benefit-policy--9-october-2026) |
| Nexus responses and composition | Betrayal private-response timing, Emperor fewer-than-five, fresh advisor/storm cases, Duke restoration and remaining faction-specific combinations remain source-bound. Tleilaxu Secret Ally now has the explicitly provisional recommended combined policy in its own row below; it is not borrowed authority from native revival or Ghola. | [Common boundaries](NEXUS_CARD_RULES.md#remaining-source-questions-and-release-boundaries) and individual faction source/runtime documents |
| Tleilaxu Secret Ally revival | Under choose-recommended-option priority, a quiet unallied actual holder with Tleilaxu absent returns one mandatory own dead leader free, using the ordinary leader slot, and zero-to-five exact counters at one bank spice each. The card shares an ordinary ceiling expanded to at least five; prior normal returns count, larger native/Recruits allowances remain, and the fixed card group is never doubled. Only the unavailable-roster trigger is waived; repeated-death/face-down and special custody rules remain, with original ordinary cycles preserved. No force-only use, native Tleilaxu income or nonexistent cancellation owner is invented. This is provisional implementation policy, not publisher clarification or a separately answered numerical user ruling. | [Recommended combined policy](NEXUS_TLEILAXU_RULES.md#recommended-combined-revival-prototype--14-october-2026) |
+6 -3
View File
@@ -2,7 +2,7 @@ import type { FactionId } from './catalog';
import { gameTerritories, splitLocation, type MobileBoard } from './board';
import { presenceAt, type ForcePresence } from './force-presence';
import { stormExposesTerritory, stormSectorAfter } from './disaster-rules';
import { validateSpiritualAdvisorSource, type SpiritualAdvisorSource } from './spiritual-advisor';
import { validateSpiritualAdvisorSource, type SpiritualAdvisorSource, type SpiritualAdvisorContext } from './spiritual-advisor';
export type AdvisorStance = {
lockedTurn?: number;
@@ -115,6 +115,9 @@ export type AutomaticAdvisorFlipContext = AdvisorSettlementContext & MobileBoard
status: string;
turn: number;
phase: number;
homeworldRevivalReturn?: SpiritualAdvisorContext['homeworldRevivalReturn'];
pendingSpiritualAdvisor?: SpiritualAdvisorContext['pendingSpiritualAdvisor'];
pendingAmbassador?: SpiritualAdvisorContext['pendingAmbassador'];
choamMentatPending?: boolean;
lastBattleContext?: {
event: string;
@@ -167,7 +170,7 @@ function otherPresence(ctx: AdvisorSettlementContext, player: AutomaticAdvisorFl
function currentWindow(ctx: AutomaticAdvisorFlipContext) {
return ctx.advanced === true && ctx.status === 'playing' &&
[0, 1, 3, 5, 6, 8].includes(ctx.phase) && positive(ctx.turn);
[0, 1, 3, 4, 5, 6, 8].includes(ctx.phase) && positive(ctx.turn);
}
/** A supported committed producer opens a response for these current groups.
@@ -487,7 +490,7 @@ export function validateAutomaticAdvisorFlip(
(source.producer === 'specialRelocation'
? record(source.relocation) && (source.relocation.kind === 'discovery'
? source.phase === 0 : source.relocation.kind === 'ambassador' && [1, 3, 5, 8].includes(source.phase))
: source.producer === 'spiritualArrival' ? [1, 3, 5, 8].includes(source.phase)
: source.producer === 'spiritualArrival' ? [1, 3, 4, 5, 8].includes(source.phase)
: source.phase === (source.producer === 'storm' ? 0 : source.producer === 'gamont' ? 8 :
source.producer === 'battleLosses' || source.producer === 'faceDance' ? 6 : 5)) &&
currentWindow(ctx) && source.phase === ctx.phase,
+1 -1
View File
@@ -22,7 +22,7 @@ export type AmbassadorPhaseContext = Pick<
| 'pendingAmbassador'
>;
/** Extra phases belong only to the recorded arrival and its actual BG suffixes. */
/** Extra phases belong only to the recorded arrival and its actual BG arrivals. */
export function ambassadorPhaseAllowed(g: AmbassadorPhaseContext): boolean {
const entry = g.pendingAmbassador;
const candidates: {
+47 -19
View File
@@ -144,7 +144,6 @@ import {
type HomeworldRevivalProgress,
validateHomeworldRevivalReturn,
homeworldRevivalResumeSignature,
appendHomeworldRevivalAmbassador,
homeworldRevivalArrivalSignature,
type HomeworldRevivalReturn,
} from './homeworld-revival-return';
@@ -1026,6 +1025,7 @@ export type Decision =
shipment: string;
destination?: string;
ambassadorEvent?: string;
revivalEvent?: string;
}
| {
kind: 'intrusion';
@@ -1034,7 +1034,8 @@ export type Decision =
wormRide?: boolean;
discoveryEntry?: DiscoveryEntryArrivalChild;
ambassadorEvent?: string;
followup?: { shipment: string; destination: string };
revivalEvent?: string;
followup?: { shipment: string; destination: string; revivalEvent?: string };
}
| { kind: 'advisorBattle'; player: string; territories: string[] }
| {
@@ -1110,8 +1111,9 @@ export type ResponseWindow = {
advisorGatherMove?: { turn: number; move: number; order: MovementOrder };
discoveryEntry?: DiscoveryEntryArrivalChild;
advisorAmbassadorEvent?: string;
advisorRevivalEvent?: string;
advisorRemaining?: string[];
advisorFollowup?: { shipment: string; destination: string };
advisorFollowup?: { shipment: string; destination: string; revivalEvent?: string };
amount?: number;
elite?: number;
recipient?: string;
@@ -1208,6 +1210,7 @@ type PendingHomeworldShipment = HomeworldShipmentIntent & {
allyPayment: number;
pools: ReturnType<typeof quoteHomeworldShipment>['sources'] | ReturnType<typeof quoteGuildHomeworldShipment>['boardSources'];
};
type RevivalEntryParent = { event: string; spiritual?: SpiritualAdvisorSource };
type PendingShipment = {
/** Only new declarations use the bonus; legacy quoted shipments retain their original price. */
smuggler?: SmugglerShipment;
@@ -1977,6 +1980,7 @@ export type Game = {
resume: 'none' | 'wormRide';
turn: number;
phase: number;
revivalArrival?: RevivalEntryParent;
}[] | null;
/** One committed entry with two independently optional expansion reactions. */
pendingArrivalOverlap?: {
@@ -17547,6 +17551,7 @@ function openAmbassadorEntry(
g: Game, owner: Player, token: AmbassadorToken, entrant: Player, to: string,
sector: number, resume: 'none' | 'wormRide',
arrivalCause?: NonNullable<Game['pendingTerrorEntry']>['cause'],
revivalArrival?: RevivalEntryParent,
): boolean {
g.pendingAmbassador = {
...(arrivalCause ? { arrivalCause } : {}),
@@ -17567,6 +17572,14 @@ function openAmbassadorEntry(
: [],
};
g.decision = { kind: 'ecazAmbassador', player: owner.id };
if (revivalArrival) {
const frame = g.homeworldRevivalReturn;
requireRule(frame?.stage === 'arrival' && frame.event === revivalArrival.event,
'This entry has lost its original revived group.');
g.pendingAmbassador.revivalEvent = frame.event;
homeworldRule(() => appendHomeworldArrivalAmbassador(frame, g.pendingAmbassador!, undefined,
revivalArrival.spiritual));
}
log(g, `${owner.name} may trigger the ${faction(token.effect).name} Ambassador because ${entrant.name} entered ${territory(to).name}. The entrant’s remaining actions wait for this opportunity.`);
return true;
}
@@ -17587,6 +17600,7 @@ function openTerritoryEntry(
elite: number,
cause: NonNullable<Game['pendingTerrorEntry']>['cause'],
resume: 'none' | 'wormRide' = 'none',
revivalArrival?: RevivalEntryParent,
) {
// The arrival's own interaction (guild payment, advisor choice, storm
// protection) settles first; the entry then opens from the automatic pass.
@@ -17595,6 +17609,7 @@ function openTerritoryEntry(
(g.pendingArrivalReaction ??= []).push({
entrant: entrant.id, territory: to, sector, amount, elite, cause, resume,
turn: g.turn, phase: g.phase,
...(revivalArrival ? { revivalArrival } : {}),
});
return true;
}
@@ -17634,7 +17649,8 @@ function openTerritoryEntry(
requireRule(!g.response && !g.decision && !g.pendingTerrorEntry && !g.pendingAmbassador,
'Finish the other arrival reaction before opening this entry.');
if (!terrorTokens.length)
return openAmbassadorEntry(g, owner, token, entrant, to, sector, resume, cause);
return openAmbassadorEntry(g, owner, token, entrant, to, sector, resume, cause, revivalArrival);
requireRule(!revivalArrival, 'Revival deployment with overlapping Terror and Ambassador reactions is still being implemented.');
requireRule(arrivalOverlapModeSupported(g) && !g.pendingArrivalOverlap &&
moritani && g.order.includes(owner.id) && g.order.includes(moritani.id),
'Ambassador and Terror overlap still needs a supported classic Ecaz/Moritani table.');
@@ -18447,6 +18463,7 @@ function continueGuildAmbassadorShipment(g: Game, event: string) {
shipment: p.id,
destination: location(order.territory, order.sector),
ambassadorEvent: event,
...(g.phase === 4 && entry.revivalEvent ? {revivalEvent:entry.revivalEvent} : {}),
};
return;
}
@@ -20524,9 +20541,9 @@ function quoteSpiritualOrder(g:Game,decision:Extract<Decision,{kind:'advisor'}>,
}
function spiritualAdvisorChoices(g:Game,id:string){
const decision=g.decision;
if(!g.advanced||decision?.kind!=='advisor'||decision.player!==id)return null;
if(decision?.kind!=='advisor'||decision.player!==id||(!g.advanced&&!decision.revivalEvent))return null;
const destination=decision.destination?splitLocation(decision.destination).territory:null;
const choices=[...(destination?gameTerritories(g).find(t=>t.id===destination)?.sectors.map(sector=>
const choices=[...(g.advanced&&destination?gameTerritories(g).find(t=>t.id===destination)?.sectors.map(sector=>
({accompany:true,territory:destination,sector,amount:1}))??[]:[]),
...Array.from({length:Math.max(1,spiritualAdvisorMaximum(g,id))},(_,index)=>
({accompany:false,territory:'polar_sink',sector:0,amount:index+1}))];
@@ -20546,7 +20563,7 @@ function spiritualAdvisorChoices(g:Game,id:string){
}
function spiritualFrameBody(g:Game){
const frame=g.pendingSpiritualAdvisor;
requireRule(frame&&g.advanced&&g.status==='playing'&&frame.turn===g.turn&&frame.phase===g.phase&&
requireRule(frame&&(g.advanced||frame.phase===4&&!!frame.decision.revivalEvent)&&g.status==='playing'&&frame.turn===g.turn&&frame.phase===g.phase&&
getPlayer(g,frame.decision.player).faction==='beneGesserit'&&
frame.signature===JSON.stringify([frame.event,spiritualParentSignature(g,frame.decision)]),
'Spiritual Advisors lost the original committed foreign shipment.');
@@ -20598,7 +20615,9 @@ function beginSpiritualAdvisor(g:Game,decision:Extract<Decision,{kind:'advisor'}
signature:JSON.stringify([event,spiritualParentSignature(g,decision)]),stage:'offer',denied:[]};
}
const source:SpiritualAdvisorSource={event:frame.event,owner:decision.player,turn:g.turn,phase:g.phase,
territory:order.territory,sector:order.sector,amount:order.amount};
territory:order.territory,sector:order.sector,amount:order.amount,
...(g.phase===4&&(decision.revivalEvent||g.pendingAmbassador?.revivalEvent)
?{revivalEvent:decision.revivalEvent??g.pendingAmbassador!.revivalEvent}: {})};
if(spiritualAdvisorArrival(g,source.owner,source.territory).matching)
requireRule(!frame.denied.includes(spiritualAdvisorMatchingKey(source)),
'Matching this free packet here was prevented. Choose another legal packet or decline.');
@@ -20676,7 +20695,8 @@ function completeSpiritualAdvisorArrival(g:Game){
advisorAmbassadorEvent:frame.decision.ambassadorEvent},
{...order,amount:order.amount as 1|2,advisors:isAdvisor(bg,order.territory)});
}else{
openTerritoryEntry(g,bg,order.territory,order.sector,order.amount,0,'advisor');
openTerritoryEntry(g,bg,order.territory,order.sector,order.amount,0,'advisor','none',
frame.source!.revivalEvent ? {event:frame.source!.revivalEvent,spiritual:frame.source} : undefined);
}
}
type TransportMatchingIntent=NonNullable<Game['pendingAdvisorTransportMatching']>['intent'];
@@ -22099,8 +22119,9 @@ function finishAdvisorReaction(
advisorResume?: ResponseWindow['advisorResume'];
discoveryEntry?: DiscoveryEntryArrivalChild;
advisorAmbassadorEvent?: string;
advisorRevivalEvent?: string;
advisorRemaining?: string[];
advisorFollowup?: { shipment: string; destination: string };
advisorFollowup?: { shipment: string; destination: string; revivalEvent?: string };
},
) {
const bg = byFaction(g, 'beneGesserit')!;
@@ -22125,7 +22146,8 @@ function intrusion(
wormRide?: boolean;
discoveryEntry?: DiscoveryEntryArrivalChild;
ambassadorEvent?: string;
followup?: { shipment: string; destination: string };
revivalEvent?: string;
followup?: { shipment: string; destination: string; revivalEvent?: string };
} = {},
) {
const bg = byFaction(g, 'beneGesserit');
@@ -27959,7 +27981,8 @@ function resumeHomeworldRevivalReturn(g: Game) {
if (!frame || frame.stage === 'complete' || frame.stage === 'choice' ||
g.pendingTreacheryDiscard || g.pendingNullentropy || g.truthtrance ||
g.pendingExchange || g.pendingRicheseGift || g.pendingAmbassador ||
g.pendingTerrorEntry || g.pendingKarama || g.decision) return;
g.pendingTerrorEntry || g.pendingKarama || g.decision || g.pendingSpiritualAdvisor ||
g.pendingArrivalReaction?.length || g.pendingArrivalOverlap) return;
if (frame.stage === 'arrival') {
if (!g.response) completeHomeworldRevivalReturn(g);
return;
@@ -28014,11 +28037,14 @@ function decideHomeworldRevivalReturn(g: Game, p: Player, action: Action) {
frame.arrivalSignature = homeworldRevivalArrivalSignature(frame);
if (quote.selected.territory) {
place(p, quote.selected.territory, quote.selected.sector!, amount, frame.quote.elite);
openTerritoryEntry(g, p, quote.selected.territory, quote.selected.sector!, amount, frame.quote.elite, 'homeworldRevival');
if (g.pendingAmbassador) {
g.pendingAmbassador.revivalEvent = frame.event;
appendHomeworldRevivalAmbassador(frame, g.pendingAmbassador);
}
const bg = byFaction(g, 'beneGesserit');
const followup = frame.quote.kind === 'tleilax' && bg && bg.id !== p.id &&
spiritualAdvisorMaximum(g, bg.id) > 0
? {shipment:p.id,destination:quote.selected.id,revivalEvent:frame.event} : undefined;
if (!intrusion(g, p, quote.selected.territory, {revivalEvent:frame.event,...(followup ? {followup} : {})}) && followup)
g.decision = {kind:'advisor',player:bg!.id,...followup};
openTerritoryEntry(g, p, quote.selected.territory, quote.selected.sector!, amount,
frame.quote.elite, 'homeworldRevival', 'none', {event:frame.event});
}
log(g, `${p.name} placed ${amount} newly revived ${frame.quote.kind === 'fedaykin' ? 'Fedaykin' : 'free forces'} in ${quote.selected.name}. No additional revival payment or shipment allowance was used.`,
{ faction: p.faction, name: frame.quote.kind === 'fedaykin' ? 'Southern Hemisphere revival' : 'Tleilax free revival' });
@@ -33378,7 +33404,8 @@ function settleAutomaticContinuations(g: Game) {
const entrant = g.players.find(p => p.id === deferred.entrant);
requireRule(!!entrant, 'The deferred arrival entry has lost its entrant.');
openTerritoryEntry(g, entrant!, deferred.territory, deferred.sector,
deferred.amount, deferred.elite, deferred.cause, deferred.resume);
deferred.amount, deferred.elite, deferred.cause, deferred.resume, deferred.revivalArrival);
if (deferred.revivalArrival) resumeHomeworldRevivalReturn(g);
continue;
}
const response = g.response;
@@ -35259,6 +35286,7 @@ function applyActionInner(
? ('wormRide' as const)
: undefined,
advisorAmbassadorEvent: decision.ambassadorEvent,
advisorRevivalEvent: decision.revivalEvent,
discoveryEntry: decision.discoveryEntry,
};
if (action.accept)
@@ -35297,7 +35325,7 @@ function applyActionInner(
};
} else movementTurn(g);
} else if (decision.kind === 'advisor') {
if(g.advanced){
if(g.advanced||decision.revivalEvent!==undefined){
requireRule(typeof action.accept==='boolean','Choose the free Spiritual shipment or decline.');
if(action.accept){
beginSpiritualAdvisor(g,decision,action);
+79 -22
View File
@@ -1,12 +1,14 @@
import type { Game } from './engine';
import { location, validLocation } from './board';
import { location, splitLocation, validLocation } from './board';
import { HomeworldCustodyError } from './homeworld-custody';
import type { SpiritualAdvisorSource } from './spiritual-advisor';
export type HomeworldAmbassadorArrival = {
event: string;
entrant: string;
destination: string;
parent?: string;
spiritual?: SpiritualAdvisorSource;
completed: boolean;
};
export type HomeworldArrivalFrame = {
@@ -42,12 +44,45 @@ function keys(value: unknown, allowed: string[]): boolean {
);
}
/** This is the public source of an already committed Spiritual Advisor arrival,
* not another permission or a reconstruction from current BG reserves. */
function spiritualSource(
frame: HomeworldArrivalFrame,
entry: HomeworldAmbassadorArrival,
): SpiritualAdvisorSource & { revivalEvent: string } {
const source = entry.spiritual as (SpiritualAdvisorSource & { revivalEvent?: string }) | undefined;
if (!source || !keys(source, ['event', 'owner', 'turn', 'phase', 'territory', 'sector', 'amount', 'revivalEvent'])) fail();
const destination = splitLocation(frame.destination!);
if (
Object.keys(source).sort().join(',') !== 'amount,event,owner,phase,revivalEvent,sector,territory,turn' ||
!text(source.event) ||
!text(source.owner) ||
source.owner !== entry.entrant ||
source.owner === frame.player ||
source.revivalEvent !== frame.event ||
frame.phase !== 4 || source.phase !== 4 ||
source.turn !== frame.turn ||
!Number.isSafeInteger(source.turn) || source.turn < 1 ||
!text(source.territory) ||
!Number.isSafeInteger(source.sector) ||
!validLocation(source.territory, source.sector) ||
!validLocation(destination.territory, destination.sector) ||
(source.territory !== destination.territory && source.territory !== 'polar_sink') ||
(source.amount !== 1 && source.amount !== 2) ||
entry.parent !== undefined ||
entry.destination !== location(source.territory, source.sector)
) fail();
return source as SpiritualAdvisorSource & { revivalEvent: string };
}
/** Arrival provenance is fixed separately from the original revival grant. */
export function homeworldArrivalSignature(
frame: HomeworldArrivalFrame,
): string {
if (!text(frame.destination) || !Array.isArray(frame.ambassadors)) fail();
const seen = new Set<string>();
const spiritualEvents = new Set<string>();
let primary = false;
for (const [index, entry] of frame.ambassadors.entries()) {
if (
!keys(entry, [
@@ -55,6 +90,7 @@ export function homeworldArrivalSignature(
'entrant',
'destination',
'parent',
'spiritual',
'completed',
]) ||
!text(entry.event) ||
@@ -62,14 +98,17 @@ export function homeworldArrivalSignature(
!text(entry.destination) ||
typeof entry.completed !== 'boolean' ||
(index < frame.ambassadors.length - 1 && !entry.completed) ||
seen.has(entry.event) ||
(index === 0
? entry.parent !== undefined ||
entry.entrant !== frame.player ||
entry.destination !== frame.destination
: !text(entry.parent) || !seen.has(entry.parent))
seen.has(entry.event)
)
fail();
if (entry.spiritual !== undefined) {
const source = spiritualSource(frame, entry);
if (spiritualEvents.has(source.event)) fail();
spiritualEvents.add(source.event);
} else if (entry.parent === undefined) {
if (primary || entry.entrant !== frame.player || entry.destination !== frame.destination) fail();
primary = true;
} else if (index === 0 || !text(entry.parent) || !seen.has(entry.parent)) fail();
seen.add(entry.event);
}
if (frame.destination === 'decline' && frame.ambassadors.length !== 0) fail();
@@ -84,6 +123,18 @@ export function homeworldArrivalSignature(
entry.destination,
entry.parent ?? null,
entry.completed,
// Legacy rows remain exactly five values. Only a new independent
// Spiritual root adds its canonical, public placement source.
...(entry.spiritual === undefined ? [] : [[
entry.spiritual.event,
entry.spiritual.owner,
entry.spiritual.turn,
entry.spiritual.phase,
entry.spiritual.territory,
entry.spiritual.sector,
entry.spiritual.amount,
spiritualSource(frame, entry).revivalEvent,
]]),
]),
]);
}
@@ -100,6 +151,7 @@ export function appendHomeworldArrivalAmbassador(
phase: number;
},
parent?: string,
spiritual?: SpiritualAdvisorSource,
): void {
if (
frame.stage !== 'arrival' ||
@@ -110,20 +162,23 @@ export function appendHomeworldArrivalAmbassador(
(parent !== undefined && parent !== frame.ambassadors!.at(-1)?.event)
)
fail();
const appended: HomeworldAmbassadorArrival = {
event: entry.event,
entrant: entry.entrant,
destination: location(entry.territory, entry.sector),
completed: false,
...(parent === undefined ? {} : { parent }),
...(spiritual === undefined ? {} : { spiritual }),
};
const ambassadors = [
...frame.ambassadors!,
{
event: entry.event,
entrant: entry.entrant,
destination: location(entry.territory, entry.sector),
completed: false,
...(parent === undefined ? {} : { parent }),
},
appended,
];
const arrivalSignature = homeworldArrivalSignature({
...frame,
ambassadors,
});
if (spiritual !== undefined) appended.spiritual = structuredClone(spiritual);
frame.ambassadors = ambassadors;
frame.arrivalSignature = arrivalSignature;
}
@@ -175,14 +230,16 @@ export function validateHomeworldArrival(
(frame.stage === 'arrival' &&
(frame.turn !== g.turn || frame.phase !== g.phase)) ||
frame.arrivalSignature !== homeworldArrivalSignature(frame) ||
frame.ambassadors!.some(
(entry, index) =>
!g.players.some(
(seat) =>
seat.id === entry.entrant &&
(index === 0 || seat.faction === 'beneGesserit'),
),
)
frame.ambassadors!.some((entry) => {
if (entry.spiritual !== undefined) {
const entrants = g.players.filter((seat) => seat.id === entry.entrant);
const owners = g.players.filter((seat) => seat.id === frame.player);
return tag !== 'revivalEvent' || entrants.length !== 1 ||
entrants[0].faction !== 'beneGesserit' || owners.length !== 1 || owners[0].faction !== 'tleilaxu';
}
return !g.players.some((seat) => seat.id === entry.entrant &&
(entry.parent === undefined || seat.faction === 'beneGesserit'));
})
)
fail();
const unfinished = frame.ambassadors!.find((entry) => !entry.completed);
+6 -14
View File
@@ -1,14 +1,12 @@
import type { Game } from './engine';
import { gameTerritories, location, MOBILE_STRONGHOLD, splitLocation } from './board';
import { presenceAt } from './force-presence';
import { fighterCount } from './advisors';
import { territoryEntryBlock } from './occupancy';
import { homeworldContext } from './homeworld-game';
import { HOMEWORLD_CARDS } from './homeworld-cards';
import { homeworldForceGroups, quoteHomeworldCustody, HomeworldCustodyError } from './homeworld-custody';
import { quoteNativeReserveWithdrawal } from './homeworld-native-reserves';
import { lowGrummanRevealBlock } from './homeworld-collection';
import { homeworldSpiritualAdvisorLimit } from './homeworld-mobility';
import type { HomeworldRevivalDeploymentQuote } from './homeworld-revival-deployment';
type Context = Pick<Game, 'advanced' | 'players' | 'homeworlds' | 'storm' | 'mobileStronghold' | 'moritaniTerror'>;
@@ -22,7 +20,7 @@ export type HomeworldRevivalDestination = {
};
/** Only the complete earned group to one destination is admitted here.
* Shipment-specific reactions remain explicit source boundaries. No token face,
* The engine owns provisional SEND-entry reactions and their continuation. No token face,
* opposing hand, spice balance or concealed denomination influences these lists. */
export function homeworldRevivalDestinations(
g: Context,
@@ -33,17 +31,13 @@ export function homeworldRevivalDestinations(
if (!p || !g.homeworlds?.custody)
throw new HomeworldCustodyError('Revival placement needs its seated native Homeworld owner.');
const amount = grant.normal + grant.elite;
const bg = g.players.find((seat) => seat.faction === 'beneGesserit');
const moritani = g.players.find((seat) => seat.faction === 'moritani');
const advisors = grant.kind === 'tleilax' && bg && bg.reserves > 0 &&
homeworldSpiritualAdvisorLimit(g, bg.id, 'polar_sink') > 0
? 'Spiritual Advisors after Tleilax revival deployment await a shipment-classification ruling.' : null;
const moritani = g.players.find(seat => seat.faction === 'moritani');
const destinations: HomeworldRevivalDestination[] = gameTerritories(g).flatMap((t) => {
if (grant.kind === 'fedaykin' && presenceAt(p, t.id) === 0) return [];
return t.sectors.map((sector) => {
const effectiveSector = t.id === MOBILE_STRONGHOLD
? splitLocation(g.mobileStronghold!.location!).sector : sector;
let blocked = grant.blocked ?? advisors;
let blocked = grant.blocked;
if (!blocked && effectiveSector !== 0 && effectiveSector === g.storm)
blocked = grant.kind === 'fedaykin'
? 'Fedaykin revival placement into storm awaits its own permission ruling.'
@@ -51,12 +45,10 @@ export function homeworldRevivalDestinations(
if (!blocked && t.id === MOBILE_STRONGHOLD)
blocked = 'Revival placement in the Hidden Mobile Stronghold awaits its shipment-classification ruling.';
blocked ??= territoryEntryBlock(g.players, p.id, t.id);
if (!blocked && g.advanced && bg && bg.id !== p.id && fighterCount(bg, t.id) > 0)
blocked = 'Intrusion after Homeworld revival placement awaits an entry-classification ruling.';
if (!blocked && moritani && p.id !== moritani.id && p.id !== moritani.ally &&
!lowGrummanRevealBlock(g, moritani.id, amount) &&
g.moritaniTerror?.tokens.some((token) => token.status === 'placed' && token.location === t.id))
blocked = 'Terror after Homeworld revival placement awaits an entry-classification ruling.';
!lowGrummanRevealBlock(g, moritani.id, amount) &&
g.moritaniTerror?.tokens.some(token => token.status === 'placed' && token.location === t.id))
blocked = 'Revival SEND into Terror awaits its original entry continuation.';
return { id: location(t.id, sector), name: `${t.name}${sector ? ` · sector ${sector}` : ''}`,
territory: t.id, sector, blocked };
});
+24
View File
@@ -1,6 +1,8 @@
import { validAmbassadorResume } from './ambassador-resume';
import { validateDiscoveryEntryArrivalChild, validateDiscoveryEntryRound } from './discovery-entry';
import { ambassadorPhaseAllowed } from './ambassador-phase';
import { validateHomeworldRevivalReturn } from './homeworld-revival-return';
import { HomeworldCustodyError } from './homeworld-custody';
import {
validateGuildAmbassadorArrivalContext,
GuildAmbassadorContinuationError,
@@ -512,6 +514,28 @@ export function quoteMovementCancellation(
'Only an Ambassador arrival may carry its event.',
);
if (response.advisorResume === 'wormRide') return null;
if (response.advisorRevivalEvent !== undefined) {
const parent = g.homeworldRevivalReturn;
try { validateHomeworldRevivalReturn(g, parent); }
catch (error) {
if (error instanceof HomeworldCustodyError) throw new MovementCancellationError(error.message);
throw error;
}
requireContext(g.advanced && response.advisors === true &&
parent?.stage === 'arrival' && parent.event === response.advisorRevivalEvent &&
parent.turn === g.turn && parent.phase === g.phase && parent.player !== owner.id &&
typeof parent.destination === 'string' && sectorKey(g, parent.destination) &&
response.location === splitLocation(parent.destination).territory &&
response.advisorResume === undefined && response.advisorRemaining === undefined,
'The canceled Intrusion lost its original committed revived-force arrival.');
const followup = response.advisorFollowup;
requireContext(followup === undefined || (parent.quote.kind === 'tleilax' &&
followup.shipment === parent.player && followup.destination === parent.destination &&
followup.revivalEvent === parent.event),
'The revived Intrusion lost its original Spiritual Advisor followup.');
return { kind: 'advisorFlip', successor: followup &&
homeworldSpiritualAdvisorLimit(g, owner.id, 'polar_sink') > 0 ? 'advisor' : 'none' };
}
requireContext(
g.phase === 5,
'This advisor cancellation is outside its shipment and movement phase.',
+1
View File
@@ -4587,6 +4587,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Battle cleanup checks population when each card is actually discarded. Losing-card disposal comes before winner casualties. Mandatory winning cards, including a used Poison Tooth, are discarded after winner casualties and any Ixian substitution, before optional winning-card cleanup. If Ecaz falls below seven during its winning losses, its later card discards earn no poison income.',
'If Tleilax starts Revival at low population, it receives no bank income for other factions’ ordinary free revivals throughout that phase, even if its population rises. Paid revival receipts and the separately named Ghola reward remain available.',
'Low Caladan prevents the phase-opening Spice Blow peek. Low Wallach IX prevents free Spiritual Advisors, while high Wallach IX allows one or two to Polar Sink after another faction’s qualifying shipment. Accompanying elsewhere still uses one. Low Ix prevents moving the mobile stronghold; low Richese prevents moving a No-Field token. Physical-force movement and ordinary paid shipments retain their separate rules.',
'Newly revived eligible placement permits Advanced BG Intrusion when entering fighters. Under the provisional SEND composition, Tleilax free forces sent from off-planet to Arrakis also earn ordinary Spiritual accompaniment; Southern on-planet placement does not. The original group stays placed through independent BG counters, and original revival income waits until arrival finishes. No fee, ordinary allowance, Guild stop or Revival-phase industry is added. Phase-four Spiritual sources retain the original return or actual Guild child. Terror-entry continuations remain unfinished.',
'After a supported Atreides victory, Caladan with at least six native reserves may add one reserve force to the battle location if an Atreides force survives there. The owned choice follows ordinary battle cleanup and offers an eligible sector or the original foreign Homeworld. This spends no spice or shipment allowance. Leaving the force in reserves is optional; a battle on Caladan itself cannot create an extra counter from reserves already there.',
'Caladan reinforcement keeps the original battle identity through refresh and supported Ambassador reactions, so the same victory cannot add a second force. Placement involving Face Dancer ordering, storm, the mobile stronghold or unresolved entry reactions remains guarded. These guards do not certify those combined rules.',
'At low population, Tupile lets CHOAM learn an opposing faction’s current spice and either its weapon count or defense count, once per faction for the entire game. CHOAM must have forces on that faction’s Homeworld, or that faction must have forces on Tupile. Either Emperor Homeworld supplies the same single entitlement. The answer is recorded privately as a historical observation and does not update when that faction’s hand or spice changes.',
+35 -2
View File
@@ -1,5 +1,6 @@
import { gameTerritories, splitLocation, type MobileBoard } from './board';
import type { FactionId } from './catalog';
import type { Game } from './engine';
import { presenceAt, type ForcePresence } from './force-presence';
export type SpiritualAdvisorSource = {
@@ -10,6 +11,7 @@ export type SpiritualAdvisorSource = {
territory: string;
sector: number;
amount: number;
revivalEvent?: string;
};
export type SpiritualAdvisorPlayer = ForcePresence & {
@@ -27,6 +29,11 @@ export type SpiritualAdvisorContext = SpiritualAdvisorBoardContext & MobileBoard
status: string;
turn: number;
phase: number;
homeworldRevivalReturn?: Pick<NonNullable<Game['homeworldRevivalReturn']>, 'event' | 'turn' | 'phase' | 'stage' | 'destination'>;
pendingSpiritualAdvisor?: Pick<NonNullable<Game['pendingSpiritualAdvisor']>, 'event' | 'turn' | 'phase'> & {
decision: Pick<NonNullable<Game['pendingSpiritualAdvisor']>['decision'], 'player' | 'destination' | 'ambassadorEvent' | 'revivalEvent'>;
};
pendingAmbassador?: Pick<NonNullable<Game['pendingAmbassador']>, 'event' | 'revivalEvent' | 'turn' | 'phase' | 'stage'> | null;
};
export type SpiritualAdvisorTarget = string | { territory: string; sector?: number };
@@ -110,9 +117,10 @@ export function spiritualAdvisorMatchingKey(source: SpiritualAdvisorSource): str
* checked by the original shipment parent, before any physical placement. */
export function validateSpiritualAdvisorSource(ctx: SpiritualAdvisorContext, source: SpiritualAdvisorSource): void {
requireSpiritual(record(source) &&
Object.keys(source).sort().join(',') === 'amount,event,owner,phase,sector,territory,turn' &&
Object.keys(source).sort().join(',') === (source.phase === 4
? 'amount,event,owner,phase,revivalEvent,sector,territory,turn' : 'amount,event,owner,phase,sector,territory,turn') &&
identifier(source.event) && identifier(source.owner) && positive(source.turn) && source.turn === ctx.turn &&
source.phase === ctx.phase && [1, 3, 5, 8].includes(source.phase) && ctx.status === 'playing' &&
source.phase === ctx.phase && [1, 3, 4, 5, 8].includes(source.phase) && ctx.status === 'playing' &&
identifier(source.territory) && Number.isSafeInteger(source.sector) && source.sector >= 0 &&
(source.amount === 1 || source.amount === 2),
'Spiritual Advisors lost their exact current-turn public shipment source.');
@@ -120,4 +128,29 @@ export function validateSpiritualAdvisorSource(ctx: SpiritualAdvisorContext, sou
requireSpiritual(gameTerritories(ctx).some((place) =>
place.id === source.territory && place.sectors.includes(source.sector)),
'Spiritual Advisors need their current board destination and sector.');
if (source.phase === 4) {
const parent = ctx.homeworldRevivalReturn;
requireSpiritual(identifier(source.revivalEvent) && record(parent) &&
parent.event === source.revivalEvent && parent.turn === ctx.turn && parent.phase === 4 &&
parent.stage === 'arrival' && identifier(parent.destination),
'Revival Spiritual Advisors need their original current-turn arrival parent.');
const destination = splitLocation(parent.destination);
requireSpiritual(parent.destination === `${destination.territory}:${destination.sector}` &&
gameTerritories(ctx).some(place => place.id === destination.territory && place.sectors.includes(destination.sector)),
'Revival Spiritual Advisors need their original board arrival parent.');
const frame = ctx.pendingSpiritualAdvisor, ambassador = ctx.pendingAmbassador;
const decision = frame?.decision;
const childDestination = identifier(decision?.destination) ? splitLocation(decision.destination) : null;
const guildChild = record(frame) && frame.event === source.event && frame.turn === ctx.turn && frame.phase === 4 &&
record(decision) && decision.player === source.owner && identifier(decision.ambassadorEvent) &&
(decision.revivalEvent === undefined || decision.revivalEvent === source.revivalEvent) &&
record(ambassador) && ambassador.event === decision.ambassadorEvent && ambassador.revivalEvent === source.revivalEvent &&
ambassador.turn === ctx.turn && ambassador.phase === 4 && ambassador.stage === 'arrival' &&
childDestination && decision.destination === `${childDestination.territory}:${childDestination.sector}` &&
gameTerritories(ctx).some(place => place.id === childDestination.territory && place.sectors.includes(childDestination.sector)) &&
source.territory === childDestination.territory;
requireSpiritual(source.territory === 'polar_sink' ||
(decision?.ambassadorEvent !== undefined ? guildChild : source.territory === destination.territory),
'Revival Spiritual Advisors need the original board arrival, its owned Guild shipment or Polar Sink.');
}
}
+1 -1
View File
@@ -78,7 +78,7 @@ void test('quotes exclude held, occupied and physically empty advisor groups whi
ctx.players[1].forces = { ...ctx.players[1].forces, 'arrakeen:10': 1 };
assert.deepEqual(quoteAutomaticAdvisorFlips(ctx), []);
for (const patch of [
{ advanced: false }, { status: 'lobby' }, { status: 'setup', phase: 0 }, { phase: 2 }, { phase: 4 }, { phase: 7 },
{ advanced: false }, { status: 'lobby' }, { status: 'setup', phase: 0 }, { phase: 2 }, { phase: 7 },
]) {
assert.deepEqual(quoteAutomaticAdvisorFlips({ ...context(), ...patch }), []);
}
+201
View File
@@ -0,0 +1,201 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { homeworldGameIntegrity, homeworldTable } from '../game/homeworld-game';
import { nativeShipmentSources } from '../game/homeworld-options';
import { homeworldRevivalDeploymentChoice } from '../game/homeworld-revival-deployment-options';
import {
actAdvisorPreparation, advisorPreparationInventory, advisorPreparationPlayer,
} from './fixture-advisor-preparation';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
export type RevivalArrivalReactionsStep = { actor: string; action: Action };
export type RevivalArrivalReactionsTransition = {
before: Game; steps: RevivalArrivalReactionsStep[]; after: Game;
};
export const actRevivalArrivalReactions = actAdvisorPreparation;
export const revivalArrivalReactionsPlayer = advisorPreparationPlayer;
export const revivalArrivalReactionsInventory = advisorPreparationInventory;
export const revivalArrivalReactionsClean = (game: Game) => !game.phaseOpening && !game.response &&
!game.decision && !game.pendingRevival && !game.truthtrance && !game.pendingTreacheryDiscard;
/** Owned placement, Intrusion and Spiritual choices remain caller controls.
* Both actual Storms advance through native dials/readiness, never a clock edit. */
export function nextRevivalArrivalReactions(game: Game): RevivalArrivalReactionsStep | null {
if (game.truthtrance || game.response?.automaticAdvisorFlip ||
game.response?.kind === 'advisor' || game.response?.kind === 'advisorFlip') return null;
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
const decision = game.decision;
if (decision && ['homeworldRevivalDeployment', 'advisor', 'intrusion', 'revivalStop'].includes(decision.kind))
return null;
if (game.status === 'setup' && game.setupStage === 'prediction')
return { actor: 'beneGesserit', action: { type: 'predict', faction: 'tleilaxu', turn: 10 } };
if (game.advanced && game.status === 'setup' && game.setupStage === 'forces' &&
!revivalArrivalReactionsPlayer(game, 'beneGesserit').advisorSetup)
return { actor: 'beneGesserit', action: { type: 'advisorSetup', territory: 'polar_sink', sector: 0 } };
if (decision?.kind === 'advisorBattle')
return { actor: 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
? game.stormDialers.indexOf(actor) === 0 ? game.advanced ? 11 : 10 : 0
: 1 } };
}
}
return nextStrongholdFactionsNativeStep(game);
}
export function advanceRevivalArrivalReactions(game: Game, until: (state: Game) => boolean,
actions: RevivalArrivalReactionsStep[] = []): Game {
for (let count = 0; count < 1200; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original revival-arrival source ended before its next control');
const next = nextRevivalArrivalReactions(game);
assert.ok(next, `Choose the original arrival/response at ${game.turn}/${game.phase}/${game.decision?.kind}/${game.response?.kind}`);
game = actRevivalArrivalReactions(game, next.actor, next.action, actions);
}
throw Error('The genuine revival-arrival programme did not reach its finite boundary');
}
/** Calling this explicitly allows ordinary responses, including a declared
* Spiritual shipment/Intrusion flip. A new automatic conversion stays owned. */
export function passRevivalArrivalReactions(game: Game, actions: RevivalArrivalReactionsStep[] = []): Game {
for (let count = 0; game.response && !game.response.automaticAdvisorFlip && count < 100; count++) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
game = actRevivalArrivalReactions(game, actor.id, { type: 'passResponse' }, actions);
}
assert.ok(!game.response || game.response.automaticAdvisorFlip); return game;
}
/** Earn the original returned-group decision; no destination or BG reaction is chosen. */
export function openRevivalArrivalReactions(game: Game, fixture: { owner: string; revivalAction: Action },
actions: RevivalArrivalReactionsStep[] = []): Game {
game = actRevivalArrivalReactions(game, fixture.owner, fixture.revivalAction, actions);
game = advanceRevivalArrivalReactions(game, state =>
state.decision?.kind === 'homeworldRevivalDeployment' && !state.response && !state.phaseOpening, actions);
return game;
}
export function commitRevivalArrivalReactions(game: Game,
fixture: { owner: string; destination: string }, actions: RevivalArrivalReactionsStep[] = []) {
const before = structuredClone(game), start = actions.length;
const choice = homeworldRevivalDeploymentChoice(viewGame(game, fixture.owner), fixture.destination);
assert.equal(choice.blocked, null); assert.ok(choice.action);
game = actRevivalArrivalReactions(game, fixture.owner, choice.action, actions);
return { game, before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
}
export function finishRevivalArrivalReactions(game: Game, actions: RevivalArrivalReactionsStep[] = []): Game {
return advanceRevivalArrivalReactions(game, state => state.homeworldRevivalReturn?.stage === 'complete' &&
revivalArrivalReactionsClean(state), actions);
}
/** Actual public Basic/Advanced BG/Tleilaxu + Homeworlds/E1. A genuine paid
* one-counter sand shipment is destroyed by the next native Storm. No phase,
* force, Tank, card, wallet, stance, population or pending receipt is installed. */
export function createRevivalArrivalReactionsFixture(options: { advanced?: boolean; resident?: boolean } = {}) {
const advanced = options.advanced ?? true, resident = options.resident ?? false;
const bg = 'beneGesserit', owner = 'tleilaxu', holder = owner;
const territory = 'carthag', sector = 11, destination = `${territory}:${sector}`;
const casualtyTerritory = 'hagga_basin', casualtyLocation = 'hagga_basin:13';
let game = createGame('REVIVALARRIVALREACTIONS', newPlayer(bg, 'Bene Gesserit', 'beneGesserit'), advanced, ['ix']);
joinGame(game, newPlayer(owner, 'Tleilaxu', 'tleilaxu'));
const initial = structuredClone(game), actions: RevivalArrivalReactionsStep[] = [];
const act = (actor: string, action: Action) => { game = actRevivalArrivalReactions(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceRevivalArrivalReactions(game, until, actions); };
const transition = (run: () => void): RevivalArrivalReactionsTransition => {
const before = structuredClone(game), start = actions.length; run();
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
};
const homeworlds = transition(() => { act(game.host, { type: 'homeworlds', enabled: true }); });
for (const seat of game.players) act(seat.id, { type: 'ready' });
const lobby = structuredClone(game);
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
const setup = structuredClone(game);
assert.equal(game.status, 'setup'); assert.ok(game.players.every(seat => !seat.hand.length));
assert.deepEqual(game.expansions, ['ix']); assert.ok(game.homeworlds);
assert.ok(!game.nexusCards && !game.leaderSkills && !game.strongholdCards && !game.techTokens && !game.discoveryEnabled);
const originalInventory = revivalArrivalReactionsInventory(game); assert.equal(originalInventory.length, 47);
const ordinary = game.deck.find(card => card.kind === 'projectile');
const karama = game.deck.find(card => card.effect === 'karama'); assert.ok(ordinary && karama);
const selected = [ordinary, karama];
const dealOrder = selected.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);
reach(state => state.status === 'playing');
const afterSetup = structuredClone(game);
assert.deepEqual(game.players.flatMap(seat => seat.hand.map(card => card.id)), selected.map(card => card.id));
assert.deepEqual(revivalArrivalReactionsPlayer(game, bg).forces, { 'polar_sink:0': 1 });
assert.equal(revivalArrivalReactionsPlayer(game, bg).reserves, 19);
assert.equal(revivalArrivalReactionsPlayer(game, owner).reserves, 20);
assert.equal(revivalArrivalReactionsPlayer(game, owner).faceDancers?.length, 3);
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
const spiceOrder = Array.from({ length: advanced ? 4 : 2 }, (_, position) => {
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card &&
!card.discovery && ![territory, casualtyTerritory].includes(card.territory));
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);
const firstStorm = transition(() => {
reach(state => state.turn === 1 && state.phase === 1);
});
assert.equal(game.storm, advanced ? 12 : 11);
assert.deepEqual(firstStorm.steps.filter(step => step.action.type === 'stormDial').map(step => step.action.amount), [advanced ? 11 : 10, 0]);
reach(state => state.turn === 1 && state.phase === 5 && state.active === bg && revivalArrivalReactionsClean(state));
let residentShipment: RevivalArrivalReactionsTransition | null = null;
if (resident) residentShipment = transition(() => {
const homeworldSources = nativeShipmentSources(viewGame(game, bg), 1, 0); assert.ok(homeworldSources);
act(bg, { type: 'ship', territory, sector, amount: 1, elite: 0, allyPayment: 0, homeworldSources });
reach(revivalArrivalReactionsClean);
});
act(bg, { type: 'endMovement' });
reach(state => state.turn === 1 && state.phase === 5 && state.active === owner && revivalArrivalReactionsClean(state));
const beforeShipment = structuredClone(game);
const homeworldSources = nativeShipmentSources(viewGame(game, owner), 1, 0); assert.ok(homeworldSources);
const shipmentStep: RevivalArrivalReactionsStep = { actor: owner, action: {
type: 'ship', territory: casualtyTerritory, sector: 13, amount: 1, elite: 0, allyPayment: 0, homeworldSources,
} };
const shipment = transition(() => {
act(shipmentStep.actor, shipmentStep.action);
reach(state => !state.response && !state.phaseOpening && state.decision?.kind === 'advisor');
});
const originalSpiritualDecline = transition(() => { act(bg, { type: 'decision', accept: false }); });
assert.equal(revivalArrivalReactionsPlayer(beforeShipment, owner).spice - revivalArrivalReactionsPlayer(game, owner).spice, 2);
assert.equal(revivalArrivalReactionsPlayer(game, owner).forces[casualtyLocation], 1);
assert.equal(revivalArrivalReactionsPlayer(game, owner).reserves, 19);
const beforeNextStorm = structuredClone(game);
reach(state => state.turn === 2 && state.phase === 0 && !state.phaseOpening && !state.response &&
state.stormPending === (advanced ? 1 : 2));
const beforeStormMovement = structuredClone(game), stormSource = structuredClone(game.stormMovementSource);
const storm = transition(() => { reach(state => state.turn === 2 && state.phase === 1); });
assert.equal(game.storm, 13);
assert.equal(revivalArrivalReactionsPlayer(game, owner).forces[casualtyLocation], undefined);
assert.equal(revivalArrivalReactionsPlayer(game, owner).tanks, 1);
reach(state => state.turn === 2 && state.phase === 4 && revivalArrivalReactionsClean(state));
assert.equal(revivalArrivalReactionsPlayer(game, owner).tanks, 1);
assert.equal(revivalArrivalReactionsPlayer(game, owner).reserves, 19);
assert.equal(revivalArrivalReactionsPlayer(game, bg).reserves, resident ? 18 : 19);
assert.equal(revivalArrivalReactionsPlayer(game, bg).forces[destination] ?? 0, resident ? 1 : 0);
assert.ok(revivalArrivalReactionsPlayer(game, holder).hand.some(card => card.id === karama.id));
assert.equal(homeworldTable(game)!.find(home => home.native === owner)!.side, 'high');
assert.equal(homeworldTable(game)!.find(home => home.native === bg)!.side, 'high');
assert.deepEqual(revivalArrivalReactionsInventory(game), originalInventory); homeworldGameIntegrity(game);
const source = structuredClone(game), revivalAction: Action = { type: 'revive', amount: 1, elite: 0, freeElite: 0 };
return {
game, source, beforeRequest: source, initial, lobby, setup, beforeStartingDeal, afterSetup, homeworlds,
bg, owner, holder, tleilaxu: owner, target: owner, actor: owner, karama: karama.id,
advanced, resident, territory, sector, destination, location: destination,
casualtyTerritory, casualtyLocation, beforeShipment, shipmentStep, shipment, residentShipment,
originalSpiritualDecline, beforeNextStorm, beforeStormMovement, stormSource, storm, firstStorm,
revivalAction, action: revivalAction,
cancelAction: { type: 'card', card: karama.id, mode: 'cancel' } as Action,
declineAction: { type: 'decision', accept: false } as Action,
sinkAction: { type: 'decision', accept: true, accompany: false, amount: 1 } as Action,
highSinkAction: { type: 'decision', accept: true, accompany: false, amount: 2 } as Action,
accompanyAction: { type: 'decision', accept: true, accompany: true, amount: 1, sector } as Action,
dealOrder, spiceOrder, originalSpice, originalInventory, originalCards: originalInventory,
steps: actions, actions, sourceActionCount: actions.length,
staging: [
'Public Basic/Advanced Bene Gesserit/Tleilaxu with actual Homeworlds lobby setting and original 47-card E1 deck. Only two original unopened starting-deal positions, unopened ordinary land Spice positions and native scalar shuffle order are selected. BG starts with one actual Polar fighter (Advanced uses its original advisorSetup); Tleilaxu receives its original Karama and original three Face Dancers. Actual first dials end Storm at11 in Basic (10+0) or12 in Advanced (11+0). Tleilaxu pays two spice to ship one native counter into Hagga Basin13; BG really declines that first Spiritual offer. The next native Storm (Basic 1+1 dials; Advanced original six-card shuffle/draw1) ends at13 and destroys the counter. Original phase controls reach the clean second Revival with Tanks1 and High19 native Tleilaxu/BG. Optional resident branch has BG actually pay to ship one fighter to Carthag11, leaving High18. No played hand, wallet, army, Tank, phase, stance, threshold, placement or reaction is installed. Source stops before ordinary free revive1; caller owns placement and every BG entry reaction.',
],
};
}
export type RevivalArrivalReactionsFixture = ReturnType<typeof createRevivalArrivalReactionsFixture>;
@@ -52,77 +52,104 @@ function allow(g: Game, kind: string) {
return g;
}
void test('actual phase-four revived arrival holds income through Guild shipment, BG child and Fremen relocation across JSON', () => {
void test('actual phase-four revived arrival rejects fighter accompaniment and holds income through the Guild Polar alternative', () => {
let g = open();
const original = structuredClone(g.homeworldRevivalReturn!);
const beforeIncome = p(g, 't').spice;
const parent = g.pendingAmbassador!.event;
g = applyAction(reload(g), 'ec', {
type: 'decision',
event: parent,
trigger: true,
beneficiary: 'a',
});
g = applyAction(reload(g), 'ec', { type: 'decision', event: parent, trigger: true, beneficiary: 'a' });
assert.equal(g.pendingAmbassador?.stage, 'ship');
g = applyAction(reload(g), 'a', {
type: 'decision',
event: parent,
amount: 2,
territory: 'sietch_tabr',
sector: 14,
});
g = applyAction(reload(g), 'a', { type: 'decision', event: parent, amount: 2, territory: 'sietch_tabr', sector: 14 });
assert.equal(g.decision?.kind, 'intrusion');
g = applyAction(reload(g), 'bg', { type: 'decision', accept: false });
assert.equal(g.decision?.kind, 'advisor');
g = applyAction(reload(g), 'bg', {
type: 'decision',
accept: true,
accompany: true,
});
const before = reload(g);
assert.throws(() => applyAction(g, 'bg', { type: 'decision', accept: true, accompany: true }), /cannot accompany/);
assert.deepEqual(g, before);
g = applyAction(reload(g), 'bg', { type: 'decision', accept: true, accompany: false, amount: 1 });
assert.equal(g.response?.kind, 'advisor');
g = allow(g, 'advisor');
assert.equal(g.pendingAmbassador?.stage, 'offer');
assert.equal(g.pendingAmbassador?.entrant, 'bg');
assert.equal(g.homeworldRevivalReturn?.ambassadors?.length, 2);
assert.equal(g.homeworldRevivalReturn!.ambassadors![1].parent, parent);
assert.equal(p(g, 't').spice, beforeIncome);
assert.deepEqual(
g.homeworldRevivalReturn!.resumeResponse,
original.resumeResponse,
);
inventory(g);
assert.deepEqual(normalizeAutomaticGame(reload(g)), reload(g));
g = applyAction(reload(g), 'ec', {
type: 'decision',
event: g.pendingAmbassador!.event,
trigger: true,
beneficiary: 'ec',
});
assert.equal(g.pendingAmbassador?.stage, 'move');
g = applyAction(reload(g), 'ec', {
type: 'decision',
event: g.pendingAmbassador!.event,
forces: { 'red_chasm:7': 1 },
territory: 'carthag',
sector: 11,
});
g = allow(g, 'advisorFlip');
assert.equal(g.pendingAmbassador ?? null, null);
assert.equal(g.homeworldRevivalReturn?.ambassadors?.length, 1);
assert.equal(g.homeworldRevivalReturn!.ambassadors![0].completed, true);
assert.equal(g.homeworldRevivalReturn?.stage, 'complete');
assert.deepEqual(g.response, original.resumeResponse);
assert.equal(p(g, 't').spice, beforeIncome);
g = allow(g, 'revivalIncome');
assert.equal(
p(g, 't').spice,
beforeIncome + original.resumeResponse!.amount!,
);
assert.equal(p(g, 't').spice, beforeIncome + original.resumeResponse!.amount!);
assert.equal(p(g, 'f').forces['arrakeen:10'], 13);
assert.equal(p(g, 'a').forces['sietch_tabr:14'], 2);
assert.equal(p(g, 'bg').forces['sietch_tabr:14'], 2);
assert.equal(p(g, 'ec').forces['carthag:11'], 1);
assert.equal(p(g, 'bg').forces['sietch_tabr:14'], 1);
assert.equal(p(g, 'bg').forces['polar_sink:0'], 1);
assert.equal(p(g, 'ec').forces['red_chasm:7'], 1);
assert.ok(g.ecazAmbassadors!.tokens.some(token => token.effect === 'fremen' && token.zone === 'placed' && token.location === 'sietch_tabr'));
inventory(g);
assert.deepEqual(normalizeAutomaticGame(reload(g)), reload(g));
});
void test('a controlled native World source completes revived Tleilax Intrusion and Spiritual declines before its Fremen Ambassador relocation', () => {
let g = fixture();
// This existing fixture conserves staged native armies/cards. The return,
// Intrusion, Spiritual choice and Ambassador continuations are real actions.
g = applyAction(g, 't', { type: 'revive', amount: 1, elite: 0 });
const event = g.homeworldRevivalReturn!.event;
const beforeIncome = p(g, 't').spice;
g = applyAction(g, 't', { type: 'decision', event, destination: 'sietch_tabr:14' });
assert.equal(g.decision?.kind, 'intrusion');
g = applyAction(g, 'bg', { type: 'decision', accept: false });
assert.equal(g.decision?.kind, 'advisor');
g = applyAction(g, 'bg', { type: 'decision', accept: false });
assert.equal(g.pendingAmbassador?.stage, 'offer');
assert.equal(g.pendingAmbassador?.entrant, 't');
const entry = g.pendingAmbassador!;
assert.equal(entry.revivalEvent, event);
g = applyAction(g, 'ec', { type: 'decision', event: entry.event, trigger: true, beneficiary: 'ec' });
assert.equal(g.pendingAmbassador?.stage, 'move');
g = applyAction(g, 'ec', { type: 'decision', event: entry.event,
forces: { 'red_chasm:7': 1 }, territory: 'carthag', sector: 11 });
assert.equal(g.homeworldRevivalReturn?.stage, 'complete');
assert.equal(g.pendingAmbassador ?? null, null);
assert.equal(p(g, 'ec').forces['carthag:11'], 1);
assert.equal(p(g, 't').forces['sietch_tabr:14'], 1);
assert.equal(p(g, 't').spice, beforeIncome);
g = allow(g, 'revivalIncome');
assert.equal(p(g, 't').spice, beforeIncome + 1);
inventory(g);
});
void test('a controlled conserved Southern return permits Intrusion Karama without inventing Spiritual accompaniment', () => {
let g = fixture();
// Move existing Fremen/BG counters to a non-stronghold in this controlled
// fixture. No revival, entry, response or continuation is installed.
p(g, 'f').forces['arrakeen:10']--;
p(g, 'f').forces['imperial_basin:9'] = 1;
delete p(g, 'bg').forces['sietch_tabr:14'];
p(g, 'bg').forces['imperial_basin:9'] = 1;
inventory(g);
g = applyAction(g, 'f', { type: 'revive', amount: 1, elite: 1 });
if (g.decision?.kind === 'revivalStop')
g = applyAction(g, 't', { type: 'decision', decline: true });
while (g.homeworldRevivalReturn?.stage !== 'choice' && g.response) g = allow(g, g.response.kind);
assert.equal(g.homeworldRevivalReturn?.stage, 'choice');
const event = g.homeworldRevivalReturn!.event;
g = applyAction(g, 'f', { type: 'decision', event, destination: 'imperial_basin:9' });
assert.equal(g.decision?.kind, 'intrusion');
g = applyAction(g, 'bg', { type: 'decision', accept: true });
assert.equal(g.response?.kind, 'advisorFlip');
assert.equal(g.response?.advisorRevivalEvent, event);
assert.equal(g.response?.advisorFollowup, undefined);
const karama = p(g, 'ec').hand.find(card => card.effect === 'karama')!;
g = applyAction(g, 'ec', { type: 'card', card: karama.id, mode: 'cancel' });
assert.equal(g.pendingAmbassador ?? null, null);
assert.equal(g.homeworldRevivalReturn?.stage, 'complete');
assert.equal(g.decision, null);
assert.equal(p(g, 'bg').advisors?.imperial_basin, undefined);
assert.equal(p(g, 'f').forces['imperial_basin:9'], 2);
assert.equal(g.discard.filter(card => card.id === karama.id).length, 1);
inventory(g);
});
void test('an actual revival Ambassador offer rejects edited original arrival identities before view or action', () => {
const original = open();
for (const edit of [
+7 -7
View File
@@ -40,11 +40,11 @@ void test('Southern lists existing territories only and withdraws the entire typ
assert.deepEqual(g, before);
});
void test('BG accompaniment uncertainty applies to Tleilax arrival on Arrakis, never Homeworld destinations', () => {
void test('Tleilax SEND destinations remain available before their original Spiritual Advisor continuation', () => {
const { g, grant } = fixture('tleilaxu');
g.players[1].reserves = 20;
const choices = homeworldRevivalDestinations(g, 'p', grant);
assert.match(choices.find((c) => c.id === 'polar_sink:0')!.blocked!, /Spiritual Advisors/);
assert.equal(choices.find((c) => c.id === 'polar_sink:0')!.blocked, null);
const home = choices.filter((c) => c.homeworld);
assert.ok(home.length > 0);
assert.ok(home.every((c) => c.blocked === null));
@@ -93,11 +93,11 @@ void test('storm uses the selected actual sector and mobile stronghold pointer;
}
});
void test('BG Intrusion gate requires Advanced fighters at the destination, not advisors or another location', () => {
void test('BG fighters do not refuse SEND destinations before their original Intrusion continuation', () => {
const { g, grant } = fixture('fremen');
g.players[1].forces = { 'arrakeen:10': 1 };
const target = () => homeworldRevivalDestinations(g, 'p', grant).find((c) => c.id === 'arrakeen:10')!;
assert.match(target().blocked!, /Intrusion/);
assert.equal(target().blocked, null);
g.advanced = false;
assert.equal(target().blocked, null);
g.advanced = true;
@@ -105,18 +105,18 @@ void test('BG Intrusion gate requires Advanced fighters at the destination, not
assert.equal(target().blocked, null);
});
void test('Terror boundary reads public placement and original physical count, never a secret token face', () => {
void test('the unfinished Revival Terror continuation stays fenced without reading a token face', () => {
const { g, grant } = fixture('tleilaxu');
g.moritaniTerror = createTerrorState(() => 0);
const token = g.moritaniTerror.tokens[0];
g.moritaniTerror = placeTerror(g.moritaniTerror, token.id, 'arrakeen', 1);
for (const t of g.moritaniTerror.tokens) Object.defineProperty(t, 'kind', { get() { throw new Error('private Terror face read'); } });
const target = () => homeworldRevivalDestinations(g, 'p', grant).find((c) => c.id === 'arrakeen:10')!;
assert.match(target().blocked!, /Terror/);
assert.match(target().blocked!, /Terror.*continuation/);
g.players[2].reserves = 7;
assert.equal(target().blocked, null);
grant.normal = 3;
assert.match(target().blocked!, /Terror/);
assert.match(target().blocked!, /Terror.*continuation/);
g.players[2].ally = 'p'; g.players[0].ally = 'm';
assert.equal(target().blocked, null);
});
@@ -0,0 +1,237 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createGame, newPlayer, type Game } from '../game/engine';
import { createHomeworldCustody } from '../game/homeworld-custody';
import { homeworldContext } from '../game/homeworld-game';
import {
appendHomeworldArrivalAmbassador,
completeHomeworldArrivalAmbassador,
homeworldArrivalSignature,
validateHomeworldArrival,
} from '../game/homeworld-arrival';
import {
makeHomeworldRevivalReturn,
makeHomeworldRevivalProgress,
homeworldRevivalResumeSignature,
validateHomeworldRevivalReturn,
} from '../game/homeworld-revival-return';
import { ambassadorPhaseAllowed } from '../game/ambassador-phase';
import type { SpiritualAdvisorSource } from '../game/spiritual-advisor';
type RevivalSpiritual = SpiritualAdvisorSource & { revivalEvent: string };
type Entry = NonNullable<Game['pendingAmbassador']>;
/** Controlled public receipt/continuation context. Actual placement and the
* original revival entitlement remain the native engine producer's duty. */
function fixture() {
const g = createGame('REVIVAL-SPIRITUAL', newPlayer('t', 'Tleilaxu', 'tleilaxu'), true, ['ix', 'ecaz']);
g.players.push(newPlayer('b', 'BG', 'beneGesserit'), newPlayer('e', 'Ecaz', 'ecaz'), newPlayer('i', 'Ixians', 'ixians'));
g.status = 'playing';
g.turn = 2;
g.phase = 4;
g.players[0].reserves = 11;
g.homeworlds = { custody: createHomeworldCustody(homeworldContext(g)) };
const frame = makeHomeworldRevivalReturn({
event: 'revival-t-2', player: 't', turn: 2, phase: 4, source: 'normal',
group: { amount: 2, elite: 0, free: 2 },
quote: { kind: 'tleilax', normal: 2, elite: 0, beforePopulation: 9, afterPopulation: 11, blocked: null },
});
frame.stage = 'arrival';
frame.resumeResponse = null;
frame.resumeSignature = homeworldRevivalResumeSignature(null);
frame.destination = 'arrakeen:10';
frame.ambassadors = [];
frame.arrivalSignature = homeworldArrivalSignature(frame);
g.homeworldRevivalReturn = frame;
g.homeworldRevivalProgress = makeHomeworldRevivalProgress(frame);
g.pendingAmbassador = null;
return { g, frame };
}
function entry(event = 'ambassador-bg-1', entrant = 'b', territory = 'arrakeen', sector = 10): Entry {
return { event, owner: 'e', entrant, territory, sector, turn: 2, phase: 4,
revivalEvent: 'revival-t-2', token: 'token-guild', stage: 'offer', copyChoices: [], resume: 'none' };
}
function source(patch: Partial<RevivalSpiritual> = {}): RevivalSpiritual {
return { event: 'spiritual-1', owner: 'b', turn: 2, phase: 4, territory: 'arrakeen', sector: 10,
amount: 1, revivalEvent: 'revival-t-2', ...patch };
}
void test('a committed Spiritual root opens phase-four Ambassador before the original Tleilax entry', () => {
const { g, frame } = fixture();
const bg = entry(), actual = source();
appendHomeworldArrivalAmbassador(frame, bg, undefined, actual);
g.pendingAmbassador = bg;
validateHomeworldRevivalReturn(g, frame);
assert.equal(ambassadorPhaseAllowed(g), true);
assert.equal(ambassadorPhaseAllowed(JSON.parse(JSON.stringify(g)) as Game), true);
assert.deepEqual(frame.ambassadors![0].spiritual, actual);
assert.notEqual(frame.ambassadors![0].spiritual, actual);
actual.amount = 2;
assert.equal(frame.ambassadors![0].spiritual!.amount, 1, 'The accepted public placement source is copied once.');
completeHomeworldArrivalAmbassador(frame, bg.event);
const original = entry('ambassador-t-original', 't');
appendHomeworldArrivalAmbassador(frame, original);
g.pendingAmbassador = original;
assert.equal(frame.ambassadors![1].parent, undefined);
assert.equal(frame.ambassadors![1].spiritual, undefined);
validateHomeworldRevivalReturn(g, frame);
assert.equal(ambassadorPhaseAllowed(g), true);
completeHomeworldArrivalAmbassador(frame, original.event);
g.pendingAmbassador = null;
frame.stage = 'complete';
g.homeworldRevivalProgress = makeHomeworldRevivalProgress(frame);
validateHomeworldRevivalReturn(g, frame);
g.pendingAmbassador = bg;
assert.equal(ambassadorPhaseAllowed(g), false, 'A completed BG root cannot be reopened.');
});
void test('legacy primary rows retain their exact signature bytes without a Spiritual attribute', () => {
const { g, frame } = fixture();
const original = entry('ambassador-t-original', 't');
appendHomeworldArrivalAmbassador(frame, original);
g.pendingAmbassador = original;
assert.equal(frame.arrivalSignature, JSON.stringify(['homeworldRevivalArrival', frame.signature,
'arrakeen:10', [[original.event, 't', 'arrakeen:10', null, false]]]));
validateHomeworldRevivalReturn(g, frame);
assert.equal(ambassadorPhaseAllowed(g), true);
});
void test('Spiritual provenance binds the original revival, actual owner, public packet and exact arrival destination', () => {
for (const patch of [
{ event: '' }, { owner: 'i' }, { owner: 't' }, { revivalEvent: 'other-revival' },
{ turn: 3 }, { phase: 5 }, { territory: 'carthag', sector: 11 },
{ sector: 9 }, { amount: 0 }, { amount: 3 }, { amount: 1.5 },
] satisfies Partial<RevivalSpiritual>[]) {
const { frame } = fixture(), before = JSON.stringify(frame);
assert.throws(() => appendHomeworldArrivalAmbassador(frame, entry(), undefined, source(patch)));
assert.equal(JSON.stringify(frame), before);
}
for (const actual of [
{ ...source(), card: 'unrelated-card' },
{ ...source(), privateFrame: {} },
Object.fromEntries(Object.entries(source()).filter(([key]) => key !== 'revivalEvent')),
]) {
const { frame } = fixture(), before = JSON.stringify(frame);
assert.throws(() => appendHomeworldArrivalAmbassador(frame, entry(), undefined, actual as RevivalSpiritual));
assert.equal(JSON.stringify(frame), before);
}
});
void test('a direct Spiritual root accompanies only the original territory or Polar Sink', () => {
const { g, frame } = fixture();
const polar = entry('ambassador-bg-polar', 'b', 'polar_sink', 0);
appendHomeworldArrivalAmbassador(frame, polar, undefined, source({ territory: 'polar_sink', sector: 0, amount: 2 }));
g.pendingAmbassador = polar;
assert.equal(ambassadorPhaseAllowed(g), true);
const unrelated = fixture();
assert.throws(() => appendHomeworldArrivalAmbassador(unrelated.frame,
entry('ambassador-bg-elsewhere', 'b', 'carthag', 11), undefined, source({ territory: 'carthag', sector: 11 })));
const home = fixture();
home.frame.destination = 'homeworld:i';
home.frame.arrivalSignature = homeworldArrivalSignature(home.frame);
assert.throws(() => appendHomeworldArrivalAmbassador(home.frame, polar, undefined,
source({ territory: 'polar_sink', sector: 0 })));
});
void test('unique Spiritual openings may precede the one original root while duplicate events and unproven roots reject', () => {
const { g, frame } = fixture();
const first = entry();
appendHomeworldArrivalAmbassador(frame, first, undefined, source());
g.pendingAmbassador = first;
const stillOpen = JSON.stringify(frame);
assert.throws(() => appendHomeworldArrivalAmbassador(frame, entry('ambassador-bg-2'), undefined,
source({ event: 'spiritual-2' })));
assert.equal(JSON.stringify(frame), stillOpen);
completeHomeworldArrivalAmbassador(frame, first.event);
const completed = JSON.stringify(frame);
assert.throws(() => appendHomeworldArrivalAmbassador(frame, entry('ambassador-bg-replay'), undefined, source()));
assert.throws(() => appendHomeworldArrivalAmbassador(frame, entry('ambassador-unproven-bg')));
assert.throws(() => appendHomeworldArrivalAmbassador(frame, entry('ambassador-unproven-i', 'i')));
assert.throws(() => appendHomeworldArrivalAmbassador(frame, entry('ambassador-wrong-original', 't', 'carthag', 11)));
assert.equal(JSON.stringify(frame), completed);
const second = entry('ambassador-bg-2');
appendHomeworldArrivalAmbassador(frame, second, undefined, source({ event: 'spiritual-2' }));
g.pendingAmbassador = second;
assert.equal(ambassadorPhaseAllowed(g), true);
completeHomeworldArrivalAmbassador(frame, second.event);
const original = entry('ambassador-t-original', 't');
appendHomeworldArrivalAmbassador(frame, original);
completeHomeworldArrivalAmbassador(frame, original.event);
const before = JSON.stringify(frame);
assert.throws(() => appendHomeworldArrivalAmbassador(frame, entry('ambassador-original-replay', 't')));
assert.throws(() => appendHomeworldArrivalAmbassador(frame, entry(), undefined,
source({ event: 'spiritual-3' })));
assert.equal(JSON.stringify(frame), before);
});
void test('a Guild accompaniment suffix retains its original parent independently of the later primary root', () => {
const { g, frame } = fixture();
const bg = entry();
appendHomeworldArrivalAmbassador(frame, bg, undefined, source());
completeHomeworldArrivalAmbassador(frame, bg.event);
const child: Entry = { ...entry('ambassador-bg-guild-child', 'b', 'carthag', 11),
guildAdvisorOrigin: { event: bg.event, player: 'i', territory: 'carthag', sector: 11 } };
const before = JSON.stringify(frame);
assert.throws(() => appendHomeworldArrivalAmbassador(frame, child, bg.event, source({ event: 'spiritual-child', territory: 'carthag', sector: 11 })));
assert.equal(JSON.stringify(frame), before);
appendHomeworldArrivalAmbassador(frame, child, bg.event);
g.pendingAmbassador = child;
assert.equal(ambassadorPhaseAllowed(g), true);
child.guildAdvisorOrigin!.event = 'unrelated';
assert.equal(ambassadorPhaseAllowed(g), false);
child.guildAdvisorOrigin!.event = bg.event;
completeHomeworldArrivalAmbassador(frame, child.event);
const original = entry('ambassador-t-original', 't');
appendHomeworldArrivalAmbassador(frame, original);
g.pendingAmbassador = original;
assert.equal(ambassadorPhaseAllowed(g), true);
});
void test('phase permission requires the current native BG source and revival tag, without reading hidden inventories', () => {
const { g, frame } = fixture();
const bg = entry();
appendHomeworldArrivalAmbassador(frame, bg, undefined, source());
g.pendingAmbassador = bg;
for (const mutate of [
(copy: Game) => { copy.players.find(player => player.id === 'b')!.faction = 'atreides'; },
(copy: Game) => { copy.players.push(newPlayer('b', 'Duplicate BG', 'beneGesserit')); },
(copy: Game) => { copy.pendingAmbassador!.revivalEvent = 'another-revival'; },
(copy: Game) => { copy.pendingAmbassador!.guildAdvisorOrigin = { event: 'unrelated', player: 'i', territory: 'arrakeen', sector: 10 }; },
(copy: Game) => { copy.phase = 5; },
]) {
const copy = structuredClone(g);
mutate(copy);
const before = JSON.stringify(copy);
assert.equal(ambassadorPhaseAllowed(copy), false);
assert.equal(JSON.stringify(copy), before);
}
const wrongTag = structuredClone(g);
wrongTag.pendingAmbassador!.victoryEvent = frame.event;
delete wrongTag.pendingAmbassador!.revivalEvent;
assert.throws(() => validateHomeworldArrival(wrongTag, wrongTag.homeworldRevivalReturn!, 'victoryEvent'));
for (const player of g.players)
for (const field of ['hand', 'spice', 'forces', 'reserves', 'tanks', 'traitors', 'faceDancers'])
Object.defineProperty(player, field, { get() { throw new Error(`unrelated ${field}`); } });
validateHomeworldArrival(g, frame, 'revivalEvent');
assert.equal(ambassadorPhaseAllowed(g), true);
});
void test('the signed Spiritual row is canonical after JSON reordering and rejects changed public evidence', () => {
const { g, frame } = fixture();
const bg = entry();
appendHomeworldArrivalAmbassador(frame, bg, undefined, source());
g.pendingAmbassador = bg;
const signature = frame.arrivalSignature;
frame.ambassadors![0].spiritual = Object.fromEntries(Object.entries(frame.ambassadors![0].spiritual!).reverse()) as RevivalSpiritual;
assert.equal(homeworldArrivalSignature(frame), signature);
validateHomeworldArrival(g, frame, 'revivalEvent');
frame.ambassadors![0].spiritual!.amount = 2;
assert.throws(() => validateHomeworldArrival(g, frame, 'revivalEvent'));
assert.equal(ambassadorPhaseAllowed(g), false);
});
@@ -0,0 +1,179 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { viewGame, type Game } from '../game/engine';
import { isAdvisor } from '../game/advisors';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { homeworldGameIntegrity, homeworldTable } from '../game/homeworld-game';
import {
createRevivalArrivalReactionsFixture as fixture,
actRevivalArrivalReactions as act,
openRevivalArrivalReactions as open,
commitRevivalArrivalReactions as commit,
passRevivalArrivalReactions as pass,
finishRevivalArrivalReactions as finish,
revivalArrivalReactionsPlayer as player,
revivalArrivalReactionsInventory as inventory,
type RevivalArrivalReactionsFixture,
} from './fixture-revival-arrival-reactions';
function ownEvent(game: Game, owner: string) {
const p = player(game, owner);
return {
reserves: p.reserves, tanks: p.tanks, forces: structuredClone(p.forces), spice: p.spice,
shipped: p.shipped, moved: p.moved, revived: p.revived, freeForcesRevived: p.freeForcesRevived,
};
}
function bgOffer(f: RevivalArrivalReactionsFixture) {
const returned = open(f.game, f);
assert.equal(returned.homeworldRevivalReturn?.stage, 'choice');
assert.equal(returned.decision?.kind, 'homeworldRevivalDeployment');
assert.equal(returned.homeworldRevivalReturn?.resumeResponse?.kind, 'revivalIncome');
assert.equal(returned.homeworldRevivalReturn?.resumeResponse?.amount, 1);
assert.equal(player(returned, f.owner).tanks, 0); assert.equal(player(returned, f.owner).reserves, 20);
assert.equal(player(returned, f.owner).revived, 1);
assert.equal(player(returned, f.owner).freeForcesRevived, 1);
const placed = commit(returned, f).game;
assert.equal(placed.homeworldRevivalReturn?.event, returned.homeworldRevivalReturn!.event);
assert.equal(placed.homeworldRevivalReturn?.stage, 'arrival');
assert.equal(placed.decision?.kind, 'advisor'); assert.equal(placed.decision?.player, f.bg);
assert.equal(player(placed, f.owner).forces[f.destination], 1);
assert.equal(player(placed, f.owner).reserves, 19);
assert.equal(player(placed, f.bg).reserves, player(f.game, f.bg).reserves);
return { returned, game: placed };
}
function originalAccounting(game: Game, f: RevivalArrivalReactionsFixture) {
const before = player(f.game, f.owner), after = player(game, f.owner);
assert.equal(game.turn, 2); assert.equal(game.phase, 4);
assert.equal(after.spice, before.spice + 1, 'The original free-revival bank income settles once after arrival choices');
assert.equal(after.shipped, before.shipped); assert.equal(after.moved, before.moved);
assert.equal(after.tanks, 0); assert.equal(after.revived, 1); assert.equal(after.freeForcesRevived, 1);
assert.equal(after.reserves, 19); assert.equal(after.forces[f.destination], 1);
assert.equal(after.reserves + after.tanks + Object.values(after.forces).reduce((sum, amount) => sum + amount, 0), 20);
assert.equal(game.homeworldRevivalReturn?.stage, 'complete');
assert.deepEqual(inventory(game), f.originalInventory); homeworldGameIntegrity(game);
}
for (const advanced of [false, true]) {
const mode = advanced ? 'Advanced' : 'Basic';
void test(`${mode} genuine High Tleilax revival arrival opens BG Spiritual choice and decline finishes the original free return`, () => {
const f = fixture({ advanced });
assert.deepEqual(f.game.expansions, ['ix']); assert.ok(f.game.homeworlds);
assert.equal(f.originalInventory.length, 47); assert.equal(f.sourceActionCount, f.actions.length);
assert.equal(player(f.afterSetup, f.owner).reserves, 20);
assert.equal(player(f.afterSetup, f.owner).tanks, 0);
assert.equal(player(f.shipment.after, f.owner).forces[f.casualtyLocation], 1);
assert.equal(player(f.beforeShipment, f.owner).spice - player(f.shipment.after, f.owner).spice, 2);
assert.equal(f.beforeStormMovement.storm, advanced ? 12 : 11);
assert.equal(f.beforeStormMovement.stormPending, advanced ? 1 : 2);
assert.equal(f.storm.after.storm, 13);
assert.equal(player(f.storm.after, f.owner).tanks, 1);
assert.equal(player(f.game, f.owner).tanks, 1); assert.equal(player(f.game, f.owner).revived, 0);
assert.equal(player(f.game, f.owner).reserves, 19); assert.equal(player(f.game, f.bg).reserves, 19);
assert.ok(homeworldTable(f.game)!.filter(home => home.native === f.owner || home.native === f.bg)
.every(home => home.side === 'high'));
const { game } = bgOffer(f), before = ownEvent(game, f.owner);
const done = finish(act(game, f.bg, f.declineAction));
assert.deepEqual(ownEvent(done, f.owner), { ...before, spice: before.spice + 1 });
assert.equal(player(done, f.bg).reserves, 19); assert.equal(player(done, f.bg).forces['polar_sink:0'], 1);
assert.ok(player(done, f.holder).hand.some(card => card.id === f.karama));
originalAccounting(done, f);
});
void test(`${mode} genuine revival-triggered BG can send one or High Wallach two to Polar without a second revival or shipment fee`, () => {
const f = fixture({ advanced }), { game } = bgOffer(f);
for (const amount of [1, 2]) {
const before = ownEvent(game, f.owner), bgBefore = ownEvent(game, f.bg);
const declared = act(game, f.bg, amount === 1 ? f.sinkAction : f.highSinkAction);
assert.equal(declared.response?.kind, 'advisor'); assert.equal(declared.response?.owner, f.bg);
assert.equal(player(declared, f.bg).reserves, bgBefore.reserves);
assert.deepEqual(ownEvent(declared, f.owner), before);
const done = finish(pass(declared));
assert.deepEqual(ownEvent(done, f.owner), { ...before, spice: before.spice + 1 });
assert.equal(player(done, f.bg).reserves, bgBefore.reserves - amount);
assert.equal(player(done, f.bg).forces['polar_sink:0'], 1 + amount);
assert.equal(player(done, f.bg).spice, bgBefore.spice);
assert.equal(player(done, f.bg).shipped, bgBefore.shipped);
assert.equal(player(done, f.bg).moved, bgBefore.moved);
assert.equal(player(done, f.bg).revived, bgBefore.revived);
assert.ok(player(done, f.holder).hand.some(card => card.id === f.karama));
originalAccounting(done, f);
}
});
void test(`${mode} original Tleilaxu Karama independently cancels BG accompaniment after the revived force is already placed`, () => {
const f = fixture({ advanced }), { game } = bgOffer(f);
const before = ownEvent(game, f.owner), bgBefore = ownEvent(game, f.bg);
const declared = act(game, f.bg, f.highSinkAction);
assert.equal(declared.response?.kind, 'advisor');
const done = finish(act(declared, f.holder, f.cancelAction));
assert.deepEqual(ownEvent(done, f.owner), { ...before, spice: before.spice + 1 }); assert.deepEqual(ownEvent(done, f.bg), bgBefore);
assert.equal(done.discard.filter(card => card.id === f.karama).length, 1);
assert.equal(player(done, f.holder).hand.some(card => card.id === f.karama), false);
originalAccounting(done, f);
});
void test(`${mode} all four minimal BG policies and original Karama responders act legally on the revival arrival`, () => {
const f = fixture({ advanced }), { game } = bgOffer(f);
for (const difficulty of DIFFICULTIES) {
const own = viewGame(game, f.bg); own.players.find(seat => seat.id === f.bg)!.bot = difficulty;
const actions = botActions(own); assert.ok(actions.length, difficulty);
const declared = act(game, f.bg, actions[0]);
if (declared.response) {
const counter = viewGame(declared, f.holder);
counter.players.find(seat => seat.id === f.holder)!.bot = difficulty;
const replies = botActions(counter); assert.ok(replies.length, difficulty);
assert.doesNotThrow(() => act(declared, f.holder, replies[0]), difficulty);
}
const done = finish(pass(declared)); originalAccounting(done, f);
assert.deepEqual(inventory(done), f.originalInventory);
}
});
}
void test('Advanced genuine revived Tleilaxu arrival permits one advisor to accompany into its original Carthag destination', () => {
const f = fixture(), { game } = bgOffer(f), before = ownEvent(game, f.owner);
const done = finish(pass(act(game, f.bg, f.accompanyAction)));
assert.deepEqual(ownEvent(done, f.owner), { ...before, spice: before.spice + 1 });
assert.equal(player(done, f.bg).reserves, 18);
assert.equal(player(done, f.bg).forces[f.destination], 1);
assert.equal(isAdvisor(player(done, f.bg), f.territory), true);
assert.equal(player(done, f.bg).forces['polar_sink:0'], 1);
assert.ok(player(done, f.holder).hand.some(card => card.id === f.karama));
originalAccounting(done, f);
});
void test('Advanced actual BG resident fighters receive Intrusion before the revived arrival Spiritual followup', () => {
const f = fixture({ resident: true }); assert.ok(f.residentShipment);
assert.equal(player(f.residentShipment.before, f.bg).spice - player(f.residentShipment.after, f.bg).spice, 1);
assert.equal(player(f.game, f.bg).reserves, 18); assert.equal(isAdvisor(player(f.game, f.bg), f.territory), false);
const returned = open(f.game, f), placed = commit(returned, f).game;
assert.equal(placed.decision?.kind, 'intrusion'); assert.equal(placed.decision?.player, f.bg);
assert.equal(player(placed, f.owner).forces[f.destination], 1);
const before = ownEvent(placed, f.owner);
const converting = act(placed, f.bg, { type: 'decision', accept: true });
assert.equal(converting.response?.kind, 'advisorFlip'); assert.equal(converting.response?.advisors, true);
const offered = pass(converting);
assert.equal(isAdvisor(player(offered, f.bg), f.territory), true);
assert.equal(offered.decision?.kind, 'advisor'); assert.equal(offered.decision?.player, f.bg);
const done = finish(pass(act(offered, f.bg, f.accompanyAction)));
assert.deepEqual(ownEvent(done, f.owner), { ...before, spice: before.spice + 1 });
assert.equal(player(done, f.bg).forces[f.destination], 2);
assert.equal(player(done, f.bg).reserves, 17); assert.equal(isAdvisor(player(done, f.bg), f.territory), true);
originalAccounting(done, f);
});
void test('Advanced original Karama can prevent Intrusion conversion while the revived force and later decline remain committed', () => {
const f = fixture({ resident: true }), returned = open(f.game, f), placed = commit(returned, f).game;
assert.equal(placed.decision?.kind, 'intrusion');
const before = ownEvent(placed, f.owner), bgBefore = ownEvent(placed, f.bg);
const converting = act(placed, f.bg, { type: 'decision', accept: true });
assert.equal(converting.response?.kind, 'advisorFlip');
const offered = act(converting, f.holder, f.cancelAction);
assert.equal(offered.decision?.kind, 'advisor'); assert.equal(isAdvisor(player(offered, f.bg), f.territory), false);
const done = finish(act(offered, f.bg, f.declineAction));
assert.deepEqual(ownEvent(done, f.owner), { ...before, spice: before.spice + 1 }); assert.deepEqual(ownEvent(done, f.bg), bgBefore);
assert.equal(done.discard.filter(card => card.id === f.karama).length, 1);
originalAccounting(done, f);
});
+179
View File
@@ -0,0 +1,179 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
validateSpiritualAdvisorSource, SpiritualAdvisorError,
type SpiritualAdvisorContext, type SpiritualAdvisorSource,
} from '../game/spiritual-advisor';
import {
quoteAutomaticAdvisorFlips, validateAutomaticAdvisorFlip, AutomaticAdvisorFlipError,
type AutomaticAdvisorFlipContext, type AutomaticAdvisorFlipSource,
} from '../game/advisor-automatic-flip';
/** Minimal controlled public arrival contexts. Original force-return and
* Ambassador receipts remain the engine's separate authority. */
function context(): SpiritualAdvisorContext {
return { advanced: true, status: 'playing', turn: 3, phase: 4,
players: [
{ id: 'bg', faction: 'beneGesserit', forces: {} },
{ id: 't', faction: 'tleilaxu', forces: { 'arrakeen:10': 2 } },
],
homeworldRevivalReturn: { event: 'original-return', turn: 3, phase: 4, stage: 'arrival', destination: 'arrakeen:10' },
};
}
function source(patch: Partial<SpiritualAdvisorSource> = {}): SpiritualAdvisorSource {
return { event: 'original-advisor-opportunity', owner: 'bg', turn: 3, phase: 4,
territory: 'polar_sink', sector: 0, amount: 1, revivalEvent: 'original-return', ...patch };
}
void test('provisional Revival SEND permits native Spiritual Advisors only against the original board arrival parent', () => {
for (const advanced of [false, true]) for (const amount of [1, 2]) {
const ctx = context(); ctx.advanced = advanced;
for (const receipt of [source({ amount }), source({ amount, territory: 'arrakeen', sector: 10 })]) {
const before = structuredClone({ ctx, receipt });
assert.doesNotThrow(() => validateSpiritualAdvisorSource(ctx, receipt));
assert.deepEqual({ ctx, receipt }, before);
}
}
});
void test('missing, foreign, stale or completed Revival parents cannot authorize a phase-four source', () => {
const changes: ((ctx: SpiritualAdvisorContext) => void)[] = [
ctx => { delete ctx.homeworldRevivalReturn; },
ctx => { ctx.homeworldRevivalReturn!.event = 'foreign-return'; },
ctx => { ctx.homeworldRevivalReturn!.turn = 2; },
ctx => { ctx.homeworldRevivalReturn!.phase = 5; },
...(['waiting', 'choice', 'complete'] as const).map(stage =>
(ctx: SpiritualAdvisorContext) => { ctx.homeworldRevivalReturn!.stage = stage; }),
...[undefined, 'decline', 'reserves', 'homeworld:tleilaxu', 'missing:0', 'arrakeen:9', 'arrakeen:010'].map(destination =>
(ctx: SpiritualAdvisorContext) => { ctx.homeworldRevivalReturn!.destination = destination; }),
];
for (const change of changes) {
const ctx = context(), receipt = source(); change(ctx);
const before = structuredClone({ ctx, receipt });
assert.throws(() => validateSpiritualAdvisorSource(ctx, receipt), SpiritualAdvisorError);
assert.deepEqual({ ctx, receipt }, before);
}
});
void test('non-Polar accompaniment must use the original arrival territory while Polar remains the fallback', () => {
const ctx = context();
assert.throws(() => validateSpiritualAdvisorSource(ctx, source({ territory: 'carthag', sector: 11 })), SpiritualAdvisorError);
assert.doesNotThrow(() => validateSpiritualAdvisorSource(ctx, source()));
assert.doesNotThrow(() => validateSpiritualAdvisorSource(ctx, source({ territory: 'arrakeen', sector: 10 })));
assert.throws(() => validateSpiritualAdvisorSource(ctx, source({ territory: 'homeworld:tleilaxu', sector: 0 })), SpiritualAdvisorError);
});
void test('Revival source keeps current native BG ownership and exact optional-field timing', () => {
const ctx = context();
for (const receipt of [source({ owner: 't' }), source({ revivalEvent: 'foreign-return' }),
source({ revivalEvent: '' }), source({ revivalEvent: undefined }), source({ turn: 2 }), source({ phase: 5 })])
assert.throws(() => validateSpiritualAdvisorSource(ctx, receipt), SpiritualAdvisorError);
for (const phase of [1, 3, 5, 8]) {
const old = source({ phase }); delete old.revivalEvent;
const original = { ...ctx, phase };
assert.doesNotThrow(() => validateSpiritualAdvisorSource(original, old));
assert.deepEqual(Object.keys(old).sort(), ['amount', 'event', 'owner', 'phase', 'sector', 'territory', 'turn']);
assert.throws(() => validateSpiritualAdvisorSource(original, { ...old, revivalEvent: 'original-return' }), SpiritualAdvisorError);
}
});
function guildChildContext(): SpiritualAdvisorContext {
const ctx = context();
ctx.pendingSpiritualAdvisor = { event: 'original-advisor-opportunity', turn: 3, phase: 4,
decision: { player: 'bg', destination: 'carthag:11', ambassadorEvent: 'original-ambassador', revivalEvent: 'original-return' } };
ctx.pendingAmbassador = { event: 'original-ambassador', revivalEvent: 'original-return', turn: 3, phase: 4, stage: 'arrival' };
return ctx;
}
void test('actual Guild Ambassador child provenance binds accompaniment to its own captured board destination', () => {
const ctx = guildChildContext(), receipt = source({ territory: 'carthag', sector: 11 });
const before = structuredClone({ ctx, receipt });
assert.doesNotThrow(() => validateSpiritualAdvisorSource(ctx, receipt));
assert.doesNotThrow(() => validateSpiritualAdvisorSource(ctx, source()));
assert.throws(() => validateSpiritualAdvisorSource(ctx, source({ territory: 'arrakeen', sector: 10 })), SpiritualAdvisorError);
assert.deepEqual({ ctx, receipt }, before);
const changes: ((ctx: SpiritualAdvisorContext) => void)[] = [
ctx => { delete ctx.pendingSpiritualAdvisor; },
ctx => { delete ctx.pendingAmbassador; },
ctx => { ctx.pendingSpiritualAdvisor!.event = 'foreign-opportunity'; },
ctx => { ctx.pendingSpiritualAdvisor!.turn = 2; },
ctx => { ctx.pendingSpiritualAdvisor!.phase = 5; },
ctx => { ctx.pendingSpiritualAdvisor!.decision.player = 't'; },
ctx => { ctx.pendingSpiritualAdvisor!.decision.ambassadorEvent = 'foreign-ambassador'; },
ctx => { ctx.pendingSpiritualAdvisor!.decision.revivalEvent = 'foreign-return'; },
ctx => { ctx.pendingSpiritualAdvisor!.decision.destination = 'homeworld:tleilaxu'; },
ctx => { ctx.pendingAmbassador!.revivalEvent = 'foreign-return'; },
ctx => { ctx.pendingAmbassador!.turn = 2; },
ctx => { ctx.pendingAmbassador!.phase = 5; },
ctx => { ctx.pendingAmbassador!.stage = 'offer'; },
];
for (const change of changes) {
const original = guildChildContext(); change(original);
assert.throws(() => validateSpiritualAdvisorSource(original, receipt), SpiritualAdvisorError);
}
});
function automatic() {
const ctx: AutomaticAdvisorFlipContext = { advanced: true, status: 'playing', turn: 3, phase: 4,
players: [
{ id: 'bg', faction: 'beneGesserit', forces: { 'polar_sink:0': 1 }, advisors: { polar_sink: {} } },
{ id: 't', faction: 'tleilaxu', forces: { 'arrakeen:10': 2 }, moved: 1 },
], homeworldRevivalReturn: context().homeworldRevivalReturn,
};
const receipt: Extract<AutomaticAdvisorFlipSource, { producer: 'spiritualArrival' }> = {
event: 'original-automatic-occurrence', owner: 'bg', territory: 'polar_sink', turn: 3, phase: 4,
forces: { 'polar_sink:0': 1 }, producer: 'spiritualArrival', spiritual: source(),
};
return { ctx, receipt };
}
void test('a committed phase-four Spiritual arrival can capture and validate its exact alone conversion', () => {
const { ctx, receipt } = automatic();
assert.deepEqual(quoteAutomaticAdvisorFlips(ctx), [{ player: 'bg', territory: 'polar_sink', forces: { 'polar_sink:0': 1 } }]);
ctx.players[0].advisors = { polar_sink: { aloneFlipHold: receipt.event } };
const before = structuredClone({ ctx, receipt });
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, receipt));
assert.deepEqual({ ctx, receipt }, before);
assert.deepEqual(quoteAutomaticAdvisorFlips(ctx), []);
const group = { event: receipt.event, owner: receipt.owner, territory: receipt.territory,
turn: receipt.turn, phase: receipt.phase, forces: receipt.forces };
assert.throws(() => validateAutomaticAdvisorFlip(ctx,
{ ...group, producer: 'groundMovement', mover: 't', move: 1 }), AutomaticAdvisorFlipError);
assert.throws(() => validateAutomaticAdvisorFlip(ctx, { ...group, producer: 'specialRelocation',
relocation: { event: 'original-relocation', kind: 'ambassador', player: 't', origin: 'carthag', destination: 'arrakeen', sector: 10 } }),
AutomaticAdvisorFlipError);
});
void test('phase-four automatic conversion delegates its parent, completed placement and surviving-group checks', () => {
const changes: ((ctx: AutomaticAdvisorFlipContext) => void)[] = [
ctx => { delete ctx.homeworldRevivalReturn; },
ctx => { ctx.homeworldRevivalReturn!.event = 'foreign-return'; },
ctx => { ctx.homeworldRevivalReturn!.stage = 'complete'; },
ctx => { ctx.homeworldRevivalReturn!.destination = 'homeworld:tleilaxu'; },
ctx => { ctx.players[0].forces = {}; },
ctx => { ctx.players[1].forces = { 'polar_sink:0': 1 }; },
];
for (const change of changes) {
const { ctx, receipt } = automatic();
ctx.players[0].advisors = { polar_sink: { aloneFlipHold: receipt.event } };
change(ctx);
const before = structuredClone({ ctx, receipt });
assert.throws(() => validateAutomaticAdvisorFlip(ctx, receipt), AutomaticAdvisorFlipError);
assert.deepEqual({ ctx, receipt }, before);
}
});
void test('phase-four automatic conversion forwards the actual Guild child provenance to the Spiritual validator', () => {
const { ctx, receipt } = automatic(), child = guildChildContext();
ctx.pendingSpiritualAdvisor = child.pendingSpiritualAdvisor;
ctx.pendingAmbassador = child.pendingAmbassador;
ctx.players[0].forces = { 'carthag:11': 1 };
ctx.players[0].advisors = { carthag: { aloneFlipHold: receipt.event } };
receipt.territory = 'carthag'; receipt.forces = { 'carthag:11': 1 };
receipt.spiritual = source({ territory: 'carthag', sector: 11 });
const before = structuredClone({ ctx, receipt });
assert.doesNotThrow(() => validateAutomaticAdvisorFlip(ctx, receipt));
assert.deepEqual({ ctx, receipt }, before);
ctx.pendingAmbassador!.revivalEvent = 'foreign-return';
assert.throws(() => validateAutomaticAdvisorFlip(ctx, receipt), AutomaticAdvisorFlipError);
});