Make Fremen allied revival grants cancelable
Bind the original Fremen/ally/turn grant before Karama allowance or prevention. A prevented grant leaves intrinsic free rates and prior payments intact. Permit late grants under the explicit provisional Recruits current-rate policy, with shared controls, minimal bot support and native public chronology evidence. Complete rules and browser/deployed acceptance remain open.
This commit is contained in:
1 parent
d63efccb55
commit
0e3d6a250e
23 files changed
+1183
-44
No files matched your search
@@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
|
||||
export function FremenRevivalGrant({
|
||||
game,
|
||||
act,
|
||||
busy,
|
||||
}: {
|
||||
game: GameView;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const grant = game.fremenRevivalGrant;
|
||||
const me = game.players.find((player) => player.id === game.me);
|
||||
if (!grant || game.status !== 'playing' || game.phase !== 4 ||
|
||||
me?.faction !== 'fremen' || me.ally !== grant.recipient) return null;
|
||||
const recipient = game.players.find((player) => player.id === grant.recipient)?.name ?? 'your ally';
|
||||
const disabled = busy || !!game.truthtrance || !!game.nexusTraitors?.pending ||
|
||||
!!grant.blocked || grant.granted;
|
||||
return (
|
||||
<section className="notice space-y-3" aria-label="Fremen ally free revival">
|
||||
<h3 className="font-serif text-xl">Fremen ally revival</h3>
|
||||
<p className="muted">
|
||||
Grant {recipient} an ordinary free force revival rate of three this phase. This
|
||||
replaces their native ordinary rate rather than adding three. Ordinary total
|
||||
revival and special-force limits still apply.
|
||||
</p>
|
||||
<p className="fine">
|
||||
Provisional current-rate policy: Recruits doubles the current granted rate, including applicable bonuses,
|
||||
before caps. Without other bonuses, three becomes six.
|
||||
Later grants use the same current-rate calculation;
|
||||
your ally’s revival controls show the actual remaining allowance.
|
||||
Previously recorded free returns use the allowance. Earlier paid returns keep their payments and count
|
||||
toward the total revival limit; there is no refund or retroactive
|
||||
reclassification.
|
||||
</p>
|
||||
<p className="fine">
|
||||
Another player may use Karama before the original alliance grant takes effect.
|
||||
Canceling blocks this Fremen grant to the current ally for this Revival;
|
||||
the ally retains its intrinsic rate and Recruits. Completed revivals and
|
||||
payments stay unchanged. This response timing and prevention scope are provisional.
|
||||
</p>
|
||||
{grant.granted && (
|
||||
<p className="muted">Free revival has been granted to {recipient} for this phase.</p>
|
||||
)}
|
||||
{grant.blocked && (
|
||||
<p className="notice" id="fremen-revival-grant-unavailable">{grant.blocked}</p>
|
||||
)}
|
||||
<Button
|
||||
data-testid="fremen-revival-grant-submit"
|
||||
className="game-action"
|
||||
disabled={disabled}
|
||||
aria-describedby={grant.blocked ? 'fremen-revival-grant-unavailable' : undefined}
|
||||
onClick={() => {
|
||||
if (!disabled) act({ type: 'grantRevival' });
|
||||
}}
|
||||
>
|
||||
Grant ally free revival
|
||||
<ArrowRight size={15} />
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
+12
-11
@@ -52,6 +52,7 @@ import { NexusSardaukar } from './nexus-sardaukar';
|
||||
import { TerrorBoardMarkers } from './terror-board-markers';
|
||||
import { AmbassadorBoardMarkers } from './ambassador-board-markers';
|
||||
import { CharityClaim } from './charity-claim';
|
||||
import { FremenRevivalGrant } from './fremen-revival-grant';
|
||||
import { homeworldRevivalActionBlock } from '@/game/homeworld-revival-deployment-options';
|
||||
import { GuildHomeworldShipment } from './guild-homeworld-shipment';
|
||||
import { JunctionTransport } from './junction-transport';
|
||||
@@ -2423,6 +2424,7 @@ export function GameTable({
|
||||
advisor: 'Spiritual advisor',
|
||||
emperorGift: 'Emperor spice gift',
|
||||
emperorRevival: 'Emperor extra revival',
|
||||
fremenRevivalGrant: 'Fremen allied free revival',
|
||||
bgCharity: 'Bene Gesserit charity',
|
||||
choamCharity: 'CHOAM charity income',
|
||||
choamInflation: 'CHOAM Inflation placement',
|
||||
@@ -2546,6 +2548,14 @@ export function GameTable({
|
||||
allow five forces.
|
||||
</p>
|
||||
)}
|
||||
{g.response.kind === 'fremenRevivalGrant' && (
|
||||
<p className="notice">
|
||||
Fremen offered their current ally the ordinary free-revival rate of three.
|
||||
Karama can prevent this grant before it takes effect. Recruits uses the
|
||||
current valid rate after settlement, with prior returns and payments retained.
|
||||
Response timing and prevention of this grant for the current Revival are provisional.
|
||||
</p>
|
||||
)}
|
||||
{g.response.kind === 'choamCharity' && (
|
||||
<p className="muted">
|
||||
CHOAM receives{' '}
|
||||
@@ -4367,19 +4377,10 @@ export function GameTable({
|
||||
? ' · Tleilaxu half-price discount'
|
||||
: ''}
|
||||
</p>
|
||||
{me.faction === 'fremen' &&
|
||||
me.ally &&
|
||||
!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 && (
|
||||
<p className="notice" id="recruits-grant-unavailable">{g.recruitsPreview.grantRevivalBlocked}</p>
|
||||
)}
|
||||
<FremenRevivalGrant game={g} act={act} busy={busy} />
|
||||
{g.freeRevival.includes(me.id) && !g.revival.freeBlocked && (
|
||||
<p className="muted">
|
||||
Fremen allow you up to {g.recruitsPreview?.active ? 'six' : 'three'} free force revivals this phase.
|
||||
Fremen’s grant is active. Your current free allowance above includes Recruits and any applicable bonuses.
|
||||
</p>
|
||||
)}
|
||||
{amountInput}
|
||||
|
||||
@@ -16,6 +16,25 @@ open mode gates.
|
||||
|
||||
|
||||
## Current checkpoint and work
|
||||
**Fremen allied free revival — 24 October. Prototyped / Partial.**
|
||||
The original owner/ally/turn grant now resolves Karama before commitment.
|
||||
Prevention provisionally denies only that grant for the current Revival;
|
||||
intrinsic rates, Recruits and completed returns/payments stay intact. A later
|
||||
Recruits grant now uses the same current-rate calculation and actual free-use
|
||||
ledger as the opposite order, with no refund or retroactive reclassification.
|
||||
Persistent controls and minimal bots share the projected permission.
|
||||
[Source, selected timing and proof](FREMEN_REVIVAL_GRANT.md).
|
||||
|
||||
Disjoint helper/source/controls lanes and bounded review pass **14 files305/305**,
|
||||
types/lint/build, twelve actual control/gameplay/GameTable SSR journeys into
|
||||
Shipment, and sixteen existing-profile paths (80 accepted proposals).
|
||||
Genuine75–77 Basic/92–94 Advanced action sources earn Tanks in an actual battle
|
||||
and form the alliance at a real Nexus. Feedback repairs guidance expectations,
|
||||
two exhaustive kind registries and a fixture response label. No full-mode,
|
||||
strategy, comprehensive/browser/deployed certificate or saved-room/D1/reset/
|
||||
restart/deployment operation. The next mapped native candidate is Advanced
|
||||
Bene Gesserit advisor preparation timing before the first shipment.
|
||||
|
||||
**CHOAM Charity funding — 24 October. Prototyped / Partial.**
|
||||
CHOAM's own eligible poverty claim now uses the Spice Bank; E2 names other
|
||||
factions as its payees. After legal intervening spending, the selected preview
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# Fremen allied free revival
|
||||
|
||||
## Source and selected timing — 24 October 2026
|
||||
|
||||
The base publisher rulebook physical p16 gives the Fremen ally three free
|
||||
revivals; ordinary force limits and special-force rules remain independent.
|
||||
The November2020 publisher FAQ p7 permits a Karama to stop an alliance ability
|
||||
for one use. The native permission therefore needs a response before its
|
||||
benefit is committed, rather than an uncancelable direct addition to the
|
||||
permission list. The exact digital declaration point and prevention duration
|
||||
below are implementation policy, not a Fremen-specific worked FAQ.
|
||||
[Official base rules](https://www.gf9games.com/dunegame/wp-content/uploads/Dune-Rulebook.pdf),
|
||||
[official FAQ](https://www.gf9games.com/dune/wp-content/uploads/2020/11/Dune-FAQ-Nov-2020.pdf).
|
||||
|
||||
The existing optional Fremen grant action now binds the current native owner,
|
||||
mutual ally and turn during Revival. It opens the existing Karama response.
|
||||
All-pass commits the original grant once; prevention leaves it uncommitted and
|
||||
blocks that Fremen-to-current-ally opportunity for this Revival turn. It never
|
||||
sets general free-revival prevention. The recipient keeps its intrinsic free
|
||||
rate, Recruits, prior payments and completed returns. A later native Revival
|
||||
opening resets this denial through the existing phase rules. The user’s
|
||||
14 October choose-recommended authority selects this scope; no new question
|
||||
or claim of publisher confirmation is implied.
|
||||
|
||||
Publisher E3 physical pp11/16 says Recruits doubles current free rates, with
|
||||
the ordinary total raised to seven. Under the same explicit current-rate
|
||||
prototype policy, a grant accepted after Recruits uses the same current
|
||||
calculation as a grant accepted before it. Recruits doubles the current granted rate, including applicable bonuses,
|
||||
before caps; without other bonuses, three becomes six. It replaces the native ordinary rate,
|
||||
not an additional six-force gift. Actual prior free usage consumes this rate;
|
||||
all prior returns consume the total cap. The already selected late-paid policy
|
||||
retains prices/classifications and supplies no refund or retroactive relabeling.
|
||||
No direct combined late-grant ruling was found in the bounded primary search.
|
||||
[Official E3](https://www.gf9games.com/dune/wp-content/uploads/EcazMoritani-Rulebook-LOWRES.pdf),
|
||||
[existing Recruits interpretation](RECRUITS_RULES.md).
|
||||
|
||||
Original source passages were freshly checked through the publisher's indexed
|
||||
rulebooks and FAQ. No new edition, community rule or unrelated faction/module
|
||||
permission is adopted. This composition is provisional; complete rules/modes
|
||||
and final acceptance remain unfinished.
|
||||
|
||||
## Connected implementation
|
||||
|
||||
`game/fremen-revival-grant.ts` validates the original relationship and source,
|
||||
blocks repeated/prevented grants and checks known prior free usage before an
|
||||
active Recruits rate change. Native `game/revival.ts` remains the only owner of
|
||||
force quotes, intrinsic/granted rates, paid prices, total and elite caps,
|
||||
Homeworld additions and separate free prevention. A grant never moves counters.
|
||||
|
||||
The engine opens `fremenRevivalGrant`, validates that same source on allow and
|
||||
before cancellation-card cost, then commits or marks the optional
|
||||
`revivalRules.fremenGrantBlocked` recipient list. New responses retain their
|
||||
source in the existing cancellation-context/purpose protocols. Completed grants
|
||||
have no new response or implicit undo. Unknown old grants are not converted into
|
||||
new pending windows. A pending native return or priority transaction must finish
|
||||
before a new grant can be declared; no accepted invoice is silently repriced.
|
||||
|
||||
The public owner-only projection contains recipient identity, granted status
|
||||
and a generic blocked reason, without private wallets/Tanks or numeric usage.
|
||||
`components/fremen-revival-grant.tsx` provides the real persistent table action,
|
||||
status and provisional guidance. The normal response panel names the offered
|
||||
grant. Existing minimal bots consume that same legal projection and ordinary
|
||||
response/return controls, with no strategy or strength changes.
|
||||
|
||||
## Verification and remaining work
|
||||
|
||||
Frozen proof passes **14 affected offline files305/305**, types/lint and build.
|
||||
Twelve actual grant/Recruits/return callback and full GameTable SSR journeys
|
||||
reach native Shipment. Sixteen existing-profile native and Recruits paths
|
||||
accept80 legal grant/response/return proposals through their own views, with
|
||||
no strategy refinement or difficulty calibration. Focused engine cases also
|
||||
reach the next actual Revival and verify the denied grant becomes available.
|
||||
|
||||
The genuine three-seat public Fremen/Atreides/Emperor source selects only
|
||||
original scalar order, three unopened Treachery positions, two ordinary Spice
|
||||
positions and a later unopened worm. Native Fremen setup puts ten at Tabr;
|
||||
Atreides pays six to invade with six real reserves. Both seal native unarmed
|
||||
plans; Fremen dials six and wins, producing six actual Tank counters for each.
|
||||
The next turn's real worm/Nexus permits an actual proposal and accepted alliance.
|
||||
Native controls reach second Revival:75–77 Basic or92–94 Advanced source actions.
|
||||
Optional Recruits is selected before setup and originally dealt; optional prior
|
||||
Atreides returns are actually two free and one paid for two spice. No played
|
||||
phase, wallet, army, alliance, grant/denial or payment receipt is installed.
|
||||
|
||||
The twelve journeys cover ordinary allow/cancel, both Recruits/grant orders,
|
||||
prevention retaining intrinsic doubled returns and the actual prior-paid branch.
|
||||
Original force/card quantities and prior payment/usage remain intact. Existing
|
||||
related cases retain La La La, native/World limits, other revival and source/
|
||||
purpose behavior. First feedback passes304/305, exposing an old text assertion
|
||||
after review corrected bonus/cancellation wording. Targeted guidance feedback
|
||||
then passes. The next union passes305/305; types identify two exhaustive kind
|
||||
registries and one test's invented response label. The final frozen batch passes
|
||||
all steps after those corrections. Bounded review finds no concrete blocker;
|
||||
this is not comprehensive rules or privacy/persistence review.
|
||||
|
||||
Private evidence:
|
||||
`/home/quintine/.local/share/dune/checkpoints/20261024-fremen-revival-grant/`,
|
||||
including `frozen-integrated-3/report.json`, `runtime-smoke.json`, HTML and feedback.
|
||||
Verified base `d63efccb55723c8ff33d9ec0d9daf037d0bd66ca`, source tree
|
||||
`62f9b1817cbe890828d2c1aa9c1563fd492e5d05fd923dc3881aefcf468e5034`
|
||||
(2699 nonignored files, identical before/after checks). Only checkpoint
|
||||
prose, reference strings and evidence paths follow proof; delivery records that
|
||||
reviewed overlay and pushed revision. The supplied PDF stays untracked.
|
||||
|
||||
Framework aliases/CSS stubs mean callback/gameplay/SSR evidence, not visual
|
||||
browser or deployed acceptance. Full modes, all combinations, broader native
|
||||
Karama/optional nesting and comprehensive rules/recovery/privacy/custody
|
||||
assurance remain unfinished. Exact timing/prevention scope and late-grant
|
||||
rate composition remain provisional. No server, saved-room/D1/reset/restart
|
||||
or deployment operation occurs here.
|
||||
@@ -1,5 +1,31 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 24 October 2026 — Fremen allied revival response and late Recruits
|
||||
|
||||
Replace direct permission commitment with an original owner/ally/turn-bound
|
||||
Karama response. Before-cost and allow/deny checks share the focused quote.
|
||||
All-pass grants once; prevention marks only the current Revival grant and
|
||||
retains intrinsic free rates, Recruits and history. The existing phase opening
|
||||
clears that denial. A successful later Recruits grant uses current rates and
|
||||
actual free usage without refunding prior paid returns. Timing/duration and
|
||||
late-rate symmetry remain explicit provisional choices. Persistent controls,
|
||||
response names, source/purpose registries and minimal bots are connected.
|
||||
|
||||
Frozen14-file305/305, types/lint/build, twelve actual control/gameplay/GameTable
|
||||
SSR journeys into Shipment and sixteen existing-profile paths/80 accepted
|
||||
proposals pass. Genuine public classic setup, paid six-counter invasion, native
|
||||
battle losses and a second-turn worm/Nexus alliance supply75–77 Basic or92–94
|
||||
Advanced source actions. Recruits is originally dealt; optional prior return is
|
||||
actually two free/one paid. No played phase, army, wallet or alliance is assigned.
|
||||
Feedback corrects a stale guidance assertion, two exhaustive response tables
|
||||
and a synthetic response label. Bounded review corrects bonus-order wording;
|
||||
existing arithmetic already doubles applicable bonuses before caps.
|
||||
|
||||
[Source, exact evidence and limits](FREMEN_REVIVAL_GRANT.md)
|
||||
retain complete rules/modes, wider combinations and comprehensive/browser/
|
||||
deployed acceptance. No strategy/calibration or saved-room/D1/reset/restart/
|
||||
deployment operation. Private proof:20261024-fremen-revival-grant/frozen-integrated-3.
|
||||
|
||||
## 24 October 2026 — CHOAM Charity self-claims and shortfall funding
|
||||
|
||||
Make the ordinary poverty claim effective for CHOAM itself using the bank,
|
||||
|
||||
@@ -17,6 +17,13 @@ provisional, not a new FAQ or resolution of all later-rate questions. See the
|
||||
[native contract](RECRUITS_RUNTIME.md#classic-homeworld-composition--16-october-2026).
|
||||
|
||||
|
||||
**Native grant follow-up — 24 October 2026:** the earlier direct-commit and
|
||||
later-grant absence claims below are superseded by the source-bound
|
||||
[Fremen grant protocol](FREMEN_REVIVAL_GRANT.md). The existing optional action
|
||||
now offers Karama before commitment; current-Recruits rate symmetry and denial
|
||||
of that grant for this Revival are explicit prototype choices. This does not
|
||||
settle unrelated timing/combination questions or certify full modes.
|
||||
|
||||
## Primary evidence
|
||||
|
||||
| Source | Relevant verified rule |
|
||||
|
||||
@@ -6,6 +6,19 @@ Advanced faction setup. All three physical cards are present exactly once.
|
||||
Recruits, bounded Reinforcements and Harass & Withdraw have connected controls,
|
||||
AI and saved continuation; full interactions and publication remain gated.
|
||||
|
||||
## Native Fremen grant after play — 24 October 2026
|
||||
|
||||
The earlier later-grant guard is replaced by the symmetric current-rate
|
||||
prototype: an original Fremen allied grant may be declared after Recruits.
|
||||
Its native Karama window settles before permission is committed. Prevention
|
||||
blocks only that grant for this Revival; it does not erase intrinsic free
|
||||
allowance, Recruits or earlier returns/payments. A successful late grant uses
|
||||
the same current3→6 calculation as a grant preceding play, with actual prior
|
||||
free usage deducted, no refund, and ordinary/native total and typed limits.
|
||||
The exact response scope and later-rate composition remain provisional under
|
||||
14 October, not a new publisher combined ruling.
|
||||
[Source, controls and bounded evidence](FREMEN_REVIVAL_GRANT.md).
|
||||
|
||||
## Original Skills/phase composition — 17 October 2026
|
||||
|
||||
Supported classic, selected E1/E2 and standalone/paired E3 all14/card profiles
|
||||
@@ -30,7 +43,7 @@ user ruling or a newly retrieved publisher answer.
|
||||
Thus Atreides' actual two-free/one-paid return retains its two-spice payment
|
||||
and three total returns; after Recruits it may take two additional free
|
||||
forces under the seven total cap. Missing or inconsistent free-use ledgers,
|
||||
pending declarations, later rate changes and same-turn replay remain guarded.
|
||||
pending declarations, unrelated later rate changes and same-turn replay remain guarded.
|
||||
The owned before-play panel and public active panel name this interpretation.
|
||||
|
||||
Basic/Advanced before/after native smokes use a genuinely dealt card, an
|
||||
@@ -113,7 +126,7 @@ source review. No newly retrieved late-paid ruling is claimed.
|
||||
|
||||
The scope permits activation before returns or after consistently recorded
|
||||
free/paid normal returns, once priority windows and transactions finish.
|
||||
Unknown legacy usage, later Fremen grants, same-turn replay after recovery
|
||||
Unknown legacy usage, unrelated late-rate changes, same-turn replay after recovery
|
||||
and other optional modules remain guarded, apart from clean classic Homeworlds
|
||||
above or the separate clean classic/Ix/CHOAM
|
||||
[Tech/Advanced Stronghold composition](ECAZ_TREACHERY_RULES.md#original-phase-module-composition--16-october-2026).
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# Rule decision index
|
||||
|
||||
## Fremen allied revival response and later Recruits grant — 24 October 2026
|
||||
|
||||
Compose the base Fremen allied free rate with the general FAQ's cancelable
|
||||
alliance use. Before committing the original owner/mutual-ally/turn grant,
|
||||
resolve the native Karama window. Under the 14 October recommended policy,
|
||||
prevention denies only this grant for current Revival, retaining intrinsic
|
||||
free rates, Recruits and completed history. An allowed later Recruits grant
|
||||
uses the same current rate and recorded free usage as the opposite order;
|
||||
prior paid returns keep their prices without refund. Exact response duration
|
||||
and late-rate symmetry are provisional, not a direct publisher worked ruling.
|
||||
[Source, implementation and limits](FREMEN_REVIVAL_GRANT.md#source-and-selected-timing--24-october-2026).
|
||||
|
||||
## CHOAM self-claim and provisional shortfall funding — 24 October 2026
|
||||
|
||||
E2 p7 names other factions as CHOAM's payees; the ordinary poverty rule leaves
|
||||
|
||||
@@ -141,6 +141,7 @@ const responses = {
|
||||
guildTransportPermission: true,
|
||||
emperorGift: true,
|
||||
emperorRevival: true,
|
||||
fremenRevivalGrant: true,
|
||||
stormPeek: true,
|
||||
stormProtection: true,
|
||||
wormPlacement: true,
|
||||
|
||||
+1
-2
@@ -3245,8 +3245,7 @@ function policyActions(g: GameView): Action[] {
|
||||
actions.push({ type: 'requestLeaderRevival', leader: dead.id });
|
||||
}
|
||||
}
|
||||
if (me.faction === 'fremen' && me.ally && !g.freeRevival.includes(me.ally) &&
|
||||
!g.recruitsPreview?.grantRevivalBlocked)
|
||||
if (g.fremenRevivalGrant && !g.fremenRevivalGrant.granted && !g.fremenRevivalGrant.blocked)
|
||||
actions.push({ type: 'grantRevival' });
|
||||
if (me.faction === 'emperor' && me.ally && level > 0) {
|
||||
const ally = g.players.find((p) => p.id === me.ally)!;
|
||||
|
||||
+40
-20
@@ -532,6 +532,7 @@ import { quoteHomeworldPaymentIncome, quoteHomeworldBankPayment } from './homewo
|
||||
import { quoteEcazPoisonIncome, EcazPoisonIncomeError, type EcazPoisonDiscard } from './ecaz-poison-income';
|
||||
import { resolveBattleWeapons } from './effective-weapons';
|
||||
import { choamGholaEvent, choamGholaSkillOfferEvent, choamGholaSkillSignature, choamMarketGholaError, choamSaleGholaTiming, type ChoamMarketGhola } from './choam-market-ghola';
|
||||
import { quoteFremenRevivalGrant, validateFremenRevivalGrant, projectFremenRevivalGrant, FremenRevivalGrantError, type FremenRevivalGrantSource } from './fremen-revival-grant';
|
||||
import {
|
||||
charityAmount,
|
||||
charityQuote,
|
||||
@@ -1060,6 +1061,7 @@ type SpiceBankerIncomePayment = {
|
||||
};
|
||||
|
||||
export type ResponseWindow = {
|
||||
fremenGrant?: FremenRevivalGrantSource;
|
||||
spiceBankerIncomePayment?: SpiceBankerIncomePayment;
|
||||
bureaucratPayment?: BureaucratPaymentSource;
|
||||
bureaucratPaymentEvent?: string;
|
||||
@@ -1113,6 +1115,7 @@ export type ResponseWindow = {
|
||||
| 'guildTransportPermission'
|
||||
| 'emperorGift'
|
||||
| 'emperorRevival'
|
||||
| 'fremenRevivalGrant'
|
||||
| 'stormPeek'
|
||||
| 'stormProtection'
|
||||
| 'wormPlacement'
|
||||
@@ -14452,11 +14455,24 @@ function recruitsPreview(g: Game, viewer: Player): RecruitsPreview | null {
|
||||
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,
|
||||
grantRevivalBlocked: projectFremenRevivalGrant(g, viewer.id)?.blocked ?? null,
|
||||
};
|
||||
}
|
||||
function fremenRevivalGrantRule<T>(quote: () => T): T {
|
||||
try { return quote(); }
|
||||
catch (error) {
|
||||
if (error instanceof FremenRevivalGrantError) throw new RuleError(error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
function currentFremenRevivalGrant(g: Game, response: ResponseWindow): FremenRevivalGrantSource {
|
||||
requireRule(response.kind === 'fremenRevivalGrant' && response.fremenGrant &&
|
||||
response.owner === response.fremenGrant.owner && response.recipient === response.fremenGrant.recipient,
|
||||
'The Fremen revival grant lost its original owner and ally.');
|
||||
requireRule(!g.pendingRevival, 'Finish the existing force return before resolving the Fremen grant.');
|
||||
fremenRevivalGrantRule(() => validateFremenRevivalGrant(g, response.fremenGrant!));
|
||||
return response.fremenGrant;
|
||||
}
|
||||
/** Pure normal-lot prerequisites; free payment deliberately does not require spice. */
|
||||
function normalKaramaAuction(
|
||||
g: Game,
|
||||
@@ -14694,6 +14710,7 @@ function validateKaramaUse(
|
||||
'Karama cancels another faction’s power.',
|
||||
);
|
||||
karamaCanceledSource(g, use.response);
|
||||
if (use.response.kind === 'fremenRevivalGrant') currentFremenRevivalGrant(g, use.response);
|
||||
validateAuctionContinuationCancellation(
|
||||
g,
|
||||
use.response,
|
||||
@@ -27119,6 +27136,7 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
||||
karamaConversionIntegrity(g);
|
||||
currentFactionPayment(g);
|
||||
const response = g.response!;
|
||||
const fremenGrant = response.kind === 'fremenRevivalGrant' ? currentFremenRevivalGrant(g, response) : null;
|
||||
if (g.pendingKull?.stage === 'counter' && response.kind === 'choamWorthless') kullIntegrity(g);
|
||||
if (response.kind === 'nexusPrescience') validateNexusInspectionResponse(g, response);
|
||||
if (response.kind === 'nexusAdvisorFlip') validateNexusAdvisorResponse(g, response);
|
||||
@@ -27196,6 +27214,18 @@ function finishResponse(g: Game, canceled: boolean, settlement?: BureaucratSettl
|
||||
return;
|
||||
}
|
||||
if (offerOccupiedBonus(g, response)) return;
|
||||
if (response.kind === 'fremenRevivalGrant') {
|
||||
requireRule(fremenGrant, 'The original Fremen revival grant is missing.');
|
||||
if (canceled) {
|
||||
g.revivalRules ??= newRevivalRules();
|
||||
(g.revivalRules.fremenGrantBlocked ??= []).push(fremenGrant.recipient);
|
||||
log(g, `${getPlayer(g, fremenGrant.owner).name}'s allied revival grant was prevented for this Revival. The ally keeps its intrinsic free rate and completed returns.`);
|
||||
} else {
|
||||
g.freeRevival.push(fremenGrant.recipient);
|
||||
log(g, `${getPlayer(g, fremenGrant.owner).name} granted ${getPlayer(g, fremenGrant.recipient).name} the Fremen allied free-revival rate for this phase. Current Recruits rates, prior usage and total limits still apply.`, { faction: 'fremen', name: 'Allied free revival' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (response.kind === 'ecazOccupy') {
|
||||
const b = g.battle, occupy = b?.ecazOccupy;
|
||||
requireRule(b && occupy?.lead && occupy.ecaz === response.owner && !b.revealed &&
|
||||
@@ -34783,23 +34813,12 @@ function applyActionInner(
|
||||
return g;
|
||||
}
|
||||
if (t === 'grantRevival') {
|
||||
requireRule(
|
||||
g.phase === 4 && p.faction === 'fremen' && p.ally,
|
||||
'Only Fremen may grant their ally free revival during revival.',
|
||||
);
|
||||
requireRule(
|
||||
!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,
|
||||
`${p.name} granted their ally up to three free force revivals this phase.`,
|
||||
);
|
||||
requireRule(g.phase === 4 && p.faction === 'fremen' && p.ally,
|
||||
'Only Fremen may grant their ally free revival during revival.');
|
||||
requireRule(!recruitsInteractionPending(g), 'Finish the current revival transaction or priority window before granting allied revival.');
|
||||
const source = fremenRevivalGrantRule(() => quoteFremenRevivalGrant(g, id));
|
||||
g.response = { kind: 'fremenRevivalGrant', owner: source.owner,
|
||||
recipient: source.recipient, fremenGrant: source, passed: [] };
|
||||
return g;
|
||||
}
|
||||
if (t === 'tleilaxuRevivalLimit' || t === 'tleilaxuAllyDiscount') {
|
||||
@@ -36803,6 +36822,7 @@ export function viewGame(state: Game, id: string) {
|
||||
emperorExtra: g.emperorExtra ?? {},
|
||||
freeRevival: g.freeRevival ?? [],
|
||||
recruitsPreview: recruitsPreview(g, me),
|
||||
fremenRevivalGrant: projectFremenRevivalGrant(g, id),
|
||||
revivalRules: {
|
||||
...(g.revivalRules ?? newRevivalRules()),
|
||||
earlyBlocked: (g.revivalRules?.earlyBlocked ?? []).filter(
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { Game } from './engine';
|
||||
|
||||
export type FremenRevivalGrantSource = {
|
||||
turn: number;
|
||||
owner: string;
|
||||
recipient: string;
|
||||
};
|
||||
|
||||
export type FremenRevivalGrantContext = Pick<
|
||||
Game,
|
||||
'status' | 'phase' | 'turn' | 'players' | 'freeRevival' | 'revivalRules' | 'recruits'
|
||||
>;
|
||||
|
||||
export class FremenRevivalGrantError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'FremenRevivalGrantError';
|
||||
}
|
||||
}
|
||||
|
||||
const id = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.length > 0;
|
||||
const count = (value: unknown): value is number =>
|
||||
Number.isSafeInteger(value) && (value as number) >= 0;
|
||||
|
||||
/** Permission only. The engine owns the response and commits or denies this
|
||||
* source once; normal revival quotes retain rates, prior usage and payment.
|
||||
* No busy gate belongs here because settlement validates its own response. */
|
||||
export function fremenRevivalGrantBlock(
|
||||
g: FremenRevivalGrantContext,
|
||||
owner: string,
|
||||
): string | null {
|
||||
if (g.status !== 'playing' || g.phase !== 4 || !count(g.turn) || g.turn < 1)
|
||||
return 'Grant Fremen allied free revival during the current Revival phase.';
|
||||
if (!id(owner) || !Array.isArray(g.players))
|
||||
return 'The grant needs the current native Fremen owner.';
|
||||
const owners = g.players.filter(player => player.id === owner);
|
||||
const fremen = owners[0];
|
||||
if (owners.length !== 1 || fremen.faction !== 'fremen' ||
|
||||
g.players.filter(player => player.faction === 'fremen').length !== 1)
|
||||
return 'The grant needs the current native Fremen owner.';
|
||||
const recipients = g.players.filter(player => player.id === fremen.ally);
|
||||
const recipient = recipients[0];
|
||||
if (!id(fremen.ally) || recipients.length !== 1 ||
|
||||
recipient.id === owner || recipient.ally !== owner)
|
||||
return 'The grant needs the Fremen owner’s current mutual ally.';
|
||||
if (!Array.isArray(g.freeRevival))
|
||||
return 'The current Fremen revival permissions are invalid.';
|
||||
if (g.freeRevival.includes(recipient.id))
|
||||
return 'Fremen allied free revival has already been granted this phase.';
|
||||
if (g.revivalRules?.fremenGrantBlocked !== undefined &&
|
||||
(!Array.isArray(g.revivalRules.fremenGrantBlocked) ||
|
||||
g.revivalRules.fremenGrantBlocked.some(target => !id(target))))
|
||||
return 'The current Fremen grant prevention record is invalid.';
|
||||
if (g.revivalRules?.fremenGrantBlocked?.includes(recipient.id))
|
||||
return 'Karama prevented this Fremen allied grant for the current Revival phase.';
|
||||
if (g.recruits?.turn === g.turn &&
|
||||
(!count(recipient.revived) ||
|
||||
(recipient.freeForcesRevived === undefined
|
||||
? recipient.revived > 0
|
||||
: !count(recipient.freeForcesRevived) ||
|
||||
recipient.freeForcesRevived > recipient.revived)))
|
||||
return 'The ally’s prior normal revival usage is inconsistent or unknown; this grant cannot safely change its rate.';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function quoteFremenRevivalGrant(
|
||||
g: FremenRevivalGrantContext,
|
||||
owner: string,
|
||||
): FremenRevivalGrantSource {
|
||||
const blocked = fremenRevivalGrantBlock(g, owner);
|
||||
if (blocked) throw new FremenRevivalGrantError(blocked);
|
||||
const fremen = g.players.find(player => player.id === owner)!;
|
||||
return { turn: g.turn, owner, recipient: fremen.ally! };
|
||||
}
|
||||
|
||||
/** Bind the original relationship and turn before either response outcome.
|
||||
* No source supplied by a client may retarget a pending grant. */
|
||||
export function validateFremenRevivalGrant(
|
||||
g: FremenRevivalGrantContext,
|
||||
source: FremenRevivalGrantSource,
|
||||
): void {
|
||||
if (!source || typeof source !== 'object' || Array.isArray(source) ||
|
||||
Object.keys(source).sort().join(',') !== 'owner,recipient,turn' ||
|
||||
!count(source.turn) || source.turn !== g.turn ||
|
||||
!id(source.owner) || !id(source.recipient))
|
||||
throw new FremenRevivalGrantError('The Fremen allied revival grant has lost its original source.');
|
||||
const current = quoteFremenRevivalGrant(g, source.owner);
|
||||
if (current.recipient !== source.recipient)
|
||||
throw new FremenRevivalGrantError('The Fremen allied revival grant no longer matches its original ally.');
|
||||
}
|
||||
|
||||
/** Owner-only status. No wallet, Tanks, prior counts or quoted free amount is
|
||||
* projected; the existing normal revival panel owns numerical allowances. */
|
||||
export function projectFremenRevivalGrant(
|
||||
g: FremenRevivalGrantContext,
|
||||
viewer: string,
|
||||
): { recipient: string; granted: boolean; blocked: string | null } | null {
|
||||
if (!id(viewer) || !Array.isArray(g.players)) return null;
|
||||
const owners = g.players.filter(player => player.id === viewer);
|
||||
const owner = owners[0];
|
||||
if (owners.length !== 1 || owner.faction !== 'fremen' ||
|
||||
!id(owner.ally) || owner.ally === owner.id ||
|
||||
g.players.filter(player => player.id === owner.ally).length !== 1)
|
||||
return null;
|
||||
return {
|
||||
recipient: owner.ally,
|
||||
granted: Array.isArray(g.freeRevival) && g.freeRevival.includes(owner.ally),
|
||||
blocked: fremenRevivalGrantBlock(g, viewer),
|
||||
};
|
||||
}
|
||||
@@ -189,6 +189,14 @@ const cancellationSources = {
|
||||
: response.advisorResume === 'declaration'
|
||||
? { battle: battleSource(g), active: g.active }
|
||||
: movementSource(g),
|
||||
fremenRevivalGrant: (g, response) => ({
|
||||
source: response.fremenGrant ?? null,
|
||||
ownerAlly: g.players.find(p => p.id === response.owner)?.ally ?? null,
|
||||
recipientAlly: g.players.find(p => p.id === response.recipient)?.ally ?? null,
|
||||
granted: g.freeRevival.includes(response.recipient ?? ''),
|
||||
blocked: g.revivalRules?.fremenGrantBlocked?.includes(response.recipient ?? '') ?? false,
|
||||
recruits: g.recruits?.turn === g.turn,
|
||||
}),
|
||||
bgCharity: () => null,
|
||||
choamCharity: () => null,
|
||||
choamInflation: (g) => ({
|
||||
|
||||
@@ -42,6 +42,7 @@ const cancellationLabels = {
|
||||
guildTransportPermission: 'Guild transport permission',
|
||||
emperorGift: 'the Emperor gift',
|
||||
emperorRevival: 'Emperor revival',
|
||||
fremenRevivalGrant: 'the Fremen allied free-revival grant',
|
||||
stormPeek: 'the storm peek',
|
||||
stormProtection: 'storm protection',
|
||||
wormPlacement: 'additional worm placement',
|
||||
|
||||
+80
-3
@@ -5206,6 +5206,83 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{area:'Verification',status:'Partial',detail:"Classic Nexus optional14 now retains original Tech3+ and Advanced Strongholds2+ with selected33/36. Mandatory Banker remains separate from native phase income; actual third-party receipts, trained-disc posture, local bank support and original cleanup/replacement stay distinct. Real normal lobby, earned Carthag/Arrakeen and fixed/free Axlotl controls complete with four legal policies. Earlier native/Richese evidence remains dated; full modes, strategy and deferred assurance stay open.",evidence:['tests/nexus-core-tech-runtime.test.ts','tests/fixture-nexus-core-tech.ts','tests/nexus-core-strongholds-runtime.test.ts','tests/fixture-nexus-core-strongholds.ts','tests/classic-nexus-core.test.ts','tests/fixture-classic-nexus-core.ts','game/faction-module-profile.ts','tests/classic-independent-nexus-utility.test.ts', 'tests/classic-independent-nexus-battle.test.ts', 'tests/fixture-classic-independent-nexus.ts', 'tests/bureaucrat-richese-cache.test.ts','tests/bureaucrat-richese-black-market.test.ts','tests/fixture-bureaucrat-richese-funding.ts','docs/BUREAUCRAT_PAYMENTS.md','tests/diplomat-used-defense.test.ts', 'tests/diplomat-late-journey.test.ts', 'tests/fixture-diplomat-late-journey.ts', 'tests/diplomat-context-journey.test.ts', 'tests/fixture-diplomat-context-journey.ts', 'docs/DIPLOMAT_DEFENSE.md', 'tests/smuggler-kwisatz-journey.test.ts', 'tests/fixture-smuggler-kwisatz-journey.ts', 'tests/smuggler-artillery-journey.test.ts', 'tests/fixture-smuggler-artillery-journey.ts', 'docs/SMUGGLER_BATTLE.md', 'tests/homeworld-classic-nexus-skills-runtime.test.ts', 'tests/homeworld-classic-discovery-nexus-runtime.test.ts','tests/homeworld-native-e1e2-modules-runtime.test.ts','tests/homeworld-native-e3-modules-runtime.test.ts','tests/homeworld-classic-skills-runtime.test.ts','tests/homeworld-skills-discovery-runtime.test.ts','tests/homeworld-optional-modules-runtime.test.ts','tests/leader-skills-engine.test.ts','tests/leader-skills-capture.test.ts','tests/captured-skill-revival.test.ts','tests/fixture-captured-skill-revival.ts','tests/captured-skill-special-revival.test.ts','tests/fixture-captured-skill-special-revival.ts','tests/leader-battle-effects.test.ts','tests/suk-graduate.test.ts','tests/mentat-question.test.ts','tests/bureaucrat-payment.test.ts','tests/smuggler-battle.test.ts','tests/spice-banker.test.ts','tests/advanced-native-skills.test.ts','tests/fixture-advanced-native-skills.ts','tests/skills-tech-payments-runtime.test.ts','tests/skills-tech-battle-runtime.test.ts','tests/ix-choam-skills-tech-runtime.test.ts','tests/tleilaxu-skills-tech-runtime.test.ts','tests/richese-skills-tech-runtime.test.ts','tests/classic-skills-stronghold-runtime.test.ts','tests/native-skills-stronghold-runtime.test.ts','tests/ecaz-skills-modules-runtime.test.ts','tests/moritani-skills-modules-runtime.test.ts','docs/LEADER_SKILLS_RUNTIME.md','docs/HOMEWORLD_RUNTIME_IMPLEMENTATION.md']},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "fremen-revival-grant",
|
||||
"title": "Fremen allied free revival",
|
||||
"category": "Factions",
|
||||
"coverage": "Partial",
|
||||
"developmentStage": "Prototyped",
|
||||
"summary": "Grant the current ally its Fremen free-revival rate after a native Karama response, including after Recruits.",
|
||||
"steps": [
|
||||
"During Revival, Fremen may offer the current ally its ordinary free-force rate of three. The grant replaces the native ordinary rate; current bonuses, total limits and separate special-force limits still apply. It never moves counters by itself.",
|
||||
"The original owner, ally and turn remain bound while another player may prevent the grant with Karama. All-pass commits once. Under the provisional prevention policy, cancellation denies this grant for the current Revival, preserving the ally’s intrinsic free rate, Recruits and completed returns/payments. The next native Revival clears the denial.",
|
||||
"Recruits doubles the current granted rate including applicable bonuses before caps. With no other bonus, three becomes six. Provisionally accept grants before or after the card with the same calculation: deduct recorded free usage, preserve all earlier total returns and paid classifications, and give no refund. Finish a pending transaction before declaring a grant.",
|
||||
"The Fremen panel keeps status and unavailable reasons visible. Your ally’s normal revival panel supplies the actual remaining amount. Complete modes, broader combinations and final acceptance remain unfinished."
|
||||
],
|
||||
"related": [
|
||||
"revival",
|
||||
"faction-fremen",
|
||||
"card-recruits",
|
||||
"card-karama"
|
||||
],
|
||||
"checklist": [
|
||||
{
|
||||
"area": "Implementation",
|
||||
"status": "Partial",
|
||||
"detail": "Original owner/mutual-ally/turn source validates before cancellation cost and both outcomes. Only all-pass commits; cancellation marks a phase-scoped denied grant without general free prevention. Existing native arithmetic retains limits, prices, bonuses and prior usage.",
|
||||
"evidence": [
|
||||
"game/fremen-revival-grant.ts",
|
||||
"game/engine.ts",
|
||||
"game/revival.ts",
|
||||
"game/karama-context.ts",
|
||||
"game/kull-purpose.ts"
|
||||
]
|
||||
},
|
||||
{
|
||||
"area": "Player controls",
|
||||
"status": "Partial",
|
||||
"detail": "Actual grant callback, native response and persistent status/policy guidance are integrated. Busy, interrupted, already granted and denied actions stay disabled; no private balance, Tanks or usage count is read.",
|
||||
"evidence": [
|
||||
"components/fremen-revival-grant.tsx",
|
||||
"components/game-table.tsx",
|
||||
"tests/fremen-revival-grant-controls.test.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"area": "AI",
|
||||
"status": "Partial",
|
||||
"detail": "Sixteen existing-profile native/Recruits paths accept80 legal own-view grant/response/return proposals. The same projected permission prevents repeat grants. Strategy and strength calibration remain deferred.",
|
||||
"evidence": [
|
||||
"game/bots.ts",
|
||||
"tests/recruits-controls.test.tsx",
|
||||
"docs/FREMEN_REVIVAL_GRANT.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"area": "Documentation",
|
||||
"status": "Partial",
|
||||
"detail": "Distinguishes printed native rates and general alliance cancellation from provisional digital response duration and late-Recruits current-rate symmetry.",
|
||||
"evidence": [
|
||||
"docs/FREMEN_REVIVAL_GRANT.md",
|
||||
"docs/RECRUITS_RUNTIME.md",
|
||||
"docs/RECRUITS_RULES.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"area": "Verification",
|
||||
"status": "Partial",
|
||||
"detail": "14 files305/305, types/lint/build, twelve actual control/gameplay/GameTable SSR journeys and sixteen minimal-policy paths pass. Genuine75–77 Basic/92–94 Advanced actions earn casualties and form a native Nexus alliance; Recruits is originally dealt and prior returns paid normally. Next actual Revival resets denial. Complete modes, visual/deployed and comprehensive assurance remain open.",
|
||||
"evidence": [
|
||||
"tests/fixture-fremen-revival-grant.ts",
|
||||
"tests/fremen-revival-grant-engine.test.ts",
|
||||
"tests/fremen-revival-grant-quote.test.ts",
|
||||
"tests/fremen-revival-grant-controls.test.tsx",
|
||||
"tests/recruits-engine.test.ts",
|
||||
"tests/recruits-controls.test.tsx"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'card-recruits',
|
||||
title: 'Recruits and the Ecaz Treachery Cards variant',
|
||||
@@ -5217,16 +5294,16 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'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; Recruits, bounded Reinforcements and Harass & Withdraw controls are connected.',
|
||||
'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 prevention still prevents free returns. Prior paid ordinary returns now have a visibly provisional policy: preserve payment, deduct only recorded free returns from the doubled free allowance and all returns from the total cap. Two free plus one paid Atreides may take two more free without refund; this is not a numerical user ruling. Missing/inconsistent ledgers, later Fremen grants, pending transactions and same-turn replay remain guarded.',
|
||||
'Free prevention still prevents free returns. Prior paid ordinary returns now have a visibly provisional policy: preserve payment, deduct only recorded free returns from the doubled free allowance and all returns from the total cap. Two free plus one paid Atreides may take two more free without refund; this is not a numerical user ruling. Fremen may now grant after play under the same provisional current-rate policy, with its original Karama response settled before commitment. Missing/inconsistent ledgers, pending transactions and same-turn replay remain guarded.',
|
||||
'Clean classic2–6 Basic/Advanced Homeworlds compose through the fresh independent36-card setup. Double current native free rates including low-population bonuses, within the seven-force total. Provisionally recheck population and recorded free use on each separate request: low Fremen may take7 together, but1 crossing high leaves5 further free and cannot buy a seventh without Tleilaxu. Original typed destinations and special-force limits stay. Other overlays/rosters, public activation and whole-mode acceptance remain unfinished.',
|
||||
'Classic or selected Ix/CHOAM native/base rosters, including Homeworlds, may retain original unused Tech Tokens3–6 and/or Stronghold Cards2–6 Advanced. Setup, first-Storm Tech and actual end-Mentat custody remain original. Recruits changes current native free rates, not the once-per-phase Axlotl trigger/delayed payout. Harass returns are not free Revival.',
|
||||
'Selected Ix/CHOAM native/base Basic/Advanced Worlds use original38/50 independent-card setup with optional original phase modules. Current native bonuses, typed deposits and accepted paid/free classifications stay. Ix keeps its multiple-Cyborg allowance; Tleilaxu and CHOAM remain unlimited rather than being reduced to seven. Other card/overlay/public-start gates remain.',
|
||||
'Supported original all14/card rosters retain Tech3+ and Advanced Strongholds2+, including classic/E1/E2 Worlds. Current native free allowance doubles after recorded use; separate World groups re-observe native population. Typed elite caps, unlimited native allowances, paid Cyborg surcharges and any Tleilaxu recipient stay. Axlotl accrues once and waits for phase end; normal Banker uses the accepted final Bank invoice and waits for Mentat, not a net wallet delta after a separate native payout. No ledger, refund, owner or premature spendable income is invented.',
|
||||
'Independent Discovery, with or without Nexus, retains original optional World/all14/phase modules. Purchased Recruits may double the native free rate against actual earlier nested-battle losses; accepted physical fee and dial casualties supply the Tanks. Existing late-play/current-rate policies stay provisional. Broader interactions and complete modes remain open.',
|
||||
],
|
||||
related: ['revival', 'homeworlds', 'ecaz-modules', 'expansion-faction-games', 'implementation-checklist'],
|
||||
related: ['fremen-revival-grant', '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, 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: '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. A later Fremen grant now uses the same current-rate/free-use quote after native Karama settlement; unknown ledgers and pending repricing 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'] },
|
||||
|
||||
@@ -28,6 +28,8 @@ export type RevivalRules = {
|
||||
nexusFremen?: { turn: number; owners: string[] };
|
||||
choamBlocked?: boolean;
|
||||
freeBlocked?: string[];
|
||||
/** This Revival turn's prevented native Fremen-to-ally grant opportunities. */
|
||||
fremenGrantBlocked?: string[];
|
||||
limitBlocked: boolean;
|
||||
fullPrice: boolean;
|
||||
allyDiscount: string | null;
|
||||
|
||||
@@ -127,6 +127,7 @@ const RESPONSES = {
|
||||
guildTransportPermission: true,
|
||||
emperorGift: true,
|
||||
emperorRevival: true,
|
||||
fremenRevivalGrant: true,
|
||||
stormPeek: true,
|
||||
stormProtection: true,
|
||||
wormPlacement: true,
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { applyAction, createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { RECRUITS_CARD_ID } from '../game/recruits';
|
||||
import { nextRicheseStrongholdsNativeStep } from './fixture-richese-strongholds';
|
||||
|
||||
export type FremenRevivalGrantStep = { actor: string; action: Action };
|
||||
export type FremenRevivalGrantTransition = {
|
||||
before: Game; steps: FremenRevivalGrantStep[]; after: Game;
|
||||
};
|
||||
export const fremenRevivalGrantPlayer = (game: Game, id: string) => {
|
||||
const player = game.players.find(player => player.id === id); assert.ok(player); return player;
|
||||
};
|
||||
export const fremenRevivalGrantClean = (game: Game) => !game.phaseOpening && !game.response &&
|
||||
!game.decision && !game.truthtrance && !game.pendingTreacheryDiscard && !game.pendingRevival;
|
||||
export const fremenRevivalGrantInventory = (game: Game) => {
|
||||
const cards = [...game.deck, ...game.discard, ...game.players.flatMap(player => player.hand),
|
||||
...(game.auction?.cards.slice(game.auction.index + (game.currentAuctionSale ? 1 : 0)) ?? [])];
|
||||
const ids = cards.map(card => card.id).sort();
|
||||
assert.equal(new Set(ids).size, ids.length, 'Original physical Treachery Cards retain distinct current slots');
|
||||
return ids;
|
||||
};
|
||||
|
||||
/** Scope original scalar shuffle choices only; UUIDs and other byte entropy stay
|
||||
* native. No card, force, wallet or historical event is installed after a deal. */
|
||||
function originalScalarOrder<T>(run: () => T): T {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
|
||||
const original = crypto.getRandomValues.bind(crypto);
|
||||
crypto.getRandomValues = <V extends ArrayBufferView | null>(array: V): V => {
|
||||
if (array instanceof Uint32Array && array.length === 1) array[0] = 0xffffffff;
|
||||
else Reflect.apply(original, crypto, [array]);
|
||||
return array;
|
||||
};
|
||||
try { return run(); }
|
||||
finally {
|
||||
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
|
||||
else Reflect.deleteProperty(crypto, 'getRandomValues');
|
||||
}
|
||||
}
|
||||
export function actFremenRevivalGrant(game: Game, actor: string, action: Action,
|
||||
actions: FremenRevivalGrantStep[] = []): Game {
|
||||
const next = originalScalarOrder(() => applyAction(game, actor, action));
|
||||
actions.push({ actor, action: structuredClone(action) }); return next;
|
||||
}
|
||||
/** Advance native controls only. No voluntary revival, grant, Recruits play,
|
||||
* alliance or private battle plan is selected by the generic continuation. */
|
||||
export function nextFremenRevivalGrantStep(game: Game): FremenRevivalGrantStep | null {
|
||||
if (!game.phaseOpening && !game.response && !game.pendingTreacheryDiscard) {
|
||||
if (game.status === 'setup' && game.setupStage === 'forces' &&
|
||||
fremenRevivalGrantPlayer(game, 'fremen').reserves === 20)
|
||||
return { actor: 'fremen', action: { type: 'fremenSetup', placements: { 'sietch_tabr:14': 10 } } };
|
||||
if (game.decision?.kind === 'wormRide' || game.decision?.kind === 'wormPlacement')
|
||||
return { actor: game.decision.player, action: { type: 'decision', accept: false } };
|
||||
if (game.decision?.kind === 'guildTiming')
|
||||
return { actor: game.decision.player, action: { type: 'decision', take: false } };
|
||||
}
|
||||
return nextRicheseStrongholdsNativeStep(game);
|
||||
}
|
||||
export function advanceFremenRevivalGrant(game: Game, until: (state: Game) => boolean,
|
||||
actions: FremenRevivalGrantStep[] = []): Game {
|
||||
for (let count = 0; count < 1800; count++) {
|
||||
if (until(game)) return game;
|
||||
assert.notEqual(game.status, 'finished', 'The genuine Fremen grant chronology ended before its requested control');
|
||||
const next = nextFremenRevivalGrantStep(game);
|
||||
assert.ok(next, `Supply the original control at ${game.status}/${game.turn}/${game.phase}/${game.decision?.kind}`);
|
||||
game = actFremenRevivalGrant(game, next.actor, next.action, actions);
|
||||
}
|
||||
throw Error('The native Fremen grant chronology did not reach its finite boundary');
|
||||
}
|
||||
export function passFremenRevivalGrantResponses(game: Game,
|
||||
actions: FremenRevivalGrantStep[] = []): Game {
|
||||
for (let count = 0; game.response && count < 60; count++) {
|
||||
const actor = game.players.find(player => !game.response!.passed.includes(player.id)); assert.ok(actor);
|
||||
game = actFremenRevivalGrant(game, actor.id, { type: 'passResponse' }, actions);
|
||||
}
|
||||
assert.ok(!game.response); return game;
|
||||
}
|
||||
export function cloneFremenRevivalGrant(game: Game): Game {
|
||||
assert.ok(fremenRevivalGrantClean(game), 'Clone a clean original Revival choice boundary');
|
||||
return structuredClone(game);
|
||||
}
|
||||
|
||||
/** Actual public classic setup, a paid Tabr invasion, six native casualties per
|
||||
* combatant, a later original Shai-Hulud and accepted Nexus alliance. Stop at
|
||||
* clean turn-two Revival BEFORE grant or optional Recruits activation. Only
|
||||
* unopened physical card/Spice positions and native scalar shuffles are chosen. */
|
||||
export function createFremenRevivalGrantFixture(options: {
|
||||
advanced?: boolean; recruits?: boolean; priorRevival?: boolean;
|
||||
} = {}) {
|
||||
const advanced = options.advanced ?? false, recruits = options.recruits ?? false;
|
||||
const fremen = 'fremen', ally = 'atreides', observer = 'emperor';
|
||||
let game = createGame('FREMENREVIVALGRANT', newPlayer(fremen, 'Fremen', 'fremen'), advanced);
|
||||
joinGame(game, newPlayer(ally, 'Atreides', 'atreides'));
|
||||
joinGame(game, newPlayer(observer, 'Emperor', 'emperor'));
|
||||
const initial = structuredClone(game), actions: FremenRevivalGrantStep[] = [];
|
||||
const act = (actor: string, action: Action) => { game = actFremenRevivalGrant(game, actor, action, actions); };
|
||||
const reach = (until: (state: Game) => boolean) => { game = advanceFremenRevivalGrant(game, until, actions); };
|
||||
const transition = (run: () => void): FremenRevivalGrantTransition => {
|
||||
const before = structuredClone(game), start = actions.length; run();
|
||||
return { before, steps: structuredClone(actions.slice(start)), after: structuredClone(game) };
|
||||
};
|
||||
if (recruits) act(game.host, { type: 'moduleOption', module: 'ecazTreachery', enabled: true });
|
||||
for (const player of game.players) act(player.id, { type: 'ready' });
|
||||
const lobby = structuredClone(game);
|
||||
act(game.host, advanced ? { type: 'start', advancedPreview: true } : { type: 'start' });
|
||||
const setup = structuredClone(game);
|
||||
assert.equal(game.status, 'setup'); assert.equal(game.advanced, advanced);
|
||||
assert.equal(!!game.ecazTreachery, recruits);
|
||||
assert.ok(!game.homeworlds && !game.nexusCards && !game.leaderSkills && !game.techTokens &&
|
||||
!game.strongholdCards && !game.discoveryEnabled);
|
||||
assert.ok(game.players.every(player => player.hand.length === 0));
|
||||
const originalInventory = fremenRevivalGrantInventory(game);
|
||||
const ordinary = game.deck.filter(card => ['projectile', 'poison', 'shield', 'snooper'].includes(card.kind)).slice(0, 2);
|
||||
const karama = game.deck.find(card => card.effect === 'karama');
|
||||
const recruitsCard = recruits ? game.deck.find(card => card.id === RECRUITS_CARD_ID) : null;
|
||||
assert.equal(ordinary.length, 2); assert.ok(karama); assert.ok(!recruits || recruitsCard);
|
||||
const startingCards = [recruitsCard ?? ordinary[0], ordinary[1], karama];
|
||||
const dealOrder = startingCards.map((card, position) => {
|
||||
const from = game.deck.findIndex(candidate => candidate.id === card.id); assert.ok(from >= position);
|
||||
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
|
||||
return { position, from, card: card.id };
|
||||
});
|
||||
const beforeStartingDeal = structuredClone(game);
|
||||
reach(state => state.status === 'playing');
|
||||
const afterSetup = structuredClone(game);
|
||||
assert.deepEqual(game.players.flatMap(player => player.hand.map(card => card.id)), startingCards.map(card => card.id));
|
||||
assert.equal(fremenRevivalGrantPlayer(game, fremen).forces['sietch_tabr:14'], 10);
|
||||
assert.ok(fremenRevivalGrantPlayer(game, observer).hand.some(card => card.id === karama.id));
|
||||
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const spiceOrder = [0, 1].map(position => {
|
||||
const from = game.spiceDeck.findIndex((card, index) => index >= position && 'territory' in card && !card.discovery &&
|
||||
card.territory !== 'sietch_tabr');
|
||||
assert.ok(from >= position); const card = game.spiceDeck.splice(from, 1)[0];
|
||||
game.spiceDeck.splice(position, 0, card); return { position, from, card: structuredClone(card) };
|
||||
});
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), originalSpice);
|
||||
reach(state => state.turn === 1 && state.phase === 5 && state.active === ally && fremenRevivalGrantClean(state));
|
||||
const shipment = transition(() => {
|
||||
act(ally, { type: 'ship', territory: 'sietch_tabr', sector: 14, amount: 6, elite: 0, allyPayment: 0 });
|
||||
reach(fremenRevivalGrantClean);
|
||||
});
|
||||
assert.equal(fremenRevivalGrantPlayer(game, ally).forces['sietch_tabr:14'], 6);
|
||||
assert.equal(fremenRevivalGrantPlayer(shipment.before, ally).spice - fremenRevivalGrantPlayer(game, ally).spice, 6);
|
||||
reach(state => state.turn === 1 && state.phase === 6 && fremenRevivalGrantClean(state) && !!state.active);
|
||||
const beforeBattle = structuredClone(game);
|
||||
const choice = viewGame(game, game.active!).battleChoices.find(choice => choice.territory === 'sietch_tabr'); assert.ok(choice);
|
||||
const battleStep: FremenRevivalGrantStep = { actor: choice.chooser,
|
||||
action: { type: 'chooseBattle', territory: 'sietch_tabr',
|
||||
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker } };
|
||||
act(battleStep.actor, battleStep.action);
|
||||
const plans: FremenRevivalGrantStep[] = [];
|
||||
for (const actor of [fremen, ally]) {
|
||||
reach(state => !!state.battle && fremenRevivalGrantClean(state) &&
|
||||
!state.battle.preparation && state.battle.preLeader?.closed !== false);
|
||||
const leaders = fremenRevivalGrantPlayer(game, actor).leaders.filter(leader => !leader.dead && !leader.usedAt)
|
||||
.sort((a, b) => a.strength - b.strength || a.id.localeCompare(b.id));
|
||||
const leader = actor === fremen ? leaders.at(-1) : leaders[0]; assert.ok(leader);
|
||||
const step: FremenRevivalGrantStep = { actor, action: { type: 'battlePlan',
|
||||
leader: leader.id, dial: actor === fremen ? 6 : 0, support: 0, weapon: null, defense: null } };
|
||||
plans.push(step); act(step.actor, step.action);
|
||||
}
|
||||
reach(state => !state.battle && fremenRevivalGrantClean(state));
|
||||
const afterBattle = structuredClone(game);
|
||||
assert.equal(fremenRevivalGrantPlayer(game, fremen).tanks, 6);
|
||||
assert.equal(fremenRevivalGrantPlayer(game, ally).tanks, 6);
|
||||
reach(state => state.turn === 2 && state.phase === 1 && fremenRevivalGrantClean(state) &&
|
||||
!state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus && state.ready.length === 0);
|
||||
const beforeWormOrder = game.spiceDeck.map(card => JSON.stringify(card)).sort();
|
||||
const wormIndex = game.spiceDeck.findIndex(card => 'worm' in card && !card.greatMaker && !card.suppressed && !card.thumper);
|
||||
assert.ok(wormIndex >= 0);
|
||||
const worm = game.spiceDeck.splice(wormIndex, 1)[0]; game.spiceDeck.unshift(worm);
|
||||
const wormOrder = { position: 0, from: wormIndex, card: structuredClone(worm) };
|
||||
assert.deepEqual(game.spiceDeck.map(card => JSON.stringify(card)).sort(), beforeWormOrder);
|
||||
reach(state => state.nexus && !state.spiceWindow && !state.spiceResolution && fremenRevivalGrantClean(state));
|
||||
const alliance = transition(() => {
|
||||
act(fremen, { type: 'alliance', target: ally });
|
||||
act(ally, { type: 'alliance', target: fremen });
|
||||
});
|
||||
assert.equal(fremenRevivalGrantPlayer(game, fremen).ally, ally);
|
||||
assert.equal(fremenRevivalGrantPlayer(game, ally).ally, fremen);
|
||||
reach(state => state.turn === 2 && state.phase === 4 && fremenRevivalGrantClean(state));
|
||||
const beforePriorRevival = structuredClone(game);
|
||||
let priorRevival: FremenRevivalGrantTransition | null = null;
|
||||
if (options.priorRevival) priorRevival = transition(() => {
|
||||
act(ally, { type: 'revive', amount: 3, elite: 0 });
|
||||
reach(fremenRevivalGrantClean);
|
||||
});
|
||||
assert.equal(game.turn, 2); assert.equal(game.phase, 4); assert.ok(fremenRevivalGrantClean(game));
|
||||
assert.equal(fremenRevivalGrantPlayer(game, ally).tanks, options.priorRevival ? 3 : 6);
|
||||
assert.equal(fremenRevivalGrantPlayer(game, ally).revived, options.priorRevival ? 3 : 0);
|
||||
assert.equal(fremenRevivalGrantPlayer(game, ally).freeForcesRevived, options.priorRevival ? 2 : 0);
|
||||
assert.ok(!options.priorRevival ||
|
||||
fremenRevivalGrantPlayer(beforePriorRevival, ally).spice - fremenRevivalGrantPlayer(game, ally).spice === 2);
|
||||
assert.ok(!game.freeRevival.includes(ally));
|
||||
assert.ok(!game.recruits);
|
||||
assert.ok(fremenRevivalGrantPlayer(game, observer).hand.some(card => card.id === karama.id));
|
||||
assert.deepEqual(fremenRevivalGrantInventory(game), originalInventory);
|
||||
const grant: Action = { type: 'grantRevival' };
|
||||
const recruitsPlay: Action | null = recruits ? { type: 'card', card: RECRUITS_CARD_ID } : null;
|
||||
return { game, initial, lobby, setup, beforeStartingDeal, afterSetup, beforeBattle, afterBattle,
|
||||
beforePriorRevival, shipment, battleStep, plans, alliance, priorRevival,
|
||||
fremen, owner: fremen, ally, beneficiary: ally, observer, counterOwner: observer,
|
||||
karama: karama.id, recruits: recruitsCard?.id ?? null, recruitsPlay,
|
||||
grant, grantStep: { actor: fremen, action: grant } satisfies FremenRevivalGrantStep,
|
||||
recruitsStep: recruitsPlay ? { actor: fremen, action: recruitsPlay } satisfies FremenRevivalGrantStep : null,
|
||||
ordinaryRevival: { type: 'revive', amount: 3, elite: 0 } satisfies Action,
|
||||
advanced, actions, sourceActionCount: actions.length, originalInventory, originalSpice,
|
||||
dealOrder, spiceOrder, wormOrder,
|
||||
staging: [
|
||||
'Actual public classic Fremen/Atreides/Emperor ready/start and native private setup. Optional independent three-card variant is selected before start; Advanced uses the original explicit preview action.',
|
||||
'Select original scalar shuffle order and three original unopened Treachery positions. Emperor receives its original Karama; optional Recruits is physically dealt to Fremen. No played hand is replaced or later card acquired.',
|
||||
'Native Fremen setup puts ten ordinary counters at Tabr. Atreides actually pays six to ship six original reserves there. Both seal unarmed native leader plans; Fremen really dials six and wins. Original casualties put six counters from each faction into Tanks.',
|
||||
'Order two original unopened ordinary first-turn Spice Cards and, only before actual turn-two Spice Blow, one original unopened Shai-Hulud. The actual Nexus permits Fremen proposal and Atreides acceptance; intervening native Storm, market-free Bidding, worm and readiness controls execute.',
|
||||
options.priorRevival
|
||||
? 'At the real second Revival, Atreides actually returns three normal counters before the grant: two recorded free and one paid at two spice. Payment, usage and native Tanks remain earned state.'
|
||||
: 'At the real second Revival, neither ally has returned any counter this phase.',
|
||||
'Stop before Fremen grant or optional Recruits play. No alliance, phase, wallet, army, denial stamp, response or receipt is assigned, and no grant/Recruits settlement policy is asserted.',
|
||||
],
|
||||
};
|
||||
}
|
||||
export type FremenRevivalGrantFixture = ReturnType<typeof createFremenRevivalGrantFixture>;
|
||||
@@ -0,0 +1,148 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Children, createElement, isValidElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { FremenRevivalGrant } from '../components/fremen-revival-grant';
|
||||
import {
|
||||
createGame,
|
||||
newPlayer,
|
||||
viewGame,
|
||||
type Action,
|
||||
type GameView,
|
||||
} from '../game/engine';
|
||||
|
||||
// These client fixtures exercise public grant projections. Engine source tests
|
||||
// own the actual alliance, Karama response and physical revival transitions.
|
||||
function projected(): GameView {
|
||||
const game = createGame('FREMENGRANTCONTROLS', newPlayer('f', 'Fremen', 'fremen'));
|
||||
game.players.push(newPlayer('a', 'Ally', 'emperor'));
|
||||
Object.assign(game, {
|
||||
status: 'playing', phase: 4, turn: 3, order: ['f', 'a'],
|
||||
});
|
||||
game.players[0].ally = 'a';
|
||||
game.players[1].ally = 'f';
|
||||
const view = viewGame(game, 'f');
|
||||
view.fremenRevivalGrant = { recipient: 'a', granted: false, blocked: null };
|
||||
return view;
|
||||
}
|
||||
|
||||
function markup(game: GameView, busy = false) {
|
||||
return renderToStaticMarkup(createElement(FremenRevivalGrant, {
|
||||
game, busy, act() {},
|
||||
}));
|
||||
}
|
||||
|
||||
function click(node: ReactNode): boolean {
|
||||
for (const child of Children.toArray(node)) {
|
||||
if (!isValidElement<{
|
||||
children?: ReactNode;
|
||||
onClick?: () => void;
|
||||
'data-testid'?: string;
|
||||
}>(child)) continue;
|
||||
if (child.props['data-testid'] === 'fremen-revival-grant-submit' && child.props.onClick) {
|
||||
child.props.onClick();
|
||||
return true;
|
||||
}
|
||||
if (click(child.props.children)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function grant(game: GameView, busy = false): Action[] {
|
||||
const actions: Action[] = [];
|
||||
click(FremenRevivalGrant({ game, busy, act(action) { actions.push(action); } }));
|
||||
return actions;
|
||||
}
|
||||
|
||||
void test('native Fremen grant renders its ally and submits the original grant action once', () => {
|
||||
const game = projected();
|
||||
const html = markup(game);
|
||||
assert.match(html, /aria-label="Fremen ally free revival"/);
|
||||
assert.match(html, /Grant Ally an ordinary free force revival rate of three this phase/);
|
||||
assert.match(html, /Grant ally free revival/);
|
||||
assert.doesNotMatch(html, /disabled=""/);
|
||||
assert.deepEqual(grant(game), [{ type: 'grantRevival' }]);
|
||||
});
|
||||
|
||||
void test('the persistent grant guide names native three, provisional Recruits current rates, prior free use and no refunds', () => {
|
||||
const html = markup(projected());
|
||||
assert.match(html, /replaces their native ordinary rate rather than adding three/);
|
||||
assert.match(html, /Ordinary total revival and special-force limits still apply/);
|
||||
assert.match(html, /Provisional current-rate policy/);
|
||||
assert.match(html, /Recruits doubles the current granted rate, including applicable bonuses,\s*before caps/);
|
||||
assert.match(html, /Later grants use the same current-rate calculation/);
|
||||
assert.match(html, /your ally’s revival controls show the actual remaining allowance/);
|
||||
assert.match(html, /Previously recorded free returns use the allowance/);
|
||||
assert.match(html, /Earlier paid returns keep their payments and count toward the total revival limit/);
|
||||
assert.match(html, /no refund or retroactive reclassification/);
|
||||
assert.match(html, /Another player may use Karama before the original alliance grant takes effect/);
|
||||
assert.match(html, /Canceling blocks this Fremen grant to the current ally for this Revival/);
|
||||
assert.match(html, /the ally retains its intrinsic rate and Recruits/);
|
||||
assert.match(html, /Completed revivals and payments stay unchanged/);
|
||||
assert.match(html, /response timing and prevention scope are provisional/);
|
||||
});
|
||||
|
||||
void test('busy, Truthtrance and pending Nexus block the actual callback', () => {
|
||||
const game = projected();
|
||||
assert.deepEqual(grant(game, true), []);
|
||||
assert.match(markup(game, true), /<button\b[^>]*\sdisabled=""/);
|
||||
const truthtrance = structuredClone(game);
|
||||
truthtrance.truthtrance = {} as NonNullable<GameView['truthtrance']>;
|
||||
assert.deepEqual(grant(truthtrance), []);
|
||||
assert.match(markup(truthtrance), /<button\b[^>]*\sdisabled=""/);
|
||||
const nexus = structuredClone(game);
|
||||
nexus.nexusTraitors = {
|
||||
offer: null,
|
||||
pending: {} as NonNullable<NonNullable<GameView['nexusTraitors']>['pending']>,
|
||||
};
|
||||
assert.deepEqual(grant(nexus), []);
|
||||
assert.match(markup(nexus), /<button\b[^>]*\sdisabled=""/);
|
||||
});
|
||||
|
||||
void test('blocked and granted projections retain readable status and guidance without permitting replay', () => {
|
||||
for (const status of ['blocked', 'granted'] as const) {
|
||||
const game = projected();
|
||||
if (status === 'blocked') game.fremenRevivalGrant!.blocked = 'Karama prevents this ally grant for the rest of this phase.';
|
||||
if (status === 'granted') game.fremenRevivalGrant!.granted = true;
|
||||
for (let render = 0; render < 3; render++) {
|
||||
const html = markup(game);
|
||||
assert.match(html, /Grant ally free revival/);
|
||||
assert.match(html, /<button\b[^>]*\sdisabled=""/);
|
||||
assert.match(html, /Provisional current-rate policy/);
|
||||
if (status === 'blocked') {
|
||||
assert.match(html, /Karama prevents this ally grant for the rest of this phase/);
|
||||
assert.match(html, /aria-describedby="fremen-revival-grant-unavailable"/);
|
||||
} else {
|
||||
assert.match(html, /Free revival has been granted to Ally for this phase/);
|
||||
}
|
||||
assert.deepEqual(grant(game), []);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
void test('the grant control uses only its native owner, actual ally and current Revival projection', () => {
|
||||
for (const field of ['phase', 'status', 'viewer', 'faction', 'ally', 'recipient', 'offer'] as const) {
|
||||
const game = projected();
|
||||
if (field === 'phase') game.phase = 5;
|
||||
if (field === 'status') game.status = 'lobby';
|
||||
if (field === 'viewer') game.me = 'a';
|
||||
if (field === 'faction') game.players[0].faction = 'emperor';
|
||||
if (field === 'ally') game.players[0].ally = null;
|
||||
if (field === 'recipient') game.fremenRevivalGrant!.recipient = 'someone-else';
|
||||
if (field === 'offer') game.fremenRevivalGrant = null;
|
||||
assert.equal(markup(game), '', field);
|
||||
assert.deepEqual(grant(game), [], field);
|
||||
}
|
||||
});
|
||||
|
||||
void test('grant guidance never reads the ally wallet, Tanks or private revival use', () => {
|
||||
const game = projected();
|
||||
const ally = game.players.find((player) => player.id === 'a')!;
|
||||
for (const field of ['spice', 'tanks', 'deadForces', 'revived', 'freeForcesRevived']) {
|
||||
Object.defineProperty(ally, field, {
|
||||
get() { throw new Error(`Grant control read private ally ${field}`); },
|
||||
});
|
||||
}
|
||||
assert.match(markup(game), /Grant Ally an ordinary free force revival rate of three/);
|
||||
assert.deepEqual(grant(game), [{ type: 'grantRevival' }]);
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { viewGame } from '../game/engine';
|
||||
import {
|
||||
createFremenRevivalGrantFixture as fixture,
|
||||
actFremenRevivalGrant as act,
|
||||
passFremenRevivalGrantResponses as pass,
|
||||
advanceFremenRevivalGrant as advance,
|
||||
fremenRevivalGrantClean as clean,
|
||||
fremenRevivalGrantPlayer as player,
|
||||
fremenRevivalGrantInventory as inventory,
|
||||
} from './fixture-fremen-revival-grant';
|
||||
|
||||
for (const advanced of [false, true]) {
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} original allied grant waits for its native Karama response then permits three free`, () => {
|
||||
const f = fixture({ advanced }), before = structuredClone(f.game);
|
||||
let g = act(f.game, f.fremen, f.grant);
|
||||
assert.equal(g.response?.kind, 'fremenRevivalGrant');
|
||||
assert.deepEqual(g.response?.fremenGrant, { turn: 2, owner: f.fremen, recipient: f.ally });
|
||||
assert.equal(g.freeRevival.includes(f.ally), false);
|
||||
assert.ok(viewGame(g, f.observer).responseControls?.cancelCards.includes(f.karama));
|
||||
assert.throws(() => act(g, f.ally, { type: 'revive', amount: 3, elite: 0 }), /response|power|interaction/i);
|
||||
g = pass(g);
|
||||
assert.equal(g.freeRevival.filter(id => id === f.ally).length, 1);
|
||||
const spice = player(g, f.ally).spice;
|
||||
g = advance(act(g, f.ally, { type: 'revive', amount: 3, elite: 0 }), clean);
|
||||
assert.equal(player(g, f.ally).spice, spice);
|
||||
assert.equal(player(g, f.ally).revived, 3);
|
||||
assert.equal(player(g, f.ally).freeForcesRevived, 3);
|
||||
assert.deepEqual(inventory(g), f.originalInventory);
|
||||
assert.deepEqual(f.game, before);
|
||||
assert.throws(() => act(g, f.fremen, f.grant), /already been granted/);
|
||||
});
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} real Karama denies only the original allied grant and cannot be bypassed by redeclaring`, () => {
|
||||
const f = fixture({ advanced });
|
||||
let g = act(f.game, f.fremen, f.grant);
|
||||
g = act(g, f.observer, { type: 'card', card: f.karama, mode: 'cancel' });
|
||||
assert.ok(!g.response);
|
||||
assert.equal(g.freeRevival.includes(f.ally), false);
|
||||
assert.deepEqual(g.revivalRules?.fremenGrantBlocked, [f.ally]);
|
||||
assert.equal(g.revivalRules?.freeBlocked?.includes(f.ally) ?? false, false);
|
||||
assert.equal(viewGame(g, f.ally).revival.freeRemaining, 2);
|
||||
assert.equal(g.discard.filter(c => c.id === f.karama).length, 1);
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => act(g, f.fremen, f.grant), /Karama prevented/);
|
||||
assert.deepEqual(g, before);
|
||||
g = advance(act(g, f.ally, { type: 'revive', amount: 2, elite: 0 }), clean);
|
||||
assert.equal(player(g, f.ally).freeForcesRevived, 2);
|
||||
assert.deepEqual(inventory(g), f.originalInventory);
|
||||
});
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} grant prevention expires through the next actual Revival opening`, () => {
|
||||
const f = fixture({ advanced });
|
||||
let g = act(f.game, f.fremen, f.grant);
|
||||
g = act(g, f.observer, { type: 'card', card: f.karama, mode: 'cancel' });
|
||||
g = advance(g, state => state.turn === 3 && state.phase === 4 && clean(state));
|
||||
assert.equal(player(g, f.fremen).ally, f.ally);
|
||||
assert.equal(g.revivalRules?.fremenGrantBlocked?.includes(f.ally) ?? false, false);
|
||||
assert.equal(viewGame(g, f.fremen).fremenRevivalGrant?.blocked, null);
|
||||
g = pass(act(g, f.fremen, f.grant));
|
||||
assert.equal(g.freeRevival.filter(id => id === f.ally).length, 1);
|
||||
assert.deepEqual(inventory(g), f.originalInventory);
|
||||
});
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} later Recruits grant preserves earned paid/free history and increases the current allowance`, () => {
|
||||
const f = fixture({ advanced, recruits: true, priorRevival: true });
|
||||
assert.ok(f.recruitsPlay);
|
||||
let g = act(f.game, f.fremen, f.recruitsPlay);
|
||||
const spice = player(g, f.ally).spice;
|
||||
assert.equal(viewGame(g, f.ally).revival.freeRemaining, 2);
|
||||
assert.equal(viewGame(g, f.fremen).fremenRevivalGrant?.blocked, null);
|
||||
g = pass(act(g, f.fremen, f.grant));
|
||||
assert.equal(viewGame(g, f.ally).revival.freeRemaining, 4);
|
||||
assert.equal(player(g, f.ally).spice, spice);
|
||||
assert.equal(player(g, f.ally).revived, 3);
|
||||
assert.equal(player(g, f.ally).freeForcesRevived, 2);
|
||||
g = advance(act(g, f.ally, { type: 'revive', amount: 3, elite: 0 }), clean);
|
||||
assert.equal(player(g, f.ally).spice, spice);
|
||||
assert.equal(player(g, f.ally).revived, 6);
|
||||
assert.equal(player(g, f.ally).freeForcesRevived, 5);
|
||||
assert.equal(player(g, f.ally).tanks, 0);
|
||||
assert.equal(g.discard.filter(c => c.id === f.recruits).length, 1);
|
||||
assert.deepEqual(inventory(g), f.originalInventory);
|
||||
});
|
||||
void test(`${advanced ? 'Advanced' : 'Basic'} prevention after Recruits leaves intrinsic doubled free returns usable`, () => {
|
||||
const f = fixture({ advanced, recruits: true });
|
||||
assert.ok(f.recruitsPlay);
|
||||
let g = act(f.game, f.fremen, f.recruitsPlay);
|
||||
g = act(g, f.fremen, f.grant);
|
||||
g = act(g, f.observer, { type: 'card', card: f.karama, mode: 'cancel' });
|
||||
assert.ok(!g.response);
|
||||
assert.equal(viewGame(g, f.ally).revival.freeRemaining, 4);
|
||||
const spice = player(g, f.ally).spice;
|
||||
g = advance(act(g, f.ally, { type: 'revive', amount: 4, elite: 0 }), clean);
|
||||
assert.equal(player(g, f.ally).spice, spice);
|
||||
assert.equal(player(g, f.ally).freeForcesRevived, 4);
|
||||
assert.equal(g.freeRevival.includes(f.ally), false);
|
||||
assert.deepEqual(inventory(g), f.originalInventory);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createGame, newPlayer, type Game } from '../game/engine';
|
||||
import { newRevivalRules } from '../game/revival';
|
||||
import {
|
||||
FremenRevivalGrantError,
|
||||
fremenRevivalGrantBlock,
|
||||
projectFremenRevivalGrant,
|
||||
quoteFremenRevivalGrant,
|
||||
validateFremenRevivalGrant,
|
||||
type FremenRevivalGrantSource,
|
||||
} from '../game/fremen-revival-grant';
|
||||
|
||||
function fixture(recruits = false): Game {
|
||||
const g = createGame('FREMENGRANTQUOTE', newPlayer('f', 'Fremen', 'fremen'));
|
||||
g.players.push(
|
||||
newPlayer('a', 'Ally', 'atreides'),
|
||||
newPlayer('e', 'Emperor', 'emperor'),
|
||||
);
|
||||
Object.assign(g, { status: 'playing', phase: 4, turn: 3 });
|
||||
g.players[0].ally = 'a';
|
||||
g.players[1].ally = 'f';
|
||||
g.players[1].revived = 0;
|
||||
g.freeRevival = [];
|
||||
g.revivalRules = newRevivalRules();
|
||||
if (recruits) g.recruits = { turn: g.turn, player: 'e', card: 'ecaz-recruits' };
|
||||
return g;
|
||||
}
|
||||
|
||||
function rejects(g: Game, owner = 'f') {
|
||||
const before = structuredClone(g);
|
||||
assert.equal(typeof fremenRevivalGrantBlock(g, owner), 'string');
|
||||
assert.throws(() => quoteFremenRevivalGrant(g, owner), FremenRevivalGrantError);
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
|
||||
void test('a current native Fremen grant quotes only its original turn and mutual ally without mutation', () => {
|
||||
const g = fixture(), before = structuredClone(g);
|
||||
assert.equal(fremenRevivalGrantBlock(g, 'f'), null);
|
||||
assert.deepEqual(quoteFremenRevivalGrant(g, 'f'), { turn: 3, owner: 'f', recipient: 'a' });
|
||||
validateFremenRevivalGrant(g, quoteFremenRevivalGrant(g, 'f'));
|
||||
assert.deepEqual(projectFremenRevivalGrant(g, 'f'), { recipient: 'a', granted: false, blocked: null });
|
||||
assert.deepEqual(g, before);
|
||||
});
|
||||
|
||||
void test('quote validation remains usable within the grant’s own response without a busy gate', () => {
|
||||
const g = fixture(), source = quoteFremenRevivalGrant(g, 'f');
|
||||
// The focused quote needs no response fields; the engine owns live priority.
|
||||
g.response = { kind: 'fremenRevivalGrant', owner: 'f', recipient: 'a', fremenGrant: source, passed: [] };
|
||||
const before = structuredClone(g);
|
||||
validateFremenRevivalGrant(g, source);
|
||||
assert.equal(fremenRevivalGrantBlock(g, 'f'), null);
|
||||
assert.deepEqual(g, before);
|
||||
});
|
||||
|
||||
void test('already committed and canceled grants cannot be repeated for the same recipient', () => {
|
||||
for (const canceled of [false, true]) {
|
||||
const g = fixture(), source = quoteFremenRevivalGrant(g, 'f');
|
||||
if (canceled) g.revivalRules!.fremenGrantBlocked = ['a'];
|
||||
else g.freeRevival.push('a');
|
||||
rejects(g);
|
||||
assert.throws(() => validateFremenRevivalGrant(g, source), FremenRevivalGrantError);
|
||||
const projected = projectFremenRevivalGrant(g, 'f')!;
|
||||
assert.equal(projected.granted, !canceled);
|
||||
assert.match(projected.blocked!, canceled ? /Karama prevented/ : /already been granted/);
|
||||
}
|
||||
const other = fixture();
|
||||
other.revivalRules!.fremenGrantBlocked = ['e'];
|
||||
assert.equal(fremenRevivalGrantBlock(other, 'f'), null);
|
||||
});
|
||||
|
||||
void test('grant cancellation never changes intrinsic prevention, rates, payments or recipient resources', () => {
|
||||
const g = fixture(true);
|
||||
g.revivalRules!.fremenGrantBlocked = ['a'];
|
||||
g.revivalRules!.freeBlocked = ['e'];
|
||||
g.players[1].revived = 3;
|
||||
g.players[1].freeForcesRevived = 2;
|
||||
const before = structuredClone(g);
|
||||
rejects(g);
|
||||
projectFremenRevivalGrant(g, 'f');
|
||||
assert.deepEqual(g, before);
|
||||
assert.deepEqual(g.revivalRules!.freeBlocked, ['e']);
|
||||
});
|
||||
|
||||
void test('a grant needs playing Revival, a valid turn and the unique native Fremen owner', () => {
|
||||
for (const change of [
|
||||
(g: Game) => { g.status = 'lobby'; },
|
||||
(g: Game) => { g.phase = 5; },
|
||||
(g: Game) => { g.turn = 0; },
|
||||
(g: Game) => { g.turn = 1.5; },
|
||||
(g: Game) => { g.players[0].faction = 'guild'; },
|
||||
(g: Game) => { g.players[2].faction = 'fremen'; },
|
||||
(g: Game) => { g.players.push(structuredClone(g.players[0])); },
|
||||
]) {
|
||||
const g = fixture(); change(g); rejects(g);
|
||||
}
|
||||
rejects(fixture(), 'a');
|
||||
rejects(fixture(), 'missing');
|
||||
});
|
||||
|
||||
void test('a missing, self, duplicate or nonreciprocal ally cannot receive the grant', () => {
|
||||
for (const change of [
|
||||
(g: Game) => { g.players[0].ally = null; },
|
||||
(g: Game) => { g.players[0].ally = 'missing'; },
|
||||
(g: Game) => { g.players[0].ally = 'f'; },
|
||||
(g: Game) => { g.players[1].ally = null; },
|
||||
(g: Game) => { g.players[1].ally = 'e'; },
|
||||
(g: Game) => { g.players.push(structuredClone(g.players[1])); },
|
||||
]) {
|
||||
const g = fixture(); change(g); rejects(g);
|
||||
}
|
||||
});
|
||||
|
||||
void test('saved source validation rejects a changed turn, original ally or client-retargeted recipient', () => {
|
||||
const original = fixture(), source = quoteFremenRevivalGrant(original, 'f');
|
||||
for (const change of [
|
||||
(g: Game) => { g.turn++; },
|
||||
(g: Game) => { g.players[0].ally = 'e'; g.players[2].ally = 'f'; },
|
||||
(g: Game) => { g.players[1].ally = null; },
|
||||
]) {
|
||||
const g = structuredClone(original); change(g);
|
||||
const before = structuredClone(g);
|
||||
assert.throws(() => validateFremenRevivalGrant(g, source), FremenRevivalGrantError);
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
assert.throws(() => validateFremenRevivalGrant(original, { ...source, recipient: 'e' }), FremenRevivalGrantError);
|
||||
});
|
||||
|
||||
void test('malformed saved sources reject with the same focused error before mutation', () => {
|
||||
const g = fixture(), source = quoteFremenRevivalGrant(g, 'f'), before = structuredClone(g);
|
||||
for (const malformed of [
|
||||
null, [], {}, { ...source, turn: -1 }, { ...source, owner: '' },
|
||||
{ ...source, recipient: '' }, { ...source, owner: 'a' },
|
||||
{ ...source, amount: 6 }, { owner: 'f', recipient: 'a' },
|
||||
]) assert.throws(() => validateFremenRevivalGrant(g, malformed as FremenRevivalGrantSource), FremenRevivalGrantError);
|
||||
assert.deepEqual(g, before);
|
||||
});
|
||||
|
||||
void test('active Recruits accepts a later grant with actual prior free usage and retains all prior accounting', () => {
|
||||
for (const [revived, free] of [[0, undefined], [0, 0], [3, 2], [4, 4]]) {
|
||||
const g = fixture(true);
|
||||
g.players[1].revived = revived!;
|
||||
g.players[1].freeForcesRevived = free;
|
||||
const before = structuredClone(g);
|
||||
assert.equal(fremenRevivalGrantBlock(g, 'f'), null);
|
||||
assert.deepEqual(quoteFremenRevivalGrant(g, 'f'), { turn: 3, owner: 'f', recipient: 'a' });
|
||||
assert.deepEqual(g, before);
|
||||
}
|
||||
});
|
||||
|
||||
void test('active Recruits rejects missing prior free history and malformed beneficiary ledgers', () => {
|
||||
const usage: [number, number | undefined][] = [
|
||||
[1, undefined], [-1, 0], [0.5, 0], [NaN, 0], [Infinity, 0],
|
||||
[2, -1], [2, 0.5], [2, NaN], [2, Infinity], [2, 3],
|
||||
];
|
||||
for (const [revived, free] of usage) {
|
||||
const g = fixture(true);
|
||||
g.players[1].revived = revived;
|
||||
g.players[1].freeForcesRevived = free;
|
||||
rejects(g);
|
||||
const projected = projectFremenRevivalGrant(g, 'f')!;
|
||||
assert.match(projected.blocked!, /usage is inconsistent or unknown/);
|
||||
assert.deepEqual(Object.keys(projected).sort(), ['blocked', 'granted', 'recipient']);
|
||||
}
|
||||
});
|
||||
|
||||
void test('inactive and expired Recruits retain the ordinary grant’s existing legacy usage boundary', () => {
|
||||
for (const expired of [false, true]) {
|
||||
const g = fixture(expired);
|
||||
if (g.recruits) g.recruits.turn--;
|
||||
g.players[1].revived = 2;
|
||||
delete g.players[1].freeForcesRevived;
|
||||
assert.equal(fremenRevivalGrantBlock(g, 'f'), null);
|
||||
}
|
||||
});
|
||||
|
||||
void test('projection is owner-only and reads no recipient wallet, Tanks or hand', () => {
|
||||
const g = fixture(true);
|
||||
for (const key of ['spice', 'tanks', 'hand'] as const)
|
||||
Object.defineProperty(g.players[1], key, {
|
||||
get() { throw new Error(`Grant projection read private ${key}`); },
|
||||
});
|
||||
assert.deepEqual(projectFremenRevivalGrant(g, 'f'), { recipient: 'a', granted: false, blocked: null });
|
||||
for (const viewer of ['a', 'e', 'missing', '']) assert.equal(projectFremenRevivalGrant(g, viewer), null);
|
||||
g.players[0].ally = null;
|
||||
assert.equal(projectFremenRevivalGrant(g, 'f'), null);
|
||||
});
|
||||
@@ -149,7 +149,7 @@ for (const level of ['Easy', 'Medium', 'Hard', 'Brutal'] as const) {
|
||||
});
|
||||
}
|
||||
|
||||
void test('all AI profiles skip the unresolved late Fremen grant while using other legal revival choices', () => {
|
||||
void test('all AI profiles retain the legal late Fremen grant after Recruits', () => {
|
||||
let g = ownerGame();
|
||||
g.players[0].ally = 'fr'; g.players[1].ally = 'at';
|
||||
g = applyAction(g, 'at', { type: 'card', card: 'ecaz-recruits' });
|
||||
@@ -158,7 +158,9 @@ void test('all AI profiles skip the unresolved late Fremen grant while using oth
|
||||
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'));
|
||||
const grant = actions.find(action => action.type === 'grantRevival');
|
||||
assert.ok(grant);
|
||||
assert.doesNotThrow(() => applyAction(g, 'fr', grant));
|
||||
assert.doesNotThrow(() => applyAction(g, 'fr', actions[0]));
|
||||
}
|
||||
});
|
||||
@@ -88,7 +88,7 @@ void test('playing Recruits discards its exact card and publishes effective rate
|
||||
);
|
||||
assert.equal(preview.active, true);
|
||||
assert.equal(preview.playedBy, 'ch');
|
||||
assert.equal(preview.grantRevivalBlocked?.includes('before Recruits'), true);
|
||||
assert.equal(preview.grantRevivalBlocked, null);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -149,13 +149,17 @@ void test('free prevention remains zero and canceled unlimited faction caps comp
|
||||
assert.equal(rows.ch.limit, 7);
|
||||
});
|
||||
|
||||
void test('prior settled Fremen ally grant is doubled, while a grant after activation rejects immutably', () => {
|
||||
void test('Fremen ally grant uses the same current doubled rate before or after Recruits', () => {
|
||||
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' });
|
||||
while (g.response) {
|
||||
const actor = g.players.find(p => !g.response!.passed.includes(p.id))!;
|
||||
g = applyAction(g, actor.id, { type: 'passResponse' });
|
||||
}
|
||||
g = applyAction(g, 'ch', play);
|
||||
assert.deepEqual(
|
||||
viewGame(g, 'at').recruitsPreview!.rates.find((row) => row.player === 'at'),
|
||||
@@ -165,8 +169,14 @@ void test('prior settled Fremen ally grant is doubled, while a grant after activ
|
||||
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' });
|
||||
let active = applyAction(late, 'ch', play);
|
||||
active = applyAction(active, 'fr', { type: 'grantRevival' });
|
||||
while (active.response) {
|
||||
const actor = active.players.find(p => !active.response!.passed.includes(p.id))!;
|
||||
active = applyAction(active, actor.id, { type: 'passResponse' });
|
||||
}
|
||||
assert.deepEqual(viewGame(active, 'at').recruitsPreview!.rates.find(row => row.player === 'at'),
|
||||
{ player: 'at', freeRate: 6, limit: 7 });
|
||||
});
|
||||
|
||||
void test('activation rejects pending, unknown and inconsistent revival histories without consuming the card', () => {
|
||||
|
||||
Reference in new issue
Block a user