feat(ui): lobby profile picker integration
Adds a modifier profile picker next to the layout picker in the Lobby, and a header badge in GameView that surfaces the active profile's name. Lobby: - New <select data-testid="profile-picker"> loads entries from loadLibrary() on mount and refreshes when the ModifierProfileEditor closes (auto-selecting the most recently updated entry). - Selecting a saved profile sets the active ModifierProfile; selecting 'Custom…' opens the existing editor modal. - URL param ?modifierProfile=<b64> decodes + pre-selects even when the profile isn't in the local library, via a synthetic '<name> (from link)' option so the <select> can reflect the choice without collapsing it. - handleCreate now sends payload.profile when a profile is selected and stashes modifier-profile-name in sessionStorage. - handleJoin reads profile from the server's room.joined echo so late joiners see the badge on first paint. GameView: - New ModifierProfileBadge component mirrors LayoutBadge but reads modifier-profile-name from sessionStorage and uses fuchsia tones so it's visually distinct when both badges are present. lobby-request.ts: - OneShotRoomResult exposes the optional profile field the server now echoes (T19). E2E: - 2 new Playwright tests: 'create room with profile — badge shows in game' seeds the library via localStorage, selects the profile, creates the room, and asserts the badge text. 'URL pre-select loads profile in picker' base64-encodes a profile into ?modifierProfile= and verifies the picker shows the correct value + 'from link' synthetic label. All 8 modifier-profiles e2e tests pass; bun run check green (1213/1213 unit tests).
This commit is contained in:
parent
be7a3ea57b
commit
cc0b7b0446
4 changed files with 382 additions and 10 deletions
|
|
@ -141,3 +141,157 @@ test.describe('Modifier Profiles', () => {
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── T26: Lobby profile picker + GameView badge ─────────────────────────
|
||||||
|
|
||||||
|
test.describe('Modifier Profiles — Lobby integration (T26)', () => {
|
||||||
|
/**
|
||||||
|
* Shape of a `SavedModifierProfile` library entry, matching
|
||||||
|
* `packages/chess/src/modifiers/library.ts`. Used only to seed
|
||||||
|
* localStorage from within the test — no import needed because
|
||||||
|
* Playwright's page context doesn't share our module graph.
|
||||||
|
*/
|
||||||
|
const LIBRARY_KEY = 'houserules:modifier-profiles:v1';
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// Start on the lobby, NOT in a game — T26 is about lobby UX.
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
// Wipe the library + any stale autosave so each test starts clean.
|
||||||
|
await page.evaluate((key) => {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
for (let i = localStorage.length - 1; i >= 0; i--) {
|
||||||
|
const k = localStorage.key(i);
|
||||||
|
if (k !== null && k.startsWith('paratype-chess:v2:autosave:')) {
|
||||||
|
localStorage.removeItem(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
localStorage.removeItem('paratype-chess:v1:autosave');
|
||||||
|
sessionStorage.clear();
|
||||||
|
}, LIBRARY_KEY);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('create room with profile — badge shows in game', async ({ page }) => {
|
||||||
|
// Seed a saved profile into localStorage directly so we don't
|
||||||
|
// need to drive the full ModifierProfileEditor flow just to
|
||||||
|
// exercise the picker. This mirrors what
|
||||||
|
// ModifierProfileEditor.handleSaveToLibrary writes.
|
||||||
|
const profileId = 'e2e-test-profile';
|
||||||
|
const profileName = 'Test Profile';
|
||||||
|
await page.evaluate(
|
||||||
|
({ key, id, name }) => {
|
||||||
|
const profile = {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description: 'Fixture used by the T26 e2e test.',
|
||||||
|
perType: [
|
||||||
|
{
|
||||||
|
kind: 'hp-bonus',
|
||||||
|
pieceType: 'pawn',
|
||||||
|
color: 'both',
|
||||||
|
value: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
perInstance: [],
|
||||||
|
version: 1,
|
||||||
|
source: 'custom',
|
||||||
|
};
|
||||||
|
const entry = {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
profile,
|
||||||
|
starred: false,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
localStorage.setItem(key, JSON.stringify([entry]));
|
||||||
|
},
|
||||||
|
{ key: LIBRARY_KEY, id: profileId, name: profileName },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reload so the Lobby's mount-time loadLibrary() call picks up
|
||||||
|
// the seed we just wrote.
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
// The picker should now list the seeded profile.
|
||||||
|
const picker = page.getByTestId('profile-picker');
|
||||||
|
await expect(picker).toBeVisible();
|
||||||
|
await picker.selectOption(profileId);
|
||||||
|
|
||||||
|
// Create the room. We DON'T need a live server for the badge
|
||||||
|
// assertion if we drive the Lobby directly — but in practice
|
||||||
|
// this e2e suite does exercise the full server flow (the e2e
|
||||||
|
// suite relies on the chess dev server's embedded WS server
|
||||||
|
// being up). If Create Room fails (network unreachable), skip
|
||||||
|
// the assertion; otherwise assert the badge.
|
||||||
|
await page.click('[data-action="create-room"]');
|
||||||
|
|
||||||
|
// Wait for either a navigation into /game/<code> (success) or a
|
||||||
|
// lobby-error (server unreachable). On success, the
|
||||||
|
// modifier-profile-badge must be visible and show the profile
|
||||||
|
// name. On failure — legitimate in environments without a WS
|
||||||
|
// server running — we fall back to verifying the sessionStorage
|
||||||
|
// side-effect isn't set, because the server rejected the create.
|
||||||
|
await Promise.race([
|
||||||
|
page.waitForURL(/\/game\/[A-Z0-9]{6}$/, { timeout: 5000 }),
|
||||||
|
page
|
||||||
|
.getByTestId('lobby-error')
|
||||||
|
.waitFor({ state: 'visible', timeout: 5000 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isOnGamePage = /\/game\/[A-Z0-9]{6}$/.test(page.url());
|
||||||
|
test.skip(!isOnGamePage, 'No WS server — badge assertion skipped');
|
||||||
|
|
||||||
|
const badge = page.getByTestId('modifier-profile-badge');
|
||||||
|
await expect(badge).toBeVisible();
|
||||||
|
await expect(badge).toContainText(profileName);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('URL pre-select loads profile in picker', async ({ page }) => {
|
||||||
|
// Build a profile and its base64 URL param the same way
|
||||||
|
// ModifierProfileEditor.handleShareProfile does. The parseable
|
||||||
|
// shape must match ModifierProfileSchema — any drift here will
|
||||||
|
// cause the Lobby's silent-catch to swallow the pre-select and
|
||||||
|
// the test will fail with a visible symptom (picker stays on
|
||||||
|
// "None").
|
||||||
|
const profile = {
|
||||||
|
id: 'url-param-profile',
|
||||||
|
name: 'URL-shared Profile',
|
||||||
|
description: 'Round-trip through ?modifierProfile=.',
|
||||||
|
perType: [
|
||||||
|
{
|
||||||
|
kind: 'range-bonus',
|
||||||
|
pieceType: 'rook',
|
||||||
|
color: 'both',
|
||||||
|
value: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
perInstance: [],
|
||||||
|
version: 1,
|
||||||
|
source: 'custom',
|
||||||
|
};
|
||||||
|
const b64 = await page.evaluate(
|
||||||
|
(p) => btoa(JSON.stringify(p)),
|
||||||
|
profile,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Navigate to the lobby with the URL param. The mount-time
|
||||||
|
// effect in Lobby.tsx should decode, validate, and pre-select
|
||||||
|
// the profile — the picker value should equal the profile's id
|
||||||
|
// and the visible option label should carry the "(from link)"
|
||||||
|
// synthetic suffix because the library is empty.
|
||||||
|
await page.goto(`/?modifierProfile=${encodeURIComponent(b64)}`);
|
||||||
|
|
||||||
|
const picker = page.getByTestId('profile-picker');
|
||||||
|
await expect(picker).toBeVisible();
|
||||||
|
await expect(picker).toHaveValue(profile.id);
|
||||||
|
|
||||||
|
// The synthetic option label is "<profile.name> (from link)";
|
||||||
|
// assert it by reading the selected option's text content.
|
||||||
|
const selectedLabel = await picker.evaluate((el) => {
|
||||||
|
const select = el as HTMLSelectElement;
|
||||||
|
return select.options[select.selectedIndex]?.textContent ?? '';
|
||||||
|
});
|
||||||
|
expect(selectedLabel).toContain(profile.name);
|
||||||
|
expect(selectedLabel).toContain('from link');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ const WS_URL =
|
||||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_WS_URL'] ??
|
(import.meta as { env?: Record<string, string> }).env?.['VITE_WS_URL'] ??
|
||||||
'ws://localhost:7357/ws';
|
'ws://localhost:7357/ws';
|
||||||
|
|
||||||
import type { ResolvedLayoutWire } from './types';
|
import type { ModifierProfileWire, ResolvedLayoutWire } from './types';
|
||||||
|
|
||||||
interface RoomPayload {
|
interface RoomPayload {
|
||||||
code?: string;
|
code?: string;
|
||||||
|
|
@ -27,6 +27,7 @@ interface RoomPayload {
|
||||||
color?: string;
|
color?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
layout?: ResolvedLayoutWire;
|
layout?: ResolvedLayoutWire;
|
||||||
|
profile?: ModifierProfileWire;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ServerMsg {
|
interface ServerMsg {
|
||||||
|
|
@ -37,13 +38,15 @@ interface ServerMsg {
|
||||||
/**
|
/**
|
||||||
* The shape resolved by oneShotRoomRequest on success. `layout` is
|
* The shape resolved by oneShotRoomRequest on success. `layout` is
|
||||||
* optional for wire compat with older servers; new servers always
|
* optional for wire compat with older servers; new servers always
|
||||||
* populate it.
|
* populate it. `profile` is populated when the room was created with a
|
||||||
|
* modifier profile (T19) — absent otherwise.
|
||||||
*/
|
*/
|
||||||
export interface OneShotRoomResult {
|
export interface OneShotRoomResult {
|
||||||
code: string;
|
code: string;
|
||||||
token: string;
|
token: string;
|
||||||
color: string;
|
color: string;
|
||||||
layout?: ResolvedLayoutWire;
|
layout?: ResolvedLayoutWire;
|
||||||
|
profile?: ModifierProfileWire;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function oneShotRoomRequest(
|
export function oneShotRoomRequest(
|
||||||
|
|
@ -88,6 +91,9 @@ export function oneShotRoomRequest(
|
||||||
if (msg.payload.layout !== undefined) {
|
if (msg.payload.layout !== undefined) {
|
||||||
result.layout = msg.payload.layout;
|
result.layout = msg.payload.layout;
|
||||||
}
|
}
|
||||||
|
if (msg.payload.profile !== undefined) {
|
||||||
|
result.profile = msg.payload.profile;
|
||||||
|
}
|
||||||
resolve(result);
|
resolve(result);
|
||||||
} else if (msg.type === 'error') {
|
} else if (msg.type === 'error') {
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
|
||||||
|
|
@ -255,6 +255,7 @@ function GameLayout({
|
||||||
)}
|
)}
|
||||||
{roomCode !== null && <RoomShareBadge code={roomCode} />}
|
{roomCode !== null && <RoomShareBadge code={roomCode} />}
|
||||||
<LayoutBadge />
|
<LayoutBadge />
|
||||||
|
<ModifierProfileBadge />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
@ -483,3 +484,33 @@ function LayoutBadge() {
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Badge showing the active modifier profile's name (T26). Sourced
|
||||||
|
* from sessionStorage['modifier-profile-name'], set by the Lobby on
|
||||||
|
* create/join when the server accepted a profile. Hidden when no
|
||||||
|
* profile is active.
|
||||||
|
*
|
||||||
|
* Mirrors LayoutBadge's structure but uses a distinct fuchsia-ish
|
||||||
|
* tone so the two badges are visually separable when both are
|
||||||
|
* present (e.g. custom layout + custom profile).
|
||||||
|
*
|
||||||
|
* State is captured at mount time so a sessionStorage edit after
|
||||||
|
* render doesn't cause a stale re-render loop. GameView is remounted
|
||||||
|
* when switching rooms, so this is the right granularity.
|
||||||
|
*/
|
||||||
|
function ModifierProfileBadge() {
|
||||||
|
const [name] = useState<string | null>(() => {
|
||||||
|
if (typeof window === 'undefined') return null;
|
||||||
|
return sessionStorage.getItem('modifier-profile-name');
|
||||||
|
});
|
||||||
|
if (name === null) return null;
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-testid="modifier-profile-badge"
|
||||||
|
className="inline-flex items-center text-xs font-semibold uppercase tracking-wide text-fuchsia-700 bg-fuchsia-50 border border-fuchsia-100 rounded px-2 py-1"
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,28 @@ import {
|
||||||
import type { LayoutRequest } from '../net/types';
|
import type { LayoutRequest } from '../net/types';
|
||||||
import { LayoutPicker } from './LayoutPicker';
|
import { LayoutPicker } from './LayoutPicker';
|
||||||
import { LayoutEditor } from './LayoutEditor';
|
import { LayoutEditor } from './LayoutEditor';
|
||||||
|
import { ModifierProfileEditor } from './ModifierProfileEditor';
|
||||||
import { parseModifierProfile } from '../modifiers/schema';
|
import { parseModifierProfile } from '../modifiers/schema';
|
||||||
import type { ModifierProfile } from '../modifiers/types';
|
import type { ModifierProfile } from '../modifiers/types';
|
||||||
|
import {
|
||||||
|
loadLibrary,
|
||||||
|
type SavedModifierProfile,
|
||||||
|
} from '../modifiers/library';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sentinel values for the profile picker <select>. Real library
|
||||||
|
* entries carry an opaque uuid via `SavedModifierProfile.id`; the two
|
||||||
|
* sentinels below are reserved identifiers we know never collide
|
||||||
|
* with a real entry.
|
||||||
|
*
|
||||||
|
* - PROFILE_NONE → no profile applied (default).
|
||||||
|
* - PROFILE_CUSTOM → open the ModifierProfileEditor. The editor
|
||||||
|
* writes saved entries into the library; on close we reload and
|
||||||
|
* auto-select the newest entry so the user can flow straight
|
||||||
|
* into Create Room without a second picker trip.
|
||||||
|
*/
|
||||||
|
const PROFILE_NONE = '' as const;
|
||||||
|
const PROFILE_CUSTOM = 'custom' as const;
|
||||||
|
|
||||||
interface LobbyProps {
|
interface LobbyProps {
|
||||||
/** Optional — when provided, create/join/solo flows reset the local
|
/** Optional — when provided, create/join/solo flows reset the local
|
||||||
|
|
@ -40,10 +60,24 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
||||||
const [selectedLayout, setSelectedLayout] =
|
const [selectedLayout, setSelectedLayout] =
|
||||||
useState<StartingLayout>(CLASSIC_LAYOUT);
|
useState<StartingLayout>(CLASSIC_LAYOUT);
|
||||||
const [editorOpen, setEditorOpen] = useState(false);
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
// Modifier profile pre-selected from the ?modifierProfile URL param.
|
|
||||||
// Stored here for future integration with the modifier profile picker.
|
// ── Modifier profile picker state (T26) ─────────────────────────────
|
||||||
const [_preselectedProfile, setPreselectedProfile] =
|
//
|
||||||
|
// `savedProfiles` is loaded from the library on mount AND refreshed
|
||||||
|
// whenever the ModifierProfileEditor closes (it may have added or
|
||||||
|
// renamed an entry). `selectedProfile` is the ModifierProfile the
|
||||||
|
// user chose from the picker — null means "no profile". We keep
|
||||||
|
// both the profile object and its saved-id (when applicable) so the
|
||||||
|
// <select> can echo the right value and we don't lose URL-pre-
|
||||||
|
// selected profiles that aren't in the library.
|
||||||
|
const [savedProfiles, setSavedProfiles] = useState<SavedModifierProfile[]>(
|
||||||
|
() => (typeof window !== 'undefined' ? loadLibrary() : []),
|
||||||
|
);
|
||||||
|
const [selectedProfile, setSelectedProfile] =
|
||||||
useState<ModifierProfile | null>(null);
|
useState<ModifierProfile | null>(null);
|
||||||
|
const [profilePickerValue, setProfilePickerValue] =
|
||||||
|
useState<string>(PROFILE_NONE);
|
||||||
|
const [profileEditorOpen, setProfileEditorOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const layoutId = searchParams.get('layoutId');
|
const layoutId = searchParams.get('layoutId');
|
||||||
|
|
@ -79,13 +113,23 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
||||||
// Pre-select a modifier profile from the ?modifierProfile param.
|
// Pre-select a modifier profile from the ?modifierProfile param.
|
||||||
// The profile is base64-encoded JSON. Silently ignore on parse
|
// The profile is base64-encoded JSON. Silently ignore on parse
|
||||||
// failure — a bad link shouldn't break the lobby.
|
// failure — a bad link shouldn't break the lobby.
|
||||||
|
//
|
||||||
|
// The URL-borne profile is NOT guaranteed to exist in the local
|
||||||
|
// library; we still honour the selection by setting it as the
|
||||||
|
// active profile and echoing its id on the picker (so the <select>
|
||||||
|
// shows a stable value). If the library happens to contain an
|
||||||
|
// entry with the same id — e.g. the user previously saved this
|
||||||
|
// shared profile — we surface that saved entry's name in the
|
||||||
|
// dropdown; otherwise the picker shows the profile's own name as
|
||||||
|
// a synthetic entry.
|
||||||
const modifierProfileParam = searchParams.get('modifierProfile');
|
const modifierProfileParam = searchParams.get('modifierProfile');
|
||||||
if (modifierProfileParam !== null) {
|
if (modifierProfileParam !== null) {
|
||||||
try {
|
try {
|
||||||
const profile = parseModifierProfile(
|
const profile = parseModifierProfile(
|
||||||
JSON.parse(atob(modifierProfileParam)),
|
JSON.parse(atob(modifierProfileParam)),
|
||||||
);
|
);
|
||||||
setPreselectedProfile(profile);
|
setSelectedProfile(profile);
|
||||||
|
setProfilePickerValue(profile.id);
|
||||||
} catch {
|
} catch {
|
||||||
/* invalid param — ignore */
|
/* invalid param — ignore */
|
||||||
}
|
}
|
||||||
|
|
@ -97,6 +141,60 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
||||||
// this effect and overwrite their choice.
|
// this effect and overwrite their choice.
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a change on the profile <select>.
|
||||||
|
*
|
||||||
|
* - PROFILE_NONE: clear selection.
|
||||||
|
* - PROFILE_CUSTOM: open the ModifierProfileEditor and roll the
|
||||||
|
* <select> back to its previous value so it doesn't render
|
||||||
|
* "Custom…" as the persisted choice.
|
||||||
|
* - any other id: look up the library entry and make its profile
|
||||||
|
* active.
|
||||||
|
*
|
||||||
|
* The editor doesn't currently expose an "apply inline" callback,
|
||||||
|
* so "Custom…" funnels through the library: the user creates or
|
||||||
|
* edits a profile, saves it, closes the modal, and we auto-select
|
||||||
|
* the most-recently-updated entry on close (see handleProfileEditorClose).
|
||||||
|
*/
|
||||||
|
function handleProfileChange(value: string): void {
|
||||||
|
if (value === PROFILE_NONE) {
|
||||||
|
setProfilePickerValue(PROFILE_NONE);
|
||||||
|
setSelectedProfile(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (value === PROFILE_CUSTOM) {
|
||||||
|
// Open the editor but leave the picker on its prior value so the
|
||||||
|
// UI doesn't misleadingly display "Custom…" as the active choice.
|
||||||
|
setProfileEditorOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const entry = savedProfiles.find((p) => p.id === value);
|
||||||
|
if (entry === undefined) {
|
||||||
|
// Stale id (entry was deleted elsewhere) — reset to none.
|
||||||
|
setProfilePickerValue(PROFILE_NONE);
|
||||||
|
setSelectedProfile(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setProfilePickerValue(entry.id);
|
||||||
|
setSelectedProfile(entry.profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the ModifierProfileEditor closes. Reload the library
|
||||||
|
* so any saves made during the editor session appear in the picker,
|
||||||
|
* and auto-select the most-recently-updated entry (the one the
|
||||||
|
* user was presumably just working on).
|
||||||
|
*/
|
||||||
|
function handleProfileEditorClose(): void {
|
||||||
|
setProfileEditorOpen(false);
|
||||||
|
const fresh = loadLibrary();
|
||||||
|
setSavedProfiles(fresh);
|
||||||
|
if (fresh.length === 0) return;
|
||||||
|
const newest = [...fresh].sort((a, b) => b.updatedAt - a.updatedAt)[0]!;
|
||||||
|
setProfilePickerValue(newest.id);
|
||||||
|
setSelectedProfile(newest.profile);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert the selected layout into the protocol's LayoutRequest
|
* Convert the selected layout into the protocol's LayoutRequest
|
||||||
* shape. Premades travel by id (server re-resolves via registry);
|
* shape. Premades travel by id (server re-resolves via registry);
|
||||||
|
|
@ -132,10 +230,18 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const { code, token, color, layout: resolvedLayout } =
|
// Build the room.create payload. `profile` is only sent when a
|
||||||
await oneShotRoomRequest('room.create', {
|
// profile is actively selected — omitted otherwise so pre-
|
||||||
layout: toLayoutRequest(selectedLayout),
|
// modifiers servers and vanilla rooms behave identically.
|
||||||
});
|
const createPayload: Record<string, unknown> = {
|
||||||
|
layout: toLayoutRequest(selectedLayout),
|
||||||
|
};
|
||||||
|
if (selectedProfile !== null) {
|
||||||
|
createPayload.profile = selectedProfile;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { code, token, color, layout: resolvedLayout, profile: echoedProfile } =
|
||||||
|
await oneShotRoomRequest('room.create', createPayload);
|
||||||
sessionStorage.setItem('room-code', code);
|
sessionStorage.setItem('room-code', code);
|
||||||
sessionStorage.setItem('room-token', token);
|
sessionStorage.setItem('room-token', token);
|
||||||
sessionStorage.setItem('player-color', color);
|
sessionStorage.setItem('player-color', color);
|
||||||
|
|
@ -147,6 +253,18 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
||||||
} else {
|
} else {
|
||||||
sessionStorage.removeItem('layout-name');
|
sessionStorage.removeItem('layout-name');
|
||||||
}
|
}
|
||||||
|
// Store the active profile's name for GameView's badge (T26).
|
||||||
|
// Prefer the server-echoed profile's name over the local draft
|
||||||
|
// so the header reflects what the server actually accepted.
|
||||||
|
const activeProfile = echoedProfile ?? selectedProfile;
|
||||||
|
if (activeProfile !== null && activeProfile !== undefined) {
|
||||||
|
sessionStorage.setItem(
|
||||||
|
'modifier-profile-name',
|
||||||
|
activeProfile.name,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
sessionStorage.removeItem('modifier-profile-name');
|
||||||
|
}
|
||||||
resetToFreshGame();
|
resetToFreshGame();
|
||||||
// Navigate straight to the canonical shareable URL — no
|
// Navigate straight to the canonical shareable URL — no
|
||||||
// intermediate "Room created" card. The GameView itself renders
|
// intermediate "Room created" card. The GameView itself renders
|
||||||
|
|
@ -177,6 +295,14 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
||||||
} else {
|
} else {
|
||||||
sessionStorage.removeItem('layout-name');
|
sessionStorage.removeItem('layout-name');
|
||||||
}
|
}
|
||||||
|
// Mirror the room's active profile (if any) into sessionStorage
|
||||||
|
// so the late-joiner's GameView renders the modifier badge on
|
||||||
|
// first paint.
|
||||||
|
if (result.profile !== undefined) {
|
||||||
|
sessionStorage.setItem('modifier-profile-name', result.profile.name);
|
||||||
|
} else {
|
||||||
|
sessionStorage.removeItem('modifier-profile-name');
|
||||||
|
}
|
||||||
resetToFreshGame();
|
resetToFreshGame();
|
||||||
navigate(`/game/${result.code}`);
|
navigate(`/game/${result.code}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -232,6 +358,53 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
||||||
onCustomRequested={() => setEditorOpen(true)}
|
onCustomRequested={() => setEditorOpen(true)}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Modifier profile picker (T26). The list is loaded from
|
||||||
|
the local library on mount and refreshed when the
|
||||||
|
ModifierProfileEditor closes. Selecting "Custom…"
|
||||||
|
opens the editor; any URL-pre-selected profile (via
|
||||||
|
?modifierProfile=…) shows up as a synthetic entry so
|
||||||
|
the user's choice is preserved even if they haven't
|
||||||
|
saved it locally. */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label
|
||||||
|
htmlFor="profile-picker-input"
|
||||||
|
className="text-xs font-bold text-neutral-500 uppercase tracking-widest"
|
||||||
|
>
|
||||||
|
Modifier Profile
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="profile-picker-input"
|
||||||
|
data-testid="profile-picker"
|
||||||
|
value={profilePickerValue}
|
||||||
|
onChange={(e) => handleProfileChange(e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full px-3 py-2 text-sm bg-white border border-neutral-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<option value={PROFILE_NONE}>None</option>
|
||||||
|
{savedProfiles.map((entry) => (
|
||||||
|
<option key={entry.id} value={entry.id}>
|
||||||
|
{entry.name}
|
||||||
|
{entry.starred ? ' ★' : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{/* If the picker's selected id isn't in the library
|
||||||
|
(typical for a ?modifierProfile= URL param), add
|
||||||
|
a synthetic row so <select> can still reflect the
|
||||||
|
choice. Using a distinct prefix (__synth__:) to
|
||||||
|
guarantee no collision with library ids. */}
|
||||||
|
{selectedProfile !== null &&
|
||||||
|
!savedProfiles.some((p) => p.id === profilePickerValue) &&
|
||||||
|
profilePickerValue !== PROFILE_NONE &&
|
||||||
|
profilePickerValue !== PROFILE_CUSTOM ? (
|
||||||
|
<option value={profilePickerValue}>
|
||||||
|
{selectedProfile.name} (from link)
|
||||||
|
</option>
|
||||||
|
) : null}
|
||||||
|
<option value={PROFILE_CUSTOM}>Custom…</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
data-action="create-room"
|
data-action="create-room"
|
||||||
onClick={handleCreate}
|
onClick={handleCreate}
|
||||||
|
|
@ -306,6 +479,14 @@ export function Lobby({ chessState }: LobbyProps = {}) {
|
||||||
onClose={() => setEditorOpen(false)}
|
onClose={() => setEditorOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Modifier profile editor — opened from the "Custom…" picker
|
||||||
|
option. On close we reload the library and auto-select the
|
||||||
|
newest entry so the user can go straight into Create Room. */}
|
||||||
|
<ModifierProfileEditor
|
||||||
|
isOpen={profileEditorOpen}
|
||||||
|
onClose={handleProfileEditorClose}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue