From d8d2e7207bb6fe260b3bda90c3244f55aec61c2c Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:37:46 +1100 Subject: [PATCH] Support staged group Fremen revival grants --- components/fremen-revival-grant.tsx | 22 ++++ docs/CURRENT_STATUS.md | 16 ++- docs/FREMEN_REVIVAL_GRANT.md | 13 ++ docs/RULE_DECISIONS.md | 6 + game/bots.ts | 3 + game/engine.ts | 8 +- game/fremen-revival-grant.ts | 53 ++++++-- game/reference.ts | 10 +- tests/coalition-fremen-revival.test.ts | 161 +++++++++++++++++++++++++ 9 files changed, 277 insertions(+), 15 deletions(-) create mode 100644 tests/coalition-fremen-revival.test.ts diff --git a/components/fremen-revival-grant.tsx b/components/fremen-revival-grant.tsx index 2dfe36f3..dcf8c297 100644 --- a/components/fremen-revival-grant.tsx +++ b/components/fremen-revival-grant.tsx @@ -15,6 +15,28 @@ export function FremenRevivalGrant({ }) { const grant = game.fremenRevivalGrant; const me = game.players.find((player) => player.id === game.me); + const group = game.fremenGroupRevivalGrant; + if (group && game.status === 'playing' && game.phase === 4 && me?.faction === 'fremen') { + const busyWindow = busy || !!game.truthtrance || !!game.nexusTraitors?.pending; + return ( +
+

Fremen group revival

+

Choose an ally to receive the ordinary Fremen free-revival rate of three for this phase. This replaces their native rate; normal revival limits still apply.

+

The grant to each ally is a provisional interpretation of the larger-alliance rule. Each declaration opens a Karama response.

+ {group.map(option => { + const name = game.players.find(player => player.id === option.recipient)?.name ?? 'Ally'; + return
+ {option.granted &&

Free revival has been granted to {name}.

} + {option.blocked && !option.granted &&

{name}: {option.blocked}

} + +
; + })} +
+ ); + } 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'; diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index 40679c89..95c1b1ca 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,15 @@ open mode gates. ## Current checkpoint and work +**Staged group Fremen revival aid — 28 October. Prototyped / Partial.** Fremen +can select each ally for a free-revival grant in the unexposed Basic classic +group path. Each declaration retains its recipient through the Karama response; +per-ally grants are provisional. Controls and minimal legal bot actions are +connected. Seven related files pass 57 cases, including actual Nexus and +revival actions after a staged setup and server-rendered controls. Other group +benefits and lobby access remain unfinished. +[Scope](FREMEN_REVIVAL_GRANT.md#staged-three-member-alliance--28-october-2026). + **Staged group Emperor aid — 28 October. Prototyped / Partial.** An Emperor can choose either group ally for a spice gift or paid extra revival; the selected recipient survives the Karama response. Separate per-ally revival @@ -288,7 +297,12 @@ Run 9 was canceled before starting; run 10 for `1ba9421` succeeded and published digest `sha256:2c403b71b24de97188804c874f621af5b72bc6dd807bbcdcca9cbe7ab420029c`. Run 11 for `4d769af` succeeded and published digest `sha256:766e3b94f9268fc5a089a1a47c2cf031a5c6a25bd789c51f7e3e81f4eeaa528e`. -Run 12 was canceled before starting; run 13 for `50c6201` is running. +Run 12 was canceled before starting; run 21 for `1eeeb32` passed 25 +administrator and 69 HTTP checks, origin/restart/replacement verification, +and published both revision and checkpoint tags with digest +`sha256:fbfc252a46f2ee900e4640e1ef8ad29c0605088267b992f42fe2fa5519dc91db`. +Run 22 was canceled before starting; run 23 for `9081249` was running at the +last check. Deployment remains unverified. Existing NAS deployment and its persistent storage remain untouched. diff --git a/docs/FREMEN_REVIVAL_GRANT.md b/docs/FREMEN_REVIVAL_GRANT.md index ce38c335..7ff0c6e0 100644 --- a/docs/FREMEN_REVIVAL_GRANT.md +++ b/docs/FREMEN_REVIVAL_GRANT.md @@ -1,5 +1,18 @@ # Fremen allied free revival +## Staged three-member alliance — 28 October 2026 + +In the unexposed Basic classic group prototype, Fremen may select each of its +two allies for a separate grant. Each declaration binds its recipient through +the original Karama response; prevention blocks that recipient for the phase +without removing the other ally's opportunity. The printed rule describes a +single ally and does not specify the three-member case, so applying the rate to +each member is provisional. The original force-revival limits, costs and +physical returns remain authoritative. This prototype excludes Advanced, +expansion factions and optional modules, and does not enable group games in +the lobby. The table exposes separate named actions; minimal bots use the same +owner-only projection. Other group faction abilities remain unfinished. + ## Source and selected timing — 24 October 2026 The base publisher rulebook physical p16 gives the Fremen ally three free diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index 208d3cc8..b37ce69e 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,11 @@ # Rule decision index +## Group Fremen revival aid — 28 October 2026 + +The staged Basic group prototype provisionally lets Fremen grant its ordinary +free-revival rate separately to each ally. The printed rule describes one ally +and does not resolve larger groups. [Scope](FREMEN_REVIVAL_GRANT.md#staged-three-member-alliance--28-october-2026). + ## Group Emperor aid — 28 October 2026 The staged Basic group prototype provisionally gives each ally a separate diff --git a/game/bots.ts b/game/bots.ts index bfb29ec9..3793e990 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -3426,6 +3426,9 @@ function policyActions(g: GameView): Action[] { } if (g.fremenRevivalGrant && !g.fremenRevivalGrant.granted && !g.fremenRevivalGrant.blocked) actions.push({ type: 'grantRevival' }); + for (const grant of g.fremenGroupRevivalGrant ?? []) + if (!grant.granted && !grant.blocked) + actions.push({type:'grantRevival', target:grant.recipient}); const emperorGroup = g.coalitionEmperor; const emperorAllies = me.ally ? [me.ally] : emperorGroup?.allies ?? []; if (me.faction === 'emperor' && level > 0) for (const allyId of emperorAllies) { diff --git a/game/engine.ts b/game/engine.ts index a554e21b..c9f9d809 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -656,7 +656,7 @@ import { quoteEcazPoisonIncome, EcazPoisonIncomeError, type EcazPoisonDiscard } import { resolveBattleWeapons } from './effective-weapons'; import { choamGholaEvent, choamGholaSkillOfferEvent, choamGholaSkillSignature, choamMarketGholaError, choamSaleGholaTiming, type ChoamMarketGhola } from './choam-market-ghola'; import { quoteAdvisorPreparationWindow, quoteAdvisorPreparation, validateAdvisorPreparation, projectAdvisorPreparationWindow, AdvisorPreparationError, type AdvisorPreparationWindow, type AdvisorPreparationSource } from './advisor-preparation-window'; -import { quoteFremenRevivalGrant, validateFremenRevivalGrant, projectFremenRevivalGrant, FremenRevivalGrantError, type FremenRevivalGrantSource } from './fremen-revival-grant'; +import { quoteFremenRevivalGrant, validateFremenRevivalGrant, projectFremenRevivalGrant, projectFremenGroupRevivalGrant, FremenRevivalGrantError, type FremenRevivalGrantSource } from './fremen-revival-grant'; import { splitOrdinaryPayments, type OrdinaryPayment } from './ordinary-payment-plan'; import { charityAmount, @@ -44722,10 +44722,11 @@ function applyActionInner( return g; } if (t === 'grantRevival') { - requireRule(g.phase === 4 && p.faction === 'fremen' && p.ally, + requireRule(g.phase === 4 && p.faction === 'fremen', '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)); + const target = projectFremenGroupRevivalGrant(g, id) ? stringField(action.target) : undefined; + const source = fremenRevivalGrantRule(() => quoteFremenRevivalGrant(g, id, target)); g.response = { kind: 'fremenRevivalGrant', owner: source.owner, recipient: source.recipient, fremenGrant: source, passed: [] }; return g; @@ -46979,6 +46980,7 @@ export function viewGame(state: Game, id: string, now?: number) { freeRevival: g.freeRevival ?? [], recruitsPreview: recruitsPreview(g, me), fremenRevivalGrant: projectFremenRevivalGrant(g, id), + fremenGroupRevivalGrant: projectFremenGroupRevivalGrant(g, id), advisorPreparation: advisorPreparationView(g, id), revivalRules: { ...(g.revivalRules ?? newRevivalRules()), diff --git a/game/fremen-revival-grant.ts b/game/fremen-revival-grant.ts index cf26c170..d506ac7f 100644 --- a/game/fremen-revival-grant.ts +++ b/game/fremen-revival-grant.ts @@ -1,4 +1,5 @@ import type { Game } from './engine'; +import { coalitionOf, CoalitionAllianceError } from './coalition-alliance'; export type FremenRevivalGrantSource = { turn: number; @@ -9,7 +10,8 @@ export type FremenRevivalGrantSource = { export type FremenRevivalGrantContext = Pick< Game, 'status' | 'phase' | 'turn' | 'players' | 'freeRevival' | 'revivalRules' | 'recruits' ->; +> & Partial>; export class FremenRevivalGrantError extends Error { constructor(message: string) { @@ -23,12 +25,30 @@ const id = (value: unknown): value is string => const count = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) >= 0; +/** Match the unexposed Basic classic group profile; other profiles retain the + * existing mutual-pair path until their interactions are connected. */ +function groupRecipients(g: FremenRevivalGrantContext, owner: string): string[] | null { + if (!g.coalitionState || g.status !== 'playing' || g.advanced !== false || + !Array.isArray(g.expansions) || g.expansions.length || g.homeworlds || g.nexusCards || + g.leaderSkills || g.techTokens || g.strongholdCards || g.ecazTreachery || g.discoveryEnabled || + !g.players.every(player => ['atreides', 'beneGesserit', 'emperor', 'fremen', 'guild', 'harkonnen'].includes(player.faction))) + return null; + try { + const group = coalitionOf(g.players, g.coalitionState, owner); + return group?.members.length === 3 ? group.members.filter(member => member !== owner) : null; + } catch (error) { + if (error instanceof CoalitionAllianceError) throw new FremenRevivalGrantError(error.message); + throw error; + } +} + /** 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, + selectedRecipient?: 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.'; @@ -39,10 +59,13 @@ export function fremenRevivalGrantBlock( 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 group = groupRecipients(g, owner); + const recipientId = group ? selectedRecipient : fremen.ally; + const recipients = g.players.filter(player => player.id === recipientId); const recipient = recipients[0]; - if (!id(fremen.ally) || recipients.length !== 1 || - recipient.id === owner || recipient.ally !== owner) + if (!id(recipientId) || recipients.length !== 1 || recipient.id === owner || + (group ? !group.includes(recipientId) : recipient.ally !== owner || + (selectedRecipient !== undefined && selectedRecipient !== fremen.ally))) return 'The grant needs the Fremen owner’s current mutual ally.'; if (!Array.isArray(g.freeRevival)) return 'The current Fremen revival permissions are invalid.'; @@ -67,11 +90,12 @@ export function fremenRevivalGrantBlock( export function quoteFremenRevivalGrant( g: FremenRevivalGrantContext, owner: string, + recipient?: string, ): FremenRevivalGrantSource { - const blocked = fremenRevivalGrantBlock(g, owner); + const blocked = fremenRevivalGrantBlock(g, owner, recipient); if (blocked) throw new FremenRevivalGrantError(blocked); const fremen = g.players.find(player => player.id === owner)!; - return { turn: g.turn, owner, recipient: fremen.ally! }; + return { turn: g.turn, owner, recipient: groupRecipients(g, owner) ? recipient! : fremen.ally! }; } /** Bind the original relationship and turn before either response outcome. @@ -85,11 +109,26 @@ export function validateFremenRevivalGrant( !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); + const current = quoteFremenRevivalGrant(g, source.owner, source.recipient); if (current.recipient !== source.recipient) throw new FremenRevivalGrantError('The Fremen allied revival grant no longer matches its original ally.'); } +/** Separate owner-only projection avoids changing the established pair API. */ +export function projectFremenGroupRevivalGrant( + g: FremenRevivalGrantContext, + viewer: string, +): Array<{recipient: string; granted: boolean; blocked: string | null}> | null { + if (!id(viewer) || !Array.isArray(g.players) || + g.players.filter(player => player.id === viewer && player.faction === 'fremen').length !== 1) + return null; + const recipients = groupRecipients(g, viewer); + if (!recipients) return null; + return recipients.map(recipient => ({recipient, + granted: Array.isArray(g.freeRevival) && g.freeRevival.includes(recipient), + blocked: fremenRevivalGrantBlock(g, viewer, recipient)})); +} + /** 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( diff --git a/game/reference.ts b/game/reference.ts index f11b2133..412caad0 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -6538,7 +6538,8 @@ export const RULE_TOPICS: RuleTopic[] = [ "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." + "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.", + "In an unexposed staged Basic three-member group, Fremen can provisionally grant each ally the rate separately; each named declaration has its own Karama response. Group games remain unavailable in the lobby." ], "related": [ "revival", @@ -6550,7 +6551,7 @@ export const RULE_TOPICS: RuleTopic[] = [ { "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.", + "detail": "Original owner/ally/turn source validates before cancellation cost and both outcomes. Staged Basic groups bind an explicit recipient. Only all-pass commits; cancellation marks a recipient-specific phase denial. Existing native arithmetic retains limits, prices, bonuses and prior usage.", "evidence": [ "game/fremen-revival-grant.ts", "game/engine.ts", @@ -6562,7 +6563,7 @@ export const RULE_TOPICS: RuleTopic[] = [ { "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.", + "detail": "Actual pair and staged group grant callbacks, 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", @@ -6572,7 +6573,7 @@ export const RULE_TOPICS: RuleTopic[] = [ { "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.", + "detail": "Existing-profile native/Recruits paths and staged group grants use the same projected legal permissions. Strategy and strength calibration remain deferred.", "evidence": [ "game/bots.ts", "tests/recruits-controls.test.tsx", @@ -6594,6 +6595,7 @@ export const RULE_TOPICS: RuleTopic[] = [ "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/coalition-fremen-revival.test.ts", "tests/fixture-fremen-revival-grant.ts", "tests/fremen-revival-grant-engine.test.ts", "tests/fremen-revival-grant-quote.test.ts", diff --git a/tests/coalition-fremen-revival.test.ts b/tests/coalition-fremen-revival.test.ts new file mode 100644 index 00000000..8076ca20 --- /dev/null +++ b/tests/coalition-fremen-revival.test.ts @@ -0,0 +1,161 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { FremenRevivalGrant } from '../components/fremen-revival-grant'; +import { baseDeck } from '../game/cards'; +import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance'; +import { botActions } from '../game/bots'; +import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; + +const send = (game: Game, id: string, action: Action) => applyAction(game, id, action); +const player = (game: Game, id: string) => game.players.find(seat => seat.id === id)!; + +/** The game is staged at an actual Nexus because three-player alliances are + * not yet selectable in the lobby. Pair formation and admission use the real + * actions; tanks are then moved from reserves without creating forces. */ +function fixture(group = true) { + let game = createGame('GROUPFREMEN', newPlayer('f', 'Fremen', 'fremen'), false); + game.players.push(newPlayer('a', 'Atreides', 'atreides'), + newPlayer('h', 'Harkonnen', 'harkonnen'), newPlayer('g', 'Guild', 'guild')); + Object.assign(game, {status:'playing', phase:1, turn:2, nexus:true, order:['f','a','h','g'], + ready:[], storm:18, deck:baseDeck(), response:null, decision:null, phaseOpening:null}); + game.coalitionState = emptyCoalitionState(); + game = send(game, 'f', {type:'alliance', target:'a'}); + game = send(game, 'a', {type:'alliance', target:'f'}); + if (group) { + const pair = coalitionOf(game.players, game.coalitionState, 'f'); assert.ok(pair); + game = send(game, 'f', {type:'coalitionPropose', groupId:pair.id, entrant:'h', + expectedRevision:game.coalitionState!.revision}); + for (const id of ['a','h']) { + const proposal = game.coalitionState!.proposal!; + game = send(game, id, {type:'coalitionAnswer', event:proposal.event, nexus:proposal.nexus, + turn:proposal.turn, accept:true, expectedRevision:game.coalitionState!.revision}); + } + assert.deepEqual(coalitionOf(game.players, game.coalitionState, 'f')!.members, ['a','f','h']); + } + Object.assign(game, {phase:4, nexus:false, ready:[], response:null, decision:null}); + for (const seat of game.players) { + seat.forces = {}; seat.reserves = 20; seat.tanks = 0; seat.spice = 10; + seat.hand = []; seat.traitors = []; seat.traitorChoices = []; + } + for (const id of ['a','h']) { + player(game, id).reserves = 16; + player(game, id).tanks = 4; + } + const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0); + const [karama] = game.deck.splice(index, 1); + player(game, 'g').hand.push(karama); + return {game, karama}; +} +function passResponses(game: Game): Game { + for (let count = 0; game.response && count < 24; count++) { + const next = game.players.find(seat => !game.response!.passed.includes(seat.id)); + assert.ok(next, 'Each response has a remaining eligible player.'); + game = send(game, next.id, {type:'passResponse'}); + } + assert.equal(game.response ?? null, null); + return game; +} +function reject(game: Game, id: string, action: Action) { + const before = structuredClone(game); + assert.throws(() => send(game, id, action)); + assert.deepEqual(game, before); +} + +void test('Fremen grants each selected group ally three free force revivals', () => { + let {game} = fixture(); + for (const ally of ['a','h']) { + const originalSpice = player(game, ally).spice; + const pending = send(game, 'f', {type:'grantRevival', target:ally}); + assert.equal(pending.response?.kind, 'fremenRevivalGrant'); + assert.equal(pending.response?.recipient, ally); + assert.deepEqual(pending.response?.fremenGrant, {turn:2, owner:'f', recipient:ally}); + assert.equal(pending.freeRevival.includes(ally), false); + game = passResponses(pending); + assert.equal(game.freeRevival.filter(id => id === ally).length, 1); + assert.equal(viewGame(game, ally).revival.freeRemaining, 3); + game = passResponses(send(game, ally, {type:'revive', amount:3, elite:0})); + assert.equal(player(game, ally).spice, originalSpice); + assert.equal(player(game, ally).tanks, 1); + assert.equal(player(game, ally).reserves, 19); + assert.equal(player(game, ally).freeForcesRevived, 3); + reject(game, 'f', {type:'grantRevival', target:ally}); + } +}); + +void test('group grant rejects missing, non-allied and self targets as well as wrong actors and phases', () => { + const {game} = fixture(); + reject(game, 'f', {type:'grantRevival'}); + for (const target of ['f','g','missing']) reject(game, 'f', {type:'grantRevival', target}); + reject(game, 'a', {type:'grantRevival', target:'h'}); + game.phase = 3; + reject(game, 'f', {type:'grantRevival', target:'a'}); +}); + +void test('group grant rejects unsupported Advanced play and cannot retarget a pending source', () => { + const {game} = fixture(); + const advanced = structuredClone(game); + advanced.advanced = true; + assert.equal(viewGame(advanced, 'f').fremenGroupRevivalGrant, null); + reject(advanced, 'f', {type:'grantRevival', target:'a'}); + const pending = send(game, 'f', {type:'grantRevival', target:'a'}); + pending.response!.recipient = 'h'; + reject(pending, 'g', {type:'passResponse'}); +}); + +void test('Karama prevents only the selected group grant and leaves the other ally eligible', () => { + const initial = fixture(); + let {game} = initial; + const {karama} = initial; + game = send(game, 'f', {type:'grantRevival', target:'a'}); + game = passResponses(send(game, 'g', {type:'card', mode:'cancel', card:karama.id})); + assert.equal(game.freeRevival.includes('a'), false); + assert.deepEqual(game.revivalRules?.fremenGrantBlocked, ['a']); + assert.equal(game.discard.filter(card => card.id === karama.id).length, 1); + reject(game, 'f', {type:'grantRevival', target:'a'}); + game = passResponses(send(game, 'f', {type:'grantRevival', target:'h'})); + assert.equal(game.freeRevival.includes('h'), true); + assert.equal(game.freeRevival.includes('a'), false); + assert.equal(viewGame(game, 'a').revival.freeRemaining, 2); + assert.equal(viewGame(game, 'h').revival.freeRemaining, 3); +}); + +void test('pair Fremen grant retains its implicit target and native Karama response', () => { + let {game} = fixture(false); + const pending = send(game, 'f', {type:'grantRevival'}); + assert.equal(pending.response?.recipient, 'a'); + game = passResponses(pending); + assert.equal(game.freeRevival.includes('a'), true); + assert.equal(viewGame(game, 'a').revival.freeRemaining, 3); + reject(game, 'f', {type:'grantRevival'}); +}); + +void test('a Medium Fremen bot offers a legal targeted group grant', () => { + const {game} = fixture(); + const view = viewGame(game, 'f'); + view.players.find(seat => seat.id === 'f')!.bot = 'Medium'; + const grant = botActions(view).find(action => action.type === 'grantRevival'); + assert.ok(grant); + assert.ok(grant.target === 'a' || grant.target === 'h'); + const pending = send(game, 'f', grant); + assert.equal(pending.response?.recipient, grant.target); + assert.equal(passResponses(pending).freeRevival.includes(grant.target as string), true); +}); + +void test('the actual group view renders both named grant controls only to Fremen', () => { + const {game} = fixture(); + const own = viewGame(game, 'f'); + assert.deepEqual(own.fremenGroupRevivalGrant?.map(entry => entry.recipient).sort(), ['a','h']); + const ownMarkup = renderToStaticMarkup(createElement(FremenRevivalGrant, { + game:own, busy:false, act() {}, + })); + assert.match(ownMarkup, /Grant Atreides free revival/); + assert.match(ownMarkup, /Grant Harkonnen free revival/); + assert.doesNotMatch(ownMarkup, /disabled=""/); + const observer = viewGame(game, 'g'); + assert.equal(observer.fremenGroupRevivalGrant, null); + assert.equal(renderToStaticMarkup(createElement(FremenRevivalGrant, { + game:observer, busy:false, act() {}, + })), ''); +});