Support staged group Fremen revival grants
Verify and publish checkpoint container / container (push) Canceled after 0s

This commit is contained in:
Quintine committed 2026-10-28 23:37:46 +11:00
1 parent 9081249574
commit d8d2e7207b
9 files changed
+277 -15

No files matched your search

+3
View File
@@ -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) {
+5 -3
View File
@@ -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()),
+46 -7
View File
@@ -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<Pick<Game, 'coalitionState' | 'advanced' | 'expansions' | 'homeworlds' |
'nexusCards' | 'leaderSkills' | 'techTokens' | 'strongholdCards' | 'ecazTreachery' | 'discoveryEnabled'>>;
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(
+6 -4
View File
@@ -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",