feat(ui): extinction-chess target cycler in rules drawer
Feature 2 of post-epic-deferrals. Exposes extinction-chess's
configurable targetType through the in-game rules drawer so players
don't need to open devtools or call engine.presetState themselves.
UI:
- RulesDrawer.tsx renders a 'Target: <PieceType>' chip inside the
extinction-chess detail card when the preset is active.
Clicking advances through pawn -> knight -> bishop -> rook ->
queen -> king -> pawn (wraps). Plural labels ('Pawns', 'Knights',
...) for prose.
- data-testid='extinction-target-cycler' on the chip for e2e.
- New optional RulesDrawer props extinctionTarget +
onExtinctionTargetChange; omitted props hide the cycler (no
hard dependency on the engine).
Wiring (GameView.tsx):
- Solo-only per plan decision 2a. Multiplayer games use the
target set at room-creation time; the cycler doesn't render in
MP to avoid desync (a future preset-config.update WS message
could lift this; out of v1 scope).
- Local useState syncs with engine.presetState via a useEffect;
setExtinctionTarget writes back through the same state API and
calls refresh() so legal highlights + terminal-state panels
pick up the target flip.
Docs:
- extinction-chess.ts docblock updated to reference the shipped
UI surface + the MP deferral.
- PRESET-API.md post-landing backlog: remove the 'UI cycling'
deferral (now shipped), add the MP-target-sync deferral.
Tests:
- 2 new rule-variants.spec.ts cases: (a) cycler cycles through
all 6 labels when preset active, (b) cycler hidden when preset
inactive.
Verification: 1663 unit + 89/89 e2e (87 + 2 new). Typecheck + lint
clean.
Plan: .sisyphus/plans/post-epic-deferrals.md Feature 2 complete.
This commit is contained in:
parent
f2dff7e530
commit
0ce500906c
5 changed files with 209 additions and 6 deletions
|
|
@ -599,6 +599,8 @@ Documented but NOT yet available:
|
|||
- Server-side piece-type manifest echo — when custom types are authored outside the shared `packages/chess`.
|
||||
- Save-state migration — versioning for saves that predate attribute additions.
|
||||
- Berolina en-passant — deferred for v1.
|
||||
- Extinction-chess UI cycling — setting target type via a drawer chip.
|
||||
- Extinction-chess multiplayer target sync — solo cycler shipped in
|
||||
post-epic Feature 2; MP target is fixed at room creation until a
|
||||
`preset-config.update` WS message lands.
|
||||
|
||||
File issues or propose extensions via pull request.
|
||||
|
|
|
|||
|
|
@ -93,6 +93,65 @@ async function togglePresetInGameDrawer(
|
|||
await expect(drawer).not.toBeVisible();
|
||||
}
|
||||
|
||||
test.describe('F2 post-epic: extinction-chess target cycler', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.evaluate(() => {
|
||||
for (let i = localStorage.length - 1; i >= 0; i--) {
|
||||
const k = localStorage.key(i);
|
||||
if (k?.startsWith('paratype-chess:')) localStorage.removeItem(k);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('cycler renders when extinction-chess is active; cycles through target types', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Solo game (cycler is solo-only in v1 per plan decision 2a).
|
||||
await page.locator('[data-action="play-solo"]').click();
|
||||
await page.waitForURL('**/game');
|
||||
|
||||
// Open rules drawer and enable extinction-chess.
|
||||
await page.locator('[data-action="open-rules-drawer"]').click();
|
||||
const toggle = page.locator(
|
||||
'[data-preset="extinction-chess"] [data-role="toggle"]',
|
||||
);
|
||||
await expect(toggle).toBeVisible();
|
||||
await toggle.click();
|
||||
|
||||
// Default target is "Pawns" (seeded on activation).
|
||||
const cycler = page.locator('[data-testid="extinction-target-cycler"]');
|
||||
await expect(cycler).toBeVisible();
|
||||
await expect(cycler).toHaveText('Pawns');
|
||||
|
||||
// Cycle: pawn → knight → bishop → rook → queen → king → pawn.
|
||||
const expected = [
|
||||
'Knights',
|
||||
'Bishops',
|
||||
'Rooks',
|
||||
'Queens',
|
||||
'Kings',
|
||||
'Pawns',
|
||||
];
|
||||
for (const label of expected) {
|
||||
await cycler.click();
|
||||
await expect(cycler).toHaveText(label);
|
||||
}
|
||||
});
|
||||
|
||||
test('cycler is absent when extinction-chess is not active', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.locator('[data-action="play-solo"]').click();
|
||||
await page.waitForURL('**/game');
|
||||
await page.locator('[data-action="open-rules-drawer"]').click();
|
||||
// Drawer open, no preset toggled → no cycler.
|
||||
await expect(
|
||||
page.locator('[data-testid="extinction-target-cycler"]'),
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Rule variants — lobby vertical slice', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await resetStorage(page);
|
||||
|
|
|
|||
|
|
@ -44,11 +44,12 @@
|
|||
* This is idempotent: re-activating doesn't stomp a target the
|
||||
* consumer already set during the same activation-cycle.
|
||||
*
|
||||
* UI wiring: intentionally deferred. Phase F cycling a target type
|
||||
* via a UI chip is explicitly out of scope for D.3 per the
|
||||
* rule-variants plan. The API is configuration-driven today; a
|
||||
* future UI patch can wire a dropdown / radio onto this same
|
||||
* presetState slot.
|
||||
* UI wiring: the RulesDrawer renders a "Target: {PieceType}" chip
|
||||
* cycler inside this preset's detail card when the preset is
|
||||
* active in a SOLO game. Solo-only in v1 (post-epic-deferrals
|
||||
* Feature 2, plan decision 2a) — multiplayer games use whatever
|
||||
* target was set at room-creation time. A future `preset-config.update`
|
||||
* WS message could lift the solo-only limitation; not in v1 scope.
|
||||
*
|
||||
* Hook wiring
|
||||
* ───────────
|
||||
|
|
|
|||
|
|
@ -170,6 +170,40 @@ function GameLayout({
|
|||
applyMove(from, to, promoteTo || 'queen');
|
||||
};
|
||||
|
||||
// F2 (post-epic-deferrals): extinction-chess target-type cycler.
|
||||
// Solo-only in v1 (plan decision 2a) — multiplayer games use the
|
||||
// target that was set at room-creation time and won't render the
|
||||
// cycler. We detect "solo" via `roomCode === null` and "active"
|
||||
// via the activation list.
|
||||
const isSolo = roomCode === null;
|
||||
const extinctionActive = activations.some((a) => a.id === 'extinction-chess');
|
||||
const [extinctionTargetState, setExtinctionTargetState] =
|
||||
useState<PieceType>('pawn');
|
||||
// Keep local state in sync with engine preset state whenever the
|
||||
// drawer is about to open (cheap to re-read on every render).
|
||||
useEffect(() => {
|
||||
if (!isSolo || !extinctionActive || engine === null) return;
|
||||
const actual =
|
||||
engine
|
||||
.presetState<{ targetType: PieceType }>('extinction-chess')
|
||||
.get('targetType') ?? 'pawn';
|
||||
if (actual !== extinctionTargetState) setExtinctionTargetState(actual);
|
||||
}, [isSolo, extinctionActive, engine, extinctionTargetState]);
|
||||
const setExtinctionTarget = (next: PieceType) => {
|
||||
if (engine === null) return;
|
||||
engine
|
||||
.presetState<{ targetType: PieceType }>('extinction-chess')
|
||||
.set('targetType', next);
|
||||
setExtinctionTargetState(next);
|
||||
// Recompute legal highlights / panels that depend on terminal
|
||||
// state (extinction target flip can flip a game-result).
|
||||
refresh();
|
||||
};
|
||||
const extinctionTarget =
|
||||
isSolo && extinctionActive && engine !== null
|
||||
? extinctionTargetState
|
||||
: null;
|
||||
|
||||
const isGameOver = result !== 'ongoing';
|
||||
|
||||
// Confetti on any decisive win (checkmate or variant win condition).
|
||||
|
|
@ -278,6 +312,12 @@ function GameLayout({
|
|||
{...(sendRegisterCustomModifier !== undefined
|
||||
? { onShareCustomModifierWithRoom: sendRegisterCustomModifier }
|
||||
: {})}
|
||||
{...(extinctionTarget !== null
|
||||
? {
|
||||
extinctionTarget,
|
||||
onExtinctionTargetChange: setExtinctionTarget,
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
<ModifierProposalDialog
|
||||
proposal={modifierProposal || null}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,21 @@ interface RulesDrawerProps {
|
|||
onShareCustomModifierWithRoom?: (
|
||||
descriptor: import('../modifiers/custom/types.js').CustomModifierDescriptor,
|
||||
) => void;
|
||||
/**
|
||||
* Feature 2 (post-epic-deferrals): the current target-type for the
|
||||
* `extinction-chess` preset. When provided AND extinction-chess is
|
||||
* active, the drawer renders a chip cycler letting the user pick
|
||||
* which piece type is the extinction target.
|
||||
*
|
||||
* Omitting these props (e.g. in the lobby preview, or when the
|
||||
* engine is unavailable) hides the cycler — the preset still uses
|
||||
* its default target ("pawn").
|
||||
*
|
||||
* Solo-only in v1: multiplayer target-sync via a new WS message is
|
||||
* a deferred v2 follow-up (plan decision 2a).
|
||||
*/
|
||||
extinctionTarget?: import('../schema.js').PieceType;
|
||||
onExtinctionTargetChange?: (next: import('../schema.js').PieceType) => void;
|
||||
}
|
||||
|
||||
/** Build a fresh activation list that reflects a single edit. */
|
||||
|
|
@ -69,11 +84,84 @@ function applyEdit(
|
|||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered list of target types for the extinction-chess cycler.
|
||||
* Mirrors chess's six canonical PieceType values in a UX-friendly
|
||||
* order (pawns first because they're the default, then forward
|
||||
* through the value ladder). Clicking the chip advances one step and
|
||||
* wraps around at the end.
|
||||
*/
|
||||
const EXTINCTION_TARGET_ORDER: readonly import('../schema.js').PieceType[] = [
|
||||
'pawn',
|
||||
'knight',
|
||||
'bishop',
|
||||
'rook',
|
||||
'queen',
|
||||
'king',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* F2 (post-epic-deferrals): compact cycler for extinction-chess's
|
||||
* configurable target type. Rendered inline inside the preset's
|
||||
* detail block when the preset is active. Solo-only in v1 — the
|
||||
* parent (GameView) is responsible for wiring the target to the
|
||||
* engine's preset state; in multiplayer games the host's initial
|
||||
* target is authoritative and this cycler is hidden or read-only
|
||||
* (Feature 2 plan decision 2a).
|
||||
*/
|
||||
function ExtinctionTargetCycler({
|
||||
target,
|
||||
onChange,
|
||||
}: {
|
||||
target: import('../schema.js').PieceType;
|
||||
onChange: (next: import('../schema.js').PieceType) => void;
|
||||
}) {
|
||||
const idx = EXTINCTION_TARGET_ORDER.indexOf(target);
|
||||
const cycle = () => {
|
||||
const next =
|
||||
EXTINCTION_TARGET_ORDER[
|
||||
(idx + 1) % EXTINCTION_TARGET_ORDER.length
|
||||
]!;
|
||||
onChange(next);
|
||||
};
|
||||
// Plural label for prose: "Target: Pawns" reads better than "Pawn".
|
||||
const plural =
|
||||
target === 'knight'
|
||||
? 'Knights'
|
||||
: target === 'bishop'
|
||||
? 'Bishops'
|
||||
: target === 'rook'
|
||||
? 'Rooks'
|
||||
: target === 'queen'
|
||||
? 'Queens'
|
||||
: target === 'king'
|
||||
? 'Kings'
|
||||
: 'Pawns';
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<label className="text-xs font-semibold text-neutral-600 uppercase tracking-wide">
|
||||
Target
|
||||
</label>
|
||||
<button
|
||||
data-testid="extinction-target-cycler"
|
||||
type="button"
|
||||
onClick={cycle}
|
||||
className="px-2.5 py-1 rounded-md bg-neutral-100 hover:bg-neutral-200 text-xs font-medium text-neutral-700"
|
||||
aria-label={`Extinction target (currently ${plural}); click to cycle`}
|
||||
>
|
||||
{plural}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RulesDrawer({
|
||||
activations,
|
||||
setPresets,
|
||||
onRulesChanged,
|
||||
onShareCustomModifierWithRoom,
|
||||
extinctionTarget,
|
||||
onExtinctionTargetChange,
|
||||
}: RulesDrawerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [modifierEditorOpen, setModifierEditorOpen] = useState(false);
|
||||
|
|
@ -518,6 +606,19 @@ export function RulesDrawer({
|
|||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* F2 (post-epic-deferrals): extinction-chess
|
||||
target cycler. Only renders when the preset
|
||||
is extinction-chess AND the parent threaded
|
||||
the target-state props. */}
|
||||
{preset.id === 'extinction-chess' &&
|
||||
extinctionTarget !== undefined &&
|
||||
onExtinctionTargetChange !== undefined && (
|
||||
<ExtinctionTargetCycler
|
||||
target={extinctionTarget}
|
||||
onChange={onExtinctionTargetChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue