feat(ui): multi-profile stacking in lobby

T3 Wave 4 (T27). Lobby now supports stacking multiple modifier
profiles for solo play. The primary picker stays single-select for
backwards compatibility with multiplayer create/join (which sends
exactly one ModifierProfile on the wire today); a 'Stacked (solo
only)' list below it lets the user append additional profiles, with
up/down reorder and remove buttons per entry.

State:
- additionalProfiles: ModifierProfile[] holds the stack-on-top
  entries. Empty by default; appears in the UI only when the primary
  picker has a selection.

resetToFreshGame branches:
- 0 or 1 profile total → unchanged single-profile fast path through
  EngineOptions.profile (preserves T1/T2 behaviour exactly).
- 2+ profiles → constructs a profile-less engine, then runs
  applyProfilesToSession across the full ordered stack so additive
  contributions compose correctly across profiles. Sets activeProfile
  to the LAST entry as a UI-display canonical (header badge etc.).

UI:
- Primary picker unchanged.
- profile-stack list with data-testids profile-stack, profile-stack-{i},
  profile-stack-{i}-up/down/remove for e2e access.
- profile-stack-add dropdown filters out already-selected ids.

Multiplayer wire shape unchanged — sending the stack to the server is
a future T27 extension that would touch the protocol.

E2E for the new stacking UI defers to T29; existing 9/9 solo-smoke
+ all 1378 unit tests pass.
This commit is contained in:
Joey Yakimowich-Payne 2026-04-19 20:22:58 -06:00
commit 31af101b55
No known key found for this signature in database

View file

@ -17,6 +17,7 @@ import { LayoutPicker } from './LayoutPicker';
import { LayoutEditor } from './LayoutEditor';
import { ModifierProfileEditor } from './ModifierProfileEditor';
import { parseModifierProfile } from '../modifiers/schema';
import { applyProfilesToSession } from '../modifiers/apply';
import type { ModifierProfile } from '../modifiers/types';
import {
loadLibrary,
@ -78,6 +79,19 @@ export function Lobby({ chessState }: LobbyProps = {}) {
const [profilePickerValue, setProfilePickerValue] =
useState<string>(PROFILE_NONE);
const [profileEditorOpen, setProfileEditorOpen] = useState(false);
/**
* T27: optional ADDITIONAL profiles stacked on top of `selectedProfile`
* for the local solo engine. The first entry in the resulting stack is
* `selectedProfile`; subsequent entries override per the priority-wins
* stacking rule (last wins). Empty when no extras are added.
*
* Multiplayer create/join still sends ONLY `selectedProfile` on the
* wire — the server protocol carries one ModifierProfile per room
* (T2). Multi-stack on the wire is a future T27+ extension.
*/
const [additionalProfiles, setAdditionalProfiles] = useState<
ModifierProfile[]
>([]);
useEffect(() => {
const layoutId = searchParams.get('layoutId');
@ -223,18 +237,44 @@ export function Lobby({ chessState }: LobbyProps = {}) {
// the selected layout so the board shows the chosen starting
// position the instant the user lands on /game.
clearAllAutoSaves();
// Pass the selected profile (if any) so the local engine seeds
// modifier facts and tracks `activeProfile`. Without this, the
// server reconciles the facts via game.state snapshots but the
// client's `engine.activeProfile` stays null, breaking source-chain
// attribution and any other UI that needs to know the active profile.
chessState?.loadEngine(
new ChessEngine(
selectedProfile !== null
? { layout: selectedLayout, profile: selectedProfile }
: { layout: selectedLayout },
),
// Construct the engine. The simple path (zero or one profile) goes
// through the EngineOptions.profile field. The multi-profile path
// (T27) builds the engine without a profile, then runs
// applyProfilesToSession across the full stack so additive
// contributions compose correctly across profiles.
const stack: ModifierProfile[] =
selectedProfile !== null
? [selectedProfile, ...additionalProfiles]
: additionalProfiles;
if (stack.length <= 1) {
const single = stack[0];
chessState?.loadEngine(
new ChessEngine(
single !== undefined
? { layout: selectedLayout, profile: single }
: { layout: selectedLayout },
),
);
return;
}
// Multi-profile stack: build a profile-less engine, then layer
// every profile in order via applyProfilesToSession (T23).
const engine = new ChessEngine({ layout: selectedLayout });
applyProfilesToSession(
engine.session,
stack,
selectedLayout,
engine,
engine.customModifiers,
);
// Track the LAST profile as the canonical activeProfile so the
// header badge has something to show; the editor-side display
// would need a multi-aware refactor to surface every entry.
engine.activeProfile = stack[stack.length - 1] ?? null;
chessState?.loadEngine(engine);
};
const handleCreate = async () => {
@ -425,6 +465,109 @@ export function Lobby({ chessState }: LobbyProps = {}) {
) : null}
<option value={PROFILE_CUSTOM}>Custom…</option>
</select>
{/* T27: stacked profiles below the primary picker.
Solo-only — multiplayer create/join sends only the
primary `selectedProfile`. Each entry can be reordered
or removed; ordering decides priority-wins precedence
(last in list wins). */}
<div data-testid="profile-stack" className="space-y-1">
{additionalProfiles.length > 0 && (
<p className="text-[10px] font-bold text-neutral-400 uppercase tracking-widest pt-2">
Stacked (solo only)
</p>
)}
{additionalProfiles.map((p, idx) => (
<div
key={`${p.id}-${idx}`}
data-testid={`profile-stack-${idx}`}
className="flex items-center gap-1.5 px-2 py-1 text-xs bg-neutral-50 border border-neutral-200 rounded"
>
<span className="flex-1 truncate text-neutral-700 font-medium">
{p.name}
</span>
<button
type="button"
data-testid={`profile-stack-${idx}-up`}
disabled={idx === 0}
onClick={() => {
setAdditionalProfiles((prev) => {
const next = prev.slice();
const [moved] = next.splice(idx, 1);
if (moved !== undefined) next.splice(idx - 1, 0, moved);
return next;
});
}}
className="px-1 text-neutral-500 hover:bg-neutral-200 disabled:opacity-30 disabled:hover:bg-transparent rounded"
aria-label="Move up"
>
↑
</button>
<button
type="button"
data-testid={`profile-stack-${idx}-down`}
disabled={idx === additionalProfiles.length - 1}
onClick={() => {
setAdditionalProfiles((prev) => {
const next = prev.slice();
const [moved] = next.splice(idx, 1);
if (moved !== undefined) next.splice(idx + 1, 0, moved);
return next;
});
}}
className="px-1 text-neutral-500 hover:bg-neutral-200 disabled:opacity-30 disabled:hover:bg-transparent rounded"
aria-label="Move down"
>
↓
</button>
<button
type="button"
data-testid={`profile-stack-${idx}-remove`}
onClick={() => {
setAdditionalProfiles((prev) =>
prev.filter((_, i) => i !== idx),
);
}}
className="px-1 text-neutral-500 hover:bg-red-100 hover:text-red-600 rounded"
aria-label="Remove from stack"
>
×
</button>
</div>
))}
{selectedProfile !== null && savedProfiles.length > 1 && (
<select
data-testid="profile-stack-add"
value=""
onChange={(e) => {
const id = e.target.value;
if (id === '') return;
const entry = savedProfiles.find((p) => p.id === id);
if (entry === undefined) return;
// Prevent duplicate of the primary or any existing stack entry.
if (entry.profile.id === selectedProfile.id) return;
if (additionalProfiles.some((p) => p.id === entry.profile.id)) return;
setAdditionalProfiles((prev) => [...prev, entry.profile]);
}}
className="w-full px-2 py-1 text-xs bg-white border border-neutral-200 rounded focus:outline-none focus:ring-1 focus:ring-blue-500"
>
<option value="">+ Stack another profile…</option>
{savedProfiles
.filter(
(p) =>
p.profile.id !== selectedProfile.id &&
!additionalProfiles.some(
(a) => a.id === p.profile.id,
),
)
.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
)}
</div>
</div>
<button