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 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 ||
|
||||
me?.faction !== 'fremen' || me.ally !== grant.recipient) return null;
|
||||
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
|
||||
|
||||
**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
|
||||
can choose either group ally for a spice gift or paid extra revival; the
|
||||
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`.
|
||||
Run 11 for `4d769af` succeeded and published digest
|
||||
`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.
|
||||
Existing NAS deployment and its persistent storage remain untouched.
|
||||
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
# 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
|
||||
|
||||
The base publisher rulebook physical p16 gives the Fremen ally three free
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 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
|
||||
|
||||
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)
|
||||
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
@@ -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()),
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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