Show revealed battle components in one shared table area
Present public plans in physical attacker/defender order with exact force dials, played leader portraits and printed strength, readable used-card faces and direct inspectors. Keep Cheap Heroes, printed Worthless category, empty slots and late rescue defense distinct. Preserve Banker, native bonus, KH, Stone Burner, Poison Tooth and Diplomat details rather than treating printed values as scores. Mount one shared display after the main grid so every viewer retains access while another seat owns a post-reveal choice. Keyboard links focus the display and pending controls. Render nothing before public revelation, select exact used identities from the authorized projection, and consume no private insight or hands. No game rule, saved field, endpoint, AI strategy or release gate changes. Five new cases cover genuine sealed/revealed actions, stable order, used-card privacy, JSON immutability, fractional dials, hero/Worthless/late/empty slots and both seats during a real Diplomat decision. All28 related focused cases passed before final rule-link adapter changes; types, lint, all 5017 offline tests and production build pass on the final stable source. First broad run caught plain rules-page anchors; next/link and test aliases correct this. Independent privacy/layout review and final follow-up found no material issues. No API/auth/persistence changes; HTTP tests and full game samples not repeated. Browser QA creates a fresh human Emperor/Easy Harkonnen lobby with genuine setup, then explicitly stages a conserved battle. Normal engine actions choose battle and seal the opponent plan; human browser action seals dial2, Captain Aramsham, Slip Tip and Shield. Public display absent beforehand and shows correct both plans afterward. Leader/opponent-card inspection, Escape/focus return, internal help, keyboard section links and refresh preserve the pending decision. At390x844, client/scrollWidth375, stacked panels299px, rules16px and controls44px; normal viewport restores1265px and two560.5px plans without horizontal overflow. Screenshot capture timed out; full visual/screen-reader acceptance remains open. All 1579 opening games, 3347 saved seats and auxiliary rows remain identical. One isolated QA game adds one seat and one entry receipt. Its room remains unchanged after human seal through inspection/help/refresh, and its saved seat remains unchanged. SQLite integrity and foreign keys pass. No server restart, migration, reset, recurring automation or calibration. Record Basic Moritani plus the full Leader Skills deck as the next concrete integration target, requiring coordinated profile guards and real Terror/death/ revival continuations. Existing rulings and mode gates stay explicit; the later Tleilaxu revival-offer ordering issue remains a gated integration dependency. Private evidence: 20260921-revealed-plans. Verified source tree: 8d5496cdea0076aa1cdab4590e95fcaa677c9d2d8513190c2ce9ac6c90bb9196
This commit is contained in:
1 parent
de26aaf029
commit
e7e16024b7
14 files changed
+368
-86
No files matched your search
@@ -1562,10 +1562,11 @@ export function GameTable({
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
<aside className="action-panel">
|
||||
<aside className="action-panel" id="table-decisions" tabIndex={-1}>
|
||||
<div className="eyebrow">
|
||||
{g.status === 'finished' ? 'FINAL OUTCOME' : 'YOUR NEXT DECISION'}
|
||||
</div>
|
||||
{g.battle?.revealed && <a className="battle-display-link" href="#revealed-battle-plans">Compare revealed battle plans</a>}
|
||||
<PrivateBattlePlan game={g} />
|
||||
<NexusCards game={g} act={act} busy={busy} />
|
||||
<Recruits game={g} act={act} busy={busy} />
|
||||
@@ -2605,7 +2606,6 @@ export function GameTable({
|
||||
/>
|
||||
) : g.decision.kind === 'stoneBurner' ? (
|
||||
<>
|
||||
<RevealedBattle game={g} />
|
||||
<h3>Choose Stone Burner’s effect</h3>
|
||||
<p className="muted">
|
||||
Choose whether both leaders die or surviving leaders
|
||||
@@ -2630,17 +2630,14 @@ export function GameTable({
|
||||
</>
|
||||
) : g.decision.kind === 'harassWithdraw' ? (
|
||||
<>
|
||||
<RevealedBattle game={g} />
|
||||
<HarassWithdrawChoice key={g.decision.event} game={g} act={act} busy={busy} />
|
||||
</>
|
||||
) : g.decision.kind === 'diplomatDefense' ? (
|
||||
<>
|
||||
<RevealedBattle game={g} />
|
||||
<DiplomatDefense game={g} act={act} busy={busy} />
|
||||
</>
|
||||
) : g.decision.kind === 'poisonTooth' ? (
|
||||
<>
|
||||
<RevealedBattle game={g} />
|
||||
<p className="muted">
|
||||
Choose after reviewing both plans. Activating attacks both
|
||||
leaders, including yours. Chemistry as a defense protects;
|
||||
@@ -2964,7 +2961,6 @@ export function GameTable({
|
||||
</>
|
||||
) : g.decision.kind === 'homeworldDefense' ? (
|
||||
<>
|
||||
<RevealedBattle game={g} />
|
||||
<h3>Late poison defense</h3>
|
||||
<p className="muted">
|
||||
The Traitor window is complete. You may add Portable Snooper
|
||||
@@ -4403,7 +4399,6 @@ export function GameTable({
|
||||
strength in this battle.
|
||||
</p>
|
||||
)}
|
||||
{g.battle.revealed && <RevealedBattle game={g} />}
|
||||
<NexusInspectionHistory game={g} />
|
||||
{(!g.battle.preLeader || g.battle.preLeader.closed) && (
|
||||
<BattlePromises
|
||||
@@ -4832,6 +4827,7 @@ export function GameTable({
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
<RevealedBattle game={g} />
|
||||
{g.inflation && (
|
||||
<p className="notice" role="status">
|
||||
Inflation · {g.inflation.side === 'double' ? 'DOUBLE' : 'CANCEL'} ·{' '}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { GameView } from '@/game/engine';
|
||||
import Link from 'next/link';
|
||||
import { faction } from '@/game/catalog';
|
||||
import { leaderStrengthLabel } from '@/game/cards';
|
||||
import { isStoneBurner } from '@/game/battle-cards';
|
||||
import { stoneBurnerComparison } from '@/game/stone-burner';
|
||||
import { BattleComponentInspection } from './battle-component-inspection';
|
||||
import { RevealedPlanPieces } from './revealed-plan-pieces';
|
||||
import { KwisatzInspector } from './kwisatz-inspector';
|
||||
export function RevealedBattle({ game }: { game: GameView }) {
|
||||
const battle = game.battle;
|
||||
if (!battle?.revealed) return null;
|
||||
@@ -32,8 +34,14 @@ export function RevealedBattle({ game }: { game: GameView }) {
|
||||
id)
|
||||
: 'None';
|
||||
return (
|
||||
<div className="revealed-plans" aria-label="Revealed battle plans">
|
||||
{Object.entries(battle.plans).map(([id, plan]) => {
|
||||
<section className="revealed-plans" id="revealed-battle-plans" tabIndex={-1} aria-label="Revealed battle plans">
|
||||
<h3>Revealed battle plans</h3>
|
||||
<a className="battle-display-link" href="#table-decisions">Return to battle decisions</a>
|
||||
<p className="revealed-plans-help">Both plans are public. Inspect any played component; use the decision controls to resolve the battle. <Link href="/rules?topic=revealed-battle-components#revealed-battle-components">Component guide</Link> · <Link href="/rules?topic=battle#battle">Battle rules</Link></p>
|
||||
<div className="revealed-plan-pair">
|
||||
{[battle.attacker, battle.defender].map((id) => {
|
||||
const plan = battle.plans[id];
|
||||
if (!plan) return null;
|
||||
const leader = game.allLeaders.find((l) => l.id === plan.leader);
|
||||
const diplomat =
|
||||
battle.diplomatDefense?.player === id ? battle.diplomatDefense : null;
|
||||
@@ -46,32 +54,30 @@ export function RevealedBattle({ game }: { game: GameView }) {
|
||||
),
|
||||
].sort((a, b) => a - b);
|
||||
return (
|
||||
<div key={id}>
|
||||
<b>{game.players.find((p) => p.id === id)?.name}</b>
|
||||
<span>
|
||||
<section className="revealed-plan" key={id} aria-label={`${id === battle.attacker ? 'Attacker' : 'Defender'} plan`}>
|
||||
<h4>{game.players.find((p) => p.id === id)?.name} · {id === battle.attacker ? 'Attacker' : 'Defender'}</h4>
|
||||
<span className="revealed-plan-detail">
|
||||
Dial {plan.dial}
|
||||
{game.advanced ? ` · ${plan.support} spice support` : ''}
|
||||
{!!plan.allyPayment && ` (${plan.allyPayment} from CHOAM)`}
|
||||
</span>
|
||||
{!!plan.bankerSpice && (
|
||||
<span>Spice Banker: {plan.bankerSpice} spice committed</span>
|
||||
<span className="revealed-plan-detail">Spice Banker: {plan.bankerSpice} spice committed</span>
|
||||
)}
|
||||
{battle.native === id && !battle.cards.some(isStoneBurner) && (
|
||||
<span>
|
||||
<span className="revealed-plan-detail">
|
||||
Native Homeworld bonus: +{battle.nativeBattleStrength} to the
|
||||
battle score, separately from the dial
|
||||
</span>
|
||||
)}
|
||||
<span>Leader: {name(plan.leader)}</span>
|
||||
{plan.kwisatz && (
|
||||
<span>
|
||||
<span className="revealed-plan-detail">
|
||||
Kwisatz Haderach · +2 if leader survives · protected from
|
||||
traitors
|
||||
</span>
|
||||
)}
|
||||
<span>Weapon: {name(plan.weapon)}</span>
|
||||
{undialed.length > 0 && (
|
||||
<span>
|
||||
<span className="revealed-plan-detail">
|
||||
Stone Burner undialed tokens: {undialed.join(' or ')}
|
||||
{undialed.length > 1
|
||||
? ' (possible counts before casualty choice)'
|
||||
@@ -79,7 +85,7 @@ export function RevealedBattle({ game }: { game: GameView }) {
|
||||
</span>
|
||||
)}
|
||||
{battle.stoneBurner[id] && (
|
||||
<span>
|
||||
<span className="revealed-plan-detail">
|
||||
Stone Burner:{' '}
|
||||
{battle.stoneBurner[id] === 'kill'
|
||||
? 'both leaders die'
|
||||
@@ -88,49 +94,32 @@ export function RevealedBattle({ game }: { game: GameView }) {
|
||||
</span>
|
||||
)}
|
||||
{battle.poisonTooth[id] !== undefined && (
|
||||
<span>
|
||||
<span className="revealed-plan-detail">
|
||||
Poison Tooth: {battle.poisonTooth[id] ? 'activated' : 'unused'}
|
||||
</span>
|
||||
)}
|
||||
<span>Defense in original plan: {name(plan.defense)}</span>
|
||||
{diplomat?.stage === 'copied' && diplomat.card && (
|
||||
<span>
|
||||
<span className="revealed-plan-detail">
|
||||
Diplomat: {name(diplomat.card)} copies {name(diplomat.source)} ·
|
||||
discard after battle
|
||||
</span>
|
||||
)}
|
||||
{diplomat?.stage === 'declined' && (
|
||||
<span>Diplomat: defense copy declined</span>
|
||||
<span className="revealed-plan-detail">Diplomat: defense copy declined</span>
|
||||
)}
|
||||
{battle.lateDefense[id] && (
|
||||
<span>Added after reveal: {name(battle.lateDefense[id])}</span>
|
||||
)}
|
||||
<BattleComponentInspection
|
||||
kwisatz={plan.kwisatz}
|
||||
playerName={
|
||||
game.players.find((p) => p.id === id)?.name ?? 'Player'
|
||||
}
|
||||
cards={battle.cards.filter((card) =>
|
||||
[
|
||||
plan.leader,
|
||||
plan.weapon,
|
||||
plan.defense,
|
||||
battle.lateDefense[id],
|
||||
].includes(card.id),
|
||||
)}
|
||||
leader={
|
||||
leader
|
||||
? {
|
||||
name: leader.name,
|
||||
factionName: faction(leader.faction).name,
|
||||
strength: leaderStrengthLabel(leader),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
<RevealedPlanPieces
|
||||
dial={plan.dial}
|
||||
leader={leader ? { id: leader.id, name: leader.name, factionName: faction(leader.faction).name, strength: leaderStrengthLabel(leader) } : undefined}
|
||||
leaderCard={battle.cards.find(card => card.id === plan.leader)}
|
||||
weapon={battle.cards.find(card => card.id === plan.weapon)}
|
||||
defense={battle.cards.find(card => card.id === plan.defense)}
|
||||
lateDefense={battle.cards.find(card => card.id === battle.lateDefense[id])}
|
||||
/>
|
||||
</div>
|
||||
{plan.kwisatz && <KwisatzInspector context="plan" />}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { cardPresentation, type VisibleCard } from '@/game/card-presentation';
|
||||
import { CardInspector } from './card-inspector';
|
||||
import { LeaderInspector, type LeaderDisplayIdentity } from './leader-inspector';
|
||||
import { LeaderPortrait } from './leader-portrait';
|
||||
|
||||
function PlayedCard({ label, card }: { label: string; card?: VisibleCard }) {
|
||||
if (!card) return <article className="revealed-piece revealed-piece-empty" aria-label={`${label}: None`}>
|
||||
<p className="revealed-piece-label">{label}</p><h4>None</h4>
|
||||
</article>;
|
||||
const face = cardPresentation(card);
|
||||
return <article className={`revealed-piece revealed-piece-${face.role}`} aria-label={`${label}: ${card.name}`}>
|
||||
<p className="revealed-piece-label">{label} · {face.category}</p>
|
||||
<h4>{card.name}</h4>
|
||||
<p className="revealed-piece-rules">{face.guidance}</p>
|
||||
<CardInspector card={card} />
|
||||
</article>;
|
||||
}
|
||||
|
||||
/** Read-only components. The caller must supply only a publicly revealed plan. */
|
||||
export function RevealedPlanPieces({ dial, leader, leaderCard, weapon, defense, lateDefense }: {
|
||||
dial: number;
|
||||
leader?: LeaderDisplayIdentity;
|
||||
leaderCard?: VisibleCard;
|
||||
weapon?: VisibleCard;
|
||||
defense?: VisibleCard;
|
||||
lateDefense?: VisibleCard;
|
||||
}) {
|
||||
return <div className="revealed-plan-pieces">
|
||||
<div className="revealed-plan-leader">
|
||||
<dl className="revealed-dial" aria-label="Revealed force dial">
|
||||
<dt>Forces dialed</dt><dd>{dial}</dd>
|
||||
</dl>
|
||||
{leader ? <article className="revealed-piece revealed-leader" aria-label={`Leader: ${leader.name}`}>
|
||||
<LeaderPortrait identityId={leader.id} name={leader.name} className="size-20 shrink-0" sizes="80px" fallback={<span aria-hidden="true">◆</span>} />
|
||||
<div><p className="revealed-piece-label">{leader.factionName} · Leader</p><h4>{leader.name}</h4>
|
||||
<p className="revealed-piece-rules">Printed strength {leader.strength}</p>
|
||||
</div>
|
||||
<LeaderInspector identity={leader} />
|
||||
</article> : <PlayedCard label="Leader" card={leaderCard} />}
|
||||
</div>
|
||||
<div className="revealed-plan-cards">
|
||||
<PlayedCard label="Weapon" card={weapon} />
|
||||
<PlayedCard label="Defense in original plan" card={defense} />
|
||||
{lateDefense && <PlayedCard label="Added after reveal" card={lateDefense} />}
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
Reference in new issue
Block a user