cff7cc3859
Tests · UI / test (push) Failing after 3m8s
- lobby-create-screen: call lobbyData.refresh() after a successful POST so the new game shows up in the private-games panel immediately. The shared lobby-data store is otherwise lazy (ensure-on-first-mount), which rendered a stale list across the post-create navigation in the e2e suite. - e2e tests that move between lobby sub-panels now go through `window.__galaxyNav.go(...)` rather than clicking the sidebar items. The mobile sidebar tucks the submenu behind a dropdown, so testid-based clicks fail on the mobile-iphone-13 / pixel-5 viewports — the dev nav surface bypasses that UX (which has its own coverage in `lobby-tier-gate` / future submenu specs). - game-shell-map missing-membership test: assert `lobby-account-name` instead of `lobby-create-button` on drop-back-to-lobby (the button moved into the paid-only private-games sub-panel; the identity strip is the constant lobby chrome). - inspector-ship-group + ship-group-send synthetic loader specs: jump straight to the dev-only `synthetic-reports` top-level screen via the dev nav surface before looking for the file input (the loader moved off Overview in F8-04b). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
315 lines
8.2 KiB
Svelte
315 lines
8.2 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from "svelte";
|
|
import { appScreen } from "$lib/app-nav.svelte";
|
|
|
|
import { createGatewayClient } from "../../api/connect";
|
|
import { GalaxyClient } from "../../api/galaxy-client";
|
|
import { LobbyError, createGame } from "../../api/lobby";
|
|
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
|
|
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
|
import { loadCore } from "../../platform/core/index";
|
|
import { session } from "$lib/session-store.svelte";
|
|
import { lobbyData } from "$lib/lobby-data.svelte";
|
|
|
|
const DEFAULT_MIN_PLAYERS = 2;
|
|
const DEFAULT_MAX_PLAYERS = 8;
|
|
const DEFAULT_START_GAP_HOURS = 24;
|
|
const DEFAULT_START_GAP_PLAYERS = 2;
|
|
const DEFAULT_TARGET_ENGINE_VERSION = "v1";
|
|
|
|
let gameName = $state("");
|
|
let description = $state("");
|
|
let turnSchedule = $state("0 0 * * *");
|
|
let enrollmentEndsAt = $state("");
|
|
|
|
let minPlayers = $state(DEFAULT_MIN_PLAYERS);
|
|
let maxPlayers = $state(DEFAULT_MAX_PLAYERS);
|
|
let startGapHours = $state(DEFAULT_START_GAP_HOURS);
|
|
let startGapPlayers = $state(DEFAULT_START_GAP_PLAYERS);
|
|
let targetEngineVersion = $state(DEFAULT_TARGET_ENGINE_VERSION);
|
|
|
|
let formError: string | null = $state(null);
|
|
let configError: string | null = $state(null);
|
|
let submitting = $state(false);
|
|
|
|
let client: GalaxyClient | null = null;
|
|
|
|
async function sha256(payload: Uint8Array): Promise<Uint8Array> {
|
|
const digest = await crypto.subtle.digest("SHA-256", payload as BufferSource);
|
|
return new Uint8Array(digest);
|
|
}
|
|
|
|
function describeLobbyError(err: unknown): string {
|
|
if (err instanceof LobbyError) {
|
|
// Free-tier callers reach this branch when the backend gate
|
|
// at `lobby.game.create` rejects them. Show the dedicated
|
|
// inline message instead of the generic "operation
|
|
// forbidden" — the user got to this screen via the
|
|
// `private games` panel, so we want to spell out that the
|
|
// gate is the tier (not a permission misconfig).
|
|
if (err.code === "forbidden") {
|
|
return i18n.t("lobby.create.error.forbidden");
|
|
}
|
|
const key = `lobby.error.${err.code}` as TranslationKey;
|
|
const translated = i18n.t(key);
|
|
if (translated !== key) {
|
|
return translated;
|
|
}
|
|
return i18n.t("lobby.error.unknown", { message: err.message });
|
|
}
|
|
return err instanceof Error ? err.message : "request failed";
|
|
}
|
|
|
|
function cancel(): void {
|
|
appScreen.go("lobby");
|
|
}
|
|
|
|
async function submit(): Promise<void> {
|
|
formError = null;
|
|
const trimmedName = gameName.trim();
|
|
const trimmedSchedule = turnSchedule.trim();
|
|
const trimmedEnrollment = enrollmentEndsAt.trim();
|
|
if (trimmedName === "") {
|
|
formError = i18n.t("lobby.create.game_name_required");
|
|
return;
|
|
}
|
|
if (trimmedSchedule === "") {
|
|
formError = i18n.t("lobby.create.turn_schedule_required");
|
|
return;
|
|
}
|
|
if (trimmedEnrollment === "") {
|
|
formError = i18n.t("lobby.create.enrollment_ends_at_required");
|
|
return;
|
|
}
|
|
const enrollmentDate = new Date(trimmedEnrollment);
|
|
if (Number.isNaN(enrollmentDate.getTime())) {
|
|
formError = i18n.t("lobby.create.enrollment_ends_at_required");
|
|
return;
|
|
}
|
|
if (client === null) {
|
|
formError = configError ?? "client not ready";
|
|
return;
|
|
}
|
|
submitting = true;
|
|
try {
|
|
await createGame(client, {
|
|
gameName: trimmedName,
|
|
description: description.trim(),
|
|
minPlayers,
|
|
maxPlayers,
|
|
startGapHours,
|
|
startGapPlayers,
|
|
enrollmentEndsAt: enrollmentDate,
|
|
turnSchedule: trimmedSchedule,
|
|
targetEngineVersion: targetEngineVersion.trim() || DEFAULT_TARGET_ENGINE_VERSION,
|
|
});
|
|
// Refresh the lobby-data cache so the freshly-created game
|
|
// shows up in the private-games panel the moment we land
|
|
// there. The shared store is otherwise lazy (ensure-on-
|
|
// first-mount) and would render a stale list across the
|
|
// navigation.
|
|
try {
|
|
await lobbyData.refresh();
|
|
} catch {
|
|
// Refresh failure does not block the navigation —
|
|
// the panel will retry on its own mount.
|
|
}
|
|
appScreen.go("games-private-games");
|
|
} catch (err) {
|
|
formError = describeLobbyError(err);
|
|
} finally {
|
|
submitting = false;
|
|
}
|
|
}
|
|
|
|
onMount(async () => {
|
|
if (
|
|
session.keypair === null ||
|
|
session.deviceSessionId === null ||
|
|
GATEWAY_RESPONSE_PUBLIC_KEY.length === 0
|
|
) {
|
|
if (GATEWAY_RESPONSE_PUBLIC_KEY.length === 0) {
|
|
configError = "VITE_GATEWAY_RESPONSE_PUBLIC_KEY is not configured";
|
|
}
|
|
return;
|
|
}
|
|
const keypair = session.keypair;
|
|
const core = await loadCore();
|
|
client = new GalaxyClient({
|
|
core,
|
|
edge: createGatewayClient(gatewayRpcBaseUrl()),
|
|
signer: (canonical) => keypair.sign(canonical),
|
|
sha256,
|
|
deviceSessionId: session.deviceSessionId,
|
|
gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY,
|
|
});
|
|
});
|
|
</script>
|
|
|
|
<a class="skip-link" href="#main-content">{i18n.t("common.skip_to_content")}</a>
|
|
<main id="main-content" tabindex="-1">
|
|
<h1>{i18n.t("lobby.create.title")}</h1>
|
|
{#if configError !== null}
|
|
<p role="alert" data-testid="lobby-create-config-error">{configError}</p>
|
|
{/if}
|
|
<form
|
|
onsubmit={(event) => {
|
|
event.preventDefault();
|
|
submit();
|
|
}}
|
|
data-testid="lobby-create-form"
|
|
>
|
|
<label>
|
|
{i18n.t("lobby.create.game_name_label")}
|
|
<input
|
|
type="text"
|
|
bind:value={gameName}
|
|
data-testid="lobby-create-game-name"
|
|
autocomplete="off"
|
|
/>
|
|
</label>
|
|
<label>
|
|
{i18n.t("lobby.create.description_label")}
|
|
<textarea
|
|
bind:value={description}
|
|
data-testid="lobby-create-description"
|
|
rows="3"
|
|
></textarea>
|
|
</label>
|
|
<label>
|
|
{i18n.t("lobby.create.turn_schedule_label")}
|
|
<input
|
|
type="text"
|
|
bind:value={turnSchedule}
|
|
data-testid="lobby-create-turn-schedule"
|
|
autocomplete="off"
|
|
/>
|
|
<small>{i18n.t("lobby.create.turn_schedule_hint")}</small>
|
|
</label>
|
|
<label>
|
|
{i18n.t("lobby.create.enrollment_ends_at_label")}
|
|
<input
|
|
type="datetime-local"
|
|
bind:value={enrollmentEndsAt}
|
|
data-testid="lobby-create-enrollment-ends-at"
|
|
/>
|
|
</label>
|
|
<details data-testid="lobby-create-advanced">
|
|
<summary>{i18n.t("lobby.create.advanced")}</summary>
|
|
<label>
|
|
{i18n.t("lobby.create.min_players_label")}
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
bind:value={minPlayers}
|
|
data-testid="lobby-create-min-players"
|
|
/>
|
|
</label>
|
|
<label>
|
|
{i18n.t("lobby.create.max_players_label")}
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
bind:value={maxPlayers}
|
|
data-testid="lobby-create-max-players"
|
|
/>
|
|
</label>
|
|
<label>
|
|
{i18n.t("lobby.create.start_gap_hours_label")}
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
bind:value={startGapHours}
|
|
data-testid="lobby-create-start-gap-hours"
|
|
/>
|
|
</label>
|
|
<label>
|
|
{i18n.t("lobby.create.start_gap_players_label")}
|
|
<input
|
|
type="number"
|
|
min="0"
|
|
bind:value={startGapPlayers}
|
|
data-testid="lobby-create-start-gap-players"
|
|
/>
|
|
</label>
|
|
<label>
|
|
{i18n.t("lobby.create.target_engine_version_label")}
|
|
<input
|
|
type="text"
|
|
bind:value={targetEngineVersion}
|
|
data-testid="lobby-create-target-engine-version"
|
|
autocomplete="off"
|
|
/>
|
|
</label>
|
|
</details>
|
|
{#if formError !== null}
|
|
<p role="alert" data-testid="lobby-create-error">{formError}</p>
|
|
{/if}
|
|
<div class="actions">
|
|
<button type="submit" disabled={submitting} data-testid="lobby-create-submit">
|
|
{submitting ? i18n.t("lobby.create.submitting") : i18n.t("lobby.create.submit")}
|
|
</button>
|
|
<button type="button" onclick={cancel} data-testid="lobby-create-cancel">
|
|
{i18n.t("lobby.create.cancel")}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</main>
|
|
|
|
<style>
|
|
main {
|
|
padding: 1.5rem 1rem;
|
|
max-width: 32rem;
|
|
margin: 0 auto;
|
|
font-family: system-ui, sans-serif;
|
|
}
|
|
|
|
form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
label {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
}
|
|
|
|
input[type="text"],
|
|
input[type="number"],
|
|
input[type="datetime-local"],
|
|
textarea {
|
|
font-size: 1rem;
|
|
padding: 0.4rem 0.5rem;
|
|
}
|
|
|
|
details {
|
|
border: 1px solid var(--color-border);
|
|
border-radius: 0.4rem;
|
|
padding: 0.5rem 0.75rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
details > label {
|
|
margin-top: 0.5rem;
|
|
}
|
|
|
|
details summary {
|
|
cursor: pointer;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.actions {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
margin-top: 0.5rem;
|
|
}
|
|
|
|
small {
|
|
color: var(--color-text-muted);
|
|
}
|
|
</style>
|