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
@@ -81,6 +81,10 @@ Above the board, **Treachery draw pile** and **Spice draw pile** show live card
|
||||
counts separately from hands, auction cards and discards. See
|
||||
[draw-pile counts](docs/DRAW_PILES.md).
|
||||
|
||||
After both battle plans are revealed, **Compare revealed battle plans** opens
|
||||
the shared dials, leader portraits and played card faces. Inspect any component,
|
||||
then return to the pending decision. See [battle components](docs/REVEALED_BATTLE_COMPONENTS.md).
|
||||
|
||||
In **Your private hand**, search card names, filter by printed category or change
|
||||
the display order. **Show all cards** clears filters. Inspectors and card actions
|
||||
stay with each physical card. See [hand browsing](docs/HAND_BROWSING.md).
|
||||
|
||||
+42
-9
@@ -946,18 +946,51 @@ footer {
|
||||
font-size: 15px;
|
||||
color: #e0ba75;
|
||||
}
|
||||
.revealed-plans > div {
|
||||
.revealed-plans { min-width: 0; padding: 20px; scroll-margin-top: 16px; }
|
||||
.revealed-plan-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 420px), 1fr)); gap: 16px; }
|
||||
.battle-display-link { display: inline-flex; align-items: center; min-height: 44px; font-size: 16px; line-height: 1.5; text-decoration: underline; margin: 4px 0; }
|
||||
#table-decisions { scroll-margin-top: 16px; }
|
||||
.revealed-plans-help { font-size: 16px !important; line-height: 1.6; }
|
||||
.revealed-plans-help a { text-decoration: underline; }
|
||||
.revealed-plan {
|
||||
min-width: 0;
|
||||
border: 1px solid #70633f;
|
||||
padding: 14px;
|
||||
margin: 10px 0;
|
||||
font-size: 14px;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin: 16px 0;
|
||||
background: #181f19;
|
||||
}
|
||||
.revealed-plans span {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #b5bca6;
|
||||
margin-top: 8px;
|
||||
.revealed-plan > h4 { margin: 0 0 12px; font-size: 20px; overflow-wrap: anywhere; }
|
||||
.revealed-plan-detail { display: block; font-size: 16px; line-height: 1.6; color: #d0d5c7; margin: 8px 0; }
|
||||
.revealed-plan-pieces { display: grid; gap: 14px; margin: 16px 0; min-width: 0; }
|
||||
.revealed-plan-leader { display: flex; flex-wrap: wrap; align-items: stretch; gap: 14px; min-width: 0; }
|
||||
.revealed-dial {
|
||||
flex: 0 0 116px; align-self: center; width: 116px; height: 116px; margin: 0;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
border: 5px double #c5a66d; border-radius: 50%;
|
||||
background: radial-gradient(circle at 38% 28%, #394331, #11170f);
|
||||
box-shadow: inset 0 0 0 5px #222b1d;
|
||||
}
|
||||
.revealed-dial dt { font-size: 14px; color: #e1d2b3; }
|
||||
.revealed-dial dd { margin: 0; font: 36px/1.2 Georgia, serif; color: #fff0c9; }
|
||||
.revealed-plan-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: 14px; }
|
||||
.revealed-piece {
|
||||
min-width: 0; display: flex; flex-direction: column; gap: 10px; padding: 16px;
|
||||
border: 1px solid #a18b62; border-radius: 12px; background: linear-gradient(145deg, #35352a, #151b16);
|
||||
}
|
||||
.revealed-piece-weapon { background: linear-gradient(145deg, #512f28, #211b19); }
|
||||
.revealed-piece-defense { background: linear-gradient(145deg, #284544, #18231f); }
|
||||
.revealed-piece-leader { background: linear-gradient(145deg, #4b3c23, #221e15); }
|
||||
.revealed-piece-utility { background: linear-gradient(145deg, #38344b, #1d1d26); }
|
||||
.revealed-piece-empty { border-style: dashed; background: #171c17; }
|
||||
.revealed-plan-leader > .revealed-piece { flex: 1 1 210px; }
|
||||
.revealed-piece .revealed-piece-label { margin: 0; font-size: 14px !important; line-height: 1.5; color: #ebd4a4; }
|
||||
.revealed-piece h4 { margin: 0; font: 22px/1.25 Georgia, serif; color: #fff1d3; overflow-wrap: anywhere; }
|
||||
.revealed-piece .revealed-piece-rules { margin: 0; font-size: 16px !important; line-height: 1.6; color: #eee5d2; }
|
||||
.revealed-piece > button { margin-top: auto; align-self: stretch; min-height: 44px; }
|
||||
.revealed-leader { display: flex; flex-flow: row wrap; align-items: center; }
|
||||
.revealed-leader > div { flex: 1 1 120px; min-width: 0; }
|
||||
.revealed-leader > button { flex: 1 0 100%; }
|
||||
.prescience {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
# Component inventory and verification ledger
|
||||
|
||||
**Revealed battle components, 21 September 2026:** public plans now display
|
||||
physical dials, leader portraits/printed strength and readable used-card faces,
|
||||
with direct inspectors and separate late-defense slots. One shared table area
|
||||
remains visible across decision ownership; sealed and unplayed identities remain
|
||||
private. See [scope and evidence](REVEALED_BATTLE_COMPONENTS.md).
|
||||
|
||||
**Ecaz Loyalty, 21 September 2026:** one existing native Traitor Card now has a
|
||||
separate face-up public location in new Advanced Ecaz setup. Its canonical
|
||||
identity and portrait use the enlarged Traitor inspector; it is excluded from
|
||||
|
||||
+35
-23
@@ -6,34 +6,46 @@ gated.** Prototypes do not open completion or publication gates.
|
||||
|
||||
## Current checkpoint and work
|
||||
|
||||
Latest delivered checkpoint: `c2f24fc`, [public faction sheets](FACTION_SHEETS.md).
|
||||
Types, lint, **5,000 offline tests** and production build passed. Public table
|
||||
inspection, all twelve gallery entries, Basic/Advanced preview, keyboard and
|
||||
mobile layouts, internal help and refresh passed bounded browser checks. All
|
||||
1,539 opening rooms, 3,259 human seats and auxiliary rows stayed unchanged.
|
||||
Screenshot capture timed out; full visual acceptance remains open. The healthy
|
||||
server was reused. Prior checkpoints include `e3cfd82` for hand browsing,
|
||||
`14a7800` for draw counts and `537da4a` for force inspection.
|
||||
Latest delivered checkpoint: `de26aaf`, [Ecaz Loyalty](ECAZ_LOYALTY.md).
|
||||
Types, lint, **5,012 offline tests**, production build and **49 HTTP tests** passed.
|
||||
Six genuine faction-game samples completed with no rejected actions. A fresh
|
||||
Advanced Ecaz/Harkonnen browser setup preserved the public set-aside card through
|
||||
Traitor/force choices and refresh. All 1,539 opening rooms, 3,259 human seats and
|
||||
auxiliary rows stayed unchanged; isolated QA/HTTP fixtures brought the baseline
|
||||
to 1,579 rooms and 3,347 human seats. Screenshot capture timed out. The healthy
|
||||
server was reused. Earlier checkpoints include `c2f24fc` for public faction sheets
|
||||
and `e3cfd82` for private-hand browsing.
|
||||
|
||||
Current work: [Ecaz Loyalty](ECAZ_LOYALTY.md) selects one native Traitor Card
|
||||
before genuine Advanced dealing, keeps it publicly set aside through setup and
|
||||
Nexus replacement draws, and adds enlarged inspection and internal guidance.
|
||||
Focused custody, public-view, all-profile legal setup and JSON checks accompany
|
||||
independent review, browser checks, final check/build/HTTP and saved preservation.
|
||||
Legacy dealt games remain unchanged; combined Skills/assassination rosters and
|
||||
complete faction acceptance remain gated.
|
||||
Current work: [revealed battle components](REVEALED_BATTLE_COMPONENTS.md) adds
|
||||
public dials, leader portraits and readable used-card faces in one shared area,
|
||||
with direct inspectors and navigation to pending decisions. Sealed plans and
|
||||
private hands stay separate. Focused checks, independent review, browser QA and
|
||||
final check/build/saved-preservation evidence accompany this presentation work.
|
||||
The separate Bureaucrat/Emperor gift question is pending.
|
||||
The `8f7a62c` policy checkpoint removed usage checks/cutoffs and deferred AI
|
||||
refinement until feature completion, followed by three adjacent 75% targets.
|
||||
|
||||
A bounded remaining-effect audit found that the reviewed faction/Nexus candidates
|
||||
cross recorded pending rulings, with the independent Loyalty setup omission now
|
||||
being connected. Continue independent features rather than
|
||||
inventing answers. The [platform audit](AUTOPILOT.md#platform-audit-20-september-2026)
|
||||
found no supported Sites scheduler or Durable Object provisioning contract.
|
||||
Durable unattended bot resumption remains blocked on that capability; current
|
||||
request/reconnect continuation does not establish it. Continue independent
|
||||
player-facing features and integration while preserving pending rules questions.
|
||||
The next concrete gameplay milestone is **Basic Moritani plus Leader Skills**
|
||||
with base opponents and no other optional modules. A bounded current-code review
|
||||
found no new Moritani-specific ruling, but widening setup alone is insufficient:
|
||||
coordinate Banker, Diplomat, Sandmaster, Smuggler, Bureaucrat, Planetologist and
|
||||
battle/victory public-profile guards, keep the full fourteen-card deck, and test
|
||||
Terror leader death, skill return and revival through real continuations. Do not
|
||||
hide unsupported cards after a random deal or claim complete combination support.
|
||||
This provides a concrete dependency-ordered integration target for the next pass.
|
||||
|
||||
The reviewed remaining native Ecaz/Moritani effects, Duke revival and Richese
|
||||
empty-cache auction arithmetic still cross recorded source questions; Loyalty is
|
||||
now connected. For later Tleilaxu/Skills integration, create replacement offers
|
||||
at successful native revival commitment: the current automatic response loop can
|
||||
revive after the outer custody observer. This is a confirmed dependency in the
|
||||
currently gated roster, not a demonstrated supported-profile defect; no synthetic
|
||||
profile was enabled or labeled verified in this pass.
|
||||
|
||||
The [platform audit](AUTOPILOT.md#platform-audit-20-september-2026) found no supported
|
||||
Sites scheduler or Durable Object provisioning contract. Durable unattended bot
|
||||
resumption remains blocked on that capability; request/reconnect continuation
|
||||
does not establish it. Preserve pending rules questions and mode gates.
|
||||
|
||||
The connected [Harass & Withdraw prototype](HARASS_WITHDRAW_RUNTIME.md) supports either
|
||||
battle-card slot, private category inspections, exact undialed normal/elite
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Dune implementation status
|
||||
|
||||
## 21 September 2026 — public battle component display
|
||||
|
||||
Revealed plans now share one table area with exact dials, played leader portraits
|
||||
and readable card faces. Direct inspection and navigation preserve pending
|
||||
decisions; both players retain access when a post-reveal choice belongs to one
|
||||
seat. Sealed plans and unused hands stay out of this display.
|
||||
[Battle components](REVEALED_BATTLE_COMPONENTS.md) records scope and evidence;
|
||||
focused tests and independent review accompany private check/build, browser,
|
||||
preservation and Git results. No game rule, saved field or AI strategy changed.
|
||||
|
||||
## 21 September 2026 — Advanced Ecaz Loyalty
|
||||
|
||||
Genuine Advanced Ecaz setup now randomly sets one native Traitor Card aside face
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Revealed battle components
|
||||
|
||||
Connected presentation prototype, 21 September 2026. Existing server plan
|
||||
revelation and battle rules remain authoritative. No rule, action, AI strategy,
|
||||
saved field, endpoint or mode/publication gate changes.
|
||||
|
||||
## Shared table presentation
|
||||
|
||||
Once both plans are public, a single shared area displays the physical attacker
|
||||
and defender in that order, regardless of which player sealed first. Each plan
|
||||
has its exact force dial, the played leader portrait and printed strength, and
|
||||
readable weapon and original-defense faces with their existing guidance. A Cheap
|
||||
Hero appears as a card in the leader slot. Empty slots say None; a late defense
|
||||
has a separate Added after reveal label. Printed category remains distinct from
|
||||
its chosen battle slot, including a Worthless card in the weapon slot.
|
||||
|
||||
One-click inspectors enlarge the actual visible cards and leaders. Leader IDs
|
||||
now reach the portrait/inspector rather than dropping the bundled identity art.
|
||||
Kwisatz Haderach inspection remains public inclusion only. Banker commitment,
|
||||
CHOAM support, native Homeworld bonus, Diplomat copy/decline, Poison Tooth and
|
||||
Stone Burner details keep their existing separate descriptions. The dial and
|
||||
printed leader strength are not presented as a calculated winning score.
|
||||
|
||||
`GameTable` mounts this display once after the main grid. It stays available to
|
||||
all viewers while another player owns a post-reveal decision, unlike the earlier
|
||||
copies nested under specific decision branches. Compare revealed battle plans
|
||||
and Return to battle decisions link to focusable sections. No inspector or
|
||||
navigation link submits a game action. This keeps the larger faces separate from
|
||||
the pending controls while preserving their connection.
|
||||
|
||||
The layout fits two plan panels when space permits and stacks on smaller screens.
|
||||
Cards wrap within each plan; readable rules use 16px text and inspection controls
|
||||
retain 44px minimum height. No new animation, timing delay or confirmation is
|
||||
introduced. Full visual/component and screen-reader acceptance remains open.
|
||||
|
||||
## Privacy and continuation
|
||||
|
||||
The renderer returns nothing unless `battle.revealed` is true. It never consumes
|
||||
Atreides private full-plan insight or any player's hand. Each visible face is
|
||||
selected by an exact used ID from the already projected `battle.cards`; native
|
||||
leader identity comes from the authorized leader catalog. The small pieces
|
||||
component receives only those selected public identities and the dial.
|
||||
|
||||
The original defense remains separate from a late defense or a Diplomat copy.
|
||||
Refresh reconstructs the same display from the saved public plans. Reordering
|
||||
the presentation does not reorder saved plans, reveal Traitors, change payments,
|
||||
execute a card or submit a battle decision.
|
||||
|
||||
## Verification
|
||||
|
||||
Five focused cases cover real sealed/revealed transitions, explicit plan order,
|
||||
used versus unplayed card privacy, immutable JSON rendering, fractional dials,
|
||||
Cheap Hero, Worthless, empty/late slots and both viewers during a real Diplomat
|
||||
choice. Existing Kwisatz, Banker and Diplomat regressions exercise retained
|
||||
special-effect metadata. These are bounded rendering checks, not full rules-mode
|
||||
certification or proof that every battle combination works.
|
||||
|
||||
Private browser evidence uses a new Emperor/Harkonnen QA lobby with genuine
|
||||
Basic setup, followed by an explicitly staged battle position conserving card
|
||||
and force inventory. Choosing the battle, the opponent's saved plan and the
|
||||
human's sealing action use normal engine actions; the intervening game was not
|
||||
played. Browser inspection, final check/build results, original saved-game
|
||||
preservation and Git delivery are recorded under `20260921-revealed-plans` outside
|
||||
the checkout. The commit message records actual outcomes and limitations.
|
||||
@@ -2241,6 +2241,28 @@ export const RULE_TOPICS: RuleTopic[] = [
|
||||
'You have two spice and your ally pledges four. You may bid six; winning uses two of yours and four of theirs.',
|
||||
related: ['bidding', 'movement', 'faction-guild', 'card-karama'],
|
||||
},
|
||||
{
|
||||
id: 'revealed-battle-components',
|
||||
title: 'Compare revealed battle components',
|
||||
category: 'Cards',
|
||||
coverage: 'Partial',
|
||||
developmentStage: 'Prototyped',
|
||||
summary: 'After both plans are public, compare their dials, leaders and played card faces in one shared area.',
|
||||
steps: [
|
||||
'Use Compare revealed battle plans beside your next decision. Attacker and defender are shown in a stable order with the exact force dial and the played leader’s printed strength. These numbers alone are not the final battle score.',
|
||||
'Weapon and original-defense cards show their names, printed categories and gameplay guidance. A Cheap Hero appears in the leader slot. None marks an empty slot. Added after reveal is separate from the original defense; Diplomat and other effect details explain changes without replacing the recorded plan.',
|
||||
'Inspect card or Inspect leader enlarges that public component. Return to battle decisions takes you back to the pending controls. Inspection never plays a card, changes a dial or submits a Traitor call.',
|
||||
'The shared display stays available while another player owns a post-reveal choice. It does not appear while either plan remains sealed. Private Atreides inspection and unplayed hand cards stay on their separate authorized surfaces.',
|
||||
],
|
||||
checklist: [
|
||||
{ area: 'Implementation', status: 'Implemented', detail: 'One read-only display consumes existing revealed projections, exact used card identities and authorized leaders. No new engine or saved-state behavior.', evidence: ['components/revealed-battle.tsx', 'components/revealed-plan-pieces.tsx'] },
|
||||
{ area: 'Player controls', status: 'Implemented', detail: 'Public faces, exact dials, direct enlarged inspection and navigation to the decision controls remain available across decision ownership.', evidence: ['components/game-table.tsx', 'app/globals.css'] },
|
||||
{ area: 'AI', status: 'Implemented', detail: 'The same public components display an AI plan after normal revelation; presentation adds no AI decision or strategy change.', evidence: ['components/revealed-battle.tsx'] },
|
||||
{ area: 'Documentation', status: 'Implemented', detail: 'The guide distinguishes public disclosure, printed values, original and late slots, private insight and decision ownership.', evidence: ['game/reference.ts', 'docs/REVEALED_BATTLE_COMPONENTS.md'] },
|
||||
{ area: 'Verification', status: 'Partial', detail: 'Sealed/revealed actions, used-card privacy, JSON rendering, empty/hero/late slots and shared display during another player’s choice have focused tests. Browser and final checkpoint evidence are recorded separately; full visual and interaction acceptance remain open.', evidence: ['tests/revealed-plan-pieces.test.tsx', 'tests/kwisatz.test.ts', 'tests/diplomat-defense-controls.test.tsx', 'tests/spice-banker-controls.test.tsx'] },
|
||||
],
|
||||
related: ['battle', 'battle-cards', 'kwisatz-haderach', 'implementation-checklist'],
|
||||
},
|
||||
{
|
||||
id: 'battle-cards',
|
||||
title: 'Weapons, defenses and winning cards',
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(
|
||||
specifier === 'next/image' ? 'vinext/shims/image' : specifier,
|
||||
specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier,
|
||||
context,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -14,7 +14,7 @@ import { registerHooks } from 'node:module';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
const aliases = registerHooks({ resolve(specifier, context, next) {
|
||||
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier, context);
|
||||
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
|
||||
} });
|
||||
const { RevealedBattle } = await import('../components/revealed-battle');
|
||||
const { PrivateBattlePlan } = await import('../components/private-battle-plan');
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { registerHooks } from 'node:module';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { applyAction, viewGame, type Game } from '../game/engine';
|
||||
import { baseDeck } from '../game/cards';
|
||||
import { leaderSkillBattle } from './leader-skill-battle-fixture';
|
||||
import { takeBattleCard, diplomatDefenseGame, revealDiplomatPlans } from './diplomat-defense-fixture';
|
||||
const aliases = registerHooks({ resolve(specifier, context, next) {
|
||||
return next(specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier, context);
|
||||
}, load(url, context, next) {
|
||||
if (!url.endsWith('.module.css')) return next(url, context);
|
||||
const css = readFileSync(new URL(url), 'utf8');
|
||||
const classes = Object.fromEntries([...css.matchAll(/\.([a-zA-Z_][\w-]*)/g)].map(m => [m[1], m[1]]));
|
||||
return { format: 'module', source: `export default ${JSON.stringify(classes)}`, shortCircuit: true };
|
||||
} });
|
||||
const { GameTable } = await import('../components/game-table');
|
||||
const { RevealedBattle } = await import('../components/revealed-battle');
|
||||
const { RevealedPlanPieces } = await import('../components/revealed-plan-pieces');
|
||||
aliases.deregister();
|
||||
const render = (g: Game, player: string) => renderToStaticMarkup(createElement(RevealedBattle, { game: viewGame(g, player) }));
|
||||
|
||||
void test('public plan pieces stay absent before both real plans are revealed, including for their owners', () => {
|
||||
let g = leaderSkillBattle({ skill: 'warmaster', unsealed: true, hide: false, advanced: true });
|
||||
const weapon = takeBattleCard(g, 'd', 'poison');
|
||||
for (const p of g.players) assert.equal(render(g, p.id), '');
|
||||
g = applyAction(g, 'd', { type: 'battlePlan', dial: .5, support: 0, leader: 'guild-1', weapon: weapon.id });
|
||||
assert.ok(!g.battle!.revealed);
|
||||
for (const p of g.players) assert.equal(render(g, p.id), '');
|
||||
});
|
||||
|
||||
void test('actual revealed plans show physical faces in attacker/defender order and never include unplayed cards', () => {
|
||||
let g = leaderSkillBattle({ skill: 'warmaster', unsealed: true, hide: false, advanced: true });
|
||||
const ownWeapon = takeBattleCard(g, 'a', 'projectile');
|
||||
const secret = takeBattleCard(g, 'a', 'lasgun');
|
||||
const otherWeapon = takeBattleCard(g, 'd', 'poison');
|
||||
const otherDefense = takeBattleCard(g, 'd', 'snooper');
|
||||
g = applyAction(g, 'd', { type: 'battlePlan', dial: .5, support: 0, leader: 'guild-1', weapon: otherWeapon.id, defense: otherDefense.id });
|
||||
g = applyAction(g, 'a', { type: 'battlePlan', dial: 1, support: 1, leader: 'emperor-1', weapon: ownWeapon.id });
|
||||
assert.ok(g.battle!.revealed);
|
||||
const before = JSON.stringify(g);
|
||||
for (const p of g.players) {
|
||||
const html = render(g, p.id);
|
||||
assert.ok(html.indexOf('aria-label="Attacker plan"') < html.indexOf('aria-label="Defender plan"'));
|
||||
assert.equal((html.match(/aria-label="Revealed force dial"/g) ?? []).length, 2);
|
||||
assert.match(html, /<dd>0.5<\/dd>/);
|
||||
for (const card of [ownWeapon, otherWeapon, otherDefense]) assert.ok(html.includes(`Inspect card: ${card.name}`));
|
||||
assert.ok(!html.includes(`Inspect card: ${secret.name}`));
|
||||
assert.match(html, /aria-label="Defense in original plan: None"/);
|
||||
assert.match(html, /Printed strength/);
|
||||
assert.match(html, /Inspect leader: Captain Aramsham/);
|
||||
assert.match(html, /emperor/);
|
||||
assert.match(html, /href="\/rules\?topic=battle#battle"/);
|
||||
assert.doesNotMatch(html, /type="range"|type="number"|Seal battle plan|Your private status/);
|
||||
assert.equal(render(JSON.parse(before), p.id), html);
|
||||
}
|
||||
assert.equal(JSON.stringify(g), before);
|
||||
});
|
||||
|
||||
void test('Cheap Hero, Worthless-slot use and late defense retain printed faces and explicit slot labels', () => {
|
||||
const cards = baseDeck();
|
||||
const hero = cards.find(c => c.kind === 'hero')!;
|
||||
const worthless = cards.find(c => c.kind === 'worthless')!;
|
||||
const defense = cards.find(c => c.kind === 'snooper')!;
|
||||
// Presentation-only: rules for late defense and physical slot legality have engine tests.
|
||||
const html = renderToStaticMarkup(createElement(RevealedPlanPieces, { dial: 0, leaderCard: hero, weapon: worthless, lateDefense: defense }));
|
||||
assert.ok(html.includes(`aria-label="Leader: ${hero.name}"`));
|
||||
assert.ok(html.includes(`aria-label="Weapon: ${worthless.name}"`));
|
||||
assert.match(html, /Weapon · Worthless/);
|
||||
assert.match(html, /Defense in original plan: None/);
|
||||
assert.ok(html.includes(`aria-label="Added after reveal: ${defense.name}"`));
|
||||
assert.ok(html.includes(`Inspect card: ${hero.name}`));
|
||||
assert.doesNotMatch(html, /Inspect leader:/);
|
||||
});
|
||||
|
||||
void test('an empty revealed plan has clear empty slots and no invented leader or card inspector', () => {
|
||||
const html = renderToStaticMarkup(createElement(RevealedPlanPieces, { dial: 0 }));
|
||||
for (const label of ['Leader', 'Weapon', 'Defense in original plan']) assert.ok(html.includes(`aria-label="${label}: None"`));
|
||||
assert.doesNotMatch(html, /Inspect card:|Inspect leader:|Added after reveal/);
|
||||
});
|
||||
|
||||
void test('both seats retain one shared public plan display while an owned post-reveal choice is pending', () => {
|
||||
const game = revealDiplomatPlans(diplomatDefenseGame());
|
||||
assert.equal(game.decision?.kind, 'diplomatDefense');
|
||||
const before = JSON.stringify(game);
|
||||
for (const player of game.players) {
|
||||
const html = renderToStaticMarkup(createElement(GameTable, { game: viewGame(game, player.id), busy: true,
|
||||
send: async () => { assert.fail('Public inspection must not submit an action.'); }, onExit() {},
|
||||
}));
|
||||
assert.equal((html.match(/aria-label="Revealed battle plans"/g) ?? []).length, 1);
|
||||
assert.equal((html.match(/aria-label="Attacker plan"/g) ?? []).length, 1);
|
||||
assert.equal((html.match(/aria-label="Defender plan"/g) ?? []).length, 1);
|
||||
assert.match(html, /href="#revealed-battle-plans"/);
|
||||
assert.match(html, /href="#table-decisions"/);
|
||||
}
|
||||
assert.equal(JSON.stringify(game), before);
|
||||
});
|
||||
@@ -17,7 +17,7 @@ import { spiceBankerGame } from './spice-banker-fixture';
|
||||
const aliases = registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
return next(
|
||||
specifier === 'next/image' ? 'vinext/shims/image' : specifier,
|
||||
specifier === 'next/image' ? 'vinext/shims/image' : specifier === 'next/link' ? 'vinext/shims/link' : specifier,
|
||||
context,
|
||||
);
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user