Coordinate Fremen grants in ordered revival
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
64706cc7e0
commit
220d78c779
11 files changed
+429
-12
No files matched your search
@@ -16,6 +16,9 @@ export function FremenRevivalGrant({
|
||||
const grant = game.fremenRevivalGrant;
|
||||
const me = game.players.find((player) => player.id === game.me);
|
||||
const group = game.fremenGroupRevivalGrant;
|
||||
const orderedAutomatedAlly = (recipient: string) =>
|
||||
game.advanced && !!game.revivalOrder && game.revivalOrder.remaining.includes(recipient) &&
|
||||
game.players.some(player => player.id === recipient && (player.bot || player.autopilot));
|
||||
if (group && game.status === 'playing' && game.phase === 4 && me?.faction === 'fremen') {
|
||||
const busyWindow = busy || !!game.truthtrance || !!game.nexusTraitors?.pending;
|
||||
return (
|
||||
@@ -35,6 +38,18 @@ export function FremenRevivalGrant({
|
||||
onClick={() => act({type:'grantRevival', target:option.recipient})}>
|
||||
Grant {name} free revival <ArrowRight size={15} />
|
||||
</Button>
|
||||
{orderedAutomatedAlly(option.recipient) && !option.granted && !option.blocked && <>
|
||||
<p className="fine">An automated ally may wait for your grant when it would help. You can let it continue without waiting and may still grant later while Revival remains open.</p>
|
||||
<Button data-testid={`fremen-revival-wait-release-${option.recipient}`}
|
||||
className="game-action" disabled={busyWindow || !!option.waitReleased}
|
||||
onClick={() => {
|
||||
if (!busyWindow && !option.waitReleased)
|
||||
act({type:'releaseFremenRevivalWait', target:option.recipient});
|
||||
}}>
|
||||
Continue without waiting <ArrowRight size={15} />
|
||||
</Button>
|
||||
{option.waitReleased && <p className="fine">{name} can continue without waiting for your grant.</p>}
|
||||
</>}
|
||||
</div>;
|
||||
})}
|
||||
</section>
|
||||
@@ -58,6 +73,18 @@ export function FremenRevivalGrant({
|
||||
(automatedAlly?.bot || automatedAlly?.autopilot) && !grant.granted && !grant.blocked && (
|
||||
<p className="fine">When this grant would help your automated ally, it waits to revive forces until you grant it or select Ready for next phase.</p>
|
||||
)}
|
||||
{orderedAutomatedAlly(grant.recipient) && !grant.granted && !grant.blocked && <>
|
||||
<p className="fine">An automated ally may wait for your grant when it would help. You can let it continue without waiting and may still grant later while Revival remains open.</p>
|
||||
<Button data-testid={`fremen-revival-wait-release-${grant.recipient}`}
|
||||
className="game-action" disabled={disabled || !!grant.waitReleased}
|
||||
onClick={() => {
|
||||
if (!disabled && !grant.waitReleased)
|
||||
act({type:'releaseFremenRevivalWait', target:grant.recipient});
|
||||
}}>
|
||||
Continue without waiting <ArrowRight size={15} />
|
||||
</Button>
|
||||
{grant.waitReleased && <p className="fine">{recipient} can continue without waiting for your grant.</p>}
|
||||
</>}
|
||||
<p className="fine">
|
||||
Provisional current-rate policy: Recruits doubles the current granted rate, including applicable bonuses,
|
||||
before caps. Without other bonuses, three becomes six.
|
||||
|
||||
@@ -17,6 +17,19 @@ open mode gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
**Advanced Fremen allied revival scheduling — 29 October. Prototyped /
|
||||
Partial.** During ordered Revival, an automated current ally waits for a useful
|
||||
human Fremen grant. Fremen can release each eligible ally before or during its
|
||||
turn without forfeiting a later grant; ordinary Karama prevention and unrelated
|
||||
actions retain their own timing. Genuine Advanced setups with staged alliances
|
||||
and conserved Tanks cover both relative orders, all four bot profiles, grant,
|
||||
release and prevention. A separately staged group formed through real Nexus
|
||||
actions verifies independent releases; controls do not expose Tanks. Ten
|
||||
related files pass 95 cases, including 18 new cases; type checking, lint and
|
||||
the production build pass. The priority and release are provisional online
|
||||
conventions; broader interactions and deployed acceptance remain open.
|
||||
[Scope](FREMEN_REVIVAL_GRANT.md#human-fremen-and-ordered-automated-allies--29-october-2026).
|
||||
|
||||
**Advanced Mentat victory timing — 29 October. Prototyped / Partial.**
|
||||
Fresh Advanced non-CHOAM phases now check victory after players finish their
|
||||
Mentat opportunities, so a real Mentat action can precede the result. The
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# Fremen allied free revival
|
||||
|
||||
## Human Fremen and ordered automated allies — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** In an ordered Advanced Revival, an automated ally
|
||||
whose current turn would benefit from an available human Fremen grant waits
|
||||
before its normal force return or Ready. Fremen can grant through the existing
|
||||
Karama response or release that ally to continue without waiting. The release
|
||||
may be given before the ally's turn and does not prevent a later grant while
|
||||
the opportunity remains. It is recorded per recipient for this turn; a new
|
||||
Revival clears it. Fremen may act before or after its own ordered opportunity.
|
||||
The control exposes no allied Tanks or rate. An unavailable or prevented grant,
|
||||
no useful benefit, and automated Fremen do not hold the ally. Other bot actions
|
||||
and responses retain their own timing.
|
||||
|
||||
The user-authorized revised rules, physical p. 32 (printed p. 30), permit
|
||||
Fremen to increase an ally's free revival to three; physical p. 9 (printed
|
||||
p. 7) orders force revival. Neither prescribes online bot priority or a release
|
||||
action, so this is a provisional scheduling convention. The supported classic
|
||||
group preview retains independent recipients. Full faction interactions,
|
||||
strategic AI calibration and deployed acceptance remain open.
|
||||
|
||||
## Human Fremen and automated group allies — 29 October 2026
|
||||
|
||||
**Prototyped / Partial.** In concurrent Basic Revival, each automated ally in
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# Rule decision index
|
||||
|
||||
## Ordered Fremen allied revival and automated seats — 29 October 2026
|
||||
|
||||
The authorized revision, physical pp. 9 and 32, gives ordered Force Revival
|
||||
and an optional Fremen allied free-revival benefit. A useful human Fremen grant
|
||||
now provisionally holds an automated current ally's force return and Ready;
|
||||
Fremen may release each eligible ally without losing the ability to grant later.
|
||||
This priority and its release control are online conventions, not printed
|
||||
timing. [Scope](FREMEN_REVIVAL_GRANT.md#human-fremen-and-ordered-automated-allies--29-october-2026).
|
||||
|
||||
## Advanced Mentat victory timing — 29 October 2026
|
||||
|
||||
The supplied revision, physical p. 14 (printed p. 12), places victory during
|
||||
|
||||
+10
-5
@@ -4665,14 +4665,19 @@ export function botActions(g: GameView): Action[] {
|
||||
: [{ type: 'endMovement' }];
|
||||
}
|
||||
|
||||
/** In concurrent Basic Revival, let a human Fremen decide its existing grant
|
||||
* before its automated ally spends or finishes. Ready is the normal way to
|
||||
* finish that decision; responses and unrelated actions remain available. */
|
||||
/** Let a human Fremen decide a useful grant before an automated ally spends or
|
||||
* finishes. Ordered Advanced uses a per-recipient release; concurrent Basic
|
||||
* retains its normal Ready release. Responses and unrelated actions continue. */
|
||||
function waitsForHumanFremenGrant(g: Game, player: Game['players'][number]): boolean {
|
||||
if (g.status !== 'playing' || g.advanced || g.phase !== 4 || g.revivalOrder ||
|
||||
const ordered = g.advanced && !!g.revivalOrder;
|
||||
if (g.status !== 'playing' || g.phase !== 4 || (g.advanced && !ordered) ||
|
||||
(!g.advanced && !!g.revivalOrder) ||
|
||||
(ordered && g.revivalOrder!.remaining[0] !== player.id) ||
|
||||
player.tanks <= 0 || revivalPrevented(g, player.id)) return false;
|
||||
const fremen = g.players.find(seat => seat.faction === 'fremen');
|
||||
if (!fremen || fremen.bot || fremen.autopilot || g.ready.includes(fremen.id) ||
|
||||
if (!fremen || fremen.bot || fremen.autopilot || (!ordered && g.ready.includes(fremen.id)) ||
|
||||
(ordered && g.fremenRevivalWaitRelease?.turn === g.turn &&
|
||||
g.fremenRevivalWaitRelease.recipients.includes(player.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);
|
||||
|
||||
@@ -1501,6 +1501,8 @@ export type Game = {
|
||||
mentatVictoryAfterActions?: number;
|
||||
charityPromises?: CharityPromise[];
|
||||
revivalOrder?: RevivalOrder;
|
||||
/** Owner's per-ally release of the provisional ordered bot wait. */
|
||||
fremenRevivalWaitRelease?: { turn: number; recipients: string[] };
|
||||
charityOrder?: CharityOrder;
|
||||
pendingAutomaticAdvisorFlips?: AutomaticAdvisorFlipSource[];
|
||||
pendingAutomaticAdvisorTransport?: AutomaticAdvisorTransport;
|
||||
@@ -30531,6 +30533,7 @@ function beginPhase(g: Game) {
|
||||
}
|
||||
}
|
||||
if (g.phase === 4) {
|
||||
delete g.fremenRevivalWaitRelease;
|
||||
g.revivalRules = newRevivalRules();
|
||||
g.revivalRequests = {};
|
||||
g.freeRevival = [];
|
||||
@@ -46752,6 +46755,23 @@ function applyActionInner(
|
||||
};
|
||||
return g;
|
||||
}
|
||||
if (t === 'releaseFremenRevivalWait') {
|
||||
requireRule(Object.keys(action).sort().join(',') === 'target,type' &&
|
||||
g.phase === 4 && g.advanced && g.revivalOrder && p.faction === 'fremen',
|
||||
'Release an automated ally only during ordered Advanced Revival.');
|
||||
requireRule(!recruitsInteractionPending(g), 'Finish the current revival transaction or priority window first.');
|
||||
const target = stringField(action.target);
|
||||
const recipient = getPlayer(g, target);
|
||||
requireRule(!!(recipient.bot || recipient.autopilot) && g.revivalOrder.remaining.includes(target),
|
||||
'Choose an automated ally whose Revival turn remains.');
|
||||
fremenRevivalGrantRule(() => quoteFremenRevivalGrant(g, id, target));
|
||||
const released = g.fremenRevivalWaitRelease?.turn === g.turn
|
||||
? g.fremenRevivalWaitRelease.recipients : [];
|
||||
requireRule(!released.includes(target), 'This ally has already been released from the wait.');
|
||||
g.fremenRevivalWaitRelease = {turn: g.turn, recipients: [...released, target]};
|
||||
log(g, `${p.name} released ${recipient.name} to continue Revival without waiting for a Fremen grant.`);
|
||||
return g;
|
||||
}
|
||||
if (t === 'grantRevival') {
|
||||
requireRule(g.phase === 4 && p.faction === 'fremen',
|
||||
'Only Fremen may grant their ally free revival during revival.');
|
||||
|
||||
@@ -11,7 +11,8 @@ 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'>>;
|
||||
'nexusCards' | 'leaderSkills' | 'techTokens' | 'strongholdCards' | 'ecazTreachery' | 'discoveryEnabled' |
|
||||
'fremenRevivalWaitRelease'>>;
|
||||
|
||||
export class FremenRevivalGrantError extends Error {
|
||||
constructor(message: string) {
|
||||
@@ -118,7 +119,7 @@ export function validateFremenRevivalGrant(
|
||||
export function projectFremenGroupRevivalGrant(
|
||||
g: FremenRevivalGrantContext,
|
||||
viewer: string,
|
||||
): Array<{recipient: string; granted: boolean; blocked: string | null}> | null {
|
||||
): Array<{recipient: string; granted: boolean; blocked: string | null; waitReleased?: boolean}> | null {
|
||||
if (!id(viewer) || !Array.isArray(g.players) ||
|
||||
g.players.filter(player => player.id === viewer && player.faction === 'fremen').length !== 1)
|
||||
return null;
|
||||
@@ -126,7 +127,9 @@ export function projectFremenGroupRevivalGrant(
|
||||
if (!recipients) return null;
|
||||
return recipients.map(recipient => ({recipient,
|
||||
granted: Array.isArray(g.freeRevival) && g.freeRevival.includes(recipient),
|
||||
blocked: fremenRevivalGrantBlock(g, viewer, recipient)}));
|
||||
blocked: fremenRevivalGrantBlock(g, viewer, recipient),
|
||||
...(g.fremenRevivalWaitRelease?.turn === g.turn &&
|
||||
g.fremenRevivalWaitRelease.recipients.includes(recipient) ? {waitReleased: true} : {})}));
|
||||
}
|
||||
|
||||
/** Owner-only status. No wallet, Tanks, prior counts or quoted free amount is
|
||||
@@ -134,7 +137,7 @@ export function projectFremenGroupRevivalGrant(
|
||||
export function projectFremenRevivalGrant(
|
||||
g: FremenRevivalGrantContext,
|
||||
viewer: string,
|
||||
): { recipient: string; granted: boolean; blocked: string | null } | null {
|
||||
): { recipient: string; granted: boolean; blocked: string | null; waitReleased?: boolean } | null {
|
||||
if (!id(viewer) || !Array.isArray(g.players)) return null;
|
||||
const owners = g.players.filter(player => player.id === viewer);
|
||||
const owner = owners[0];
|
||||
@@ -146,5 +149,7 @@ export function projectFremenRevivalGrant(
|
||||
recipient: owner.ally,
|
||||
granted: Array.isArray(g.freeRevival) && g.freeRevival.includes(owner.ally),
|
||||
blocked: fremenRevivalGrantBlock(g, viewer),
|
||||
...(g.fremenRevivalWaitRelease?.turn === g.turn &&
|
||||
g.fremenRevivalWaitRelease.recipients.includes(owner.ally) ? {waitReleased: true} : {}),
|
||||
};
|
||||
}
|
||||
+6
-3
@@ -6566,6 +6566,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.",
|
||||
"During ordered Advanced Revival, an automated ally whose turn would benefit may wait for a human Fremen's grant. Fremen can release that ally to continue without waiting, before or during its turn, and may still grant later if possible. Each eligible ally has a separate release; the choice reveals no Tanks. This is a provisional online priority.",
|
||||
"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": [
|
||||
@@ -6590,20 +6591,22 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
{
|
||||
"area": "Player controls",
|
||||
"status": "Partial",
|
||||
"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.",
|
||||
"detail": "Actual pair and staged group grant callbacks, native response and persistent status/policy guidance are integrated. Ordered automated allies have a separate per-recipient release. 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",
|
||||
"tests/fremen-revival-grant-controls.test.tsx"
|
||||
"tests/fremen-revival-grant-controls.test.tsx",
|
||||
"tests/fremen-ordered-revival-controls.test.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"area": "AI",
|
||||
"status": "Partial",
|
||||
"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.",
|
||||
"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. Ordered Advanced allies use a recipient-specific release. Strategy and strength calibration remain deferred.",
|
||||
"evidence": [
|
||||
"game/bots.ts",
|
||||
"tests/fremen-revival-grant-bot-priority.test.ts",
|
||||
"tests/fremen-ordered-revival-bot-priority.test.ts",
|
||||
"tests/coalition-fremen-bot-grant.test.ts",
|
||||
"tests/recruits-controls.test.tsx",
|
||||
"docs/FREMEN_REVIVAL_GRANT.md"
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createGame, joinGame, newPlayer, viewGame, type Game } from '../game/engine';
|
||||
import { botActions, runBots } from '../game/bots';
|
||||
import { DIFFICULTIES, type Difficulty } from '../game/bot-profiles';
|
||||
import {
|
||||
actFremenRevivalGrant as act,
|
||||
advanceFremenRevivalGrant as advance,
|
||||
fremenRevivalGrantClean as clean,
|
||||
fremenRevivalGrantPlayer as player,
|
||||
passFremenRevivalGrantResponses as pass,
|
||||
} from './fixture-fremen-revival-grant';
|
||||
|
||||
/** Actual Advanced public setup and first ordered Revival. Reversing the two
|
||||
* original lobby seats changes the real storm order. At Revival only, the
|
||||
* reciprocal prior alliance and three ordinary Atreides casualties are staged;
|
||||
* the counters move from his reserves into Tanks, without inventing supply.
|
||||
* This controlled alliance is not a claim about Nexus history or full games. */
|
||||
function source(fremenFirstSeat: boolean) {
|
||||
const first = fremenFirstSeat ? newPlayer('fremen', 'Fremen', 'fremen')
|
||||
: newPlayer('atreides', 'Atreides', 'atreides');
|
||||
const second = fremenFirstSeat ? newPlayer('atreides', 'Atreides', 'atreides')
|
||||
: newPlayer('fremen', 'Fremen', 'fremen');
|
||||
let game = createGame('FREMENORDEREDBOT', first, true);
|
||||
joinGame(game, second);
|
||||
joinGame(game, newPlayer('emperor', 'Emperor', 'emperor'));
|
||||
for (const p of game.players) game = act(game, p.id, { type: 'ready' });
|
||||
game = act(game, game.host, { type: 'start', advancedPreview: true });
|
||||
game = advance(game, state => state.status === 'playing');
|
||||
// Select only two original unopened ordinary blows, avoiding incidental worms.
|
||||
for (let position = 0; position < 2; position++) {
|
||||
const index = game.spiceDeck.findIndex((card, i) => i >= position && 'territory' in card && !card.discovery);
|
||||
assert.ok(index >= position);
|
||||
game.spiceDeck.splice(position, 0, game.spiceDeck.splice(index, 1)[0]);
|
||||
}
|
||||
game = advance(game, state => state.phase === 4 && clean(state));
|
||||
assert.ok(game.revivalOrder);
|
||||
const ally = player(game, 'atreides'), fremen = player(game, 'fremen');
|
||||
fremen.ally = ally.id; ally.ally = fremen.id;
|
||||
assert.ok(ally.reserves >= 3);
|
||||
ally.reserves -= 3; ally.tanks += 3;
|
||||
return game;
|
||||
}
|
||||
|
||||
let cached: { earlier: Game; later: Game } | undefined;
|
||||
function sources() {
|
||||
if (cached) return cached;
|
||||
const options = [source(true), source(false)];
|
||||
const earlier = options.find(g => g.order.indexOf('fremen') < g.order.indexOf('atreides'));
|
||||
const later = options.find(g => g.order.indexOf('fremen') > g.order.indexOf('atreides'));
|
||||
assert.ok(earlier && later, 'Opposite real seat assignments must produce both relative Revival orders');
|
||||
cached = { earlier, later }; return cached;
|
||||
}
|
||||
function awaitingAlly(order: 'earlier' | 'later', level: Difficulty = 'Easy') {
|
||||
let game = structuredClone(sources()[order]);
|
||||
while (game.revivalOrder?.remaining[0] !== 'atreides') {
|
||||
const actor = game.revivalOrder?.remaining[0]; assert.ok(actor);
|
||||
game = act(game, actor, { type: 'ready' });
|
||||
}
|
||||
assert.ok(game.revivalOrder);
|
||||
assert.equal(game.revivalOrder.remaining[0], 'atreides');
|
||||
assert.equal(game.revivalOrder.completed.includes('fremen'), order === 'earlier');
|
||||
game = act(game, 'atreides', { type: 'setAutopilot', difficulty: level });
|
||||
return game;
|
||||
}
|
||||
function swapKaramaToObserver(game: Game) {
|
||||
const observer = player(game, 'emperor');
|
||||
if (observer.hand.some(card => card.effect === 'karama')) return observer.hand.find(card => card.effect === 'karama')!.id;
|
||||
const held = observer.hand[0]; assert.ok(held);
|
||||
const pools = [game.deck, game.discard, ...game.players.filter(p => p.id !== observer.id).map(p => p.hand)];
|
||||
const pool = pools.find(cards => cards.some(card => card.effect === 'karama')); assert.ok(pool);
|
||||
const index = pool.findIndex(card => card.effect === 'karama');
|
||||
const card = pool[index]; pool[index] = held; observer.hand[0] = card;
|
||||
return card.id;
|
||||
}
|
||||
|
||||
for (const order of ['earlier', 'later'] as const) for (const level of DIFFICULTIES) void test(
|
||||
`Advanced ${level} allied bot waits for human Fremen ${order} in ordered Revival and uses a real grant`, () => {
|
||||
let game = awaitingAlly(order, level);
|
||||
const before = structuredClone(game.revivalOrder), ally = player(game, 'atreides');
|
||||
const tanks = ally.tanks, spice = ally.spice;
|
||||
game = runBots(game, 1);
|
||||
assert.deepEqual(game.revivalOrder, before);
|
||||
assert.equal(player(game, 'atreides').revived, 0);
|
||||
assert.equal(player(game, 'atreides').tanks, tanks);
|
||||
game = act(game, 'fremen', { type: 'grantRevival' });
|
||||
if (game.response) game = pass(game);
|
||||
assert.equal(viewGame(game, 'atreides').revival.freeRemaining, 3);
|
||||
const offered = botActions(viewGame(game, 'atreides')).find(action => action.type === 'revive' && action.amount === 3);
|
||||
assert.ok(offered);
|
||||
game = runBots(game, 1);
|
||||
assert.equal(player(game, 'atreides').tanks, tanks - 3);
|
||||
assert.equal(player(game, 'atreides').spice, spice);
|
||||
assert.equal(player(game, 'atreides').freeForcesRevived, 3);
|
||||
},
|
||||
);
|
||||
|
||||
for (const order of ['earlier', 'later'] as const) void test(
|
||||
`Fremen ${order} in the ordered queue can release an automated ally without forfeiting a later grant`, () => {
|
||||
let game = awaitingAlly(order);
|
||||
const before = structuredClone(game.revivalOrder);
|
||||
assert.ok(!viewGame(game, 'fremen').fremenRevivalGrant?.waitReleased);
|
||||
game = act(game, 'fremen', { type: 'releaseFremenRevivalWait', target: 'atreides' });
|
||||
assert.deepEqual(game.revivalOrder, before);
|
||||
assert.equal(viewGame(game, 'fremen').fremenRevivalGrant?.waitReleased, true);
|
||||
assert.equal(viewGame(game, 'fremen').fremenRevivalGrant?.blocked, null);
|
||||
assert.equal(game.freeRevival.includes('atreides'), false);
|
||||
let granted = act(game, 'fremen', {type:'grantRevival'});
|
||||
if (granted.response) granted = pass(granted);
|
||||
assert.equal(granted.freeRevival.includes('atreides'), true);
|
||||
const resumed = runBots(game, 1);
|
||||
assert.ok(player(resumed, 'atreides').revived > 0 || resumed.ready.includes('atreides'));
|
||||
},
|
||||
);
|
||||
|
||||
void test('Fremen can release a future ally, while other seats and stale releases are rejected', () => {
|
||||
let game = structuredClone(sources().earlier);
|
||||
game = act(game, 'atreides', {type:'setAutopilot', difficulty:'Easy'});
|
||||
assert.ok(game.revivalOrder?.remaining.includes('atreides'));
|
||||
assert.notEqual(game.revivalOrder?.remaining[0], 'atreides');
|
||||
const before = structuredClone(game);
|
||||
assert.throws(() => act(game, 'emperor', {type:'releaseFremenRevivalWait', target:'atreides'}));
|
||||
assert.deepEqual(game, before);
|
||||
game = act(game, 'fremen', {type:'releaseFremenRevivalWait', target:'atreides'});
|
||||
assert.equal(viewGame(game, 'fremen').fremenRevivalGrant?.waitReleased, true);
|
||||
assert.equal(viewGame(game, 'emperor').fremenRevivalGrant, null);
|
||||
const released = structuredClone(game);
|
||||
assert.throws(() => act(game, 'fremen', {type:'releaseFremenRevivalWait', target:'atreides'}));
|
||||
assert.deepEqual(game, released);
|
||||
});
|
||||
|
||||
void test('real Karama prevention releases the Advanced bot with its intrinsic free rate', () => {
|
||||
let game = awaitingAlly('later', 'Medium');
|
||||
// Conserved exchange of original Treachery cards gives Emperor a counter.
|
||||
const karama = swapKaramaToObserver(game);
|
||||
game = act(game, 'fremen', { type: 'grantRevival' });
|
||||
assert.equal(game.response?.kind, 'fremenRevivalGrant');
|
||||
game = act(game, 'emperor', { type: 'card', card: karama, mode: 'cancel' });
|
||||
assert.ok(game.revivalRules?.fremenGrantBlocked?.includes('atreides'));
|
||||
assert.equal(viewGame(game, 'atreides').revival.freeRemaining, 2);
|
||||
const resumed = runBots(game, 1);
|
||||
assert.ok(player(resumed, 'atreides').revived > 0 || resumed.ready.includes('atreides'));
|
||||
});
|
||||
|
||||
void test('Advanced bots do not wait for an unnecessary grant or an automated Fremen', () => {
|
||||
for (const variant of ['two-tanks', 'automated-fremen'] as const) {
|
||||
let game = awaitingAlly('later');
|
||||
if (variant === 'two-tanks') {
|
||||
// Return one ordinary counter from Tanks to its original reserve supply.
|
||||
player(game, 'atreides').tanks--; player(game, 'atreides').reserves++;
|
||||
} else game = act(game, 'fremen', { type: 'setAutopilot', difficulty: 'Easy' });
|
||||
game = runBots(game, 1);
|
||||
assert.ok(player(game, 'atreides').revived > 0 || game.ready.includes('atreides'));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { Children, createElement, isValidElement, type ReactNode } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { FremenRevivalGrant } from '../components/fremen-revival-grant';
|
||||
import { createGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
|
||||
|
||||
/** Projection-only control fixtures; engine tests own the actual alliance and
|
||||
* ordered Revival transitions. The ally's private revival inventory is not read. */
|
||||
function projected(group = false): GameView {
|
||||
const game = createGame('ORDEREDGRANTCONTROLS', newPlayer('f', 'Fremen', 'fremen'), true);
|
||||
game.players.push(newPlayer('a', 'Ally', 'atreides'));
|
||||
game.players.push(newPlayer('e', 'Emperor', 'emperor'));
|
||||
Object.assign(game, { status: 'playing', phase: 4, turn: 3, order: ['f', 'e', 'a'] });
|
||||
game.players[0].ally = 'a';
|
||||
game.players[1].ally = 'f';
|
||||
const view = viewGame(game, 'f');
|
||||
view.players.find(player => player.id === 'a')!.autopilot = 'Easy';
|
||||
view.players.find(player => player.id === 'e')!.bot = 'Medium';
|
||||
view.revivalOrder = { version: 1, turn: 3, original: ['f', 'e', 'a'],
|
||||
remaining: ['e', 'a'], completed: ['f'], started: false };
|
||||
view.fremenRevivalGrant = { recipient: 'a', granted: false, blocked: null, waitReleased: false };
|
||||
if (group) view.fremenGroupRevivalGrant = [
|
||||
{ recipient: 'a', granted: false, blocked: null, waitReleased: false },
|
||||
{ recipient: 'e', granted: false, blocked: null, waitReleased: false },
|
||||
];
|
||||
return view;
|
||||
}
|
||||
function markup(game: GameView, busy = false) {
|
||||
return renderToStaticMarkup(createElement(FremenRevivalGrant, { game, busy, act() {} }));
|
||||
}
|
||||
function click(node: ReactNode, target: string): boolean {
|
||||
for (const child of Children.toArray(node)) {
|
||||
if (!isValidElement<{ children?: ReactNode; onClick?: () => void; 'data-testid'?: string }>(child)) continue;
|
||||
if (child.props['data-testid'] === `fremen-revival-wait-release-${target}` && child.props.onClick) {
|
||||
child.props.onClick();
|
||||
return true;
|
||||
}
|
||||
if (click(child.props.children, target)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function release(game: GameView, target: string, busy = false): Action[] {
|
||||
const actions: Action[] = [];
|
||||
click(FremenRevivalGrant({ game, busy, act(action) { actions.push(action); } }), target);
|
||||
return actions;
|
||||
}
|
||||
|
||||
void test('Fremen can release a later automated ally before its ordered Revival turn', () => {
|
||||
const game = projected();
|
||||
assert.match(markup(game), /Continue without waiting/);
|
||||
assert.match(markup(game), /may still grant later while Revival remains open/);
|
||||
assert.deepEqual(release(game, 'a'), [{type:'releaseFremenRevivalWait', target:'a'}]);
|
||||
});
|
||||
|
||||
void test('release is disabled during a busy window or after release without hiding grant', () => {
|
||||
const game = projected();
|
||||
assert.deepEqual(release(game, 'a', true), []);
|
||||
game.fremenRevivalGrant!.waitReleased = true;
|
||||
assert.match(markup(game), /Ally can continue without waiting for your grant/);
|
||||
assert.match(markup(game), /Grant ally free revival/);
|
||||
assert.deepEqual(release(game, 'a'), []);
|
||||
game.truthtrance = {} as NonNullable<GameView['truthtrance']>;
|
||||
game.fremenRevivalGrant!.waitReleased = false;
|
||||
assert.deepEqual(release(game, 'a'), []);
|
||||
});
|
||||
|
||||
void test('group controls release each recipient independently without reading private forces', () => {
|
||||
const game = projected(true);
|
||||
for (const ally of game.players.filter(player => player.id !== 'f')) {
|
||||
for (const field of ['spice', 'tanks', 'deadForces', 'revived', 'freeForcesRevived'])
|
||||
Object.defineProperty(ally, field, {get() { throw new Error(`Read private ${field}`); }});
|
||||
}
|
||||
assert.deepEqual(release(game, 'a'), [{type:'releaseFremenRevivalWait', target:'a'}]);
|
||||
assert.deepEqual(release(game, 'e'), [{type:'releaseFremenRevivalWait', target:'e'}]);
|
||||
game.fremenGroupRevivalGrant![0].waitReleased = true;
|
||||
assert.deepEqual(release(game, 'a'), []);
|
||||
assert.deepEqual(release(game, 'e'), [{type:'releaseFremenRevivalWait', target:'e'}]);
|
||||
});
|
||||
|
||||
void test('release is absent for human, finished, blocked or nonordered allies', () => {
|
||||
for (const condition of ['human', 'finished', 'blocked', 'basic'] as const) {
|
||||
const game = projected();
|
||||
if (condition === 'human') game.players.find(player => player.id === 'a')!.autopilot = undefined;
|
||||
if (condition === 'finished') game.revivalOrder!.remaining = ['e'];
|
||||
if (condition === 'blocked') game.fremenRevivalGrant!.blocked = 'Grant prevented.';
|
||||
if (condition === 'basic') game.advanced = false;
|
||||
assert.doesNotMatch(markup(game), /Continue without waiting/, condition);
|
||||
assert.deepEqual(release(game, 'a'), [], condition);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { runBots } from '../game/bots';
|
||||
import { coalitionOf, emptyCoalitionState } from '../game/coalition-alliance';
|
||||
import { applyAction, createGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
|
||||
import { createRevivalOrder } from '../game/revival-order';
|
||||
|
||||
const act = (game: Game, actor: string, action: Action) => applyAction(game, actor, action);
|
||||
const seat = (game: Game, id: string) => {
|
||||
const found = game.players.find(player => player.id === id); assert.ok(found); return found;
|
||||
};
|
||||
|
||||
/** Advanced group is built by genuine pair and group Nexus actions. The Nexus
|
||||
* itself, original public order and subsequent Revival phase are staged, not a
|
||||
* native full game. Atreides and Harkonnen each move four of their own ordinary
|
||||
* reserves to Tanks; the queue is made with the production order helper, and
|
||||
* Fremen actually finishes his opportunity before the first ally acts. */
|
||||
function fixture() {
|
||||
let game = createGame('ORDEREDGROUPREVIVAL', newPlayer('f', 'Fremen', 'fremen'), true);
|
||||
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'});
|
||||
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; assert.ok(proposal);
|
||||
game = act(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,
|
||||
active: 'f', phaseOpening: 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 -= 4; seat(game, id).tanks += 4;}
|
||||
game.revivalOrder = createRevivalOrder({turn: game.turn, order: game.order, ready: game.ready});
|
||||
game = act(game, 'f', {type: 'ready'});
|
||||
game = act(game, 'a', {type: 'setAutopilot', difficulty: 'Easy'});
|
||||
game = act(game, 'h', {type: 'setAutopilot', difficulty: 'Hard'});
|
||||
assert.equal(game.revivalOrder?.remaining[0], 'a');
|
||||
return game;
|
||||
}
|
||||
|
||||
void test('Advanced group release is recipient-specific across consecutive automated Revival turns', () => {
|
||||
let game = fixture();
|
||||
const original = structuredClone(game.revivalOrder);
|
||||
game = runBots(game, 12);
|
||||
assert.deepEqual(game.revivalOrder, original);
|
||||
assert.equal(seat(game, 'a').revived, 0); assert.equal(seat(game, 'h').revived, 0);
|
||||
game = act(game, 'f', {type: 'releaseFremenRevivalWait', target: 'a'});
|
||||
const projection = viewGame(game, 'f').fremenGroupRevivalGrant;
|
||||
assert.equal(projection?.find(row => row.recipient === 'a')?.waitReleased, true);
|
||||
assert.ok(!projection?.find(row => row.recipient === 'h')?.waitReleased);
|
||||
game = runBots(game, 30);
|
||||
assert.ok(seat(game, 'a').revived > 0);
|
||||
assert.ok(game.revivalOrder?.completed.includes('a'));
|
||||
assert.equal(game.revivalOrder?.remaining[0], 'h');
|
||||
assert.equal(seat(game, 'h').revived, 0); assert.equal(seat(game, 'h').tanks, 4);
|
||||
assert.equal(game.freeRevival.includes('a'), false); assert.equal(game.freeRevival.includes('h'), false);
|
||||
});
|
||||
Reference in new issue
Block a user