From 31af101b5577ad2a5244bb3e12384785be2f031b Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Sun, 19 Apr 2026 20:22:58 -0600 Subject: [PATCH] feat(ui): multi-profile stacking in lobby MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/chess/src/ui/Lobby.tsx | 165 +++++++++++++++++++++++++++++--- 1 file changed, 154 insertions(+), 11 deletions(-) diff --git a/packages/chess/src/ui/Lobby.tsx b/packages/chess/src/ui/Lobby.tsx index 87c7ef4..af68411 100644 --- a/packages/chess/src/ui/Lobby.tsx +++ b/packages/chess/src/ui/Lobby.tsx @@ -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(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} + + {/* 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). */} +
+ {additionalProfiles.length > 0 && ( +

+ Stacked (solo only) +

+ )} + {additionalProfiles.map((p, idx) => ( +
+ + {p.name} + + + + +
+ ))} + {selectedProfile !== null && savedProfiles.length > 1 && ( + + )} +