diff --git a/components/game-table.tsx b/components/game-table.tsx
index 74fe19b5..e0284c37 100644
--- a/components/game-table.tsx
+++ b/components/game-table.tsx
@@ -77,6 +77,8 @@ import { VictoryProgress } from './victory-progress';
import { tableActionOwner } from '@/game/table-turn';
import { DukeVidal } from './duke-vidal';
import { CardInspector, CardRules } from './card-inspector';
+import { Recruits } from './recruits';
+import { ecazTreacheryDefinition } from '@/game/ecaz-cards';
import { ordinaryCardAvailability } from '@/game/card-availability';
import {
cardPresentation,
@@ -1526,6 +1528,7 @@ export function GameTable({
+
@@ -3575,11 +3578,14 @@ export function GameTable({
!g.freeRevival.includes(me.ally) &&
actionButton('Grant ally three free revivals', {
type: 'grantRevival',
- })}
+ }, !!g.recruitsPreview?.grantRevivalBlocked,
+ g.recruitsPreview?.grantRevivalBlocked ? 'recruits-grant-unavailable' : undefined)}
+ {me.faction === 'fremen' && me.ally && !g.freeRevival.includes(me.ally) && g.recruitsPreview?.grantRevivalBlocked && (
+
{g.recruitsPreview.grantRevivalBlocked}
+ )}
{g.freeRevival.includes(me.id) && !g.revival.freeBlocked && (
- Fremen allow you up to three free force revivals this
- phase.
+ Fremen allow you up to {g.recruitsPreview?.active ? 'six' : 'three'} free force revivals this phase.
)}
{amountInput}
@@ -4803,7 +4809,13 @@ export function GameTable({
{me.hand?.map((c) => {
const richese = richeseCardDefinition(c);
const presentation = cardPresentation(c);
+ const ecaz = ecazTreacheryDefinition(c);
const block =
+ (ecaz
+ ? ecaz.card.effect === 'recruits'
+ ? g.recruitsPreview?.play?.blocked ?? 'Use the Recruits panel during Revival to play this card.'
+ : 'This card’s battle effect is still being implemented.'
+ : null) ??
(richese?.card.effect === 'distrans'
? 'Use the Distrans transfer panel above to choose a recipient and another card.'
: richese?.card.effect === 'nullentropyBox'
diff --git a/components/recruits.tsx b/components/recruits.tsx
new file mode 100644
index 00000000..eb4afb12
--- /dev/null
+++ b/components/recruits.tsx
@@ -0,0 +1,49 @@
+'use client';
+
+import type { Action, GameView } from '@/game/engine';
+import { recruitsPlayAction } from '@/game/recruits';
+import { faction } from '@/game/catalog';
+import { Button } from '@/components/ui/button';
+
+export function Recruits({ game, act, busy }: {
+ game: GameView;
+ act: (action: Action) => void;
+ busy: boolean;
+}) {
+ const offer = game.recruitsPreview;
+ if (!offer || game.status !== 'playing' || game.phase !== 4 || (!offer.active && !offer.play))
+ return null;
+ const action = recruitsPlayAction(offer);
+ return (
+
+ Recruits{offer.active ? ' · Active this turn' : ''}
+
+ {offer.active
+ ? 'Free revival rates are doubled for this turn. The ordinary force limit is seven; factions with unlimited revival keep that advantage.'
+ : 'Play this card to double every faction’s free revival rate and raise the ordinary force limit to seven for this turn. Factions with unlimited revival keep that advantage.'}
+
+ Already completed revivals still count. Paid forces retain their normal prices, and special-force revival limits still apply.
+
+ Current revival rates
+ Faction Free rate Force limit
+ {offer.rates.map((rate) => (
+
+ {faction(game.players.find((p) => p.id === rate.player)!.faction).name}
+ {rate.freeRate}
+ {rate.limit === 20 ? 'Unlimited' : rate.limit}
+
+ ))}
+
+ {offer.play && (
+ <>
+ {offer.play.blocked && {offer.play.blocked}
}
+ { if (!busy && action) act(action); }}>
+ Play Recruits · Discard card
+
+ >
+ )}
+
+ );
+}
diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md
index cc4afa2d..9f6e1e5f 100644
--- a/docs/CURRENT_STATUS.md
+++ b/docs/CURRENT_STATUS.md
@@ -6,100 +6,38 @@ gated.** Prototypes do not open completion or publication gates.
## Current checkpoint and work
-Previous verified checkpoint: `58ae84f`, Moritani Assassinate Leaders preview.
-Types, lint, **4,690 offline tests**, build and **43 HTTP tests** passed.
-All 801 opening rooms and original seat/recovery records were preserved
-(835 after HTTP checks); all 802 present immediately before verification also
-remained unchanged. The healthy server was reused.
+Previous verified checkpoint: `9ca17a6`, [Fremen/Ecaz final-turn victory](ECAZ_VICTORY_RULES.md#fremen-final-turn-runtime-follow-up)
+and [deferred shipment repair](DEFERRED_SHIPMENT_ARRIVAL.md). Types, lint,
+**4,716 offline tests**, build and **43 HTTP tests** passed. All 835 opening games
+and the 869 present after the outage, including their seat/recovery records,
+were preserved (902 after verification). Three source-stable samples finish,
+including the exact failed shipment resumption; full scope remains unfinished.
+Detailed earlier capabilities and evidence are linked below and retained in the
+[implementation history](IMPLEMENTATION_STATUS.md).
-[Mentat](MENTAT_QUESTION.md) connects private naming/disclosure before posture
-and faction powers, all four AI profiles and saved continuation in explicitly
-opted-in previews. Two controlled-shuffle genuine-setup samples finish. Ordinary
-activation still awaits the uniform private response UX decision.
+Current prototype: [Recruits](RECRUITS_RUNTIME.md) connects the independent complete
+three-card inventory before genuine Basic/Advanced faction setup, owned card
+play/discard, public current revival rates, existing CHOAM/Tleilaxu decisions,
+four AI profiles and saved continuation. Exact free usage and physical custody
+are retained. Prior-free activation is supported; prior-paid or unknown accounting,
+later grants, pending repricing, same-turn replay and other optional modules stay
+gated. Reinforcements and Harass & Withdraw are present but their effects remain
+unfinished. Independent-cap precedence is explicitly labeled as an inference.
-[Bureaucrat payments](BUREAUCRAT_PAYMENTS.md) connect
-ordinary auctions, shipment income, Richese sale proceeds and actual bribe
-transfers, with a once-per-phase optional redirection and preserved continuation.
-Qualifying allied funding, other payment families and combined-module acceptance
-stay explicit. Independent review and focused controls/AI/recovery checks pass.
-Four genuine-setup games finish without rejected candidates, but contain no payment
-choice; a separate staged browser exercise verifies both choices and refresh.
-All 699 opening rooms and original seat/recovery records survived the reported
-outage. The healthy server was reused. The completed checkpoint's Git record
-contains final broad verification and post-HTTP preservation totals.
+Independent review's 37-case union and four authenticated recovery cases pass.
+Eight fixed Basic/Advanced six-seat samples finish in 7,995 actions with no
+rejected candidates and three actual Recruits plays. Exact custody, 212 periodic
+all-seat JSON restores and terminal private views pass. A separate conserved
+browser fixture verifies inspection, play/discard, public rates, four free returns
+and refresh. Final broad-check totals, source fingerprints and Git delivery are
+recorded with the checkpoint; these are bounded playability results.
-Completed prototype: [Emperor Nexus Secret Ally revival](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md).
-Exactly three free returns, independent ordinary allowances, shared Fedaykin caps,
-private controls, four AI profiles and saved continuation are connected. Independent
-review and 17 focused effect tests pass. Two genuine-start and two explicitly staged
-samples finish with zero rejections; only the staged pair uses this effect. A staged
-browser exercise verifies a Fedaykin return, three ordinary returns and refresh.
-Final broad checks, preservation counts and Git delivery are recorded in the
-checkpoint commit and private report before handoff.
-
-The purchase alternative still needs a private post-win payment choice. The earlier
-question asks whether to allow a uniform winner payment step, including otherwise
-forced spice payments. No answer or purchase policy is assumed. All 733 opening
-rooms and seat/recovery records survived the latest outage unchanged; a new snapshot
-protects all 734 rooms before final verification. The healthy server is reused.
-
-Completed prototype: [Truthtrance recorded knowledge](TRUTHTRANCE_KNOWLEDGE.md), connecting
-stored BG prediction faction/turn, submitted current Storm dials and actually known
-Fremen forecasts. The same private verified answer drives controls, all four bots
-and saved continuation. Independent source/privacy review and 44 focused cases pass,
-including five authenticated SQLite recovery cases. Two genuine-setup games finish
-in 192/518 actions with zero rejections and three scripted, naturally held-card
-questions; forecast coverage comes from the separate conserved browser exercise.
-Browser controls, unknown/rephrasing, compound answers and resolved refresh pass.
-All 767 opening rooms and seat/recovery records survived the latest reported outage;
-the healthy server is reused. A new snapshot protects all 768 rooms before broad
-verification. Final checks, preservation totals and Git delivery are recorded in
-the checkpoint commit and private report; full mode/publication gates remain.
-
-Completed prototype: [Moritani Assassinate Leaders](MORITANI_ASSASSINATE_LEADERS.md)
-connects a qualifying battle loss to a private reveal/continue choice, living/dead
-targets, bank bounty, public markers and automatic Mentat replacement. All four
-AI profiles and saved continuation are connected in the bounded Advanced preview.
-Independent source/privacy/persistence review and 32 focused cases pass. Two genuine
-four-profile games finish in 296/101 actions with no rejections; both observed
-opportunities are declined. A separate conserved, staged browser battle verifies
-the reveal, three-spice payment, automatic physical replacement, readable inspectors
-and pending/resolved refresh. Normal-traitor-call duration and the uniform private
-UX question remain pending; exceptional custody and combined modules stay gated.
-All 801 opening games remain unchanged after the reported outage; the healthy
-server is reused. Required broad checks, source fingerprint, final preservation
-and Git delivery are recorded in the checkpoint commit and private report.
-
-Current checkpoint: [Fremen/Ecaz final-turn victory](ECAZ_VICTORY_RULES.md#fremen-final-turn-runtime-follow-up).
-The explicit Sietch Tabr exception is connected to the same public board quote
-used by automatic victory and the table guide. Reciprocal allies must both be
-present; solitary/non-allied Ecaz and third-party fighters still block. Ordinary
-stronghold/BG precedence, the other protected sietch and Tuek conditions, Guild
-fallback and private No-Field identity remain intact. Independent review and
-17 focused cases pass, including all-profile final readiness and authenticated
-SQLite restart/concurrent writes. Final-turn fixtures are staged after genuine
-setup; they do not establish naturally reached allied special wins. Habbanya
-symmetry and complete mode/expansion acceptance remain unfinished. Broad checks,
-browser/sample evidence, saved-game preservation and Git delivery are recorded
-with the checkpoint.
-
-The same checkpoint repairs a pre-existing [deferred Guild shipment deadlock](DEFERRED_SHIPMENT_ARRIVAL.md)
-found after 90 accepted Advanced actions. Fresh ordinary and No-Field declarations
-preflight unsupported arrivals; an eligible saved ordinary Guild allowance returns
-unspent for another choice. Special card/parent receipts retain their own gates.
-Independent review and its 149-case union pass. The exact failed snapshot now
-finishes in 842 further accepted actions. The original Basic/Advanced seeds finish
-in 563/806 actions, with 14/81 guarded candidates; the resume has 109. All physical
-custody, 58 periodic JSON rounds and 12 terminal private views pass. The Advanced
-runs form Ecaz/Fremen alliances but do not exercise the shared-Tabr exception.
-These are bounded playability checks, not complete arrival-order acceptance.
-
-After the latest reported outage, the server responds normally, all 835 opening
-rooms and original seat/recovery records remain unchanged, and integrity/foreign-key
-checks pass. A new backup protects all 869 rooms before final checks. The completed
-browser game restores its result and private seat; no restart, reset or automation
-is required. Final broad verification, preservation totals and Git delivery are
-recorded with the checkpoint.
+After the latest reported power failure, the existing server responds normally.
+All **902 rooms**, **1,888 seats** and original recovery/entry/handover records
+match the opening backup; SQLite integrity and foreign-key checks pass. No server
+restart, database reset or recurring automation is needed. Final review, sample and
+browser evidence, broad verification and Git delivery are recorded with this
+checkpoint before handoff. Public mode and publication gates remain closed.
## Authoritative navigation
@@ -130,7 +68,7 @@ These links define bounded working behavior, not complete module certification.
| Discoveries | [Prototype](DISCOVERY_PROTOTYPE.md): genuine setup, Great Maker, seven cards/eight tokens, inspection and stash rewards, nested sites, signed later free entry, carried Ornithopter, sole Cistern, bounded Jacurutu income, Testing Station and Shrine. Orgiz and contested/mixed cases remain pending. |
| Nexus/Homeworlds | [Decision index](RULE_DECISIONS.md) links each integrated faction family, native/borrowed effects, physical custody, private choices, payments and transport. [CHOAM Collection trade](NEXUS_CHOAM_SECRET_ALLY.md) and [Emperor extra revival](NEXUS_EMPEROR_SECRET_ALLY_RUNTIME.md) are connected; CHOAM inspection and Emperor purchase remain pending. A source audit or helper alone is not a completed effect. |
| Truthtrance | [Spice facts](TRUTHTRANCE_SPICE_FACTS.md), [card counts](TRUTHTRANCE_CARD_COUNT.md), [hand inventory](TRUTHTRANCE_HAND_INVENTORY.md), [recorded knowledge](TRUTHTRANCE_KNOWLEDGE.md), and [Basic/no-Guild Advanced reserve-shipment promises](TRUTHTRANCE_SHIPMENT_PROMISES.md) use authoritative private state. Earlier readiness audits do not supersede these follow-ups. |
-| Other cards | [Sapho runtime](JUICE_OF_SAPHO_RUNTIME.md) includes first among remaining unstarted movement turns after Advanced Guild has finished. Richese card contracts and the checklist identify the other connected effects and explicit gaps. |
+| Other cards | [Sapho runtime](JUICE_OF_SAPHO_RUNTIME.md) includes first among remaining unstarted movement turns after Advanced Guild has finished. [Recruits](RECRUITS_RUNTIME.md) connects clean Revival play in its independent three-card preview. Richese card contracts and the checklist identify other connected effects and explicit gaps. |
| Multiplayer | Saved rooms/seats, [recovery](../README.md#saved-seats-and-reconnecting), uncertain-request retry, [own-seat AI](AUTOPILOT.md), and [voluntary seat handover](SEAT_HANDOVER.md) have connected controls and recovery evidence. |
## Remaining readiness
diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md
index c7c98184..3109fdaa 100644
--- a/docs/IMPLEMENTATION_STATUS.md
+++ b/docs/IMPLEMENTATION_STATUS.md
@@ -1,5 +1,38 @@
# Dune implementation status
+## 14 September 2026 — Recruits independent-card preview
+
+[Recruits](RECRUITS_RUNTIME.md) is connected in an explicit development preview.
+Genuine Basic/Advanced faction setup includes all three independent Ecaz Treachery
+Cards before shuffle and deal. Physical play/discard, public effective rates,
+shared revival quotes, exact free-use tracking, existing CHOAM/Tleilaxu response
+continuation, owner controls and all four AI profiles share the authoritative
+contract. Inactive games retain their earlier free-use arithmetic. The other two
+battle effects and public variant starts remain unfinished.
+
+Source review supports the bounded clean-case contract. The independent seven-force
+cap after faction cancellation remains an explicit inference. Earlier-paid returns,
+late grants, pending repricing and same-turn recovered-card replay remain gated;
+the user's late-paid question is pending. Focused rules, controls, genuine local
+setup and authenticated recovery tests accompany the implementation. Complete-game
+and browser evidence, required broad checks and Git delivery are recorded with
+the checkpoint; this does not certify complete variant or combined-mode compliance.
+
+All 902 opening rooms and original seat/recovery records survived the latest power
+failure unchanged. The healthy server was reused; integrity and foreign keys pass.
+No database reset, server restart or recurring automation was needed.
+
+Final independent review's 37-case union and four SQLite recovery cases pass.
+Review closed normal-auction sale aliasing, malformed canonical cards, known-zero
+legacy ledger initialization, seat-control validation and ordinary-start bypasses.
+Eight genuine six-seat Basic/Advanced samples finish in 7,995 accepted actions,
+with zero rejected candidates and three Recruits plays. Every action preserves
+physical card/Traitor/force/elite custody; 212 periodic all-seat JSON restores and
+terminal views pass. The conserved browser exercise verifies play/discard, global
+rates, four free returns, explanatory chronicle, private seat and refresh. Earlier
+harness attempts remain preserved; JSON comparison normalizes undefined properties
+on both sides without dropping defined values.
+
## 14 September 2026 — Fremen/Ecaz final-turn victory and deferred shipment repair
The [explicit Sietch Tabr FAQ](ECAZ_VICTORY_RULES.md#fremen-final-turn-runtime-follow-up)
diff --git a/docs/RECRUITS_RUNTIME.md b/docs/RECRUITS_RUNTIME.md
new file mode 100644
index 00000000..da92d8dc
--- /dev/null
+++ b/docs/RECRUITS_RUNTIME.md
@@ -0,0 +1,91 @@
+# Recruits development preview
+
+14 September 2026. **Prototyped, not full card-variant acceptance.** The independent
+Ecaz & Moritani Treachery Cards variant is included before genuine Basic or
+Advanced faction setup. All three physical cards are present exactly once.
+Recruits has connected controls, AI and saved continuation; Reinforcements and
+Harass & Withdraw remain unfinished. Public starts and publication remain gated.
+
+## Rules contract
+
+[The source audit](RECRUITS_RULES.md) records publisher Ecaz & Moritani pages 11
+and 16, the base and faction revival rules, and designer Jack Reda's explanation
+at 8:25–8:48. The card plays during Revival, doubles every current free rate,
+raises the normal force limit to seven for this turn, and is discarded after use.
+The publisher's four-to-eight example is capped at seven by the ordinary limit.
+Fresh publisher/designer-linked PDF fetches on 14 September returned HTTP 403;
+this follow-up uses the earlier recorded primary-source audit and independent
+source review. No newly retrieved late-paid ruling is claimed.
+
+The connected scope permits clean activation before normal returns or after only
+exactly recorded free normal returns, once existing priority windows and transactions
+have finished. Earlier paid returns, unknown legacy usage, later Fremen grants,
+same-turn replay after recovery and additional optional modules remain guarded.
+Those guards identify unfinished cases; they are not new printed timing rules.
+Already-ready players retain their Revival opportunity while phase four is open.
+
+Atreides doubles two to four, Fremen three to six, and Guild one to two. Native
+CHOAM and Tleilaxu retain their unlimited allowance with free rates zero and four.
+Paid prices, prevention and separate elite caps remain in force. A Fremen grant
+settled before play doubles the ally's replacement rate three to six.
+
+The seven-force allowance remains an independent global card effect when a
+CHOAM or Tleilaxu faction advantage is canceled. **This is an implementation
+inference from independent effects, not a direct combined publisher FAQ.** A
+Tleilaxu limit response is not opened merely for crossing three under Recruits;
+other qualifying price/prevention responses retain their original purposes.
+
+The user has been asked about Atreides taking two free plus one paid return before
+Recruits: preserve the gate, provide two further free returns without refund, or
+provide one further free return without refund. No answer is assumed. The source
+audit also records the refund alternative and later changing-rate ambiguity.
+
+## Connected path and persistence
+
+`initializeEcazTreacheryGameForAudit` composes the complete three-card inventory
+with the selected ordinary deck before shuffle/deal. The local `ecaz-treachery`
+prototype-room profile accepts only a fresh ready lobby and uses the existing
+version/state compare-and-swap. It preserves seats and other rooms.
+
+The holder's existing inspector shows the complete original-language card guide.
+The Revival panel submits the shared `recruitsPlayAction`; rivals receive no held
+card offer. A successful action discards the physical card once and saves its
+player, turn and identity. Every player then sees the same effective public rates
+and force limits. No rival hand, private spice or revival-use ledger is added.
+
+Authoritative shared revival quotes use the effect. Exact free usage is separate
+from total normal returns only while Recruits is active; inactive/legacy games
+retain their previous arithmetic. Active receipts and ledgers are validated on
+action and projection. JSON and SQLite restoration retain the same allowance;
+the next turn clears the effect. Rejected actions leave the supplied state intact.
+
+All four AI profiles use the same legal owned offer when their faction or ally can
+benefit, then ordinary quoted revival choices. The late Fremen grant is excluded
+from bot candidates as well as server and UI controls. This is a legal first
+strategy, not completed Recruits difficulty calibration.
+
+## Verification
+
+Focused engine, component, AI, genuine prototype-room and authenticated SQLite
+recovery suites are maintained alongside the pure arithmetic and inventory tests.
+Independent review passes a 37-case related union; four authenticated SQLite cases
+cover restored free/paid returns, malformed saves, stale/duplicate actions and
+competing writes. Eight fixed-seed six-seat Basic/Advanced samples across all four
+AI profiles finish in 7,995 accepted actions with no rejected candidates. Recruits
+is genuinely played three times. Physical cards, Traitors, safe force/elite pools,
+212 periodic all-seat JSON restores and terminal private views pass. Undefined
+object properties are normalized through JSON on both sides of the comparison;
+defined values are retained. Earlier harness attempts remain in private artifacts.
+
+A separate conserved browser Revival fixture verifies the full inspector, actual
+play/discard, public Atreides four/Fremen six rates, four free returns, automatic
+chronicle and active/resolved refresh. Exact state comparison preserves the seat,
+cards, forces and unchanged spice. This staged browser position does not establish
+naturally reached card use. Final broad checks, source fingerprints, saved-game
+preservation and Git delivery are recorded with the checkpoint. These samples do
+not certify the unfinished three-card variant or every combined configuration.
+
+See [current status](CURRENT_STATUS.md), [decision index](RULE_DECISIONS.md) and
+[the reference checklist](../game/reference.ts), topic `card-recruits`, for the
+remaining boundaries. Final polishing, other two effects and valid optional-module
+combinations remain required work.
diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md
index 384fbce3..7e8414a3 100644
--- a/docs/RULE_DECISIONS.md
+++ b/docs/RULE_DECISIONS.md
@@ -41,6 +41,7 @@ These are unresolved in the cited records. Do not infer an answer from elapsed t
| Boundary | Still unresolved | Existing record |
| --- | --- | --- |
+| Recruits late accounting and rates | A user question has been sent for two free plus one paid Atreides return before Recruits: keep the disputed case gated, or one/two further free returns without refund. No answer is assumed. Late-changing free rates, pending repricing and same-turn recovered-card replay also stay gated. The connected clean-case preview retains the independent seven-force cap after faction-advantage cancellation as an explicit inference, not a direct combined FAQ. | [Source audit](RECRUITS_RULES.md), [runtime scope](RECRUITS_RUNTIME.md) |
| Ambassador/Terror arrival priority | Both optionally react to the same entry, but no publisher/designer priority was found. The order can change Fremen relocation versus Atomics losses. A user question has been sent: keep the overlap gated pending an official ruling, or use explicitly provisional storm order. No answer is assumed; Baliset remains a separate pre-entry prevention effect. | [Reproduction and source boundary](DEFERRED_MOVEMENT_ARRIVAL.md) |
| Sapho remaining timing | First versus unspent Advanced Guild priority, partly completed combined turns, cyclic auction duration and remaining battle timing stay guarded. Prior questions remain unanswered. | [Source audit](JUICE_OF_SAPHO_SOURCE_UPDATE.md), [battle review](JUICE_OF_SAPHO_BATTLE_REVIEW.md) |
| Mirror Weapon | Whether copied Tooth/Artillery mandatory-discard rules attach to the physical Mirror remains unanswered; arithmetic helpers do not admit the full battle card. | [Engine audit](MIRROR_WEAPON_ENGINE_AUDIT.md) |
diff --git a/game/bots.ts b/game/bots.ts
index 60581d7d..ab168439 100644
--- a/game/bots.ts
+++ b/game/bots.ts
@@ -1,4 +1,5 @@
import { discoveryBotActions } from './discovery-options';
+import { recruitsPlayAction } from './recruits';
import type { Card } from './cards';
import { bureaucratBattlePenalty, canUsePlanetologistBattleSpecial, leaderSkillBattleBonus, validLeaderSkillBattleCardPair } from './leader-skill-combat';
import { leaderSkillStrongholdCount } from './leader-skill-battle-board';
@@ -2870,7 +2871,8 @@ function policyActions(g: GameView): Action[] {
actions.push({ type: 'requestLeaderRevival', leader: dead.id });
}
}
- if (me.faction === 'fremen' && me.ally && !g.freeRevival.includes(me.ally))
+ if (me.faction === 'fremen' && me.ally && !g.freeRevival.includes(me.ally) &&
+ !g.recruitsPreview?.grantRevivalBlocked)
actions.push({ type: 'grantRevival' });
if (me.faction === 'emperor' && me.ally && level > 0) {
const ally = g.players.find((p) => p.id === me.ally)!;
@@ -3722,6 +3724,19 @@ export function botActions(g: GameView): Action[] {
? policyActions({ ...g, decision: null })
: nexusTraitorBotActions(g);
if (g.automaticContinuationPending) return [];
+ const recruits = recruitsPlayAction(g.recruitsPreview);
+ if (recruits) {
+ const own = g.players.find((player) => player.id === g.me)!;
+ const rates = g.recruitsPreview!.rates;
+ const ownRate = rates.find((rate) => rate.player === own.id)!;
+ const ally = g.players.find((player) => player.id === own.ally);
+ const allyRate = rates.find((rate) => rate.player === ally?.id);
+ const freeBenefit = ownRate.freeRate > 0 && own.tanks > g.revival.freeRemaining;
+ const paidBenefit = ownRate.limit < 7 && own.tanks > g.revival.forcesRemaining &&
+ (own.spice ?? 0) >= 2;
+ const allyBenefit = ally && allyRate && allyRate.freeRate > 0 && ally.tanks > allyRate.freeRate;
+ if (freeBenefit || paidBenefit || allyBenefit) return [recruits];
+ }
if (g.nexusCards?.waiting.length) return nexusCardBotActions(g);
const discoveryEntry = discoveryEntryBotActions(g);
if (discoveryEntry.length) return discoveryEntry;
diff --git a/game/card-presentation.ts b/game/card-presentation.ts
index b649c71b..acd8efa6 100644
--- a/game/card-presentation.ts
+++ b/game/card-presentation.ts
@@ -109,7 +109,10 @@ export function cardPresentation(card: VisibleCard): CardPresentation {
role: 'utility',
guidance: ecaz.summary,
gameplay: ecaz.gameplay,
- topics: [],
+ availability: ecaz.card.effect === 'recruits'
+ ? 'Recruits has development controls during Revival. Earlier paid revivals, pending transactions, later free-rate changes and repeated use this turn remain guarded pending their rulings.'
+ : 'This battle effect is still being implemented.',
+ topics: RULE_TOPICS.filter((topic) => topic.id === 'card-recruits'),
};
const topicIds =
card.kind === 'special'
diff --git a/game/ecaz-cards.ts b/game/ecaz-cards.ts
index 3f4bb6f5..7c340a31 100644
--- a/game/ecaz-cards.ts
+++ b/game/ecaz-cards.ts
@@ -26,7 +26,7 @@ export type EcazTreacheryDefinition = Readonly<{
verification: Readonly<{
inventory: 'verified';
sourceRules: 'verified';
- runtime: 'not-implemented';
+ runtime: 'prototype' | 'not-implemented';
combinedInteractions: 'incomplete';
unresolved: readonly string[];
}>;
@@ -39,7 +39,7 @@ export const ECAZ_TREACHERY_VARIANT = Object.freeze({
physicalCards: 3,
independentOfFactions: true,
independentOfOtherVariants: true,
- activation: 'not-implemented',
+ activation: 'audit-prototype',
audit: 'docs/ECAZ_TREACHERY_RULES.md',
} as const);
@@ -86,7 +86,7 @@ export const ECAZ_TREACHERY_DEFINITIONS: readonly EcazTreacheryDefinition[] =
verification: {
inventory: 'verified',
sourceRules: 'verified',
- runtime: 'not-implemented',
+ runtime: 'prototype',
combinedInteractions: 'incomplete',
unresolved: [
'Timing after earlier completed revivals, refunds, reopening opportunities and any already pending transaction.',
@@ -170,11 +170,12 @@ export function ecazTreacheryCards(): EcazTreacheryCard[] {
/** Resolve a canonical physical identity, not an arbitrary special with a similar name. */
export function ecazTreacheryDefinition(
- card: Pick,
+ card: Pick,
): EcazTreacheryDefinition | undefined {
return ECAZ_TREACHERY_DEFINITIONS.find(
(definition) =>
definition.card.id === card.id &&
+ definition.card.name === card.name &&
definition.card.kind === card.kind &&
definition.card.effect === card.effect,
);
diff --git a/game/engine.ts b/game/engine.ts
index 97c4770b..db1595e8 100644
--- a/game/engine.ts
+++ b/game/engine.ts
@@ -470,6 +470,7 @@ import {
forceRevivalQuote,
forceRevivalRemaining,
freeRevivalRemaining,
+ normalRevivalAllowance,
leaderRevivalOptions,
type RevivalRules,
type PendingRevival,
@@ -509,6 +510,11 @@ import {
type Leader,
type SpiceCard,
} from './cards';
+import { ecazTreacheryCards, ecazTreacheryDefinition } from './ecaz-cards';
+import {
+ RECRUITS_CARD_ID,
+ type RecruitsPreview,
+} from './recruits';
import {
TERRITORIES,
MOBILE_STRONGHOLD,
@@ -1594,6 +1600,14 @@ export type Game = {
spiceResolution: { skipped: SpiceCard[] } | null;
spiceSequence: { pile: 0 | 1; skipped: SpiceCard[] } | null;
freeRevival: string[];
+ /** Presence enables the independently selected three-card Ecaz variant. */
+ ecazTreachery?: true;
+ /** Public, turn-scoped receipt for the discarded Recruits card. */
+ recruits?: {
+ turn: number;
+ player: string;
+ card: typeof RECRUITS_CARD_ID;
+ } | null;
emperorExtra: Record;
aid: Record;
};
@@ -5589,7 +5603,10 @@ function initializeSetup(g: Game) {
stormOrder(g);
g.stormDialers = [g.order[0], g.order.at(-1)!];
g.lastBattle = [...g.stormDialers];
- g.deck = shuffle(treacheryDeck(g.expansions));
+ g.deck = shuffle([
+ ...treacheryDeck(g.expansions),
+ ...(g.ecazTreachery ? ecazTreacheryCards() : []),
+ ]);
g.spiceDeck = shuffle([...spiceDeck(g.expansions.includes('ix')), ...(g.discoveryEnabled ? [...DISCOVERY_SPICE_CARDS, {worm:true as const, greatMaker:true as const}] : [])]);
g.sandtrout = false;
g.status = 'setup';
@@ -5830,7 +5847,14 @@ export function initializeLeaderSkillsGameForAudit(state: Game): Game {
g.leaderSkills = createLeaderSkills(random);
return initializeSetupGameForAudit(g, false, false, false, false, true, g.expansions.length === 1 && g.expansions[0] === 'choam');
}
-function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false, ix = false, discovery = false, leaderSkills = false, choam = false, factions = false): Game {
+/** Gated development setup for the independent three-card Ecaz variant. */
+export function initializeEcazTreacheryGameForAudit(state: Game): Game {
+ requireRule(!state.ecazTreachery, 'Ecaz Treachery Cards cannot redeal an existing inventory.');
+ const g = structuredClone(state);
+ g.ecazTreachery = true;
+ return initializeSetupGameForAudit(g, false, false, false, false, false, false, true, true);
+}
+function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = false, ix = false, discovery = false, leaderSkills = false, choam = false, factions = false, ecazTreachery = false): Game {
nexusCardsIntegrity(state);
homeworldRule(() => homeworldGameIntegrity(state));
homeworldBattleLossIntegrity(state);
@@ -5858,6 +5882,7 @@ function initializeSetupGameForAudit(state: Game, homeworlds: boolean, nexus = f
(leaderSkills || !g.leaderSkills) &&
(discovery || !g.discoveryEnabled) &&
(nexus || !g.nexusCards) &&
+ (ecazTreachery || !g.ecazTreachery) &&
!g.techTokens &&
!g.strongholdCards &&
(homeworlds || !g.homeworlds) &&
@@ -8033,9 +8058,135 @@ function physicalTreacheryCards(g: Game) {
...(g.ixSetupCards ?? []),
...(g.ixAuction?.cards ?? []),
...(g.ornithopter ? [g.ornithopter.card] : []),
- ...(g.auction?.cards.slice(g.auction.index) ?? []),
+ ...(g.auction?.cards.slice(
+ g.auction.index + Number(g.currentAuctionSale?.origin === 'normal'),
+ ) ?? []),
];
}
+function ecazTreacheryIntegrity(g: Game) {
+ if (!g.ecazTreachery) {
+ requireRule(!g.recruits, 'Recruits requires the independent Ecaz Treachery Cards variant.');
+ return;
+ }
+ requireRule(g.ecazTreachery === true, 'Invalid Ecaz Treachery Cards module marker.');
+ if (g.status === 'lobby') {
+ throw new RuleError(
+ 'Ecaz Treachery Cards can begin only through their gated audit initializer.',
+ );
+ }
+ const physical = physicalTreacheryCards(g);
+ for (const expected of ecazTreacheryCards()) {
+ const matching = physical.filter((card) => card.id === expected.id);
+ requireRule(
+ matching.length === 1 &&
+ !!ecazTreacheryDefinition(matching[0]) &&
+ Object.keys(matching[0]).sort().join(',') === 'effect,id,kind,name',
+ 'The Ecaz Treachery Cards inventory is missing, duplicated or malformed.',
+ );
+ }
+ requireRule(
+ physical.filter((card) => card.id.startsWith('ecaz-')).length === 3,
+ 'The Ecaz Treachery Cards inventory contains an unknown physical card.',
+ );
+ if (g.recruits) {
+ requireRule(
+ Object.keys(g.recruits).sort().join(',') === 'card,player,turn' &&
+ g.recruits.card === RECRUITS_CARD_ID &&
+ g.recruits.turn === g.turn &&
+ g.players.some((player) => player.id === g.recruits!.player),
+ 'The Recruits effect has a malformed or expired receipt.',
+ );
+ requireRule(
+ g.players.every(
+ (player) =>
+ Number.isSafeInteger(player.revived) &&
+ player.revived >= 0 &&
+ Number.isSafeInteger(player.freeForcesRevived) &&
+ player.freeForcesRevived! >= 0 &&
+ player.freeForcesRevived! <= player.revived,
+ ),
+ 'The active Recruits effect has inconsistent normal revival ledgers.',
+ );
+ }
+}
+function recruitsModeSupported(g: Game) {
+ return (
+ !g.homeworlds &&
+ !g.nexusCards &&
+ !g.leaderSkills &&
+ !g.techTokens &&
+ !g.strongholdCards &&
+ !g.discoveryEnabled
+ );
+}
+function recruitsInteractionPending(g: Game) {
+ return !!(
+ g.response ||
+ g.decision ||
+ g.truthtrance ||
+ g.phaseOpening ||
+ g.pendingRevival ||
+ g.pendingKarama ||
+ g.pendingTreacheryDiscard ||
+ g.pendingNullentropy ||
+ g.pendingExchange ||
+ g.pendingRicheseGift ||
+ g.pendingRichesePurchaseIncome ||
+ g.pendingAmbassador ||
+ g.pendingCapture ||
+ g.battle
+ );
+}
+function recruitsPlayBlock(g: Game, owner: Player, card: Card): string | null {
+ if (!g.ecazTreachery || !ecazTreacheryDefinition(card) || card.id !== RECRUITS_CARD_ID)
+ return 'Choose the physical Recruits card from the enabled Ecaz Treachery Cards variant.';
+ if (!recruitsModeSupported(g))
+ return 'This Recruits prototype currently requires a faction-only game without other optional modules.';
+ if (g.status !== 'playing' || g.phase !== 4)
+ return 'Play Recruits during Revival.';
+ if (g.recruits?.turn === g.turn)
+ return 'Recruits is already active this turn; a recovered copy cannot be played again.';
+ const reserved = transferCardBlock(g, owner, card);
+ if (reserved) return reserved;
+ if (recruitsInteractionPending(g))
+ return 'Finish the current transaction or priority window before playing Recruits.';
+ for (const player of g.players) {
+ if (
+ player.freeForcesRevived !== undefined &&
+ (!Number.isSafeInteger(player.freeForcesRevived) ||
+ player.freeForcesRevived < 0 ||
+ player.freeForcesRevived > player.revived)
+ )
+ return 'Normal force revival history is inconsistent; Recruits cannot safely reprice it.';
+ if (player.revived > 0 && player.freeForcesRevived === undefined)
+ return 'Earlier normal force revival history is incomplete; Recruits cannot safely reprice it.';
+ if (player.revived > (player.freeForcesRevived ?? 0))
+ return 'Recruits after a paid normal force revival awaits a rules ruling.';
+ }
+ return null;
+}
+function recruitsPreview(g: Game, viewer: Player): RecruitsPreview | null {
+ if (!g.ecazTreachery) return null;
+ ecazTreacheryIntegrity(g);
+ const held = viewer.hand.find((card) => card.id === RECRUITS_CARD_ID);
+ const play = held
+ ? { type: 'card' as const, card: RECRUITS_CARD_ID, blocked: recruitsPlayBlock(g, viewer, held) }
+ : null;
+ const active = g.recruits?.turn === g.turn;
+ return {
+ active,
+ ...(active ? { playedTurn: g.recruits!.turn, playedBy: g.recruits!.player } : {}),
+ play,
+ rates: g.order.map((player) => {
+ const target = getPlayer(g, player);
+ const allowance = normalRevivalAllowance(g, target);
+ return { player, freeRate: allowance.freeRate, limit: allowance.limit };
+ }),
+ grantRevivalBlocked: active
+ ? 'Grant Fremen ally revival before Recruits; changing the free rate after activation awaits a rules ruling.'
+ : null,
+ };
+}
/** Pure normal-lot prerequisites; free payment deliberately does not require spice. */
function normalKaramaAuction(
g: Game,
@@ -12553,6 +12704,7 @@ function completePhase(g: Game) {
if (g.dukeVidal) g.dukeVidal = expireDuke(g.dukeVidal, g.turn);
observeOccupation(g, 'turnEnd');
g.turn++;
+ g.recruits = null;
g.phase = 0;
observeOccupation(g, 'turnStart');
}
@@ -19930,6 +20082,7 @@ function normalizeCardNames(g: Game) {
]);
}
export function applyAction(state: Game, id: string, action: Action): Game {
+ ecazTreacheryIntegrity(state);
ixRicheseTechnologyIntegrity(state);
leaderSkillsIntegrity(state);
discoveryIntegrity(state);
@@ -20192,6 +20345,7 @@ function settleAutomaticContinuations(g: Game) {
}
/** Internal authoritative continuation. Callers must persist with their usual CAS fence. */
export function normalizeAutomaticGame(state: Game): Game {
+ ecazTreacheryIntegrity(state);
ixRicheseTechnologyIntegrity(state);
leaderSkillsIntegrity(state);
discoveryIntegrity(state);
@@ -20243,6 +20397,7 @@ function applyActionInner(
action: Action,
execution: 'live' | 'shipmentPreparation' = 'live',
): Game {
+ ecazTreacheryIntegrity(state);
const g = structuredClone(state);
if (g.biddingEnd && action.type !== 'advanceBots' &&
!(action.type === 'biddingEnd' && action.mode === 'ready'))
@@ -20354,6 +20509,25 @@ function applyActionInner(
throw error;
}
requireRule(!pendingNexusTraitors(g), 'Finish the private Nexus card return before continuing play.');
+ if (t === 'card' && action.card === RECRUITS_CARD_ID) {
+ requireRule(
+ Object.keys(action).sort().join(',') === 'card,type',
+ 'Play Recruits without an additional mode or selection.',
+ );
+ const card = p.hand.find((held) => held.id === RECRUITS_CARD_ID);
+ requireRule(card, 'The physical Recruits card is not in your hand.');
+ const blocked = recruitsPlayBlock(g, p, card);
+ requireRule(!blocked, blocked ?? 'Recruits is unavailable.');
+ discard(g, p, card.id);
+ for (const player of g.players) player.freeForcesRevived ??= 0;
+ g.recruits = { turn: g.turn, player: p.id, card: RECRUITS_CARD_ID };
+ log(
+ g,
+ `${p.name} played Recruits. Current free revival rates are doubled and the ordinary force revival limit is seven for this turn.`,
+ { faction: p.faction, name: 'Recruits' },
+ );
+ return g;
+ }
if (t === 'card' && action.card === 'richese-juice-of-sapho') {
playSapho(g, p, action);
return g;
@@ -22396,6 +22570,10 @@ function applyActionInner(
!g.freeRevival.includes(p.ally),
'Free revival has already been granted.',
);
+ requireRule(
+ g.recruits?.turn !== g.turn,
+ 'Grant Fremen ally revival before Recruits; changing the free rate after activation awaits a rules ruling.',
+ );
g.freeRevival.push(p.ally);
log(
g,
@@ -22593,7 +22771,8 @@ function applyActionInner(
requireRule(quote.cost <= p.spice, 'Not enough spice for revival.');
const checks: PendingRevival['checks'] = [];
const choamBenefit = p.faction === 'choam' && !g.revivalRules?.choamBlocked;
- const uncancelableLimit = Math.max(3, freeRevivalRate(g, p));
+ const uncancelableLimit =
+ g.recruits?.turn === g.turn ? 7 : Math.max(3, freeRevivalRate(g, p));
if (choamBenefit && (n > quote.free || p.revived + n > uncancelableLimit))
checks.push('choamRevival');
if (!choamBenefit && p.revived + n > uncancelableLimit)
@@ -23715,6 +23894,7 @@ function applyActionInner(
throw new RuleError('That action is not available.');
}
export function viewGame(state: Game, id: string) {
+ ecazTreacheryIntegrity(state);
ixRicheseTechnologyIntegrity(state);
leaderSkillsIntegrity(state);
discoveryFlightIntegrity(state);
@@ -24303,6 +24483,7 @@ export function viewGame(state: Game, id: string) {
g.revivalPrevention?.turn === g.turn ? g.revivalPrevention : null,
emperorExtra: g.emperorExtra ?? {},
freeRevival: g.freeRevival ?? [],
+ recruitsPreview: recruitsPreview(g, me),
revivalRules: {
...(g.revivalRules ?? newRevivalRules()),
earlyBlocked: (g.revivalRules?.earlyBlocked ?? []).filter(
diff --git a/game/recruits.ts b/game/recruits.ts
index a5c9ed21..28d6d07a 100644
--- a/game/recruits.ts
+++ b/game/recruits.ts
@@ -1,5 +1,27 @@
/** A global card effect expires when the authoritative turn changes. */
export type RecruitsEffect = Readonly<{ turn: number }>;
+export const RECRUITS_CARD_ID = 'ecaz-recruits' as const;
+export type RecruitsPlay = Readonly<{
+ type: 'card';
+ card: typeof RECRUITS_CARD_ID;
+ blocked: string | null;
+}>;
+export type RecruitsPreview = Readonly<{
+ active: boolean;
+ playedTurn?: number;
+ playedBy?: string;
+ play: RecruitsPlay | null;
+ rates: ReadonlyArray>;
+ grantRevivalBlocked: string | null;
+}>;
+
+/** A single shared control adapter for UI and bots; blocked details stay display-only. */
+export function recruitsPlayAction(
+ preview?: Pick | null,
+): { type: 'card'; card: typeof RECRUITS_CARD_ID } | null {
+ const play = preview?.play;
+ return play && !play.blocked ? { type: 'card', card: play.card } : null;
+}
export type RecruitsScope = Readonly<{
turn: number;
recruits?: RecruitsEffect | null;
diff --git a/game/reference.ts b/game/reference.ts
index 6a85b4cf..1c9fef34 100644
--- a/game/reference.ts
+++ b/game/reference.ts
@@ -3669,6 +3669,29 @@ export const RULE_TOPICS: RuleTopic[] = [
{ area:'Verification',status:'Partial',detail:'Focused inventory, scoring, Voice, capture, saved private setup/revival Planetologist movement/cancellation and Suk Graduate casualty/SQLite checks, plus Rihani privacy/concurrent recovery battle-effect regressions and Smuggler quote/decision/legacy recovery and Banker funding, death, traitor, privacy and concurrent settlement checks cover this boundary. Diplomat checks cover self-protection, mandatory discard, public-choice privacy, concurrent saved decisions and legacy continuation. Bureaucrat checks cover once-per-phase use, private source receipts and resumed payment custody. Sample games and browser continuation are recorded separately; full module acceptance remains open.',evidence:['tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/leader-skills-recovery.test.ts','tests/leader-skills-controls.test.ts','tests/suk-graduate.test.ts','tests/suk-graduate-recovery.test.ts','tests/leader-battle-effects.test.ts','tests/leader-battle-effects-recovery.test.ts','tests/leader-battle-effects-controls.test.tsx','tests/mentat-question.test.ts','tests/mentat-question-bots.test.ts','tests/mentat-question-controls.test.tsx','tests/mentat-question-recovery.test.ts','tests/bureaucrat-payment.test.ts','tests/bureaucrat-payment-controls.test.tsx','tests/bureaucrat-payment-bots.test.ts','tests/bureaucrat-payment-recovery.test.ts','tests/smuggler-shipment.test.ts','tests/smuggler-shipment-controls.test.tsx','tests/smuggler-shipment-recovery.test.ts','tests/smuggler-no-field.test.ts','tests/smuggler-no-field-controls.test.tsx','tests/smuggler-no-field-recovery.test.ts','tests/spice-banker.test.ts','tests/spice-banker-recovery.test.ts','tests/spice-banker-controls.test.tsx','tests/sandmaster-movement.test.ts','tests/sandmaster-movement-recovery.test.ts','tests/sandmaster-movement-controls.test.tsx','tests/diplomat-defense.test.ts','tests/diplomat-defense-controls.test.tsx','tests/diplomat-defense-recovery.test.ts'] },
],
},
+ {
+ id: 'card-recruits',
+ title: 'Recruits and the Ecaz Treachery Cards variant',
+ category: 'Cards',
+ coverage: 'Partial',
+ developmentStage: 'Prototyped',
+ summary: 'During Revival, Recruits doubles every faction’s current free rate and raises the ordinary force limit to seven for this turn.',
+ steps: [
+ 'The separate three-card variant includes Recruits, Reinforcements and Harass & Withdraw. It does not require the Ecaz faction. A development preview includes all three physical cards before setup; the other two battle effects remain unfinished.',
+ 'The holder may play Recruits from its Revival panel. Discard the card once, then all players use the increased rates while this Revival phase remains open, including players who had already marked themselves ready. Completed returns still count toward their turn totals.',
+ 'Atreides rises from two free forces to four; Fremen from three to six; Guild from one to two. The ordinary force limit becomes seven. Native unlimited CHOAM and Tleilaxu allowances remain unlimited, and their free rates become zero and four. Paid prices and separate elite limits still apply.',
+ 'Free-revival prevention still prevents free returns. A Fremen ally grant completed before Recruits doubles from three to six. A later grant, play after a paid ordinary return, unresolved transactions and a second use in the same turn remain guarded pending their rules decisions.',
+ 'The preview supports Basic or Advanced faction games without additional optional modules. Public activation, the other two card effects and complete combined-game acceptance remain unfinished.',
+ ],
+ related: ['revival', 'ecaz-modules', 'expansion-faction-games', 'implementation-checklist'],
+ checklist: [
+ { area: 'Implementation', status: 'Partial', detail: 'Genuine independent three-card setup, physical Recruits play/discard, public turn effect, shared revival quotes, preserved use and guarded timing are connected. Reinforcements and Harass & Withdraw remain missing.', 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 card guide records examples, current prototype limits and the independent-cap inference; late-paid and late-rate questions remain explicit.', evidence: ['docs/RECRUITS_RULES.md', 'docs/RECRUITS_RUNTIME.md'] },
+ { area: 'Verification', status: 'Partial', detail: 'Focused rules, controls, AI and authenticated recovery checks cover the bounded preview. Sample and browser evidence is recorded separately; full variant acceptance remains open.', evidence: ['tests/recruits.test.ts', 'tests/recruits-engine.test.ts', 'tests/recruits-controls.test.tsx', 'tests/recruits-recovery.test.ts', 'tests/prototype-room.test.ts'] },
+ ],
+ },
{
id: 'expansion-faction-games',
title: 'Expansion faction development games',
@@ -3680,7 +3703,7 @@ export const RULE_TOPICS: RuleTopic[] = [
'Development setup supports two through six distinct seats from the base factions and selected expansion sets. Expansion selection and optional modules are separate; public expansion starts remain disabled.',
'Ecaz places six starting forces in Imperial Basin, allocated among sectors 9, 10 and 11, and keeps fourteen in reserve. Finish Fremen placement first; Bene Gesserit then places its Advanced starting advisor. Moritani places its six forces in an unoccupied territory after all other setup is complete.',
'Ixians keep their private starting-card selection and physical cyborg/suboid forces. Tleilaxu receives three private Face Dancers. Advanced CHOAM receives its Auditor. Richese has a separate ten-card cache and three private No-Fields. Ecaz and Moritani receive their Ambassador and Terror inventories and share one Duke Vidal disc.',
- 'Without Ix, CHOAM adds Poison Tooth and Artillery Strike to the 33-card ordinary deck. With Ix, its 47-card deck contains those physical cards once. Ecaz faction selection adds no Treachery Cards: Recruits, Reinforcements and Harass & Withdraw form a separate unfinished optional variant.',
+ 'Without Ix, CHOAM adds Poison Tooth and Artillery Strike to the 33-card ordinary deck. With Ix, its 47-card deck contains those physical cards once. Ecaz faction selection adds no Treachery Cards: the separate three-card variant has a Recruits development preview; Reinforcements and Harass & Withdraw remain unfinished.',
'Advanced Ixians may explicitly decline Technology for one Richese special lot. Their once-per-round use remains available later. The special-lot exchange itself is unfinished pending replacement-card custody interpretation.',
'Fresh unsupported movement arrivals reject before opening prevention choices or reserving a new movement card. A saved CHOAM allow choice can return an uncommitted unsupported move for another choice, preserving its existing reserved Ornithopter. Simultaneous Ambassador/Terror order remains unresolved.',
'Ordinary shipment checks unfinished arrival combinations before saving the Guild interception choice. An already saved ordinary shipment may return unspent to its shipper when allowance encounters that arrival guard, so the shipper can choose another destination. Older No-Field, Smuggler, Nexus and Ambassador shipments are excluded from this recovery. Simultaneous arrival order remains unfinished.',
diff --git a/game/revival.ts b/game/revival.ts
index 8cfd111a..4f0811b2 100644
--- a/game/revival.ts
+++ b/game/revival.ts
@@ -2,9 +2,16 @@ import type { Game, Player } from './engine';
import { faction, type FactionId } from './catalog';
import { isAuditorLeader } from './choam-auditor';
import { homeworldLowBonus } from './homeworld-benefits';
+import { recruitsRevivalAllowance } from './recruits';
type RevivalRateContext = Pick<
Game,
- 'advanced' | 'players' | 'homeworlds' | 'revivalRules' | 'freeRevival'
+ | 'advanced'
+ | 'players'
+ | 'homeworlds'
+ | 'revivalRules'
+ | 'freeRevival'
+ | 'turn'
+ | 'recruits'
>;
export type RevivalRules = {
expanded: string[];
@@ -57,19 +64,35 @@ export function freeRevivalRate(
* the extra free rate never creates an additional paid allowance. */
export function normalForceRevivalLimit(
g: RevivalRateContext,
- p: Pick,
+ p: Pick,
) {
- return Math.max(forceRevivalLimit(g, p), freeRevivalRate(g, p));
+ return normalRevivalAllowance(g, p).limit;
+}
+export function normalRevivalAllowance(
+ g: RevivalRateContext,
+ p: Pick,
+) {
+ const currentFreeRate = freeRevivalRate(g, p);
+ return recruitsRevivalAllowance(g, {
+ currentFreeRate,
+ currentLimit: Math.max(forceRevivalLimit(g, p), currentFreeRate),
+ revived: p.revived,
+ // A Recruits activation is rejected when this ledger is absent and use is
+ // nonzero. The fallback preserves old-room behavior while the card is idle.
+ freeAllowanceUsed:
+ g.recruits?.turn === g.turn ? (p.freeForcesRevived ?? p.revived) : p.revived,
+ freeRateCap: 20,
+ });
}
export function freeRevivalRemaining(
g: RevivalRateContext,
- p: Pick,
+ p: Pick,
) {
- return Math.max(0, freeRevivalRate(g, p) - p.revived);
+ return normalRevivalAllowance(g, p).freeRemaining;
}
export function forceRevivalQuote(
g: RevivalRateContext,
- p: Pick,
+ p: Pick,
amount: number,
elite = 0,
freeElite?: number,
@@ -89,7 +112,7 @@ export function forceRevivalQuote(
/** Normal force returns only; Emperor-funded extra revivals and card effects are separate. */
export function forceRevivalRemaining(
g: RevivalRateContext,
- p: Pick,
+ p: Pick,
) {
return Math.max(
0,
diff --git a/tests/ecaz-cards.test.ts b/tests/ecaz-cards.test.ts
index f4067c1d..3bf40574 100644
--- a/tests/ecaz-cards.test.ts
+++ b/tests/ecaz-cards.test.ts
@@ -42,7 +42,7 @@ void test('the verified Ecaz variant has one physical special of each identity a
assert.throws(() => treacheryDeck([unsupported]), /not implemented/);
assert.equal(ECAZ_TREACHERY_VARIANT.independentOfFactions, true);
assert.equal(ECAZ_TREACHERY_VARIANT.independentOfOtherVariants, true);
- assert.equal(ECAZ_TREACHERY_VARIANT.activation, 'not-implemented');
+ assert.equal(ECAZ_TREACHERY_VARIANT.activation, 'audit-prototype');
});
void test('Ecaz faction selection preserves each ordinary deck and never includes its separate unfinished variant', () => {
@@ -121,7 +121,10 @@ void test('verified inventory and source rules remain distinct from unimplemente
assert.equal(definition.quantity, 1);
assert.equal(definition.verification.inventory, 'verified');
assert.equal(definition.verification.sourceRules, 'verified');
- assert.equal(definition.verification.runtime, 'not-implemented');
+ assert.equal(
+ definition.verification.runtime,
+ definition.card.effect === 'recruits' ? 'prototype' : 'not-implemented',
+ );
assert.equal(definition.verification.combinedInteractions, 'incomplete');
assert.ok(definition.verification.unresolved.length > 0);
assert.ok(definition.summary.length > 20);
@@ -139,6 +142,7 @@ void test('every canonical Ecaz face receives its full original gameplay guide a
assert.equal(presentation.category, 'Special treachery');
assert.equal(presentation.role, 'utility');
assert.deepEqual(Object.keys(presentation).sort(), [
+ 'availability',
'category',
'gameplay',
'guidance',
@@ -150,7 +154,9 @@ void test('every canonical Ecaz face receives its full original gameplay guide a
assert.equal(serialized.includes(note), false);
assert.equal(serialized.includes('sourceRules'), false);
assert.equal(serialized.includes('not-implemented'), false);
- assert.deepEqual(presentation.topics, []);
+ assert.deepEqual(presentation.topics.map((topic) => topic.id), ['card-recruits']);
+ assert.match(presentation.availability ?? '', card.effect === 'recruits'
+ ? /development controls during Revival/ : /battle effect is still being implemented/);
}
});
diff --git a/tests/fixture-recruits.ts b/tests/fixture-recruits.ts
new file mode 100644
index 00000000..3d07f8f8
--- /dev/null
+++ b/tests/fixture-recruits.ts
@@ -0,0 +1,88 @@
+import assert from 'node:assert/strict';
+import {
+ applyAction,
+ createGame,
+ initializeEcazTreacheryGameForAudit,
+ joinGame,
+ newPlayer,
+ viewGame,
+ type Game,
+} from '../game/engine';
+import { botActions } from '../game/bots';
+
+function seeded(seed: number, work: () => T): T {
+ const original = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
+ let state = seed >>> 0;
+ Object.defineProperty(crypto, 'getRandomValues', {
+ configurable: true,
+ value: (array: Uint32Array) => {
+ for (let i = 0; i < array.length; i++) {
+ state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
+ array[i] = state;
+ }
+ return array;
+ },
+ });
+ try {
+ return work();
+ } finally {
+ if (original) Object.defineProperty(crypto, 'getRandomValues', original);
+ }
+}
+
+/** Genuine faction setup with the independent cards included before shuffling. */
+export function recruitsGame(
+ advanced = true,
+ seed = 3,
+ seatIds: readonly [string, string, string, string] = ['at', 'fr', 'tl', 'ch'],
+): Game {
+ return seeded(seed, () => {
+ let g = createGame('RECRUITS', newPlayer(seatIds[0], 'Atreides', 'atreides'), advanced, [
+ 'ix',
+ 'choam',
+ ]);
+ for (const [id, name, faction] of [
+ [seatIds[1], 'Fremen', 'fremen'],
+ [seatIds[2], 'Tleilaxu', 'tleilaxu'],
+ [seatIds[3], 'CHOAM', 'choam'],
+ ] as const)
+ joinGame(g, newPlayer(id, name, faction));
+ for (const player of g.players) g = applyAction(g, player.id, { type: 'ready' });
+ g = initializeEcazTreacheryGameForAudit(g);
+ for (let step = 0; g.status === 'setup' && step < 80; step++) {
+ let next: Game | undefined;
+ for (const player of g.players) {
+ const view = viewGame(g, player.id);
+ view.players.find((candidate) => candidate.id === player.id)!.bot = 'Medium';
+ const action = botActions(view)[0];
+ if (action) {
+ next = applyAction(g, player.id, action);
+ break;
+ }
+ }
+ assert.ok(next, 'Genuine setup must provide a legal continuation.');
+ g = next;
+ }
+ assert.equal(g.status, 'playing');
+ assert.ok(g.players.some((player) => player.hand.some((card) => card.id === 'ecaz-recruits')),
+ 'Fixture seed must deal Recruits without restaging card custody.');
+ g.turn = 2;
+ g.phase = 4;
+ g.active = null;
+ g.ready = [];
+ g.phaseOpening = null;
+ g.decision = null;
+ g.response = null;
+ g.revivalRules = undefined;
+ g.freeRevival = [];
+ for (const player of g.players) {
+ player.bot = undefined;
+ player.revived = 0;
+ player.freeForcesRevived = 0;
+ player.spice = 20;
+ player.tanks = Math.min(10, player.reserves - (player.elites?.reserves ?? 0));
+ player.reserves -= player.tanks;
+ }
+ return g;
+ });
+}
diff --git a/tests/prototype-room.test.ts b/tests/prototype-room.test.ts
index 5723a30f..04c8cdfc 100644
--- a/tests/prototype-room.test.ts
+++ b/tests/prototype-room.test.ts
@@ -49,6 +49,25 @@ function fixture(t: test.TestContext) {
return { db, game };
}
+void test('Ecaz Treachery preview saves the complete independent inventory before genuine setup once', (t) => {
+ const { db } = fixture(t);
+ const beforeSeats = db.prepare('SELECT * FROM seats').all();
+ const other = db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00');
+ const result = startPrototypeRoom(db, 'PROTOTYP', 7, 'ecaz-treachery');
+ const row = db.prepare('SELECT state FROM rooms WHERE code=?').get('PROTOTYP')!;
+ const g = JSON.parse(row.state as string) as Game;
+ assert.equal(result.version, 8);
+ assert.equal(g.ecazTreachery, true);
+ const cards = [...g.deck, ...g.players.flatMap((p) => p.hand), ...(g.ixSetupCards ?? [])];
+ assert.deepEqual(cards.filter((c) => c.id.startsWith('ecaz-')).map((c) => c.id).sort(),
+ ['ecaz-harass-withdraw', 'ecaz-recruits', 'ecaz-reinforcements']);
+ assert.deepEqual(db.prepare('SELECT * FROM seats').all(), beforeSeats);
+ assert.deepEqual(db.prepare('SELECT * FROM rooms WHERE code=?').get('KEEPME00'), other);
+ const rows = db.prepare('SELECT * FROM rooms').all();
+ assert.throws(() => startPrototypeRoom(db, 'PROTOTYP', 8, 'ecaz-treachery'), /cannot redeal/);
+ assert.deepEqual(db.prepare('SELECT * FROM rooms').all(), rows);
+});
+
void test('local prototype starts the named ready lobby once, retaining every seat and other room', (t) => {
const { db } = fixture(t);
const seats = db.prepare('SELECT * FROM seats').all();
diff --git a/tests/recruits-controls.test.tsx b/tests/recruits-controls.test.tsx
new file mode 100644
index 00000000..d4db5e47
--- /dev/null
+++ b/tests/recruits-controls.test.tsx
@@ -0,0 +1,110 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { createElement } from 'react';
+import { renderToStaticMarkup } from 'react-dom/server';
+import { Recruits } from '../components/recruits';
+import { applyAction, viewGame, type Game } from '../game/engine';
+import { botActions } from '../game/bots';
+import { recruitsPlayAction } from '../game/recruits';
+import { cardPresentation } from '../game/card-presentation';
+import { recruitsGame } from './fixture-recruits';
+
+function ownerGame() {
+ const g = recruitsGame();
+ const owner = g.players.find((p) => p.hand.some((c) => c.id === 'ecaz-recruits'))!;
+ const atreides = g.players.find((p) => p.faction === 'atreides')!;
+ const index = owner.hand.findIndex((c) => c.id === 'ecaz-recruits');
+ // A conserved fixture exchange gives the free-rate faction the actual card.
+ [owner.hand[index], atreides.hand[0]] = [atreides.hand[0], owner.hand[index]];
+ return g;
+}
+const render = (g: Game, viewer = 'at', busy = false) => renderToStaticMarkup(
+ createElement(Recruits, { game: viewGame(g, viewer), act() {}, busy }),
+);
+function allowRevival(g: Game) {
+ for (let step = 0; g.pendingRevival && step < 20; step++) {
+ let next: Game | undefined;
+ for (const p of g.players) {
+ const view = viewGame(g, p.id);
+ view.players.find((player) => player.id === p.id)!.bot = 'Easy';
+ const action = botActions(view)[0];
+ if (action) { next = applyAction(g, p.id, action); break; }
+ }
+ assert.ok(next, 'Revival decisions must have legal continuation.');
+ g = next;
+ }
+ assert.equal(g.pendingRevival, null);
+ return g;
+}
+
+void test('Recruits owner has an accessible physical-card action; rivals cannot infer its owner before play', () => {
+ const g = ownerGame(), html = render(g);
+ assert.match(html, /Play Recruits · Discard card/);
+ assert.match(html, /Current revival rates/);
+ assert.match(html, /Unlimited/);
+ assert.doesNotMatch(html, /disabled=""/);
+ assert.match(render(g, 'at', true), /disabled=""/);
+ assert.equal(render(g, 'fr'), '');
+ assert.equal(viewGame(g, 'fr').recruitsPreview?.play, null);
+ const card = g.players[0].hand.find((c) => c.id === 'ecaz-recruits')!;
+ assert.ok(cardPresentation(card).topics.some((topic) => topic.id === 'card-recruits'));
+});
+
+void test('late-paid and wrong-phase controls stay guarded through the shared action adapter', () => {
+ let g = ownerGame();
+ g = allowRevival(applyAction(g, 'at', { type: 'revive', amount: 3 }));
+ const preview = viewGame(g, 'at').recruitsPreview;
+ assert.match(preview?.play?.blocked ?? '', /paid normal force revival/);
+ assert.equal(recruitsPlayAction(preview), null);
+ assert.match(render(g), /aria-describedby="recruits-unavailable"/);
+ assert.match(render(g), /disabled=""/);
+ g.phase = 5;
+ assert.equal(render(g), '');
+ assert.equal(recruitsPlayAction(undefined), null);
+});
+
+void test('resolved public rates survive JSON restoration without displaying an extra card action', () => {
+ const g = applyAction(ownerGame(), 'at', { type: 'card', card: 'ecaz-recruits' });
+ const restored = JSON.parse(JSON.stringify(g)) as Game;
+ for (const p of g.players) {
+ const html = render(restored, p.id);
+ assert.match(html, /Active this turn/);
+ assert.doesNotMatch(html, /Play Recruits · Discard card/);
+ assert.deepEqual(viewGame(restored, p.id).recruitsPreview, viewGame(g, p.id).recruitsPreview);
+ }
+});
+
+for (const level of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
+ void test(`${level} uses the owned offer once, then legally revives at the doubled rate`, () => {
+ let g = ownerGame();
+ let view = viewGame(g, 'at');
+ view.players.find((p) => p.id === 'at')!.bot = level;
+ const action = botActions(view)[0];
+ assert.deepEqual(action, { type: 'card', card: 'ecaz-recruits' });
+ assert.deepEqual(action, recruitsPlayAction(view.recruitsPreview));
+ g = applyAction(g, 'at', action);
+ view = viewGame(g, 'at');
+ view.players.find((p) => p.id === 'at')!.bot = level;
+ const revival = botActions(view).find((candidate) => candidate.type === 'revive');
+ assert.ok(revival);
+ const done = allowRevival(applyAction(g, 'at', revival));
+ assert.ok(done.players[0].revived > 0);
+ assert.equal(done.players[0].freeForcesRevived, 4);
+ assert.equal(done.discard.filter((c) => c.id === 'ecaz-recruits').length, 1);
+ assert.ok(!botActions(view).some((candidate) => candidate.type === 'card' && candidate.card === 'ecaz-recruits'));
+ });
+}
+
+void test('all AI profiles skip the unresolved late Fremen grant while using other legal revival choices', () => {
+ let g = ownerGame();
+ g.players[0].ally = 'fr'; g.players[1].ally = 'at';
+ g = applyAction(g, 'at', { type: 'card', card: 'ecaz-recruits' });
+ for (const level of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
+ const view = viewGame(g, 'fr');
+ view.players.find((p) => p.id === 'fr')!.bot = level;
+ const actions = botActions(view);
+ assert.ok(actions.length);
+ assert.ok(!actions.some((action) => action.type === 'grantRevival'));
+ assert.doesNotThrow(() => applyAction(g, 'fr', actions[0]));
+ }
+});
diff --git a/tests/recruits-engine.test.ts b/tests/recruits-engine.test.ts
new file mode 100644
index 00000000..2523a389
--- /dev/null
+++ b/tests/recruits-engine.test.ts
@@ -0,0 +1,265 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ applyAction,
+ createGame,
+ initializeBaseGameForAudit,
+ joinGame,
+ newPlayer,
+ viewGame,
+ type Action,
+ type Game,
+} from '../game/engine';
+import { recruitsPlayAction } from '../game/recruits';
+import { normalRevivalAllowance, newRevivalRules } from '../game/revival';
+import { botActions } from '../game/bots';
+import { recruitsGame } from './fixture-recruits';
+
+const play = { type: 'card', card: 'ecaz-recruits' } as const;
+function rejectUnchanged(g: Game, player: string, action: Action = play) {
+ const before = structuredClone(g);
+ assert.throws(() => applyAction(g, player, action));
+ assert.deepEqual(g, before);
+}
+function physical(g: Game) {
+ return [
+ ...g.deck,
+ ...g.discard,
+ ...g.players.flatMap((player) => player.hand),
+ ...(g.ixSetupCards ?? []),
+ ...(g.ixAuction?.cards ?? []),
+ ...(g.auction?.cards.slice(g.auction.index) ?? []),
+ ];
+}
+function settleRevival(state: Game) {
+ let g = state;
+ for (let step = 0; g.pendingRevival && step < 20; step++) {
+ let next: Game | undefined;
+ for (const player of g.players) {
+ const view = viewGame(g, player.id);
+ view.players.find((candidate) => candidate.id === player.id)!.bot = 'Medium';
+ const action = botActions(view)[0];
+ if (action) {
+ next = applyAction(g, player.id, action);
+ break;
+ }
+ }
+ assert.ok(next, 'Pending revival must retain a legal continuation.');
+ g = next;
+ }
+ assert.equal(g.pendingRevival, null);
+ return g;
+}
+
+void test('genuine Basic and Advanced setup owns exactly one of all three independent cards and projects only the holder play', () => {
+ for (const advanced of [false, true]) {
+ const g = recruitsGame(advanced);
+ const cards = physical(g).filter((card) => card.id.startsWith('ecaz-'));
+ assert.deepEqual(cards.map((card) => card.id).sort(), [
+ 'ecaz-harass-withdraw',
+ 'ecaz-recruits',
+ 'ecaz-reinforcements',
+ ]);
+ const owner = g.players.find((player) => player.hand.some((card) => card.id === play.card))!;
+ for (const player of g.players) {
+ const preview = viewGame(g, player.id).recruitsPreview;
+ assert.ok(preview);
+ assert.equal(preview.active, false);
+ assert.deepEqual(
+ recruitsPlayAction(preview),
+ player.id === owner.id ? play : null,
+ );
+ assert.equal(preview.play === null, player.id !== owner.id);
+ }
+ }
+});
+
+void test('playing Recruits discards its exact card and publishes effective rates and independent limits', () => {
+ for (const advanced of [false, true]) {
+ const before = recruitsGame(advanced);
+ const g = applyAction(before, 'ch', play);
+ assert.deepEqual(g.recruits, { turn: 2, player: 'ch', card: play.card });
+ assert.equal(g.players.find((player) => player.id === 'ch')!.hand.some((card) => card.id === play.card), false);
+ assert.equal(g.discard.filter((card) => card.id === play.card).length, 1);
+ const preview = viewGame(JSON.parse(JSON.stringify(g)), 'at').recruitsPreview!;
+ assert.deepEqual(
+ Object.fromEntries(preview.rates.map((row) => [row.player, [row.freeRate, row.limit]])),
+ { at: [4, 7], fr: [6, 7], tl: [4, 20], ch: [0, 20] },
+ );
+ assert.equal(preview.active, true);
+ assert.equal(preview.playedBy, 'ch');
+ assert.equal(preview.grantRevivalBlocked?.includes('before Recruits'), true);
+ }
+});
+
+void test('the accepted card changes the authoritative normal revival quote and free ledger', () => {
+ let g = applyAction(recruitsGame(true), 'ch', play);
+ const player = g.players.find((candidate) => candidate.id === 'at')!;
+ const before = { reserves: player.reserves, tanks: player.tanks, spice: player.spice };
+ g = settleRevival(applyAction(g, player.id, { type: 'revive', amount: 4, elite: 0 }));
+ const after = g.players.find((candidate) => candidate.id === player.id)!;
+ assert.equal(after.reserves, before.reserves + 4);
+ assert.equal(after.tanks, before.tanks - 4);
+ assert.equal(after.spice, before.spice);
+ assert.equal(after.revived, 4);
+ assert.equal(after.freeForcesRevived, 4);
+ assert.equal(viewGame(g, player.id).revival.forcesRemaining, 3);
+ rejectUnchanged(g, player.id, { type: 'revive', amount: 4, elite: 0 });
+});
+
+void test('free prevention remains zero and canceled unlimited faction caps compose to seven', () => {
+ const g = recruitsGame();
+ g.revivalRules = {
+ ...newRevivalRules(),
+ freeBlocked: ['at'],
+ choamBlocked: true,
+ limitBlocked: true,
+ };
+ const active = applyAction(g, 'ch', play);
+ const rows = Object.fromEntries(
+ viewGame(active, 'at').recruitsPreview!.rates.map((row) => [row.player, row]),
+ );
+ assert.equal(rows.at.freeRate, 0);
+ assert.equal(rows.at.limit, 7);
+ assert.equal(rows.tl.limit, 7);
+ assert.equal(rows.ch.limit, 7);
+});
+
+void test('prior settled Fremen ally grant is doubled, while a grant after activation rejects immutably', () => {
+ let g = recruitsGame();
+ const fremen = g.players.find((player) => player.id === 'fr')!;
+ const atreides = g.players.find((player) => player.id === 'at')!;
+ fremen.ally = atreides.id;
+ atreides.ally = fremen.id;
+ g = applyAction(g, fremen.id, { type: 'grantRevival' });
+ g = applyAction(g, 'ch', play);
+ assert.deepEqual(
+ viewGame(g, 'at').recruitsPreview!.rates.find((row) => row.player === 'at'),
+ { player: 'at', freeRate: 6, limit: 7 },
+ );
+
+ const late = recruitsGame();
+ late.players.find((player) => player.id === 'fr')!.ally = 'at';
+ late.players.find((player) => player.id === 'at')!.ally = 'fr';
+ const active = applyAction(late, 'ch', play);
+ rejectUnchanged(active, 'fr', { type: 'grantRevival' });
+});
+
+void test('activation rejects pending, prior paid, unknown and inconsistent revival histories without consuming the card', () => {
+ const cases = [
+ (g: Game) => {
+ g.decision = { kind: 'revivalStop', player: 'at', recipient: 'at', revival: 'forces' };
+ },
+ (g: Game) => { const p = g.players[0]; p.revived = 2; p.freeForcesRevived = 1; },
+ (g: Game) => { const p = g.players[0]; p.revived = 1; delete p.freeForcesRevived; },
+ (g: Game) => { const p = g.players[0]; p.revived = 1; p.freeForcesRevived = 2; },
+ ];
+ for (const arrange of cases) {
+ const g = recruitsGame();
+ arrange(g);
+ assert.ok(viewGame(g, 'ch').recruitsPreview!.play?.blocked);
+ rejectUnchanged(g, 'ch');
+ }
+});
+
+void test('same-turn Box recovery cannot replay or consume Recruits', () => {
+ const g = applyAction(recruitsGame(), 'ch', play);
+ const card = g.discard.find((candidate) => candidate.id === play.card)!;
+ g.discard = g.discard.filter((candidate) => candidate.id !== play.card);
+ g.players.find((player) => player.id === 'ch')!.hand.push(card);
+ assert.equal(viewGame(g, 'ch').recruitsPreview!.play?.blocked?.includes('already active'), true);
+ rejectUnchanged(g, 'ch');
+});
+
+void test('a ready owner may still play before Revival closes, and the receipt expires on the next turn', () => {
+ let g = recruitsGame();
+ for (const player of g.players) {
+ player.reserves += Object.values(player.forces).reduce((sum, amount) => sum + amount, 0);
+ player.forces = {};
+ if (player.elites) {
+ player.elites.reserves += Object.values(player.elites.forces).reduce((sum, amount) => sum + amount, 0);
+ player.elites.forces = {};
+ }
+ if (player.advisors) player.advisors = {};
+ }
+ g.ready = ['ch'];
+ g = applyAction(g, 'ch', play);
+ assert.equal(g.recruits?.turn, 2);
+ for (let step = 0; g.status === 'playing' && g.turn === 2 && step < 1000; step++) {
+ let next: Game | undefined;
+ for (const player of g.players) {
+ const view = viewGame(g, player.id);
+ view.players.find((candidate) => candidate.id === player.id)!.bot = 'Medium';
+ const action = botActions(view)[0];
+ if (action) {
+ next = applyAction(g, player.id, action);
+ break;
+ }
+ }
+ assert.ok(next, 'The active effect must retain a legal path to turn end.');
+ g = next;
+ }
+ assert.equal(g.turn, 3);
+ assert.equal(g.recruits, null);
+ assert.equal(viewGame(g, 'at').recruitsPreview?.active, false);
+});
+
+void test('legacy known-zero ledgers are initialized atomically when Recruits is accepted', () => {
+ const g = recruitsGame();
+ for (const player of g.players) delete player.freeForcesRevived;
+ const active = applyAction(g, 'ch', play);
+ assert.ok(active.players.every((player) => player.freeForcesRevived === 0));
+ assert.doesNotThrow(() => viewGame(JSON.parse(JSON.stringify(active)), 'at'));
+});
+
+void test('a sold Recruits card is counted once while its normal auction continuation is open', () => {
+ const g = recruitsGame();
+ const card = g.players.find((player) => player.id === 'ch')!.hand.find((candidate) => candidate.id === play.card)!;
+ g.phase = 3;
+ g.auction = { cards: [card], index: 0, bid: 1, bidder: 'ch', active: 'ch', passed: [], opener: 0, peekKnown: false };
+ g.currentAuctionSale = { winner: 'ch', amount: 1, free: false, origin: 'normal', seller: null };
+ g.response = { kind: 'harkonnenBonus', owner: 'ch', passed: [] };
+ assert.doesNotThrow(() => viewGame(g, 'at'));
+});
+
+void test('ordinary audit setup cannot silently activate a forged Ecaz variant marker', () => {
+ const lobby = createGame('NOECAZV', newPlayer('at', 'Atreides', 'atreides'));
+ joinGame(lobby, newPlayer('fr', 'Fremen', 'fremen'));
+ for (const player of lobby.players) player.ready = true;
+ lobby.ecazTreachery = true;
+ rejectUnchanged(lobby, 'at', { type: 'start' });
+ assert.throws(() => initializeBaseGameForAudit(lobby), /audit initializer supports/);
+});
+
+void test('ordinary revival math remains unchanged while Recruits is inactive', () => {
+ const g = recruitsGame();
+ const player = g.players.find((candidate) => candidate.id === 'at')!;
+ g.revivalRules = newRevivalRules();
+ g.freeRevival = [player.id];
+ player.revived = 2;
+ player.freeForcesRevived = 0;
+ assert.equal(normalRevivalAllowance(g, player).freeRemaining, 1);
+ g.recruits = { turn: g.turn, player: 'ch', card: 'ecaz-recruits' };
+ assert.equal(normalRevivalAllowance(g, player).freeRemaining, 5);
+});
+
+void test('preview rejects missing, duplicate and forged physical variant cards', () => {
+ for (const corrupt of [
+ (g: Game) => { g.deck = g.deck.filter((card) => card.id !== 'ecaz-reinforcements'); },
+ (g: Game) => { g.deck.push({ ...physical(g).find((card) => card.id === 'ecaz-harass-withdraw')! }); },
+ (g: Game) => {
+ const card = physical(g).find((candidate) => candidate.id === 'ecaz-reinforcements')!;
+ card.effect = 'recruits';
+ },
+ ]) {
+ const g = recruitsGame();
+ corrupt(g);
+ assert.throws(() => viewGame(g, 'at'), /inventory/);
+ }
+});
+
+void test('seat-control changes cannot persist a malformed variant save', () => {
+ const g = recruitsGame();
+ g.deck = g.deck.filter((card) => card.id !== 'ecaz-reinforcements');
+ rejectUnchanged(g, 'at', { type: 'setAutopilot', difficulty: 'Medium' });
+});
diff --git a/tests/recruits-recovery.test.ts b/tests/recruits-recovery.test.ts
new file mode 100644
index 00000000..75ecc26f
--- /dev/null
+++ b/tests/recruits-recovery.test.ts
@@ -0,0 +1,417 @@
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import type { DatabaseSync } from 'node:sqlite';
+import { viewGame, type Action, type Game } from '../game/engine';
+import { treacheryDeck } from '../game/cards';
+import { ecazTreacheryCards } from '../game/ecaz-cards';
+import { recruitsPlayAction } from '../game/recruits';
+import { traitorDeck } from '../game/traitors';
+import type { RoomsClock } from '../db/rooms';
+import { unitStore } from './fixture-nexus-room-store';
+import { recruitsGame } from './fixture-recruits';
+
+const clock: RoomsClock = { now: () => 73000, sleep: async () => {} };
+const play: Action = { type: 'card', card: 'ecaz-recruits' };
+const rows = (sqlite: DatabaseSync) => ({
+ rooms: sqlite.prepare('SELECT * FROM rooms ORDER BY code').all(),
+ seats: sqlite.prepare('SELECT * FROM seats ORDER BY player_id').all(),
+});
+
+function custody(g: Game) {
+ const cards = [...g.deck, ...g.discard, ...g.players.flatMap((p) => p.hand)];
+ assert.deepEqual(
+ cards.map((card) => card.id).sort(),
+ [...treacheryDeck(g.expansions), ...ecazTreacheryCards()]
+ .map((card) => card.id)
+ .sort(),
+ );
+ const traitors = [
+ ...(g.traitorReserve ?? []),
+ ...g.players.flatMap((p) => [
+ ...p.traitors,
+ ...(p.faceDancers ?? []).map((card) => card.leader),
+ ]),
+ ];
+ assert.deepEqual(traitors.sort(), traitorDeck(g.players, true).sort());
+ for (const p of g.players) {
+ const values = [p.reserves, p.tanks, ...Object.values(p.forces)];
+ assert.ok(values.every((n) => Number.isSafeInteger(n) && n >= 0));
+ assert.equal(
+ values.reduce((a, b) => a + b, 0),
+ 20,
+ );
+ if (p.elites) {
+ const elite = [
+ p.elites.reserves,
+ p.elites.tanks,
+ ...Object.values(p.elites.forces),
+ ];
+ assert.ok(elite.every((n) => Number.isSafeInteger(n) && n >= 0));
+ assert.equal(
+ elite.reduce((a, b) => a + b, 0),
+ 3,
+ );
+ assert.ok(p.elites.reserves <= p.reserves && p.elites.tanks <= p.tanks);
+ for (const [location, amount] of Object.entries(p.elites.forces))
+ assert.ok(amount <= (p.forces[location] ?? 0));
+ }
+ }
+}
+
+async function fixture(t: test.TestContext, advanced = true) {
+ const store = unitStore();
+ t.after(() => store.sqlite.close());
+ const made = await store.rooms.createRoom(
+ 'Recruits recovery',
+ 'atreides',
+ advanced,
+ ['ix', 'choam'],
+ );
+ const code = made.view.code,
+ tokens = [made.token];
+ for (const faction of ['fremen', 'tleilaxu', 'choam'] as const)
+ tokens.push((await store.rooms.joinRoom(code, faction, faction)).token!);
+ const auths = await Promise.all(
+ tokens.map((token) => store.rooms.authenticate(code, token)),
+ );
+ const ids = [
+ auths[0].playerId,
+ auths[1].playerId,
+ auths[2].playerId,
+ auths[3].playerId,
+ ] as const;
+ // Genuine identities/setup and dealt cards; the shared fixture explicitly stages Revival and normal losses.
+ const g = recruitsGame(advanced, 3, ids);
+ g.code = code;
+ g.version = (await store.rooms.readRoom(code)).version;
+ custody(g);
+ const owner = g.players.find((p) =>
+ p.hand.some((card) => card.id === 'ecaz-recruits'),
+ )!.id;
+ assert.equal(owner, ids[3]);
+ store.sqlite
+ .prepare('UPDATE rooms SET state=?, version=? WHERE code=?')
+ .run(JSON.stringify(g), g.version, code);
+ store.writes.length = 0;
+ return { ...store, code, tokens, auths, ids, owner };
+}
+type Fixture = Awaited>;
+
+async function act(f: Fixture, player: string, action: Action) {
+ const rooms = f.restart(),
+ index = f.ids.indexOf(player);
+ assert.ok(index >= 0);
+ const auth = await rooms.authenticate(f.code, f.tokens[index]);
+ const before = await rooms.readRoom(f.code);
+ await rooms.act(f.code, auth, before.version, action, clock);
+ const after = await rooms.readRoom(f.code);
+ custody(after);
+ return after;
+}
+
+async function restored(f: Fixture) {
+ const g = await f.restart().readRoom(f.code),
+ before = rows(f.sqlite);
+ const publicRates = viewGame(g, f.owner).recruitsPreview!.rates;
+ for (const [index, token] of f.tokens.entries()) {
+ const rooms = f.restart(),
+ auth = await rooms.authenticate(f.code, token);
+ const view = await rooms.readSeatView(f.code, auth);
+ assert.deepEqual(view, viewGame(g, f.ids[index]));
+ assert.deepEqual(
+ view,
+ viewGame(JSON.parse(JSON.stringify(g)) as Game, f.ids[index]),
+ );
+ assert.deepEqual(view.recruitsPreview!.rates, publicRates);
+ if (auth.playerId !== f.owner)
+ assert.equal(view.recruitsPreview!.play, null);
+ for (const rival of view.players.filter((p) => p.id !== auth.playerId))
+ for (const field of [
+ 'hand',
+ 'traitors',
+ 'spice',
+ 'prediction',
+ 'faceDancers',
+ ])
+ assert.equal(field in rival, false);
+ }
+ assert.deepEqual(rows(f.sqlite), before);
+ custody(g);
+ return g;
+}
+
+async function noWrite(
+ f: Fixture,
+ player: string,
+ action: Action,
+ version?: number,
+ reason?: RegExp,
+) {
+ const rooms = f.restart(),
+ auth = await rooms.authenticate(f.code, f.tokens[f.ids.indexOf(player)]);
+ const current = await rooms.readRoom(f.code),
+ before = rows(f.sqlite),
+ writes = f.writes.length;
+ const attempt = rooms.act(
+ f.code,
+ auth,
+ version ?? current.version,
+ action,
+ clock,
+ );
+ if (reason) await assert.rejects(attempt, reason);
+ else await assert.rejects(attempt);
+ assert.deepEqual(rows(f.sqlite), before);
+ assert.equal(f.writes.length, writes);
+}
+
+/** Allow only the existing revival/CHOAM/Tleilaxu continuation through authenticated room actions. */
+async function settleRevival(f: Fixture) {
+ for (let step = 0; step < 30; step++) {
+ const g = await f.restart().readRoom(f.code);
+ if (!g.pendingRevival && !g.decision && !g.response) return g;
+ if (g.decision) {
+ assert.ok(['choamFreeRevival', 'revivalStop'].includes(g.decision.kind));
+ await act(f, g.decision.player, { type: 'decision', decline: true });
+ } else if (g.response) {
+ const next = g.players.find((p) => !g.response!.passed.includes(p.id));
+ assert.ok(next);
+ await act(f, next.id, { type: 'passResponse' });
+ } else assert.fail('A pending revival needs an explicit continuation.');
+ }
+ assert.fail('The saved revival failed to settle.');
+}
+
+void test('Recruits restores privately and counts earlier free revivals once across later free and paid returns', async (t) => {
+ for (const advanced of [false, true]) {
+ const f = await fixture(t, advanced),
+ original = await restored(f);
+ const seats = rows(f.sqlite).seats,
+ initialAtreides = original.players[0];
+ assert.deepEqual(
+ recruitsPlayAction(viewGame(original, f.owner).recruitsPreview),
+ play,
+ );
+ await noWrite(f, f.ids[0], play, undefined, /not in your hand/);
+ await noWrite(
+ f,
+ f.owner,
+ { ...play, blocked: null },
+ undefined,
+ /additional mode or selection/,
+ );
+
+ await act(f, f.ids[0], { type: 'revive', amount: 2, elite: 0 });
+ const priorFree = await settleRevival(f);
+ assert.equal(priorFree.players[0].freeForcesRevived, 2);
+ assert.equal(priorFree.players[0].revived, 2);
+ assert.equal(priorFree.players[0].spice, initialAtreides.spice);
+ const played = await act(f, f.owner, play);
+ assert.deepEqual(played.recruits, {
+ turn: 2,
+ player: f.owner,
+ card: 'ecaz-recruits',
+ });
+ assert.equal(
+ played.discard.filter((card) => card.id === 'ecaz-recruits').length,
+ 1,
+ );
+ assert.equal(
+ played.players
+ .flatMap((p) => p.hand)
+ .some((card) => card.id === 'ecaz-recruits'),
+ false,
+ );
+ for (const p of played.players) {
+ const before = priorFree.players.find(
+ (candidate) => candidate.id === p.id,
+ )!;
+ assert.deepEqual(
+ [p.spice, p.reserves, p.tanks, p.revived, p.freeForcesRevived],
+ [
+ before.spice,
+ before.reserves,
+ before.tanks,
+ before.revived,
+ before.freeForcesRevived,
+ ],
+ );
+ }
+ const active = await restored(f);
+ assert.deepEqual(
+ viewGame(active, f.owner).recruitsPreview!.rates,
+ active.order.map((player) => {
+ const faction = active.players.find((p) => p.id === player)!.faction;
+ return {
+ player,
+ freeRate: faction === 'fremen' ? 6 : faction === 'choam' ? 0 : 4,
+ limit: faction === 'tleilaxu' || faction === 'choam' ? 20 : 7,
+ };
+ }),
+ );
+ await noWrite(f, f.owner, play, priorFree.version);
+ await noWrite(f, f.owner, play, active.version, /not in your hand/);
+
+ await act(f, f.ids[0], { type: 'revive', amount: 2, elite: 0 });
+ const four = await settleRevival(f);
+ assert.equal(four.players[0].freeForcesRevived, 4);
+ assert.equal(four.players[0].revived, 4);
+ assert.equal(four.players[0].spice, initialAtreides.spice);
+ await restored(f);
+ await act(f, f.ids[0], { type: 'revive', amount: 3, elite: 0 });
+ const seven = await settleRevival(f);
+ assert.equal(seven.players[0].freeForcesRevived, 4);
+ assert.equal(seven.players[0].revived, 7);
+ assert.equal(seven.players[0].spice, initialAtreides.spice - 6);
+ assert.equal(seven.players[0].tanks, initialAtreides.tanks - 7);
+ assert.equal(seven.players[0].reserves, initialAtreides.reserves + 7);
+ assert.equal(
+ seven.discard.filter((card) => card.id === 'ecaz-recruits').length,
+ 1,
+ );
+ await noWrite(f, f.ids[0], { type: 'revive', amount: 1, elite: 0 });
+ await restored(f);
+ assert.deepEqual(rows(f.sqlite).seats, seats);
+ }
+});
+
+void test('pending and already paid revivals reject Recruits after restart without changing the saved transaction', async (t) => {
+ const f = await fixture(t);
+ const pending = await act(f, f.ids[0], {
+ type: 'revive',
+ amount: 3,
+ elite: 0,
+ });
+ assert.equal(pending.decision?.kind, 'choamFreeRevival');
+ assert.equal(pending.pendingRevival?.cost, 2);
+ await restored(f);
+ assert.match(
+ viewGame(pending, f.owner).recruitsPreview!.play!.blocked!,
+ /transaction or priority/,
+ );
+ await noWrite(f, f.owner, play, undefined, /transaction or priority/);
+ assert.deepEqual(
+ (await f.restart().readRoom(f.code)).pendingRevival,
+ pending.pendingRevival,
+ );
+ const paid = await settleRevival(f);
+ assert.equal(paid.players[0].revived, 3);
+ assert.equal(paid.players[0].freeForcesRevived, 2);
+ assert.equal(paid.players[0].spice, 18);
+ assert.match(
+ viewGame(paid, f.owner).recruitsPreview!.play!.blocked!,
+ /after a paid normal force revival/,
+ );
+ assert.equal(
+ recruitsPlayAction(viewGame(paid, f.owner).recruitsPreview),
+ null,
+ );
+ await noWrite(
+ f,
+ f.owner,
+ play,
+ undefined,
+ /after a paid normal force revival/,
+ );
+ const final = await restored(f);
+ assert.equal(final.recruits, undefined);
+ assert.ok(final.players[3].hand.some((card) => card.id === 'ecaz-recruits'));
+});
+
+void test('competing authenticated Recruits plays commit one physical discard and one effect', async (t) => {
+ const f = await fixture(t),
+ before = await f.rooms.readRoom(f.code),
+ seatRows = rows(f.sqlite).seats;
+ let entered = 0,
+ release!: () => void;
+ const both = new Promise((resolve) => {
+ release = resolve;
+ });
+ f.hooks.beforeWrite = async () => {
+ if (++entered === 2) release();
+ await both;
+ };
+ const attempts = await Promise.allSettled(
+ [0, 1].map(() =>
+ f.rooms.act(f.code, f.auths[3], before.version, play, clock),
+ ),
+ );
+ f.hooks.beforeWrite = undefined;
+ assert.equal(
+ attempts.filter((result) => result.status === 'fulfilled').length,
+ 1,
+ );
+ assert.deepEqual(
+ f.writes.map((write) => write.changes).sort((a, b) => a - b),
+ [0, 1],
+ );
+ const after = await restored(f);
+ assert.equal(after.version, before.version + 1);
+ assert.equal(
+ after.discard.filter((card) => card.id === 'ecaz-recruits').length,
+ 1,
+ );
+ assert.equal(
+ after.log.filter((entry) => entry.text.includes('played Recruits.')).length,
+ 1,
+ );
+ for (const p of after.players) assert.equal(p.revived, 0);
+ await noWrite(f, f.owner, play, before.version);
+ await noWrite(f, f.owner, play, after.version);
+ assert.deepEqual(rows(f.sqlite).seats, seatRows);
+});
+
+void test('malformed active Recruits saves fail private reads and actions without SQL writes', async (t) => {
+ const f = await fixture(t),
+ valid = await act(f, f.owner, play);
+ const mutations: ((g: Game) => void)[] = [
+ (g) => {
+ g.recruits!.turn++;
+ },
+ (g) => {
+ g.players[0].freeForcesRevived = 1;
+ },
+ (g) => {
+ g.players[0].hand.push(
+ g.discard.find((card) => card.id === 'ecaz-recruits')!,
+ );
+ },
+ ];
+ for (const mutate of mutations) {
+ const corrupt = JSON.parse(JSON.stringify(valid)) as Game;
+ mutate(corrupt);
+ f.sqlite
+ .prepare('UPDATE rooms SET state=? WHERE code=?')
+ .run(JSON.stringify(corrupt), f.code);
+ const before = rows(f.sqlite),
+ writes = f.writes.length;
+ for (const [index, token] of f.tokens.entries()) {
+ const rooms = f.restart(),
+ auth = await rooms.authenticate(f.code, token);
+ await assert.rejects(
+ rooms.readSeatView(f.code, auth),
+ /Recruits|Ecaz Treachery/,
+ );
+ assert.throws(
+ () => viewGame(corrupt, f.ids[index]),
+ /Recruits|Ecaz Treachery/,
+ );
+ }
+ await noWrite(
+ f,
+ f.owner,
+ { type: 'ready' },
+ valid.version,
+ /Recruits|Ecaz Treachery/,
+ );
+ await noWrite(
+ f,
+ f.owner,
+ { type: 'setAutopilot', difficulty: 'Easy' },
+ valid.version,
+ /Recruits|Ecaz Treachery/,
+ );
+ assert.deepEqual(rows(f.sqlite), before);
+ assert.equal(f.writes.length, writes);
+ }
+});
diff --git a/tests/richese-card-presentation.test.ts b/tests/richese-card-presentation.test.ts
index 8f1bcdd5..3987bb59 100644
--- a/tests/richese-card-presentation.test.ts
+++ b/tests/richese-card-presentation.test.ts
@@ -154,7 +154,7 @@ void test('the public collection and ten full card topics are searchable, linked
);
});
-void test('base, Ix and Ecaz presentations stay unchanged as the two CHOAM deck replacements become available', () => {
+void test('base, Ix and Ecaz guides remain separate from Richese replacement-card guidance', () => {
for (const card of [...baseDeck(), ...ixDeck()]) {
const presentation = cardPresentation(card);
assert.equal(presentation.availability, undefined);
@@ -168,8 +168,9 @@ void test('base, Ix and Ecaz presentations stay unchanged as the two CHOAM deck
const presentation = cardPresentation(card);
assert.equal(presentation.category, 'Special treachery');
assert.ok(presentation.gameplay!.length >= 4);
- assert.equal(presentation.availability, undefined);
- assert.deepEqual(presentation.topics, []);
+ assert.match(presentation.availability ?? '', card.effect === 'recruits'
+ ? /development controls during Revival/ : /battle effect is still being implemented/);
+ assert.deepEqual(presentation.topics.map((topic) => topic.id), ['card-recruits']);
}
assert.equal(treacheryDeck().length, 33);
assert.equal(treacheryDeck(['ix']).length, 47);
diff --git a/tools/prototype-room.ts b/tools/prototype-room.ts
index 78c67744..963055b8 100644
--- a/tools/prototype-room.ts
+++ b/tools/prototype-room.ts
@@ -6,6 +6,7 @@ import {
initializeFactionExpansionsGameForAudit,
initializeNexusGameForAudit,
initializeMoritaniAssassinateGameForAudit,
+ initializeEcazTreacheryGameForAudit,
viewGame,
type Game,
} from '../game/engine';
@@ -17,6 +18,7 @@ export const PROTOTYPE_PROFILES = [
'factions',
'nexus',
'moritani-assassinate',
+ 'ecaz-treachery',
] as const;
export type PrototypeProfile = (typeof PROTOTYPE_PROFILES)[number];
export function isPrototypeProfile(value: string): value is PrototypeProfile {
@@ -59,6 +61,8 @@ export function startPrototypeRoom(
const game =
profile === 'ix'
? initializeIxGameForAudit(initial)
+ : profile === 'ecaz-treachery'
+ ? initializeEcazTreacheryGameForAudit(initial)
: profile === 'moritani-assassinate'
? initializeMoritaniAssassinateGameForAudit(initial)
: profile === 'factions'