Enforce Salusa free Sardaukar revival penalty

This commit is contained in:
Quintine committed 2026-10-27 23:01:54 +11:00
1 parent 1f92affea7
commit 533c651b07
24 files changed
+962 -213

No files matched your search

+2 -5
View File
@@ -299,11 +299,7 @@ export function EcazEntry({
onClick={() =>
send({
forces: count,
elite: Math.min(
entry.revival!.eliteTanks,
Math.max(0, 1 - entry.revival!.eliteRevived),
count,
),
elite: Math.min(entry.revival!.eliteMaximum, count),
})
}
>
@@ -312,6 +308,7 @@ export function EcazEntry({
))}
</fieldset>
)}
{entry.revival?.eliteBlock && <p className="fine">{entry.revival.eliteBlock}</p>}
<Button
variant="outline"
className={buttonClass}
+9 -4
View File
@@ -4688,11 +4688,15 @@ export function GameTable({
)
: undefined,
g.revival.choamBlocked,
!!g.revival.sardaukarFreeBlock,
).cost
}{' '}
spice.
</p>
)}
{g.revival.sardaukarFreeBlock && (
<p className="fine">{g.revival.sardaukarFreeBlock} Ordinary Sardaukar revival remains available at the displayed paid price.</p>
)}
{g.revival.auditorCyclePolicy && (
<p className="notice">{g.revival.auditorCyclePolicy}</p>
)}
@@ -6331,13 +6335,14 @@ export function GameTable({
ordinary revival allowances, including the normal
one-Sardaukar limit.
</p>
{g.revival.sardaukarFreeBlock && <p className="fine">{g.revival.sardaukarFreeBlock}</p>}
<label htmlFor={`special-revive-${c.id}`}>
Forces to revive
<Input
id={`special-revive-${c.id}`}
type="number"
min={1}
max={Math.min(3, me.tanks)}
max={Math.min(3, me.tanks - (g.revival.sardaukarFreeBlock ? (me.elites?.tanks ?? 0) : 0))}
value={amount}
onChange={(e) =>
setAmount(Number(e.target.value))
@@ -6349,7 +6354,7 @@ export function GameTable({
label="Sardaukar to revive"
value={eliteAmount}
min={Math.max(0, amount - (me.tanks - me.elites.tanks))}
max={Math.min(amount, me.elites.tanks)}
max={g.revival.sardaukarFreeBlock ? 0 : Math.min(amount, me.elites.tanks)}
onChange={setEliteAmount}
/>
)}
@@ -6363,9 +6368,9 @@ export function GameTable({
amount,
elite: eliteAmount,
},
!Number.isSafeInteger(amount) || amount < 1 || amount > Math.min(3, me.tanks) ||
!Number.isSafeInteger(amount) || amount < 1 || amount > Math.min(3, me.tanks - (g.revival.sardaukarFreeBlock ? (me.elites?.tanks ?? 0) : 0)) ||
!Number.isSafeInteger(eliteAmount) || eliteAmount < Math.max(0, amount - (me.tanks - (me.elites?.tanks ?? 0))) ||
eliteAmount > Math.min(amount, me.elites?.tanks ?? 0),
eliteAmount > (g.revival.sardaukarFreeBlock ? 0 : Math.min(amount, me.elites?.tanks ?? 0)),
)}
{me.leaders
.filter(
+9
View File
@@ -20,6 +20,15 @@ open mode gates.
Keep identified Basic-function gaps ahead of Advanced and wider interactions.
Prototype labels and final acceptance gates remain.
**Salusa low free-revival prohibition — 27 October. Prototyped / Partial.** Apply
the printed low penalty to ordinary force pricing, Ghola, Emperor special
Karama and the Tleilaxu Ambassador, preserving normal-force/leader choices,
paid Sardaukar, native quotas and existing pending receipts. The owner view
exposes the prohibition and exact price; Ambassador returns use native
Homeworld custody. The affected 15-file union passed 86 cases and the
production build passed. Source composition and occupied retention remain
explicitly documented; wider combinations and deployed acceptance remain open.
**Voice naming Portable Snooper — 27 October. Prototyped / Partial.** Voice
can name the canonical Portable separately from ordinary Snooper or the generic
poison-defense category. Native plan and late-defense checks share the same
+38
View File
@@ -2,8 +2,40 @@
Source audit, 9 September 2026. This separates publisher wording, rule composition, and remaining decisions. It does not enable Homeworld games or replace the outstanding Ambassador/Duke revival rulings.
## Salusa Low and free Sardaukar revival — 27 October 2026
The [GF9 Ecaz and Moritani rulebook, pp. 9–10](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=10)
gives Salusa's Low penalty as no free Sardaukar revival and says Karama cannot
affect Homeworld advantages or penalties. The supplied Advanced rulebook,
physical p. 31 / printed p. 29, exempts Emperor special revival from turn limits,
but does not exempt it from this separate Homeworld prohibition. Physical p. 39 /
printed p. 37 makes Ghola's force return free. Applying the Low penalty across
these sources is a source-composition interpretation, not a specific FAQ.
For fresh Advanced Homeworld actions, the shared rule excludes Sardaukar from
free normal revival, Ghola, special Emperor Karama and Tleilaxu Ambassador
returns while the Low penalty applies. Paid normal Sardaukar revival remains
legal subject to the usual costs, discounts and limits; free ordinary forces and
eligible leaders remain available. An unused ordinary free allowance is retained
after a paid star, so a later eligible ordinary return may consume it. Ambassador
returns use the native reserve destinations: normal forces to Kaitain and
Sardaukar to Salusa.
Provisionally, an established Low occupation retains its penalty after native
repopulation until the original qualifying occupation ends. Foreign presence
without a usable original qualification history is guarded rather than assumed
to be occupied or unoccupied. Previously accepted unstamped revival receipts and
special intents retain their original terms; the change does not rewrite saved
counters. Rule behavior, controls and minimal bots are connected in the current
prototype; focused integration verification and wider combinations remain due.
This section supersedes the Low-to-High free Ghola claims in the historical
sections below; it does not certify complete Homeworlds or release readiness.
## Salusa Ghola and live Truthtrance promises — 27 October 2026
Historical: free Sardaukar cannot now be returned while the Low penalty applies;
see the later [Salusa Low decision](#salusa-low-and-free-sardaukar-revival--27-october-2026).
Before either full plan seals, a current battle promise no longer forbids every
population-crossing Ghola. Preserve a feasible Yes, whole-No or grouped claim.
Reject the actor's return if the resulting High support regime makes that
@@ -66,6 +98,9 @@ dashboard and implementation log.
## Salusa Ghola and renewed inspection answers — 27 October 2026
Historical: free Sardaukar cannot now be returned while the Low penalty applies;
see the later [Salusa Low decision](#salusa-low-and-free-sardaukar-revival--27-october-2026).
The earlier commitment-free window below now includes completed inspection
answers before either full plan is sealed. Commit the actual Ghola return and
current Salusa High benefit first. Preserve a still-feasible native answer;
@@ -119,6 +154,9 @@ recovery/privacy/custody acceptance remain separate.
## Salusa Ghola before battle commitments — 27 October 2026
Historical: free Sardaukar cannot now be returned while the Low penalty applies;
see the later [Salusa Low decision](#salusa-low-and-free-sardaukar-revival--27-october-2026).
Historical first window: inspection restrictions described in this subsection
are superseded by the renewed-answer follow-up above. Its proof remains dated.
+10 -5
View File
@@ -1,9 +1,9 @@
# Homeworld combat foundation and Salusa support
Current follow-up: [Ghola inspection renewal](HOMEWORLD_BENEFITS_RULES.md#salusa-ghola-and-renewed-inspection-answers--27-october-2026)
can raise Salusa before either full plan seals, retaining feasible answers or
renewing impossible original fields. The [live-promise follow-up](HOMEWORLD_BENEFITS_RULES.md#salusa-ghola-and-live-truthtrance-promises--27-october-2026)
retains feasible claims and rejects self-impossible returns; full seals remain guarded.
Current follow-up: the [Salusa Low penalty](HOMEWORLD_BENEFITS_RULES.md#salusa-low-and-free-sardaukar-revival--27-october-2026)
prohibits free Sardaukar revival, including Ghola. This supersedes the earlier
Low-to-High free Ghola scenarios and their inspection/promise claims below.
The retained occupation composition is provisional and wider combinations remain open.
9 September 2026. This extends the native-reserve runtime. Homeworld invasion and live off-planet battle actions remain unavailable; no start gate is opened.
@@ -11,7 +11,7 @@ retains feasible claims and rejects self-impossible returns; full seals remain g
In Advanced Homeworld development games, maintaining at least two Sardaukar on Salusa grants free support to the Emperor’s Sardaukar wherever they fight. Normal forces retain their ordinary support costs. The shared combat calculation uses a separate optional elite-support flag, so ordinary Sardaukar Karama can reduce their strength without canceling the Homeworld benefit. Shipping a fourth Sardaukar away from the initial five leaves Salusa low and removes free elite support. Normal reserves transferred there do not replace the named population threshold.
Server plans, typed casualty options, Stone Burner comparisons, the private player view, all four AI policies and the table’s support maximum share this calculation. Saved casualty decisions preserve the relevant force snapshot. Legacy snapshots without the optional flag retain their existing behavior. Salusa’s low free-revival prohibition and occupation penalty remain unfinished.
Server plans, typed casualty options, Stone Burner comparisons, the private player view, all four AI policies and the table’s support maximum share this calculation. Saved casualty decisions preserve the relevant force snapshot. Legacy snapshots without the optional flag retain their existing behavior. The Low free-revival prohibition is connected; occupation retention and broader interactions remain provisional.
## Off-planet foundation
@@ -23,6 +23,11 @@ These quotes are not yet wired into live off-planet battle discovery, aftermath,
## Verification
The following is historical evidence for the original combat checkpoint. Its
assertion that free Ghola can later return a Sardaukar while Salusa is Low is
superseded by the current Low penalty; do not treat these counts as verification
of the new rule.
Focused checks pass 62 combat/Stone Burner cases, 29 battle-resolution cases, nine Homeworld army/discovery cases and eight Salusa engine cases. These groups overlap existing regression coverage and are not a combined full-suite count. The Salusa cases use genuine Advanced setup, typed shipments and actual battle preparation, sealed plans, traitor actions and casualty settlement. They cover high/low transitions, normal support costs, ordinary Karama, JSON restoration, opponent privacy, physical conservation and legal decisions from every AI level.
The first integrated pass passed **3,218 offline cases**, typecheck, lint, production build and **40 HTTP cases**. Independent review then reproduced a Ghola deadlock: raising Salusa from one to two Sardaukar during combat could invalidate an already sealed paid-support plan. A temporary explicit release boundary now blocks only that population-changing Sardaukar Ghola return during the Emperor’s battle, before any card or force moves. Regressions cover both a sealed plan and a binding prescience dial before either full plan, JSON rejection without mutation, subsequent battle completion and the later legal Sardaukar return. The player view, AI allowance and controls expose the same restriction; ordinary force and leader targets remain available. This is an unfinished interaction, not an official prohibition. The final check after the guard passes **3,221 offline cases**, typecheck and lint; the final production build passes. The 40 HTTP cases passed before the guard; the guard’s new coverage executes production engine actions with JSON restoration. All **2,648** earlier baseline rooms retain their versions and state hashes. No new browser acceptance was performed for this slice. No live Homeworld invasion, complete Homeworld game, full module combination or browser acceptance is claimed by this checkpoint. The recurring automation remains removed.
+13
View File
@@ -1,5 +1,18 @@
# Dune implementation status
## 27 October 2026 — Salusa Low free Sardaukar revival
Advanced Homeworld play applies the Low Salusa penalty across normal free
revival, Ghola, Emperor's special Karama and Tleilaxu Ambassador returns.
Ordinary paid Sardaukar remain eligible, and unused free normal allowance stays
available. Ambassador forces use their native Kaitain and Salusa destinations.
Controls and minimal bot choices share the same rule. The penalty's retention
during an established occupation after repopulation is provisional; old accepted
unstamped receipts keep their original terms. The affected 15-file test union
passed 86 cases and the production build passed. Broader combinations and
final/deployed acceptance remain open.
[Source and superseded Ghola claims](HOMEWORLD_BENEFITS_RULES.md#salusa-low-and-free-sardaukar-revival--27-october-2026).
## 27 October 2026 — Voice naming Portable Snooper
Voice can name the physical Portable Snooper independently of generic poison
+29 -3
View File
@@ -1,5 +1,23 @@
# Rule decision index
## Salusa Low and free Sardaukar revival — 27 October 2026
GF9's [Ecaz and Moritani rulebook, pp. 9–10](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf#page=10)
prohibits free Sardaukar revival while Salusa's Low penalty applies and says
Karama cannot affect Homeworld advantages or penalties. Apply the ban to normal
free revival, Ghola, Emperor's special Karama and Tleilaxu Ambassador returns.
Ordinary paid Sardaukar revival remains available within its own limits; normal
forces and eligible leaders remain available. The supplied Advanced rulebook's
special-revival exemption from turn limits does not override this separate ban.
An established Low occupation retains its penalty after native repopulation until
the qualifying occupation ends; this composition is provisional. A legacy
foreign presence without the original qualification history is guarded rather
than classified as an occupation. Preserve previously accepted unstamped revival
receipts and special intents with their original terms. This supersedes the
earlier claims below that a free Ghola can raise Low Salusa to High.
[Contract](HOMEWORLD_BENEFITS_RULES.md#salusa-low-and-free-sardaukar-revival--27-october-2026).
## Voice naming Portable Snooper — 27 October 2026
The authorized Advanced source permits Voice to name a specific card; the
@@ -67,9 +85,8 @@ return through the special power without spending the ordinary elite allowance.
This is a source-composition interpretation, not an official GF9 FAQ. Ordinary
revival, Ghola and allied extra revival retain their existing limits. Existing
saved counters are not retroactively reclassified.
The Homeworld Salusa Secundus low-population penalty prohibits free Sardaukar
revival; its interaction with this special power remains unresolved and is not
certified by this interpretation.
The later [Salusa Low decision](#salusa-low-and-free-sardaukar-revival--27-october-2026)
applies the separate Homeworld ban to this free special power.
[Runtime evidence](../tests/emperor-special-revival-exemption-runtime.test.ts).
## Sabotage gift overflow — 27 October 2026
@@ -339,6 +356,9 @@ publisher ruling. [Source and contract](NEXUS_EMPEROR_RULES.md#positive-shortage
## Salusa Ghola with binding Truthtrance — 27 October 2026
Historical: the later [Salusa Low decision](#salusa-low-and-free-sardaukar-revival--27-october-2026)
supersedes free Sardaukar returns from Low Salusa.
Remove the blanket live-promise restriction before either full seal. Preserve
feasible Yes/whole-No/grouped claims and reject a return that deliberately makes
the actor's own promise impossible. Renew obsolete inspections first, then test
@@ -352,6 +372,9 @@ their preview boundary. [Contract](HOMEWORLD_BENEFITS_RULES.md#salusa-ghola-and-
## Salusa Ghola inspection renewal — 27 October 2026
Historical: the later [Salusa Low decision](#salusa-low-and-free-sardaukar-revival--27-october-2026)
supersedes free Sardaukar returns from Low Salusa.
Before either full plan is sealed, an actual Salusa-crossing Ghola return may
retain a feasible inspection or renew an impossible original field. The supplied
PDF's outlined impossible-answer Q&A (physical41 / printed39) is an accepted
@@ -364,6 +387,9 @@ the inspection restriction in the earlier window below.
## Salusa Ghola commitment-free battle window — 27 October 2026
Historical: the later [Salusa Low decision](#salusa-low-and-free-sardaukar-revival--27-october-2026)
supersedes free Sardaukar returns from Low Salusa.
Remove the whole-battle population-crossing restriction before any sealed plan,
inspection answer or active battle promise. Actual revival raises current native
Salusa population and the existing combat quote uses its new High free support;
+5 -6
View File
@@ -1703,8 +1703,9 @@ function policyActions(g: GameView): Action[] {
leader: dead.id,
},
];
const amount = Math.min(3, me.tanks);
const elite = Math.min(amount, me.elites?.tanks ?? 0);
const eliteStock = me.elites?.tanks ?? 0;
const amount = Math.min(3, me.tanks - (g.revival.sardaukarFreeBlock ? eliteStock : 0));
const elite = g.revival.sardaukarFreeBlock ? 0 : Math.min(amount, eliteStock);
if (amount === 3 || (level === 0 && amount > 0))
return [
{ type: 'card', mode: 'special', card: specialCard.id, amount, elite },
@@ -2110,10 +2111,7 @@ function policyActions(g: GameView): Action[] {
: { ...action, decline: true }];
// Minimal legal participation: return as many Tanks counters as the
// printed maximum allows, spending the single elite option first.
const elite = Math.min(
revival.eliteTanks,
Math.max(0, 1 - revival.eliteRevived),
);
const elite = revival.eliteMaximum;
const forces = Math.min(revival.maximum, revival.tanks);
return [{ ...action, forces, elite: Math.min(elite, forces) }];
}
@@ -3455,6 +3453,7 @@ function policyActions(g: GameView): Action[] {
g.revival.discount,
undefined,
g.revival.choamBlocked,
!!g.revival.sardaukarFreeBlock,
);
if (
quote.cost === 0 ||
+28 -8
View File
@@ -240,6 +240,7 @@ import { GuildAmbassadorMarkerError, markerDestinations, markerOwner, quoteGuild
type GuildAmbassadorMarker, type GuildAmbassadorMarkerQuote } from './guild-ambassador-no-field';
import {
homeworldLowBonus,
homeworldSardaukarFreeRevivalBlock,
homeworldSardaukarFreeSupport,
homeworldSardaukarGholaBlock,
homeworldSardaukarGholaCrossing,
@@ -18372,7 +18373,9 @@ function kullIntegrity(g: Game): void {
'The interrupted Karama has no proven Truthtrance continuation.');
} else {
requireRule(frame.intent?.kind === 'special', 'The Kull attempt has no validated intent.');
const prepared = prepareSpecialKaramaIntent(source, frame.owner, specialKaramaAction(frame.intent.use));
const original = frame.intent.use;
const prepared = prepareSpecialKaramaIntent(source, frame.owner, specialKaramaAction(original),
{ legacySalusaIntent: original.kind === 'emperorForces' && original.salusaFreePolicy === undefined });
requireRule(JSON.stringify(prepared) === JSON.stringify(frame.intent.use),
'The saved special Karama selection has changed.');
}
@@ -25008,10 +25011,11 @@ function decideAmbassador(g: Game, p: Player, action: Action) {
throw new RuleError(error.message);
throw error;
}
p.reserves = quote.after.reserves;
const salusaBlock = quote.elite > 0 ? homeworldRule(() => homeworldSardaukarFreeRevivalBlock(g, p.id)) : null;
requireRule(!salusaBlock, salusaBlock ?? 'Choose eligible forces for the free Ambassador return.');
addNativeReserves(g, p, quote.amount, quote.elite);
p.tanks = quote.after.tanks;
if (p.elites) {
p.elites.reserves = quote.after.eliteReserves;
p.elites.tanks = quote.after.eliteTanks;
p.elites.revived = quote.eliteRevivedNext;
}
@@ -30016,7 +30020,8 @@ function gholaOptions(g: Game, p: Player) {
strength,
}));
const kwisatz = !!(g.advanced && p.faction === 'atreides' && p.kwisatz?.dead);
let eliteBlock = homeworldSardaukarGholaBlock(g, p.id);
let eliteBlock = homeworldRule(() => homeworldSardaukarFreeRevivalBlock(g, p.id)) ??
homeworldSardaukarGholaBlock(g, p.id);
let eliteRemaining = eliteBlock ? 0 : Math.min(
p.elites?.tanks ?? 0,
eliteRevivalRemaining(p, g.advanced),
@@ -30237,7 +30242,8 @@ function applyGholaEffect(g: Game, p: Player, action: Action, cardId?: string) {
'Forces',
);
const elite = eliteChoice(n, p.tanks, p.elites?.tanks ?? 0, action.elite);
const eliteBlock = elite > 0 ? homeworldSardaukarGholaBlock(g, p.id) : null;
const eliteBlock = elite > 0 ? homeworldRule(() => homeworldSardaukarFreeRevivalBlock(g, p.id)) ??
homeworldSardaukarGholaBlock(g, p.id) : null;
requireRule(!eliteBlock, eliteBlock ?? 'Choose an eligible Ghola return.');
requireRule(
elite <= eliteRevivalRemaining(p, g.advanced),
@@ -39042,7 +39048,7 @@ export type SpecialKaramaIntent = {
}
| { kind: 'guild'; target: string; shipment: PendingShipment }
| { kind: 'guildHomeworld'; target: string; shipment: PendingHomeworldShipment }
| { kind: 'emperorForces'; amount: number; elite: number }
| { kind: 'emperorForces'; amount: number; elite: number; salusaFreePolicy?: 1 }
| { kind: 'emperorLeader'; leader: string }
| { kind: 'harkonnen'; target: string; amount: number }
| { kind: 'ecaz'; event: string }
@@ -39054,6 +39060,7 @@ export function prepareSpecialKaramaIntent(
g: Game,
playerId: string,
action: Action,
options: { legacySalusaIntent?: boolean } = {},
): SpecialKaramaIntent {
const p = getPlayer(g, playerId);
requireRule(action.emperorNexus === undefined || p.faction === 'richese',
@@ -39303,7 +39310,11 @@ export function prepareSpecialKaramaIntent(
}
const n = integer(action.amount, 1, Math.min(3, p.tanks), 'Forces');
const elite = eliteChoice(n, p.tanks, p.elites?.tanks ?? 0, action.elite);
return { ...base, kind: 'emperorForces', amount: n, elite };
const eliteBlock = elite > 0 && !options.legacySalusaIntent
? homeworldRule(() => homeworldSardaukarFreeRevivalBlock(g, p.id)) : null;
requireRule(!eliteBlock, eliteBlock ?? 'Choose eligible forces for the special revival.');
return { ...base, kind: 'emperorForces', amount: n, elite,
...(g.homeworlds?.custody && !options.legacySalusaIntent ? { salusaFreePolicy: 1 as const } : {}) };
} else if (p.faction === 'harkonnen') {
requireRule(
g.phase === 3 && !g.pendingExchange,
@@ -39405,6 +39416,7 @@ export function executeSpecialKaramaIntent(
g,
intent.owner,
specialKaramaAction(intent),
{ legacySalusaIntent: intent.kind === 'emperorForces' && intent.salusaFreePolicy === undefined },
);
requireRule(
prepared.kind === intent.kind,
@@ -45111,10 +45123,17 @@ export function viewGame(state: Game, id: string, now?: number) {
revival:
entry.stage === 'revival' && id === entry.beneficiary
? {
maximum: Math.min(4, me.tanks),
maximum: Math.min(4, me.tanks - (me.elites?.tanks ?? 0) +
(homeworldRule(() => homeworldSardaukarFreeRevivalBlock(g, me.id)) ? 0 :
me.faction === 'ixians' ? (me.elites?.tanks ?? 0) :
Math.min(me.elites?.tanks ?? 0, Math.max(0, 1 - (me.elites?.revived ?? 0))))),
tanks: me.tanks,
eliteTanks: me.elites?.tanks ?? 0,
eliteRevived: me.elites?.revived ?? 0,
eliteMaximum: homeworldRule(() => homeworldSardaukarFreeRevivalBlock(g, me.id)) ? 0 :
me.faction === 'ixians' ? (me.elites?.tanks ?? 0) :
Math.min(me.elites?.tanks ?? 0, Math.max(0, 1 - (me.elites?.revived ?? 0))),
eliteBlock: homeworldRule(() => homeworldSardaukarFreeRevivalBlock(g, me.id)),
leaders: tleilaxuAmbassadorLeaderChoices(g, me),
blocked:
ambassadorEffectBlock(
@@ -45576,6 +45595,7 @@ export function viewGame(state: Game, id: string, now?: number) {
orderBlocked: nativeRevivalOrderBlock(g,id),
pending: !!g.pendingRevival,
specialKaramaBlock: null,
sardaukarFreeBlock: homeworldRule(() => homeworldSardaukarFreeRevivalBlock(g, me.id)),
leaders: leaderRevivals.leaders,
kwisatz: leaderRevivals.kwisatz,
...(leaderRevivals.auditorCyclePolicy ? { auditorCyclePolicy: leaderRevivals.auditorCyclePolicy } : {}),
+1 -1
View File
@@ -92,7 +92,7 @@ export const FACTION_RULES: Record<
advanced: [
'Five Sardaukar forces have a printed battle strength of two each, except against Fremen, where they count as ordinary forces.',
'Spice support applies per token. A supported Sardaukar contributes two strength; an unsupported Sardaukar contributes one, except when fighting Fremen.',
'Once per game during Revival, spend Karama to revive up to three of your own forces or one of your own dead leaders for free. These special returns do not use normal revival allowances, including the ordinary one-Sardaukar limit.',
'Once per game during Revival, spend Karama to revive up to three of your own forces or one of your own dead leaders for free. These special returns do not use normal revival allowances, including the ordinary one-Sardaukar limit. With Homeworlds, a low Salusa prohibits free Sardaukar returns; normal forces and eligible leaders remain available.',
'An elite token counts as one force for ordinary revival. Only one Sardaukar may be ordinarily revived per turn; the Emperor’s special Karama is separate.',
],
},
+42
View File
@@ -1,6 +1,11 @@
import type { Game } from './engine';
import { homeworldForceGroups } from './homeworld-custody';
import { homeworldContext } from './homeworld-game';
import { homeworldPopulations } from './homeworld-population';
import {
quoteStableHomeworldOccupation,
type StableHomeworldOccupationContext,
} from './homeworld-stable-occupation';
type HomeworldBenefitContext = Pick<
Game,
@@ -59,6 +64,43 @@ export function homeworldSardaukarFreeSupport(
);
}
/** The Low Salusa penalty applies to free Sardaukar revival independently of
* the revival source and its limits. An established occupation retains that
* penalty after native repopulation until the original occupier leaves. */
export function homeworldSardaukarFreeRevivalBlock(
context: HomeworldBenefitContext & StableHomeworldOccupationContext,
playerId: string,
): string | null {
if (
!context.advanced ||
!context.homeworlds?.custody ||
!context.players.some((player) => player.id === playerId && player.faction === 'emperor')
) return null;
const physical = homeworldContext(context);
const salusa = homeworldPopulations(physical, context.homeworlds.custody).find(
(home) => home.native === playerId && home.card === 'salusa_secundus',
);
if (!salusa) return null;
const penalty = 'Salusa Secundus prohibits free Sardaukar revival while its low-population penalty applies.';
if (salusa.side === 'low') return penalty;
// Older profiles do not have the occupation history needed to distinguish
// a retained occupier from an unqualified visitor. Do not infer either.
if (!context.homeworldOccupationPreview) {
const world = homeworldForceGroups(physical, context.homeworlds.custody).find(
(home) => home.id === salusa.location,
);
if (world && Object.entries(world.forces).some(([id, forces]) =>
id !== playerId && forces.normal + forces.elite > 0,
)) return 'Salusa occupation eligibility requires the original Homeworld qualification history.';
return null;
}
const occupation = quoteStableHomeworldOccupation(context, 'salusa_secundus');
if (occupation.blocked) return occupation.blocked;
return occupation.entitlement ? penalty : null;
}
/** One returned star crosses Salusa's current threshold during its native
* player's battle. The caller still owns card timing and actual return count. */
export function homeworldSardaukarGholaCrossing(
+1 -1
View File
@@ -102,7 +102,7 @@ export const HELP = {
'The table pauses before this power resolves. Other factions can use a legal Karama cancellation; everyone can allow the power.',
ally: 'Set aside spice for an ally’s bids or shipments. You may reclaim unused funds, but a current winning bid must remain funded.',
prescience:
'Only the requested element becomes fixed. Advanced Salusa Ghola may renew an impossible answer before either full plan is sealed; answer that same field again. Other plan elements stay private until reveal.',
'Only the requested element becomes fixed. Other plan elements stay private until reveal. Low Salusa prohibits free Sardaukar revival, including Ghola; other legal Ghola choices remain available.',
};
const phaseDetails = [
+10 -1
View File
@@ -28,9 +28,12 @@ export type RevivalCancellationContext = Pick<
| 'turn'
| 'advanced'
| 'homeworlds'
| 'homeworldOccupationHistory'
| 'homeworldOccupationPreview'
| 'players'
| 'revivalRules'
| 'freeRevival'
| 'recruits'
| 'dukeVidal'
| 'pendingRevival'
| 'emperorExtra'
@@ -273,9 +276,15 @@ export function quoteRevivalCancellation(
nonnegative(p.revived),
'The pending force revival has invalid physical counts.',
);
if (original.salusaFreePolicy !== undefined)
requireRevival(original.salusaFreePolicy === 1 && g.advanced &&
p.faction === 'emperor' && !!g.homeworlds?.custody && !original.emperorExtra &&
original.payer === undefined &&
original.free === forceRevivalQuote(g, p, original.amount!, original.elite ?? 0).free,
'The pending Salusa revival no longer matches its original free allocation.');
} else {
requireRevival(
original.free === 0 &&
original.salusaFreePolicy === undefined && original.free === 0 &&
original.amount === undefined &&
original.elite === undefined &&
(original.kind === 'kwisatz'
+12 -3
View File
@@ -1,6 +1,6 @@
import type { Decision, Game, ResponseWindow } from './engine';
import type { PendingRevival } from './revival';
import { forceRevivalRemaining, freeRevivalRemaining } from './revival';
import { forceRevivalQuote, forceRevivalRemaining, legacyFreeRevivalRemaining } from './revival';
import {
continuationCustody,
RevivalCancellationError,
@@ -26,11 +26,14 @@ export type RevivalResumeContext = Pick<
| 'advanced'
| 'expansions'
| 'homeworlds'
| 'homeworldOccupationHistory'
| 'homeworldOccupationPreview'
| 'homeworldRevival'
| 'players'
| 'pendingRevival'
| 'revivalRules'
| 'freeRevival'
| 'recruits'
| 'emperorExtra'
| 'techTokens'
| 'revivalFreeIncome'
@@ -203,6 +206,11 @@ export function quoteRevivalResume(
);
if (original.nexusEmperorEvent !== undefined)
validateNexusEmperorSource(g, original);
if (original.salusaFreePolicy !== undefined)
requireResume(original.salusaFreePolicy === 1 && original.kind === 'forces' &&
p.faction === 'emperor' && g.advanced && !!g.homeworlds?.custody &&
!original.emperorExtra && original.nexusEmperorEvent === undefined,
'The saved Salusa revival policy does not match its ordinary Emperor source.');
if (original.kind === 'forces') {
requireResume(
count(original.amount) &&
@@ -222,8 +230,9 @@ export function quoteRevivalResume(
if (g.homeworlds && !original.emperorExtra &&
original.nexusEmperorEvent === undefined)
requireResume(
original.free ===
Math.min(original.amount, freeRevivalRemaining(g, p)),
original.free === (original.salusaFreePolicy === 1
? forceRevivalQuote(g, p, original.amount, original.elite ?? 0).free
: Math.min(original.amount, legacyFreeRevivalRemaining(g, p))),
'The saved Homeworld revival free allocation does not match its current eligible group.',
);
const elite = original.elite ?? 0;
+23 -3
View File
@@ -2,7 +2,7 @@ import type { Game, Player } from './engine';
import type { Leader } from './cards';
import { faction, type FactionId } from './catalog';
import { isAuditorLeader } from './choam-auditor';
import { homeworldLowBonus } from './homeworld-benefits';
import { homeworldLowBonus, homeworldSardaukarFreeRevivalBlock } from './homeworld-benefits';
import { recruitsRevivalAllowance } from './recruits';
import { DUKE_VIDAL_ID } from './duke-vidal';
import { ecazDukeRevivalBlock, quoteEcazDukeRevival, type EcazDukeRevivalContext } from './ecaz-duke-revival';
@@ -15,6 +15,8 @@ type RevivalRateContext = Pick<
| 'advanced'
| 'players'
| 'homeworlds'
| 'homeworldOccupationHistory'
| 'homeworldOccupationPreview'
| 'revivalRules'
| 'freeRevival'
| 'turn'
@@ -85,8 +87,10 @@ export function normalForceRevivalLimit(
export function normalRevivalAllowance(
g: RevivalRateContext,
p: Pick<Player, 'id' | 'faction' | 'revived' | 'freeForcesRevived'>,
legacySalusaUsage = false,
) {
const currentFreeRate = freeRevivalRate(g, p);
const salusaLedger = !legacySalusaUsage && g.advanced && p.faction === 'emperor' && !!g.homeworlds?.custody;
const native = recruitsRevivalAllowance(g, {
currentFreeRate,
currentLimit: Math.max(forceRevivalLimit(g, p), currentFreeRate),
@@ -94,7 +98,7 @@ export function normalRevivalAllowance(
// A Recruits activation is rejected when this ledger is absent and use is
// nonzero. The fallback preserves old-room behavior while the card is idle.
freeAllowanceUsed:
g.recruits?.turn === g.turn ? (p.freeForcesRevived ?? p.revived) : p.revived,
g.recruits?.turn === g.turn || salusaLedger ? (p.freeForcesRevived ?? p.revived) : p.revived,
freeRateCap: 20,
});
if (g.revivalRules?.nexusFremen?.turn !== g.turn ||
@@ -113,6 +117,13 @@ export function freeRevivalRemaining(
) {
return normalRevivalAllowance(g, p).freeRemaining;
}
/** Existing unstamped pending requests retain the earlier allowance calculation. */
export function legacyFreeRevivalRemaining(
g: RevivalRateContext,
p: Pick<Player, 'id' | 'faction' | 'revived' | 'freeForcesRevived'>,
) {
return normalRevivalAllowance(g, p, true).freeRemaining;
}
export function forceRevivalQuote(
g: RevivalRateContext,
p: Pick<Player, 'id' | 'faction' | 'revived' | 'freeForcesRevived'>,
@@ -121,7 +132,8 @@ export function forceRevivalQuote(
freeElite?: number,
) {
const free = Math.min(amount, freeRevivalRemaining(g, p));
return forceRevivalPrice(
const salusaPolicy = g.advanced && p.faction === 'emperor' && !!g.homeworlds?.custody;
const quote = forceRevivalPrice(
p.faction,
amount,
elite,
@@ -129,7 +141,9 @@ export function forceRevivalQuote(
!!revivalDiscount(g, p),
freeElite,
!!g.revivalRules?.choamBlocked,
salusaPolicy && !!homeworldSardaukarFreeRevivalBlock(g, p.id),
);
return { ...quote, ...(salusaPolicy ? { salusaFreePolicy: 1 as const } : {}) };
}
/** Normal force returns only; Emperor-funded extra revivals and card effects are separate. */
@@ -319,6 +333,9 @@ export type PendingRevival = {
/** Frozen physical Ix HIGH grant, additional to ordinary allowance. Absent
* on requests accepted before this effect was implemented. */
ixSuboidBonus?: number;
/** New Emperor Homeworld requests record the Salusa/free-ledger policy.
* Its absence preserves already accepted legacy request semantics. */
salusaFreePolicy?: 1;
amount?: number;
elite?: number;
leader?: string;
@@ -369,7 +386,10 @@ export function forceRevivalPrice(
discount: boolean,
freeElite?: number,
choamBlocked = false,
sardaukarFreeBlocked = false,
) {
if (faction === 'emperor' && sardaukarFreeBlocked)
free = Math.min(free, Math.max(0, amount - elite));
const freeCyborgs =
faction === 'ixians' ? (freeElite ?? Math.min(free, elite)) : 0;
const normalCost = paidForceRevivalCost(
+44 -39
View File
@@ -16,7 +16,7 @@ import { DIFFICULTIES } from '../game/bot-profiles';
import { homeworldGameIntegrity } from '../game/homeworld-game';
import { casualtyOptions, type Casualties } from '../game/combat';
import { baseDeck } from '../game/cards';
import { homeworldSardaukarGholaBlock } from '../game/homeworld-benefits';
import { homeworldSardaukarFreeRevivalBlock } from '../game/homeworld-benefits';
const emperor = (g: Game) => g.players.find((player) => player.id === 'e')!;
const atreides = (g: Game) => g.players.find((player) => player.id === 'a')!;
@@ -343,34 +343,40 @@ function gholaThresholdBattle(normal = 1) {
return { g, card: card.id };
}
void test('Ghola can raise Salusa before battle commitments and the same battle uses current free Sardaukar support', () => {
void test('Low Salusa prohibits free Sardaukar Ghola before battle commitments', () => {
const { g: before, card } = gholaThresholdBattle();
assert.equal(homeworldSardaukarGholaBlock(before, 'e'), null);
assert.equal(viewGame(before, 'e').ghola.eliteRemaining, 1);
const done = applyAction(before, 'e', { type: 'card', card, amount: 1, elite: 1 });
assert.equal(done.homeworlds!.custody!.salusa!.elite, 2);
assert.equal(emperor(done).elites!.tanks, 0);
assert.equal(emperor(done).elites!.revived, 1);
assert.equal(done.discard.filter((held) => held.id === card).length, 1);
assert.equal(viewGame(done, 'e').battle!.ownForces!.eliteFreeSupport, true);
assert.equal(viewGame(done, 'e').battle!.ownForces!.elite, 3);
assert.equal(emperor(done).spice, 3);
const resolved = resolve(done, 6);
const reason = homeworldSardaukarFreeRevivalBlock(before, 'e');
assert.match(reason!, /prohibits free Sardaukar revival/);
assert.equal(viewGame(before, 'e').ghola.eliteBlock, reason);
assert.equal(viewGame(before, 'e').ghola.eliteRemaining, 0);
const snapshot = structuredClone(before);
assert.throws(() => applyAction(before, 'e', { type: 'card', card, amount: 1, elite: 1 }), /prohibits free Sardaukar revival/);
assert.deepEqual(before, snapshot);
assert.equal(before.discard.filter((held) => held.id === card).length, 0);
assert.equal(viewGame(before, 'e').battle!.ownForces!.eliteFreeSupport, undefined);
let resolved = resolve(before, 6, 3);
if (resolved.decision?.kind === 'battleLosses') {
const choice = resolved.decision.options.findIndex(
(loss) => loss.normal === 0 && loss.elite === 3,
);
assert.ok(choice >= 0);
resolved = applyAction(reload(resolved), 'e', { type: 'decision', choice });
}
assert.equal(emperor(resolved).forces['arrakeen:10'], 1);
assert.equal(emperor(resolved).spice, 5, 'Only automatic Arrakeen collection adds spice.');
assert.equal(resolved.homeworlds!.custody!.salusa!.elite, 2);
assert.equal(emperor(resolved).spice, 2, 'The paid battle support leaves only automatic Arrakeen collection.');
assert.equal(resolved.homeworlds!.custody!.salusa!.elite, 1);
inventory(resolved);
});
void test('the remaining Salusa preview guard protects a sealed paid-star plan until its actual battle completes', () => {
void test('the Low Salusa free revival prohibition remains through a sealed plan and completed battle', () => {
const { g: before, card } = gholaThresholdBattle();
const sealed = applyAction(before, 'e', plan(before, 6, 3));
for (const state of [sealed]) {
const g = reload(state);
const reason = homeworldSardaukarGholaBlock(g, 'e');
const reason = homeworldSardaukarFreeRevivalBlock(g, 'e');
assert.ok(
reason,
'The unresolved Salusa timing combination must have an explicit reason.',
'The Low Salusa prohibition must have an explicit reason.',
);
const view = viewGame(g, 'e');
assert.equal(view.ghola.eliteRemaining, 0);
@@ -406,11 +412,13 @@ void test('the remaining Salusa preview guard protects a sealed paid-star plan u
g = applyAction(reload(g), 'e', { type: 'decision', choice });
}
assert.equal(g.battle, null);
assert.equal(homeworldSardaukarGholaBlock(g, 'e'), null);
assert.equal(viewGame(g, 'e').ghola.eliteRemaining, 1);
g = applyAction(reload(g), 'e', { type: 'card', card, amount: 1, elite: 1 });
assert.equal(g.homeworlds!.custody!.salusa!.elite, 2);
assert.equal(g.discard.filter((held) => held.id === card).length, 1);
assert.match(homeworldSardaukarFreeRevivalBlock(g, 'e')!, /prohibits free Sardaukar revival/);
assert.equal(viewGame(g, 'e').ghola.eliteRemaining, 0);
const finished = structuredClone(g);
assert.throws(() => applyAction(g, 'e', { type: 'card', card, amount: 1, elite: 1 }), /prohibits free Sardaukar revival/);
assert.deepEqual(g, finished);
assert.equal(g.homeworlds!.custody!.salusa!.elite, 1);
assert.equal(g.discard.filter((held) => held.id === card).length, 0);
inventory(g);
});
@@ -455,7 +463,7 @@ void test('the Salusa commitment guard leaves normal-force and eligible-leader G
}
});
void test('an opponent-only sealed plan keeps the remaining Salusa timing boundary visible before Emperor submits', () => {
void test('an opponent-only sealed plan keeps the Low Salusa free revival prohibition visible before Emperor submits', () => {
const fixture = gholaThresholdBattle();
const g = applyAction(fixture.g, 'a', {
type: 'battlePlan', dial: 0, support: 0, leader: atreides(fixture.g).leaders[0].id,
@@ -465,15 +473,15 @@ void test('an opponent-only sealed plan keeps the remaining Salusa timing bounda
const view = viewGame(g, 'e');
assert.deepEqual(view.battle!.plans, {});
assert.equal(view.ghola.eliteRemaining, 0);
assert.match(view.ghola.eliteBlock!, /not yet supported/);
assert.match(view.ghola.eliteBlock!, /prohibits free Sardaukar revival/);
const before = structuredClone(g);
assert.throws(() => applyAction(g, 'e', {
type: 'card', card: fixture.card, amount: 1, elite: 1,
}), /not yet supported/);
}), /prohibits free Sardaukar revival/);
assert.deepEqual(g, before);
});
void test('Salusa Ghola renews an impossible answered Prescience dial before either full plan is sealed', () => {
void test('a rejected Low Salusa Ghola leaves an answered Prescience dial binding', () => {
const fixture = gholaThresholdBattle(0);
const card = fixture.card;
let g = fixture.g;
@@ -491,19 +499,16 @@ void test('Salusa Ghola renews an impossible answered Prescience dial before eit
g = applyAction(g, 'e', { type: 'prescienceAnswer', value: 1 });
assert.equal(g.battle!.prescience!.value, 1);
assert.deepEqual(g.battle!.plans, {});
assert.equal(viewGame(g, 'e').ghola.eliteBlock, null);
g = applyAction(g, 'e', { type: 'card', card, amount: 1, elite: 1 });
assert.match(viewGame(g, 'e').ghola.eliteBlock!, /prohibits free Sardaukar revival/);
const snapshot = structuredClone(g);
assert.throws(() => applyAction(g, 'e', { type: 'card', card, amount: 1, elite: 1 }), /prohibits free Sardaukar revival/);
assert.deepEqual(g, snapshot);
assert.equal(g.battle!.prescience!.field, 'dial');
assert.equal(Object.hasOwn(g.battle!.prescience!, 'value'), false);
assert.equal(g.battle!.preparation!.kind, 'prescienceAnswer');
assert.equal(g.battle!.preparation!.owner, 'e');
assert.throws(() => applyAction(g, 'e', plan(g, 2)), /battle plan is not available/);
assert.throws(() => applyAction(g, 'e', { type: 'prescienceAnswer', value: 1 }), /legal battle plan/);
g = applyAction(g, 'e', { type: 'prescienceAnswer', value: 2 });
const done = resolve(g, 2, 0);
assert.equal(g.battle!.prescience!.value, 1);
assert.throws(() => applyAction(g, 'e', plan(g, 2)), /battle inspection/);
const done = resolve(g, 1);
assert.equal(done.lastBattleContext!.winner, 'e');
assert.equal(done.homeworlds!.custody!.salusa!.elite, 2);
assert.equal(emperor(done).elites!.tanks, 1);
assert.equal(done.discard.filter((held) => held.id === card).length, 1);
assert.equal(done.homeworlds!.custody!.salusa!.elite, 1);
assert.equal(done.discard.filter((held) => held.id === card).length, 0);
inventory(done);
});
+122
View File
@@ -0,0 +1,122 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { registerHooks } from 'node:module';
import { applyAction, viewGame } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { createAmbassadors, validateAmbassadors } from '../game/ecaz-ambassadors';
import { homeworldRevivalFixture, revivalInventory, revivalPlayer } from './fixture-homeworld-revival';
/** Real Homeworld setup, with a controlled and conserved phase-5 position,
* token placement, alliance, and Emperor casualties. The arrival and Ambassador
* entry/acceptance themselves run through the normal engine actions. */
function source(stars: 1 | 2, ordinaryTanks: 0 | 2) {
let g = homeworldRevivalFixture({ advanced: true, extraSeats: [
{ id: 'ec', faction: 'ecaz' }, { id: 'a', faction: 'atreides' },
] });
g.expansions.push('ecaz');
const emperor = revivalPlayer(g, 'e');
const deadStars = 5 - stars;
emperor.reserves -= deadStars + ordinaryTanks;
emperor.tanks += deadStars + ordinaryTanks;
emperor.elites!.reserves -= deadStars;
emperor.elites!.tanks += deadStars;
g.homeworlds!.custody!.salusa!.elite = stars;
revivalPlayer(g, 'f').forces = { 'sietch_tabr:14': 12 };
revivalPlayer(g, 'ec').ally = 'e';
emperor.ally = 'ec';
const ambassadors = createAmbassadors(() => 0);
const token = ambassadors.tokens.find(candidate => candidate.effect === 'tleilaxu')!;
const otherIds = ambassadors.tokens.filter(candidate => candidate.effect !== 'ecaz' && candidate.id !== token.id)
.slice(0, 4).map(candidate => candidate.id);
ambassadors.cohort = [token.id, ...otherIds];
for (const candidate of ambassadors.tokens) {
candidate.zone = candidate.effect === 'ecaz' || ambassadors.cohort.includes(candidate.id) ? 'supply' : 'pool';
candidate.location = null;
}
token.zone = 'placed';
token.location = 'arrakeen';
validateAmbassadors(ambassadors);
g.ecazAmbassadors = ambassadors;
Object.assign(g, { phase: 5, active: 'a', ready: [], phaseOpening: null, response: null, decision: null });
g.movementRemaining = [...g.order];
revivalInventory(g);
g = applyAction(g, 'a', { type: 'ship', amount: 1, territory: 'arrakeen', sector: 10 });
assert.equal(g.pendingAmbassador?.stage, 'offer');
g = applyAction(g, 'ec', { type: 'decision', event: g.pendingAmbassador!.event,
trigger: true, beneficiary: 'e', choice: 'effect' });
assert.equal(g.pendingAmbassador?.stage, 'revival');
assert.equal(g.decision?.player, 'e');
return g;
}
void test('Low Salusa blocks a free Ambassador star and returns normal forces to Kaitain', () => {
const g = source(1, 2);
const offer = viewGame(g, 'e').ambassadorEntry!.revival!;
assert.equal(offer.maximum, 2);
assert.equal(offer.eliteMaximum, 0);
assert.match(offer.eliteBlock!, /Salusa/i);
assert.equal(offer.blocked, null);
const original = structuredClone(g);
assert.throws(() => applyAction(g, 'e', { type: 'decision', event: g.pendingAmbassador!.event,
forces: 1, elite: 1 }), /Salusa/i);
assert.deepEqual(g, original);
const next = applyAction(g, 'e', { type: 'decision', event: g.pendingAmbassador!.event,
forces: 2, elite: 0 });
const before = revivalPlayer(g, 'e'), after = revivalPlayer(next, 'e');
assert.equal(after.reserves, before.reserves + 2);
assert.equal(after.tanks, before.tanks - 2);
assert.equal(after.elites!.tanks, before.elites!.tanks);
assert.equal(next.homeworlds!.custody!.salusa!.elite, 1);
assert.equal(after.spice, before.spice);
revivalInventory(next);
});
void test('High Salusa permits one free Ambassador star and physically returns it to Salusa', () => {
const g = source(2, 2);
const offer = viewGame(g, 'e').ambassadorEntry!.revival!;
assert.equal(offer.maximum, 3);
assert.equal(offer.eliteMaximum, 1);
assert.equal(offer.eliteBlock, null);
const next = applyAction(g, 'e', { type: 'decision', event: g.pendingAmbassador!.event,
forces: 2, elite: 1 });
const before = revivalPlayer(g, 'e'), after = revivalPlayer(next, 'e');
assert.equal(after.reserves, before.reserves + 2);
assert.equal(after.tanks, before.tanks - 2);
assert.equal(after.elites!.tanks, before.elites!.tanks - 1);
assert.equal(after.elites!.revived, before.elites!.revived + 1);
assert.equal(next.homeworlds!.custody!.salusa!.elite, 3);
assert.equal(after.spice, before.spice);
revivalInventory(next);
});
void test('Low with only stars shows no return button and all bot profiles choose a legal alternative', async () => {
const g = source(1, 0);
const view = viewGame(g, 'e');
assert.equal(view.ambassadorEntry!.revival!.maximum, 0);
assert.equal(view.ambassadorEntry!.revival!.eliteMaximum, 0);
const hooks = registerHooks({ resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier, context);
} });
const { EcazEntry } = await import('../components/ecaz-entry');
hooks.deregister();
const markup = renderToStaticMarkup(createElement(EcazEntry, {
game: view as unknown as Parameters<typeof EcazEntry>[0]['game'],
act: () => {}, busy: false,
}));
assert.match(markup, /Salusa/);
assert.doesNotMatch(markup, /Return 1 force/);
assert.match(markup, /Return no forces/);
for (const difficulty of DIFFICULTIES) {
const choiceView = viewGame(g, 'e');
choiceView.players.find(player => player.id === 'e')!.bot = difficulty;
const actions = botActions(choiceView);
assert.ok(actions.length, difficulty);
for (const action of actions) {
assert.notEqual(action.elite, 1, difficulty);
assert.doesNotThrow(() => applyAction(g, 'e', action), `${difficulty}: ${JSON.stringify(action)}`);
}
}
});
@@ -0,0 +1,135 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { registerHooks } from 'node:module';
import { readFileSync } from 'node:fs';
import * as React from 'react';
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { applyAction, viewGame, type GameView } from '../game/engine';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import {
holdRevivalCard, homeworldRevivalFixture, revivalInventory, revivalPlayer,
} from './fixture-homeworld-revival';
import { reachAdvancedRevivalOrderActor } from './fixture-advanced-revival-order';
const aliases = registerHooks({
resolve(specifier, context, next) {
return next(specifier === 'next/image' ? 'vinext/shims/image' :
specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
},
load(url, context, next) {
if (!url.endsWith('.module.css')) return next(url, context);
const css = readFileSync(new URL(url), 'utf8');
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(match => [match[1], match[1]]));
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
},
});
const { GameTable } = await import('../components/game-table');
aliases.deregister();
/** Actual Advanced Homeworld setup, then real ordered Charity/Bidding/Revival.
* Controlled supplement: move 4 original Sardaukar and 2 normal reserves to
* Tanks, leaving one on Salusa; transfer real Ghola/Karama cards from the deck
* and stage one native leader dead. No casualty or card purchase is claimed. */
function source() {
let game = homeworldRevivalFixture({ advanced: true });
const p = revivalPlayer(game, 'e');
assert.equal(p.reserves, 20);
assert.equal(p.elites!.reserves, 5);
p.reserves -= 6; p.tanks += 6;
p.elites!.reserves -= 4; p.elites!.tanks += 4;
game.homeworlds!.custody!.salusa!.elite = 1;
const leader = p.leaders[0]; leader.dead = true; leader.deaths = 1;
const ghola = holdRevivalCard(game, 'e', 'ghola');
const karama = holdRevivalCard(game, 'e', 'karama');
revivalInventory(game);
game = reachAdvancedRevivalOrderActor(game, 'e');
const view = viewGame(game, 'e');
assert.ok(view.revival.sardaukarFreeBlock);
assert.equal(view.ghola.eliteRemaining, 0);
return { game, view, ghola, karama, leader };
}
const props = (game: GameView) => ({ game, busy: false, send: async () => {}, onExit() {} });
const markup = (view: GameView) => renderToStaticMarkup(createElement(GameTable, props(view)));
const words = (html: string) => html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ');
/** Render the real GameTable with the two existing numeric useState selections
* controlled for SSR. React's server dispatcher is restored immediately;
* this exercises rendered values, not a browser or hydration interaction. */
function selectedMarkup(view: GameView, amount: number, elite: number): string {
function SelectedTable() {
const internals = React as unknown as { __CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE: {
H: { useState: (initial: unknown) => [unknown, unknown] };
} };
const dispatcher = internals.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE.H;
const old = dispatcher.useState;
let index = 0;
dispatcher.useState = (initial: unknown) => {
const pair = old(initial);
index++;
if (index === 6) return [amount, pair[1]];
if (index === 10) return [elite, pair[1]];
return pair;
};
try { return GameTable(props(view)); }
finally { dispatcher.useState = old; }
}
return renderToStaticMarkup(createElement(SelectedTable));
}
void test('actual Low Salusa table shows special-Karama force bounds, reason and native leader alternative', () => {
const { game, view, karama, leader } = source();
const html = markup(view), start = html.indexOf('Once per game: revive up to three forces');
assert.ok(start >= 0);
const section = html.slice(start, html.indexOf('</article>', start));
assert.ok(section.includes(view.revival.sardaukarFreeBlock!));
assert.match(section, new RegExp(`id="special-revive-${karama}"[^>]*max="2"`));
assert.match(section, /Sardaukar to revive\s*<input\b[^>]*max="0"/);
assert.match(section, /Use special Karama · forces/);
assert.ok(words(section).includes(`Revive ${leader.name} with special Karama`));
const continued = applyAction(game, 'e', { type: 'card', mode: 'special', card: karama, leader: leader.id });
assert.equal(revivalPlayer(continued, 'e').leaders[0].dead, false);
});
void test('actual SSR selected ordinary revival inputs quote solo normal, solo star and mixed groups correctly', () => {
const { view } = source();
for (const [amount, elite, cost] of [[1, 0, 0], [1, 1, 2], [2, 1, 2]] as const) {
const html = selectedMarkup(view, amount, elite), text = words(html);
assert.match(html, new RegExp(`id="forces"[^>]*value="${amount}"`));
assert.match(html, new RegExp(`Elite forces\\s*<input\\b[^>]*value="${elite}"`));
assert.ok(text.includes(`Selected force revival costs ${cost} spice`),
`The actual ${amount}/${elite} selector must display ${cost} spice`);
}
});
void test('actual Ghola inspector explains Low star exclusion while retaining normal and leader targets', () => {
const { game, view, ghola, leader } = source();
const html = markup(view), start = html.indexOf('<h3>Tleilaxu Ghola</h3>');
assert.ok(start >= 0);
const section = html.slice(start, html.indexOf('</article>', start));
assert.ok(view.ghola.eliteBlock && section.includes(view.ghola.eliteBlock));
assert.match(section, new RegExp(`id="ghola-${ghola}"[^>]*max="2"`));
assert.match(section, /Elite forces to revive\s*<input\b[^>]*max="0"/);
assert.ok(section.includes(leader.name));
const before = structuredClone(game);
assert.throws(() => applyAction(game, 'e', { type: 'card', card: ghola, amount: 1, elite: 1 }));
assert.deepEqual(game, before);
const normal = applyAction(game, 'e', { type: 'card', card: ghola, amount: 1, elite: 0 });
assert.equal(revivalPlayer(normal, 'e').tanks, revivalPlayer(game, 'e').tanks - 1);
});
void test('four own-view bots keep legal Low Salusa force and card participation', () => {
const { game, karama, ghola } = source();
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, 'e');
view.players.find(player => player.id === 'e')!.bot = difficulty;
const actions = botActions(view);
assert.ok(actions.length, `${difficulty} needs a legal continuation`);
for (const action of actions) {
if (action.type === 'card' && (action.card === karama && action.mode === 'special' || action.card === ghola) && !action.leader)
assert.equal(action.elite ?? 0, 0, `${difficulty} cannot freely revive a star`);
assert.doesNotThrow(() => applyAction(game, 'e', action), `${difficulty}: ${JSON.stringify(action)}`);
}
}
});
+178
View File
@@ -0,0 +1,178 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { baseDeck } from '../game/cards';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import { forceRevivalQuote } from '../game/revival';
import {
holdRevivalCard, homeworldRevivalFixture,
revivalInventory, revivalPlayer,
} from './fixture-homeworld-revival';
import { reachAdvancedRevivalOrderActor } from './fixture-advanced-revival-order';
import {
createSalusaGholaFixture, salusaGholaArmy, salusaGholaInventory,
} from './fixture-salusa-ghola';
const emperor = (game: Game) => revivalPlayer(game, 'e');
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
/** Real Advanced Homeworld setup and the existing actual Charity/auction
* continuation. The fixture itself stages Charity and Fremen positions. This
* supplement explicitly moves conserved Imperial reserves into Tanks: retain
* 1 or 2 original stars on Salusa, put the other stars and 2 ordinary forces
* into Tanks. It does not claim natural casualties or a played-card purchase. */
function source(salusaStars: 1 | 2 = 1) {
let game = homeworldRevivalFixture({ advanced: true });
const p = emperor(game), deadStars = 5 - salusaStars;
assert.equal(p.reserves, 20);
assert.equal(p.elites!.reserves, 5);
assert.equal(game.homeworlds!.custody!.salusa!.elite, 5);
p.reserves -= deadStars + 2;
p.tanks += deadStars + 2;
p.elites!.reserves -= deadStars;
p.elites!.tanks += deadStars;
game.homeworlds!.custody!.salusa!.elite = salusaStars;
const ghola = holdRevivalCard(game, 'e', 'ghola');
const karama = holdRevivalCard(game, 'e', 'karama');
revivalInventory(game);
game = reachAdvancedRevivalOrderActor(game, 'e');
assert.equal(game.phase, 4);
assert.ok(!game.revivalOrder || game.revivalOrder.remaining[0] === 'e');
revivalInventory(game);
return { game, ghola, karama };
}
/** This is a separately controlled legacy-shaped Advanced battleless Revival
* with no Homeworld module; the staged physical Tanks match the Low source. */
function withoutWorlds() {
const player = newPlayer('e', 'Emperor', 'emperor');
const game = createGame('SALUSANOWORLD', player, true);
game.players.push(newPlayer('f', 'Fremen', 'fremen'));
Object.assign(game, { status: 'playing', phase: 4, turn: 2, active: null,
order: ['e', 'f'], ready: [], storm: 18, deck: baseDeck() });
Object.assign(player, { reserves: 14, tanks: 6, spice: 20,
elites: { reserves: 1, tanks: 4, forces: {}, revived: 0 } });
return game;
}
void test('low Salusa makes a normal Sardaukar return paid, leaving the free allowance for an ordinary force', () => {
const { game: initial } = source();
const original = structuredClone(emperor(initial));
const view = viewGame(initial, 'e');
assert.equal(typeof view.revival.sardaukarFreeBlock, 'string');
assert.equal(view.revival.freeRemaining, 1);
const quote = forceRevivalQuote(initial, emperor(initial), 1, 1);
assert.equal(quote.free, 0);
assert.equal(quote.cost, 2);
const poor = structuredClone(initial);
emperor(poor).spice = 1; // Controlled insufficient-funds boundary.
reject(poor, 'e', { type: 'revive', amount: 1, elite: 1 });
let game = applyAction(reload(initial), 'e', { type: 'revive', amount: 1, elite: 1 });
assert.equal(emperor(game).spice, original.spice - 2);
assert.equal(emperor(game).freeForcesRevived, 0);
assert.equal(emperor(game).revived, 1);
assert.equal(emperor(game).elites!.revived, 1);
assert.equal(game.homeworlds!.custody!.salusa!.elite, 2);
assert.equal(viewGame(game, 'e').revival.sardaukarFreeBlock, null);
assert.equal(viewGame(game, 'e').revival.freeRemaining, 1);
game = applyAction(reload(game), 'e', { type: 'revive', amount: 1, elite: 0 });
assert.equal(emperor(game).spice, original.spice - 2);
assert.equal(emperor(game).freeForcesRevived, 1);
assert.equal(emperor(game).revived, 2);
revivalInventory(game);
});
void test('a mixed Low group spends free allowance on normal forces and pays for its Sardaukar', () => {
const { game: initial } = source();
const quote = forceRevivalQuote(initial, emperor(initial), 2, 1);
assert.equal(quote.free, 1);
assert.equal(quote.cost, 2);
const before = structuredClone(emperor(initial));
const game = applyAction(reload(initial), 'e', { type: 'revive', amount: 2, elite: 1 });
assert.equal(emperor(game).spice, before.spice - 2);
assert.equal(emperor(game).tanks, before.tanks - 2);
assert.equal(emperor(game).elites!.tanks, before.elites!.tanks - 1);
assert.equal(emperor(game).freeForcesRevived, 1);
assert.equal(emperor(game).revived, 2);
assert.equal(game.homeworlds!.custody!.salusa!.elite, 2);
revivalInventory(game);
});
void test('actual native Low Salusa battle cannot revive its earned dead Sardaukar with Ghola', () => {
const f = createSalusaGholaFixture();
assert.deepEqual(salusaGholaArmy(f.game), { normal: 0, elite: 1 });
assert.equal(f.game.players.find(player => player.id === f.actor)!.elites!.tanks, 1);
const view = viewGame(f.game, f.actor);
assert.equal(view.ghola.eliteRemaining, 0);
assert.equal(typeof view.ghola.eliteBlock, 'string');
reject(f.game, f.actor, f.action);
assert.ok(f.game.players.find(player => player.id === f.actor)!.hand.some(card => card.id === f.card));
assert.deepEqual(salusaGholaInventory(f.game), f.original);
});
void test('Low Ghola and Emperor special reject free stars while retaining normal-force alternatives', () => {
const { game: initial, ghola, karama } = source();
reject(initial, 'e', { type: 'card', card: ghola, amount: 1, elite: 1 });
reject(initial, 'e', { type: 'card', mode: 'special', card: karama, amount: 1, elite: 1 });
assert.notEqual(emperor(initial).specialKaramaUsed, true);
assert.equal(initial.discard.some(card => card.id === karama || card.id === ghola), false);
const gholaNormal = applyAction(reload(initial), 'e', { type: 'card', card: ghola, amount: 1, elite: 0 });
assert.equal(emperor(gholaNormal).tanks, emperor(initial).tanks - 1);
assert.equal(emperor(gholaNormal).elites!.tanks, emperor(initial).elites!.tanks);
assert.equal(gholaNormal.discard.filter(card => card.id === ghola).length, 1);
const specialNormal = applyAction(reload(initial), 'e', { type: 'card', mode: 'special', card: karama, amount: 1, elite: 0 });
assert.equal(emperor(specialNormal).tanks, emperor(initial).tanks - 1);
assert.equal(emperor(specialNormal).elites!.tanks, emperor(initial).elites!.tanks);
assert.equal(emperor(specialNormal).specialKaramaUsed, true);
assert.equal(specialNormal.discard.filter(card => card.id === karama).length, 1);
revivalInventory(gholaNormal);
revivalInventory(specialNormal);
});
void test('Salusa at the high threshold permits free Ghola and three-star special revival again', () => {
const { game: initial, ghola, karama } = source(2);
assert.equal(viewGame(initial, 'e').revival.sardaukarFreeBlock, null);
assert.equal(forceRevivalQuote(initial, emperor(initial), 1, 1).cost, 0);
const gholaGame = applyAction(reload(initial), 'e', { type: 'card', card: ghola, amount: 1, elite: 1 });
assert.equal(gholaGame.homeworlds!.custody!.salusa!.elite, 3);
const specialGame = applyAction(reload(initial), 'e', { type: 'card', mode: 'special', card: karama, amount: 3, elite: 3 });
assert.equal(specialGame.homeworlds!.custody!.salusa!.elite, 5);
assert.equal(emperor(specialGame).elites!.revived, 0);
assert.equal(emperor(specialGame).specialKaramaUsed, true);
revivalInventory(gholaGame);
revivalInventory(specialGame);
});
void test('legacy Advanced games without Homeworlds retain a free normal Sardaukar return', () => {
const initial = withoutWorlds();
assert.equal(viewGame(initial, 'e').revival.sardaukarFreeBlock, null);
assert.equal(forceRevivalQuote(initial, emperor(initial), 1, 1).free, 1);
const game = applyAction(initial, 'e', { type: 'revive', amount: 1, elite: 1 });
assert.equal(emperor(game).spice, 20);
assert.equal(emperor(game).freeForcesRevived, 1);
assert.equal(emperor(game).elites!.reserves, 2);
assert.equal(game.homeworlds, undefined);
});
void test('all minimal bot profiles use only legal Low revival and Ghola proposals', () => {
const { game } = source();
for (const difficulty of DIFFICULTIES) {
const view = viewGame(game, 'e');
view.players.find(player => player.id === 'e')!.bot = difficulty;
const actions = botActions(view);
assert.ok(actions.length, `${difficulty} must have a legal continuation`);
for (const action of actions) {
if (action.type === 'card' && (action.mode === 'special' ||
view.ghola.cards.includes(String(action.card))))
assert.equal(action.elite ?? 0, 0, `${difficulty} cannot propose a free star`);
assert.doesNotThrow(() => applyAction(game, 'e', action), `${difficulty}: ${JSON.stringify(action)}`);
}
}
});
+35 -46
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import {
createSalusaGholaFixture, playSalusaGholaBattle, actSalusaGhola as act,
createSalusaGholaFixture, actSalusaGhola as act,
advanceSalusaGhola as advance, salusaGholaClean as clean,
salusaGholaArmy as army, salusaGholaPlayer as p, salusaGholaInventory as inventory,
type SalusaGholaFixture,
@@ -28,8 +28,8 @@ function leader(game: Game, actor: string, strongest: boolean) {
choices.sort((a,b) => strongest ? b.strength-a.strength : a.strength-b.strength);
assert.ok(choices[0]); return choices[0].id;
}
function finish(f: SalusaGholaFixture, state: Game) {
let game = act(state,f.actor,{type:'battlePlan',leader:leader(state,f.actor,true),dial:2,support:0});
function finish(f: SalusaGholaFixture, state: Game, dial: 1|2) {
let game = act(state,f.actor,{type:'battlePlan',leader:leader(state,f.actor,true),dial,support:0});
game = act(game,f.opponent,{type:'battlePlan',leader:leader(game,f.opponent,false),dial:0,support:0});
return advance(game,g => !g.battle && clean(g));
}
@@ -61,65 +61,54 @@ void test('earned Basin-first source answers real Atreides dial Prescience befor
assert.deepEqual(inventory(f.game),f.original);
});
void test('actual Ghola invalidates an impossible old odd dial and requires owned legal reanswer before native plans', () => {
void test('Low Salusa rejection preserves the original native odd-dial Prescience answer', () => {
const f = source(1);
let game = act(structuredClone(f.game),f.actor,f.action);
assert.deepEqual(army(game),{normal:0,elite:2});
let game = structuredClone(f.game);
reject(game,f.actor,f.action);
assert.deepEqual(army(game),{normal:0,elite:1});
assert.equal(game.battle!.event,f.game.battle!.event);
assert.equal(game.battle!.prescience!.field,'dial');
assert.equal(game.battle!.prescience!.player,'atreides');
assert.equal(Object.hasOwn(game.battle!.prescience!,'value'),false);
assert.equal(game.battle!.preparation?.kind,'prescienceAnswer');
assert.equal(game.battle!.preparation?.owner,f.actor);
assert.equal(game.battle!.preparation?.beneficiary,'atreides');
assert.deepEqual(game.battle!.plans,{});
assert.equal(viewGame(game,f.actor).battle!.ownForces!.eliteFreeSupport,true);
assert.equal(game.discard.filter(c => c.id === f.card).length,1);
assert.equal(p(game).elites!.tanks,0);
reject(game,f.actor,{type:'battlePlan',leader:leader(game,f.actor,true),dial:1,support:0});
reject(game,f.actor,{type:'prescienceAnswer',value:1});
reject(game,f.opponent,{type:'prescienceAnswer',value:2});
game = act(game,f.actor,{type:'prescienceAnswer',value:2});
assert.equal(game.battle!.prescience!.value,2);
assert.equal(game.battle!.prescience!.value,1);
assert.equal(game.battle!.preparation,undefined);
assert.equal(p(game).elites!.tanks,0);
game = finish(f,game);
assert.equal(game.lastBattleContext!.winner,f.actor);
assert.deepEqual(game.battle!.plans,{});
assert.equal(viewGame(game,f.actor).battle!.ownForces!.eliteFreeSupport,undefined);
assert.equal(game.discard.filter(c => c.id === f.card).length,0);
assert.equal(p(game).elites!.tanks,1);
assert.deepEqual(army(game),{normal:0,elite:2});
assert.equal(game.discard.filter(c => c.id === f.card).length,1);
reject(game,f.actor,{type:'battlePlan',leader:leader(game,f.actor,true),dial:2,support:0});
game = finish(f,game,1);
assert.equal(game.lastBattleContext!.winner,f.actor);
assert.equal(p(game).elites!.tanks,2);
assert.deepEqual(army(game),{normal:0,elite:1});
assert.equal(game.discard.filter(c => c.id === f.card).length,0);
assert.deepEqual(inventory(game),f.original);
});
void test('actual Ghola retains a still-feasible native dial2 answer and finishes without another inspection', () => {
const f = source(2), result = playSalusaGholaBattle(f);
void test('Low Salusa rejection retains a native dial2 answer and finishes without another inspection', () => {
const f = source(2), before = structuredClone(f.game);
assert.equal(f.game.battle!.prescience!.value,2);
assert.equal(result.returned.battle!.prescience!.value,2);
assert.equal(result.returned.battle!.preparation,undefined);
assert.equal(result.returned.battle!.prescience!.player,'atreides');
assert.equal(result.afterPlan.battle!.plans[f.actor].dial,2);
assert.equal(result.afterPlan.battle!.plans[f.actor].support,0);
assert.equal(result.actions.filter(step => step.action.type === 'prescience' ||
step.action.type === 'prescienceAnswer').length,0);
assert.equal(result.game.lastBattleContext!.winner,f.actor);
assert.equal(result.game.discard.filter(c => c.id === f.card).length,1);
assert.deepEqual(inventory(result.game),f.original);
reject(before,f.actor,f.action);
assert.equal(before.battle!.prescience!.value,2);
assert.equal(before.battle!.preparation,undefined);
const done = finish(f,before,2);
assert.equal(done.lastBattleContext!.winner,f.actor);
assert.equal(done.discard.filter(c => c.id === f.card).length,0);
assert.deepEqual(inventory(done),f.original);
});
void test('all four minimal profiles can supply a legal current Salusa Prescience reanswer', () => {
const f = source(1), pending = act(structuredClone(f.game),f.actor,f.action);
void test('all four minimal profiles honor the existing Salusa Prescience answer after rejection', () => {
const f = source(1), pending = structuredClone(f.game);
reject(pending,f.actor,f.action);
for (const profile of ['Easy','Medium','Hard','Brutal'] as const) {
const game = structuredClone(pending), view = viewGame(game,f.actor);
view.players.find(p => p.id === f.actor)!.bot = profile;
const action = botActions(view).find(action => action.type === 'prescienceAnswer');
assert.ok(action,`${profile} needs the actual owned reanswer action.`);
assert.notEqual(action.value,1);
const action = botActions(view).find(action => action.type === 'battlePlan' && action.dial === 1);
assert.ok(action,`${profile} needs a legal plan honoring the actual answered dial.`);
const next = act(game,f.actor,action);
assert.equal(next.battle!.prescience!.value,action.value);
assert.equal(next.battle!.preparation,undefined);
assert.deepEqual(next.battle!.plans,{});
assert.equal(next.discard.filter(c => c.id === f.card).length,1);
assert.deepEqual(army(next),{normal:0,elite:2});
assert.equal(next.battle!.prescience!.value,1);
assert.equal(next.battle!.plans[f.actor].dial,1);
assert.equal(next.discard.filter(c => c.id === f.card).length,0);
assert.deepEqual(army(next),{normal:0,elite:1});
assert.deepEqual(inventory(next),f.original);
}
});
+35 -40
View File
@@ -3,7 +3,8 @@ import assert from 'node:assert/strict';
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import {
createSalusaGholaFixture, playSalusaGholaBattle, actSalusaGhola as act,
createSalusaGholaFixture, actSalusaGhola as act,
advanceSalusaGhola as advance, salusaGholaClean as clean,
salusaGholaPlayer as p, salusaGholaArmy as army,
salusaGholaInventory as inventory, type SalusaGholaFixture,
} from './fixture-salusa-ghola';
@@ -56,58 +57,52 @@ void test('earned public Salusa source has one actual Sardaukar casualty and a l
assert.match(f.disclosure,/Original public.*No later phase, force, purse, Tank, hand, battle commitment or receipt is installed/);
});
void test('actual uncommitted-battle Ghola returns the dead Sardaukar to Salusa2 and immediately changes the native support view', () => {
void test('earned Low Salusa rejects a free Sardaukar Ghola without changing the physical battle or card', () => {
const f = fixture(), before = structuredClone(f.game);
reject(before,f.opponent,f.action);
const game = act(before,f.actor,f.action), player = p(game);
assert.deepEqual(army(game),{normal:0,elite:2});
assert.equal(player.tanks,p(before).tanks-1);
assert.equal(player.elites!.tanks,p(before).elites!.tanks-1);
assert.equal(player.reserves,p(before).reserves+1);
assert.equal(player.elites!.reserves,p(before).elites!.reserves+1);
assert.equal(player.spice,p(before).spice);
assert.deepEqual(player.forces,p(before).forces);
assert.deepEqual(player.elites!.forces,p(before).elites!.forces);
assert.deepEqual(game.battle!.plans,{});
assert.equal(game.battle!.event,before.battle!.event);
assert.equal(viewGame(game,f.actor).battle!.ownForces!.eliteFreeSupport,true);
assert.equal(viewGame(game,f.actor).battle!.ownForces!.elite,2);
assert.equal(game.discard.filter(c => c.id === f.card).length,1);
assert.equal(player.hand.some(c => c.id === f.card),false);
reject(before,f.actor,f.action);
const view = viewGame(before,f.actor);
assert.match(view.ghola.eliteBlock!,/Salusa.*free Sardaukar/i);
assert.equal(view.ghola.eliteRemaining,0);
assert.deepEqual(army(before),{normal:0,elite:1});
assert.equal(p(before).elites!.tanks,1);
assert.deepEqual(before.battle!.plans,{});
assert.equal(view.battle!.ownForces!.eliteFreeSupport,undefined);
assert.ok(p(before).hand.some(c => c.id === f.card));
assert.equal(before.discard.filter(c => c.id === f.card).length,0);
assert.deepEqual(inventory(before),f.original);
});
void test('native battle continues legally after the Low Salusa Ghola rejection', () => {
const f = fixture(); let game = structuredClone(f.game);
reject(game,f.actor,f.action);
const own = p(game,f.actor).leaders.filter(l => !l.dead && !l.usedAt && !l.capturedBy).sort((a,b) => b.strength-a.strength)[0];
const enemy = p(game,f.opponent).leaders.filter(l => !l.dead && !l.usedAt && !l.capturedBy).sort((a,b) => a.strength-b.strength)[0];
assert.ok(own && enemy);
game = act(game,f.actor,{type:'battlePlan',leader:own.id,dial:2,support:0});
assert.equal(game.battle!.plans[f.actor].dial,2);
game = act(game,f.opponent,{type:'battlePlan',leader:enemy.id,dial:0,support:0});
game = advance(game,g => !g.battle && clean(g));
assert.equal(game.lastBattleContext!.winner,f.actor);
assert.equal(game.lastBattleContext!.territory,f.territory);
assert.deepEqual(army(game),{normal:0,elite:1});
assert.ok(p(game).hand.some(c => c.id === f.card));
assert.equal(game.discard.filter(c => c.id === f.card).length,0);
assert.deepEqual(inventory(game),f.original);
});
void test('native plans and calls after Salusa Ghola use zero-spice support and settle actual star casualties', () => {
const f = fixture(), result = playSalusaGholaBattle(f);
assert.deepEqual(army(result.returned),{normal:0,elite:2});
assert.equal(result.afterPlan.battle!.plans[f.actor].support,0);
assert.equal(result.afterPlan.battle!.plans[f.actor].dial,2);
assert.equal(p(result.afterPlan).spice,p(result.returned).spice);
assert.equal(result.game.lastBattleContext!.winner,f.actor);
assert.equal(result.game.lastBattleContext!.territory,f.territory);
assert.equal(result.game.battle,null);
assert.equal(p(result.game).elites!.tanks,1);
assert.deepEqual(army(result.game),{normal:0,elite:2});
assert.equal(result.actions.filter(s => s.action.type === 'battlePlan').length,2);
assert.equal(result.actions.filter(s => s.action.type === 'traitorCall').length,2);
assert.equal(result.game.discard.filter(c => c.id === f.card).length,1);
assert.deepEqual(inventory(result.game),f.original);
});
void test('all four minimal policies propose legal native plans after the actual Salusa Ghola return', () => {
const f = fixture(), returned = act(structuredClone(f.game),f.actor,f.action);
void test('all four minimal policies propose legal native plans without a blocked Low Salusa Ghola return', () => {
const f = fixture();
for (const profile of ['Easy','Medium','Hard','Brutal'] as const) {
const game = structuredClone(returned), view = viewGame(game,f.actor);
const game = structuredClone(f.game), view = viewGame(game,f.actor);
view.players.find(p => p.id === f.actor)!.bot = profile;
const plans = botActions(view).filter(action => action.type === 'battlePlan');
assert.ok(plans.length,`${profile} needs a legal current Battle Plan.`);
for (const plan of plans) {
assert.equal(plan.support ?? 0,0);
const next = act(game,f.actor,plan);
assert.ok(next.battle!.plans[f.actor]);
assert.deepEqual(army(next),{normal:0,elite:2});
assert.equal(next.discard.filter(c => c.id === f.card).length,1);
assert.deepEqual(army(next),{normal:0,elite:1});
assert.ok(p(next).hand.some(c => c.id === f.card));
assert.deepEqual(inventory(next),f.original);
}
}
+15 -17
View File
@@ -30,28 +30,27 @@ function ownPromise(game: Game, actor: string) {
// These are shared control-adapter and minimal-policy checks over the earned
// native source. No Ghola amount selector or Battle Plan form is operated here;
// GameTable SSR is covered by the integration owner's separate runtime smoke.
void test('the exact owned Ghola group remains available when Salusa High preserves the native Yes dial2 promise', () => {
void test('the exact owned Ghola group is blocked at Low Salusa while the native Yes dial2 remains', () => {
const f = source('dial2'), game = structuredClone(f.game), view = viewGame(game, f.actor);
assert.deepEqual(f.action, { type: 'card', card: f.card, amount: 1, elite: 1 });
assert.deepEqual(ownPromise(game, f.actor).claim, f.claim);
assert.equal(homeworldRevivalActionBlock(view, f.action), null);
assert.equal(view.ghola.available, true); assert.ok(view.ghola.cards.includes(f.card));
assert.equal(view.ghola.maxForces, 1); assert.equal(view.ghola.eliteRemaining, 1);
const returned = applyAction(game, f.actor, f.action);
assert.equal(returned.battle?.event, game.battle?.event);
assert.deepEqual(ownPromise(returned, f.actor).claim, f.claim);
assert.equal(ownPromise(returned, f.actor).answer, true);
assert.equal(viewGame(returned, f.actor).battle?.ownForces?.eliteFreeSupport, true);
assert.equal(returned.discard.filter(card => card.id === f.card).length, 1);
assert.equal(returned.players.find(player => player.id === f.actor)!.elites!.tanks, 0);
assert.match(homeworldRevivalActionBlock(view, f.action)!, /Salusa.*free Sardaukar/i);
assert.equal(view.ghola.available, false); assert.ok(view.ghola.cards.includes(f.card));
assert.equal(view.ghola.maxForces, 0); assert.equal(view.ghola.eliteRemaining, 0);
const before = structuredClone(game);
assert.throws(() => applyAction(game, f.actor, f.action), /Salusa.*free Sardaukar/i);
assert.deepEqual(game, before);
assert.equal(ownPromise(game, f.actor).answer, true);
assert.equal(game.discard.filter(card => card.id === f.card).length, 0);
assert.equal(game.players.find(player => player.id === f.actor)!.elites!.tanks, 1);
});
void test('the selected Ghola group block agrees with native rejection for an odd-dial or paid-support Yes promise', () => {
void test('the selected Ghola group block agrees with Low Salusa rejection for other Yes promises', () => {
for (const kind of ['dial1', 'support'] as const) {
const f = source(kind), game = structuredClone(f.game), view = viewGame(game, f.actor);
const blocked = homeworldRevivalActionBlock(view, f.action);
assert.ok(blocked, `${kind}: the actual selected return needs a visible block.`);
assert.match(blocked, /Truthtrance|promise/i);
assert.match(blocked, /Salusa.*free Sardaukar/i);
assert.ok(view.homeworldRevivalBlocks?.some(row => row.source === 'ghola' &&
row.amount === f.action.amount && row.elite === f.action.elite && row.reason === blocked));
assert.equal(view.ghola.maxForces, 0); assert.equal(view.ghola.eliteRemaining, 0);
@@ -67,23 +66,22 @@ void test('the selected Ghola group block agrees with native rejection for an od
}
});
void test('all four minimal profiles omit blocked Ghola and retain legal required plans before and after an allowed return', () => {
void test('all four minimal profiles omit blocked Ghola and retain legal required plans', () => {
for (const kind of ['dial1', 'support', 'dial2'] as const) {
const f = source(kind);
const start = kind === 'dial2' ? applyAction(f.game, f.actor, f.action) : f.game;
const start = f.game;
for (const profile of profiles) {
const game = structuredClone(start), view = viewGame(game, f.actor);
view.players.find(player => player.id === f.actor)!.bot = profile;
const actions = botActions(view);
assert.ok(actions.length, `${kind}/${profile}: current legal participation remains available.`);
assert.ok(actions.every(action => action.type !== 'card' || action.card !== f.card),
`${kind}/${profile}: no blocked or already spent Ghola is offered.`);
`${kind}/${profile}: the blocked Ghola is not offered.`);
const plans = actions.filter(action => action.type === 'battlePlan');
assert.ok(plans.length, `${kind}/${profile}: the live promise has a legal current plan.`);
for (const plan of plans) {
if (kind === 'support') assert.ok(Number(plan.support) >= 1);
else assert.equal(plan.dial, kind === 'dial1' ? 1 : 2);
if (kind === 'dial2') assert.equal(plan.support ?? 0, 0);
const sealed = applyAction(game, f.actor, plan);
assert.ok(sealed.battle?.plans[f.actor]);
assert.equal(ownPromise(sealed, f.actor).answer, true);
+31 -31
View File
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { applyAction, type Action, type Game } from '../game/engine';
import {
createSalusaGholaTruthFixture as fixture, actSalusaGholaTruth as act,
createSalusaGholaTruthFixture as fixture,
salusaGholaTruthPlayer as p, salusaGholaTruthInventory as inventory,
salusaGholaTruthPlan as plan, finishSalusaGholaTruthBattle as finish,
type SalusaGholaTruthFixture,
@@ -32,7 +32,7 @@ function done(game: Game, f: SalusaGholaTruthFixture, gholaUsed: boolean) {
assert.deepEqual(inventory(game),f.base.original);
}
void test('earned native Truth Yes dial2 permits Salusa Ghola and preserves its original binding through the actual plan', () => {
void test('earned native Truth Yes dial2 survives Low Salusa Ghola rejection and the actual plan', () => {
const f = fixture();
assert.equal(p(f.base.afterSetup,f.opponent).hand[0].id,f.truthCard);
assert.equal(f.base.beforeDeal.players.every(p => p.hand.length === 0),true);
@@ -42,29 +42,31 @@ void test('earned native Truth Yes dial2 permits Salusa Ghola and preserves its
assert.equal(f.sourceCount,f.sourceActions.length);
assert.deepEqual(f.game.battle!.plans,{});
binding(f.game,f);
const game = act(structuredClone(f.game),f.actor,f.action);
assert.deepEqual(army(game),{normal:0,elite:2});
assert.equal(p(game).elites!.tanks,0);
const game = structuredClone(f.game);
reject(game,f.actor,f.action);
assert.deepEqual(army(game),{normal:0,elite:1});
assert.equal(p(game).elites!.tanks,1);
binding(game,f);
const result = finish(f,game);
assert.equal(result.afterPlan.battle!.plans[f.actor].dial,2);
assert.equal(result.afterPlan.battle!.plans[f.actor].support,0);
assert.equal(result.revealed.battle!.truthPromises![0].released,undefined);
done(result.game,f,true);
done(result.game,f,false);
});
void test('earned native Truth No dial1 remains feasible after Salusa Ghola without releasing its aggregate answer', () => {
void test('earned native Truth No dial1 remains feasible after Low Salusa rejection', () => {
const f = fixture({claim:{kind:'dial',compare:'eq',value:1},answer:'no'});
const game = act(structuredClone(f.game),f.actor,f.action);
assert.deepEqual(army(game),{normal:0,elite:2});
const game = structuredClone(f.game);
reject(game,f.actor,f.action);
assert.deepEqual(army(game),{normal:0,elite:1});
binding(game,f);
const result = finish(f,game,plan(game,2,0));
assert.equal(result.revealed.battle!.truthPromises![0].answer,false);
assert.equal(result.revealed.battle!.truthPromises![0].released,undefined);
done(result.game,f,true);
done(result.game,f,false);
});
void test('earned native Truth Yes odd1 rejects Salusa Ghola before cost and can finish dial1 before later printed use', () => {
void test('earned native Truth Yes odd1 survives Low Salusa rejection and still prohibits free stars afterward', () => {
const f = fixture({claim:{kind:'dial',compare:'eq',value:1}});
binding(f.game,f);
reject(f.game,f.actor,f.action);
@@ -75,10 +77,10 @@ void test('earned native Truth Yes odd1 rejects Salusa Ghola before cost and can
const result = finish(f,structuredClone(f.game),plan(f.game,1,0));
assert.equal(result.afterPlan.battle!.plans[f.actor].dial,1);
done(result.game,f,false);
const returned = act(result.game,f.actor,f.action);
assert.deepEqual(army(returned),{normal:0,elite:2});
assert.equal(returned.discard.filter(c => c.id === f.card).length,1);
assert.deepEqual(inventory(returned),f.base.original);
reject(result.game,f.actor,f.action);
assert.deepEqual(army(result.game),{normal:0,elite:1});
assert.equal(result.game.discard.filter(c => c.id === f.card).length,0);
assert.deepEqual(inventory(result.game),f.base.original);
});
void test('earned native Truth Yes paid support rejects Salusa Ghola while the original supported plan stays legal', () => {
@@ -93,35 +95,33 @@ void test('earned native Truth Yes paid support rejects Salusa Ghola while the o
done(result.game,f,false);
});
void test('earned grouped Truth Yes odd1 or even2 keeps its live even alternative after Salusa Ghola', () => {
void test('earned grouped Truth Yes odd1 or even2 keeps its live alternative after Low Salusa rejection', () => {
const f = fixture({claim:{kind:'or',terms:[
{kind:'dial',compare:'eq',value:1},{kind:'dial',compare:'eq',value:2},
]}});
const game = act(structuredClone(f.game),f.actor,f.action);
const game = structuredClone(f.game);
reject(game,f.actor,f.action);
binding(game,f);
assert.deepEqual(army(game),{normal:0,elite:2});
assert.deepEqual(army(game),{normal:0,elite:1});
const result = finish(f,game,plan(game,2,0));
assert.deepEqual(result.revealed.battle!.truthPromises![0].claim,f.claim);
assert.equal(result.revealed.battle!.truthPromises![0].released,undefined);
done(result.game,f,true);
done(result.game,f,false);
});
void test('earned Prescience odd1 and native Truth Yes even2 can renew the inspection through actual Salusa Ghola', () => {
const f = fixture({inspectionDial:1});
void test('earned Prescience and compatible Truth retain their original dial2 after Low Salusa rejection', () => {
const f = fixture({inspectionDial:2});
assert.ok(f.base.inspection);
assert.equal(f.base.inspection.answered.battle!.prescience!.value,1);
assert.equal(f.game.battle!.prescience!.value,1);
assert.equal(f.base.inspection.answered.battle!.prescience!.value,2);
assert.equal(f.game.battle!.prescience!.value,2);
binding(f.game,f);
let game = act(structuredClone(f.game),f.actor,f.action);
assert.deepEqual(army(game),{normal:0,elite:2});
const game = structuredClone(f.game);
reject(game,f.actor,f.action);
assert.deepEqual(army(game),{normal:0,elite:1});
binding(game,f);
assert.equal(Object.hasOwn(game.battle!.prescience!,'value'),false);
assert.equal(game.battle!.preparation?.kind,'prescienceAnswer');
assert.equal(game.battle!.preparation?.owner,f.actor);
reject(game,f.actor,{type:'prescienceAnswer',value:1});
game = act(game,f.actor,{type:'prescienceAnswer',value:2});
assert.equal(game.battle!.prescience!.value,2);
assert.equal(game.battle!.preparation,undefined);
binding(game,f);
const result = finish(f,game,plan(game,2,0));
done(result.game,f,true);
done(result.game,f,false);
});
+135
View File
@@ -0,0 +1,135 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyAction, executeSpecialKaramaIntent, viewGame,
type Action, type Game, type SpecialKaramaIntent,
} from '../game/engine';
import { quoteRevivalResume } from '../game/revival-resume';
import {
holdRevivalCard, homeworldRevivalFixture, revivalInventory, revivalPlayer,
} from './fixture-homeworld-revival';
import { reachAdvancedRevivalOrderActor } from './fixture-advanced-revival-order';
const p = (game: Game) => revivalPlayer(game, 'e');
const reload = (game: Game): Game => JSON.parse(JSON.stringify(game));
function reject(game: Game, actor: string, action: Action) {
const before = structuredClone(game);
assert.throws(() => applyAction(game, actor, action));
assert.deepEqual(game, before);
}
/** Original Advanced Homeworld setup and native ordered phase progression.
* This controlled supplement stages four Imperial stars and two normal forces
* in Tanks, leaving Salusa at one, and transfers an actual Karama from the
* deck. Its later revivalStop is a real accepted action, not installed. */
function source() {
let game = homeworldRevivalFixture({ advanced: true, tleilaxu: true });
const owner = p(game);
assert.equal(owner.reserves, 20);
assert.equal(owner.elites!.reserves, 5);
owner.reserves -= 6; owner.tanks += 6;
owner.elites!.reserves -= 4; owner.elites!.tanks += 4;
game.homeworlds!.custody!.salusa!.elite = 1;
const card = holdRevivalCard(game, 'e', 'karama');
revivalInventory(game);
game = reachAdvancedRevivalOrderActor(game, 'e');
assert.equal(game.phase, 4);
assert.ok(!game.decision && !game.response && !game.phaseOpening);
assert.ok(viewGame(game, 'e').revival.sardaukarFreeBlock);
return { game, card };
}
function request(game: Game) {
const pending = applyAction(game, 'e', { type: 'revive', amount: 1, elite: 1 });
assert.equal(pending.decision?.kind, 'revivalStop');
assert.equal(pending.decision?.player, 't');
assert.ok(pending.pendingRevival);
return pending;
}
function allow(state: Game) {
let game = applyAction(reload(state), 't', { type: 'decision', decline: true });
for (let n = 0; game.response && n < 20; n++) {
const actor = game.players.find(player => !game.response!.passed.includes(player.id));
assert.ok(actor);
game = applyAction(game, actor.id, { type: 'passResponse' });
}
assert.equal(game.response ?? null, null);
assert.equal(game.pendingRevival ?? null, null);
revivalInventory(game);
return game;
}
void test('unstamped pre-policy pending free-star quote resumes its original accepted terms once', () => {
const { game } = source();
const accepted = request(game);
// Controlled historical receipt: represent an already accepted old-policy
// free star by changing only the frozen quote on the real pending stop.
// This is not presented as a naturally captured pre-upgrade room.
const legacy = reload(accepted), receipt = legacy.pendingRevival!;
delete receipt.salusaFreePolicy;
receipt.free = 1; receipt.cost = 0; receipt.normalCost = 0;
assert.equal(receipt.player, 'e');
assert.equal(receipt.kind, 'forces');
assert.equal(receipt.amount, 1);
assert.equal(receipt.elite, 1);
const before = structuredClone(legacy), quote = quoteRevivalResume(legacy, 'finish');
assert.equal(quote.outcome, 'revive');
assert.deepEqual(quote.pending, receipt);
assert.deepEqual(legacy, before);
const settled = allow(legacy);
assert.equal(p(settled).spice, p(legacy).spice);
assert.equal(p(settled).tanks, p(legacy).tanks - 1);
assert.equal(p(settled).elites!.tanks, p(legacy).elites!.tanks - 1);
assert.equal(p(settled).elites!.revived, 1);
assert.equal(p(settled).revived, 1);
assert.equal(p(settled).freeForcesRevived, 1);
assert.equal(settled.homeworlds!.custody!.salusa!.elite, 2);
reject(settled, 't', { type: 'decision', decline: true });
});
void test('new stamped Low pending quote settles the paid star, preserving the free allowance', () => {
const { game } = source();
const accepted = request(game), receipt = accepted.pendingRevival!;
assert.equal(receipt.salusaFreePolicy, 1);
assert.equal(receipt.free, 0);
assert.equal(receipt.normalCost, 2);
assert.equal(receipt.cost, 2);
const restored = reload(accepted), before = structuredClone(restored);
const quote = quoteRevivalResume(restored, 'finish');
assert.equal(quote.outcome, 'revive');
assert.deepEqual(quote.pending, receipt);
assert.deepEqual(restored, before);
const settled = allow(restored);
assert.equal(p(settled).spice, p(restored).spice - 2);
assert.equal(p(settled).tanks, p(restored).tanks - 1);
assert.equal(p(settled).elites!.tanks, p(restored).elites!.tanks - 1);
assert.equal(p(settled).elites!.revived, 1);
assert.equal(p(settled).revived, 1);
assert.equal(p(settled).freeForcesRevived, 0);
assert.equal(settled.homeworlds!.custody!.salusa!.elite, 2);
assert.equal(viewGame(settled, 'e').revival.freeRemaining, 1);
reject(settled, 't', { type: 'decision', decline: true });
});
void test('only an internal pre-policy special intent can finish its already accepted Low star selection', () => {
const { game, card } = source();
const action: Action = { type: 'card', mode: 'special', card, amount: 1, elite: 1 };
reject(game, 'e', action);
reject(game, 'e', { ...action, legacySalusaIntent: true });
reject(game, 'e', { ...action, salusaFreePolicy: undefined });
// The old normalized record simulates an already accepted internal Kull
// intent. It is not constructed from a player action and does not claim an
// actual Kull/Homeworld production path or captured persisted transaction.
const legacy: SpecialKaramaIntent = { kind: 'emperorForces', owner: 'e', card,
turn: game.turn, phase: game.phase, amount: 1, elite: 1 };
const resumed = reload(game), original = structuredClone(resumed);
executeSpecialKaramaIntent(resumed, legacy);
assert.equal(p(resumed).tanks, p(original).tanks - 1);
assert.equal(p(resumed).elites!.tanks, p(original).elites!.tanks - 1);
assert.equal(p(resumed).elites!.revived, p(original).elites!.revived);
assert.equal(p(resumed).spice, p(original).spice);
assert.equal(p(resumed).specialKaramaUsed, true);
assert.equal(resumed.discard.filter(held => held.id === card).length, 1);
assert.equal(resumed.homeworlds!.custody!.salusa!.elite, 2);
revivalInventory(resumed);
});