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`.
|
- 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.
|
- Save-state migration — versioning for saves that predate attribute additions.
|
||||||
- Berolina en-passant — deferred for v1.
|
- 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.
|
File issues or propose extensions via pull request.
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,65 @@ async function togglePresetInGameDrawer(
|
||||||
await expect(drawer).not.toBeVisible();
|
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.describe('Rule variants — lobby vertical slice', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await resetStorage(page);
|
await resetStorage(page);
|
||||||
|
|
|
||||||
|
|
@ -44,11 +44,12 @@
|
||||||
* This is idempotent: re-activating doesn't stomp a target the
|
* This is idempotent: re-activating doesn't stomp a target the
|
||||||
* consumer already set during the same activation-cycle.
|
* consumer already set during the same activation-cycle.
|
||||||
*
|
*
|
||||||
* UI wiring: intentionally deferred. Phase F cycling a target type
|
* UI wiring: the RulesDrawer renders a "Target: {PieceType}" chip
|
||||||
* via a UI chip is explicitly out of scope for D.3 per the
|
* cycler inside this preset's detail card when the preset is
|
||||||
* rule-variants plan. The API is configuration-driven today; a
|
* active in a SOLO game. Solo-only in v1 (post-epic-deferrals
|
||||||
* future UI patch can wire a dropdown / radio onto this same
|
* Feature 2, plan decision 2a) — multiplayer games use whatever
|
||||||
* presetState slot.
|
* 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
|
* Hook wiring
|
||||||
* ───────────
|
* ───────────
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,40 @@ function GameLayout({
|
||||||
applyMove(from, to, promoteTo || 'queen');
|
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';
|
const isGameOver = result !== 'ongoing';
|
||||||
|
|
||||||
// Confetti on any decisive win (checkmate or variant win condition).
|
// Confetti on any decisive win (checkmate or variant win condition).
|
||||||
|
|
@ -278,6 +312,12 @@ function GameLayout({
|
||||||
{...(sendRegisterCustomModifier !== undefined
|
{...(sendRegisterCustomModifier !== undefined
|
||||||
? { onShareCustomModifierWithRoom: sendRegisterCustomModifier }
|
? { onShareCustomModifierWithRoom: sendRegisterCustomModifier }
|
||||||
: {})}
|
: {})}
|
||||||
|
{...(extinctionTarget !== null
|
||||||
|
? {
|
||||||
|
extinctionTarget,
|
||||||
|
onExtinctionTargetChange: setExtinctionTarget,
|
||||||
|
}
|
||||||
|
: {})}
|
||||||
/>
|
/>
|
||||||
<ModifierProposalDialog
|
<ModifierProposalDialog
|
||||||
proposal={modifierProposal || null}
|
proposal={modifierProposal || null}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,21 @@ interface RulesDrawerProps {
|
||||||
onShareCustomModifierWithRoom?: (
|
onShareCustomModifierWithRoom?: (
|
||||||
descriptor: import('../modifiers/custom/types.js').CustomModifierDescriptor,
|
descriptor: import('../modifiers/custom/types.js').CustomModifierDescriptor,
|
||||||
) => void;
|
) => 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. */
|
/** Build a fresh activation list that reflects a single edit. */
|
||||||
|
|
@ -69,11 +84,84 @@ function applyEdit(
|
||||||
return next;
|
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({
|
export function RulesDrawer({
|
||||||
activations,
|
activations,
|
||||||
setPresets,
|
setPresets,
|
||||||
onRulesChanged,
|
onRulesChanged,
|
||||||
onShareCustomModifierWithRoom,
|
onShareCustomModifierWithRoom,
|
||||||
|
extinctionTarget,
|
||||||
|
onExtinctionTargetChange,
|
||||||
}: RulesDrawerProps) {
|
}: RulesDrawerProps) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [modifierEditorOpen, setModifierEditorOpen] = useState(false);
|
const [modifierEditorOpen, setModifierEditorOpen] = useState(false);
|
||||||
|
|
@ -518,6 +606,19 @@ export function RulesDrawer({
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue