diff --git a/components/fremen-revival-grant.tsx b/components/fremen-revival-grant.tsx index dcf8c297..895b9673 100644 --- a/components/fremen-revival-grant.tsx +++ b/components/fremen-revival-grant.tsx @@ -40,6 +40,7 @@ export function FremenRevivalGrant({ 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 automatedAlly = game.players.find(player => player.id === grant.recipient); const disabled = busy || !!game.truthtrance || !!game.nexusTraitors?.pending || !!grant.blocked || grant.granted; return ( @@ -50,6 +51,10 @@ export function FremenRevivalGrant({ replaces their native ordinary rate rather than adding three. Ordinary total revival and special-force limits still apply.

+ {!game.advanced && !game.revivalOrder && !group && + (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.

+ )}

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 cd409bf7..cdbb4aab 100644 --- a/docs/CURRENT_STATUS.md +++ b/docs/CURRENT_STATUS.md @@ -17,6 +17,17 @@ open mode gates. ## Current checkpoint and work +**Basic Fremen ally revival scheduling — 29 October. Prototyped / Partial.** +An automated mutual ally now waits for a useful human Fremen free-revival grant +in concurrent Basic Revival, until the grant resolves or the Fremen selects the +existing Ready action. Karama responses remain active; other bot actions do not +wait. The Fremen panel explains the choice. Genuine setup, battle casualties, +Nexus alliance, grant/prevention and all four automated profiles are covered; +the four-file related batch passes 40 cases, and type checking, lint and the +production build pass. Ordered Revival, larger alliances, +other opportunity races and deployed acceptance remain open. +[Scope](FREMEN_REVIVAL_GRANT.md#human-fremen-and-an-automated-ally--29-october-2026). + **Opt-in CHOAM market continuation — 29 October. Prototyped / Partial.** In supported Basic CHOAM/classic games, a human can finish a market and choose to skip later markets for that turn; an owner-only persistent control revokes diff --git a/docs/FREMEN_REVIVAL_GRANT.md b/docs/FREMEN_REVIVAL_GRANT.md index 7ff0c6e0..8e7d8fc2 100644 --- a/docs/FREMEN_REVIVAL_GRANT.md +++ b/docs/FREMEN_REVIVAL_GRANT.md @@ -1,5 +1,18 @@ # Fremen allied free revival +## Human Fremen and an automated ally — 29 October 2026 + +**Prototyped / Partial.** During concurrent Basic Revival, an automated mutual +ally waits to make its ordinary force return or finish Revival while a human +Fremen has an eligible grant that would increase that ally's remaining free +allowance and the ally has forces in the Tanks. The Fremen may use the existing +grant action; its ordinary Karama response still resolves before the ally acts. +The Fremen's existing Ready for next phase also releases the wait, as do an +accepted or prevented grant. Other bot actions and responses remain available. +The Fremen panel explains this scheduling choice. This is a provisional online +priority policy, not a printed rule or a new mandatory confirmation. Ordered +Revival, larger alliances and other races are not covered by this policy. + ## Staged three-member alliance — 28 October 2026 In the unexposed Basic classic group prototype, Fremen may select each of its diff --git a/game/bots.ts b/game/bots.ts index 097aa3cf..bed554fb 100644 --- a/game/bots.ts +++ b/game/bots.ts @@ -2,6 +2,7 @@ import { liveMovementExpressionPromises } from './movement-expression-promises'; import { isAlliedShipmentVisit } from './allied-separation'; import { discoveryBotActions } from './discovery-options'; import { normalAuctionHandBlock } from './normal-auction'; +import { fremenRevivalGrantBlock } from './fremen-revival-grant'; import { atreidesFullPlanAction } from './atreides-full-plan'; import { liveMovementPromises } from './movement-promises'; import { ecazOccupyDialChoices, ecazOccupyOwnProfile, ecazOccupyPolicyActions } from './ecaz-occupy-options'; @@ -60,6 +61,8 @@ import { strongholdBattleEffects } from './stronghold-battle'; import { eliteRevivalRemaining, forceRevivalPrice, + freeRevivalRemaining, + revivalPrevented, paidForceRevivalCost, } from './revival'; import { controlsLeader, nativeAvailable } from './leader-control'; @@ -4661,6 +4664,21 @@ 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. */ +function waitsForHumanFremenGrant(g: Game, player: Game['players'][number]): boolean { + if (g.status !== 'playing' || g.advanced || g.phase !== 4 || g.revivalOrder || + g.coalitionState || player.tanks <= 0 || revivalPrevented(g, player.id)) return false; + const fremen = g.players.find(seat => seat.faction === 'fremen'); + if (!fremen || fremen.bot || fremen.autopilot || fremen.ally !== player.id || + player.ally !== fremen.id || g.ready.includes(fremen.id) || + fremenRevivalGrantBlock(g, fremen.id) !== null) return false; + const current = freeRevivalRemaining(g, player); + const granted = freeRevivalRemaining({ ...g, freeRevival: [...g.freeRevival, player.id] }, player); + return Math.min(player.tanks, granted) > Math.min(player.tanks, current); +} + export function runBots(state: Game, limit = 96): Game { const supplier = state.players.find((p) => p.faction === 'richese'); const unopenedAllyWindow = @@ -4700,6 +4718,8 @@ export function runBots(state: Game, limit = 96): Game { actors.sort((a, b) => Number(b.faction === 'guild') - Number(a.faction === 'guild')); for (const p of actors) { for (const action of botActions(viewGame(g, p.id))) { + if ((action.type === 'revive' || action.type === 'ready') && waitsForHumanFremenGrant(g, p)) + continue; try { next = applyAction(g, p.id, action); break; diff --git a/game/reference.ts b/game/reference.ts index 1df759a8..82c36541 100644 --- a/game/reference.ts +++ b/game/reference.ts @@ -6553,7 +6553,7 @@ 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. 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.", "In the Basic group preview, Fremen can provisionally grant each ally the rate separately; each named declaration has its own Karama response. Group interactions remain incomplete." ], "related": [ @@ -6588,9 +6588,10 @@ export const RULE_TOPICS: RuleTopic[] = [ { "area": "AI", "status": "Partial", - "detail": "Existing-profile native/Recruits paths and staged group grants use the same projected legal permissions. Strategy and strength calibration remain deferred.", + "detail": "Existing-profile native/Recruits paths and staged group grants use the same projected legal permissions. In concurrent Basic Revival a mutual automated ally waits for a useful human Fremen grant until it is resolved or Fremen is Ready. Strategy and strength calibration remain deferred.", "evidence": [ "game/bots.ts", + "tests/fremen-revival-grant-bot-priority.test.ts", "tests/recruits-controls.test.tsx", "docs/FREMEN_REVIVAL_GRANT.md" ] @@ -6615,6 +6616,7 @@ export const RULE_TOPICS: RuleTopic[] = [ "tests/fremen-revival-grant-engine.test.ts", "tests/fremen-revival-grant-quote.test.ts", "tests/fremen-revival-grant-controls.test.tsx", + "tests/fremen-revival-grant-bot-priority.test.ts", "tests/recruits-engine.test.ts", "tests/recruits-controls.test.tsx" ] diff --git a/tests/fremen-revival-grant-bot-priority.test.ts b/tests/fremen-revival-grant-bot-priority.test.ts new file mode 100644 index 00000000..ff10021f --- /dev/null +++ b/tests/fremen-revival-grant-bot-priority.test.ts @@ -0,0 +1,140 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { 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, + createFremenRevivalGrantFixture, fremenRevivalGrantClean as clean, + fremenRevivalGrantPlayer as player, passFremenRevivalGrantResponses as pass } from './fixture-fremen-revival-grant'; + +let fixture: ReturnType | undefined; +const source = () => fixture ??= createFremenRevivalGrantFixture(); + +/** The source uses real public Basic setup, an actual battle with six allied + * casualties and reciprocal Nexus consent. Handover uses the real seat action. */ +function botAlly(difficulty: Difficulty) { + const f = source(); + const game = act(structuredClone(f.game), f.ally, {type:'setAutopilot', difficulty}); + assert.equal(game.advanced, false); + assert.equal(game.phase, 4); + assert.equal(player(game, f.fremen).ally, f.ally); + assert.equal(player(game, f.ally).ally, f.fremen); + assert.ok(player(game, f.ally).tanks >= 3); + assert.equal(viewGame(game, f.ally).revival.freeRemaining, 2); + assert.equal(viewGame(game, f.fremen).fremenRevivalGrant?.blocked, null); + return {f, game}; +} +const ownChoices = (game: Game, id: string) => botActions(viewGame(game, id)); + +for (const difficulty of DIFFICULTIES) void test( + `Basic ${difficulty} ally waits for human Fremen, then uses the granted free revival`, () => { + const {f, game} = botAlly(difficulty); + const spice = player(game, f.ally).spice, tanks = player(game, f.ally).tanks; + const afterBot = runBots(game, 1); + assert.equal(player(afterBot, f.ally).spice, spice); + assert.equal(player(afterBot, f.ally).tanks, tanks); + assert.equal(player(afterBot, f.ally).revived, 0); + assert.ok(!afterBot.ready.includes(f.ally)); + let next = act(afterBot, f.fremen, f.grant); + assert.equal(next.response?.kind, 'fremenRevivalGrant'); + assert.equal(next.freeRevival.includes(f.ally), false); + next = pass(next); + assert.equal(viewGame(next, f.ally).revival.freeRemaining, 3); + const revival = ownChoices(next, f.ally).find(action => action.type === 'revive' && action.amount === 3); + assert.ok(revival, `${difficulty} should offer its legal three-force revival`); + next = advance(runBots(next, 1), clean); + assert.equal(player(next, f.ally).spice, spice); + assert.equal(player(next, f.ally).tanks, tanks - 3); + assert.equal(player(next, f.ally).freeForcesRevived, 3); + }, +); + +void test('Fremen’s normal Ready releases the waiting bot without granting the benefit', () => { + const {f, game} = botAlly('Easy'); + const waiting = runBots(game, 1); + assert.equal(player(waiting, f.ally).revived, 0); + assert.ok(!waiting.ready.includes(f.ally)); + const next = act(waiting, f.fremen, {type:'ready'}); + assert.ok(next.ready.includes(f.fremen)); + assert.equal(next.freeRevival.includes(f.ally), false); + const resumed = runBots(next, 1); + assert.ok(player(resumed, f.ally).revived > 0 || resumed.ready.includes(f.ally)); +}); + +void test('a real Karama prevention releases the waiting bot with its intrinsic free rate', () => { + const {f, game} = botAlly('Medium'); + let next = act(game, f.fremen, f.grant); + assert.equal(next.response?.kind, 'fremenRevivalGrant'); + next = act(next, f.observer, {type:'card', card:f.karama, mode:'cancel'}); + assert.equal(next.response, null); + assert.ok(next.revivalRules?.fremenGrantBlocked?.includes(f.ally)); + assert.equal(viewGame(next, f.ally).revival.freeRemaining, 2); + const resumed = runBots(next, 1); + assert.ok(player(resumed, f.ally).revived > 0 || resumed.ready.includes(f.ally)); +}); + +void test('the waiting ally can still pass its actual Karama response to the Fremen grant', () => { + const {f, game} = botAlly('Easy'); + // Stage one conserved physical card exchange solely to make the allied + // bot, rather than the original Emperor, own the response card. + const ally = player(game, f.ally), observer = player(game, f.observer); + const karama = observer.hand.findIndex(card => card.id === f.karama); + assert.ok(karama >= 0 && ally.hand.length > 0); + [ally.hand[0], observer.hand[karama]] = [observer.hand[karama], ally.hand[0]]; + let next = act(game, f.fremen, f.grant); + assert.equal(next.response?.kind, 'fremenRevivalGrant'); + next = runBots(next, 1); + assert.equal(next.response, null); + assert.equal(next.freeRevival.includes(f.ally), true); +}); + +void test('a bot Fremen does not make its bot ally wait for a human grant', () => { + const {f, game} = botAlly('Easy'); + const next = act(game, f.fremen, {type:'setAutopilot', difficulty:'Easy'}); + // Staged actor iteration order puts the allied bot first; public storm order + // and the genuine prior game state are otherwise untouched. + next.players = [player(next, f.ally), player(next, f.fremen), player(next, f.observer)]; + const resumed = runBots(next, 1); + assert.ok(player(resumed, f.ally).revived > 0 || resumed.ready.includes(f.ally)); +}); + +void test('a completed normal revival does not hold the bot’s Ready for an unusable grant', () => { + // This fixture actually completes its paid/free three-force return before + // handover; the remaining ordinary revival allowance is genuinely zero. + const f = createFremenRevivalGrantFixture({priorRevival:true}); + const game = act(f.game, f.ally, {type:'setAutopilot', difficulty:'Easy'}); + assert.equal(viewGame(game, f.ally).revival.forcesRemaining, 0); + const resumed = runBots(game, 1); + assert.ok(resumed.ready.includes(f.ally)); +}); + +void test('two remaining Tanks forces do not hold a bot that already has two free revivals', () => { + const {f, game} = botAlly('Easy'); + const ally = player(game, f.ally); + // Labelled physical-custody staging: return the surplus ordinary Tanks + // counters to reserves, preserving the player's total force inventory. + const surplus = ally.tanks - 2; + assert.ok(surplus > 0); + ally.tanks -= surplus; + ally.reserves += surplus; + const reserves = ally.reserves, spice = ally.spice; + assert.equal(viewGame(game, f.ally).revival.freeRemaining, 2); + assert.equal(viewGame(game, f.fremen).fremenRevivalGrant?.blocked, null); + const resumed = runBots(game, 1); + assert.equal(player(resumed, f.ally).revived, 2); + assert.equal(player(resumed, f.ally).tanks, 0); + assert.equal(player(resumed, f.ally).reserves, reserves + 2); + assert.equal(player(resumed, f.ally).spice, spice); +}); + +void test('a bot barred from normal revival can Ready without waiting for a futile grant', () => { + const {f, game} = botAlly('Easy'); + // Stage the existing current-turn native prevention receipt, without + // changing the genuine alliance, Tanks, wallet or Fremen grant state. + game.revivalPrevention = {player:f.ally, turn:game.turn}; + assert.equal(viewGame(game, f.ally).revival.prevented, true); + const resumed = runBots(game, 1); + assert.ok(resumed.ready.includes(f.ally)); + assert.equal(player(resumed, f.ally).revived, 0); + assert.equal(player(resumed, f.ally).tanks, player(game, f.ally).tanks); +}); diff --git a/tests/fremen-revival-grant-controls.test.tsx b/tests/fremen-revival-grant-controls.test.tsx index 78de9ba4..5cdeaeeb 100644 --- a/tests/fremen-revival-grant-controls.test.tsx +++ b/tests/fremen-revival-grant-controls.test.tsx @@ -64,6 +64,14 @@ void test('native Fremen grant renders its ally and submits the original grant a assert.deepEqual(grant(game), [{ type: 'grantRevival' }]); }); +void test('the Fremen sees how an automated ally resumes without an extra confirmation', () => { + const game = projected(); + game.players.find(player => player.id === 'a')!.autopilot = 'Easy'; + assert.match(markup(game), /waits to revive forces until you grant it or select Ready for next phase/); + game.fremenRevivalGrant!.granted = true; + assert.doesNotMatch(markup(game), /waits to revive forces/); +}); + 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/);