Seal exact native Homeworld Reinforcements costs and plan sources
This commit is contained in:
1 parent
05c4c8c7fb
commit
c64c08af89
32 files changed
+1484
-119
No files matched your search
@@ -3,8 +3,9 @@ import { useId } from 'react';
|
||||
import { HelpTip } from './help-tip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { TRUTH_CARD_NAMES } from '@/game/truthtrance';
|
||||
import { TRUTH_CARD_NAMES } from '@/game/truthtrance-card-names';
|
||||
import { planClaimText, type PlanClaim } from '@/game/battle-promises';
|
||||
import { reinforcementsSourceLabel } from './reinforcements-sources';
|
||||
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
|
||||
import type { Action, GameView, Plan } from '@/game/engine';
|
||||
|
||||
@@ -229,6 +230,9 @@ export function BattlePromises({
|
||||
<br />
|
||||
Kwisatz: {plan.kwisatz ? 'Included' : 'Not included'}
|
||||
</p>
|
||||
{plan.reinforcementsSources && (
|
||||
<p className="fine">Native reserve fee: {reinforcementsSourceLabel(g, plan.reinforcementsSources)}</p>
|
||||
)}
|
||||
<p className="fine">
|
||||
These choices satisfy your current commitments. Review and change
|
||||
the other elements before sealing your plan.
|
||||
|
||||
@@ -61,6 +61,8 @@ import { nativeShipmentSources } from '@/game/homeworld-options';
|
||||
import { botBattleChoices } from '@/game/bot-battle-choices';
|
||||
import { specialForceName, maxCombatDial, maxCombatSupport } from '@/game/combat';
|
||||
import type { NativeReserveSelections } from '@/game/homeworld-native-reserves';
|
||||
import { reinforcementsBattleForces } from '@/game/reinforcements-homeworld';
|
||||
import { ReinforcementsSources, reinforcementsSourceLabel } from './reinforcements-sources';
|
||||
import {
|
||||
StrongholdCardGallery,
|
||||
StrongholdCopyChoice,
|
||||
@@ -436,7 +438,26 @@ export function GameTable({
|
||||
Number.isFinite(stormDial) ? Math.trunc(stormDial) : 0,
|
||||
),
|
||||
);
|
||||
const ownBattleForces = g.battle?.ownForces;
|
||||
const selectedWeaponId = battlePlanCardValue(g, 'weapon', weapon);
|
||||
const selectedDefenseId = battlePlanCardValue(g, 'defense', defense);
|
||||
const selectedBattleWeapon = me.hand?.find(c => c.id === selectedWeaponId);
|
||||
const selectedBattleDefense = me.hand?.find(c => c.id === selectedDefenseId);
|
||||
const reinforcementsSelected = selectedWeaponId === 'ecaz-reinforcements' ||
|
||||
selectedDefenseId === 'ecaz-reinforcements';
|
||||
const [reinforcementsSourceDraft, setReinforcementsSourceDraft] = useState<{
|
||||
key: string; index: number;
|
||||
} | null>(null);
|
||||
const reinforcementsSourceChoices = g.battle?.reinforcements?.homeworldSources;
|
||||
const reinforcementsSourceKey = reinforcementsSourceChoices
|
||||
? `${g.code}:${me.id}:${g.battle?.event ?? ''}:${JSON.stringify(reinforcementsSourceChoices)}` : '';
|
||||
const reinforcementsSourceIndex = reinforcementsSourceDraft?.key === reinforcementsSourceKey
|
||||
? Math.min(reinforcementsSourceDraft.index, Math.max(0, (reinforcementsSourceChoices?.length ?? 1) - 1)) : 0;
|
||||
const selectedReserveSource = reinforcementsSourceChoices?.[reinforcementsSourceIndex];
|
||||
const rawOwnBattleForces = g.battle?.ownForces;
|
||||
const ownBattleForces = rawOwnBattleForces && reinforcementsSelected &&
|
||||
selectedReserveSource && g.battle?.native === me.id
|
||||
? reinforcementsBattleForces(rawOwnBattleForces, selectedReserveSource[g.battle!.territory])
|
||||
: rawOwnBattleForces;
|
||||
const occupyProfile = ecazOccupyOwnProfile(g);
|
||||
const battleDialMinimum = occupyProfile?.fixedEcazDial ?? 0;
|
||||
const battleFreeSupport = ownBattleForces?.freeSupport ??
|
||||
@@ -542,15 +563,10 @@ export function GameTable({
|
||||
selectedLeader: committed.leader ? String(committed.leader.value ?? '') : leader,
|
||||
card,
|
||||
});
|
||||
const selectedWeaponId = battlePlanCardValue(g, 'weapon', weapon);
|
||||
const selectedDefenseId = battlePlanCardValue(g, 'defense', defense);
|
||||
const selectedBattleWeapon = me.hand?.find(c => c.id === selectedWeaponId);
|
||||
const selectedBattleDefense = me.hand?.find(c => c.id === selectedDefenseId);
|
||||
const reinforcementsSelected = selectedWeaponId === 'ecaz-reinforcements' ||
|
||||
selectedDefenseId === 'ecaz-reinforcements';
|
||||
const reinforcementsPlanReason = !reinforcementsSelected ? null :
|
||||
!g.battle?.reinforcements ? 'Reinforcements is unavailable in this battle.' :
|
||||
g.battle.reinforcements.blocked;
|
||||
g.battle.reinforcements.blocked ??
|
||||
(g.homeworlds && !selectedReserveSource ? 'Choose the exact native Reinforcements reserve sources.' : null);
|
||||
const selectedBattleKwisatz = kwisatz && !!me.kwisatz?.active &&
|
||||
!me.kwisatz.dead && !g.battle?.kwisatzBlocked &&
|
||||
(!me.kwisatz.usedAt || me.kwisatz.usedAt === g.battle?.territory);
|
||||
@@ -5105,6 +5121,12 @@ export function GameTable({
|
||||
setWeapon(plan.weapon ?? '');
|
||||
setDefense(plan.defense ?? '');
|
||||
setKwisatz(!!plan.kwisatz);
|
||||
if (plan.reinforcementsSources) {
|
||||
const chosen = JSON.stringify(plan.reinforcementsSources);
|
||||
const index = reinforcementsSourceChoices?.findIndex(choice => JSON.stringify(choice) === chosen);
|
||||
if (index !== undefined && index >= 0)
|
||||
setReinforcementsSourceDraft({ key: reinforcementsSourceKey, index });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -5394,6 +5416,11 @@ export function GameTable({
|
||||
{g.battle.reinforcements.blocked && ` ${g.battle.reinforcements.blocked}`}
|
||||
</p>
|
||||
)}
|
||||
{reinforcementsSelected && reinforcementsSourceChoices && (
|
||||
<ReinforcementsSources game={g} choices={reinforcementsSourceChoices}
|
||||
selected={reinforcementsSourceIndex} busy={busy}
|
||||
onChange={index => setReinforcementsSourceDraft({ key: reinforcementsSourceKey, index })} />
|
||||
)}
|
||||
{g.battle.reinforcements?.noField && <p className="fine">
|
||||
Provisional native order: your No-Field first materializes{' '}
|
||||
{g.battle.reinforcements.noField.materializes} forces, leaving{' '}
|
||||
@@ -5505,6 +5532,8 @@ export function GameTable({
|
||||
leader,
|
||||
weapon,
|
||||
defense,
|
||||
...(reinforcementsSelected && selectedReserveSource
|
||||
? { reinforcementsSources: selectedReserveSource } : {}),
|
||||
}),
|
||||
!!occupyControl?.blocked || !!stonePlanReason || !!harassControl.blocked || !!smugglerPlanReason || !!reinforcementsPlanReason || !battlePairValid,
|
||||
)}
|
||||
@@ -5520,6 +5549,9 @@ export function GameTable({
|
||||
spice committed.
|
||||
</p>
|
||||
)}
|
||||
{g.battle.plans[me.id]?.reinforcementsSources && (
|
||||
<p className="fine">Sealed reserve fee: {reinforcementsSourceLabel(g, g.battle.plans[me.id].reinforcementsSources!)}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { useId, type ReactElement } from 'react';
|
||||
import type { GameView } from '@/game/engine';
|
||||
import type { MixedShipmentExpression } from '@/game/mixed-shipment-question';
|
||||
import { TRUTH_CARD_NAMES, type TruthFact } from '@/game/truthtrance';
|
||||
import type { TruthFact } from '@/game/truthtrance';
|
||||
import { TRUTH_CARD_NAMES } from '@/game/truthtrance-card-names';
|
||||
import { SHIPMENT_EXPRESSION_MAX_DEPTH, SHIPMENT_EXPRESSION_MAX_LEAVES } from '@/game/shipment-promises';
|
||||
import { isKnowledgeFact, knowledgeFactInputError } from '@/game/truthtrance-knowledge';
|
||||
import { CHEAP_HERO_TRAITOR } from '@/game/traitors';
|
||||
|
||||
@@ -4,6 +4,7 @@ import { HelpTip } from './help-tip';
|
||||
import { faction } from '@/game/catalog';
|
||||
import { leaderStrengthLabel } from '@/game/cards';
|
||||
import { BattleComponentInspection } from './battle-component-inspection';
|
||||
import { reinforcementsSourceLabel } from './reinforcements-sources';
|
||||
|
||||
export function PrivateBattlePlan({ game }: { game: GameView }) {
|
||||
const insight = game.battle?.fullPlanInsight;
|
||||
@@ -74,6 +75,12 @@ export function PrivateBattlePlan({ game }: { game: GameView }) {
|
||||
<dt>Kwisatz Haderach</dt>
|
||||
<dd>{insight.plan.kwisatz ? 'Included' : 'Not included'}</dd>
|
||||
</div>
|
||||
{insight.plan.reinforcementsSources && (
|
||||
<div>
|
||||
<dt>Native Reinforcements fee</dt>
|
||||
<dd>{reinforcementsSourceLabel(game, insight.plan.reinforcementsSources)}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
<BattleComponentInspection
|
||||
playerName={
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
'use client';
|
||||
|
||||
import type { GameView } from '@/game/engine';
|
||||
import type { NativeReserveSelections } from '@/game/homeworld-native-reserves';
|
||||
import { homeworldName } from '@/game/homeworld-shipment-options';
|
||||
|
||||
export function reinforcementsSourceLabel(game: GameView, sources: NativeReserveSelections): string {
|
||||
return Object.entries(sources).map(([world, forces]) =>
|
||||
`${homeworldName(game, world)}: ${forces.normal} normal + ${forces.elite} special`).join('; ');
|
||||
}
|
||||
|
||||
export function ReinforcementsSources({ game, choices, selected, onChange, busy }: {
|
||||
game: GameView;
|
||||
choices: readonly NativeReserveSelections[];
|
||||
selected: number;
|
||||
onChange: (index: number) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
if (!choices.length) return null;
|
||||
return <fieldset disabled={busy} className="space-y-3 rounded-lg border border-[#a88b60]/50 p-3">
|
||||
<legend>Reinforcements reserve sources</legend>
|
||||
<p className="fine">Three original native reserves go to the Tanks, normal first. These are not the invading army or future Harass returns.</p>
|
||||
{choices.length > 1 ? <label className="block space-y-1">
|
||||
<span>Native reserve fee allocation</span>
|
||||
<select value={selected} onChange={event => onChange(Number(event.target.value))}>
|
||||
{choices.map((sources, index) => <option key={index} value={index}>
|
||||
{reinforcementsSourceLabel(game, sources)}
|
||||
</option>)}
|
||||
</select>
|
||||
</label> : <p className="fine">{reinforcementsSourceLabel(game, choices[0])}</p>}
|
||||
{choices.length > 1 && <p className="fine" aria-live="polite">
|
||||
Selected fee: {reinforcementsSourceLabel(game, choices[selected])}.
|
||||
</p>}
|
||||
<p className="notice">Provisional own-World composition: counters paying the fee cannot also fund your physical dial. The original printed face and support traits stay fixed; the bonus adds no physical counters.</p>
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -72,6 +72,7 @@ export function ShipmentPromises({
|
||||
id => id === CHEAP_HERO_TRAITOR
|
||||
? 'Cheap Hero / Heroine'
|
||||
: g.allLeaders.find(leader => leader.id === id)?.name ?? id,
|
||||
id => g.combatLocations.find(place => place.id === id)!.name,
|
||||
)
|
||||
: `Will you ${shipmentClaimText(p)} this turn?`}
|
||||
</li>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useId } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { TRUTH_CARD_NAMES } from '@/game/truthtrance';
|
||||
import { TRUTH_CARD_NAMES } from '@/game/truthtrance-card-names';
|
||||
import type { CardCountFact } from '@/game/truthtrance-card-count';
|
||||
import {
|
||||
HAND_INVENTORY_LABELS,
|
||||
|
||||
@@ -95,6 +95,7 @@ export function Truthtrance({
|
||||
? 'Cheap Hero / Heroine'
|
||||
: (g.allLeaders.find((l) => l.id === id)?.name ?? id);
|
||||
const name = (id: string) => g.players.find((p) => p.id === id)!.name;
|
||||
const locationName = (id: string) => g.combatLocations.find(place => place.id === id)!.name;
|
||||
const question: TruthQuestion =
|
||||
kind === 'fact'
|
||||
? {
|
||||
@@ -183,7 +184,7 @@ export function Truthtrance({
|
||||
</p>
|
||||
{w.question && (
|
||||
<blockquote>
|
||||
<p>{truthQuestionText(w.question, leaderName)}</p>
|
||||
<p>{truthQuestionText(w.question, leaderName, locationName)}</p>
|
||||
<p className="fine">Asked of {name(w.question.target)}</p>
|
||||
</blockquote>
|
||||
)}
|
||||
@@ -366,10 +367,10 @@ export function Truthtrance({
|
||||
)}
|
||||
<p className="notice" aria-label="Public grouped question preview">
|
||||
{kind === 'mixedShipment'
|
||||
? invalidMixed ?? truthQuestionText(question, leaderName)
|
||||
? invalidMixed ?? truthQuestionText(question, leaderName, locationName)
|
||||
: invalidShipment
|
||||
? 'Enter a whole minimum of one to twenty forces.'
|
||||
: truthQuestionText(question, leaderName)}
|
||||
: truthQuestionText(question, leaderName, locationName)}
|
||||
</p>
|
||||
</>
|
||||
) : kind === 'battlePlan' ? (
|
||||
@@ -404,7 +405,7 @@ export function Truthtrance({
|
||||
</fieldset>
|
||||
))}
|
||||
<p className="notice">
|
||||
{truthQuestionText(question, leaderName)}
|
||||
{truthQuestionText(question, leaderName, locationName)}
|
||||
</p>
|
||||
{g.battle &&
|
||||
![g.battle.attacker, g.battle.defender].includes(
|
||||
@@ -436,7 +437,7 @@ export function Truthtrance({
|
||||
<p className="notice">
|
||||
{invalidFact
|
||||
? 'Resolve the marked fields before asking this question.'
|
||||
: truthQuestionText(question, leaderName)}
|
||||
: truthQuestionText(question, leaderName, locationName)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
@@ -514,6 +515,7 @@ export function TruthHistory({ game: g }: { game: GameView }) {
|
||||
id === CHEAP_HERO_TRAITOR
|
||||
? 'Cheap Hero / Heroine'
|
||||
: (g.allLeaders.find((l) => l.id === id)?.name ?? id),
|
||||
id => g.combatLocations.find(place => place.id === id)!.name,
|
||||
)}
|
||||
</p>
|
||||
<strong>
|
||||
|
||||
+29
-4
@@ -29,6 +29,31 @@ optional skill bands, combinations and public expansion starts remain open.
|
||||
Rules implementation continues; comprehensive assurance and administration
|
||||
remain deferred until all rules are implemented.
|
||||
|
||||
**Exact native Homeworld Reinforcements — 16 October.** Clean classic2–6
|
||||
Basic/Advanced independent36-card setup now connects the native fee on Arrakis,
|
||||
foreign Worlds and own Worlds. Exact normal-first source allocations seal in
|
||||
the plan; ambiguous omission rejects and a unique source may derive. Reserve
|
||||
fees never borrow visitors, allies or future Harass returns. On an own World,
|
||||
exclude its fee from the physical dial/casualty pool while retaining original
|
||||
pre-fee face/support. Nested plan sources and explosion-face receipt preserve
|
||||
the actual allocation without a new game root or an invented own-World ban.
|
||||
|
||||
Canonical independent card names now work in existing current/named-slot
|
||||
Truthtrance controls. Native question logging/preview/history no longer calls
|
||||
an Arrakis-only lookup. A real named R+dial3 promise finds the needed off-World
|
||||
fee, and private reviews/form filling preserve it. Six actual390px journeys
|
||||
finish Collection, including physical0 own defense, threshold explosion,
|
||||
three-Fedaykin fee before15 Harass returns and entitled whole-plan source read.
|
||||
32 fresh four-policy programmes accept114 continuations and separately
|
||||
validate1682 R candidates against the actual engine.
|
||||
|
||||
Frozen41-file464/464, types/lint and build pass. CLI help executes;218 evidence
|
||||
paths/source anchors/related IDs resolve, with three coverage topics Partial.
|
||||
Eight initial Vite dependency aborts and automation selector limitations are
|
||||
recorded; no overflow/application exception. Owned hosts/tabs/scaffolds removed.
|
||||
Other rosters/overlays, saved-game conversion, public/deploy gates, calibration,
|
||||
whole-mode and final recovery/privacy/custody certification remain open.
|
||||
|
||||
**Current-rate Homeworld Recruits — 16 October.** Clean classic2–6 Basic and
|
||||
Advanced independent36-card setup now admits native Revival. Reuse current
|
||||
free rates/low bonuses, recorded usage and typed deposits, not a new saved
|
||||
@@ -48,15 +73,15 @@ by a passing10-case rerun. CLI help and207 guide evidence paths/source anchor
|
||||
pass. Initial QA middleware ordering and eight Vite dependency aborts are
|
||||
recorded, not called zero diagnostics. No overflow/application exception.
|
||||
Owned hosts/tab/scaffolds removed; no games/DBs/human services touched.
|
||||
Reinforcements retains its explicit Homeworld gate. Other overlays/rosters,
|
||||
public starts, whole-mode/final assurance and deployment remain gated.
|
||||
Reinforcements uses its separate exact native-source composition above. Other
|
||||
overlays/rosters, public starts, whole-mode/final assurance and deployment stay gated.
|
||||
|
||||
**Classic Homeworld Harass — 16 October.** Fresh classic2–6 Basic/Advanced
|
||||
Homeworlds can compose the original independent36-card variant. Harass works
|
||||
on Arrakis or a foreign World, never any own World, including both Emperor
|
||||
Worlds in Advanced. Recruits uses the separate native-rate composition above;
|
||||
Reinforcements keeps its World guard. No other overlays, expansion rosters,
|
||||
public starts or played-save conversion open.
|
||||
Reinforcements uses its separate native fee contract. No other overlays,
|
||||
expansion rosters, public starts or played-save conversion open.
|
||||
|
||||
Actual undialed counters return through one native custody quote: Basic
|
||||
original primary retains typed elites; Advanced normal→Kaitain/elite→Salusa.
|
||||
|
||||
@@ -145,8 +145,10 @@ physical cards before original classic2–6 Basic/Advanced setup: base33+3.
|
||||
Harass composes on Arrakis and foreign Homeworlds. The printed own-World
|
||||
prohibition remains, including both Advanced Emperor Worlds. Expansion
|
||||
rosters, other overlays, public starts and played-game conversion stay closed.
|
||||
Reinforcements retains its Homeworld guard; Recruits has a separate
|
||||
[native current-rate composition](RECRUITS_RUNTIME.md#classic-homeworld-composition--16-october-2026).
|
||||
Recruits has a separate
|
||||
[native current-rate composition](RECRUITS_RUNTIME.md#classic-homeworld-composition--16-october-2026);
|
||||
Reinforcements now uses an
|
||||
[exact native fee before these returns](REINFORCEMENTS_RUNTIME.md#classic-homeworld-reserve-fees--16-october-2026).
|
||||
This is not certification of the entire three-card variant.
|
||||
|
||||
Arrakis returns remove exact ordinary/elite board counters and use the
|
||||
|
||||
@@ -1,5 +1,75 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 16 October 2026 — Exact native Homeworld Reinforcements fees
|
||||
|
||||
Original fresh own-Salusa play rejects the blanket R module gate before a
|
||||
plan, reserve or card changes. Extend only clean classic2–6 Basic/Advanced
|
||||
Homeworlds with independent36-card setup. All three bounded effects now compose;
|
||||
this does not certify the whole variant or open other overlays, expansion
|
||||
rosters, public starts or played-save conversion.
|
||||
|
||||
One focused native quote enumerates exact normal-first World source choices
|
||||
and validates the selected three counters through original custody. Visitors,
|
||||
other players and later Harass returns do not fund it. A unique source may
|
||||
derive; ambiguous omission and malformed/foreign/wrong-type/overdraw source
|
||||
maps reject. Seal `Plan.reinforcementsSources`, excluded from ordinary
|
||||
Prescience fields; no early payment or game root. Source ownership remains
|
||||
the actual card user, not a selected leader/ally.
|
||||
|
||||
On an own World, fee counters and native army overlap physically. Explicitly
|
||||
choose the disjoint pre-fee reserved-pool interpretation: subtract the on-World
|
||||
fee from physical dial/casualties, not original face/support traits. An off-World
|
||||
fee leaves that army. Native explosion receipts keep optional nested
|
||||
`faceAtResolution`; split1K/2S at Kaitain5 crosses low at4 but still loses the
|
||||
original2, not post-fee3. Exact typed fee commits before Harass/casualties and
|
||||
counts existing battle losses on every revealed outcome. No own-World ban,
|
||||
support/bonus pawn, waiver or retroactive rescore is invented. These are visibly
|
||||
provisional compositions of the printed reserve cost and original native rules,
|
||||
not newly retrieved FAQ answers.
|
||||
|
||||
Both slot controls display finite exact native choices and a wrapping selected
|
||||
caption. Own-World dial/support bounds, sealed guidance, earned full-plan read,
|
||||
private compliant review and fill carry source metadata. Four policies generate
|
||||
legal source-bearing actions; unchanged normal-score strategy is not calibrated.
|
||||
Two workers supplied independent genuine Arrakis/World fixture/regression
|
||||
slices; parent reviewed all bodies, removed incidental stage/default/DTO-copy
|
||||
pins and integrated alone. Actual setup/shipment/native movement produces
|
||||
normal/mixed/star fee pools without counter/wallet/phase/history assignment.
|
||||
Hands, original Traitors and unopened first-Blow cards are positioned explicitly.
|
||||
|
||||
Constrained-plan smoke exposed two real pre-existing blockers: independent
|
||||
names omitted from the old whitelist, then battle question formatting calling
|
||||
Arrakis `territory()` for a World. Move the single canonical registry to
|
||||
`truthtrance-card-names.ts` and migrate every consumer without re-export;
|
||||
ordinary/Ix/cache/independent names do not select/deal a family or reclassify a
|
||||
utility. Require explicit canonical location resolution for logging, active
|
||||
preview/answer and history. A real named R+dial3 promise now answers Yes, finds
|
||||
the off-World source and executes it. Its completion fill replaces the invalid
|
||||
on-World fee draft too. Delete obsolete registry/parse echoes and incidental
|
||||
spice/knowledge wording assertions, preserving their actual action outcomes.
|
||||
|
||||
Six real390px GameTable journeys reach phase7 with battle/decision/World loss
|
||||
clear: foreign split2K/1S, native3K/physical0, split1K/2S threshold explosion,
|
||||
Fremen3E fee before15N Harass returns, private Atreides source inspection, and
|
||||
the actual compound named-card promise. Physical0 bounds are both0. Private
|
||||
inspection leaves opponent unsealed, shows complete fee only to its entitled
|
||||
reader, then continues normally. The named legal-plan fill changes stale source3
|
||||
to0 and physical max0 to3, completing reserves14/Tanks6/spice7 with Salusa9N5E.
|
||||
Thirty-two fresh programmes/four policies accept114 plan/vote/loss actions and
|
||||
separately engine-validate1682 generated R candidates; no false continuation
|
||||
count. Initial inspection probe needed original elite-strength priority passes,
|
||||
not a timing-rule change.
|
||||
|
||||
Frozen41 offline files464/464; types/lint and production build pass. Existing
|
||||
module-register/large-chunk/Unknown-route warnings remain. Actual CLI help
|
||||
executes; guide import resolves218 unique evidence paths, source anchors and
|
||||
related IDs. All three topic coverages remain Partial. Eight Vite startup
|
||||
dependency aborts and selector limitations are retained in private proof,
|
||||
not called zero diagnostics; no overflow/application exception. Fresh screenshot
|
||||
after the fade is the accepted private-panel visual proof. Owned hosts/tabs/
|
||||
scaffolds removed. Games/DBs/human services remain untouched. No deployment or
|
||||
whole-mode, recovery/privacy/custody certification is claimed.
|
||||
|
||||
## 16 October 2026 — Recruits with current native Homeworld rates
|
||||
|
||||
Final types/lint pass after narrowing the test helper's nullable World view;
|
||||
|
||||
@@ -34,9 +34,9 @@ The fresh `ecaz-treachery --homeworlds` entry now admits Recruits during
|
||||
original classic2–6 Basic/Advanced Revival, with its original36-card inventory.
|
||||
Reuse native current free rates and typed revival deposits. No rate snapshot,
|
||||
new saved field, seven extra counters, automatic refund, generic module
|
||||
activation or played-save conversion is added. Reinforcements keeps its own
|
||||
explicit Homeworld gate; other optional overlays and expansion rosters stay
|
||||
outside this clean composition.
|
||||
activation or played-save conversion is added. Reinforcements has a separate
|
||||
[native-source fee composition](REINFORCEMENTS_RUNTIME.md#classic-homeworld-reserve-fees--16-october-2026);
|
||||
other optional overlays and expansion rosters stay outside this clean entry.
|
||||
|
||||
Publisher Ecaz & Moritani printed pp11/16 specifically gives low Fremen4,
|
||||
doubled but capped at7. Under the standing choose-recommended instruction,
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
|
||||
The separate Ecaz Treachery Cards variant shuffles one physical Reinforcements
|
||||
card independently of choosing the Ecaz faction. Its bounded handler supports
|
||||
Basic or Advanced classic-faction tables and clean Ecaz/Moritani expansion
|
||||
tables without other optional modules. The fresh opted-in Advanced Occupy
|
||||
profile additionally composes co-present armies and native Ixian/Tleilaxu/CHOAM
|
||||
families; Basic/unprofiled co-side use stays guarded. The
|
||||
holder may commit it in either Battle Plan card slot. It is neither a weapon
|
||||
Basic/Advanced classic-faction tables and clean Ecaz/Moritani expansion tables.
|
||||
Fresh clean classic Homeworlds use the native-source composition below; other
|
||||
optional modules retain their guards. The selected Advanced Occupy profile also
|
||||
composes co-present armies and native Ixian/Tleilaxu/CHOAM families.
|
||||
The holder may commit it in either Battle Plan card slot. It is neither a weapon
|
||||
nor a defense: Prescience of a category receives **None**, while the physical
|
||||
slot stays occupied and hidden until reveal. A named-card inspection still
|
||||
fixes the exact card. Harass & Withdraw may share the plan under the
|
||||
[provisional original-cost-first policy](#combined-utility-plan--16-october-2026).
|
||||
Original or copied Stone Burner composes in the existing native Advanced Occupy
|
||||
envelope; Homeworld, other profile and optional-module boundaries remain.
|
||||
envelope. Other roster/profile/optional-module boundaries remain.
|
||||
|
||||
The [combined-army follow-up](ECAZ_OCCUPY_RULES.md#independent-cards-with-combined-armies--2-october-2026)
|
||||
retains own-holder reserve cost and selected lead card/payment ownership.
|
||||
@@ -144,3 +144,97 @@ D1, deployment, public profile or new broad/HTTP/recovery/privacy/custody
|
||||
assurance campaign changed. Owned hosts/scripts are removed; private proof
|
||||
artifacts stay outside Git.
|
||||
|
||||
|
||||
## Classic Homeworld reserve fees — 16 October 2026
|
||||
|
||||
Fresh `ecaz-treachery --homeworlds` composes original classic2–6 Basic/Advanced
|
||||
and all three independent physical cards (base33+3). Reinforcements may be
|
||||
used on Arrakis, a foreign World **or an own World**; it does not inherit
|
||||
Harass's printed own-World ban. Expansion rosters, other overlays, public
|
||||
starts and played-save conversion remain outside this entry.
|
||||
|
||||
### Sources, physical pool and original quote
|
||||
|
||||
The existing provisional normal-first/all-outcome fee still consumes exactly3
|
||||
own native reserve counters into matching normal/special Tanks subpools before
|
||||
Harass returns and battle casualties. Visitors, an ally and future returns
|
||||
cannot pay. Canonical native withdrawal updates World custody and aggregate
|
||||
reserves together, not fake board keys or revival. Population observation
|
||||
follows the complete typed fee. The +2 remains score-only.
|
||||
|
||||
An owner-only finite source list exposes every exact legal split, including
|
||||
real normal counters moved between Kaitain/Salusa. A unique source may derive;
|
||||
ambiguous omission, foreign IDs, wrong types, nonphysical counts and overdraw
|
||||
reject. `Plan.reinforcementsSources` records the chosen fee before both seals;
|
||||
sealing pays nothing early. Sources are not a new ordinary Prescience element.
|
||||
Named full-plan inspection receives the actual committed sources privately.
|
||||
|
||||
On an own World, native reserves and the battle army are the same physical
|
||||
counters. Under the explicit choose-recommended composition, reserve the
|
||||
on-location fee from the physical dial/support/casualty pool while retaining
|
||||
the original pre-fee printed face and combat traits. An off-World fee does
|
||||
not shrink that army. This is provisional composition, not a newly retrieved
|
||||
card-specific FAQ or permission to spend one counter twice.
|
||||
|
||||
For example, Kaitain3 paying3 here can still play its printed-strength/leader
|
||||
plan at physical dial0, but not dial1. Taking3 normal from Salusa instead
|
||||
leaves those three Kaitain natives available. With Kaitain5, a split1 Kaitain/
|
||||
2 Salusa fee crosses low at4; its explosion still loses the original high2,
|
||||
not the later low3. The optional nested World-loss `faceAtResolution` keeps
|
||||
that exact original choice through mandatory disposal. No new game root is
|
||||
added; the new nested source/face fields are not described as no saved fields.
|
||||
|
||||
Controls show exact allocations and a wrapped selected-fee caption, project
|
||||
the own-World physical maximum, retain sealed source guidance, and carry the
|
||||
whole source choice when filling a legal completion. Private full-plan and
|
||||
compliant-plan reviews display the same real source map. Four minimal policies
|
||||
generate explicit legal source-bearing plans and native-only continuations.
|
||||
|
||||
### Canonical named questions and native locations
|
||||
|
||||
The constrained-plan smoke found the old whitelist omitted the independent
|
||||
cards. `game/truthtrance-card-names.ts` now supplies canonical ordinary/Ix,
|
||||
Richese-cache and independent names to current hand/count controls and named
|
||||
battle conditions. Every consumer moved; no obsolete re-export remains.
|
||||
Names do not select/deal a family or change a utility card into a weapon.
|
||||
|
||||
A real Reinforcements-and-dial3 question then exposed the formatter's
|
||||
Arrakis-only lookup. Server logging, active preview/answer and history now
|
||||
provide canonical combat-location names explicitly. A real Kaitain promise
|
||||
can answer Yes, find the necessary off-World Salusa fee and execute it. Its
|
||||
legal-completion button replaces an invalid own-World fee draft as well as
|
||||
the dial/support/cards. Old copied-registry and incidental wording assertions
|
||||
are deleted, not re-pinned; native action/physical-outcome regressions replace
|
||||
that evidence.
|
||||
|
||||
### Scoped native proof
|
||||
|
||||
Six actual390px GameTable journeys reach Collection with no battle/decision/
|
||||
World-loss frame: foreign split2K/1S, scarce own3K/physical0, split1K/2S
|
||||
threshold explosion, Fremen three-Fedaykin fee before15 normal Harass returns,
|
||||
entitled full-plan source inspection, and the named R+dial3 Kaitain promise.
|
||||
The source review/selection stays readable; its current metadata never pays
|
||||
early. Native-only votes, original support, exact Tanks and mandatory discard
|
||||
are observed.
|
||||
|
||||
Thirty-two fresh programmes/four policies validate1682 generated R candidates
|
||||
against the actual engine and accept114 plan/vote/loss continuation actions,
|
||||
including the underfunded-card fallback. These are separate counts, not1682
|
||||
continuations. Actual setup, shipment, native movement and battles produce
|
||||
the positions; only conserved hands/Traitors/unopened first-Blow cards are
|
||||
positioned explicitly. This is not unstaged full-game card acquisition.
|
||||
|
||||
The original inspection probe initially stopped before native elite-strength
|
||||
priority passed; completing that original response fixed the probe without
|
||||
changing timing rules. The named-question failures above were real runtime
|
||||
gaps and are repaired. Across two owned Vite startups, eight old dependency
|
||||
requests abort before rendered journeys; no overflow/application exception.
|
||||
Owned hosts/tabs/scaffolds are removed and proof artifacts remain outside Git.
|
||||
No saved game, DB, human service, public gate or deployment changed. Whole
|
||||
mode/variant acceptance, calibration and final assurance remain unfinished.
|
||||
|
||||
Frozen related41 offline files464/464, types/lint and production build pass.
|
||||
Existing module-register/large-chunk/Unknown-route warnings remain. CLI help
|
||||
executes;218 unique guide evidence paths, both new anchors and related IDs
|
||||
resolve. Homeworlds, Reinforcements and Truthtrance coverage remains Partial.
|
||||
No HTTP route or DB boundary changed; no broad/final assurance campaign ran.
|
||||
@@ -662,8 +662,9 @@ dedicated retrieved worked FAQs. [Contract and remaining limits](HOMEWORLD_OCCUP
|
||||
| Mixed No-Field Occupy | Extend the existing provisional additive ordinary-plus-reserve-capped-marker pool into selected native Advanced coalition battles, as ally or opponent. Original fixed Ecaz strength, support payer/counter owners, Traitor/actual reveal and history stay; prevention restores selected-lead-only pool. Selected Ecaz plan controls the ally's private variable quote, not its own ordinary maximum; rivals get no concealed increment. The FAQ mixed-cap ambiguity remains unresolved, not newly adjudicated. Basic mixed-co-side and other profile/module gates remain. | [Exact composition](RICHESE_NO_FIELD_RULES.md#mixed-native-occupy-pool--16-october-2026) |
|
||||
| No-Field / Atreides special plan | Supplied physical p27 C bars ordinary dial Prescience; distinct p27 F grants an entire-plan special Karama and p36 B names Prescience, not separately F. Under standing choose-recommended, provisionally allow F's actual complete private plan read, retaining C's dial bar. Original target-first commitment is not public No-Field revelation; both seals still materialize once, including zero/caps, then original costs/losses/history run. No token inventory or automatic allied inspection entitlement is granted. This is explicit textual/timing composition, not a new combined FAQ/user answer or mode opening. | [Exact source and scope](ATREIDES_FULL_PLAN_TIMING.md#supplied-source-no-field-composition--16-october-2026) |
|
||||
| No-Field / current Truthtrance fact | Broad game-related yes/no-fact authority supports the distinct current concealed printed-value predicate0/3/5. No marker/revealed history is No even for0; capped/exhausted5 remains printed5. Read the respondent's own model, never an ally or planned shipment. Publish only aggregate Yes/No after the private expected answer; information disclosure is not physical revelation or future-use obligation. This is explicit composition, not a dedicated No-Field FAQ. Existing physical-counter exclusion, native disposal/parent and mode gates remain. | [Source and current-fact scope](RICHESE_NO_FIELD_RULES.md#current-concealed-value-truthtrance--16-october-2026) |
|
||||
| Harass / classic Homeworlds | Fresh classic2–6 Basic/Advanced independent36-card/Homeworld composition admits Arrakis and foreign-World returns, never an own World. Return exact typed board/visitor counters through native custody: Basic primary; Advanced Emperor normal→Kaitain/elite→Salusa. This destination/timing composition is explicit, not a new card FAQ or separate Nexus ruling. Original sealed commitment/support remains; subsequent population observations cannot rescore it. Homeworld loss receipts retain fully-committed Harass complements; native-only Traitors, late defense/explosion and after-use discard stay. Recruits has the separate current-rate composition below; Reinforcements/faction/overlay/public gates remain. | [Source and native routing](HARASS_WITHDRAW_RUNTIME.md#classic-homeworld-reserve-routing--16-october-2026) |
|
||||
| Recruits / classic Homeworlds | Publisher E3 pp11/16 gives low Fremen4 doubled but capped7. Under choose-recommended, compose original separate-request native population observations: current rate at acceptance, retained accepted group price/free classification, then later requests use changed population and recorded free usage. Bulk7 versus split1+5 is provisional timing, not a new FAQ/user answer. Fremen still cannot buy a seventh without Tleilaxu; typed native deposits and original special-force caps remain. Prior paid/ready returns retain payment and opportunity while phase4 stays open. Clean classic2–6 Basic/Advanced only; Reinforcements and other roster/overlay/public gates stay. | [Current-rate scope and proof](RECRUITS_RUNTIME.md#classic-homeworld-composition--16-october-2026) |
|
||||
| Harass / classic Homeworlds | Fresh classic2–6 Basic/Advanced independent36-card/Homeworld composition admits Arrakis and foreign-World returns, never an own World. Return exact typed board/visitor counters through native custody: Basic primary; Advanced Emperor normal→Kaitain/elite→Salusa. This destination/timing composition is explicit, not a new card FAQ or separate Nexus ruling. Original sealed commitment/support remains; subsequent population observations cannot rescore it. Homeworld loss receipts retain fully-committed Harass complements; native-only Traitors, late defense/explosion and after-use discard stay. Recruits and Reinforcements have their separate native compositions below; other faction/overlay/public gates remain. | [Source and native routing](HARASS_WITHDRAW_RUNTIME.md#classic-homeworld-reserve-routing--16-october-2026) |
|
||||
| Recruits / classic Homeworlds | Publisher E3 pp11/16 gives low Fremen4 doubled but capped7. Under choose-recommended, compose original separate-request native population observations: current rate at acceptance, retained accepted group price/free classification, then later requests use changed population and recorded free usage. Bulk7 versus split1+5 is provisional timing, not a new FAQ/user answer. Fremen still cannot buy a seventh without Tleilaxu; typed native deposits and original special-force caps remain. Prior paid/ready returns retain payment and opportunity while phase4 stays open. Clean classic2–6 Basic/Advanced only; Reinforcements uses its separate native fee contract and other roster/overlay/public gates stay. | [Current-rate scope and proof](RECRUITS_RUNTIME.md#classic-homeworld-composition--16-october-2026) |
|
||||
| Reinforcements / classic Homeworlds | Under standing choose-recommended, seal exact native normal-first sources for the existing three-reserve/all-outcome fee. Ambiguous omission rejects; a unique source may derive. Native reserves on an own World are also its army, so that fee cannot fund the same physical dial/casualties; an off-World fee leaves the pool. Keep original pre-fee traits/printed face, including explosion loss choices across a threshold, then typed Tanks, Harass return and original casualties. This is explicit provisional physical-pool composition, not a new FAQ or own-World ban. Source-aware full/complying plan reviews and named independent-card conditions use canonical names and actual combat locations. Other rosters/overlays/public starts remain gated. | [Native sources and exact proof](REINFORCEMENTS_RUNTIME.md#classic-homeworld-reserve-fees--16-october-2026) |
|
||||
| Clean Ix Stone / Tooth / Mirror | Reuse the existing CHOAM prototype order: original Stone first, then uncopied Tooth. The earlier priority question is unanswered; standing choose-recommended extends that order provisionally, not as a publisher ruling or user answer. Printed Mirror copy-first and user-selected physical retention stay independent. Clean selected Ix/Richese supports native Ixians/Tleilaxu in both modes; Ecaz/Moritani stays Advanced-only. Typed winner ambiguity, new Ix Tech/Stronghold and other module bands remain guarded. | [Source and profile distinction](MIRROR_WEAPON_ENGINE_AUDIT.md#clean-selected-ix-composition--16-october-2026) |
|
||||
| Expansion-faction setup | Selecting Ecaz/Moritani factions does not select their independent three-card variant. Ordinary decks contain 33/35/47 cards; Richese's ten-card cache stays separate. Ecaz allocates six total forces within Imperial Basin sectors 9–11, retaining fourteen reserves. Flexible sector allocation is the literal printed territory constraint; Fremen→Ecaz→Advanced BG order is an implementation inference, while Moritani-last is printed. | [Source and profile contract](EXPANSION_FACTIONS_PROTOTYPE.md) |
|
||||
| Ixian Technology on Richese lots | Superseded 8 October by the authorized supplied Advanced physical p34, which expressly excludes Richese auctions from Ixian Technology. Richese cache/Black Market lots now open directly; the once-per-round Technology use remains for ordinary lots. The temporary per-lot decline scaffolding and its tests were deleted. | [Auction source boundary](RICHESE_AUCTION_RULES.md#13-september-saved-per-lot-technology-decline--superseded-8-october) |
|
||||
|
||||
@@ -18,6 +18,15 @@ The new leaf inside the existing fact question is:
|
||||
|
||||
`value` must be a nonnegative safe integer. Thresholds are not artificially limited by a current hand limit or battle dial. Names use the same exact canonical `TRUTH_CARD_NAMES` membership as existing hand facts. Case variants and unknown names are rejected; no new alias or combat-role inference is introduced. Shield Snooper and Portable Snooper do not count as Shield or Snooper.
|
||||
|
||||
The 16 October canonical registry lives in `game/truthtrance-card-names.ts`.
|
||||
Ordinary/Ix, separate Richese cache and all three independent Ecaz names feed
|
||||
the same exact current-fact and named-slot condition controls. Recognizing a
|
||||
name does not select/deal its family or reclassify a special as a weapon.
|
||||
The actual named Reinforcements/native-dial continuation is recorded in the
|
||||
[native fee contract](REINFORCEMENTS_RUNTIME.md#canonical-named-questions-and-native-locations).
|
||||
Native battle question previews, answers/history and server logs receive
|
||||
canonical combat-location labels, not an Arrakis-only territory lookup.
|
||||
|
||||
`game/truthtrance-card-count.ts` provides the pure parser, matcher and sentence formatter. The engine adapter catches parser failures as `TruthError`, preserving the existing rejected-action path. It evaluates physical entries in the respondent's actual current hand when answering. A used card retained in hand counts; cards already in a deck, discard or on-table effect do not. The helper relies on the engine's physical inventory contract rather than deduplicating or repairing corrupted hands.
|
||||
|
||||
The shared Truthtrance engine handles priority, ownership, answer validation, exact single discard, history and interrupted-state continuation. A dishonest or unknown answer to this knowable leaf is rejected. In composed expressions, existing three-valued AND/OR rules still apply when another clause is unknown. Only the aggregate expected answer is projected to the target; other seats get no matching-card IDs, counts or per-clause answers. The published question and answer intentionally reveal their logical consequence.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { richeseCards } from './richese-cards';
|
||||
import { TRUTH_CARD_NAMES } from './truthtrance-card-names';
|
||||
import type { Plan } from './engine';
|
||||
import { treacheryDeck, type Card } from './cards';
|
||||
import type { Card } from './cards';
|
||||
import { CHEAP_HERO_TRAITOR } from './traitors';
|
||||
|
||||
export type PlanClaim =
|
||||
@@ -67,7 +67,7 @@ export function parsePlanClaim(
|
||||
if (
|
||||
v.name !== null &&
|
||||
(typeof v.name !== 'string' ||
|
||||
![...treacheryDeck(['ix']), ...richeseCards()].some((c) => c.name === v.name))
|
||||
!TRUTH_CARD_NAMES.includes(v.name))
|
||||
)
|
||||
return fail();
|
||||
return { kind: v.kind, name: v.name };
|
||||
|
||||
@@ -81,7 +81,8 @@ export type ResolutionParticipant = {
|
||||
noFieldAtTerritory?: boolean;
|
||||
};
|
||||
export type ResolutionCombatant = ResolutionParticipant & {
|
||||
/** Own physical reserve counters available before battle casualties. */
|
||||
/** Own typed reserves before card costs. The caller excludes an on-World fee
|
||||
* from the native combat pool/rules while retaining original combat traits. */
|
||||
reinforcementsReserves?: { normal: number; elite: number };
|
||||
harassWithdraw?: HarassWithdrawContext;
|
||||
harassSelection?: HarassWithdrawSelection;
|
||||
@@ -410,8 +411,8 @@ function calculate(input: BattleResolutionInput): BattleResolutionQuote {
|
||||
requireQuote(validBattleSlotPair(slots[0], slots[1]) &&
|
||||
!(side === d && reinforcements.length),
|
||||
'Reinforcements needs a unique physical card and valid weapon and defense slot pair.');
|
||||
requireQuote(!input.homeworld && !(side.leaderSkills?.length || (side === a ? d : a).leaderSkills?.length),
|
||||
'Reinforcements cannot combine with Homeworld or leader skills.');
|
||||
requireQuote(!(side.leaderSkills?.length || (side === a ? d : a).leaderSkills?.length),
|
||||
'Reinforcements cannot combine with leader skills.');
|
||||
const reserves = side.reinforcementsReserves!;
|
||||
requireQuote(!!reserves && typeof reserves === 'object' && !Array.isArray(reserves),
|
||||
'Reinforcements needs own typed reserves.');
|
||||
|
||||
+36
-13
@@ -112,6 +112,8 @@ import { botNexusHarkonnenBetrayalActions } from './bot-nexus-harkonnen-betrayal
|
||||
import { validateCohortSelection, type OrnithopterMode } from './ornithopter';
|
||||
import { fighterCount, isAdvisor } from './advisors';
|
||||
import { casualtyOptions, maxCombatDial, maxCombatSupport, type CombatForces } from './combat';
|
||||
import { reinforcementsBattleForces } from './reinforcements-homeworld';
|
||||
import type { NativeReserveSelections } from './homeworld-native-reserves';
|
||||
import { stoneBurnerPlanBlock, stoneBurnerComparison, stoneBurnerWithdrawalContext } from './stone-burner';
|
||||
import {
|
||||
applyAction,
|
||||
@@ -586,7 +588,7 @@ function ornithopterMoves(g: GameView): Action[] {
|
||||
return result;
|
||||
}
|
||||
function candidatePlan(a: Action): Plan {
|
||||
return {
|
||||
const plan: Plan = {
|
||||
...(Number(a.bankerSpice ?? 0)
|
||||
? { bankerSpice: Number(a.bankerSpice) }
|
||||
: {}),
|
||||
@@ -597,6 +599,9 @@ function candidatePlan(a: Action): Plan {
|
||||
defense: typeof a.defense === 'string' ? a.defense : null,
|
||||
kwisatz: a.kwisatz === true,
|
||||
};
|
||||
if (a.reinforcementsSources !== undefined)
|
||||
plan.reinforcementsSources = a.reinforcementsSources as NativeReserveSelections;
|
||||
return plan;
|
||||
}
|
||||
function plans(g: GameView): Action[] {
|
||||
const me = g.players.find((p) => p.id === g.me)!;
|
||||
@@ -630,18 +635,16 @@ function plans(g: GameView): Action[] {
|
||||
const ownStrength = occupyProfile?.maxDial ?? (typedForces
|
||||
? maxCombatDial(forces)
|
||||
: own);
|
||||
const supportCache = new Map<
|
||||
number,
|
||||
{ support: number; cost: number } | null
|
||||
>();
|
||||
const supporting = (dial: number) => {
|
||||
if (supportCache.has(dial)) return supportCache.get(dial)!;
|
||||
type SupportChoice = { support: number; cost: number };
|
||||
const supportCache = new Map<number, SupportChoice | null>();
|
||||
const supporting = (dial: number, pool = forces, cache = supportCache) => {
|
||||
if (cache.has(dial)) return cache.get(dial)!;
|
||||
const choices = Array.from(
|
||||
{
|
||||
length:
|
||||
(g.advanced
|
||||
? Math.min(
|
||||
maxCombatSupport(forces),
|
||||
maxCombatSupport(pool),
|
||||
(me.spice ?? 0) +
|
||||
g.aid.available +
|
||||
(b.strongholdEffects[me.id] === 'arrakeen' ? 2 : 0),
|
||||
@@ -652,7 +655,7 @@ function plans(g: GameView): Action[] {
|
||||
(occupyProfile
|
||||
? occupyDials.some((option) => option.dial === dial && option.support === support)
|
||||
? quoteEcazOccupyDial(occupyProfile, dial, support).options : []
|
||||
: casualtyOptions(forces, dial, support)).map((loss) => ({
|
||||
: casualtyOptions(pool, dial, support)).map((loss) => ({
|
||||
support,
|
||||
cost:
|
||||
Math.max(
|
||||
@@ -667,9 +670,14 @@ function plans(g: GameView): Action[] {
|
||||
.flat()
|
||||
.sort((a, b) => a.cost - b.cost);
|
||||
const result = choices[0] ?? null;
|
||||
supportCache.set(dial, result);
|
||||
cache.set(dial, result);
|
||||
return result;
|
||||
};
|
||||
const reservePools = b.reinforcements?.homeworldSources?.map(sources => {
|
||||
const pool = b.native === me.id
|
||||
? reinforcementsBattleForces(forces, sources[b.territory]) : forces;
|
||||
return { sources, pool, cache: pool === forces ? supportCache : new Map<number, SupportChoice | null>() };
|
||||
});
|
||||
// Include affordable bank-supported dials even when the estimated winning
|
||||
// dial exceeds the budget; otherwise a large army can miss its free support.
|
||||
const bankSupportedDials =
|
||||
@@ -920,9 +928,24 @@ function plans(g: GameView): Action[] {
|
||||
);
|
||||
if (committedDial) action.dial = committedDial.value;
|
||||
if (!fixed('dial', Number(action.dial))) continue;
|
||||
const commitment = typedForces
|
||||
? supporting(Number(action.dial))
|
||||
: null;
|
||||
let commitment = typedForces && !(reinforcements && g.homeworlds)
|
||||
? supporting(Number(action.dial)) : null;
|
||||
if (reinforcements && g.homeworlds) {
|
||||
if (!reservePools?.length) continue;
|
||||
let selectedSources: NativeReserveSelections | undefined;
|
||||
let selectedCommitment: SupportChoice | null = null;
|
||||
for (const variant of reservePools) {
|
||||
if (!typedForces && Number(action.dial) > variant.pool.normal + variant.pool.elite) continue;
|
||||
const supported = typedForces ? supporting(Number(action.dial), variant.pool, variant.cache) : null;
|
||||
if (typedForces && !supported) continue;
|
||||
if (selectedSources && (selectedCommitment?.cost ?? 0) <= (supported?.cost ?? 0)) continue;
|
||||
selectedSources = variant.sources;
|
||||
selectedCommitment = supported;
|
||||
}
|
||||
if (!selectedSources) continue;
|
||||
action.reinforcementsSources = selectedSources;
|
||||
commitment = selectedCommitment;
|
||||
}
|
||||
if (typedForces && !commitment) continue;
|
||||
if (commitment) action.support = commitment.support;
|
||||
const supportCost = Math.max(
|
||||
|
||||
@@ -121,7 +121,7 @@ export function cardPresentation(card: VisibleCard): CardPresentation {
|
||||
? 'Recruits has development controls during Revival, including clean classic Basic/Advanced Homeworlds. Double the current native free rate, including low-population bonuses, within the seven-force total; separate requests recheck thresholds. Native typed deposits and special-force caps stay. Recorded prior paid returns keep payment under the provisional no-refund policy; pending transactions, unknown ledgers, later Fremen grants and same-turn replay remain guarded.'
|
||||
: ecaz.card.effect === 'harassWithdraw'
|
||||
? 'Harass & Withdraw has controls in either battle-card slot, with physical allocation after reveal. Classic Homeworlds returns actual typed counters into native reserve custody: Advanced Emperor normal to Kaitain/Sardaukar to Salusa; Basic preserves its original primary World. The card is forbidden on any own Homeworld. Original sealed commitment/support remains. An own concealed No-Field separately follows provisional materialization first without resetting history. Stone compares post-withdrawal counters provisionally. Other profile/module limits remain guarded.'
|
||||
: 'Reinforcements has controls in either battle-card slot, including the explicit Advanced Occupy card variant. It spends three own reserves and adds +2 normal score, not support or physical tokens. Own No-Field reveal must leave three original reserves before this cost; later Harass returns cannot fund it. Original/copy Stone ignores the score bonus but still charges reserves. All-outcome payment and native cost-before-return order are provisional. Other profile/module limits remain guarded.',
|
||||
: 'Reinforcements works in either slot, including clean classic Basic/Advanced Homeworlds and the selected Advanced Occupy variant. Spend three original own reserves normal-first for +2 normal score, not support or physical tokens. Native World sources seal explicitly when ambiguous; a fee here cannot also fund your own-World dial. Original face/support stays fixed. No-Field must leave three before this cost; future Harass returns cannot fund it. Original/copied Stone ignores +2 but still charges reserves. Cost/order interpretations are provisional; other profiles/modules remain guarded.',
|
||||
topics: RULE_TOPICS.filter((topic) => topic.id === (ecaz.card.effect === 'harassWithdraw' ? 'card-harass-withdraw' : ecaz.card.effect === 'reinforcements' ? 'card-reinforcements' : 'card-recruits')),
|
||||
};
|
||||
const topicIds =
|
||||
|
||||
+86
-24
@@ -600,7 +600,9 @@ import {
|
||||
type SpiceCard,
|
||||
} from './cards';
|
||||
import { ecazTreacheryCards, ecazTreacheryDefinition } from './ecaz-cards';
|
||||
import { isReinforcements, quoteReinforcements, ReinforcementsError } from './reinforcements';
|
||||
import { REINFORCEMENTS_CARD, isReinforcements, quoteReinforcements, ReinforcementsError } from './reinforcements';
|
||||
import { quoteReinforcementsHomeworldCost, reinforcementsHomeworldSources,
|
||||
reinforcementsBattleForces } from './reinforcements-homeworld';
|
||||
import {
|
||||
RECRUITS_CARD_ID,
|
||||
type RecruitsPreview,
|
||||
@@ -686,10 +688,12 @@ export type Plan = {
|
||||
support: number;
|
||||
allyPayment?: number;
|
||||
kwisatz?: boolean;
|
||||
/** Exact native reserve fee sources; only a Homeworld Reinforcements plan. */
|
||||
reinforcementsSources?: NativeReserveSelections;
|
||||
};
|
||||
export type PlanField = Exclude<
|
||||
keyof Plan,
|
||||
'support' | 'kwisatz' | 'allyPayment' | 'bankerSpice'
|
||||
'support' | 'kwisatz' | 'allyPayment' | 'bankerSpice' | 'reinforcementsSources'
|
||||
>;
|
||||
export type DiplomatDefenseReceipt = DiplomatDefenseQuote & {
|
||||
event: string;
|
||||
@@ -2200,6 +2204,8 @@ export type Game = {
|
||||
kind: 'winner' | 'explosion';
|
||||
pool: HomeworldForces;
|
||||
options: HomeworldForces[];
|
||||
/** Original printed face; reserve-card fees cannot reprice this explosion. */
|
||||
faceAtResolution?: 'high' | 'low';
|
||||
commitment?: { forces: CombatForces; dial: number; support: number; fullyCommitted?: true };
|
||||
} | null;
|
||||
lastBattleContext?: {
|
||||
@@ -8184,12 +8190,16 @@ function homeworldBattleLossIntegrity(g: Game) {
|
||||
requireRule(pending.pool && Object.keys(pending.pool).length === 2 &&
|
||||
pool.normal === pending.pool.normal && pool.elite === pending.pool.elite,
|
||||
'The Homeworld casualty receipt does not match its exact physical pool.');
|
||||
requireRule(pending.faceAtResolution === undefined ||
|
||||
(pending.kind === 'explosion' && ['high', 'low'].includes(pending.faceAtResolution)),
|
||||
'The native explosion receipt needs its original printed face.');
|
||||
let options: HomeworldForces[];
|
||||
if (pending.kind === 'explosion') {
|
||||
const rules = currentHomeworldBattleRules(g, pending.territory)!;
|
||||
requireRule(rules.native === pending.player && !pending.commitment,
|
||||
'Native explosion casualties belong to this Homeworld’s native faction.');
|
||||
options = quoteHomeworldBattleRules(pending.territory, g.players, rules).explosion.options;
|
||||
options = quoteHomeworldBattleRules(pending.territory, g.players,
|
||||
pending.faceAtResolution ? { ...rules, side: pending.faceAtResolution } : rules).explosion.options;
|
||||
} else {
|
||||
const commitment = pending.commitment;
|
||||
requireRule(commitment && commitment.forces.normal === pool.normal && commitment.forces.elite === pool.elite &&
|
||||
@@ -8346,10 +8356,18 @@ function ecazBattleProfile(g: Game): EcazOccupyBattleProfile | null {
|
||||
lead, canceled: occupy.canceled,
|
||||
}));
|
||||
}
|
||||
function planCombatForces(g: Game, p: Player, opponent: Player): CombatForces {
|
||||
function planCombatForces(g: Game, p: Player, opponent: Player,
|
||||
reserveCost?: ReinforcementsCost): CombatForces {
|
||||
const profile = ecazBattleProfile(g);
|
||||
return profile?.lead === p.id ? profile.forces :
|
||||
const forces = profile?.lead === p.id ? profile.forces :
|
||||
combatForces(g, p, g.battle!.territory, profile?.lead === opponent.id ? getPlayer(g, profile.forceOwner) : opponent);
|
||||
if (!g.homeworlds || !g.battle!.territory.startsWith('homeworld:')) return forces;
|
||||
const home = homeworldBattleLocation(g, g.battle!.territory);
|
||||
if (!home || home.native !== p.id) return forces;
|
||||
const plan = g.battle!.plans[p.id];
|
||||
const cost = reserveCost ?? (plan && (plan.weapon === REINFORCEMENTS_CARD || plan.defense === REINFORCEMENTS_CARD)
|
||||
? currentReinforcementsCost(g, p, plan.reinforcementsSources) : undefined);
|
||||
return reinforcementsBattleForces(forces, cost?.sources?.[home.id]);
|
||||
}
|
||||
function takeBattleLosses(g: Game, p: Player, t: string, losses: Casualties) {
|
||||
if (t.startsWith('homeworld:')) {
|
||||
@@ -13697,8 +13715,11 @@ function ecazTreacheryIntegrity(g: Game) {
|
||||
[plan.weapon, plan.defense].every(id => id === null || !!cardOf(owner, id)) &&
|
||||
validBattleSlotPair(cardOf(owner, plan.weapon), cardOf(owner, plan.defense)),
|
||||
'The sealed Reinforcements plan lost its physical card or legal slot.');
|
||||
currentReinforcementsCost(g, owner);
|
||||
currentReinforcementsCost(g, owner, plan.reinforcementsSources);
|
||||
}
|
||||
requireRule(plan.reinforcementsSources === undefined ||
|
||||
(g.homeworlds && (plan.weapon === REINFORCEMENTS_CARD || plan.defense === REINFORCEMENTS_CARD)),
|
||||
'Native reserve fee sources require the physical Reinforcements card in a Homeworld plan.');
|
||||
requireRule(plan.leader !== HARASS_WITHDRAW_CARD && g.battle?.lateDefense?.[player] !== HARASS_WITHDRAW_CARD &&
|
||||
!(plan.weapon === HARASS_WITHDRAW_CARD && plan.defense === HARASS_WITHDRAW_CARD),
|
||||
'Harass & Withdraw occupies exactly one ordinary Battle Plan card slot.');
|
||||
@@ -13758,19 +13779,31 @@ function combinedCardBattleBlock(g: Game): string | null {
|
||||
}
|
||||
function reinforcementsModeSupported(g: Game) {
|
||||
const pairedEcaz = g.expansions.length === 1 && g.expansions[0] === 'ecaz';
|
||||
return !!g.ecazTreachery && !g.homeworlds && recruitsModeSupported(g) && !g.sandtrout &&
|
||||
return !!g.ecazTreachery && recruitsModeSupported(g) && !g.sandtrout &&
|
||||
((g.ecazOccupyPreview === true && ecazOccupyCompositionSupported(g)) ||
|
||||
((g.expansions.length === 0 || pairedEcaz) && g.players.every(player => {
|
||||
const expansion = faction(player.faction).expansion;
|
||||
return expansion === 'base' || (pairedEcaz && expansion === 'ecaz');
|
||||
})));
|
||||
}
|
||||
function currentReinforcementsCost(g: Game, p: Player) {
|
||||
type ReinforcementsCost = ReturnType<typeof quoteReinforcements> & {
|
||||
sources?: NativeReserveSelections;
|
||||
native?: ReturnType<typeof quoteNativeReserveWithdrawal>;
|
||||
};
|
||||
function currentReinforcementsCost(g: Game, p: Player,
|
||||
selections?: NativeReserveSelections): ReinforcementsCost {
|
||||
requireRule(reinforcementsModeSupported(g),
|
||||
'Reinforcements requires the independent Ecaz card variant in a supported native faction composition.');
|
||||
const combinedBlock = combinedCardBattleBlock(g);
|
||||
requireRule(!combinedBlock, combinedBlock ?? 'This combined card battle is unavailable.');
|
||||
requireRule(selections === undefined || g.homeworlds,
|
||||
'Native Reinforcements sources require the original Homeworld module.');
|
||||
try {
|
||||
if (g.homeworlds) {
|
||||
requireRule(g.homeworlds.custody, 'Reinforcements needs its original native reserve custody.');
|
||||
return homeworldRule(() => quoteReinforcementsHomeworldCost(
|
||||
homeworldContext(g), g.homeworlds!.custody!, p.id, selections));
|
||||
}
|
||||
const materializes = prospectiveNoFieldForces(p, g.battle?.territory ?? '');
|
||||
return quoteReinforcements(p.reserves - materializes - (p.elites?.reserves ?? 0), p.elites?.reserves ?? 0);
|
||||
} catch (error) {
|
||||
@@ -13799,6 +13832,8 @@ function reinforcementsPreview(g: Game, p: Player) {
|
||||
? 'Your No-Field reveals before Reinforcements; three own reserves must remain after materialization.'
|
||||
: 'Reinforcements needs three physical forces in your reserves.';
|
||||
return { blocked, normal, elite,
|
||||
...(!blocked && g.homeworlds?.custody ? { homeworldSources: homeworldRule(() =>
|
||||
reinforcementsHomeworldSources(homeworldContext(g), g.homeworlds!.custody!, p.id)) } : {}),
|
||||
...(p.noField?.deployed?.location.territory === battle.territory
|
||||
? { noField: { materializes, reservesAfterReveal } } : {}) };
|
||||
}
|
||||
@@ -21172,7 +21207,13 @@ function validatePlan(
|
||||
): Plan {
|
||||
const b = g.battle!;
|
||||
const opponent = getPlayer(g, b.attacker === p.id ? b.defender : b.attacker);
|
||||
const forces = planCombatForces(g, p, opponent);
|
||||
const selectedReinforcements = input.weapon === REINFORCEMENTS_CARD || input.defense === REINFORCEMENTS_CARD;
|
||||
requireRule(input.reinforcementsSources === undefined || selectedReinforcements,
|
||||
'Native reserve fee sources require the physical Reinforcements card.');
|
||||
const reserveCost = selectedReinforcements
|
||||
? currentReinforcementsCost(g, p, input.reinforcementsSources as NativeReserveSelections | undefined)
|
||||
: undefined;
|
||||
const forces = planCombatForces(g, p, opponent, reserveCost);
|
||||
const profile = ecazBattleProfile(g);
|
||||
const occupy = profile?.lead === p.id ? profile : null;
|
||||
const typedForces = g.advanced || p.faction === 'ixians' || !!occupy;
|
||||
@@ -21201,6 +21242,7 @@ function validatePlan(
|
||||
const plan: Plan = {
|
||||
...(bankerSpice ? { bankerSpice } : {}),
|
||||
...(allyPayment ? { allyPayment } : {}),
|
||||
...(reserveCost?.sources ? { reinforcementsSources: structuredClone(reserveCost.sources) } : {}),
|
||||
dial,
|
||||
leader:
|
||||
typeof input.leader === 'string' && input.leader ? input.leader : null,
|
||||
@@ -21291,7 +21333,6 @@ function validatePlan(
|
||||
validBattleSlotPair(w, d, planetologistWeapon),
|
||||
'Chemistry as a weapon needs another defense; Weirding Way as a defense needs another weapon.',
|
||||
);
|
||||
if ([w, d].some(isReinforcements)) currentReinforcementsCost(g, p);
|
||||
if ([w, d].some(isHarassWithdraw)) currentHarassWithdrawQuote(g, p, plan);
|
||||
if (isMirrorWeapon(w)) {
|
||||
const blocked = mirrorWeaponModeBlock(g) ?? stonePlanBlock(g, p, plan);
|
||||
@@ -21771,6 +21812,7 @@ function findLegalBattlePlan(
|
||||
const fixedDial = profile?.lead === p.id ? profile.fixedEcazDial : 0;
|
||||
const typed = g.advanced || p.faction === 'ixians';
|
||||
const maxDial = fixedDial + (typed ? maxCombatDial(forces) : forces.normal + forces.elite);
|
||||
const nativeSources = g.homeworlds ? reinforcementsPreview(g, p)?.homeworldSources : undefined;
|
||||
const numerical: { dial: number; support: number }[] = [];
|
||||
for (let dial = fixedDial; dial <= maxDial; dial += typed ? 0.5 : 1)
|
||||
for (
|
||||
@@ -21826,13 +21868,18 @@ function findLegalBattlePlan(
|
||||
for (const kwisatz of [false, true]) {
|
||||
const partial = { leader, weapon, defense, kwisatz };
|
||||
if (!accepts(partial)) continue;
|
||||
for (const n of numerical) {
|
||||
const candidate = { ...partial, ...n };
|
||||
if (!accepts(candidate)) continue;
|
||||
try {
|
||||
return validatePlan(g, p, candidate, promises);
|
||||
} catch (error) {
|
||||
if (!(error instanceof RuleError)) throw error;
|
||||
const nativeFee = !!g.homeworlds &&
|
||||
(weapon === REINFORCEMENTS_CARD || defense === REINFORCEMENTS_CARD);
|
||||
for (let source = 0; source < (nativeFee ? nativeSources?.length ?? 0 : 1); source++) {
|
||||
for (const n of numerical) {
|
||||
const candidate: Partial<Plan> = { ...partial, ...n };
|
||||
if (nativeFee) candidate.reinforcementsSources = nativeSources![source];
|
||||
if (!accepts(candidate)) continue;
|
||||
try {
|
||||
return validatePlan(g, p, candidate, promises);
|
||||
} catch (error) {
|
||||
if (!(error instanceof RuleError)) throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22920,16 +22967,24 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
|
||||
};
|
||||
};
|
||||
try {
|
||||
const attacker = combatant(b.attacker, b.defender);
|
||||
const defender = combatant(b.defender, b.attacker);
|
||||
const homeworld = currentHomeworldBattleRules(g, b.territory);
|
||||
if (homeworld) {
|
||||
const native = homeworld.native === attacker.id ? attacker :
|
||||
homeworld.native === defender.id ? defender : null;
|
||||
if (native) homeworld.nativeForces = { normal: native.forces.normal, elite: native.forces.elite };
|
||||
}
|
||||
const quote = quoteBattleResolution({
|
||||
advanced: g.advanced,
|
||||
...(b.ecazOccupy ? { ecazOccupy: ecazBattleProfile(g)! } : {}),
|
||||
aggressor: battleAggressor(g),
|
||||
typedCasualties: !!g.homeworlds,
|
||||
...(homeworldBattleLocation(g, b.territory) ? { homeworld: currentHomeworldBattleRules(g, b.territory)! } : {}),
|
||||
...(homeworld ? { homeworld } : {}),
|
||||
turn: g.turn,
|
||||
territory: b.territory,
|
||||
attacker: combatant(b.attacker, b.defender),
|
||||
defender: combatant(b.defender, b.attacker),
|
||||
attacker,
|
||||
defender,
|
||||
voters: traitorVoters(g, b).map((id) => ({
|
||||
id,
|
||||
beneficiary: traitorBeneficiary(g, b, id)!,
|
||||
@@ -23003,7 +23058,7 @@ function currentBattleResolutionQuote(g: Game, canceledVoter?: string) {
|
||||
validateBattleForceLoss(getPlayer(g, loss.owner), b.territory, loss.normal + loss.elite);
|
||||
if (quote.homeworldExplosion)
|
||||
for (const losses of quote.homeworldExplosion.options) quoteHomeworldLoss(g, quote.homeworldExplosion.player, b.territory, losses);
|
||||
return { ...quote, smuggler, smugglerAllocation, sandmasterSelection };
|
||||
return { ...quote, homeworldFace: homeworld?.side, smuggler, smugglerAllocation, sandmasterSelection };
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof BattleResolutionQuoteError ||
|
||||
@@ -23239,17 +23294,23 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
|
||||
}
|
||||
for (const cost of quote.reinforcements ?? []) {
|
||||
const player = getPlayer(g, cost.player);
|
||||
const current = currentReinforcementsCost(g, player);
|
||||
const current = currentReinforcementsCost(g, player, b.plans[player.id].reinforcementsSources);
|
||||
requireRule(cost.normal === current.normal && cost.elite === current.elite,
|
||||
'Reinforcements lost its original physical reserve cost.');
|
||||
player.reserves -= cost.normal + cost.elite;
|
||||
if (current.native) {
|
||||
const owner = current.native.players.find(seat => seat.id === player.id)!;
|
||||
player.reserves = owner.reserves;
|
||||
if (player.elites) player.elites.reserves = owner.eliteReserves;
|
||||
g.homeworlds!.custody = current.native.state;
|
||||
} else player.reserves -= cost.normal + cost.elite;
|
||||
player.tanks += cost.normal + cost.elite;
|
||||
player.battleLosses += cost.normal + cost.elite;
|
||||
if (cost.elite) {
|
||||
requireRule(player.elites, 'Reinforcements lost its elite reserve custody.');
|
||||
player.elites.reserves -= cost.elite;
|
||||
if (!current.native) player.elites.reserves -= cost.elite;
|
||||
player.elites.tanks += cost.elite;
|
||||
}
|
||||
observeOccupation(g);
|
||||
log(g, `${player.name} used Reinforcements: three reserve forces (${cost.elite} elite) went to the Tanks, and the dialed battle strength gained 2 without adding on-board forces or spice support.`,
|
||||
{ faction: player.faction, name: 'Reinforcements' });
|
||||
}
|
||||
@@ -23598,6 +23659,7 @@ function resolveBattle(g: Game, retreat?: DiplomatRetreatSelection) {
|
||||
g.homeworldBattleLoss = {
|
||||
event: g.lastBattleContext.event, territory: b.territory, player: owner,
|
||||
kind: quote.homeworldExplosion ? 'explosion' : 'winner',
|
||||
...(quote.homeworldExplosion ? { faceAtResolution: quote.homeworldFace! } : {}),
|
||||
pool: combatArmy(g, owner, b.territory),
|
||||
options: (quote.homeworldExplosion?.options ?? casualtyCommitment!.options).map(({normal, elite}) => ({normal, elite})),
|
||||
...(casualtyCommitment ? { commitment: { forces: {...casualtyCommitment.forces}, dial: casualtyCommitment.dial, support: casualtyCommitment.support,
|
||||
|
||||
+12
-7
@@ -2710,6 +2710,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
steps: [
|
||||
'Declare Truthtrance and choose Verified cards, traitors or spice. Select Number of a named card, choose its exact name, select a comparison and enter a whole count of zero or more. Review the public question before asking.',
|
||||
'Only physical cards currently in that player’s hand count. Separate cache cards, cards in discard and cards still in a deck are excluded. Shield and Shield Snooper are different names. A card retained in hand after battle still counts.',
|
||||
'The shared canonical name list includes the three independent Ecaz cards as well as ordinary/Ix and separate Richese cache identities. Naming one does not select its family, expose its physical ID or change its battle role.',
|
||||
'The server establishes the truthful Yes or No privately for the target. Their public answer reveals only the requested comparison, not card identities or the rest of their hand. An exact-count question answered Yes naturally confirms that count.',
|
||||
'Combine a card count with another count, named-card possession, traitor or spice fact using AND or OR. Only the combined answer is published. The answer describes current custody; it does not require the player to keep those cards afterward.',
|
||||
'All four AI levels answer the structured comparison. A definite answer discards Truthtrance once and resumes the interrupted table decision. This question type does not complete arbitrary facts or nonbattle future promises.',
|
||||
@@ -2917,6 +2918,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Freeform questions also accept public Yes, No, or I don’t know answers. Their truthfulness relies on the players. AI answers structured facts and battle-plan questions; it cannot yet interpret freeform questions.',
|
||||
'An answer may bind actions or decisions during the current turn only. The answering player must do everything in their power to comply; if compliance later becomes impossible, the answer is no longer binding. Freeform promises are recorded in the table’s turn history. Arbitrary freeform enforcement and broader action commitments remain unfinished. Structured reserve-shipment promises are enforced for the active unused shipment in base Basic and no-Guild Advanced games; see the shipment promise guide.',
|
||||
'Battle-plan questions can bind dial values or ranges, named weapon/defense slots, a leader or either Cheap Hero, spice support and Kwisatz use. AND/OR combinations preserve legal alternatives. Only answers with a legal completion now or through available Ghola preparation are offered; a sealed plan fixes its answer. Previous battle commitments, Voice and prescience also apply. Private preparation guidance can play a required Ghola, then a compliant-plan example can fill the form for review before sealing.',
|
||||
'Named conditions include Reinforcements and Harass slot occupancy without making either a weapon/defense category. In clean classic Homeworlds, a source-aware legal completion can choose the native fee needed for a promised dial; review and filling retain that exact allocation. Active preview, answers/history and server records use the actual combat location name, including native Worlds, rather than treating every battle as an Arrakis territory.',
|
||||
'If a completed opposing power makes a battle answer impossible, it is publicly released. A pending Voice response resolves before that release. A player cannot voluntarily spend a promised battle card or give away necessary support spice to evade the answer. Feasibility includes available Ghola cards, eligible leader/Kwisatz or force revivals, and possible revival income. Each actual card can be spent only once. Other future card/power sequences and the full timing audit remain unfinished.',
|
||||
'Holding both copies lets you declare one or both. Each question resolves separately, and each card is discarded only after its definite answer. Full timing and card-face audits remain pending.',
|
||||
],
|
||||
@@ -2929,6 +2931,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'truthtrance-hand-inventory',
|
||||
'truthtrance-shipment',
|
||||
'richese-no-field',
|
||||
'card-reinforcements',
|
||||
],
|
||||
},
|
||||
...FACTIONS.map(
|
||||
@@ -4058,7 +4061,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'Homeworlds are an optional module from Ecaz & Moritani, available under the official rules with Basic or Advanced play. They are separate locations outside Arrakis; they do not receive ordinary storm movement or count as ordinary strongholds.',
|
||||
'Fresh private Homeworld prototypes retain all fourteen Skills, Discovery7/8 or both in classic or supported E1/E2 and standalone Ecaz OR Moritani tables. Original Nexus entries additionally support single or paired one-family E1/E2, natives from BOTH selected Ix+CHOAM families with union47, and standalone OR paired Ecaz/Moritani/ecaz33 with classics, with or without Skills. Basic/Advanced2–6, Tech3+ and Advanced Strongholds2+ retain original setup, native placement, phase-end industry and source-local held cards. Advanced Moritani/paired E3 excludes Harkonnen; otherwise legal standalone no-Skills Ecaz/Harkonnen does not lift its separate Duke Cunning exclusion. Winning typed native Suk saves stay at that home; external Emperor returns allocate physical normal/elite counters to original homes as the existing interpretation. Normal revival separately deposits Sardaukar at Salusa. Nested battles receive no Homeworld bonus. Mixed E3/three-family Nexus, pending effects, public starts and played-save conversion remain separate.',
|
||||
'Each faction uses its own card and matching world token. In Basic play the Emperor uses Kaitain alone. Advanced play adds Salusa Secundus for its Sardaukar; movement can transfer both force types between the two Emperor worlds.',
|
||||
'A fresh classic Basic/Advanced independent36-card/Homeworld prototype connects Harass on Arrakis or a foreign World, never an own World, with exact typed native returns and fixed original commitment/support. Recruits doubles current native rates, including low-population bonuses, within the seven-force total; separate requests provisionally recheck thresholds and recorded free use. Native deposits and special-force caps remain. Reinforcements, expansion rosters and other overlays keep their separate guards.',
|
||||
'Fresh classic Basic/Advanced independent36-card/Homeworld setup connects all three bounded effects: Harass returns actual typed undialed forces through native custody, never on an own World; Recruits doubles current native rates with provisional separate-request thresholds; Reinforcements seals exact native reserve fee sources and excludes an on-World fee from physical dial/casualties while preserving original face/support. Other rosters/overlays, whole-mode acceptance and public starts remain guarded.',
|
||||
'Native reserves determine high or low population. Salusa counts Sardaukar, while the other cards count the faction’s reserves there. Reaching the high face’s minimum restores high population. Occupation also applies the low penalty.',
|
||||
'Low population normally grants one additional free force revival and one additional bank spice when collecting CHOAM Charity. Salusa grants neither bonus; CHOAM’s Tupile reverses the usual pattern by granting an advantage while low and a penalty while high.',
|
||||
'A qualifying charity claim separates its ordinary payer from the extra bank spice. Inflation doubles both portions or prevents the whole collection. Canceling Bene Gesserit charity prevents both portions of that claim.',
|
||||
@@ -4977,7 +4980,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
],
|
||||
related: ['revival', 'homeworlds', 'ecaz-modules', 'expansion-faction-games', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Independent physical play/discard and public turn rates use shared revival quotes, now including clean classic Homeworlds and native typed deposits. Separate requests provisionally recompute current thresholds; prior paid returns retain payment and recorded free use. Unknown ledgers, pending repricing and later Fremen grants remain guarded; Reinforcements retains its own World gate.', evidence: ['game/ecaz-cards.ts', 'game/recruits.ts', 'game/revival.ts', 'game/engine.ts'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Independent physical play/discard and public turn rates use shared revival quotes, including clean classic Homeworlds and native typed deposits. Separate requests provisionally recompute current thresholds; prior paid returns retain payment and recorded free use. Unknown ledgers, pending repricing and later Fremen grants remain guarded; Reinforcements has its separate exact native-source fee composition.', evidence: ['game/ecaz-cards.ts', 'game/recruits.ts', 'game/revival.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Implemented', detail: 'The owned physical card has full inspection, a Revival action and explicit unavailable reasons. All seats see the effective public rates after play.', evidence: ['components/recruits.tsx', 'components/game-table.tsx', 'game/card-presentation.ts'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'All four profiles use the same legal owned offer for useful own or allied revival, then existing authoritative revival choices. Complete card strategy and calibration remain unfinished.', evidence: ['game/bots.ts'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'The guide records examples, limits, independent-cap inference and the visibly provisional late-paid policy; source alternatives and late-rate questions remain explicit.', evidence: ['docs/RECRUITS_RULES.md', 'docs/RECRUITS_RUNTIME.md'] },
|
||||
@@ -4997,14 +5000,16 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'The normal-outcome score gains 2 without adding to the physical dial, spice support or on-board casualties. The card and its reserve cost are consumed even on a Traitor victory, a mutual Traitor result or a Lasgun/Shield explosion; successful Traitor calls still decide the outcome directly.',
|
||||
'All-outcome payment, normal-first selection and reserve transfer counting as battle losses remain provisional interpretations, not publisher rulings. Same-plan Harass pays the original three-reserve cost before returns; returned reserves cannot fund or change it. In native Advanced Occupy, original or copied Stone ignores +2 normal score but still charges the reserve cost. Basic/unprofiled co-side and other profile/module limits remain guarded.',
|
||||
'With your own concealed No-Field, the provisional native order materializes its reserve-limited counters before this cost. At least three own reserves must remain after that reveal, before any future Harass return; otherwise Reinforcements is unavailable before the first plan seals. This checks only the actual card user, never its ally’s marker or reserves.',
|
||||
'Clean classic2–6 Basic/Advanced Homeworlds use exact native normal-first fee sources, sealed in the plan. Ambiguous omissions reject; unique sources may derive. An own-World fee cannot also fund physical dial/casualties, but original printed face/support remains. Original high-face explosion losses survive a fee crossing low. Native-only Traitors, typed Tanks and cost-before-Harass remain; this is explicit provisional composition, not a new card FAQ.',
|
||||
'One canonical physical-name registry now includes the independent cards for current facts and named battle conditions. Naming Reinforcements in a weapon slot does not make it a weapon category. Native questions use actual combat-location labels. Source-aware legal completion, private full-plan/read-only review and the fill button retain exact native sources.',
|
||||
],
|
||||
related: ['battle-cards', 'battle', 'card-recruits', 'card-harass-withdraw', 'implementation-checklist'],
|
||||
related: ['battle-cards', 'battle', 'homeworlds', 'card-recruits', 'card-harass-withdraw', 'card-truthtrance', 'implementation-checklist'],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Physical card, both slots, own typed reserve cost, score-only bonus and mandatory discard connect in supported profiles. Same-plan Harass preserves the original reserve quote before return; selected lead cards/payment and variable ally casualties stay separate.', evidence: ['game/reinforcements.ts', 'game/battle-resolution-quote.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Owner-only availability, exact cost guidance and both battle selectors support the card; private category answers remain None.', evidence: ['components/battle-preparation.tsx', 'components/game-table.tsx'] },
|
||||
{ area: 'Implementation', status: 'Partial', detail: 'Physical slots, typed reserve cost, score-only bonus and discard connect. Clean classic World plans seal exact native sources, excluding own-World fees from the physical pool while keeping original traits/printed face. Shared legality and named questions retain source-bearing completions. Other profile/module gates remain.', evidence: ['game/reinforcements.ts', 'game/reinforcements-homeworld.ts', 'game/homeworld-native-reserves.ts', 'game/battle-resolution-quote.ts', 'game/battle-promises.ts', 'game/truthtrance-card-names.ts', 'game/truthtrance.ts', 'game/engine.ts'] },
|
||||
{ area: 'Player controls', status: 'Partial', detail: 'Owner-only cost/source choices, own-World physical bounds, wrapped exact allocation, source-aware legal-plan fill and earned private reviews connect; category answers remain None. Native named-question preview/answer/history uses canonical combat labels.', evidence: ['components/battle-preparation.tsx', 'components/reinforcements-sources.tsx', 'components/private-battle-plan.tsx', 'components/battle-promises.tsx', 'components/truthtrance.tsx', 'components/game-table.tsx'] },
|
||||
{ area: 'AI', status: 'Partial', detail: 'Four profiles consider legal physical slot candidates and the normal score bonus; strategy and calibration remain unfinished.', evidence: ['game/bots.ts'] },
|
||||
{ area: 'Documentation', status: 'Partial', detail: 'The printed effect and unresolved Traitor, explosion, type-selection and accounting interpretations are distinguished from this temporary prototype.', evidence: ['docs/ECAZ_TREACHERY_RULES.md', 'docs/REINFORCEMENTS_RUNTIME.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Original typed cost, Stone/Mirror and same-plan Harass proof now includes actual No-Field first-plan funding rejection, legal fallback, zero/funded costs, Traitor cancellation and actual Occupy owners. Mobile and four-profile continuations are bounded; complete modes/calibration/final assurance remain open.', evidence: ['tests/no-field-reinforcements.test.ts', 'tests/fixture-no-field-reinforcements.ts', 'tests/stone-reinforcements.test.ts', 'tests/harass-reinforcements.test.ts', 'tests/reinforcements.test.ts', 'tests/reinforcements-engine.test.ts', 'tests/ecaz-occupy-cards-runtime.test.ts', 'docs/ECAZ_OCCUPY_RULES.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Native actual setup/shipment/movement covers both slots, normal/mixed/star fees, deficient funding, each source allocation, own-World disjointness, frozen face/explosion, native Traitors, Harass and named promises. Six actual mobile journeys reach Collection;32 four-policy programmes/114 continuations and1682 engine-validated R candidates are separate bounded proof, not complete modes or calibration.', evidence: ['tests/reinforcements-homeworld.test.ts', 'tests/fixture-reinforcements-homeworld.ts', 'tests/reinforcements-homeworld-arrakis.test.ts', 'tests/fixture-reinforcements-homeworld-arrakis.ts', 'tests/no-field-reinforcements.test.ts', 'tests/fixture-no-field-reinforcements.ts', 'tests/stone-reinforcements.test.ts', 'tests/harass-reinforcements.test.ts', 'tests/reinforcements.test.ts', 'tests/reinforcements-engine.test.ts', 'tests/ecaz-occupy-cards-runtime.test.ts', 'docs/ECAZ_OCCUPY_RULES.md'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -5023,7 +5028,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'In explicitly selected Advanced Occupy, only the actual card user’s undialed forces return. An Ecaz user preserves its original mandatory fixed count at free full strength; an allied user uses its native variable commitment, while canceled Occupy uses its selected lead’s ordinary own pool. Returned reserves remain outside losses and Face Dance. Basic/unprofiled co-side, public starts and full acceptance remain gated.',
|
||||
'In supported same-plan Reinforcements, the provisional order pays three original own reserves before Harass returns undialed counters. Future returns cannot fund or change that reserve quote; neither the +2 score bonus nor that cost adds to the physical commitment. Both cards discard after use. An opposing successful Traitor cancels Harass but not the provisional Reinforcements cost.',
|
||||
'With an own concealed No-Field, this prototype provisionally uses the original materialization-first order. Before sealing, privately forecast its actual ordinary counters at the original marker sector; do not move them early. Both plans seal before native revelation, then any return allocation uses actual counters. Marker0 remains legal with a leader/cards, reserve shortage caps materialization, and withdrawal does not return/reset the token. Opposing successful Traitor cancels Harass, not that native reveal.',
|
||||
'Clean classic2–6 Basic/Advanced Homeworlds may be selected before independent36-card setup. Harass works on Arrakis or a foreign Homeworld, never any own World. Return actual undialed board/visitor counters through native custody, not Tanks: Basic primary; Advanced Emperor normal to Kaitain/Sardaukar to Salusa. Original dial/support and committed counters stay fixed across threshold changes. Native-only Traitors, late defense/explosion and mandatory disposal remain. Recruits has a separate native current-rate composition; Reinforcements and other overlays retain guards.',
|
||||
'Clean classic2–6 Basic/Advanced Homeworlds may be selected before independent36-card setup. Harass works on Arrakis or a foreign Homeworld, never any own World. Return actual undialed board/visitor counters through native custody, not Tanks: Basic primary; Advanced Emperor normal to Kaitain/Sardaukar to Salusa. Original dial/support and committed counters remain fixed across thresholds. Native-only Traitors, late defense/explosion and disposal stay. Recruits has native current rates; Reinforcements seals exact original native fees before these returns. Other overlays/rosters remain guarded.',
|
||||
],
|
||||
related: ['battle-cards', 'battle', 'card-recruits', 'homeworlds', 'ecaz-modules', 'implementation-checklist'],
|
||||
checklist: [
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { CombatForces } from './combat';
|
||||
import {
|
||||
homeworldForceGroups, type HomeworldCustody, type HomeworldCustodyContext,
|
||||
type HomeworldForces,
|
||||
} from './homeworld-custody';
|
||||
import {
|
||||
NativeReserveError, quoteNativeReserveWithdrawal, type NativeReserveSelections,
|
||||
} from './homeworld-native-reserves';
|
||||
import { quoteReinforcements, ReinforcementsError } from './reinforcements';
|
||||
|
||||
type Cost = ReturnType<typeof quoteReinforcements>;
|
||||
function nativeCost(context: HomeworldCustodyContext, actor: string): Cost {
|
||||
const seat = context.players.find(player => player.id === actor);
|
||||
if (!seat) throw new NativeReserveError('Reinforcements needs its seated native reserve owner.');
|
||||
return quoteReinforcements(seat.reserves - seat.eliteReserves, seat.eliteReserves);
|
||||
}
|
||||
function sourceOptions(context: HomeworldCustodyContext, custody: HomeworldCustody,
|
||||
actor: string, cost: Cost): NativeReserveSelections[] {
|
||||
const homes = homeworldForceGroups(context, custody).filter(home => home.native === actor);
|
||||
if (homes.length === 1) return [{ [homes[0].id]: { normal: cost.normal, elite: cost.elite } }];
|
||||
if (homes.length !== 2) throw new NativeReserveError('Choose the actual native Homeworld reserve sources.');
|
||||
const [first, second] = homes;
|
||||
const firstPool = first.forces[actor], secondPool = second.forces[actor];
|
||||
const options: NativeReserveSelections[] = [];
|
||||
// At most six allocations for a three-counter typed cost. Both native World
|
||||
// entries remain explicit, including zero, so clients never guess a split.
|
||||
for (let normal = Math.max(0, cost.normal - secondPool.normal);
|
||||
normal <= Math.min(cost.normal, firstPool.normal); normal++) {
|
||||
for (let elite = Math.max(0, cost.elite - secondPool.elite);
|
||||
elite <= Math.min(cost.elite, firstPool.elite); elite++) {
|
||||
options.push({
|
||||
[first.id]: { normal, elite },
|
||||
[second.id]: { normal: cost.normal - normal, elite: cost.elite - elite },
|
||||
});
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/** Public physical alternatives for the existing normal-first fee. Enumeration
|
||||
* does not select a source, move counters or grant permission to play a card. */
|
||||
export function reinforcementsHomeworldSources(context: HomeworldCustodyContext,
|
||||
custody: HomeworldCustody, actor: string): NativeReserveSelections[] {
|
||||
return sourceOptions(context, custody, actor, nativeCost(context, actor));
|
||||
}
|
||||
|
||||
/** Exact native withdrawal; the caller commits matching Tanks and observes
|
||||
* population only after this whole fee. Ambiguous sources must be declared. */
|
||||
export function quoteReinforcementsHomeworldCost(context: HomeworldCustodyContext,
|
||||
custody: HomeworldCustody, actor: string, selections?: NativeReserveSelections) {
|
||||
const cost = nativeCost(context, actor);
|
||||
let sources = selections;
|
||||
if (sources === undefined) {
|
||||
const options = sourceOptions(context, custody, actor, cost);
|
||||
if (options.length !== 1)
|
||||
throw new NativeReserveError('Choose the Homeworld sources for Reinforcements; more than one allocation is possible.');
|
||||
sources = options[0];
|
||||
}
|
||||
const native = quoteNativeReserveWithdrawal(context, custody, actor,
|
||||
{ normal: cost.normal, elite: cost.elite }, sources);
|
||||
return { ...cost, sources, native };
|
||||
}
|
||||
|
||||
/** On an own World the reserve fee and combat army share physical counters.
|
||||
* Reserve the fee first, but retain the original sealed support/strength traits.
|
||||
* Foreign-World and Arrakis callers pass no on-location cost. */
|
||||
export function reinforcementsBattleForces(forces: CombatForces,
|
||||
onLocationCost?: HomeworldForces): CombatForces {
|
||||
if (!onLocationCost) return forces;
|
||||
const { normal, elite } = onLocationCost;
|
||||
if (!Number.isSafeInteger(normal) || normal < 0 || !Number.isSafeInteger(elite) || elite < 0 ||
|
||||
normal > forces.normal || elite > forces.elite)
|
||||
throw new ReinforcementsError('The reserve fee cannot also fund the physical Homeworld dial.');
|
||||
if (normal + elite === 0) return forces;
|
||||
return { ...forces, normal: forces.normal - normal, elite: forces.elite - elite };
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { treacheryDeck } from './cards';
|
||||
import { richeseCards } from './richese-cards';
|
||||
import { ecazTreacheryCards } from './ecaz-cards';
|
||||
|
||||
/** Known physical names, not permission to deal/play an unselected family.
|
||||
* Current hand facts and named battle-slot promises share this registry. */
|
||||
export const TRUTH_CARD_NAMES = [
|
||||
...new Set([...treacheryDeck(['ix']), ...richeseCards(), ...ecazTreacheryCards()].map(card => card.name)),
|
||||
].sort();
|
||||
+6
-8
@@ -18,7 +18,6 @@ import {
|
||||
cardCountFactText,
|
||||
type CardCountFact,
|
||||
} from './truthtrance-card-count';
|
||||
import { richeseCards } from './richese-cards';
|
||||
import {
|
||||
parseHandInventoryFact,
|
||||
handInventoryFactMatches,
|
||||
@@ -30,10 +29,11 @@ import {
|
||||
planClaimText,
|
||||
type PlanClaim,
|
||||
} from './battle-promises';
|
||||
import { gameTerritories, territory } from './board';
|
||||
import { gameTerritories } from './board';
|
||||
import { combatLocationName } from './combat-location';
|
||||
import { parseForceCountFact, forceCountFactMatches, forceCountFactText, forceCountCounterError, type ForceCountFact, type ForceCountPlayer } from './truthtrance-force-count';
|
||||
import type { Action, Game, Player } from './engine';
|
||||
import { treacheryDeck } from './cards';
|
||||
import { TRUTH_CARD_NAMES } from './truthtrance-card-names';
|
||||
import { CHEAP_HERO_TRAITOR } from './traitors';
|
||||
import { DUKE_VIDAL_ID } from './duke-vidal';
|
||||
import { canUseAsTruthtranceRole } from './shrine';
|
||||
@@ -85,9 +85,6 @@ export class TruthError extends Error {}
|
||||
function check(value: unknown, message: string): asserts value {
|
||||
if (!value) throw new TruthError(message);
|
||||
}
|
||||
export const TRUTH_CARD_NAMES = [
|
||||
...new Set([...treacheryDeck(['ix']), ...richeseCards()].map((c) => c.name)),
|
||||
].sort();
|
||||
const record = (value: unknown): Record<string, unknown> => {
|
||||
check(
|
||||
value && typeof value === 'object' && !Array.isArray(value),
|
||||
@@ -400,12 +397,13 @@ export function truthShipmentClaim(
|
||||
export function truthQuestionText(
|
||||
q: TruthQuestion,
|
||||
leaderName: (id: string) => string,
|
||||
locationName: (id: string) => string,
|
||||
): string {
|
||||
if (q.kind === 'freeform') return q.text;
|
||||
if (q.kind === 'shipment')
|
||||
return `Will you ${shipmentClaimText(q)} this turn?`;
|
||||
if (q.kind === 'battlePlan')
|
||||
return `In this battle in ${territory(q.territory).name}, will it be true that ${planClaimText(q.claim, leaderName)}?`;
|
||||
return `In this battle in ${locationName(q.territory)}, will it be true that ${planClaimText(q.claim, leaderName)}?`;
|
||||
const clause = (f: TruthFact): string => {
|
||||
if (isKnowledgeFact(f)) return knowledgeFactText(f);
|
||||
if (f.kind === 'noFieldValue') return noFieldValueFactText(f);
|
||||
@@ -564,7 +562,7 @@ export function resolveTruthAction(
|
||||
)!;
|
||||
effects.log(
|
||||
g,
|
||||
`${p.name} asks ${target.name}: ${truthQuestionText(w.question, (id) => (id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine' : battleLeaderIdentities(g).find((leader) => leader.id === id)!.name))}`,
|
||||
`${p.name} asks ${target.name}: ${truthQuestionText(w.question, (id) => (id === CHEAP_HERO_TRAITOR ? 'Cheap Hero / Heroine' : battleLeaderIdentities(g).find((leader) => leader.id === id)!.name), id => combatLocationName(g, id))}`,
|
||||
);
|
||||
} else if (a.type === 'truthSave') {
|
||||
check(
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
applyAction, createGame, initializeEcazTreacheryGameForAudit, joinGame,
|
||||
newPlayer, viewGame, type Game,
|
||||
} from '../game/engine';
|
||||
import { territory } from '../game/board';
|
||||
import { type NativeReserveSelections } from '../game/homeworld-native-reserves';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { traitorDeck } from '../game/traitors';
|
||||
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||||
import { HARASS_WITHDRAW_CARD, type HarassWithdrawSelection } from '../game/harass-withdraw';
|
||||
import {
|
||||
advanceHarassHomeworldArrakis, createHarassHomeworldArrakisFixture,
|
||||
harassHomeworldArrakisClean, harassHomeworldArrakisPlayer,
|
||||
nextHarassHomeworldArrakisStep, type HarassHomeworldArrakisStep,
|
||||
} from './fixture-harass-homeworld-arrakis';
|
||||
import { finishHarassPreparation, takeHarassCard } from './fixture-harass-withdraw';
|
||||
import { assertRecruitsHomeworldInventory } from './fixture-recruits-homeworld';
|
||||
|
||||
export type ReinforcementsHomeworldArrakisStep = HarassHomeworldArrakisStep;
|
||||
export type ReinforcementsHomeworldArrakisOptions = {
|
||||
advanced?: boolean;
|
||||
slot?: 'weapon' | 'defense';
|
||||
/** Emperor ships 3N4E; Fremen free-ship 6N, 7N or 7N1E respectively. */
|
||||
reserves?: 'normal' | 'mixed' | 'elite' | 'shortage';
|
||||
outcome?: 'winner' | 'loser';
|
||||
harass?: boolean;
|
||||
traitor?: 'owner' | 'opponent' | 'both';
|
||||
explosion?: boolean;
|
||||
seatIds?: [string, string];
|
||||
/** Fresh undealt classic Emperor/Guild or Fremen/Guild lobby. */
|
||||
initial?: Game;
|
||||
};
|
||||
export type ReinforcementsHomeworldArrakisFixture = {
|
||||
initial: Game;
|
||||
afterSetup: Game;
|
||||
beforeBattle: Game;
|
||||
game: Game;
|
||||
owner: string;
|
||||
opponent: string;
|
||||
territory: string;
|
||||
location: string;
|
||||
planActions: [ReinforcementsHomeworldArrakisStep, ReinforcementsHomeworldArrakisStep];
|
||||
battleStep: ReinforcementsHomeworldArrakisStep;
|
||||
shipments: { before: Game; after: Game; step: ReinforcementsHomeworldArrakisStep }[];
|
||||
nativeSteps: ReinforcementsHomeworldArrakisStep[];
|
||||
staging: string[];
|
||||
fee: { normal: number; elite: number };
|
||||
sources: NativeReserveSelections;
|
||||
};
|
||||
export const reinforcementsHomeworldArrakisPlayer = harassHomeworldArrakisPlayer;
|
||||
export const reinforcementsHomeworldArrakisClean = harassHomeworldArrakisClean;
|
||||
|
||||
/** All counters remain in original native homes, original Arrakis armies or
|
||||
* Tanks. This programme has no visitors. Includes the original three Fedaykin
|
||||
* (not five invented stars), Sardaukar, physical 36-card deck and Traitor pack. */
|
||||
export function assertReinforcementsHomeworldArrakisInventory(game: Game): void {
|
||||
assertRecruitsHomeworldInventory(game);
|
||||
assert.deepEqual(game.homeworlds!.custody!.visitors, {});
|
||||
assert.deepEqual([...game.traitorReserve!, ...game.players.flatMap(p => [...p.traitors, ...p.traitorChoices])].sort(),
|
||||
traitorDeck(game.players).sort());
|
||||
}
|
||||
|
||||
function initialize(game: Game): Game {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
|
||||
assert.ok(array); new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255); return array;
|
||||
};
|
||||
try { return initializeEcazTreacheryGameForAudit(game); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
|
||||
function exchangeTraitor(game: Game, actor: string, identity: string, staging: string[]): void {
|
||||
const player = reinforcementsHomeworldArrakisPlayer(game, actor);
|
||||
if (player.traitors.includes(identity)) return;
|
||||
const returned = player.traitors[0]; assert.ok(returned);
|
||||
const index = game.traitorReserve!.indexOf(identity);
|
||||
const donor = game.players.find(p => p.id !== actor && p.traitors.includes(identity));
|
||||
assert.ok(index >= 0 || donor, 'Only exchange an original physical Traitor');
|
||||
if (index >= 0) game.traitorReserve![index] = returned;
|
||||
else donor!.traitors[donor!.traitors.indexOf(identity)] = returned;
|
||||
player.traitors[0] = identity;
|
||||
staging.push(`Conserved original Traitor ${identity} exchanged with ${returned} into ${actor}'s hand.`);
|
||||
}
|
||||
|
||||
function freshFremenProgramme(options: ReinforcementsHomeworldArrakisOptions) {
|
||||
const ids = options.seatIds ?? ['fremen', 'guild'];
|
||||
let game = options.initial ? structuredClone(options.initial) :
|
||||
createGame('REINFORCEMENTSWORLDARRAKIS', newPlayer(ids[0], 'Fremen', 'fremen'), options.advanced ?? false);
|
||||
if (!options.initial) joinGame(game, newPlayer(ids[1], 'Guild', 'guild'));
|
||||
assert.equal(game.status, 'lobby');
|
||||
assert.deepEqual(game.expansions, []);
|
||||
assert.deepEqual(game.players.map(p => p.faction).sort(), ['fremen', 'guild']);
|
||||
assert.equal(game.advanced, options.advanced ?? game.advanced);
|
||||
const nativeSteps: ReinforcementsHomeworldArrakisStep[] = [];
|
||||
const act = (step: ReinforcementsHomeworldArrakisStep) => {
|
||||
nativeSteps.push(structuredClone(step)); game = applyAction(game, step.actor, step.action);
|
||||
};
|
||||
if (!game.homeworlds) act({ actor: game.host, action: { type: 'homeworlds', enabled: true } });
|
||||
for (const p of game.players) if (!p.ready) act({ actor: p.id, action: { type: 'ready' } });
|
||||
const initial = structuredClone(game);
|
||||
game = initialize(game);
|
||||
for (let n = 0; n < 180 && game.status !== 'playing'; n++) {
|
||||
const step = nextHarassHomeworldArrakisStep(game); assert.ok(step);
|
||||
if (step.action.type === 'fremenSetup') step.action = { type: 'fremenSetup',
|
||||
placements: { [`sietch_tabr:${territory('sietch_tabr').sectors[0]}`]: 10 }, elitePlacements: {} };
|
||||
act(step);
|
||||
}
|
||||
assert.equal(game.status, 'playing', 'Finish every original setup choice');
|
||||
const afterSetup = structuredClone(game);
|
||||
const owner = game.players.find(p => p.faction === 'fremen')!.id;
|
||||
const opponent = game.players.find(p => p.faction === 'guild')!.id;
|
||||
const staging = ['Original classic Fremen/Guild Homeworld setup; only conserved hands and unopened Spice Cards are positioned. No counters, wallets, phases, history or receipts are assigned.'];
|
||||
// Keep turn-one Blow away from unrelated worm-riding decisions by positioning
|
||||
// original unopened non-worm Spice Cards, as in the native Recruits programme.
|
||||
for (let i = 0; i < (game.advanced ? 4 : 2); i++) {
|
||||
const at = game.spiceDeck.findIndex((card, index) => index >= i && 'territory' in card && !card.discovery);
|
||||
assert.ok(at >= 0); game.spiceDeck.splice(i, 0, game.spiceDeck.splice(at, 1)[0]);
|
||||
}
|
||||
staging.push('Position unopened original first-Blow non-worm Spice Cards; no draw, phase or army is fabricated.');
|
||||
const locationTerritory = 'sietch_tabr', sector = territory(locationTerritory).sectors[0];
|
||||
const location = `${locationTerritory}:${sector}`;
|
||||
const shipments: ReinforcementsHomeworldArrakisFixture['shipments'] = [];
|
||||
const waiting = new Set([owner, opponent]);
|
||||
const until = (predicate: (state: Game) => boolean) => {
|
||||
game = advanceHarassHomeworldArrakis(game, predicate, nativeSteps);
|
||||
};
|
||||
until(g => g.phase === 5 && harassHomeworldArrakisClean(g));
|
||||
while (waiting.size) {
|
||||
const actor = game.active!; assert.ok(waiting.has(actor));
|
||||
const isFremen = actor === owner;
|
||||
const amount = isFremen ? options.reserves === 'mixed' ? 6 : options.reserves === 'shortage' ? 8 : 7
|
||||
: options.outcome === 'loser' ? 3 : 2;
|
||||
const elite = isFremen && options.reserves === 'shortage' ? 1 : 0;
|
||||
const sources = nativeShipmentSources(viewGame(game, actor), amount, elite); assert.ok(sources);
|
||||
const step: ReinforcementsHomeworldArrakisStep = { actor, action: { type: 'ship',
|
||||
territory: locationTerritory, sector, amount, elite, homeworldSources: sources, allyPayment: 0 } };
|
||||
const before = structuredClone(game); act(step); until(harassHomeworldArrakisClean);
|
||||
shipments.push({ before, after: structuredClone(game), step });
|
||||
waiting.delete(actor); act({ actor, action: { type: 'endMovement' } });
|
||||
until(g => (g.phase === 5 || g.phase === 6) && harassHomeworldArrakisClean(g));
|
||||
}
|
||||
until(g => g.phase === 6 && harassHomeworldArrakisClean(g));
|
||||
const beforeBattle = structuredClone(game);
|
||||
const choice = viewGame(game, game.active!).battleChoices.find(c => c.territory === locationTerritory); assert.ok(choice);
|
||||
const battleStep: ReinforcementsHomeworldArrakisStep = { actor: choice.chooser, action: {
|
||||
type: 'chooseBattle', territory: locationTerritory,
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
|
||||
act(battleStep); game = finishHarassPreparation(game);
|
||||
return { initial, afterSetup, beforeBattle, game, owner, opponent, territory: locationTerritory,
|
||||
location, battleStep, shipments, nativeSteps, staging };
|
||||
}
|
||||
|
||||
/** Real turn-one native setup, shipment and battle admission. Emperor uses the
|
||||
* existing genuine 3N4E Tuek programme. Fremen free-shipping removes real normal
|
||||
* reserves and leaves real Fedaykin in their native World; never assign a pool.
|
||||
* All physical hand/Traitor staging is explicit and conserved. */
|
||||
export function createReinforcementsHomeworldArrakisFixture(
|
||||
options: ReinforcementsHomeworldArrakisOptions = {},
|
||||
): ReinforcementsHomeworldArrakisFixture {
|
||||
const base = options.reserves && options.reserves !== 'normal' ? freshFremenProgramme(options) :
|
||||
createHarassHomeworldArrakisFixture({ advanced: options.advanced, initial: options.initial,
|
||||
seatIds: options.seatIds, outcome: options.outcome, slot: options.slot });
|
||||
const { owner, opponent, location } = base;
|
||||
const game = base.game;
|
||||
const staging = [...base.staging];
|
||||
for (const p of game.players) { game.deck.push(...p.hand); p.hand = []; }
|
||||
takeHarassCard(game, owner, REINFORCEMENTS_CARD);
|
||||
if (options.harass) takeHarassCard(game, owner, HARASS_WITHDRAW_CARD);
|
||||
staging.push(`After genuine shipments/battle admission, conserve original hands into the remaining deck and place physical Reinforcements${options.harass ? ' plus Harass & Withdraw' : ''} into ${owner}'s hand.`);
|
||||
const own = reinforcementsHomeworldArrakisPlayer(game, owner);
|
||||
const other = reinforcementsHomeworldArrakisPlayer(game, opponent);
|
||||
const ownLeader = own.leaders.filter(l => !l.dead).sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id))[0];
|
||||
const otherLeader = other.leaders.filter(l => !l.dead).sort((a, b) => b.strength - a.strength || a.id.localeCompare(b.id))[0];
|
||||
if (options.traitor === 'owner' || options.traitor === 'both') exchangeTraitor(game, owner, otherLeader.id, staging);
|
||||
if (options.traitor === 'opponent' || options.traitor === 'both') exchangeTraitor(game, opponent, ownLeader.id, staging);
|
||||
const normal = Math.min(3, own.reserves - (own.elites?.reserves ?? 0));
|
||||
const fee = { normal, elite: 3 - normal };
|
||||
const sources: NativeReserveSelections = { [`homeworld:${own.faction}`]: { ...fee } };
|
||||
const slot = options.slot ?? 'weapon';
|
||||
const planActions: ReinforcementsHomeworldArrakisFixture['planActions'] = [
|
||||
{ actor: owner, action: { type: 'battlePlan', dial: 2, support: game.advanced && own.faction !== 'fremen' ? 1 : 0,
|
||||
leader: ownLeader.id, weapon: slot === 'weapon' ? REINFORCEMENTS_CARD : options.harass ? HARASS_WITHDRAW_CARD : null,
|
||||
defense: slot === 'defense' ? REINFORCEMENTS_CARD : options.harass ? HARASS_WITHDRAW_CARD : null,
|
||||
reinforcementsSources: sources } },
|
||||
{ actor: opponent, action: { type: 'battlePlan', dial: options.outcome === 'loser' ? 3 : 0,
|
||||
support: options.outcome === 'loser' && game.advanced ? 3 : 0, leader: otherLeader.id } },
|
||||
];
|
||||
if (options.explosion) {
|
||||
// The opposing side can naturally play its own Lasgun + Shield together,
|
||||
// leaving both of the utility user's physical slots available for R + H.
|
||||
for (const kind of ['lasgun', 'shield'] as const) {
|
||||
const card = game.deck.find(c => c.kind === kind); assert.ok(card);
|
||||
takeHarassCard(game, opponent, card.id);
|
||||
planActions[1].action[kind === 'lasgun' ? 'weapon' : 'defense'] = card.id;
|
||||
}
|
||||
staging.push('Conserve the original Lasgun and Shield from remaining deck into the opposing hand for an actual explosion plan.');
|
||||
}
|
||||
assertReinforcementsHomeworldArrakisInventory(game);
|
||||
return { ...base, game, planActions, staging, fee, sources, location };
|
||||
}
|
||||
|
||||
export function sealReinforcementsHomeworldArrakisPlan(game: Game, step: ReinforcementsHomeworldArrakisStep): Game {
|
||||
return finishHarassPreparation(applyAction(game, step.actor, step.action));
|
||||
}
|
||||
export function revealReinforcementsHomeworldArrakisBattle(fixture: ReinforcementsHomeworldArrakisFixture): Game {
|
||||
let game = structuredClone(fixture.game);
|
||||
for (const step of fixture.planActions) game = sealReinforcementsHomeworldArrakisPlan(game, step);
|
||||
assert.equal(game.battle?.revealed, true);
|
||||
return game;
|
||||
}
|
||||
|
||||
/** Keeps Harass allocation and typed losses explicit human choices; normal
|
||||
* native continuation handles actual calls, retention and mandatory disposal. */
|
||||
export function finishReinforcementsHomeworldArrakisBattle(state: Game,
|
||||
options: { callers?: string[]; returns?: HarassWithdrawSelection; losses?: { normal: number; elite: number } } = {},
|
||||
actions?: ReinforcementsHomeworldArrakisStep[]): Game {
|
||||
let game = state;
|
||||
for (let n = 0; n < 200; n++) {
|
||||
if (!game.battle && harassHomeworldArrakisClean(game)) return game;
|
||||
let step: ReinforcementsHomeworldArrakisStep | null;
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'harassWithdraw') {
|
||||
assert.ok(options.returns, 'Choose actual revealed physical Harass returns');
|
||||
step = { actor: game.decision.player, action: { type: 'decision', event: game.decision.event, returns: options.returns } };
|
||||
} else if (!game.response && !game.phaseOpening && game.decision?.kind === 'battleLosses' && options.losses) {
|
||||
const choice = game.decision.options.findIndex(c => c.normal === options.losses!.normal && c.elite === options.losses!.elite);
|
||||
assert.ok(choice >= 0, 'Selected typed casualties must be an actual original commitment');
|
||||
step = { actor: game.decision.player, action: { type: 'decision', choice } };
|
||||
} else step = nextHarassHomeworldArrakisStep(game);
|
||||
assert.ok(step);
|
||||
if (step.action.type === 'traitorCall') step.action.call = options.callers?.includes(step.actor) ?? false;
|
||||
actions?.push(structuredClone(step)); game = applyAction(game, step.actor, step.action);
|
||||
}
|
||||
throw Error('Native Arrakis Reinforcements aftermath did not finish');
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame, type Game } from '../game/engine';
|
||||
import { combatArmy } from '../game/combat-location';
|
||||
import type { NativeReserveSelections } from '../game/homeworld-native-reserves';
|
||||
import type { HomeworldForces } from '../game/homeworld-custody';
|
||||
import { nativeShipmentSources } from '../game/homeworld-options';
|
||||
import { homeworldShipmentChoice } from '../game/homeworld-shipment-options';
|
||||
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||||
import { takeHarassCard } from './fixture-harass-withdraw';
|
||||
import {
|
||||
actHarassHomeworld, advanceHarassHomeworld, assertHarassHomeworldCustody,
|
||||
createHarassHomeworldSetup, harassHomeworldClean, harassHomeworldPlayer,
|
||||
harassHomeworldPool, nextHarassHomeworldStep, sealHarassHomeworldPlan,
|
||||
type HarassHomeworldSetup, type HarassHomeworldStep,
|
||||
} from './fixture-harass-homeworld';
|
||||
|
||||
export { REINFORCEMENTS_CARD };
|
||||
export const REINFORCEMENTS_KAITAIN = 'homeworld:emperor';
|
||||
export const REINFORCEMENTS_SALUSA = 'homeworld:emperor:salusa';
|
||||
export type ReinforcementsHomeworldStep = HarassHomeworldStep;
|
||||
export type ReinforcementsHomeworldOptions = {
|
||||
advanced?: boolean;
|
||||
initial?: Game;
|
||||
seatIds?: [string, string, string];
|
||||
role?: 'visitor' | 'native';
|
||||
secondary?: boolean;
|
||||
slot?: 'weapon' | 'defense';
|
||||
outcome?: 'win' | 'loss' | 'traitor' | 'explosion';
|
||||
/** Real first-turn Kaitain → Salusa movement, never force staging. */
|
||||
nativeTransfer?: number;
|
||||
sources?: NativeReserveSelections;
|
||||
ownerDial?: number;
|
||||
ownerSupport?: number;
|
||||
};
|
||||
export type ReinforcementsHomeworldFixture = HarassHomeworldSetup & {
|
||||
options: ReinforcementsHomeworldOptions;
|
||||
role: 'visitor' | 'native';
|
||||
location: string;
|
||||
native: string;
|
||||
visitor: string;
|
||||
beforeNativeMove: Game;
|
||||
nativeMove: ReinforcementsHomeworldStep | null;
|
||||
afterNativeMove: Game;
|
||||
beforeShipment: Game;
|
||||
shipmentStep: ReinforcementsHomeworldStep;
|
||||
afterShipment: Game;
|
||||
beforeBattle: Game;
|
||||
battleStep: ReinforcementsHomeworldStep;
|
||||
game: Game;
|
||||
planActions: [ReinforcementsHomeworldStep, ReinforcementsHomeworldStep];
|
||||
sources: NativeReserveSelections;
|
||||
staging: string[];
|
||||
};
|
||||
export const reinforcementsHomeworldPlayer = harassHomeworldPlayer;
|
||||
export const reinforcementsHomeworldPool = harassHomeworldPool;
|
||||
export const assertReinforcementsHomeworldCustody = assertHarassHomeworldCustody;
|
||||
export const advanceReinforcementsHomeworld = advanceHarassHomeworld;
|
||||
export const reinforcementsHomeworldClean = harassHomeworldClean;
|
||||
export const sealReinforcementsHomeworldPlan = sealHarassHomeworldPlan;
|
||||
|
||||
/** Explicit normal-only fee alternatives; source keys are full native World IDs. */
|
||||
export function reinforcementsHomeworldSources(salusaNormal = 0): NativeReserveSelections {
|
||||
assert.ok(Number.isSafeInteger(salusaNormal) && salusaNormal >= 0 && salusaNormal <= 3);
|
||||
return {
|
||||
...(salusaNormal < 3 ? { [REINFORCEMENTS_KAITAIN]: { normal: 3 - salusaNormal, elite: 0 } } : {}),
|
||||
...(salusaNormal > 0 ? { [REINFORCEMENTS_SALUSA]: { normal: salusaNormal, elite: 0 } } : {}),
|
||||
};
|
||||
}
|
||||
function conserveTraitor(game: Game, actor: string, identity: string, staging: string[]): void {
|
||||
const player = harassHomeworldPlayer(game, actor);
|
||||
if (player.traitors.includes(identity)) return;
|
||||
const returned = player.traitors[0]; assert.ok(returned);
|
||||
const reserveIndex = game.traitorReserve!.indexOf(identity);
|
||||
const donor = game.players.find(p => p.id !== actor && p.traitors.includes(identity));
|
||||
assert.ok(reserveIndex >= 0 || donor, 'The original Traitor identity must have actual custody');
|
||||
if (reserveIndex >= 0) game.traitorReserve!.splice(reserveIndex, 1, returned);
|
||||
else donor!.traitors[donor!.traitors.indexOf(identity)] = returned;
|
||||
player.traitors[0] = identity;
|
||||
staging.push(`Conserved original Traitor ${identity}: ${donor?.id ?? 'undealt reserve'} exchanged for ${returned}.`);
|
||||
}
|
||||
|
||||
/** Original independent 36-card classic setup, native movement, paid typed
|
||||
* invasion and actual chooseBattle/preparation. Only conserved original hands,
|
||||
* Traitor identities and unopened Spice order are positioned and labeled.
|
||||
* No forces, wallet, phase, history, battle receipt or World face is assigned.
|
||||
* Fresh authenticated lobby/seat IDs are accepted for mobile and policy runs. */
|
||||
export function createReinforcementsHomeworldFixture(
|
||||
options: ReinforcementsHomeworldOptions = {},
|
||||
): ReinforcementsHomeworldFixture {
|
||||
const setup = createHarassHomeworldSetup(options);
|
||||
const role = options.role ?? 'visitor', outcome = options.outcome ?? 'loss';
|
||||
assert.ok(!options.secondary || role === 'native' && setup.afterSetup.advanced);
|
||||
assert.ok(outcome !== 'explosion' || options.slot !== 'weapon', 'Lasgun occupies the weapon slot');
|
||||
let game = structuredClone(setup.afterSetup);
|
||||
const staging: string[] = [];
|
||||
for (let i = 0; i < (game.advanced ? 4 : 2); i++) {
|
||||
const at = game.spiceDeck.findIndex((c, index) => index >= i && 'territory' in c && !c.discovery);
|
||||
assert.ok(at >= i); game.spiceDeck.splice(i, 0, game.spiceDeck.splice(at, 1)[0]);
|
||||
}
|
||||
staging.push('Position unopened original non-worm Spice Cards for the first native Spice Blow; no phase or storm assignment.');
|
||||
const act = (step: ReinforcementsHomeworldStep) => {
|
||||
game = actHarassHomeworld(game, step, setup.nativeSteps);
|
||||
};
|
||||
const reach = (until: (g: Game) => boolean) => {
|
||||
game = advanceHarassHomeworld(game, until, setup.nativeSteps);
|
||||
};
|
||||
reach(g => g.phase === 5 && g.active === setup.owner && harassHomeworldClean(g));
|
||||
let beforeNativeMove = structuredClone(game), afterNativeMove = structuredClone(game);
|
||||
let nativeMove: ReinforcementsHomeworldStep | null = null;
|
||||
const moveNative = () => {
|
||||
beforeNativeMove = structuredClone(game);
|
||||
if (game.advanced) {
|
||||
const normal = options.nativeTransfer ?? (role === 'visitor' || options.secondary ? 2 : 10);
|
||||
const movement = viewGame(game, setup.owner).homeworldMove; assert.ok(movement && !movement.blocked);
|
||||
nativeMove = { actor: setup.owner, action: { type: 'emperorHomeworldMove', event: movement.event,
|
||||
origin: REINFORCEMENTS_KAITAIN, normal, elite: 0 } };
|
||||
act(nativeMove);
|
||||
} else assert.equal(options.nativeTransfer ?? 0, 0, 'Basic has no inter-World movement');
|
||||
afterNativeMove = structuredClone(game);
|
||||
};
|
||||
// Moving begins movement and consumes the unused shipment opportunity.
|
||||
// A visiting Emperor must therefore invade first, then move his reserves.
|
||||
if (role === 'native') moveNative();
|
||||
const native = role === 'visitor' ? setup.opponent : setup.owner;
|
||||
const visitor = role === 'visitor' ? setup.owner : setup.opponent;
|
||||
const location = role === 'visitor' ? 'homeworld:guild'
|
||||
: options.secondary ? REINFORCEMENTS_SALUSA : REINFORCEMENTS_KAITAIN;
|
||||
reach(g => g.phase === 5 && g.active === visitor && harassHomeworldClean(g));
|
||||
const beforeShipment = structuredClone(game);
|
||||
const amount = role === 'visitor' ? 7 : 5, elite = role === 'visitor' ? 4 : 0;
|
||||
const shipmentSources = nativeShipmentSources(viewGame(game, visitor), amount, elite); assert.ok(shipmentSources);
|
||||
const choice = homeworldShipmentChoice(viewGame(game, visitor), location,
|
||||
Object.fromEntries(Object.entries(shipmentSources).filter(([, f]) => f.normal + f.elite > 0)));
|
||||
assert.ok(choice.action, choice.blocked ?? 'The original typed invasion is available');
|
||||
const shipmentStep = { actor: visitor, action: choice.action }; act(shipmentStep);
|
||||
reach(g => !g.pendingHomeworldShipment && !g.pendingShipment && harassHomeworldClean(g));
|
||||
const afterShipment = structuredClone(game);
|
||||
assert.deepEqual(combatArmy(game, visitor, location), role === 'visitor'
|
||||
? { normal: 3, elite: 4 } : { normal: 5, elite: 0 });
|
||||
if (role === 'visitor') moveNative();
|
||||
reach(g => g.phase === 6 && harassHomeworldClean(g) && !!g.active);
|
||||
for (const p of game.players) game.deck.push(...p.hand.splice(0));
|
||||
takeHarassCard(game, setup.owner, REINFORCEMENTS_CARD);
|
||||
let laser: string | undefined, shield: string | undefined;
|
||||
if (outcome === 'explosion') {
|
||||
laser = game.deck.find(c => c.kind === 'lasgun')!.id;
|
||||
shield = game.deck.find(c => c.kind === 'shield')!.id;
|
||||
takeHarassCard(game, setup.owner, laser); takeHarassCard(game, setup.opponent, shield);
|
||||
}
|
||||
staging.push('Return original hands to undealt stock, then conserve the physical Reinforcements and (explosion only) Lasgun/Shield into their owners’ hands; no card is manufactured.');
|
||||
const living = (actor: string) => harassHomeworldPlayer(game, actor).leaders.filter(l => !l.dead && !l.usedAt)
|
||||
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id));
|
||||
const ownLeader = outcome === 'win' ? living(setup.owner).at(-1)! : living(setup.owner)[0];
|
||||
const otherLeader = outcome === 'win' ? living(setup.opponent)[0] : living(setup.opponent).at(-1)!;
|
||||
if (outcome === 'traitor') conserveTraitor(game, native,
|
||||
native === setup.owner ? otherLeader.id : ownLeader.id, staging);
|
||||
const beforeBattle = structuredClone(game);
|
||||
const battleChoice = viewGame(game, game.active!).battleChoices.find(c => c.territory === location);
|
||||
assert.ok(battleChoice);
|
||||
const battleStep: ReinforcementsHomeworldStep = { actor: battleChoice.chooser,
|
||||
action: { type: 'chooseBattle', territory: location,
|
||||
target: battleChoice.attacker === battleChoice.chooser ? battleChoice.defender : battleChoice.attacker } };
|
||||
act(battleStep);
|
||||
reach(g => !!g.battle && harassHomeworldClean(g) && !g.battle.preparation && g.battle.preLeader?.closed !== false);
|
||||
assert.equal(Object.keys(game.battle!.plans).length, 0);
|
||||
const sources = structuredClone(options.sources ?? reinforcementsHomeworldSources());
|
||||
const dial = options.ownerDial ?? (role === 'visitor' || outcome === 'win' ? 2 : 0);
|
||||
const support = options.ownerSupport ?? (game.advanced ? dial : 0);
|
||||
const slot = options.slot ?? 'defense';
|
||||
const opponentSupport = game.advanced && outcome !== 'win' && outcome !== 'explosion'
|
||||
? Math.min(role === 'visitor' ? 3 : 5, harassHomeworldPlayer(game, setup.opponent).spice) : 0;
|
||||
const opponentDial = outcome === 'win' || outcome === 'explosion' ? 0 : role === 'visitor' ? 3
|
||||
: game.advanced ? (5 + opponentSupport) / 2 : 5;
|
||||
const planActions: ReinforcementsHomeworldFixture['planActions'] = [
|
||||
{ actor: setup.owner, action: { type: 'battlePlan', dial, support, leader: ownLeader.id,
|
||||
weapon: slot === 'weapon' ? REINFORCEMENTS_CARD : laser ?? null,
|
||||
defense: slot === 'defense' ? REINFORCEMENTS_CARD : null,
|
||||
reinforcementsSources: sources } },
|
||||
{ actor: setup.opponent, action: { type: 'battlePlan',
|
||||
dial: opponentDial, support: opponentSupport,
|
||||
leader: otherLeader.id, defense: shield ?? null } },
|
||||
];
|
||||
assertHarassHomeworldCustody(game);
|
||||
return { ...setup, options, role, location, native, visitor, beforeNativeMove, nativeMove, afterNativeMove,
|
||||
beforeShipment, shipmentStep, afterShipment, beforeBattle, battleStep, game, planActions, sources, staging };
|
||||
}
|
||||
export function revealReinforcementsHomeworldBattle(fixture: ReinforcementsHomeworldFixture): Game {
|
||||
let game = structuredClone(fixture.game);
|
||||
for (const step of fixture.planActions) game = sealHarassHomeworldPlan(game, step);
|
||||
assert.ok(game.battle?.revealed); return game;
|
||||
}
|
||||
/** Human-owned native Traitor/explosion choices remain explicit. Real mandatory
|
||||
* discard, typed winner casualties and legal aftercare use existing controls. */
|
||||
export function finishReinforcementsHomeworldBattle(game: Game, options: {
|
||||
callers?: string[];
|
||||
explosionLosses?: HomeworldForces;
|
||||
actions?: ReinforcementsHomeworldStep[];
|
||||
} = {}): Game {
|
||||
for (let n = 0; n < 240; n++) {
|
||||
if (!game.battle && !game.homeworldBattleLoss && harassHomeworldClean(game)) return game;
|
||||
let step: ReinforcementsHomeworldStep | null;
|
||||
if (!game.response && !game.phaseOpening && game.decision?.kind === 'homeworldExplosion' && options.explosionLosses) {
|
||||
const d = game.decision;
|
||||
const choice = d.options.findIndex(f => f.normal === options.explosionLosses!.normal && f.elite === options.explosionLosses!.elite);
|
||||
assert.ok(choice >= 0, 'Choose a real native typed explosion option');
|
||||
step = { actor: d.player, action: { type: 'decision', event: d.event, choice } };
|
||||
} else step = nextHarassHomeworldStep(game);
|
||||
assert.ok(step, 'Both genuine plans must already be sealed');
|
||||
if (step.action.type === 'traitorCall') step.action.call = options.callers?.includes(step.actor) ?? false;
|
||||
game = actHarassHomeworld(game, step, options.actions);
|
||||
}
|
||||
throw Error('Original Reinforcements Homeworld battle did not complete');
|
||||
}
|
||||
@@ -12,8 +12,6 @@ import {
|
||||
weaponKills,
|
||||
} from '../game/battle-cards';
|
||||
import { portableSnooperPlanBlock } from '../game/portable-snooper';
|
||||
import { parsePlanClaim } from '../game/battle-promises';
|
||||
import { TRUTH_CARD_NAMES } from '../game/truthtrance';
|
||||
import type { Plan } from '../game/engine';
|
||||
|
||||
const portable = richeseCards().find((c) => c.effect === 'portableSnooper')!;
|
||||
@@ -110,13 +108,3 @@ void test('late eligibility obeys forbidden poison defense while preserving unre
|
||||
null,
|
||||
);
|
||||
});
|
||||
void test('structured Truthtrance and plan questions include canonical Richese card identities', () => {
|
||||
assert.ok(TRUTH_CARD_NAMES.includes(portable.name));
|
||||
assert.deepEqual(
|
||||
parsePlanClaim({ kind: 'defense', name: portable.name }, []),
|
||||
{ kind: 'defense', name: portable.name },
|
||||
);
|
||||
assert.throws(() =>
|
||||
parsePlanClaim({ kind: 'defense', name: 'Invented Snooper' }, []),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { REINFORCEMENTS_CARD } from '../game/reinforcements';
|
||||
import { HARASS_WITHDRAW_CARD } from '../game/harass-withdraw';
|
||||
import { harassHomeworldArrakisPool as pool } from './fixture-harass-homeworld-arrakis';
|
||||
import {
|
||||
assertReinforcementsHomeworldArrakisInventory,
|
||||
createReinforcementsHomeworldArrakisFixture,
|
||||
finishReinforcementsHomeworldArrakisBattle,
|
||||
reinforcementsHomeworldArrakisClean,
|
||||
reinforcementsHomeworldArrakisPlayer as player,
|
||||
revealReinforcementsHomeworldArrakisBattle,
|
||||
sealReinforcementsHomeworldArrakisPlan,
|
||||
type ReinforcementsHomeworldArrakisFixture,
|
||||
} from './fixture-reinforcements-homeworld-arrakis';
|
||||
|
||||
function assertDisposed(game: Game, fixture: ReinforcementsHomeworldArrakisFixture, harass: boolean): void {
|
||||
for (const id of harass ? [REINFORCEMENTS_CARD, HARASS_WITHDRAW_CARD] : [REINFORCEMENTS_CARD]) {
|
||||
assert.equal(game.discard.filter(c => c.id === id).length, 1, 'Used physical utility cards discard even for the winner');
|
||||
assert.ok(!player(game, fixture.owner).hand.some(c => c.id === id));
|
||||
}
|
||||
assert.equal(game.battle, null);
|
||||
assert.ok(reinforcementsHomeworldArrakisClean(game));
|
||||
assertReinforcementsHomeworldArrakisInventory(game);
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
for (const slot of ['weapon', 'defense'] as const) {
|
||||
for (const outcome of ['winner', 'loser'] as const) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} Arrakis ${slot} ${outcome}: native fee is not dial, support or an army`, () => {
|
||||
const f = createReinforcementsHomeworldArrakisFixture({ advanced, slot, reserves: 'normal', outcome, harass: false });
|
||||
const before = player(f.game, f.owner);
|
||||
const revealed = revealReinforcementsHomeworldArrakisBattle(f);
|
||||
const done = finishReinforcementsHomeworldArrakisBattle(revealed,
|
||||
{ losses: outcome === 'winner' ? { normal: 1, elite: 1 } : { normal: 3, elite: 0 } });
|
||||
const own = player(done, f.owner), other = player(done, f.opponent);
|
||||
assert.equal(done.lastBattleContext!.result, 'normal');
|
||||
assert.equal(done.lastBattleContext!.winner, outcome === 'winner' ? f.owner : f.opponent);
|
||||
assert.equal(own.reserves, 10, 'Thirteen original native reserves pay exactly three normal counters');
|
||||
assert.equal(own.elites!.reserves, 1);
|
||||
assert.equal(own.tanks, outcome === 'winner' ? 5 : 10, 'Fee3 plus the original two committed counters, or the original losing army7');
|
||||
assert.equal(own.elites!.tanks, outcome === 'winner' ? 1 : 4);
|
||||
assert.equal(own.battleLosses, own.tanks);
|
||||
assert.equal(own.forces[f.location] ?? 0, outcome === 'winner' ? 5 : 0);
|
||||
assert.equal(own.elites!.forces[f.location] ?? 0, outcome === 'winner' ? 3 : 0);
|
||||
const collection = advanced && done.phase >= 7 && outcome === 'winner' ? 1 : 0;
|
||||
assert.equal(own.spice, before.spice - (advanced ? 1 : 0) + collection, 'Only the original sealed support is charged; later stronghold Collection is separate');
|
||||
assert.equal(other.reserves, 13, 'The enemy native reserve pool never pays this fee');
|
||||
assert.equal(other.tanks, outcome === 'winner' ? 7 : 3);
|
||||
assert.equal(other.forces[f.location] ?? 0, outcome === 'winner' ? 0 : 4);
|
||||
assert.deepEqual(pool(done, f.owner, 'homeworld:emperor'), advanced
|
||||
? { normal: 9, elite: 0 } : { normal: 9, elite: 1 });
|
||||
if (advanced) assert.deepEqual(pool(done, f.owner, 'homeworld:emperor:salusa'), { normal: 0, elite: 1 });
|
||||
assertDisposed(done, f, false);
|
||||
|
||||
if (outcome === 'winner') {
|
||||
// The same physical armies, leaders and dial lose without precisely
|
||||
// the utility's +2: Bashar2 + dial2 =4, against Staban Tuek5.
|
||||
const plain = structuredClone(f.planActions[0]);
|
||||
plain.action.weapon = null; plain.action.defense = null;
|
||||
delete plain.action.reinforcementsSources;
|
||||
let control = sealReinforcementsHomeworldArrakisPlan(structuredClone(f.game), plain);
|
||||
control = sealReinforcementsHomeworldArrakisPlan(control, f.planActions[1]);
|
||||
control = finishReinforcementsHomeworldArrakisBattle(control);
|
||||
assert.equal(control.lastBattleContext!.winner, f.opponent);
|
||||
assert.equal(player(control, f.owner).reserves, 13);
|
||||
assert.equal(player(control, f.owner).tanks, 7);
|
||||
assertReinforcementsHomeworldArrakisInventory(control);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const reserves of ['mixed', 'elite'] as const) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} Arrakis ${slot}: real Fremen ${reserves} fee follows native normal-first custody`, () => {
|
||||
const f = createReinforcementsHomeworldArrakisFixture({ advanced, slot, reserves, outcome: 'winner', harass: false });
|
||||
const done = finishReinforcementsHomeworldArrakisBattle(revealReinforcementsHomeworldArrakisBattle(f),
|
||||
{ losses: { normal: 2, elite: 0 } });
|
||||
const own = player(done, f.owner);
|
||||
assert.equal(done.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(own.reserves, reserves === 'mixed' ? 1 : 0);
|
||||
assert.equal(own.elites!.reserves, reserves === 'mixed' ? 1 : 0);
|
||||
assert.equal(own.tanks, 5, 'Three native fee counters plus two original on-board normal casualties');
|
||||
assert.equal(own.elites!.tanks, reserves === 'mixed' ? 2 : 3);
|
||||
assert.equal(own.battleLosses, 5);
|
||||
assert.equal(own.forces[f.location], reserves === 'mixed' ? 14 : 15);
|
||||
assert.equal(own.elites!.forces[f.location] ?? 0, 0);
|
||||
assert.deepEqual(pool(done, f.owner, 'homeworld:fremen'), reserves === 'mixed'
|
||||
? { normal: 0, elite: 1 } : { normal: 0, elite: 0 });
|
||||
assert.equal(player(done, f.opponent).reserves, player(f.game, f.opponent).reserves);
|
||||
assert.equal(player(done, f.opponent).tanks, 2);
|
||||
assertDisposed(done, f, false);
|
||||
});
|
||||
}
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} ${slot}: same-plan Harass returns cannot replace the original real Fedaykin fee`, () => {
|
||||
const f = createReinforcementsHomeworldArrakisFixture({ advanced, slot, reserves: 'elite', outcome: 'winner', harass: true });
|
||||
const revealed = revealReinforcementsHomeworldArrakisBattle(f);
|
||||
const returns = { [f.location]: { normal: 15, elite: 0 } };
|
||||
const done = finishReinforcementsHomeworldArrakisBattle(revealed, { returns });
|
||||
const own = player(done, f.owner);
|
||||
assert.equal(done.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(own.reserves, 15, 'Three real native elites pay before fifteen real normals return');
|
||||
assert.equal(own.elites!.reserves, 0);
|
||||
assert.equal(own.tanks, 5);
|
||||
assert.equal(own.elites!.tanks, 3, 'Newly returned normal reserves cannot replace the sealed elite payment');
|
||||
assert.equal(own.battleLosses, 5);
|
||||
assert.equal(own.forces[f.location] ?? 0, 0);
|
||||
assert.deepEqual(pool(done, f.owner, 'homeworld:fremen'), { normal: 15, elite: 0 });
|
||||
assertDisposed(done, f, true);
|
||||
});
|
||||
}
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} same-plan Harass preserves the mixed native fee before returning normals`, () => {
|
||||
const f = createReinforcementsHomeworldArrakisFixture({ advanced, slot: 'defense', reserves: 'mixed', outcome: 'winner', harass: true });
|
||||
const done = finishReinforcementsHomeworldArrakisBattle(revealReinforcementsHomeworldArrakisBattle(f),
|
||||
{ returns: { [f.location]: { normal: 14, elite: 0 } } });
|
||||
const own = player(done, f.owner);
|
||||
assert.equal(own.reserves, 15);
|
||||
assert.equal(own.elites!.reserves, 1);
|
||||
assert.equal(own.tanks, 5);
|
||||
assert.equal(own.elites!.tanks, 2);
|
||||
assert.deepEqual(pool(done, f.owner, 'homeworld:fremen'), { normal: 14, elite: 1 });
|
||||
assertDisposed(done, f, true);
|
||||
});
|
||||
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} future Harass returns cannot fund two native reserves, and sources cannot bypass normal-first`, () => {
|
||||
const short = createReinforcementsHomeworldArrakisFixture({ advanced, slot: 'weapon', reserves: 'shortage', outcome: 'winner', harass: true });
|
||||
assert.equal(player(short.game, short.owner).reserves, 2);
|
||||
assert.equal(player(short.game, short.owner).elites!.reserves, 2);
|
||||
assert.equal(player(short.game, short.owner).forces[short.location], 18);
|
||||
assert.equal(player(short.game, short.owner).elites!.forces[short.location], 1, 'A real free shipment moved the eighth physical counter, one original Fedaykin');
|
||||
const before = structuredClone(short.game);
|
||||
assert.throws(() => applyAction(short.game, short.owner, short.planActions[0].action));
|
||||
assert.deepEqual(short.game, before, 'Underfunded physical utility commitment is atomic');
|
||||
const preview = viewGame(short.game, short.owner).battle!.reinforcements;
|
||||
assert.ok(preview?.blocked, 'A large possible later Harass return does not fund the current fee');
|
||||
assertReinforcementsHomeworldArrakisInventory(short.game);
|
||||
|
||||
const mixed = createReinforcementsHomeworldArrakisFixture({ advanced, slot: 'defense', reserves: 'mixed', outcome: 'winner', harass: false });
|
||||
const mixedBefore = JSON.stringify(mixed.game);
|
||||
const wrong = structuredClone(mixed.planActions[0]);
|
||||
wrong.action.reinforcementsSources = { 'homeworld:fremen': { normal: 0, elite: 3 } };
|
||||
assert.throws(() => applyAction(mixed.game, mixed.owner, wrong.action), 'Three elites exist, but the one native normal must pay first');
|
||||
const foreign = structuredClone(mixed.planActions[0]);
|
||||
foreign.action.reinforcementsSources = { 'homeworld:guild': { normal: 1, elite: 2 } };
|
||||
assert.throws(() => applyAction(mixed.game, mixed.owner, foreign.action), 'An opposing World cannot fund the selected native fee');
|
||||
assert.equal(JSON.stringify(mixed.game), mixedBefore);
|
||||
assertReinforcementsHomeworldArrakisInventory(mixed.game);
|
||||
});
|
||||
|
||||
for (const resolution of ['owner', 'opponent', 'both', 'explosion'] as const) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} Arrakis ${resolution}: revealed native fee and mandatory disposal survive exceptional outcomes`, () => {
|
||||
const f = createReinforcementsHomeworldArrakisFixture({ advanced, slot: 'weapon', reserves: 'normal',
|
||||
outcome: 'winner', harass: true, traitor: resolution === 'explosion' ? undefined : resolution,
|
||||
explosion: resolution === 'explosion' });
|
||||
const revealed = revealReinforcementsHomeworldArrakisBattle(f);
|
||||
const callers = resolution === 'both' ? [f.owner, f.opponent] : resolution === 'owner' ? [f.owner]
|
||||
: resolution === 'opponent' ? [f.opponent] : [];
|
||||
const done = finishReinforcementsHomeworldArrakisBattle(revealed,
|
||||
{ callers, returns: { [f.location]: { normal: 2, elite: 3 } } });
|
||||
const canceled = resolution === 'opponent' || resolution === 'both';
|
||||
const own = player(done, f.owner);
|
||||
assert.equal(done.lastBattleContext!.result, resolution === 'both' ? 'mutualTraitors' : resolution === 'explosion' ? 'explosion' : 'traitor');
|
||||
assert.equal(done.lastBattleContext!.winner, resolution === 'owner' ? f.owner : resolution === 'opponent' ? f.opponent : null);
|
||||
assert.equal(own.reserves, canceled ? 10 : 15, 'Every revealed outcome pays the original native fee; only opposing Traitor cancels Harass returns');
|
||||
assert.equal(own.elites!.reserves, canceled ? 1 : 4);
|
||||
assert.equal(own.tanks, canceled ? 10 : resolution === 'owner' ? 3 : 5);
|
||||
assert.equal(own.elites!.tanks, canceled ? 4 : resolution === 'owner' ? 0 : 1);
|
||||
assert.equal(own.battleLosses, own.tanks);
|
||||
assert.equal(own.forces[f.location] ?? 0, resolution === 'owner' ? 2 : 0, 'Only the own-Traitor winner retains its original committed physical complement');
|
||||
assert.equal(own.elites!.forces[f.location] ?? 0, resolution === 'owner' ? 1 : 0);
|
||||
assert.deepEqual(pool(done, f.owner, 'homeworld:emperor'), advanced
|
||||
? { normal: canceled ? 9 : 11, elite: 0 }
|
||||
: { normal: canceled ? 9 : 11, elite: canceled ? 1 : 4 });
|
||||
if (advanced) assert.deepEqual(pool(done, f.owner, 'homeworld:emperor:salusa'), { normal: 0, elite: canceled ? 1 : 4 });
|
||||
assertDisposed(done, f, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { applyAction, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { combatArmy, homeworldBattleLocation } from '../game/combat-location';
|
||||
import type { NativeReserveSelections } from '../game/homeworld-native-reserves';
|
||||
import { takeHarassCard } from './fixture-harass-withdraw';
|
||||
import {
|
||||
REINFORCEMENTS_CARD, REINFORCEMENTS_KAITAIN as K, REINFORCEMENTS_SALUSA as S,
|
||||
advanceReinforcementsHomeworld, assertReinforcementsHomeworldCustody,
|
||||
createReinforcementsHomeworldFixture, finishReinforcementsHomeworldBattle,
|
||||
reinforcementsHomeworldClean, reinforcementsHomeworldPlayer as player,
|
||||
reinforcementsHomeworldPool as pool, reinforcementsHomeworldSources as sources,
|
||||
revealReinforcementsHomeworldBattle, sealReinforcementsHomeworldPlan,
|
||||
type ReinforcementsHomeworldFixture, type ReinforcementsHomeworldStep,
|
||||
} from './fixture-reinforcements-homeworld';
|
||||
|
||||
function rejectsUnchanged(game: Game, actor: string, action: Action): void {
|
||||
const before = JSON.stringify(game);
|
||||
assert.throws(() => applyAction(game, actor, action));
|
||||
assert.equal(JSON.stringify(game), before, 'Rejected source or dial must not mutate custody, hand or sealed plans');
|
||||
}
|
||||
function assertFinished(game: Game, f: ReinforcementsHomeworldFixture): void {
|
||||
assert.equal(game.battle, null);
|
||||
assert.ok(!game.homeworldBattleLoss && !game.pendingTreacheryDiscard && reinforcementsHomeworldClean(game));
|
||||
assert.equal(game.lastBattleContext!.territory, f.location);
|
||||
assert.equal(game.discard.filter(c => c.id === REINFORCEMENTS_CARD).length, 1);
|
||||
assert.ok(!player(game, f.owner).hand.some(c => c.id === REINFORCEMENTS_CARD));
|
||||
assertReinforcementsHomeworldCustody(game);
|
||||
}
|
||||
function canonicalSources(allocation: NativeReserveSelections): string {
|
||||
return JSON.stringify(Object.entries(allocation).filter(([, n]) => n.normal + n.elite > 0)
|
||||
.sort(([a], [b]) => a.localeCompare(b)));
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) for (const slot of ['weapon', 'defense'] as const) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} visiting ${slot} pays three native normals, not its foreign army, on loss`, () => {
|
||||
const f = createReinforcementsHomeworldFixture({ advanced, slot });
|
||||
const preview = viewGame(f.game, f.owner).battle!.reinforcements!;
|
||||
assert.equal(preview.blocked, null);
|
||||
assert.equal(viewGame(f.game, f.opponent).battle!.reinforcements, null);
|
||||
assert.equal(viewGame(f.game, f.observer).battle!.reinforcements, null);
|
||||
let game = sealReinforcementsHomeworldPlan(f.game, f.planActions[0]);
|
||||
assert.equal(viewGame(game, f.opponent).battle!.plans[f.owner], undefined);
|
||||
assert.equal(viewGame(game, f.observer).battle!.plans[f.owner], undefined);
|
||||
assert.deepEqual(game.homeworlds, f.game.homeworlds, 'Sealing is not a premature reserve payment');
|
||||
game = sealReinforcementsHomeworldPlan(JSON.parse(JSON.stringify(game)) as Game, f.planActions[1]);
|
||||
assert.deepEqual(viewGame(game, f.owner).battle!.traitorVoters, [f.native]);
|
||||
rejectsUnchanged(game, f.visitor, { type: 'traitorCall', call: false });
|
||||
const actions: ReinforcementsHomeworldStep[] = [];
|
||||
const done = finishReinforcementsHomeworldBattle(game, { actions });
|
||||
const before = player(f.game, f.owner), after = player(done, f.owner);
|
||||
assert.equal(done.lastBattleContext!.winner, f.opponent);
|
||||
assert.equal(done.lastBattleContext!.result, 'normal');
|
||||
assert.equal(after.reserves, before.reserves - 3);
|
||||
assert.equal(after.elites!.reserves, before.elites!.reserves);
|
||||
assert.equal(after.tanks, before.tanks + 10, 'Three fee counters plus the seven actual invaders');
|
||||
assert.equal(after.elites!.tanks, before.elites!.tanks + 4);
|
||||
assert.equal(after.battleLosses, before.battleLosses + 10);
|
||||
assert.equal(after.spice, before.spice - (advanced ? 2 : 0), 'Only the original two supported physical normals cost spice');
|
||||
assert.deepEqual(pool(done, f.owner, K), advanced ? { normal: 7, elite: 0 } : { normal: 9, elite: 1 });
|
||||
if (advanced) assert.deepEqual(pool(done, f.owner, S), { normal: 2, elite: 1 });
|
||||
assert.deepEqual(combatArmy(done, f.owner, f.location), { normal: 0, elite: 0 });
|
||||
assert.deepEqual(actions.filter(s => s.action.type === 'traitorCall').map(s => s.actor), [f.native]);
|
||||
assert.equal(after.revived, before.revived); assert.equal(after.freeForcesRevived, before.freeForcesRevived);
|
||||
assertFinished(done, f);
|
||||
});
|
||||
}
|
||||
|
||||
void test('actual Advanced native movement exposes all three legal fee allocations and each charges its chosen World after reload', () => {
|
||||
for (const salusaNormal of [0, 1, 2]) {
|
||||
const f = createReinforcementsHomeworldFixture({ advanced: true, sources: sources(salusaNormal) });
|
||||
const choices = viewGame(f.game, f.owner).battle!.reinforcements!.homeworldSources!;
|
||||
assert.deepEqual(choices.map(canonicalSources).sort(), [0, 1, 2].map(n => canonicalSources(sources(n))).sort());
|
||||
let game = sealReinforcementsHomeworldPlan(f.game, f.planActions[0]);
|
||||
game = sealReinforcementsHomeworldPlan(JSON.parse(JSON.stringify(game)) as Game, f.planActions[1]);
|
||||
const done = finishReinforcementsHomeworldBattle(game);
|
||||
assert.deepEqual(pool(done, f.owner, K), { normal: 7 + salusaNormal, elite: 0 });
|
||||
assert.deepEqual(pool(done, f.owner, S), { normal: 2 - salusaNormal, elite: 1 });
|
||||
assert.equal(player(done, f.owner).tanks, 10);
|
||||
assert.equal(player(done, f.owner).elites!.tanks, 4);
|
||||
assertFinished(done, f);
|
||||
}
|
||||
});
|
||||
|
||||
void test('ambiguous missing, foreign, wrong-type, nonphysical and overdrawn native fee sources reject without changing a real battle', () => {
|
||||
const f = createReinforcementsHomeworldFixture({ advanced: true });
|
||||
const missing = { ...f.planActions[0].action }; delete missing.reinforcementsSources;
|
||||
rejectsUnchanged(f.game, f.owner, missing);
|
||||
for (const allocation of [
|
||||
{},
|
||||
{ 'homeworld:guild': { normal: 3, elite: 0 } },
|
||||
{ kaitain: { normal: 3, elite: 0 } },
|
||||
{ [K]: { normal: 2, elite: 1 } },
|
||||
{ [S]: { normal: 0, elite: 3 } },
|
||||
{ [S]: { normal: 3, elite: 0 } },
|
||||
{ [K]: { normal: 4, elite: 0 } },
|
||||
{ [K]: { normal: 2, elite: 0 } },
|
||||
{ [K]: { normal: 3 } },
|
||||
{ [K]: { normal: '3', elite: 0 } },
|
||||
{ [K]: { normal: 3, elite: 0, visitor: true } },
|
||||
]) rejectsUnchanged(f.game, f.owner, { ...f.planActions[0].action, reinforcementsSources: allocation } as Action);
|
||||
rejectsUnchanged(f.game, f.owner, { ...f.planActions[0].action, weapon: null, defense: null });
|
||||
assert.equal(Object.keys(f.game.battle!.plans).length, 0);
|
||||
assertReinforcementsHomeworldCustody(f.game);
|
||||
});
|
||||
|
||||
void test('a unique Basic World fee derives its exact source instead of demanding a redundant selector', () => {
|
||||
const f = createReinforcementsHomeworldFixture({ advanced: false });
|
||||
const action = { ...f.planActions[0].action }; delete action.reinforcementsSources;
|
||||
let game = sealReinforcementsHomeworldPlan(f.game, { actor: f.owner, action });
|
||||
game = sealReinforcementsHomeworldPlan(game, f.planActions[1]);
|
||||
const done = finishReinforcementsHomeworldBattle(game);
|
||||
assert.deepEqual(pool(done, f.owner, K), { normal: 9, elite: 1 });
|
||||
assertFinished(done, f);
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) for (const role of ['visitor', 'native'] as const) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} ${role} winner pays its physical dial separately and must discard Reinforcements`, () => {
|
||||
const f = createReinforcementsHomeworldFixture({ advanced, role, outcome: 'win', slot: 'weapon' });
|
||||
const before = player(f.game, f.owner);
|
||||
const done = finishReinforcementsHomeworldBattle(revealReinforcementsHomeworldBattle(f));
|
||||
const after = player(done, f.owner);
|
||||
assert.equal(done.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(after.tanks, before.tanks + 5, 'Three native fee counters and two real dial casualties, no virtual bonus casualties');
|
||||
assert.equal(after.elites!.tanks, before.elites!.tanks);
|
||||
assert.equal(after.battleLosses, before.battleLosses + 5);
|
||||
assert.equal(after.spice, before.spice - (advanced ? 2 : 0));
|
||||
if (role === 'visitor') {
|
||||
assert.deepEqual(combatArmy(done, f.owner, f.location), { normal: 1, elite: 4 });
|
||||
assert.equal(after.reserves, before.reserves - 3);
|
||||
} else {
|
||||
assert.deepEqual(pool(done, f.owner, K), advanced ? { normal: 0, elite: 0 } : { normal: 10, elite: 5 });
|
||||
assert.equal(after.reserves, before.reserves - 5);
|
||||
}
|
||||
if (advanced) assert.deepEqual(pool(done, f.owner, S), pool(f.game, f.owner, S));
|
||||
assertFinished(done, f);
|
||||
const continued = advanceReinforcementsHomeworld(done, g => g.phase === 7 && reinforcementsHomeworldClean(g));
|
||||
assert.equal(continued.turn, f.game.turn);
|
||||
assertFinished(continued, f);
|
||||
});
|
||||
}
|
||||
|
||||
void test('own-World fee removes its counters from dial/support availability, while a fee on the other native World does not', () => {
|
||||
const f = createReinforcementsHomeworldFixture({ advanced: true, role: 'native', nativeTransfer: 12 });
|
||||
assert.deepEqual(pool(f.game, f.owner, K), { normal: 3, elite: 0 });
|
||||
assert.deepEqual(pool(f.game, f.owner, S), { normal: 12, elite: 5 });
|
||||
rejectsUnchanged(f.game, f.owner, { ...f.planActions[0].action, dial: 1, support: 1 });
|
||||
rejectsUnchanged(f.game, f.owner, { ...f.planActions[0].action, dial: 0.5, support: 0 });
|
||||
const onWorld = sealReinforcementsHomeworldPlan(f.game, f.planActions[0]);
|
||||
assert.deepEqual(onWorld.homeworlds, f.game.homeworlds);
|
||||
const offWorld = sealReinforcementsHomeworldPlan(f.game, { actor: f.owner,
|
||||
action: { ...f.planActions[0].action, dial: 3, support: 3, reinforcementsSources: sources(3) } });
|
||||
const offDone = finishReinforcementsHomeworldBattle(
|
||||
sealReinforcementsHomeworldPlan(offWorld, f.planActions[1]));
|
||||
assert.equal(offDone.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(player(offDone, f.owner).tanks, 6, 'The off-World fee leaves three real natives available to win and pay their dial');
|
||||
assert.deepEqual(pool(offDone, f.owner, K), { normal: 0, elite: 0 });
|
||||
assert.deepEqual(pool(offDone, f.owner, S), { normal: 9, elite: 5 });
|
||||
assertFinished(offDone, f);
|
||||
const done = finishReinforcementsHomeworldBattle(
|
||||
sealReinforcementsHomeworldPlan(onWorld, f.planActions[1]));
|
||||
assert.equal(player(done, f.owner).tanks, 3, 'No fee counter is destroyed a second time as a native loser');
|
||||
assert.deepEqual(pool(done, f.owner, K), { normal: 0, elite: 0 });
|
||||
assert.deepEqual(pool(done, f.owner, S), { normal: 12, elite: 5 });
|
||||
assertFinished(done, f);
|
||||
});
|
||||
|
||||
void test('own-World loss retains original high-face strength, not a retroactive low-face bonus after its fee', () => {
|
||||
const f = createReinforcementsHomeworldFixture({ advanced: true, role: 'native' });
|
||||
assert.deepEqual(pool(f.game, f.owner, K), { normal: 5, elite: 0 });
|
||||
assert.equal(homeworldBattleLocation(f.game, K)!.side, 'high');
|
||||
assert.equal(homeworldBattleLocation(f.game, K)!.nativeBattleStrength, 2);
|
||||
const opposingLeader = player(f.game, f.opponent).leaders.find(l => l.strength === 3)!;
|
||||
const game = sealReinforcementsHomeworldPlan(
|
||||
sealReinforcementsHomeworldPlan(f.game, f.planActions[0]),
|
||||
{ actor: f.opponent, action: { ...f.planActions[1].action, leader: opposingLeader.id, dial: 3.5, support: 2 } });
|
||||
// Emperor: original printed 2 + Bashar 2 + Reinforcements 2 = 6.
|
||||
// Guild: leader 3 + five normals with two support = 6.5.
|
||||
// Re-reading Kaitain's post-fee low face would illegally give Emperor 7.
|
||||
const done = finishReinforcementsHomeworldBattle(game);
|
||||
assert.equal(done.lastBattleContext!.winner, f.opponent);
|
||||
assert.equal(done.lastBattleContext!.result, 'normal');
|
||||
assert.equal(player(done, f.owner).tanks, 5, 'Three original fees and the two remaining physical natives');
|
||||
assert.equal(player(done, f.owner).elites!.tanks, 0);
|
||||
assert.deepEqual(pool(done, f.owner, K), { normal: 0, elite: 0 });
|
||||
assert.deepEqual(pool(done, f.owner, S), { normal: 10, elite: 5 });
|
||||
assertFinished(done, f);
|
||||
});
|
||||
|
||||
for (const advanced of [false, true]) for (const role of ['visitor', 'native'] as const) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} ${role} pays its original native fee on the sole native's genuine Traitor call`, () => {
|
||||
const f = createReinforcementsHomeworldFixture({ advanced, role, outcome: 'traitor' });
|
||||
const revealed = revealReinforcementsHomeworldBattle(f);
|
||||
assert.deepEqual(viewGame(revealed, f.owner).battle!.traitorVoters, [f.native]);
|
||||
rejectsUnchanged(revealed, f.visitor, { type: 'traitorCall', call: true });
|
||||
const actions: ReinforcementsHomeworldStep[] = [];
|
||||
const done = finishReinforcementsHomeworldBattle(revealed, { callers: [f.native], actions });
|
||||
assert.equal(done.lastBattleContext!.result, 'traitor');
|
||||
assert.equal(done.lastBattleContext!.winner, f.native);
|
||||
assert.equal(player(done, f.owner).tanks, role === 'visitor' ? 10 : 3);
|
||||
assert.equal(player(done, f.owner).elites!.tanks, role === 'visitor' ? 4 : 0);
|
||||
assert.equal(player(done, f.owner).reserves, player(f.game, f.owner).reserves - 3);
|
||||
assert.deepEqual(pool(done, f.owner, K), role === 'visitor'
|
||||
? advanced ? { normal: 7, elite: 0 } : { normal: 9, elite: 1 }
|
||||
: advanced ? { normal: 2, elite: 0 } : { normal: 12, elite: 5 });
|
||||
if (advanced) assert.deepEqual(pool(done, f.owner, S), pool(f.game, f.owner, S));
|
||||
assert.deepEqual(actions.filter(s => s.action.type === 'traitorCall').map(s => s.actor), [f.native]);
|
||||
const betrayed = f.native === f.owner ? f.opponent : f.owner;
|
||||
assert.equal(player(done, betrayed).leaders.find(l => l.id === revealed.battle!.plans[betrayed].leader)!.dead, true);
|
||||
assertFinished(done, f);
|
||||
});
|
||||
}
|
||||
|
||||
for (const advanced of [false, true]) for (const role of ['visitor', 'native'] as const) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} ${role} explosion pays the fee first and preserves the native printed casualty exception`, () => {
|
||||
const f = createReinforcementsHomeworldFixture({ advanced, role, outcome: 'explosion' });
|
||||
const nativeBefore = combatArmy(f.game, f.native, f.location);
|
||||
const printed = homeworldBattleLocation(f.game, f.location)!.nativeBattleStrength;
|
||||
const revealed = revealReinforcementsHomeworldBattle(f);
|
||||
const own = revealed.battle!.plans[f.owner], other = revealed.battle!.plans[f.opponent];
|
||||
const done = finishReinforcementsHomeworldBattle(revealed, { explosionLosses: { normal: printed, elite: 0 } });
|
||||
assert.equal(done.lastBattleContext!.result, 'explosion');
|
||||
assert.equal(done.lastBattleContext!.winner, null);
|
||||
assert.equal(player(done, f.owner).tanks, role === 'visitor' ? 10 : 3 + printed);
|
||||
assert.equal(player(done, f.owner).elites!.tanks, role === 'visitor' ? 4 : 0);
|
||||
assert.deepEqual(combatArmy(done, f.native, f.location), {
|
||||
normal: nativeBefore.normal - printed - (role === 'native' ? 3 : 0), elite: nativeBefore.elite,
|
||||
});
|
||||
if (advanced) assert.deepEqual(pool(done, f.owner, S), pool(f.game, f.owner, S));
|
||||
for (const [actor, plan] of [[f.owner, own], [f.opponent, other]] as const)
|
||||
assert.equal(player(done, actor).leaders.find(l => l.id === plan.leader)!.dead, true);
|
||||
for (const id of [REINFORCEMENTS_CARD, own.weapon, other.defense])
|
||||
assert.equal(done.discard.filter(c => c.id === id).length, 1);
|
||||
assertFinished(done, f);
|
||||
});
|
||||
}
|
||||
|
||||
void test('native Salusa explosion uses exact remaining Sardaukar custody after a split normal-first fee', () => {
|
||||
const f = createReinforcementsHomeworldFixture({ advanced: true, role: 'native', secondary: true,
|
||||
outcome: 'explosion', sources: sources(2) });
|
||||
assert.deepEqual(pool(f.game, f.owner, K), { normal: 13, elite: 0 });
|
||||
assert.deepEqual(pool(f.game, f.owner, S), { normal: 2, elite: 5 });
|
||||
assert.equal(homeworldBattleLocation(f.game, S)!.nativeBattleStrength, 3);
|
||||
const done = finishReinforcementsHomeworldBattle(revealReinforcementsHomeworldBattle(f), {
|
||||
explosionLosses: { normal: 0, elite: 3 },
|
||||
});
|
||||
assert.equal(done.lastBattleContext!.result, 'explosion');
|
||||
assert.equal(player(done, f.owner).reserves, player(f.game, f.owner).reserves - 6);
|
||||
assert.equal(player(done, f.owner).elites!.reserves, 2);
|
||||
assert.equal(player(done, f.owner).tanks, 6);
|
||||
assert.equal(player(done, f.owner).elites!.tanks, 3);
|
||||
assert.equal(player(done, f.owner).battleLosses, 6);
|
||||
assert.deepEqual(pool(done, f.owner, K), { normal: 12, elite: 0 });
|
||||
assert.deepEqual(pool(done, f.owner, S), { normal: 0, elite: 2 });
|
||||
assertFinished(done, f);
|
||||
});
|
||||
|
||||
void test('a split fee crossing Kaitain low retains the original printed explosion losses', () => {
|
||||
const f = createReinforcementsHomeworldFixture({ advanced: true, role: 'native',
|
||||
outcome: 'explosion', sources: sources(2) });
|
||||
const done = finishReinforcementsHomeworldBattle(revealReinforcementsHomeworldBattle(f), {
|
||||
explosionLosses: { normal: 2, elite: 0 },
|
||||
});
|
||||
assert.equal(done.lastBattleContext!.result, 'explosion');
|
||||
assert.equal(player(done, f.owner).tanks, 5, 'Three reserve fees plus the original high-face two, not the later low-face three');
|
||||
assert.equal(player(done, f.owner).elites!.tanks, 0);
|
||||
assert.deepEqual(pool(done, f.owner, K), { normal: 2, elite: 0 });
|
||||
assert.deepEqual(pool(done, f.owner, S), { normal: 8, elite: 5 });
|
||||
assertFinished(done, f);
|
||||
});
|
||||
|
||||
void test('a real named Reinforcements promise finds and executes the off-World fee needed for its fixed native dial', () => {
|
||||
const f = createReinforcementsHomeworldFixture({ advanced: true, role: 'native', nativeTransfer: 12 });
|
||||
let game = f.game;
|
||||
const truth = game.deck.find(card => card.effect === 'truthtrance'); assert.ok(truth);
|
||||
takeHarassCard(game, f.observer, truth.id);
|
||||
game = applyAction(game, f.observer, { type: 'card', card: truth.id });
|
||||
while (game.truthtrance!.stage === 'priority') {
|
||||
const actor = game.players.find(p => !game.truthtrance!.passed.includes(p.id))!.id;
|
||||
game = applyAction(game, actor, { type: 'truthPass' });
|
||||
}
|
||||
game = applyAction(game, f.observer, { type: 'truthAsk', question: {
|
||||
kind: 'battlePlan', target: f.owner, claim: { kind: 'and', terms: [
|
||||
{ kind: 'weapon', name: 'Reinforcements' }, { kind: 'dial', compare: 'eq', value: 3 },
|
||||
] },
|
||||
} });
|
||||
assert.ok(viewGame(game, f.owner).truthBattleAnswers!.includes('yes'));
|
||||
game = applyAction(game, f.owner, { type: 'truthAnswer', answer: 'yes' });
|
||||
const witness = viewGame(game, f.owner).battle!.compliantPlan; assert.ok(witness);
|
||||
rejectsUnchanged(game, f.owner, { type: 'battlePlan', ...witness, reinforcementsSources: sources(0) });
|
||||
game = applyAction(game, f.owner, { type: 'battlePlan', ...witness });
|
||||
game = sealReinforcementsHomeworldPlan(game, f.planActions[1]);
|
||||
const done = finishReinforcementsHomeworldBattle(game);
|
||||
assert.equal(done.lastBattleContext!.winner, f.owner);
|
||||
assert.equal(player(done, f.owner).tanks, 6);
|
||||
assert.equal(player(done, f.owner).spice, player(f.game, f.owner).spice - 3);
|
||||
assert.deepEqual(pool(done, f.owner, K), { normal: 0, elite: 0 });
|
||||
assert.deepEqual(pool(done, f.owner, S), { normal: 9, elite: 5 });
|
||||
assert.equal(done.discard.filter(card => card.id === truth.id).length, 1);
|
||||
assertFinished(done, f);
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { applyAction, viewGame } from '../game/engine';
|
||||
import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import { FACTIONS } from '../game/catalog';
|
||||
import { truthFactAnswer, truthQuestionText, type TruthFact } from '../game/truthtrance';
|
||||
import { truthFactAnswer, type TruthFact } from '../game/truthtrance';
|
||||
import { knowledgeFactAnswer, parseKnowledgeFact, truthKnowledgeOf, type KnowledgeFact } from '../game/truthtrance-knowledge';
|
||||
import { knowledgeGame, knownForecastGame, askKnowledge, rejectKnowledge, knowledgePhysical, knowledgeBotActions } from './truthtrance-knowledge-fixture';
|
||||
|
||||
@@ -98,6 +98,4 @@ void test('unknown stored knowledge retains the real card and permits a differen
|
||||
const retry=applyAction(unknown,'a',{type:'truthAsk',question:{kind:'fact',target:'b',fact:{kind:'prediction',field:'turn',compare:'eq',value:7}}});
|
||||
const done=applyAction(retry,'b',{type:'truthAnswer',answer:'yes'});assert.equal(done.truthtrance,null);
|
||||
assert.deepEqual(knowledgePhysical(done),knowledgePhysical(initial));
|
||||
const wording=truthQuestionText({kind:'fact',target:'b',fact:{kind:'prediction',field:'faction',faction:'guild'}},id=>id);
|
||||
assert.match(wording,/recorded.*prediction/);assert.doesNotMatch(wording,/will you win|must win/i);
|
||||
});
|
||||
@@ -13,9 +13,7 @@ import { botActions } from '../game/bots';
|
||||
import { DIFFICULTIES } from '../game/bot-profiles';
|
||||
import {
|
||||
truthFactAnswer,
|
||||
truthQuestionText,
|
||||
type TruthFact,
|
||||
type TruthQuestion,
|
||||
} from '../game/truthtrance';
|
||||
|
||||
function fixture(spice = 7) {
|
||||
@@ -392,14 +390,5 @@ void test('a current-spice answer records a fact rather than forbidding a subseq
|
||||
paid.battle?.truthPromises ?? [],
|
||||
done.battle?.truthPromises ?? [],
|
||||
);
|
||||
const q: TruthQuestion = {
|
||||
kind: 'fact',
|
||||
target: 'e',
|
||||
fact: spiceFact('gte', 41),
|
||||
};
|
||||
assert.match(
|
||||
truthQuestionText(q, () => 'unused'),
|
||||
/currently hold at least 41 spice/,
|
||||
);
|
||||
assert.deepEqual(cards(paid), cards(initial));
|
||||
});
|
||||
@@ -36,7 +36,7 @@ async function main() {
|
||||
'\nPrinted native Homeworld capture/Auditor/force-income Karama cancellation costs also offer their card. Accepted combat, paid support, leader death and original casualty/cleanup remain committed; the canceled reward never samples, transfers, inspects, refunds or credits. Earlier allowed income stays intact before a separately canceled audit.',
|
||||
'\n--profile homeworld-occupation --leader-skills composes original all14 Leader Skills with the classic2–6-seat occupied Homeworld income preview. Native setup, reserve custody, occupation history and owner allocation remain original. Bureaucrat diverts two from gross before the occupied percentage split while preserving the full original payer shares. Nexus Cards, expansions, Tech, Strongholds, Discovery, other overlays and conversion of played games remain excluded.',
|
||||
'\n--profile guild-betrayal --leader-skills composes the bounded classic native-Guild Nexus preview with original all14 skills. Original payment/physical shipment and once-per-phase Bureaucrat receipt remain; ordinary Skills use their shared quotes. Homeworlds, expansions, Tech, Strongholds, Discovery, Semuta, other overlays and conversion of played games remain excluded.',
|
||||
'\n--profile ecaz-treachery --homeworlds selects the independent three physical cards before original classic2–6 Basic/Advanced Homeworld setup (base33+3). Recruits doubles current native free rates, including low-population bonuses, within the seven-force total; separate requests provisionally recheck thresholds and prior free use. Native typed deposits/special-force caps and no-refund late-play policy stay. Harass works on Arrakis or another faction’s World, never an own World. Typed undialed returns use native reserves: Advanced Emperor normal to Kaitain/Sardaukar to Salusa; Basic primary. Original dial/support, native invasion/Traitor/explosion and disposal stay. Reinforcements remains World-guarded. No expansions, other overlays, public start or played-save conversion.',
|
||||
'\n--profile ecaz-treachery --homeworlds selects independent36-card classic2–6 Basic/Advanced Homeworld setup. Recruits doubles current native rates within seven total, provisionally rechecking separate-request thresholds/prior free use with native typed deposits/caps. Harass returns exact undialed counters through native reserves, never on an own World. Reinforcements seals exact normal-first native sources when ambiguous, pays before Harass and excludes an on-World fee from physical dial/casualties while preserving original face/support. Named-card Truthtrance and source-aware legal completion use canonical cards/locations. These are bounded provisional compositions, not full variant certification. No expansions, other overlays, public start or played-save conversion.',
|
||||
'\nEcaz-occupy (--profile ecaz-occupy) opts a fresh ready2–6 native Ecaz/classic or selected Advanced native-family lobby. Basic uses exact ecaz33 and provisionally follows the main paragraph for odd/even counts: ceil contribution/loss and floor survivors; the conflicting FAQ is recorded, not adjudicated. Advanced retains original selected ecaz/ix/choam decks and native offers/HMS/Auditor/Face Dancers/cache. Explicit --ecaz-treachery independently adds three physical cards in Advanced. Native Richese ordinary, marker-only and mixed pools compose with selected leads under the provisional additive interpretation. Ordinary dial Prescience remains barred for the physical No-Field army; distinct Advanced special Karama uses the explicit complete private-plan interpretation without early token revelation. Other profile/module and public-start gates remain.' +
|
||||
'\nLeader-skills retains existing classic and Basic native entries and adds native Basic Richese/CHOAM. Advanced Ixians, Tleilaxu, CHOAM and/or Richese plus classic rosters use distinct required family decks and all fourteen skills, with original cache, Face Dancers, Auditor exclusion and typed aftermath. Foreign revival grants no new skill. Separate non-Harkonnen Advanced Moritani uses the ecaz deck and original skill-first assassination preview. Native Ecaz supports its five ordinary discs, original Ambassador/Duke custody and ecaz deck with classic opponents in Basic/Advanced; Advanced Harkonnen retains its existing Duke capture exclusion. Normal Mentat questions use a uniform private card-show step in supported Leader Skills games. Banker-income additionally supports native Richese and Ecaz, never ordinary seller/allowed Emperor income or a bank grant as a payer cost. Basic auction recipients remain unchanged. Public activation, exhausted-cache counts, native Ixian Technology on Richese lots, own mixed No-Field, captured replacement, shared-Duke assignment, combined Occupy skills and other pending module/rulings remain guarded.',
|
||||
'\nStronghold-factions additionally admits standalone Advanced Ecaz OR Moritani with classic opponents and the exact ecaz deck, six Stronghold Cards with optional unused Tech Tokens at 3–6 seats. Original setup and actual end-Mentat claims remain. Ecaz holds shared-control cards and only the actual selected holder-plan benefits. Native Moritani retains original assassination with printed bounty and one private Mentat replacement; Harkonnen is excluded there. An E3 pair, E1/E2/E3 mixtures and other modules remain separate.' +
|
||||
|
||||
Reference in new issue
Block a user