Respect Fremen group revival grant for bots
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-29 08:46:52 +11:00
1 parent e439f3f7b6
commit f9ca7ed9a9
6 files changed
+203 -7

No files matched your search

+3
View File
@@ -23,6 +23,9 @@ export function FremenRevivalGrant({
<h3 className="font-serif text-xl">Fremen group revival</h3>
<p className="muted">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.</p>
<p className="fine">The grant to each ally is a provisional interpretation of the larger-alliance rule. Each declaration opens a Karama response.</p>
{!game.advanced && !game.revivalOrder && group.some(option => !option.granted && !option.blocked &&
game.players.some(player => player.id === option.recipient && (player.bot || player.autopilot))) &&
<p className="fine">When a grant would help an automated ally, it waits to revive forces until you grant it or select Ready for next phase.</p>}
{group.map(option => {
const name = game.players.find(player => player.id === option.recipient)?.name ?? 'Ally';
return <div key={option.recipient}>
+13
View File
@@ -17,6 +17,19 @@ open mode gates.
## Current checkpoint and work
**Basic group Fremen revival scheduling — 29 October. Prototyped / Partial.**
During concurrent Basic Revival, automated group allies wait individually for
a human Fremen's eligible, useful free-revival grant. Grant, targeted Karama
prevention, Fremen Ready or no added benefit releases the affected ally;
unrelated bot actions and responses remain available. The Fremen panel explains
the wait without exposing Tanks. Four related files pass 40 cases, including
15 new cases across both allies and all four legal bot profiles; type checking,
lint and production build pass. The tests form the group through real Nexus
actions, with staged Revival and conserved Tanks. This is a provisional online
priority, not strategic AI calibration; ordered Revival, other group interactions
and deployed acceptance remain open.
[Scope](FREMEN_REVIVAL_GRANT.md#human-fremen-and-automated-group-allies--29-october-2026).
**Basic dead-leader battle preparation — 29 October. Prototyped / Partial.**
Fresh classic Basic battles without Harkonnen open the existing neutral
pre-leader opportunity when a combatant has a dead leader and no available
+18 -1
View File
@@ -1,5 +1,21 @@
# Fremen allied free revival
## Human Fremen and automated group allies — 29 October 2026
**Prototyped / Partial.** In concurrent Basic Revival, each automated ally in
the supported classic group preview waits for its own useful, available Fremen
grant. Fremen may grant each ally independently, finish with the existing Ready
action, or have a targeted grant prevented. Only that recipient's ordinary
revival and Ready actions wait; unrelated bot actions and Karama responses
continue. The shared grant quote and normal revival allowances determine when
waiting is useful. The group panel explains this scheduling without showing
allied Tanks or balances. This is a provisional online priority, not a printed
timing rule or strategic AI calibration. The test uses genuine Nexus group
formation with staged Revival and conserved Tanks; other group interactions,
ordered Revival and deployed acceptance remain open. The later
[Basic classic group lobby preview](OPTIONAL_ALLIANCE_VICTORY.md#basic-classic-group-lobby-preview--29-october-2026)
supersedes older notes below that group starts were unavailable.
## Human Fremen and an automated ally — 29 October 2026
**Prototyped / Partial.** During concurrent Basic Revival, an automated mutual
@@ -11,7 +27,8 @@ 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.
Revival and other races are not covered by this policy. The group follow-up
above extends it to supported larger alliances.
## Staged three-member alliance — 28 October 2026
+3 -4
View File
@@ -4670,11 +4670,10 @@ export function botActions(g: GameView): Action[] {
* 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;
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;
if (!fremen || fremen.bot || fremen.autopilot || g.ready.includes(fremen.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);
return Math.min(player.tanks, granted) > Math.min(player.tanks, current);
+3 -2
View File
@@ -6562,7 +6562,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.",
"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."
"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": [
"revival",
@@ -6596,10 +6596,11 @@ 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. 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.",
"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.",
"evidence": [
"game/bots.ts",
"tests/fremen-revival-grant-bot-priority.test.ts",
"tests/coalition-fremen-bot-grant.test.ts",
"tests/recruits-controls.test.tsx",
"docs/FREMEN_REVIVAL_GRANT.md"
]
+163
View File
@@ -0,0 +1,163 @@
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 { botActions, runBots } from '../game/bots';
import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
const act = (game:Game, id:string, action:Action) => applyAction(game,id,action);
const seat = (game:Game, id:string) => {
const player = game.players.find(candidate => candidate.id === id); assert.ok(player); return player;
};
/** Real Nexus proposals, acceptance and group admission. The original ordinary
* phase, force pools, wallets and holdings are staged at Revival, with tanks
* moved from reserves and the counter card taken from the physical deck. */
function fixture(difficulty:Difficulty, bot:'a'|'h' = 'a', grouped = true) {
let game = createGame('GROUPFREMENBOT',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 = act(game,'f',{type:'alliance',target:'a'});
game = act(game,'a',{type:'alliance',target:'f'});
if (grouped) {
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!;
game = act(game,id,{type:'coalitionAnswer',event:proposal.event,nexus:proposal.nexus,
turn:proposal.turn,accept:true,expectedRevision:game.coalitionState!.revision});
}
}
Object.assign(game,{phase:4,nexus:false,ready:[],response:null,decision: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 = 16; seat(game,id).tanks = 4; }
const index = game.deck.findIndex(card => card.effect === 'karama'); assert.ok(index >= 0);
const [karama] = game.deck.splice(index,1); seat(game,'g').hand.push(karama);
game = act(game,bot,{type:'setAutopilot',difficulty});
return {game,bot,karama:karama.id};
}
function pass(game:Game):Game {
for (let count = 0; game.response && count < 24; count++) {
const player = game.players.find(candidate => !game.response!.passed.includes(candidate.id)); assert.ok(player);
game = act(game,player.id,{type:'passResponse'});
}
assert.equal(game.response ?? null,null); return game;
}
for (const difficulty of DIFFICULTIES) for (const bot of ['a','h'] as const) void test(
`${difficulty} group ally ${bot} waits for human Fremen and uses the granted free revival`, () => {
let {game} = fixture(difficulty,bot);
const priorSpice = seat(game,bot).spice, priorTanks = seat(game,bot).tanks;
assert.equal(viewGame(game,bot).revival.freeRemaining,2);
assert.equal(viewGame(game,'f').fremenGroupRevivalGrant?.find(row => row.recipient === bot)?.blocked,null);
game = runBots(game,1);
assert.equal(seat(game,bot).tanks,priorTanks);
assert.equal(seat(game,bot).spice,priorSpice);
assert.equal(seat(game,bot).revived,0);
assert.ok(!game.ready.includes(bot));
game = act(game,'f',{type:'grantRevival',target:bot});
assert.equal(game.response?.kind,'fremenRevivalGrant');
game = pass(game);
assert.equal(viewGame(game,bot).revival.freeRemaining,3);
assert.ok(botActions(viewGame(game,bot)).some(action => action.type === 'revive' && action.amount === 3));
game = runBots(game,1);
assert.equal(seat(game,bot).tanks,priorTanks - 3);
assert.equal(seat(game,bot).spice,priorSpice);
assert.equal(seat(game,bot).freeForcesRevived,3);
},
);
void test('Fremen Ready declines further group grants and releases the waiting ally', () => {
const {game: initial,bot} = fixture('Easy'); let game = initial;
game = runBots(game,1); assert.equal(seat(game,bot).revived,0);
game = act(game,'f',{type:'ready'}); assert.ok(game.ready.includes('f'));
game = runBots(game,1);
assert.ok(seat(game,bot).revived > 0 || game.ready.includes(bot));
});
void test('actual Karama prevents one group grant and releases that waiting ally', () => {
const {game: initial,bot,karama} = fixture('Medium'); let game = initial;
game = act(game,'h',{type:'setAutopilot',difficulty:'Hard'});
game = act(game,'f',{type:'grantRevival',target:bot});
assert.equal(game.response?.kind,'fremenRevivalGrant');
game = act(game,'g',{type:'card',card:karama,mode:'cancel'});
assert.equal(game.response ?? null,null);
assert.ok(game.revivalRules?.fremenGrantBlocked?.includes(bot));
assert.equal(viewGame(game,bot).revival.freeRemaining,2);
game = runBots(game,1);
assert.ok(seat(game,bot).revived > 0 || game.ready.includes(bot));
assert.equal(seat(game,'h').revived,0);
assert.ok(!game.ready.includes('h'));
});
void test('an automated non-ally does not wait for a group grant it cannot receive', () => {
let {game} = fixture('Easy');
game = act(game,'g',{type:'setAutopilot',difficulty:'Hard'});
seat(game,'g').reserves = 16; seat(game,'g').tanks = 4;
game = runBots(game,1);
assert.ok(seat(game,'g').revived > 0 || game.ready.includes('g'));
assert.equal(seat(game,'a').revived,0);
});
void test('a group ally with only its two already-free forces in Tanks does not wait', () => {
const {game: initial,bot} = fixture('Easy'); let game = initial;
// Move surplus counters back to the same native reserve pool.
seat(game,bot).reserves += 2; seat(game,bot).tanks -= 2;
assert.equal(viewGame(game,bot).revival.freeRemaining,2);
game = runBots(game,1);
assert.equal(seat(game,bot).tanks,0);
assert.equal(seat(game,bot).revived,2);
});
void test('a normal pair in a coalition-enabled game also waits for the human grant', () => {
const {game: initial,bot} = fixture('Easy','a',false); let game = initial;
assert.ok(game.coalitionState);
assert.equal(seat(game,'f').ally,bot);
game = runBots(game,1);
assert.equal(seat(game,bot).revived,0);
assert.ok(!game.ready.includes(bot));
});
void test('two automated group allies both wait for the human decision', () => {
let {game} = fixture('Easy','a');
game = act(game,'h',{type:'setAutopilot',difficulty:'Hard'});
game = runBots(game,12);
for (const id of ['a','h']) {
assert.equal(seat(game,id).revived,0);
assert.equal(seat(game,id).tanks,4);
assert.ok(!game.ready.includes(id));
}
game = act(game,'f',{type:'grantRevival',target:'a'});
game = pass(game);
game = runBots(game,1);
assert.equal(seat(game,'a').revived,3);
assert.equal(seat(game,'h').revived,0);
assert.ok(!game.ready.includes('h'));
});
void test('group grant guidance is visible only to Fremen and does not disclose an ally’s Tanks', () => {
const {game} = fixture('Easy');
const markup = (id:string) => renderToStaticMarkup(createElement(FremenRevivalGrant, {
game:viewGame(game,id),busy:false,act() {},
}));
const before = markup('f');
assert.match(before,/When a grant would help an automated ally, it waits to revive forces/);
assert.match(before,/Grant Atreides free revival/);
assert.doesNotMatch(before,/Tanks|four forces|4 forces/);
seat(game,'a').reserves += 2; seat(game,'a').tanks -= 2;
assert.equal(markup('f'),before);
assert.equal(markup('a'),'');
assert.equal(markup('g'),'');
});