From 220d78c7796414d5506da0eddb5e0a365aa683df Mon Sep 17 00:00:00 2001 From: Quintine <15965533+Quintine@users.noreply.github.com> Date: Thu, 29 Oct 2026 09:19:17 +1100 Subject: [PATCH] Coordinate Fremen grants in ordered revival --- components/fremen-revival-grant.tsx | 27 +++ docs/CURRENT_STATUS.md | 13 ++ docs/FREMEN_REVIVAL_GRANT.md | 20 +++ docs/RULE_DECISIONS.md | 9 + game/bots.ts | 15 +- game/engine.ts | 20 +++ game/fremen-revival-grant.ts | 13 +- game/reference.ts | 9 +- ...remen-ordered-revival-bot-priority.test.ts | 155 ++++++++++++++++++ .../fremen-ordered-revival-controls.test.tsx | 91 ++++++++++ tests/fremen-ordered-revival-group.test.ts | 69 ++++++++ 11 files changed, 429 insertions(+), 12 deletions(-) create mode 100644 tests/fremen-ordered-revival-bot-priority.test.ts create mode 100644 tests/fremen-ordered-revival-controls.test.tsx create mode 100644 tests/fremen-ordered-revival-group.test.ts diff --git a/components/fremen-revival-grant.tsx b/components/fremen-revival-grant.tsx index b9ea9fa3..b1315491 100644 --- a/components/fremen-revival-grant.tsx +++ b/components/fremen-revival-grant.tsx @@ -16,6 +16,9 @@ export function FremenRevivalGrant({ const grant = game.fremenRevivalGrant; const me = game.players.find((player) => player.id === game.me); const group = game.fremenGroupRevivalGrant; + const orderedAutomatedAlly = (recipient: string) => + game.advanced && !!game.revivalOrder && game.revivalOrder.remaining.includes(recipient) && + game.players.some(player => player.id === recipient && (player.bot || player.autopilot)); if (group && game.status === 'playing' && game.phase === 4 && me?.faction === 'fremen') { const busyWindow = busy || !!game.truthtrance || !!game.nexusTraitors?.pending; return ( @@ -35,6 +38,18 @@ export function FremenRevivalGrant({ onClick={() => act({type:'grantRevival', target:option.recipient})}> Grant {name} free revival + {orderedAutomatedAlly(option.recipient) && !option.granted && !option.blocked && <> +

An automated ally may wait for your grant when it would help. You can let it continue without waiting and may still grant later while Revival remains open.

+ + {option.waitReleased &&

{name} can continue without waiting for your grant.

} + } ; })} @@ -58,6 +73,18 @@ export function FremenRevivalGrant({ (automatedAlly?.bot || automatedAlly?.autopilot) && !grant.granted && !grant.blocked && (

When this grant would help your automated ally, it waits to revive forces until you grant it or select Ready for next phase.

)} + {orderedAutomatedAlly(grant.recipient) && !grant.granted && !grant.blocked && <> +

An automated ally may wait for your grant when it would help. You can let it continue without waiting and may still grant later while Revival remains open.

+ + {grant.waitReleased &&

{recipient} can continue without waiting for your grant.

} + }

Provisional current-rate policy: Recruits doubles the current granted rate, including applicable bonuses, before caps. Without other bonuses, three becomes six. diff --git a/docs/CURRENT_STATUS.md b/docs/CURRENT_STATUS.md index ac01e9c6..65c4535c 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,19 @@ open mode gates. ## Current checkpoint and work +**Advanced Fremen allied revival scheduling — 29 October. Prototyped / +Partial.** During ordered Revival, an automated current ally waits for a useful +human Fremen grant. Fremen can release each eligible ally before or during its +turn without forfeiting a later grant; ordinary Karama prevention and unrelated +actions retain their own timing. Genuine Advanced setups with staged alliances +and conserved Tanks cover both relative orders, all four bot profiles, grant, +release and prevention. A separately staged group formed through real Nexus +actions verifies independent releases; controls do not expose Tanks. Ten +related files pass 95 cases, including 18 new cases; type checking, lint and +the production build pass. The priority and release are provisional online +conventions; broader interactions and deployed acceptance remain open. +[Scope](FREMEN_REVIVAL_GRANT.md#human-fremen-and-ordered-automated-allies--29-october-2026). + **Advanced Mentat victory timing — 29 October. Prototyped / Partial.** Fresh Advanced non-CHOAM phases now check victory after players finish their Mentat opportunities, so a real Mentat action can precede the result. The diff --git a/docs/FREMEN_REVIVAL_GRANT.md b/docs/FREMEN_REVIVAL_GRANT.md index 8f88b057..9a432ae7 100644 --- a/docs/FREMEN_REVIVAL_GRANT.md +++ b/docs/FREMEN_REVIVAL_GRANT.md @@ -1,5 +1,25 @@ # Fremen allied free revival +## Human Fremen and ordered automated allies — 29 October 2026 + +**Prototyped / Partial.** In an ordered Advanced Revival, an automated ally +whose current turn would benefit from an available human Fremen grant waits +before its normal force return or Ready. Fremen can grant through the existing +Karama response or release that ally to continue without waiting. The release +may be given before the ally's turn and does not prevent a later grant while +the opportunity remains. It is recorded per recipient for this turn; a new +Revival clears it. Fremen may act before or after its own ordered opportunity. +The control exposes no allied Tanks or rate. An unavailable or prevented grant, +no useful benefit, and automated Fremen do not hold the ally. Other bot actions +and responses retain their own timing. + +The user-authorized revised rules, physical p. 32 (printed p. 30), permit +Fremen to increase an ally's free revival to three; physical p. 9 (printed +p. 7) orders force revival. Neither prescribes online bot priority or a release +action, so this is a provisional scheduling convention. The supported classic +group preview retains independent recipients. Full faction interactions, +strategic AI calibration and deployed acceptance remain open. + ## Human Fremen and automated group allies — 29 October 2026 **Prototyped / Partial.** In concurrent Basic Revival, each automated ally in diff --git a/docs/RULE_DECISIONS.md b/docs/RULE_DECISIONS.md index f31ec64e..586c3669 100644 --- a/docs/RULE_DECISIONS.md +++ b/docs/RULE_DECISIONS.md @@ -1,5 +1,14 @@ # Rule decision index +## Ordered Fremen allied revival and automated seats — 29 October 2026 + +The authorized revision, physical pp. 9 and 32, gives ordered Force Revival +and an optional Fremen allied free-revival benefit. A useful human Fremen grant +now provisionally holds an automated current ally's force return and Ready; +Fremen may release each eligible ally without losing the ability to grant later. +This priority and its release control are online conventions, not printed +timing. [Scope](FREMEN_REVIVAL_GRANT.md#human-fremen-and-ordered-automated-allies--29-october-2026). + ## Advanced Mentat victory timing — 29 October 2026 The supplied revision, physical p. 14 (printed p. 12), places victory during diff --git a/game/bots.ts b/game/bots.ts index f8a4c325..23cdb2b3 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -4665,14 +4665,19 @@ export function botActions(g: GameView): Action[] { : [{ type: 'endMovement' }]; } -/** In concurrent Basic Revival, let a human Fremen decide its existing grant - * before its automated ally spends or finishes. Ready is the normal way to - * finish that decision; responses and unrelated actions remain available. */ +/** Let a human Fremen decide a useful grant before an automated ally spends or + * finishes. Ordered Advanced uses a per-recipient release; concurrent Basic + * retains its normal Ready release. Responses and unrelated actions continue. */ function waitsForHumanFremenGrant(g: Game, player: Game['players'][number]): boolean { - if (g.status !== 'playing' || g.advanced || g.phase !== 4 || g.revivalOrder || + const ordered = g.advanced && !!g.revivalOrder; + if (g.status !== 'playing' || g.phase !== 4 || (g.advanced && !ordered) || + (!g.advanced && !!g.revivalOrder) || + (ordered && g.revivalOrder!.remaining[0] !== player.id) || player.tanks <= 0 || revivalPrevented(g, player.id)) return false; const fremen = g.players.find(seat => seat.faction === 'fremen'); - if (!fremen || fremen.bot || fremen.autopilot || g.ready.includes(fremen.id) || + if (!fremen || fremen.bot || fremen.autopilot || (!ordered && g.ready.includes(fremen.id)) || + (ordered && g.fremenRevivalWaitRelease?.turn === g.turn && + g.fremenRevivalWaitRelease.recipients.includes(player.id)) || fremenRevivalGrantBlock(g, fremen.id, player.id) !== null) return false; const current = freeRevivalRemaining(g, player); const granted = freeRevivalRemaining({ ...g, freeRevival: [...g.freeRevival, player.id] }, player); diff --git a/game/engine.ts b/game/engine.ts index 77339214..7e149df0 100644 --- a/game/engine.ts +++ b/game/engine.ts @@ -1501,6 +1501,8 @@ export type Game = { mentatVictoryAfterActions?: number; charityPromises?: CharityPromise[]; revivalOrder?: RevivalOrder; + /** Owner's per-ally release of the provisional ordered bot wait. */ + fremenRevivalWaitRelease?: { turn: number; recipients: string[] }; charityOrder?: CharityOrder; pendingAutomaticAdvisorFlips?: AutomaticAdvisorFlipSource[]; pendingAutomaticAdvisorTransport?: AutomaticAdvisorTransport; @@ -30531,6 +30533,7 @@ function beginPhase(g: Game) { } } if (g.phase === 4) { + delete g.fremenRevivalWaitRelease; g.revivalRules = newRevivalRules(); g.revivalRequests = {}; g.freeRevival = []; @@ -46752,6 +46755,23 @@ function applyActionInner( }; return g; } + if (t === 'releaseFremenRevivalWait') { + requireRule(Object.keys(action).sort().join(',') === 'target,type' && + g.phase === 4 && g.advanced && g.revivalOrder && p.faction === 'fremen', + 'Release an automated ally only during ordered Advanced Revival.'); + requireRule(!recruitsInteractionPending(g), 'Finish the current revival transaction or priority window first.'); + const target = stringField(action.target); + const recipient = getPlayer(g, target); + requireRule(!!(recipient.bot || recipient.autopilot) && g.revivalOrder.remaining.includes(target), + 'Choose an automated ally whose Revival turn remains.'); + fremenRevivalGrantRule(() => quoteFremenRevivalGrant(g, id, target)); + const released = g.fremenRevivalWaitRelease?.turn === g.turn + ? g.fremenRevivalWaitRelease.recipients : []; + requireRule(!released.includes(target), 'This ally has already been released from the wait.'); + g.fremenRevivalWaitRelease = {turn: g.turn, recipients: [...released, target]}; + log(g, `${p.name} released ${recipient.name} to continue Revival without waiting for a Fremen grant.`); + return g; + } if (t === 'grantRevival') { requireRule(g.phase === 4 && p.faction === 'fremen', 'Only Fremen may grant their ally free revival during revival.'); diff --git a/game/fremen-revival-grant.ts b/game/fremen-revival-grant.ts index b480d2a3..1a60afa9 100644 --- a/game/fremen-revival-grant.ts +++ b/game/fremen-revival-grant.ts @@ -11,7 +11,8 @@ export type FremenRevivalGrantContext = Pick< Game, 'status' | 'phase' | 'turn' | 'players' | 'freeRevival' | 'revivalRules' | 'recruits' > & Partial>; + 'nexusCards' | 'leaderSkills' | 'techTokens' | 'strongholdCards' | 'ecazTreachery' | 'discoveryEnabled' | + 'fremenRevivalWaitRelease'>>; export class FremenRevivalGrantError extends Error { constructor(message: string) { @@ -118,7 +119,7 @@ export function validateFremenRevivalGrant( export function projectFremenGroupRevivalGrant( g: FremenRevivalGrantContext, viewer: string, -): Array<{recipient: string; granted: boolean; blocked: string | null}> | null { +): Array<{recipient: string; granted: boolean; blocked: string | null; waitReleased?: boolean}> | null { if (!id(viewer) || !Array.isArray(g.players) || g.players.filter(player => player.id === viewer && player.faction === 'fremen').length !== 1) return null; @@ -126,7 +127,9 @@ export function projectFremenGroupRevivalGrant( if (!recipients) return null; return recipients.map(recipient => ({recipient, granted: Array.isArray(g.freeRevival) && g.freeRevival.includes(recipient), - blocked: fremenRevivalGrantBlock(g, viewer, recipient)})); + blocked: fremenRevivalGrantBlock(g, viewer, recipient), + ...(g.fremenRevivalWaitRelease?.turn === g.turn && + g.fremenRevivalWaitRelease.recipients.includes(recipient) ? {waitReleased: true} : {})})); } /** Owner-only status. No wallet, Tanks, prior counts or quoted free amount is @@ -134,7 +137,7 @@ export function projectFremenGroupRevivalGrant( export function projectFremenRevivalGrant( g: FremenRevivalGrantContext, viewer: string, -): { recipient: string; granted: boolean; blocked: string | null } | null { +): { recipient: string; granted: boolean; blocked: string | null; waitReleased?: boolean } | null { if (!id(viewer) || !Array.isArray(g.players)) return null; const owners = g.players.filter(player => player.id === viewer); const owner = owners[0]; @@ -146,5 +149,7 @@ export function projectFremenRevivalGrant( recipient: owner.ally, granted: Array.isArray(g.freeRevival) && g.freeRevival.includes(owner.ally), blocked: fremenRevivalGrantBlock(g, viewer), + ...(g.fremenRevivalWaitRelease?.turn === g.turn && + g.fremenRevivalWaitRelease.recipients.includes(owner.ally) ? {waitReleased: true} : {}), }; } diff --git a/game/reference.ts b/game/reference.ts index 68033376..845dbfd9 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -6566,6 +6566,7 @@ export const RULE_TOPICS: RuleTopic[] = [ "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. In concurrent Basic Revival an automated ally can wait for a useful grant until the Fremen grants it or finishes the phase; the existing Karama response still applies. Your ally’s normal revival panel supplies the actual remaining amount. Complete modes, broader combinations and final acceptance remain unfinished.", + "During ordered Advanced Revival, an automated ally whose turn would benefit may wait for a human Fremen's grant. Fremen can release that ally to continue without waiting, before or during its turn, and may still grant later if possible. Each eligible ally has a separate release; the choice reveals no Tanks. This is a provisional online priority.", "In the Basic group preview, Fremen can provisionally grant each ally the rate separately; each named declaration has its own Karama response. Automated allies wait individually when a human Fremen's available grant would help, until it resolves or Fremen is ready. Group interactions remain incomplete." ], "related": [ @@ -6590,20 +6591,22 @@ export const RULE_TOPICS: RuleTopic[] = [ { "area": "Player controls", "status": "Partial", - "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.", + "detail": "Actual pair and staged group grant callbacks, native response and persistent status/policy guidance are integrated. Ordered automated allies have a separate per-recipient release. 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" + "tests/fremen-revival-grant-controls.test.tsx", + "tests/fremen-ordered-revival-controls.test.tsx" ] }, { "area": "AI", "status": "Partial", - "detail": "Existing-profile native/Recruits paths and group grants use the same projected legal permissions. In concurrent Basic Revival each eligible automated pair or group ally waits for a useful human Fremen grant until it is resolved or Fremen is Ready. Strategy and strength calibration remain deferred.", + "detail": "Existing-profile native/Recruits paths and group grants use the same projected legal permissions. In concurrent Basic Revival each eligible automated pair or group ally waits for a useful human Fremen grant until it is resolved or Fremen is Ready. Ordered Advanced allies use a recipient-specific release. Strategy and strength calibration remain deferred.", "evidence": [ "game/bots.ts", "tests/fremen-revival-grant-bot-priority.test.ts", + "tests/fremen-ordered-revival-bot-priority.test.ts", "tests/coalition-fremen-bot-grant.test.ts", "tests/recruits-controls.test.tsx", "docs/FREMEN_REVIVAL_GRANT.md" diff --git a/tests/fremen-ordered-revival-bot-priority.test.ts b/tests/fremen-ordered-revival-bot-priority.test.ts new file mode 100644 index 00000000..5e36e73f --- /dev/null +++ b/tests/fremen-ordered-revival-bot-priority.test.ts @@ -0,0 +1,155 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine'; +import { botActions, runBots } from '../game/bots'; +import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles'; +import { + actFremenRevivalGrant as act, + advanceFremenRevivalGrant as advance, + fremenRevivalGrantClean as clean, + fremenRevivalGrantPlayer as player, + passFremenRevivalGrantResponses as pass, +} from './fixture-fremen-revival-grant'; + +/** Actual Advanced public setup and first ordered Revival. Reversing the two + * original lobby seats changes the real storm order. At Revival only, the + * reciprocal prior alliance and three ordinary Atreides casualties are staged; + * the counters move from his reserves into Tanks, without inventing supply. + * This controlled alliance is not a claim about Nexus history or full games. */ +function source(fremenFirstSeat: boolean) { + const first = fremenFirstSeat ? newPlayer('fremen', 'Fremen', 'fremen') + : newPlayer('atreides', 'Atreides', 'atreides'); + const second = fremenFirstSeat ? newPlayer('atreides', 'Atreides', 'atreides') + : newPlayer('fremen', 'Fremen', 'fremen'); + let game = createGame('FREMENORDEREDBOT', first, true); + joinGame(game, second); + joinGame(game, newPlayer('emperor', 'Emperor', 'emperor')); + for (const p of game.players) game = act(game, p.id, { type: 'ready' }); + game = act(game, game.host, { type: 'start', advancedPreview: true }); + game = advance(game, state => state.status === 'playing'); + // Select only two original unopened ordinary blows, avoiding incidental worms. + for (let position = 0; position < 2; position++) { + const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card && !card.discovery); + assert.ok(index >= position); + game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]); + } + game = advance(game, state => state.phase === 4 && clean(state)); + assert.ok(game.revivalOrder); + const ally = player(game, 'atreides'), fremen = player(game, 'fremen'); + fremen.ally = ally.id; ally.ally = fremen.id; + assert.ok(ally.reserves >= 3); + ally.reserves -= 3; ally.tanks += 3; + return game; +} + +let cached: { earlier: Game; later: Game } | undefined; +function sources() { + if (cached) return cached; + const options = [source(true), source(false)]; + const earlier = options.find(g => g.order.indexOf('fremen') < g.order.indexOf('atreides')); + const later = options.find(g => g.order.indexOf('fremen') > g.order.indexOf('atreides')); + assert.ok(earlier && later, 'Opposite real seat assignments must produce both relative Revival orders'); + cached = { earlier, later }; return cached; +} +function awaitingAlly(order: 'earlier' | 'later', level: Difficulty = 'Easy') { + let game = structuredClone(sources()[order]); + while (game.revivalOrder?.remaining[0] !== 'atreides') { + const actor = game.revivalOrder?.remaining[0]; assert.ok(actor); + game = act(game, actor, { type: 'ready' }); + } + assert.ok(game.revivalOrder); + assert.equal(game.revivalOrder.remaining[0], 'atreides'); + assert.equal(game.revivalOrder.completed.includes('fremen'), order === 'earlier'); + game = act(game, 'atreides', { type: 'setAutopilot', difficulty: level }); + return game; +} +function swapKaramaToObserver(game: Game) { + const observer = player(game, 'emperor'); + if (observer.hand.some(card => card.effect === 'karama')) return observer.hand.find(card => card.effect === 'karama')!.id; + const held = observer.hand[0]; assert.ok(held); + const pools = [game.deck, game.discard, ...game.players.filter(p => p.id !== observer.id).map(p => p.hand)]; + const pool = pools.find(cards => cards.some(card => card.effect === 'karama')); assert.ok(pool); + const index = pool.findIndex(card => card.effect === 'karama'); + const card = pool[index]; pool[index] = held; observer.hand[0] = card; + return card.id; +} + +for (const order of ['earlier', 'later'] as const) for (const level of DIFFICULTIES) void test( + `Advanced ${level} allied bot waits for human Fremen ${order} in ordered Revival and uses a real grant`, () => { + let game = awaitingAlly(order, level); + const before = structuredClone(game.revivalOrder), ally = player(game, 'atreides'); + const tanks = ally.tanks, spice = ally.spice; + game = runBots(game, 1); + assert.deepEqual(game.revivalOrder, before); + assert.equal(player(game, 'atreides').revived, 0); + assert.equal(player(game, 'atreides').tanks, tanks); + game = act(game, 'fremen', { type: 'grantRevival' }); + if (game.response) game = pass(game); + assert.equal(viewGame(game, 'atreides').revival.freeRemaining, 3); + const offered = botActions(viewGame(game, 'atreides')).find(action => action.type === 'revive' && action.amount === 3); + assert.ok(offered); + game = runBots(game, 1); + assert.equal(player(game, 'atreides').tanks, tanks - 3); + assert.equal(player(game, 'atreides').spice, spice); + assert.equal(player(game, 'atreides').freeForcesRevived, 3); + }, +); + +for (const order of ['earlier', 'later'] as const) void test( + `Fremen ${order} in the ordered queue can release an automated ally without forfeiting a later grant`, () => { + let game = awaitingAlly(order); + const before = structuredClone(game.revivalOrder); + assert.ok(!viewGame(game, 'fremen').fremenRevivalGrant?.waitReleased); + game = act(game, 'fremen', { type: 'releaseFremenRevivalWait', target: 'atreides' }); + assert.deepEqual(game.revivalOrder, before); + assert.equal(viewGame(game, 'fremen').fremenRevivalGrant?.waitReleased, true); + assert.equal(viewGame(game, 'fremen').fremenRevivalGrant?.blocked, null); + assert.equal(game.freeRevival.includes('atreides'), false); + let granted = act(game, 'fremen', {type:'grantRevival'}); + if (granted.response) granted = pass(granted); + assert.equal(granted.freeRevival.includes('atreides'), true); + const resumed = runBots(game, 1); + assert.ok(player(resumed, 'atreides').revived > 0 || resumed.ready.includes('atreides')); + }, +); + +void test('Fremen can release a future ally, while other seats and stale releases are rejected', () => { + let game = structuredClone(sources().earlier); + game = act(game, 'atreides', {type:'setAutopilot', difficulty:'Easy'}); + assert.ok(game.revivalOrder?.remaining.includes('atreides')); + assert.notEqual(game.revivalOrder?.remaining[0], 'atreides'); + const before = structuredClone(game); + assert.throws(() => act(game, 'emperor', {type:'releaseFremenRevivalWait', target:'atreides'})); + assert.deepEqual(game, before); + game = act(game, 'fremen', {type:'releaseFremenRevivalWait', target:'atreides'}); + assert.equal(viewGame(game, 'fremen').fremenRevivalGrant?.waitReleased, true); + assert.equal(viewGame(game, 'emperor').fremenRevivalGrant, null); + const released = structuredClone(game); + assert.throws(() => act(game, 'fremen', {type:'releaseFremenRevivalWait', target:'atreides'})); + assert.deepEqual(game, released); +}); + +void test('real Karama prevention releases the Advanced bot with its intrinsic free rate', () => { + let game = awaitingAlly('later', 'Medium'); + // Conserved exchange of original Treachery cards gives Emperor a counter. + const karama = swapKaramaToObserver(game); + game = act(game, 'fremen', { type: 'grantRevival' }); + assert.equal(game.response?.kind, 'fremenRevivalGrant'); + game = act(game, 'emperor', { type: 'card', card: karama, mode: 'cancel' }); + assert.ok(game.revivalRules?.fremenGrantBlocked?.includes('atreides')); + assert.equal(viewGame(game, 'atreides').revival.freeRemaining, 2); + const resumed = runBots(game, 1); + assert.ok(player(resumed, 'atreides').revived > 0 || resumed.ready.includes('atreides')); +}); + +void test('Advanced bots do not wait for an unnecessary grant or an automated Fremen', () => { + for (const variant of ['two-tanks', 'automated-fremen'] as const) { + let game = awaitingAlly('later'); + if (variant === 'two-tanks') { + // Return one ordinary counter from Tanks to its original reserve supply. + player(game, 'atreides').tanks--; player(game, 'atreides').reserves++; + } else game = act(game, 'fremen', { type: 'setAutopilot', difficulty: 'Easy' }); + game = runBots(game, 1); + assert.ok(player(game, 'atreides').revived > 0 || game.ready.includes('atreides')); + } +}); diff --git a/tests/fremen-ordered-revival-controls.test.tsx b/tests/fremen-ordered-revival-controls.test.tsx new file mode 100644 index 00000000..8d88e987 --- /dev/null +++ b/tests/fremen-ordered-revival-controls.test.tsx @@ -0,0 +1,91 @@ +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'; + +/** Projection-only control fixtures; engine tests own the actual alliance and + * ordered Revival transitions. The ally's private revival inventory is not read. */ +function projected(group = false): GameView { + const game = createGame('ORDEREDGRANTCONTROLS', newPlayer('f', 'Fremen', 'fremen'), true); + game.players.push(newPlayer('a', 'Ally', 'atreides')); + game.players.push(newPlayer('e', 'Emperor', 'emperor')); + Object.assign(game, { status: 'playing', phase: 4, turn: 3, order: ['f', 'e', 'a'] }); + game.players[0].ally = 'a'; + game.players[1].ally = 'f'; + const view = viewGame(game, 'f'); + view.players.find(player => player.id === 'a')!.autopilot = 'Easy'; + view.players.find(player => player.id === 'e')!.bot = 'Medium'; + view.revivalOrder = { version: 1, turn: 3, original: ['f', 'e', 'a'], + remaining: ['e', 'a'], completed: ['f'], started: false }; + view.fremenRevivalGrant = { recipient: 'a', granted: false, blocked: null, waitReleased: false }; + if (group) view.fremenGroupRevivalGrant = [ + { recipient: 'a', granted: false, blocked: null, waitReleased: false }, + { recipient: 'e', granted: false, blocked: null, waitReleased: false }, + ]; + return view; +} +function markup(game: GameView, busy = false) { + return renderToStaticMarkup(createElement(FremenRevivalGrant, { game, busy, act() {} })); +} +function click(node: ReactNode, target: string): 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-wait-release-${target}` && child.props.onClick) { + child.props.onClick(); + return true; + } + if (click(child.props.children, target)) return true; + } + return false; +} +function release(game: GameView, target: string, busy = false): Action[] { + const actions: Action[] = []; + click(FremenRevivalGrant({ game, busy, act(action) { actions.push(action); } }), target); + return actions; +} + +void test('Fremen can release a later automated ally before its ordered Revival turn', () => { + const game = projected(); + assert.match(markup(game), /Continue without waiting/); + assert.match(markup(game), /may still grant later while Revival remains open/); + assert.deepEqual(release(game, 'a'), [{type:'releaseFremenRevivalWait', target:'a'}]); +}); + +void test('release is disabled during a busy window or after release without hiding grant', () => { + const game = projected(); + assert.deepEqual(release(game, 'a', true), []); + game.fremenRevivalGrant!.waitReleased = true; + assert.match(markup(game), /Ally can continue without waiting for your grant/); + assert.match(markup(game), /Grant ally free revival/); + assert.deepEqual(release(game, 'a'), []); + game.truthtrance = {} as NonNullable; + game.fremenRevivalGrant!.waitReleased = false; + assert.deepEqual(release(game, 'a'), []); +}); + +void test('group controls release each recipient independently without reading private forces', () => { + const game = projected(true); + for (const ally of game.players.filter(player => player.id !== 'f')) { + for (const field of ['spice', 'tanks', 'deadForces', 'revived', 'freeForcesRevived']) + Object.defineProperty(ally, field, {get() { throw new Error(`Read private ${field}`); }}); + } + assert.deepEqual(release(game, 'a'), [{type:'releaseFremenRevivalWait', target:'a'}]); + assert.deepEqual(release(game, 'e'), [{type:'releaseFremenRevivalWait', target:'e'}]); + game.fremenGroupRevivalGrant![0].waitReleased = true; + assert.deepEqual(release(game, 'a'), []); + assert.deepEqual(release(game, 'e'), [{type:'releaseFremenRevivalWait', target:'e'}]); +}); + +void test('release is absent for human, finished, blocked or nonordered allies', () => { + for (const condition of ['human', 'finished', 'blocked', 'basic'] as const) { + const game = projected(); + if (condition === 'human') game.players.find(player => player.id === 'a')!.autopilot = undefined; + if (condition === 'finished') game.revivalOrder!.remaining = ['e']; + if (condition === 'blocked') game.fremenRevivalGrant!.blocked = 'Grant prevented.'; + if (condition === 'basic') game.advanced = false; + assert.doesNotMatch(markup(game), /Continue without waiting/, condition); + assert.deepEqual(release(game, 'a'), [], condition); + } +}); diff --git a/tests/fremen-ordered-revival-group.test.ts b/tests/fremen-ordered-revival-group.test.ts new file mode 100644 index 00000000..121d2c55 --- /dev/null +++ b/tests/fremen-ordered-revival-group.test.ts @@ -0,0 +1,69 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { baseDeck } from '../game/cards'; +import { runBots } from '../game/bots'; +import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance'; +import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine'; +import { createRevivalOrder } from '../game/revival-order'; + +const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action); +const seat = (game: Game, id: string) => { + const found = game.players.find(player => player.id === id); assert.ok(found); return found; +}; + +/** Advanced group is built by genuine pair and group Nexus actions. The Nexus + * itself, original public order and subsequent Revival phase are staged, not a + * native full game. Atreides and Harkonnen each move four of their own ordinary + * reserves to Tanks; the queue is made with the production order helper, and + * Fremen actually finishes his opportunity before the first ally acts. */ +function fixture() { + let game = createGame('ORDEREDGROUPREVIVAL', newPlayer('f', 'Fremen', 'fremen'), true); + 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 = act(game, 'f', {type: 'alliance', target: 'a'}); + game = act(game, 'a', {type: 'alliance', target: 'f'}); + const group = coalitionOf(game.players, game.coalitionState, 'f'); assert.ok(group); + game = act(game, 'f', {type: 'coalitionPropose', groupId: group.id, entrant: 'h', + expectedRevision: game.coalitionState!.revision}); + for (const id of ['a', 'h']) { + const proposal = game.coalitionState!.proposal; assert.ok(proposal); + game = act(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, + active: 'f', phaseOpening: null}); + for (const player of game.players) { + player.forces = {}; player.reserves = 20; player.tanks = 0; player.spice = 10; + player.hand = []; player.traitors = []; player.traitorChoices = []; + } + for (const id of ['a', 'h']) {seat(game, id).reserves -= 4; seat(game, id).tanks += 4;} + game.revivalOrder = createRevivalOrder({turn: game.turn, order: game.order, ready: game.ready}); + game = act(game, 'f', {type: 'ready'}); + game = act(game, 'a', {type: 'setAutopilot', difficulty: 'Easy'}); + game = act(game, 'h', {type: 'setAutopilot', difficulty: 'Hard'}); + assert.equal(game.revivalOrder?.remaining[0], 'a'); + return game; +} + +void test('Advanced group release is recipient-specific across consecutive automated Revival turns', () => { + let game = fixture(); + const original = structuredClone(game.revivalOrder); + game = runBots(game, 12); + assert.deepEqual(game.revivalOrder, original); + assert.equal(seat(game, 'a').revived, 0); assert.equal(seat(game, 'h').revived, 0); + game = act(game, 'f', {type: 'releaseFremenRevivalWait', target: 'a'}); + const projection = viewGame(game, 'f').fremenGroupRevivalGrant; + assert.equal(projection?.find(row => row.recipient === 'a')?.waitReleased, true); + assert.ok(!projection?.find(row => row.recipient === 'h')?.waitReleased); + game = runBots(game, 30); + assert.ok(seat(game, 'a').revived > 0); + assert.ok(game.revivalOrder?.completed.includes('a')); + assert.equal(game.revivalOrder?.remaining[0], 'h'); + assert.equal(seat(game, 'h').revived, 0); assert.equal(seat(game, 'h').tanks, 4); + assert.equal(game.freeRevival.includes('a'), false); assert.equal(game.freeRevival.includes('h'), false); +});