Connect expansion faction setup and saved Ixian auction decline
Add a local faction-only prototype profile for all seven nonempty expansion selections, genuine Basic/Advanced initialization and existing private setup, controls, AI and saved continuation. Ecaz allocates six forces across Imperial Basin sectors and retains fourteen reserves before later setup; preserve Fremen, Advanced BG and Moritani ordering with printed and inferred boundaries explicit. Keep optional Ecaz Treachery cards separate, ordinary deck counts 33/35/47, and Richese cache separate. Public start and publication gates remain closed. Replace the Advanced Ixian/Richese pre-offer stop with an explicit per-lot Ixian decline. Bind the saved original offer to its round, turn, owner, independent event, exact card custody and terms before view/action/normalization. Decline resumes that unchanged lot once without using Technology; later normal lots still offer the exchange. Protect reserved cards across transfers, spending and random Harkonnen exchange, and preserve legitimate Gift/Truthtrance interruptions. The actual special-lot exchange still awaits replacement-custody interpretation. Validation: focused setup, rules, UI/bot, private JSON/SQLite reload and concurrent CAS tests and independent custody/persistence review passed. Typecheck, lint, 4573 offline tests, production build and 43 HTTP tests passed. Browser Ecaz setup rejected an incomplete total, saved a 2/2/2 split, and refreshed to fourteen reserves and unchanged private cards. A separate genuine Advanced QA game restored the Ixian choice; explicit decline opened the unchanged cache lot with legal bidding and retained private hand, spice, pool count and unused Technology after refresh. Five of six genuine four-profile samples finish: CHOAM Basic 1054 actions/28 JSON restores, CHOAM Advanced 225/6, Ecaz Basic 91/2, Ecaz Advanced 523/14, all-three Basic 476/12. Combined Advanced passes three former auction stops and reaches turn three/action614 with 16 restores, then hits an existing CHOAM movement/Ambassador arrival guard. Ecaz samples also include guarded arrival candidates with legal alternatives. Full combined play is not certified; arrival integration is the next gap. Retain initial failed logs, including a corrected private harness sold-card receipt alias. After the outage, all 597 opening rooms and original seat/recovery records were preserved (632 after QA/HTTP). A stale development module cache required restart after a fresh 598-room backup; all 598 matched afterward and the QA seat restored. Final integrity and foreign keys passed. No database reset, recurring automation or publication. Verified source tree: 5e2ff244a1167f2834bb46d609b8de914a34f7049ff7b06046243068c0935131
This commit is contained in:
1 parent
f067950f5e
commit
cab2f1c42d
26 files changed
+3001
-58
No files matched your search
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import {
|
||||
ECAZ_START_FORCES,
|
||||
ECAZ_START_LOCATIONS,
|
||||
quoteEcazStartingForces,
|
||||
} from '@/game/ecaz-setup';
|
||||
import { splitLocation } from '@/game/board';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
|
||||
export function EcazSetup({
|
||||
game,
|
||||
act,
|
||||
busy,
|
||||
}: {
|
||||
game: GameView;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const [placements, setPlacements] = useState<Record<string, number>>({
|
||||
[ECAZ_START_LOCATIONS[0]]: ECAZ_START_FORCES,
|
||||
});
|
||||
const me = game.players.find((player) => player.id === game.me);
|
||||
if (
|
||||
game.status !== 'setup' ||
|
||||
game.setupStage !== 'forces' ||
|
||||
me?.faction !== 'ecaz' ||
|
||||
me.reserves !== 20 ||
|
||||
!game.setupPending.includes(game.me)
|
||||
)
|
||||
return null;
|
||||
let quoted: Record<string, number> | null = null;
|
||||
try {
|
||||
quoted = quoteEcazStartingForces(placements);
|
||||
} catch {
|
||||
/* Show the allocation until its total is legal. */
|
||||
}
|
||||
const total = Object.values(placements).reduce(
|
||||
(sum, count) => sum + count,
|
||||
0,
|
||||
);
|
||||
return (
|
||||
<section aria-label="Ecaz starting forces" className="flex flex-col gap-3">
|
||||
<p>
|
||||
Place six forces in Imperial Basin. Choose how many go in each sector;
|
||||
fourteen forces remain in reserves.
|
||||
</p>
|
||||
{ECAZ_START_LOCATIONS.map((location) => (
|
||||
<label key={location} htmlFor={`ecaz-start-${location}`}>
|
||||
Imperial Basin · Sector {splitLocation(location).sector}
|
||||
<Input
|
||||
id={`ecaz-start-${location}`}
|
||||
type="number"
|
||||
min={0}
|
||||
max={ECAZ_START_FORCES}
|
||||
step={1}
|
||||
value={placements[location] ?? 0}
|
||||
disabled={busy}
|
||||
onChange={(event) =>
|
||||
setPlacements({
|
||||
...placements,
|
||||
[location]: Number(event.target.value),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<p className="fine" aria-live="polite">
|
||||
{total} / {ECAZ_START_FORCES} forces assigned
|
||||
</p>
|
||||
<Button
|
||||
className="game-action min-h-11"
|
||||
disabled={busy || !quoted}
|
||||
onClick={() => {
|
||||
if (!busy && quoted) act({ type: 'ecazSetup', placements: quoted });
|
||||
}}
|
||||
>
|
||||
Place Ecaz starting forces
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
'use client';
|
||||
import { EcazSetup } from './ecaz-setup';
|
||||
import { IxRicheseTechnology } from './ix-richese-technology';
|
||||
import { nexusGuildCunningAction, nexusGuildCunningActive, nexusGuildMovementAvailable, nexusGuildShipmentAvailable, nexusGuildSkipShipmentAction } from '@/game/nexus-guild-cunning-options';
|
||||
import {
|
||||
matchesShipment,
|
||||
@@ -206,6 +208,7 @@ export function GameTable({
|
||||
const turnOwnerName = g.players.find((p) => p.id === turnOwner)?.name;
|
||||
const setupStage = g.setupStage;
|
||||
const setupFremen = g.players.find((p) => p.faction === 'fremen');
|
||||
const setupEcaz = g.players.find((p) => p.faction === 'ecaz');
|
||||
const setupSisterhood = g.players.find((p) => p.faction === 'beneGesserit');
|
||||
const setupPendingNames = (g.setupPending ?? [])
|
||||
.filter((id) => id !== me.id)
|
||||
@@ -213,6 +216,7 @@ export function GameTable({
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
const waitingForFremen = !!setupFremen && setupFremen.reserves !== 10;
|
||||
const waitingForEcaz = setupStage === 'forces' && !!setupEcaz && setupEcaz.reserves === 20;
|
||||
const setupStatus =
|
||||
setupStage === 'leaderSkills'
|
||||
? g.leaderSkills?.offer
|
||||
@@ -235,6 +239,10 @@ export function GameTable({
|
||||
? 'Place your ten starting forces. Bene Gesserit places its advisor afterward.'
|
||||
: g.leaderSkills ? 'Place your ten starting forces. Starting cards were already dealt before skill assignment.' : 'Place your ten starting forces. Starting cards are dealt automatically after placement.'
|
||||
: `Waiting for ${setupFremen.name} to place the Fremen starting forces.`
|
||||
: waitingForEcaz
|
||||
? setupEcaz.id === me.id
|
||||
? 'Place your six starting forces among the sectors of Imperial Basin. Fourteen remain in reserves.'
|
||||
: `Waiting for ${setupEcaz.name} to place the Ecaz starting forces.`
|
||||
: g.advanced && setupSisterhood && !setupSisterhood.advisorSetup
|
||||
? setupSisterhood.id === me.id
|
||||
? 'Choose a printed board territory and sector for your starting advisor.'
|
||||
@@ -1643,20 +1651,21 @@ export function GameTable({
|
||||
: 'Setup order: prediction, traitors, starting forces, then the automatic treachery-card deal and Storm phase.'}
|
||||
</p>
|
||||
)}
|
||||
<EcazSetup game={g} act={act} busy={busy} />
|
||||
{(!setupStage || setupStage === 'forces') &&
|
||||
g.advanced &&
|
||||
me.faction === 'beneGesserit' &&
|
||||
!me.advisorSetup && (
|
||||
<>
|
||||
<p className="muted">
|
||||
After Fremen placement, choose a territory and sector on
|
||||
After the other starting force choices, choose a territory and sector on
|
||||
the map for your starting advisor. It becomes a fighter if
|
||||
alone.
|
||||
</p>
|
||||
{destination}
|
||||
{waitingForFremen && (
|
||||
{(waitingForFremen || waitingForEcaz) && (
|
||||
<p className="fine" id="advisor-setup-wait">
|
||||
{setupFremen.name} must finish placing the Fremen forces
|
||||
{waitingForFremen ? setupFremen!.name : setupEcaz!.name} must finish placing starting forces
|
||||
before you place an advisor.
|
||||
</p>
|
||||
)}
|
||||
@@ -1670,9 +1679,10 @@ export function GameTable({
|
||||
'Place starting advisor',
|
||||
{ type: 'advisorSetup', territory: selected, sector },
|
||||
waitingForFremen ||
|
||||
waitingForEcaz ||
|
||||
me.reserves !== 20 ||
|
||||
selected === MOBILE_STRONGHOLD,
|
||||
waitingForFremen ? 'advisor-setup-wait' : undefined,
|
||||
waitingForFremen || waitingForEcaz ? 'advisor-setup-wait' : undefined,
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
@@ -2124,7 +2134,9 @@ export function GameTable({
|
||||
<>
|
||||
<span className="eyebrow">Player decision</span>
|
||||
<h2>
|
||||
{g.decision.kind === 'caladanReinforcement'
|
||||
{g.decision.kind === 'ixRicheseTechnology'
|
||||
? 'Ixian Technology · Richese lot'
|
||||
: g.decision.kind === 'caladanReinforcement'
|
||||
? 'Caladan victory reinforcement'
|
||||
: g.decision.kind === 'grummanCollection'
|
||||
? 'Grumman Collection'
|
||||
@@ -2373,6 +2385,8 @@ export function GameTable({
|
||||
act={act}
|
||||
busy={busy}
|
||||
/>
|
||||
) : g.decision.kind === 'ixRicheseTechnology' ? (
|
||||
<IxRicheseTechnology game={g} act={act} busy={busy} />
|
||||
) : g.decision.kind === 'richeseBlackMarket' ||
|
||||
g.decision.kind === 'richeseDeclaration' ||
|
||||
g.decision.kind === 'richeseCache' ||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import type { Action, GameView } from '@/game/engine';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
export function IxRicheseTechnology({
|
||||
game,
|
||||
act,
|
||||
busy,
|
||||
}: {
|
||||
game: GameView;
|
||||
act: (action: Action) => void;
|
||||
busy: boolean;
|
||||
}) {
|
||||
const offer = game.ixRicheseTechnology;
|
||||
if (
|
||||
!offer ||
|
||||
offer.player !== game.me ||
|
||||
game.decision?.kind !== 'ixRicheseTechnology' ||
|
||||
game.decision.player !== game.me ||
|
||||
game.decision.event !== offer.event
|
||||
)
|
||||
return null;
|
||||
return (
|
||||
<section
|
||||
className="flex flex-col gap-4"
|
||||
aria-label="Ixian Technology on a Richese lot"
|
||||
>
|
||||
<p>
|
||||
Richese is offering a{' '}
|
||||
{offer.source === 'cache' ? 'cache' : 'Black Market'} card. Bidding will
|
||||
begin after you decline Technology for this lot.
|
||||
</p>
|
||||
<p className="notice">{offer.exchangeBlocked}</p>
|
||||
<p>
|
||||
Declining leaves your once-per-round Technology use available for later
|
||||
lots. Your hand and the offered card stay unchanged.
|
||||
</p>
|
||||
<Button
|
||||
className="min-h-11 whitespace-normal"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (!busy)
|
||||
act({ type: 'decision', event: offer.event, decline: true });
|
||||
}}
|
||||
>
|
||||
Decline Technology for this lot
|
||||
</Button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user