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 && ( + + )} +