Support staged group Fremen revival grants
Verify and publish checkpoint container / container (push) Canceled after 0s
Verify and publish checkpoint container / container (push) Canceled after 0s
This commit is contained in:
1 parent
9081249574
commit
d8d2e7207b
9 files changed
+277
-15
No files matched your search
@@ -15,6 +15,28 @@ export function FremenRevivalGrant({
|
|||||||
}) {
|
}) {
|
||||||
const grant = game.fremenRevivalGrant;
|
const grant = game.fremenRevivalGrant;
|
||||||
const me = game.players.find((player) => player.id === game.me);
|
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 (
|
||||||
|
<section className="notice space-y-3" aria-label="Fremen group ally free revival">
|
||||||
|
<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>
|
||||||
|
{group.map(option => {
|
||||||
|
const name = game.players.find(player => player.id === option.recipient)?.name ?? 'Ally';
|
||||||
|
return <div key={option.recipient}>
|
||||||
|
{option.granted && <p className="fine">Free revival has been granted to {name}.</p>}
|
||||||
|
{option.blocked && !option.granted && <p className="fine">{name}: {option.blocked}</p>}
|
||||||
|
<Button className="game-action" disabled={busyWindow || option.granted || !!option.blocked}
|
||||||
|
onClick={() => act({type:'grantRevival', target:option.recipient})}>
|
||||||
|
Grant {name} free revival <ArrowRight size={15} />
|
||||||
|
</Button>
|
||||||
|
</div>;
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!grant || game.status !== 'playing' || game.phase !== 4 ||
|
if (!grant || game.status !== 'playing' || game.phase !== 4 ||
|
||||||
me?.faction !== 'fremen' || me.ally !== grant.recipient) return null;
|
me?.faction !== 'fremen' || me.ally !== grant.recipient) return null;
|
||||||
const recipient = game.players.find((player) => player.id === grant.recipient)?.name ?? 'your ally';
|
const recipient = game.players.find((player) => player.id === grant.recipient)?.name ?? 'your ally';
|
||||||
|
|||||||
+15
-1
@@ -17,6 +17,15 @@ open mode gates.
|
|||||||
|
|
||||||
## Current checkpoint and work
|
## 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
|
**Staged group Emperor aid — 28 October. Prototyped / Partial.** An Emperor
|
||||||
can choose either group ally for a spice gift or paid extra revival; the
|
can choose either group ally for a spice gift or paid extra revival; the
|
||||||
selected recipient survives the Karama response. Separate per-ally revival
|
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`.
|
published digest `sha256:2c403b71b24de97188804c874f621af5b72bc6dd807bbcdcca9cbe7ab420029c`.
|
||||||
Run 11 for `4d769af` succeeded and published digest
|
Run 11 for `4d769af` succeeded and published digest
|
||||||
`sha256:766e3b94f9268fc5a089a1a47c2cf031a5c6a25bd789c51f7e3e81f4eeaa528e`.
|
`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.
|
Deployment remains unverified.
|
||||||
Existing NAS deployment and its persistent storage remain untouched.
|
Existing NAS deployment and its persistent storage remain untouched.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
# Fremen allied free revival
|
# 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
|
## Source and selected timing — 24 October 2026
|
||||||
|
|
||||||
The base publisher rulebook physical p16 gives the Fremen ally three free
|
The base publisher rulebook physical p16 gives the Fremen ally three free
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# Rule decision index
|
# 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
|
## Group Emperor aid — 28 October 2026
|
||||||
|
|
||||||
The staged Basic group prototype provisionally gives each ally a separate
|
The staged Basic group prototype provisionally gives each ally a separate
|
||||||
|
|||||||
@@ -3426,6 +3426,9 @@ function policyActions(g: GameView): Action[] {
|
|||||||
}
|
}
|
||||||
if (g.fremenRevivalGrant && !g.fremenRevivalGrant.granted && !g.fremenRevivalGrant.blocked)
|
if (g.fremenRevivalGrant && !g.fremenRevivalGrant.granted && !g.fremenRevivalGrant.blocked)
|
||||||
actions.push({ type: 'grantRevival' });
|
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 emperorGroup = g.coalitionEmperor;
|
||||||
const emperorAllies = me.ally ? [me.ally] : emperorGroup?.allies ?? [];
|
const emperorAllies = me.ally ? [me.ally] : emperorGroup?.allies ?? [];
|
||||||
if (me.faction === 'emperor' && level > 0) for (const allyId of emperorAllies) {
|
if (me.faction === 'emperor' && level > 0) for (const allyId of emperorAllies) {
|
||||||
|
|||||||
+5
-3
@@ -656,7 +656,7 @@ import { quoteEcazPoisonIncome, EcazPoisonIncomeError, type EcazPoisonDiscard }
|
|||||||
import { resolveBattleWeapons } from './effective-weapons';
|
import { resolveBattleWeapons } from './effective-weapons';
|
||||||
import { choamGholaEvent, choamGholaSkillOfferEvent, choamGholaSkillSignature, choamMarketGholaError, choamSaleGholaTiming, type ChoamMarketGhola } from './choam-market-ghola';
|
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 { 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 { splitOrdinaryPayments, type OrdinaryPayment } from './ordinary-payment-plan';
|
||||||
import {
|
import {
|
||||||
charityAmount,
|
charityAmount,
|
||||||
@@ -44722,10 +44722,11 @@ function applyActionInner(
|
|||||||
return g;
|
return g;
|
||||||
}
|
}
|
||||||
if (t === 'grantRevival') {
|
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.');
|
'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.');
|
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,
|
g.response = { kind: 'fremenRevivalGrant', owner: source.owner,
|
||||||
recipient: source.recipient, fremenGrant: source, passed: [] };
|
recipient: source.recipient, fremenGrant: source, passed: [] };
|
||||||
return g;
|
return g;
|
||||||
@@ -46979,6 +46980,7 @@ export function viewGame(state: Game, id: string, now?: number) {
|
|||||||
freeRevival: g.freeRevival ?? [],
|
freeRevival: g.freeRevival ?? [],
|
||||||
recruitsPreview: recruitsPreview(g, me),
|
recruitsPreview: recruitsPreview(g, me),
|
||||||
fremenRevivalGrant: projectFremenRevivalGrant(g, id),
|
fremenRevivalGrant: projectFremenRevivalGrant(g, id),
|
||||||
|
fremenGroupRevivalGrant: projectFremenGroupRevivalGrant(g, id),
|
||||||
advisorPreparation: advisorPreparationView(g, id),
|
advisorPreparation: advisorPreparationView(g, id),
|
||||||
revivalRules: {
|
revivalRules: {
|
||||||
...(g.revivalRules ?? newRevivalRules()),
|
...(g.revivalRules ?? newRevivalRules()),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Game } from './engine';
|
import type { Game } from './engine';
|
||||||
|
import { coalitionOf, CoalitionAllianceError } from './coalition-alliance';
|
||||||
|
|
||||||
export type FremenRevivalGrantSource = {
|
export type FremenRevivalGrantSource = {
|
||||||
turn: number;
|
turn: number;
|
||||||
@@ -9,7 +10,8 @@ export type FremenRevivalGrantSource = {
|
|||||||
export type FremenRevivalGrantContext = Pick<
|
export type FremenRevivalGrantContext = Pick<
|
||||||
Game,
|
Game,
|
||||||
'status' | 'phase' | 'turn' | 'players' | 'freeRevival' | 'revivalRules' | 'recruits'
|
'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 {
|
export class FremenRevivalGrantError extends Error {
|
||||||
constructor(message: string) {
|
constructor(message: string) {
|
||||||
@@ -23,12 +25,30 @@ const id = (value: unknown): value is string =>
|
|||||||
const count = (value: unknown): value is number =>
|
const count = (value: unknown): value is number =>
|
||||||
Number.isSafeInteger(value) && (value as number) >= 0;
|
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
|
/** Permission only. The engine owns the response and commits or denies this
|
||||||
* source once; normal revival quotes retain rates, prior usage and payment.
|
* source once; normal revival quotes retain rates, prior usage and payment.
|
||||||
* No busy gate belongs here because settlement validates its own response. */
|
* No busy gate belongs here because settlement validates its own response. */
|
||||||
export function fremenRevivalGrantBlock(
|
export function fremenRevivalGrantBlock(
|
||||||
g: FremenRevivalGrantContext,
|
g: FremenRevivalGrantContext,
|
||||||
owner: string,
|
owner: string,
|
||||||
|
selectedRecipient?: string,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (g.status !== 'playing' || g.phase !== 4 || !count(g.turn) || g.turn < 1)
|
if (g.status !== 'playing' || g.phase !== 4 || !count(g.turn) || g.turn < 1)
|
||||||
return 'Grant Fremen allied free revival during the current Revival phase.';
|
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' ||
|
if (owners.length !== 1 || fremen.faction !== 'fremen' ||
|
||||||
g.players.filter(player => player.faction === 'fremen').length !== 1)
|
g.players.filter(player => player.faction === 'fremen').length !== 1)
|
||||||
return 'The grant needs the current native Fremen owner.';
|
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];
|
const recipient = recipients[0];
|
||||||
if (!id(fremen.ally) || recipients.length !== 1 ||
|
if (!id(recipientId) || recipients.length !== 1 || recipient.id === owner ||
|
||||||
recipient.id === owner || recipient.ally !== owner)
|
(group ? !group.includes(recipientId) : recipient.ally !== owner ||
|
||||||
|
(selectedRecipient !== undefined && selectedRecipient !== fremen.ally)))
|
||||||
return 'The grant needs the Fremen owner’s current mutual ally.';
|
return 'The grant needs the Fremen owner’s current mutual ally.';
|
||||||
if (!Array.isArray(g.freeRevival))
|
if (!Array.isArray(g.freeRevival))
|
||||||
return 'The current Fremen revival permissions are invalid.';
|
return 'The current Fremen revival permissions are invalid.';
|
||||||
@@ -67,11 +90,12 @@ export function fremenRevivalGrantBlock(
|
|||||||
export function quoteFremenRevivalGrant(
|
export function quoteFremenRevivalGrant(
|
||||||
g: FremenRevivalGrantContext,
|
g: FremenRevivalGrantContext,
|
||||||
owner: string,
|
owner: string,
|
||||||
|
recipient?: string,
|
||||||
): FremenRevivalGrantSource {
|
): FremenRevivalGrantSource {
|
||||||
const blocked = fremenRevivalGrantBlock(g, owner);
|
const blocked = fremenRevivalGrantBlock(g, owner, recipient);
|
||||||
if (blocked) throw new FremenRevivalGrantError(blocked);
|
if (blocked) throw new FremenRevivalGrantError(blocked);
|
||||||
const fremen = g.players.find(player => player.id === owner)!;
|
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.
|
/** Bind the original relationship and turn before either response outcome.
|
||||||
@@ -85,11 +109,26 @@ export function validateFremenRevivalGrant(
|
|||||||
!count(source.turn) || source.turn !== g.turn ||
|
!count(source.turn) || source.turn !== g.turn ||
|
||||||
!id(source.owner) || !id(source.recipient))
|
!id(source.owner) || !id(source.recipient))
|
||||||
throw new FremenRevivalGrantError('The Fremen allied revival grant has lost its original source.');
|
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)
|
if (current.recipient !== source.recipient)
|
||||||
throw new FremenRevivalGrantError('The Fremen allied revival grant no longer matches its original ally.');
|
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
|
/** Owner-only status. No wallet, Tanks, prior counts or quoted free amount is
|
||||||
* projected; the existing normal revival panel owns numerical allowances. */
|
* projected; the existing normal revival panel owns numerical allowances. */
|
||||||
export function projectFremenRevivalGrant(
|
export function projectFremenRevivalGrant(
|
||||||
|
|||||||
+6
-4
@@ -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.",
|
"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.",
|
"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.",
|
"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": [
|
"related": [
|
||||||
"revival",
|
"revival",
|
||||||
@@ -6550,7 +6551,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
{
|
{
|
||||||
"area": "Implementation",
|
"area": "Implementation",
|
||||||
"status": "Partial",
|
"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": [
|
"evidence": [
|
||||||
"game/fremen-revival-grant.ts",
|
"game/fremen-revival-grant.ts",
|
||||||
"game/engine.ts",
|
"game/engine.ts",
|
||||||
@@ -6562,7 +6563,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
{
|
{
|
||||||
"area": "Player controls",
|
"area": "Player controls",
|
||||||
"status": "Partial",
|
"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": [
|
"evidence": [
|
||||||
"components/fremen-revival-grant.tsx",
|
"components/fremen-revival-grant.tsx",
|
||||||
"components/game-table.tsx",
|
"components/game-table.tsx",
|
||||||
@@ -6572,7 +6573,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
{
|
{
|
||||||
"area": "AI",
|
"area": "AI",
|
||||||
"status": "Partial",
|
"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": [
|
"evidence": [
|
||||||
"game/bots.ts",
|
"game/bots.ts",
|
||||||
"tests/recruits-controls.test.tsx",
|
"tests/recruits-controls.test.tsx",
|
||||||
@@ -6594,6 +6595,7 @@ export const RULE_TOPICS: RuleTopic[] = [
|
|||||||
"status": "Partial",
|
"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.",
|
"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": [
|
"evidence": [
|
||||||
|
"tests/coalition-fremen-revival.test.ts",
|
||||||
"tests/fixture-fremen-revival-grant.ts",
|
"tests/fixture-fremen-revival-grant.ts",
|
||||||
"tests/fremen-revival-grant-engine.test.ts",
|
"tests/fremen-revival-grant-engine.test.ts",
|
||||||
"tests/fremen-revival-grant-quote.test.ts",
|
"tests/fremen-revival-grant-quote.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() {},
|
||||||
|
})), '');
|
||||||
|
});
|
||||||
Reference in new issue
Block a user