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 { LayoutEditor } from './LayoutEditor';
|
||||||
import { ModifierProfileEditor } from './ModifierProfileEditor';
|
import { ModifierProfileEditor } from './ModifierProfileEditor';
|
||||||
import { parseModifierProfile } from '../modifiers/schema';
|
import { parseModifierProfile } from '../modifiers/schema';
|
||||||
|
import { applyProfilesToSession } from '../modifiers/apply';
|
||||||
import type { ModifierProfile } from '../modifiers/types';
|
import type { ModifierProfile } from '../modifiers/types';
|
||||||
import {
|
import {
|
||||||
loadLibrary,
|
loadLibrary,
|
||||||
|
|
@ -78,6 +79,19 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
||||||
const [profilePickerValue, setProfilePickerValue] =
|
const [profilePickerValue, setProfilePickerValue] =
|
||||||
useState<string>(PROFILE_NONE);
|
useState<string>(PROFILE_NONE);
|
||||||
const [profileEditorOpen, setProfileEditorOpen] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
const layoutId = searchParams.get('layoutId');
|
const layoutId = searchParams.get('layoutId');
|
||||||
|
|
@ -223,18 +237,44 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
||||||
// the selected layout so the board shows the chosen starting
|
// the selected layout so the board shows the chosen starting
|
||||||
// position the instant the user lands on /game.
|
// position the instant the user lands on /game.
|
||||||
clearAllAutoSaves();
|
clearAllAutoSaves();
|
||||||
// Pass the selected profile (if any) so the local engine seeds
|
|
||||||
// modifier facts and tracks `activeProfile`. Without this, the
|
// Construct the engine. The simple path (zero or one profile) goes
|
||||||
// server reconciles the facts via game.state snapshots but the
|
// through the EngineOptions.profile field. The multi-profile path
|
||||||
// client's `engine.activeProfile` stays null, breaking source-chain
|
// (T27) builds the engine without a profile, then runs
|
||||||
// attribution and any other UI that needs to know the active profile.
|
// applyProfilesToSession across the full stack so additive
|
||||||
chessState?.loadEngine(
|
// contributions compose correctly across profiles.
|
||||||
new ChessEngine(
|
const stack: ModifierProfile[] =
|
||||||
selectedProfile !== null
|
selectedProfile !== null
|
||||||
? { layout: selectedLayout, profile: selectedProfile }
|
? [selectedProfile, ...additionalProfiles]
|
||||||
: { layout: selectedLayout },
|
: 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 () => {
|
const handleCreate = async () => {
|
||||||
|
|
@ -425,6 +465,109 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
||||||
) : null}
|
) : null}
|
||||||
<option value={PROFILE_CUSTOM}>Custom…</option>
|
<option value={PROFILE_CUSTOM}>Custom…</option>
|
||||||
</select>
|
</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>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue