Apply player order to fresh Advanced Revival phases

This commit is contained in:
Quintine committed 2026-10-26 10:33:54 +11:00
1 parent fe6c41ba40
commit 011b6aa096
20 files changed
+1212 -46

No files matched your search

+7 -4
View File
@@ -172,6 +172,7 @@ import { IxHomeworldRevivalBonus } from './ix-homeworld-revival';
import { controlsLeader } from '@/game/leader-control';
import { leaderStrengthLabel, harvesterAvailable } from '@/game/cards';
import { RevivalCommerce } from './revival-commerce';
import { RevivalTurn } from './revival-turn';
import { FaceDanceDecision } from './face-dance-decision';
import { CHEAP_HERO_TRAITOR, matchingTraitor } from '@/game/traitors';
import {
@@ -4374,6 +4375,7 @@ export function GameTable({
)}
{g.phase === 4 && (
<>
<RevivalTurn game={g} act={act} busy={busy} />
{g.revival.prevented && (
<p className="notice">
Tleilaxu prevented your force and leader revivals for
@@ -4501,6 +4503,7 @@ export function GameTable({
elite: ally.elites ? allyEliteAmount : 0,
},
cost > (me.spice ?? 0) ||
(!!g.revivalOrder && g.revivalOrder.remaining[0] !== ally.id) ||
amount > 3 - (g.emperorExtra[ally.id] ?? 0),
)}
</div>
@@ -4512,7 +4515,7 @@ export function GameTable({
{
type: 'reviveKwisatz',
},
!g.revival.kwisatz.affordable,
!!g.revival.orderBlocked || !g.revival.kwisatz.affordable,
)}
{(g.emperorExtra[me.id] ?? 0) > 0 && (
<p className="fine">
@@ -4535,7 +4538,7 @@ export function GameTable({
)
: undefined,
},
g.revival.prevented ||
!!g.revival.orderBlocked || g.revival.prevented ||
!me.tanks ||
eliteAmount > g.revival.eliteRemaining ||
amount > g.revival.forcesRemaining ||
@@ -4620,7 +4623,7 @@ export function GameTable({
type: 'reviveLeader',
leader: selectedRevival?.id ?? '',
},
!selectedRevival?.affordable,
!!g.revival.orderBlocked || !selectedRevival?.affordable,
)}
</>
);
@@ -5778,7 +5781,7 @@ export function GameTable({
)}
</>
)}
{[1, 2, 4, 7, 8].includes(g.phase) &&
{[1, 2, 4, 7, 8].includes(g.phase) && !(g.phase === 4 && g.revivalOrder) &&
actionButton(
g.ready.includes(me.id)
? 'Waiting for the table'
+40 -28
View File
@@ -4,6 +4,7 @@ import { useState } from 'react';
import type { Action, GameView } from '@/game/engine';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { revivalTurnOrderBlock } from './revival-turn';
export function RevivalCommerce({
game: g,
act,
@@ -18,6 +19,7 @@ export function RevivalCommerce({
const [selection, setSelection] = useState('');
const [prices, setPrices] = useState<Record<string, number>>({});
if (!tleilaxu) return null;
const orderBlocked = revivalTurnOrderBlock(g);
const name = (id: string) =>
id === 'kwisatz'
? 'Kwisatz Haderach'
@@ -43,6 +45,7 @@ export function RevivalCommerce({
Your native skilled leader keeps its own assignment.
</p>
)}
{orderBlocked && <p className="fine">{orderBlocked}</p>}
{g.players
.filter((p) => p.id !== me.id)
.flatMap((p) => p.leaders)
@@ -57,21 +60,18 @@ export function RevivalCommerce({
const cost = g.revival.discount
? Math.ceil(l.strength / 2)
: l.strength;
const unavailable = busy || !!orderBlocked ||
cost > (me.spice ?? 0) || me.gholaBlocked?.includes(l.id) ||
me.leaders.filter(leader => !leader.dead && controlsLeader(me, leader)).length >= 5;
return (
<Button
key={l.id}
variant="outline"
disabled={
busy ||
cost > (me.spice ?? 0) ||
me.gholaBlocked?.includes(l.id) ||
me.leaders.filter(
(leader) => !leader.dead && controlsLeader(me, leader),
).length >= 5
}
onClick={() =>
act({ type: 'reviveForeignGhola', leader: l.id })
}
disabled={unavailable}
onClick={() => {
if (!unavailable && !revivalTurnOrderBlock(g))
act({ type: 'reviveForeignGhola', leader: l.id });
}}
>
Revive {l.name} as a ghola · {cost} spice
</Button>
@@ -93,9 +93,10 @@ export function RevivalCommerce({
g.revivalRules.limitBlocked ||
g.revivalRules.expanded.includes(p.id)
}
onClick={() =>
act({ type: 'tleilaxuRevivalLimit', target: p.id })
}
onClick={() => {
if (!busy && !g.revivalRules.limitBlocked && !g.revivalRules.expanded.includes(p.id))
act({ type: 'tleilaxuRevivalLimit', target: p.id });
}}
>
Allow five revivals
</Button>
@@ -108,7 +109,10 @@ export function RevivalCommerce({
g.revivalRules.discountBlocked ||
g.revivalRules.allyDiscount === me.ally
}
onClick={() => act({ type: 'tleilaxuAllyDiscount' })}
onClick={() => {
if (!busy && !g.revivalRules.discountBlocked && g.revivalRules.allyDiscount !== me.ally)
act({ type: 'tleilaxuAllyDiscount' });
}}
>
Offer ally half-price revival
</Button>
@@ -134,20 +138,20 @@ export function RevivalCommerce({
</label>
<Button
disabled={busy}
onClick={() =>
act({
onClick={() => {
if (!busy) act({
type: 'quoteLeaderRevival',
target: id,
amount: prices[id] ?? 0,
})
}
});
}}
>
Quote this price
</Button>
<Button
variant="outline"
disabled={busy}
onClick={() => act({ type: 'declineLeaderRevival', target: id })}
onClick={() => { if (!busy) act({ type: 'declineLeaderRevival', target: id }); }}
>
Decline request
</Button>
@@ -181,6 +185,7 @@ export function RevivalCommerce({
A request and its quoted price are visible only to you and Tleilaxu. No
spice is paid until you accept and the response resolves.
</p>
{orderBlocked && <p className="fine">{orderBlocked}</p>}
{request ? (
<>
<p>
@@ -193,16 +198,21 @@ export function RevivalCommerce({
</p>
{request.price !== null && !request.declined && (
<Button
disabled={busy || request.price > (me.spice ?? 0)}
onClick={() => act({ type: 'acceptLeaderRevival' })}
disabled={busy || !!orderBlocked || request.price > (me.spice ?? 0)}
onClick={() => {
if (!busy && !revivalTurnOrderBlock(g) && request.price! <= (me.spice ?? 0))
act({ type: 'acceptLeaderRevival' });
}}
>
Accept revival price
</Button>
)}
<Button
variant="outline"
disabled={busy}
onClick={() => act({ type: 'cancelLeaderRequest' })}
disabled={busy || !!orderBlocked}
onClick={() => {
if (!busy && !revivalTurnOrderBlock(g)) act({ type: 'cancelLeaderRequest' });
}}
>
Withdraw request
</Button>
@@ -214,6 +224,7 @@ export function RevivalCommerce({
<select
id="revival-request-leader"
value={selection}
disabled={busy || !!orderBlocked}
onChange={(e) => setSelection(e.target.value)}
>
<option value="">Choose a dead leader</option>
@@ -229,10 +240,11 @@ export function RevivalCommerce({
</select>
</label>
<Button
disabled={busy || !selection}
onClick={() =>
act({ type: 'requestLeaderRevival', leader: selection })
}
disabled={busy || !!orderBlocked || !selection}
onClick={() => {
if (!busy && !revivalTurnOrderBlock(g) && selection)
act({ type: 'requestLeaderRevival', leader: selection });
}}
>
Request a quote
</Button>
+71
View File
@@ -0,0 +1,71 @@
'use client';
import type { Action, GameView } from '@/game/engine';
import { Button } from './ui/button';
type Props = { game: GameView; act: (action: Action) => void; busy: boolean };
/** Native own revival choices follow the projected serial turn. Responder
* powers use their separate commerce controls and do not use this guard. */
export function revivalTurnOrderBlock(game: GameView): string | null {
const order = game.revivalOrder;
if (!game.advanced || !order) return null;
if (order.version !== 1 || order.turn !== game.turn || game.status !== 'playing' || game.phase !== 4)
return 'This Revival turn is no longer current.';
if (!game.players.some(player => player.id === game.me) || !order.original.includes(game.me))
return 'This seat has no Revival turn in the current order.';
if (order.completed.includes(game.me) || !order.remaining.includes(game.me))
return 'Your Revival turn is finished.';
const actor = order.remaining[0];
if (actor !== game.me)
return game.revival.orderBlocked || `Waiting for ${game.players.find(player => player.id === actor)?.name ?? 'the current player'} to finish their Revival turn.`;
if (game.active !== actor) return 'The current Revival actor is unavailable.';
return game.revival.orderBlocked || null;
}
export function finishRevivalTurnBlocked(game: GameView): string | null {
if (!game.advanced || !game.revivalOrder) return 'No ordered Revival turn is available.';
const order = revivalTurnOrderBlock(game);
if (order) return order;
if (game.response || game.decision || game.truthtrance || game.phaseOpening ||
game.nexusTraitors?.pending || game.nexusCards?.waiting.length || game.revival.pending)
return 'Finish the current Revival response or decision before ending your turn.';
if (game.automaticContinuationPending || game.players.find(player => player.id === game.me)?.autopilot)
return 'Wait for the current automatic action to finish.';
return null;
}
/** Hook-free actual callback; an unaccepted quote does not prevent finishing. */
export function FinishRevivalTurn({ game, act, busy }: Props) {
const blocked = finishRevivalTurnBlocked(game);
const identity = JSON.stringify([game.turn, game.revivalOrder?.turn, game.revivalOrder?.original]);
return <Button
data-testid="finish-revival-turn"
disabled={busy || !!blocked}
onClick={() => {
if (!busy && !finishRevivalTurnBlocked(game) &&
JSON.stringify([game.turn, game.revivalOrder?.turn, game.revivalOrder?.original]) === identity)
act({ type: 'ready' });
}}
>Finish Revival turn</Button>;
}
export function RevivalTurn({ game, act, busy }: Props) {
const order = game.revivalOrder;
if (!game.advanced || !order || game.phase !== 4) return null;
const name = (id: string) => game.players.find(player => player.id === id)?.name ?? id;
const blocked = finishRevivalTurnBlocked(game);
return <section aria-label="Revival turn order" className="notice space-y-3">
<h3>Revival turn order</h3>
<p>{order.remaining.length ? `Current Revival actor: ${name(order.remaining[0])}.` : 'All Revival turns are finished.'}</p>
<p>Remaining Revival turns: {order.remaining.map(name).join(' → ') || 'None'}.</p>
<p>Completed Revival turns: {order.completed.map(name).join(', ') || 'None yet'}.</p>
<p className="fine" aria-label="Provisional Revival grouping">
Provisional grouping: finish your force, leader and Kwisatz Haderach revival
choices together before ending your turn. Revival turns follow the printed
storm order for forces. Tleilaxu can still answer another player’s requests.
</p>
{blocked && <output className="fine block">{blocked}</output>}
<FinishRevivalTurn game={game} act={act} busy={busy} />
</section>;
}