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:
parent
9b586b83b5
commit
31af101b55
1 changed files with 154 additions and 11 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue