Compare commits

...

7 Commits

Author SHA1 Message Date
Ilia Denisov a679d9cdcb fix(ui): F8-04 profile polish — IANA timezone picker, save-stay, shared identity cache
Tests · UI / test (push) Has been cancelled
Tests · Go / test (push) Successful in 2m30s
Tests · UI / test (pull_request) Successful in 2m49s
PR-feedback round on #60:

- Time-zone field is now a continent-grouped <select> populated from
  `Intl.supportedValuesOf("timeZone")`, with the browser-detected
  zone pre-selected when no value is stored. A stored zone the
  runtime no longer advertises is preserved as an "Other" entry.
- Saving the profile no longer kicks the user back to the lobby:
  the form stays put and shows a transient `saved` notice, cleared
  on the next edit. Only `cancel` returns to the lobby.
- New `lib/account-store.svelte.ts` caches `user.account.get` for
  the session; lobby + profile share it through `account.ensure()`,
  so navigating Overview ⇄ Profile no longer flashes the
  "loading account…" placeholder or fires a second gateway call.
  Profile save writes through to the store so the shell identity
  strip picks up the new display name without refetching. Cleared
  on logout to prevent identity bleed between accounts.
- e2e: existing 4 cases adjusted for save-stay; added two new ones
  for the timezone dropdown and identity-strip stability across
  navigation.
- Docs: `ui/docs/lobby.md` updated to describe the shared cache,
  the new timezone picker shape, and the save-stay behaviour.
2026-05-26 22:38:14 +02:00
Ilia Denisov 2ecdecad1e feat(ui): lobby site-style sidebar + profile screen (#47)
- Wrap lobby and profile in a shared `lobby-shell.svelte` chrome:
  page-list sidebar (Overview/Profile) and a top "Player-xxxx"
  identity strip mirroring the project site's monospace look.
- Strip the legacy `lobby.title`, device-session-id `<code>`, and
  `lobby.greeting` paragraph; the identity strip both names the user
  and opens the profile editor.
- Add a top-level `profile` AppScreen with a three-field form
  (`display_name`, `preferred_language`, `time_zone`) backed by a new
  `src/api/account.ts` wrapper around `user.account.get`,
  `user.profile.update`, and `user.settings.update`. Saving switches
  the active i18n locale in-place when the new preferred language is
  one the UI ships translations for.
- Update e2e fixture + auth-flow / lobby-flow specs to use the new
  `lobby-account-name` testid and wait for the loaded identity before
  releasing pending `SubscribeEvents` (webkit revocation race). New
  `profile-screen.spec.ts` covers navigation, edit-save, and cancel.
- Sync `ui/docs/lobby.md` and `ui/docs/navigation.md` to the new
  layout.

Closes #47
2026-05-26 22:25:40 +02:00
developer b03993fcb1 Merge pull request 'fix(ui): F8-06 calculator polish — input steps, lock idiom, tech floor, speed-lock fix' (#61) from feature/issue-49-calculator-polish into development
Deploy · Dev / deploy (push) Successful in 45s
Tests · Integration / integration (push) Successful in 1m41s
Tests · Go / test (push) Successful in 3m10s
Tests · UI / test (push) Successful in 2m41s
2026-05-26 17:23:26 +00:00
Ilia Denisov b01a60e42b fix(ui): F8-06 calculator polish — drop delete-class button, reserve lock slot
Tests · UI / test (push) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m48s
Tests · Go / test (pull_request) Successful in 2m1s
Tests · UI / test (pull_request) Successful in 2m34s
- Remove the `delete <ship_class_name>` button (and `deleteClass`,
  `canDelete`, `.delete` CSS, `game.calculator.action.delete` i18n key)
  from the calculator. Delete-class lives in the ship-classes table —
  the broader rework will land under #53.
- Bombing and cargo-capacity rows now reserve a hidden lock-slot
  placeholder so their value column lines up vertically with the
  mass/speed/attack/defence rows (which carry a lock button).
2026-05-26 19:10:59 +02:00
Ilia Denisov cc4727a32e fix(ui): F8-06 calculator polish — always 3-decimal display, mono font, input cap
Tests · UI / test (push) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m48s
Tests · Go / test (pull_request) Successful in 2m3s
Tests · UI / test (pull_request) Successful in 2m28s
Owner feedback round 2 on PR #61:

- Pad every read-only calculator value to three decimals: tech labels,
  derived results (mass, speed, attack, defence, bombing, cargo
  capacity), planet MAT, planet build-rate, modernization cost, and
  the full-cargo capacity label all read as "1.000" instead of "1",
  matching the goal-seek back-solved input and the report. Drops
  thousands grouping so the same `fmt()` string also embeds cleanly
  in the read-only `<input type="number">` cell.
- Switch label and input styling onto the existing `--font-mono`
  token (right-aligned, tabular-nums) so columns line up vertically
  across rows like a financial table.
- Refuse a fourth decimal as the user types in every calculator
  number input (DWSC blocks, tech, MAT, custom load, lock value,
  modernization target tech): the `oninput` truncates the input text
  past three decimal digits and explicitly writes the truncated
  value back through `bind:value`, so Svelte's later reactive flush
  cannot undo the cap.
- Doc + tests follow the rule (five new vitest cases covering the
  3-decimal label format, the input cap on each input class, and
  the integer-padding rule for derived results).
2026-05-26 18:43:32 +02:00
Ilia Denisov cbf7f65916 fix(ui): F8-06 calculator polish — unified spinner UX, lock-infeasible on (0, 1), dropdown reset-changes
Tests · UI / test (push) Has been cancelled
Tests · Integration / integration (pull_request) Successful in 1m45s
Tests · Go / test (pull_request) Successful in 2m3s
Tests · UI / test (pull_request) Successful in 2m28s
Owner review on PR #61:

- п.9 (option B). Hide the native spinner on EVERY numeric input in
  the calculator (DWSC blocks, armament, tech, planet MAT, custom
  load, lock value, modernization target tech) and drive every step
  through ArrowUp / ArrowDown. The column widths stay stable and the
  inputs read consistently across the whole row. The ship blocks
  keep the smart (0 ↔ 1) jump on ArrowUp/ArrowDown; armament steps
  ±1 with a JS handler instead of relying on the native spinner.
  Other inputs step by their natural grain (±0.001 for tech / lock,
  ±0.01 for MAT / load).
- п.10. Tech-level labels (`tech-val`) and the planet MAT label
  (`mat-val`) now read through the same `Ceil3` formatter as the
  derived results, so plain-text numeric values share the report's
  3-decimal tabular formatting. The design-area component receives
  `formatNumber` as a prop; the resolved (goal-seek) cell uses the
  same formatter, so the read-only computed value matches the rest
  of the row.
- п.12. `computeCalculator` now validates the back-solved block
  against the same DWSC rule the live validator enforces (`0` or
  `≥ 1`). When the solver lands in the `(0, 1)` gap (e.g. attack
  0.5 / weaponsTech 1.5 → weapons 0.333…) the lock is flagged
  infeasible — the lock input flips red and the claimed block is
  NOT back-solved into the invalid range, so the design preview
  keeps reading the user's own typed values instead of silently
  showing a sub-1 block.
- new. Selecting an existing ship class from the name datalist now
  loads it immediately. `change` fires only on blur in Firefox,
  which is why the previous behaviour looked delayed; switching the
  load to `oninput` with an `InputEvent.inputType` check makes the
  load synchronous everywhere (datalist replacement carries
  `"insertReplacementText"` in Chromium / WebKit, `undefined` in
  Firefox; keyboard typing always carries a typing `inputType`).
  Before loading we compare the live blocks to the previously
  loaded class (or to the empty defaults) and, if they differ, ask
  through a `window.confirm`. On decline we revert the name field
  and leave the design untouched.

Tests: calculator-tab and calc-model gain six cases (armament
step, tech/MAT formatter labels, lock infeasible on (0, 1) for
both attack→weapons and emptyMass→cargo, lock-value Arrow step,
dropdown immediate load + confirm-blocks-load + confirm-allows-load),
all 779 vitest tests green. docs/calculator-ux.md follows the new
behaviour.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 18:02:56 +02:00
Ilia Denisov e9b904332e fix(ui): calculator polish — smart input steps, unified tech/MAT lock idiom, tech floor, speed-lock ceiling fix
Tests · Go / test (push) Successful in 2m31s
Tests · UI / test (push) Waiting to run
Tests · Integration / integration (pull_request) Successful in 1m41s
Tests · Go / test (pull_request) Successful in 3m14s
Tests · UI / test (pull_request) Successful in 2m32s
- pkg/calc: DriveForSpeed treats restMass==0 as a valid ceiling-only
  case (every positive drive solves it), so locking the displayed
  speed of a D=1, W=A=S=C=0 ship is no longer a phantom "infeasible".
- ship-design-area: drive/weapons/shields/cargo inputs use a JS-driven
  smart step on ArrowUp/ArrowDown (0↔1 jump, otherwise ±0.1) and hide
  the native spinner so it cannot produce invalid (0, 1) values;
  armament keeps its native step 1.
- Tech and planet MAT cells follow the same lock idiom as goal-seek
  locks: open padlock (🔓) over the inherited value → click to open
  an input with a closed padlock (🔒). The padlock slot is always
  reserved, so the column width is stable.
- Tech overrides (design area and modernization target) are floored
  at the player's current tech on this turn — a lower value is
  flagged as invalid.
2026-05-26 14:30:43 +02:00
28 changed files with 2496 additions and 261 deletions
+17 -4
View File
@@ -22,12 +22,25 @@ func WeaponsForAttack(targetAttack, weaponsTech float64) (float64, bool) {
// DriveForSpeed returns the drive block that yields targetSpeed for a
// ship whose mass excluding the drive block is restMass, at drive tech
// level driveTech, inverting [Speed] composed with [DriveEffective].
// Speed approaches but never reaches the stripped-hull ceiling
// 20*driveTech, so a target at or above the ceiling (or a non-positive
// target or tech level) is infeasible.
// With a positive restMass the speed approaches but never reaches the
// stripped-hull ceiling 20*driveTech, so a target at or above the
// ceiling is infeasible. With restMass==0 the drive block carries no
// other mass: every positive drive yields exactly the ceiling speed, so
// the ceiling target is the only feasible one and any positive drive
// (canonically 1) solves it. Non-positive targetSpeed or driveTech are
// always infeasible.
func DriveForSpeed(targetSpeed, driveTech, restMass float64) (float64, bool) {
if driveTech <= 0 || targetSpeed <= 0 {
return 0, false
}
ceiling := 20 * driveTech
if driveTech <= 0 || targetSpeed <= 0 || targetSpeed >= ceiling {
if restMass <= 0 {
if targetSpeed != ceiling {
return 0, false
}
return 1, true
}
if targetSpeed >= ceiling {
return 0, false
}
return targetSpeed * restMass / (ceiling - targetSpeed), true
+19 -1
View File
@@ -24,12 +24,30 @@ func TestDriveForSpeed(t *testing.T) {
if !ok || math.Abs(got-drive) > 1e-9 {
t.Errorf("DriveForSpeed round-trip = %v (ok=%v), want %v", got, ok, drive)
}
// Speed can never reach the stripped-hull ceiling 20*driveTech.
// With a positive restMass speed can never reach 20*driveTech.
if _, ok := calc.DriveForSpeed(20*driveTech, driveTech, restMass); ok {
t.Error("DriveForSpeed at the speed ceiling should be infeasible")
}
}
func TestDriveForSpeedZeroRest(t *testing.T) {
// With restMass==0 the only achievable speed is the stripped-hull
// ceiling 20*driveTech; any positive drive reaches it. Off-ceiling
// targets are infeasible.
const driveTech = 1.5
ceiling := 20 * driveTech
got, ok := calc.DriveForSpeed(ceiling, driveTech, 0)
if !ok || got <= 0 {
t.Errorf("DriveForSpeed(ceiling, _, 0) = %v (ok=%v), want positive", got, ok)
}
if _, ok := calc.DriveForSpeed(ceiling/2, driveTech, 0); ok {
t.Error("DriveForSpeed(below ceiling, _, 0) should be infeasible")
}
if _, ok := calc.DriveForSpeed(ceiling+1, driveTech, 0); ok {
t.Error("DriveForSpeed(above ceiling, _, 0) should be infeasible")
}
}
func TestShieldsForDefence(t *testing.T) {
const shields, shieldsTech, restMass = 5.75, 1.0, 40.0
defence := calc.EffectiveDefence(shields, shieldsTech, shields+restMass)
+86 -29
View File
@@ -24,28 +24,60 @@ in as a per-ship result rather than a separate mode.
1. **Ship Class design area** — five blocks (drive, armament, weapons,
shields, cargo) and four tech levels (drive, weapons, shields,
cargo). Tech defaults to the player's current tech and shows a lock
icon once overridden; clicking it resets to the default.
cargo). Tech defaults to the player's current tech: the cell renders
the inherited number with an open padlock; clicking the open lock
activates an input (closed padlock), where the player may type an
override at or above their current tech. Clicking the closed
padlock resets to the default. The padlock slot is always reserved,
so the column width does not shift as the lock state toggles. The
inherited tech value reads through the same 3-decimal `Ceil3`
formatter the report uses, so the column lines up with derived
values. **Every numeric input in the calculator hides the native
spinner and drives stepping through ArrowUp / ArrowDown.** This keeps
the column widths stable, makes the inputs read consistently, and
gives each row a step that matches its purpose. The four ship-class
blocks (drive, weapons, shields, cargo) use a smart step that
respects the engine value rule (`0` or `≥ 1`): ArrowUp from 0 jumps
straight to 1, otherwise +0.1; ArrowDown from 1 collapses to 0,
otherwise 0.1, never producing an invalid value in `(0, 1)`.
Armament steps ±1 (clamped at 0). Tech, planet MAT, custom load,
lock value, and modernization target tech each step by their natural
grain (±0.001 for tech and lock values, ±0.01 for MAT and load).
2. **Calculator area** — derived results: empty/loaded mass, empty/
loaded speed, attack, defence, bombing (per ship), cargo capacity.
A load toggle (empty / full / custom) sets the cargo load (in cargo
units) that the loaded-column results use. At **full** the toggle
shows the ship's cargo capacity; a **custom** load over that capacity
is flagged as an error. With a zero cargo block there is no hold, so
the load is pinned to empty and the toggle is disabled.
the load is pinned to empty and the toggle is disabled. The bombing
and cargo-capacity rows have no goal-seek lock, but they still
reserve a hidden lock-slot placeholder so the value column stays
vertically aligned with the lockable rows above.
3. **Planet area** — when an own planet is selected on the map, shows
its MAT (overridable) and the single-turn build rate (ships per turn,
turns per ship). The realistic multi-turn forecast with CAP/COL
supply is planned (see ../ROADMAP.md).
turns per ship). The MAT follows the same lock idiom as the tech
cells: the planet number renders with an open padlock, clicking
opens an input with a closed padlock, and the closed padlock resets
to the planet value. The MAT label reads through the same 3-decimal
`Ceil3` formatter, matching the rest of the calculator's label
values. The realistic multi-turn forecast with CAP/COL supply is
planned (see ../ROADMAP.md).
## Locks and goal-seek
Two distinct lock semantics share one icon (a closed padlock; it only
appears once a value is pinned, click to release):
Two distinct lock semantics share one padlock affordance. Both follow
the same idiom — an open padlock (🔓) means *value is inherited /
derived, click to override*; a closed padlock (🔒) means *value is
pinned by the player, click to reset*:
- **Override locks** on inputs that have a default — the four techs and
the planet MAT. Editing one overrides the default; the lock resets it.
Any number may be overridden at once.
the planet MAT. By default the cell shows the inherited number plus
an open padlock; clicking it switches to an input plus a closed
padlock for typing the override. Closing (clicking the closed
padlock) resets to the default. Any number may be overridden at once.
Tech overrides are floored at the player's current tech on this
turn — a lower value is flagged as invalid. The same floor applies
to the modernization target tech.
- **Goal-seek locks** on derived results. Pinning a result back-solves
the single input it claims, which then renders read-only (computed):
@@ -60,12 +92,19 @@ appears once a value is pinned, click to release):
Only **one** result may be locked at a time (the others' lock
affordances disable with a tooltip). An unreachable target — e.g. a
speed at or above the stripped-hull ceiling `20 × driveTech`, or a
solved block that fails the value rules — leaves the locked cell in a
red error state and does not apply. Inverse solving lives in
`pkg/calc/solve.go`; the bisection for defence → shields is the only
non-analytic case. Locking a speed is disabled when the drive block is
zero (a deliberately immobile ship has no speed to back-solve).
speed above the stripped-hull ceiling `20 × driveTech`, or a solved
block that fails the value rules (a DWSC value in the `(0, 1)` gap)
— leaves the locked cell in a red error state and does not apply.
When that happens the claimed block is **not** back-solved into the
invalid range; the design preview keeps reading the user's typed
values, so the row never silently shows a sub-1 block. Inverse
solving lives in `pkg/calc/solve.go`; the bisection for defence →
shields is the only non-analytic case. Locking a speed is disabled
when the drive block is zero (a deliberately immobile ship has no
speed to back-solve). With the drive block as the only non-zero mass
the displayed speed equals the ceiling exactly (every positive drive
gives the same speed), so the solver accepts that ceiling target as
a feasible lock and any positive drive solves it.
## Validation and display
@@ -75,25 +114,43 @@ negative, the five blocks follow the engine value rules
(`pkg/calc/validator.go`, surfaced per-field by
`shipClassFieldErrors`), and a custom load may not exceed cargo capacity.
Every displayed number — the derived results and the goal-seek
back-solved input — is rounded **up** to three decimals through the
shared `pkg/calc/number.go.Ceil3` (bridged as `core.ceil3`), so a value
is never shown lower than it is (a speed of 5.0003 reads 5.001). The
engine keeps its own round-to-nearest `util.Fixed*`; `Ceil3` is a
display-only helper that lives in `pkg/calc` so the UI and Go share one
implementation.
Every displayed number — the derived results, the inherited tech /
planet MAT labels, and the goal-seek back-solved input — is rounded
**up** to three decimals through the shared `pkg/calc/number.go.Ceil3`
(bridged as `core.ceil3`) and always padded to three decimals so the
column reads the same on integers and fractions alike (a speed of 20
shows as `20.000`, of 5.0003 as `5.001`). Labels and inputs use the
monospace stack from the design tokens (`--font-mono`) with
right-aligned, tabular numerals so values line up vertically across
rows. To match the display rule, every number input also refuses a
fourth decimal as the user types: typing `1.2345` clamps the input to
`1.234` on input. The engine keeps its own round-to-nearest
`util.Fixed*`; `Ceil3` is a display-only helper that lives in `pkg/calc`
so the UI and Go share one implementation.
## Create / load / delete
## Create / load
The name field is a combobox over the player's existing classes. Picking
an existing class loads it as a template (so you can tweak and Create a
new one); Create is disabled while the name is invalid or duplicate
(reusing `lib/util/ship-class-validation.ts`). When a saved class is
loaded, a Delete affordance appears. Create / Delete reuse the existing
`createShipClass` / `removeShipClass` order-draft flow, so the optimistic
overlay reflects the change immediately. Ship classes are immutable after
creation (per `game/rules.txt`), so there is no edit — only Create-new
and Delete.
(reusing `lib/util/ship-class-validation.ts`). Create reuses the existing
`createShipClass` order-draft flow, so the optimistic overlay reflects
the change immediately. Ship classes are immutable after creation (per
`game/rules.txt`), so there is no edit — only Create-new. Delete-class
lives in the ship-classes table (`lib/active-view/table-ship-classes.svelte`),
not the calculator.
Selecting a class from the dropdown loads it **immediately**, the
moment the option is clicked. (Native `change` only fires on blur in
Firefox; switching the load trigger to `input` makes the load
synchronous everywhere, since the `InputEvent.inputType` flags a
datalist replacement as `"insertReplacementText"` in Chromium / WebKit
or `undefined` in Firefox — keyboard typing always carries a typing
`inputType`.) If the live blocks differ from the previously loaded
class (or, when nothing is loaded, from the empty defaults), the
calculator first asks `Discard unsaved changes and load class «…»?`
through a `window.confirm`; declining reverts the name field and
leaves the current blocks untouched.
## Reach circles
+71 -11
View File
@@ -2,16 +2,48 @@
The lobby is the first authenticated view; the user lands here after
the email-code login completes (see
[`docs/auth-flow.md`](auth-flow.md)). This doc captures the
sections, the application / invite lifecycle the user sees, and
the defaults baked into the create-game form.
[`docs/auth-flow.md`](auth-flow.md)). This doc captures the shared
shell, the Overview sections, the profile sub-screen, and the
defaults baked into the create-game form.
## Sections
## Shell
The lobby renders one column of sections, top to bottom, with the
common content max-width capped at `32rem` (same convention as the
login page). Cards inside each section take the full available
width.
Lobby and profile share a single chrome implemented in
`lib/screens/lobby-shell.svelte`. The chrome mirrors the project
site's VitePress layout: a left page-list sidebar (Overview /
Profile), a top identity strip on the right, and the page content in
the right-hand column. The shell uses `var(--font-mono)` so the
post-login pages adopt the "nerdy" type stack that the public site
already uses.
The identity strip reads the caller's account from
`lib/account-store.svelte.ts` — a session-wide cache that fetches
`user.account.get` once on first access and is written through after
every Profile save. Both `lobby-screen.svelte` and
`profile-screen.svelte` populate the same cache through
`account.ensure(client)`, so switching Overview ⇄ Profile never
re-issues `user.account.get` and the strip never flashes the
`lobby.account_loading` placeholder mid-navigation. The cache is
cleared by `session.signOut("user")` / `signOut("revoked")` so a
different user signing in on the same browser does not briefly see
the previous identity.
The strip falls back to `display_name` → immutable `user_name`
`lobby.account_loading` while the first `ensure(...)` resolves. It
renders as a `data-testid="lobby-account-name"` button; clicking it
switches the top-level screen to `profile`
(`appScreen.go("profile")`). The e2e suites use that testid as their
lobby-loaded signal. The logout button sits next to it
(`session.signOut("user")`).
The sidebar always renders both pages; clicking the active page is a
no-op. The shell collapses to a horizontal scrolling strip below
640px.
## Overview sections
The Overview page renders one column of sections, top to bottom.
Cards inside each section take the full available width.
| Section | Empty state | Source | Action |
| -------------------- | --------------------- | -------------------------- | --------------------------------------------------------- |
@@ -21,9 +53,37 @@ width.
| `my applications` | `no applications` | `lobby.my.applications.list` | Status badge (`pending` / `approved` / `rejected`) |
| `public games` | `no public games` | `lobby.public.games.list` | Submit application via inline race-name form (`lobby.application.submit`) |
The header preserves the device-session-id `<code>` block (kept as
a debug affordance) plus a greeting if the gateway returns a
`display_name` for the caller.
## Profile sub-screen
`lib/screens/profile-screen.svelte` is a top-level `AppScreen` (peer of
`lobby` and `lobby-create`). The browser Back stack treats it the
same as the create screen — pushing a fresh history entry on entry,
falling back to lobby on Back/Forward (see
[`navigation.md`](navigation.md)).
On mount it reads the caller's account through `account.ensure(...)`
(see [Shell](#shell)) — the first visit issues `user.account.get`,
subsequent visits resolve from the session-wide cache without a
gateway round-trip. The form renders an identity read-out (immutable
`user_name`, `email`) plus three editable fields:
| Field | Endpoint | Notes |
| --------------------- | --------------------- | -------------------------------------------------------------- |
| `display_name` | `user.profile.update` | Trimmed; empty value clears the stored name (backend PATCH semantics). |
| `preferred_language` | `user.settings.update`| `<select>` over `SUPPORTED_LOCALES`; if the stored value is unsupported the option is preserved verbatim so a round-trip save does not silently switch it. |
| `time_zone` | `user.settings.update`| `<select>` of every IANA zone the browser knows (`Intl.supportedValuesOf("timeZone")`), grouped by leading slash segment (Africa / America / …; singletons like `UTC` collapse into a trailing "Other" optgroup). When the form opens with no stored zone, the picker is pre-selected to `Intl.DateTimeFormat().resolvedOptions().timeZone`. A stored value the runtime no longer advertises is added as an extra "Other" entry so the round-trip never silently drops it. Browsers that lack `supportedValuesOf` fall back to a free-text input; the backend validates with `time.LoadLocation` in every shape. |
Save fires `user.profile.update` and/or `user.settings.update`
conditionally on which fields actually changed, then **stays on the
profile** and surfaces a transient `profile-saved-notice` line
(`data-testid="profile-saved-notice"`). Editing any field clears the
notice. Only the explicit `cancel` button navigates back to the lobby
(`appScreen.go("lobby")`). When the saved `preferred_language` is one
the UI also ships translations for, the active i18n locale switches
in-place so the rest of the session matches the new preference. The
write-through is also pushed into the shared `account` store so the
shell identity strip picks up the new `display_name` without a second
`user.account.get`.
`GameSummary` carries a `current_turn` field that the lobby UI does
not display directly — the in-game shell reads it from the same
+11 -7
View File
@@ -18,9 +18,9 @@ for the whole session. The only other routes are the dev/test-only
rune singletons in `src/lib/app-nav.svelte.ts`:
- **`appScreen`** — the top-level screen
(`login` / `lobby` / `lobby-create` / `game`) plus the active
`gameId`. It replaces the old `goto`-based redirects and the `[id]`
route param.
(`login` / `lobby` / `lobby-create` / `profile` / `game`) plus the
active `gameId`. It replaces the old `goto`-based redirects and the
`[id]` route param.
- **`activeView`** — the in-game view (`map` / `table` / `report` /
`battle` / `mail` / `designer-science`) plus the sub-parameters the
old route segments carried (`tableEntity`, `battleId`, `turn`,
@@ -31,8 +31,11 @@ render: it gates on `session.status` (anonymous → login, authenticated
→ the `appScreen.screen`), and for the authenticated tree mounts the
matching screen component from `src/lib/screens/`
(`login-screen.svelte`, `lobby-screen.svelte`,
`lobby-create-screen.svelte`) or, for `screen === "game"`, the in-game
shell `src/lib/game/game-shell.svelte`. The game shell in turn renders
`lobby-create-screen.svelte`, `profile-screen.svelte`) or, for
`screen === "game"`, the in-game shell
`src/lib/game/game-shell.svelte`. Lobby and profile share a
post-login chrome (sidebar + identity strip) implemented in
`lib/screens/lobby-shell.svelte`; see [`lobby.md`](lobby.md). The game shell in turn renders
the active view from `activeView` (see below). Navigation is
`appScreen.go(screen, { gameId })` and `activeView.select(view,
params)` — never `goto`.
@@ -73,8 +76,9 @@ Browser **Back/Forward move between screens**, not views, and they do
so without ever changing the URL. The shell layers screen history on
top of `appScreen` via SvelteKit shallow routing: `appScreen.go(...)`
calls `pushState("", { screen, gameId })` for the overlay screens
(`game`, `lobby-create`) and `replaceState(...)` for `lobby` / `login`,
so browser **Back from a game returns to the lobby** beneath it. On
(`game`, `lobby-create`, `profile`) and `replaceState(...)` for
`lobby` / `login`, so browser **Back from a game (or profile) returns
to the lobby** beneath it. On
the first authenticated render the dispatcher stamps the restored
overlay on top of the load entry, then mirrors `page.state` back into
the store on every popstate through `appScreen.syncFromHistory(...)`.
+132
View File
@@ -0,0 +1,132 @@
// Typed wrappers around `GalaxyClient.executeCommand` for the user-
// account command catalog. Each wrapper builds a FlatBuffers request
// payload via the generated TS bindings, calls `executeCommand`, then
// decodes the `AccountResponse` reply. Errors with a non-`ok`
// `result_code` surface as a thrown `AccountError` carrying the
// canonical backend code (`invalid_request`, `subject_not_found`,
// `forbidden`, `conflict`, `internal_error`).
import { Builder, ByteBuffer } from "flatbuffers";
import type { GalaxyClient } from "./galaxy-client";
import {
AccountResponse,
AccountView,
ErrorResponse as FbsErrorResponse,
GetMyAccountRequest,
UpdateMyProfileRequest,
UpdateMySettingsRequest,
} from "../proto/galaxy/fbs/user";
const RESULT_CODE_OK = "ok";
export class AccountError extends Error {
readonly code: string;
readonly resultCode: string;
constructor(resultCode: string, code: string, message: string) {
super(message);
this.name = "AccountError";
this.resultCode = resultCode;
this.code = code;
}
}
export interface Account {
userId: string;
email: string;
userName: string;
displayName: string;
preferredLanguage: string;
timeZone: string;
declaredCountry: string;
}
export async function getMyAccount(client: GalaxyClient): Promise<Account> {
const builder = new Builder(32);
GetMyAccountRequest.startGetMyAccountRequest(builder);
builder.finish(GetMyAccountRequest.endGetMyAccountRequest(builder));
const payload = await execute(client, "user.account.get", builder.asUint8Array());
return decodeAccountResponse(payload);
}
export async function updateMyProfile(
client: GalaxyClient,
displayName: string,
): Promise<Account> {
const builder = new Builder(128);
const displayNameOff = builder.createString(displayName);
UpdateMyProfileRequest.startUpdateMyProfileRequest(builder);
UpdateMyProfileRequest.addDisplayName(builder, displayNameOff);
builder.finish(UpdateMyProfileRequest.endUpdateMyProfileRequest(builder));
const payload = await execute(client, "user.profile.update", builder.asUint8Array());
return decodeAccountResponse(payload);
}
export async function updateMySettings(
client: GalaxyClient,
preferredLanguage: string,
timeZone: string,
): Promise<Account> {
const builder = new Builder(128);
const preferredLanguageOff = builder.createString(preferredLanguage);
const timeZoneOff = builder.createString(timeZone);
UpdateMySettingsRequest.startUpdateMySettingsRequest(builder);
UpdateMySettingsRequest.addPreferredLanguage(builder, preferredLanguageOff);
UpdateMySettingsRequest.addTimeZone(builder, timeZoneOff);
builder.finish(UpdateMySettingsRequest.endUpdateMySettingsRequest(builder));
const payload = await execute(client, "user.settings.update", builder.asUint8Array());
return decodeAccountResponse(payload);
}
async function execute(
client: GalaxyClient,
messageType: string,
payloadBytes: Uint8Array,
): Promise<Uint8Array> {
const result = await client.executeCommand(messageType, payloadBytes);
if (result.resultCode !== RESULT_CODE_OK) {
throw decodeAccountError(result.resultCode, result.payloadBytes);
}
return result.payloadBytes;
}
function decodeAccountError(resultCode: string, payload: Uint8Array): AccountError {
let code = resultCode;
let message = resultCode;
try {
const errorResponse = FbsErrorResponse.getRootAsErrorResponse(new ByteBuffer(payload));
const body = errorResponse.error();
if (body) {
code = body.code() ?? resultCode;
message = body.message() ?? resultCode;
}
} catch (_err) {
// fall through with the raw result code
}
return new AccountError(resultCode, code, message);
}
function decodeAccountResponse(payload: Uint8Array): Account {
if (payload.length === 0) {
throw new AccountError("internal_error", "internal_error", "empty account response");
}
const response = AccountResponse.getRootAsAccountResponse(new ByteBuffer(payload));
const view = response.account();
if (view === null) {
throw new AccountError("internal_error", "internal_error", "account missing in response");
}
return decodeAccountView(view);
}
function decodeAccountView(view: AccountView): Account {
return {
userId: view.userId() ?? "",
email: view.email() ?? "",
userName: view.userName() ?? "",
displayName: view.displayName() ?? "",
preferredLanguage: view.preferredLanguage() ?? "",
timeZone: view.timeZone() ?? "",
declaredCountry: view.declaredCountry() ?? "",
};
}
+1 -1
View File
@@ -7,7 +7,7 @@ declare global {
// (and active game) live in `page.state` so browser Back/Forward
// move between screens while the address bar stays at /game/.
interface PageState {
screen?: "login" | "lobby" | "lobby-create" | "game";
screen?: "login" | "lobby" | "lobby-create" | "profile" | "game";
gameId?: string | null;
}
}
@@ -0,0 +1,76 @@
// `AccountStore` is the session-wide cache for the caller's
// `user.account.get` aggregate. The lobby shell and every post-login
// screen read the identity (display name, immutable user_name, time
// zone, …) from the same rune, so navigating between Overview and
// Profile does not refetch and does not flash the
// `lobby.account_loading` placeholder.
//
// `ensure(client)` fetches once on first call, dedupes concurrent
// callers onto a single in-flight promise, and resolves immediately
// from the cache thereafter. `set(account)` is the write-through
// path used by Profile after `user.profile.update` /
// `user.settings.update` succeeds — both the shell and the screen
// pick up the change without an extra round-trip. `clear()` resets
// the cache on logout so a different user signing in on the same
// browser does not briefly see the previous identity.
//
// The store is intentionally narrow: it caches one struct, never
// retries on failure (the caller decides), and exposes no error
// state of its own. Callers that need a tighter error surface (the
// Profile form) catch the rejection from `ensure(client)` directly.
import type { GalaxyClient } from "../api/galaxy-client";
import { getMyAccount, type Account } from "../api/account";
class AccountStore {
current: Account | null = $state(null);
#inFlight: Promise<Account> | null = null;
/**
* ensure returns the cached `Account` when present, otherwise issues
* `user.account.get` through the supplied client and caches the
* result. Concurrent callers during the first fetch share the same
* in-flight promise so the gateway only sees one request per
* session.
*/
ensure(client: GalaxyClient): Promise<Account> {
if (this.current !== null) {
return Promise.resolve(this.current);
}
if (this.#inFlight !== null) {
return this.#inFlight;
}
const pending = getMyAccount(client)
.then((account) => {
this.current = account;
return account;
})
.finally(() => {
this.#inFlight = null;
});
this.#inFlight = pending;
return pending;
}
/**
* set replaces the cached `Account` with the supplied value. Used
* by the Profile screen after a successful save so both the form
* and the shell identity strip pick up the new fields without a
* second round-trip.
*/
set(next: Account): void {
this.current = next;
}
/**
* clear resets the cache. Called on logout so a different user
* signing in on the same browser does not briefly see the
* previous identity through the rune.
*/
clear(): void {
this.current = null;
this.#inFlight = null;
}
}
export const account = new AccountStore();
+7 -2
View File
@@ -22,7 +22,7 @@
import { pushState, replaceState } from "$app/navigation";
export type AppScreen = "login" | "lobby" | "lobby-create" | "game";
export type AppScreen = "login" | "lobby" | "lobby-create" | "profile" | "game";
export type GameView =
| "map"
@@ -51,6 +51,7 @@ const APP_SCREENS: readonly AppScreen[] = [
"login",
"lobby",
"lobby-create",
"profile",
"game",
];
const GAME_VIEWS: readonly GameView[] = [
@@ -183,7 +184,11 @@ class AppScreenStore {
#syncHistory(): void {
if (typeof window === "undefined") return;
const state: App.PageState = { screen: this.#screen, gameId: this.#gameId };
if (this.#screen === "game" || this.#screen === "lobby-create") {
if (
this.#screen === "game" ||
this.#screen === "lobby-create" ||
this.#screen === "profile"
) {
pushState("", state);
} else {
replaceState("", state);
+24 -2
View File
@@ -92,6 +92,18 @@ export interface CalculatorResult {
outputs: CalculatorOutputs | null;
}
// isClaimedBlockValid checks that a solver result, before we apply it
// to the resolved blocks, satisfies the same per-field rules the live
// validator enforces on user-typed values (`pkg/calc/validator.go` /
// `lib/util/ship-class-validation`). The four claimable blocks all
// share the DWSC rule, so a single predicate suffices. Used to flag
// a goal-seek target as infeasible when the only block that would
// reach it falls in the (0, 1) gap.
function isClaimedBlockValid(solved: number): boolean {
if (!Number.isFinite(solved)) return false;
return solved === 0 || solved >= 1;
}
function resolveLoad(
mode: LoadMode,
customLoad: number,
@@ -225,8 +237,18 @@ export function computeCalculator(
if (solved === null) {
lockFeasible = false;
} else {
blocks[claimed] = solved;
computedInput = claimed;
// The solver may produce a value that is mathematically
// correct yet rejected by the ship-class value rules —
// most commonly a DWSC block in the (0, 1) gap. Surface
// that as an infeasible lock so the lock input flips
// red and the outputs are suppressed, instead of
// silently showing an invalid design.
if (!isClaimedBlockValid(solved)) {
lockFeasible = false;
} else {
blocks[claimed] = solved;
computedInput = claimed;
}
}
}
}
@@ -1,13 +1,17 @@
<!--
Reusable "Ship Class design area": the five design blocks (drive,
armament, weapons, shields, cargo) plus the four tech levels they are
built with. Each tech defaults to the player's current level and shows a
lock icon once overridden; clicking the lock resets it. A block claimed
by an active goal-seek lock renders read-only with its own lock marker.
The component is presentational — the parent owns the state and the
built with. Tech and MAT locks follow the same idiom as goal-seek
locks below the design area — by default the value renders as plain
text with an open padlock; clicking it overrides (input + closed
padlock). Reserved space for the padlock keeps the column width
stable as the lock state toggles. A block claimed by an active
goal-seek lock renders read-only with its own lock marker. The
component is presentational — the parent owns the state and the
calculator math — so the ship-group upgrade flow can reuse it later.
-->
<script lang="ts">
import { tick } from "svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import {
shipClassFieldErrors,
@@ -37,8 +41,17 @@ calculator math — so the ship-group upgrade flow can reuse it later.
resolved: DesignBlocksState;
techs: TechState;
techOverridden: Record<TechKey, boolean>;
// Lower bound for the tech inputs: the player's current tech on
// this turn. A design cannot be built with tech below the player's
// own level, so we surface that as a per-field validation error.
techFloor: TechState;
computedInput?: ClaimedInput | null;
blocksReadonly?: boolean;
// Formatter applied to the read-only tech value and to the
// resolved (goal-seek) ship-block value. Same `fmt` as the
// rest of the calculator, passed in so the design area stays
// presentational and the parent owns the rounding policy.
formatNumber: (value: number) => string;
onTechInput: (key: TechKey) => void;
onResetTech: (key: TechKey) => void;
};
@@ -47,8 +60,10 @@ calculator math — so the ship-group upgrade flow can reuse it later.
resolved,
techs = $bindable(),
techOverridden,
techFloor,
computedInput = null,
blocksReadonly = false,
formatNumber,
onTechInput,
onResetTech,
}: Props = $props();
@@ -73,21 +88,93 @@ calculator math — so the ship-group upgrade flow can reuse it later.
return reason === undefined ? "" : i18n.t(VALUE_REASON_KEY[reason]);
}
function techError(key: TechKey): string {
return techs[key] < 0 ? i18n.t("game.calculator.invalid.tech_value") : "";
const value = techs[key];
if (value < 0) return i18n.t("game.calculator.invalid.tech_value");
if (value < techFloor[key]) {
return i18n.t("game.calculator.invalid.tech_below_current");
}
return "";
}
// Smart step on the four ship-class blocks (drive, weapons, shields,
// cargo): values must be 0 or ≥ 1 per `pkg/calc/validator.go`, so the
// native 0.01 step would produce invalid intermediates like 0.01.
// Up: 0 jumps straight to 1; otherwise +0.1. Down: 1 collapses to 0;
// otherwise 0.1 down to 1, clamped at 0. Armament uses a plain
// integer step (±1, clamped at 0) so it follows the same
// JS-driven idiom and we can hide the native spinner uniformly.
function bumpBlock(value: number, dir: 1 | -1): number {
if (dir === 1) {
if (value < 1) return 1;
return Math.round((value + 0.1) * 10) / 10;
}
if (value <= 1) return 0;
return Math.round((value - 0.1) * 10) / 10;
}
function bumpArmament(value: number, dir: 1 | -1): number {
const next = Math.trunc(value) + dir;
return next < 0 ? 0 : next;
}
function onBlockKey(
event: KeyboardEvent,
key: keyof DesignBlocksState,
smart: boolean,
): void {
const dir = event.key === "ArrowUp" ? 1 : event.key === "ArrowDown" ? -1 : 0;
if (dir === 0) return;
event.preventDefault();
blocks[key] = smart
? bumpBlock(blocks[key], dir)
: bumpArmament(blocks[key], dir);
}
// Tech / modernization-target inputs all use the same ±0.001 step
// with a per-row floor; lifted into a helper so the parent can
// reuse it (modernization area in `calculator-tab`).
function bumpTech(event: KeyboardEvent, key: TechKey): void {
const dir = event.key === "ArrowUp" ? 1 : event.key === "ArrowDown" ? -1 : 0;
if (dir === 0) return;
event.preventDefault();
const current = techs[key];
const next = Math.round((current + dir * 0.001) * 1000) / 1000;
const floor = techFloor[key];
techs[key] = next < floor ? floor : next;
}
// Refuse a fourth decimal as typing happens: keeps the calculator
// from ever displaying a >3-decimal fraction the user could not
// have intended (the calculator math is `Ceil3`-rounded for display
// anyway). Pairs with `bind:value` — `apply` overwrites the bound
// state when Svelte's own bind handler has already read the
// over-precise number.
function capDecimals(event: Event, apply: (next: number) => void): void {
const el = event.currentTarget as HTMLInputElement;
const txt = el.value;
const dot = txt.indexOf(".");
if (dot < 0 || txt.length - dot - 1 <= 3) return;
el.value = txt.slice(0, dot + 4);
apply(el.valueAsNumber);
}
const BLOCK_ROWS: {
key: keyof DesignBlocksState;
label: () => string;
step: string;
tech: TechKey | null;
smartStep: boolean;
}[] = [
{ key: "drive", label: () => i18n.t("game.calculator.field.drive"), step: "0.01", tech: "drive" },
{ key: "armament", label: () => i18n.t("game.calculator.field.armament"), step: "1", tech: null },
{ key: "weapons", label: () => i18n.t("game.calculator.field.weapons"), step: "0.01", tech: "weapons" },
{ key: "shields", label: () => i18n.t("game.calculator.field.shields"), step: "0.01", tech: "shields" },
{ key: "cargo", label: () => i18n.t("game.calculator.field.cargo"), step: "0.01", tech: "cargo" },
{ key: "drive", label: () => i18n.t("game.calculator.field.drive"), tech: "drive", smartStep: true },
{ key: "armament", label: () => i18n.t("game.calculator.field.armament"), tech: null, smartStep: false },
{ key: "weapons", label: () => i18n.t("game.calculator.field.weapons"), tech: "weapons", smartStep: true },
{ key: "shields", label: () => i18n.t("game.calculator.field.shields"), tech: "shields", smartStep: true },
{ key: "cargo", label: () => i18n.t("game.calculator.field.cargo"), tech: "cargo", smartStep: true },
];
const techInputEls: Partial<Record<TechKey, HTMLInputElement>> = {};
async function activateTechOverride(key: TechKey): Promise<void> {
onTechInput(key);
await tick();
techInputEls[key]?.focus();
techInputEls[key]?.select();
}
</script>
<div class="design" data-testid="calculator-design-area">
@@ -102,46 +189,50 @@ calculator math — so the ship-group upgrade flow can reuse it later.
<span class="label">{row.label()}</span>
{#if isComputed}
<input
class="ship"
class="ship no-spin"
type="number"
step={row.step}
step="any"
readonly
value={resolved[row.key]}
value={formatNumber(resolved[row.key])}
data-computed="true"
data-testid={`calculator-block-${row.key}`}
title={i18n.t("game.calculator.lock.reset")}
/>
{:else}
<input
class="ship"
class="ship no-spin"
type="number"
step={row.step}
step="any"
min="0"
bind:value={blocks[row.key]}
readonly={blocksReadonly}
aria-invalid={blockError(row.key) !== "" ? "true" : "false"}
title={blockError(row.key)}
data-testid={`calculator-block-${row.key}`}
onkeydown={(e) => onBlockKey(e, row.key, row.smartStep)}
oninput={(e) => capDecimals(e, (v) => (blocks[row.key] = v))}
/>
{/if}
{#if row.tech !== null}
{@const techKey = row.tech}
<span class="tech-cell">
<input
class="tech"
type="number"
step="0.001"
min="0"
bind:value={techs[techKey]}
oninput={() => onTechInput(techKey)}
aria-invalid={techError(techKey) !== "" ? "true" : "false"}
title={techError(techKey)}
data-testid={`calculator-tech-${techKey}`}
/>
{#if techOverridden[techKey]}
<input
bind:this={techInputEls[techKey]}
class="tech no-spin"
type="number"
step="any"
min={techFloor[techKey]}
bind:value={techs[techKey]}
aria-invalid={techError(techKey) !== "" ? "true" : "false"}
title={techError(techKey)}
data-testid={`calculator-tech-${techKey}`}
onkeydown={(e) => bumpTech(e, techKey)}
oninput={(e) => capDecimals(e, (v) => (techs[techKey] = v))}
/>
<button
type="button"
class="lock"
class="lock active"
title={i18n.t("game.calculator.tech.reset")}
aria-label={i18n.t("game.calculator.tech.reset")}
data-testid={`calculator-tech-reset-${techKey}`}
@@ -149,6 +240,23 @@ calculator math — so the ship-group upgrade flow can reuse it later.
>
🔒
</button>
{:else}
<span
class="tech-val"
data-testid={`calculator-tech-value-${techKey}`}
>
{formatNumber(techs[techKey])}
</span>
<button
type="button"
class="lock"
title={i18n.t("game.calculator.tech.override")}
aria-label={i18n.t("game.calculator.tech.override")}
data-testid={`calculator-tech-override-${techKey}`}
onclick={() => void activateTechOverride(techKey)}
>
🔓
</button>
{/if}
</span>
{:else}
@@ -182,7 +290,7 @@ calculator math — so the ship-group upgrade flow can reuse it later.
font-size: 0.8rem;
}
input {
font: inherit;
font-family: var(--font-mono);
font-size: 0.8rem;
width: 100%;
min-width: 0;
@@ -192,6 +300,19 @@ calculator math — so the ship-group upgrade flow can reuse it later.
border: 1px solid var(--color-border);
border-radius: 3px;
font-variant-numeric: tabular-nums;
text-align: right;
}
/* Hide native spinners across the design area — the row drives
every numeric edit through ArrowUp/ArrowDown so the column
width stays stable and the inputs read consistently. */
input.no-spin::-webkit-inner-spin-button,
input.no-spin::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input.no-spin {
-moz-appearance: textfield;
appearance: textfield;
}
input[data-computed="true"],
input[readonly] {
@@ -206,6 +327,15 @@ calculator math — so the ship-group upgrade flow can reuse it later.
align-items: center;
gap: 0.2rem;
}
.tech-val {
flex: 1;
min-width: 0;
font-family: var(--font-mono);
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
text-align: right;
padding: 0.2rem 0.35rem;
}
.lock {
flex: none;
padding: 0;
@@ -214,5 +344,10 @@ calculator math — so the ship-group upgrade flow can reuse it later.
background: transparent;
border: 0;
cursor: pointer;
opacity: 0.5;
}
.lock.active,
.lock:hover {
opacity: 1;
}
</style>
+23 -4
View File
@@ -50,11 +50,11 @@ const en = {
"login.device_key_not_ready":
"device key is not ready, please reload the page",
"lobby.title": "you are logged in",
"lobby.device_session_id_label": "device session id",
"lobby.greeting": "hello, {name}!",
"lobby.account_loading": "loading account…",
"lobby.logout": "logout",
"lobby.nav.aria_label": "lobby pages",
"lobby.nav.overview": "Overview",
"lobby.nav.profile": "Profile",
"lobby.section.my_games": "my games",
"lobby.section.invitations": "pending invitations",
"lobby.section.applications": "my applications",
@@ -103,6 +103,22 @@ const en = {
"lobby.error.internal_error": "internal server error",
"lobby.error.unknown": "{message}",
"profile.title": "Profile",
"profile.loading": "loading account…",
"profile.field.user_name": "username",
"profile.field.email": "email",
"profile.field.display_name": "display name",
"profile.field.preferred_language": "preferred language",
"profile.field.time_zone": "time zone",
"profile.hint.display_name": "shown wherever Galaxy needs a friendlier name than the username handle. Leave empty to fall back to the username.",
"profile.hint.time_zone": "IANA zones grouped by continent. The form opens on your browser's current zone when no value is saved.",
"profile.save": "save",
"profile.saving": "saving…",
"profile.saved": "saved",
"profile.cancel": "cancel",
"profile.error.language_required": "language must not be empty",
"profile.error.time_zone_required": "time zone must not be empty",
"game.shell.unknown": "?",
"game.shell.connection.online": "online",
"game.shell.connection.reconnecting": "reconnecting…",
@@ -364,7 +380,6 @@ const en = {
"game.calculator.name.placeholder": "new class name",
"game.calculator.name.existing": "your ship classes",
"game.calculator.action.create": "create",
"game.calculator.action.delete": "delete",
"game.calculator.col.ship": "ship",
"game.calculator.col.tech": "tech",
"game.calculator.field.drive": "drive",
@@ -393,7 +408,9 @@ const en = {
"game.calculator.lock.reset": "locked — click to release to the computed value",
"game.calculator.lock.infeasible": "this target cannot be reached with the current design",
"game.calculator.lock.max": "release the locked result first — one result at a time",
"game.calculator.tech.override": "click to override your current tech",
"game.calculator.tech.reset": "overridden — click to reset to your current tech",
"game.calculator.mat.override": "click to override the planet value",
"game.calculator.mat.reset": "overridden — click to reset to the planet value",
"game.calculator.modern.current": "current",
"game.calculator.modern.target": "target",
@@ -418,8 +435,10 @@ const en = {
"game.calculator.invalid.all_zero": "at least one value must be nonzero",
"game.calculator.invalid.negative": "value cannot be negative",
"game.calculator.invalid.tech_value": "tech level cannot be negative",
"game.calculator.invalid.tech_below_current": "tech level cannot be below your current tech this turn",
"game.calculator.invalid.load_over_capacity": "load exceeds the ship's cargo capacity",
"game.calculator.lock.no_drive": "set a non-zero drive before locking speed",
"game.calculator.confirm_reset_for_load": "Discard unsaved changes and load class «{name}»?",
"game.table.sciences.title": "sciences",
"game.table.sciences.column.name": "name",
+23 -4
View File
@@ -51,11 +51,11 @@ const ru: Record<keyof typeof en, string> = {
"login.device_key_not_ready":
"ключ устройства ещё не готов, перезагрузите страницу",
"lobby.title": "вы вошли в систему",
"lobby.device_session_id_label": "идентификатор сессии устройства",
"lobby.greeting": "здравствуйте, {name}!",
"lobby.account_loading": "загрузка профиля…",
"lobby.logout": "выйти",
"lobby.nav.aria_label": "разделы лобби",
"lobby.nav.overview": "Обзор",
"lobby.nav.profile": "Профиль",
"lobby.section.my_games": "мои игры",
"lobby.section.invitations": "ожидающие приглашения",
"lobby.section.applications": "мои заявки",
@@ -104,6 +104,22 @@ const ru: Record<keyof typeof en, string> = {
"lobby.error.internal_error": "внутренняя ошибка сервера",
"lobby.error.unknown": "{message}",
"profile.title": "Профиль",
"profile.loading": "загрузка профиля…",
"profile.field.user_name": "идентификатор",
"profile.field.email": "электронная почта",
"profile.field.display_name": "отображаемое имя",
"profile.field.preferred_language": "язык интерфейса",
"profile.field.time_zone": "часовой пояс",
"profile.hint.display_name": "показывается там, где нужно более «человеческое» имя, чем системный идентификатор. Пустое значение — вернётся к идентификатору.",
"profile.hint.time_zone": "пояса IANA, сгруппированные по континентам. Если сохранённого значения нет, форма открывается на поясе, который определил браузер.",
"profile.save": "сохранить",
"profile.saving": "сохраняем…",
"profile.saved": "сохранено",
"profile.cancel": "отмена",
"profile.error.language_required": "язык не должен быть пустым",
"profile.error.time_zone_required": "часовой пояс не должен быть пустым",
"game.shell.unknown": "?",
"game.shell.connection.online": "онлайн",
"game.shell.connection.reconnecting": "переподключение…",
@@ -365,7 +381,6 @@ const ru: Record<keyof typeof en, string> = {
"game.calculator.name.placeholder": "имя нового класса",
"game.calculator.name.existing": "ваши классы кораблей",
"game.calculator.action.create": "создать",
"game.calculator.action.delete": "удалить",
"game.calculator.col.ship": "корабль",
"game.calculator.col.tech": "технологии",
"game.calculator.field.drive": "двигатель",
@@ -394,7 +409,9 @@ const ru: Record<keyof typeof en, string> = {
"game.calculator.lock.reset": "зафиксировано — нажмите, чтобы вернуть вычисляемое значение",
"game.calculator.lock.infeasible": "эта цель недостижима при текущих параметрах",
"game.calculator.lock.max": "сначала снимите фиксацию с другого результата — по одному за раз",
"game.calculator.tech.override": "нажмите, чтобы задать свой технологический уровень",
"game.calculator.tech.reset": "переопределено — нажмите, чтобы вернуть ваши текущие технологии",
"game.calculator.mat.override": "нажмите, чтобы задать своё значение MAT",
"game.calculator.mat.reset": "переопределено — нажмите, чтобы вернуть значение планеты",
"game.calculator.modern.current": "текущий",
"game.calculator.modern.target": "целевой",
@@ -419,8 +436,10 @@ const ru: Record<keyof typeof en, string> = {
"game.calculator.invalid.all_zero": "хотя бы одно значение должно быть ненулевым",
"game.calculator.invalid.negative": "значение не может быть отрицательным",
"game.calculator.invalid.tech_value": "технологический уровень не может быть отрицательным",
"game.calculator.invalid.tech_below_current": "технологический уровень не может быть ниже ваших текущих технологий на этом ходу",
"game.calculator.invalid.load_over_capacity": "загрузка превышает грузоподъёмность корабля",
"game.calculator.lock.no_drive": "задайте ненулевой двигатель, прежде чем фиксировать скорость",
"game.calculator.confirm_reset_for_load": "Сбросить несохранённые изменения и загрузить класс «{name}»?",
"game.table.sciences.title": "науки",
"game.table.sciences.column.name": "название",
+32 -93
View File
@@ -17,8 +17,7 @@
type GameSummary,
type InviteSummary,
} from "../../api/lobby";
import { ByteBuffer } from "flatbuffers";
import { AccountResponse } from "../../proto/galaxy/fbs/user";
import { account } from "$lib/account-store.svelte";
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
import {
SyntheticReportError,
@@ -27,10 +26,8 @@
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
import { loadCore } from "../../platform/core/index";
import { session } from "$lib/session-store.svelte";
import { Builder } from "flatbuffers";
import { GetMyAccountRequest } from "../../proto/galaxy/fbs/user";
import LobbyShell from "./lobby-shell.svelte";
let displayName: string | null = $state(null);
let configError: string | null = $state(null);
let listsLoading = $state(true);
let lobbyError: string | null = $state(null);
@@ -51,10 +48,6 @@
let client: GalaxyClient | null = null;
async function logout(): Promise<void> {
await session.signOut("user");
}
async function sha256(payload: Uint8Array): Promise<Uint8Array> {
const digest = await crypto.subtle.digest("SHA-256", payload as BufferSource);
return new Uint8Array(digest);
@@ -163,26 +156,6 @@
}
}
async function loadGreeting(c: GalaxyClient): Promise<void> {
const builder = new Builder(32);
GetMyAccountRequest.startGetMyAccountRequest(builder);
builder.finish(GetMyAccountRequest.endGetMyAccountRequest(builder));
const result = await c.executeCommand("user.account.get", builder.asUint8Array());
if (result.resultCode !== "ok") {
return;
}
const response = AccountResponse.getRootAsAccountResponse(
new ByteBuffer(result.payloadBytes),
);
const account = response.account();
if (account === null) {
return;
}
const display = account.displayName();
const userName = account.userName();
displayName = display && display.length > 0 ? display : userName;
}
function gotoCreate(): void {
appScreen.go("lobby-create");
}
@@ -260,7 +233,11 @@
deviceSessionId: session.deviceSessionId,
gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY,
});
loadGreeting(client).catch(() => {});
// Populate the session-wide identity cache; the shell's
// identity strip reads from there. Swallowed errors leave
// the shell on the `lobby.account_loading` placeholder
// without breaking the rest of the lobby.
account.ensure(client).catch(() => {});
await refreshAll();
} catch (err) {
lobbyError = describeLobbyError(err);
@@ -269,24 +246,7 @@
});
</script>
<a class="skip-link" href="#main-content">{i18n.t("common.skip_to_content")}</a>
<main id="main-content" tabindex="-1">
<header>
<h1>{i18n.t("lobby.title")}</h1>
<p>
{i18n.t("lobby.device_session_id_label")}:
<code data-testid="device-session-id">{session.deviceSessionId ?? ""}</code>
</p>
{#if displayName !== null}
<p data-testid="account-greeting">
{i18n.t("lobby.greeting", { name: displayName })}
</p>
{/if}
<button onclick={logout} data-testid="lobby-logout">
{i18n.t("lobby.logout")}
</button>
</header>
<LobbyShell activePage="overview">
{#if configError !== null}
<p role="alert" data-testid="account-error">{configError}</p>
{:else if lobbyError !== null}
@@ -483,38 +443,16 @@
</ul>
{/if}
</section>
</main>
</LobbyShell>
<style>
main {
padding: 1.5rem 1rem;
max-width: 32rem;
margin: 0 auto;
font-family: system-ui, sans-serif;
}
header {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
header h1 {
margin: 0;
}
header button {
align-self: flex-start;
}
section {
margin-bottom: 2rem;
margin-bottom: var(--space-6);
}
section h2 {
font-size: 1.1rem;
margin: 0 0 0.75rem;
font-size: var(--text-lg);
margin: 0 0 var(--space-3);
}
.card-list {
@@ -523,16 +461,16 @@
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
gap: var(--space-2);
}
.card {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem;
gap: var(--space-1);
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: 0.4rem;
border-radius: var(--radius-md);
background: var(--color-surface-raised);
text-align: left;
font: inherit;
@@ -552,54 +490,55 @@
.meta {
color: var(--color-text-muted);
font-size: 0.9rem;
font-size: var(--text-sm);
}
.status {
align-self: flex-start;
padding: 0.1rem 0.5rem;
border-radius: 999px;
padding: 0.1rem var(--space-2);
border-radius: var(--radius-pill);
background: var(--color-surface-raised);
font-size: 0.8rem;
font-size: var(--text-xs);
}
.actions {
display: flex;
gap: 0.5rem;
margin-top: 0.25rem;
gap: var(--space-2);
margin-top: var(--space-1);
}
form {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.5rem;
gap: var(--space-2);
margin-top: var(--space-2);
}
label {
display: flex;
flex-direction: column;
gap: 0.25rem;
gap: var(--space-1);
}
input[type="text"] {
font-size: 1rem;
padding: 0.4rem 0.5rem;
font: inherit;
font-size: var(--text-md);
padding: var(--space-1) var(--space-2);
}
.synthetic-loader {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.75rem;
gap: var(--space-2);
padding: var(--space-1) var(--space-3);
border: 1px dashed var(--color-text-muted);
border-radius: 0.4rem;
border-radius: var(--radius-md);
background: var(--color-surface-raised);
cursor: pointer;
font: inherit;
}
.synthetic-loader input[type="file"] {
font-size: 0.9rem;
font-size: var(--text-sm);
}
</style>
@@ -0,0 +1,227 @@
<!--
Shared chrome for the post-login "site"-style pages — the lobby
landing and the editable profile. Renders a left page-list sidebar
(mirroring the project site's VitePress layout) plus a top identity
strip ("Player-xxxx" → opens profile, logout). Children fill the
right-hand column. Pages mark themselves active via `activePage`.
The identity strip reads directly from the session-wide `account`
store so navigating Overview ⇄ Profile never re-renders an empty
placeholder: both screens populate the same cache through
`account.ensure(client)` and the shell renders the latest value.
-->
<script lang="ts">
import type { Snippet } from "svelte";
import { appScreen } from "$lib/app-nav.svelte";
import { i18n } from "$lib/i18n/index.svelte";
import { session } from "$lib/session-store.svelte";
import { account } from "$lib/account-store.svelte";
type Page = "overview" | "profile";
interface Props {
activePage: Page;
children: Snippet;
}
let { activePage, children }: Props = $props();
const PAGES: ReadonlyArray<{ id: Page; labelKey: "lobby.nav.overview" | "lobby.nav.profile"; screen: "lobby" | "profile" }> = [
{ id: "overview", labelKey: "lobby.nav.overview", screen: "lobby" },
{ id: "profile", labelKey: "lobby.nav.profile", screen: "profile" },
];
let identityLabel = $derived.by(() => {
const current = account.current;
if (current !== null) {
const trimmed = current.displayName.trim();
if (trimmed.length > 0) return trimmed;
if (current.userName.length > 0) return current.userName;
}
return i18n.t("lobby.account_loading");
});
function gotoPage(screen: "lobby" | "profile"): void {
if (appScreen.screen !== screen) {
appScreen.go(screen);
}
}
function gotoProfile(): void {
gotoPage("profile");
}
async function logout(): Promise<void> {
await session.signOut("user");
}
</script>
<a class="skip-link" href="#main-content">{i18n.t("common.skip_to_content")}</a>
<div class="layout">
<header class="topbar">
<button
type="button"
class="identity"
onclick={gotoProfile}
data-testid="lobby-account-name"
>
{identityLabel}
</button>
<button type="button" class="logout" onclick={logout} data-testid="lobby-logout">
{i18n.t("lobby.logout")}
</button>
</header>
<div class="body">
<nav class="sidebar" aria-label={i18n.t("lobby.nav.aria_label")}>
<ul>
{#each PAGES as page (page.id)}
<li>
<button
type="button"
class="nav-link"
class:active={activePage === page.id}
aria-current={activePage === page.id ? "page" : undefined}
onclick={() => gotoPage(page.screen)}
data-testid="lobby-nav-{page.id}"
>
{i18n.t(page.labelKey)}
</button>
</li>
{/each}
</ul>
</nav>
<main id="main-content" tabindex="-1" class="content">
{@render children()}
</main>
</div>
</div>
<style>
.layout {
display: flex;
flex-direction: column;
min-height: 100vh;
font-family: var(--font-mono);
color: var(--color-text);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3) var(--space-5);
border-bottom: 1px solid var(--color-border-subtle);
background: var(--color-surface);
}
.identity {
font: inherit;
font-size: var(--text-md);
color: var(--color-text);
background: transparent;
border: none;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-sm);
cursor: pointer;
text-decoration: underline;
text-underline-offset: 0.2em;
}
.identity:hover {
background: var(--color-surface-hover);
}
.logout {
font: inherit;
font-size: var(--text-sm);
padding: var(--space-1) var(--space-3);
background: transparent;
color: inherit;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
}
.logout:hover {
background: var(--color-surface-hover);
}
.body {
display: flex;
flex: 1 1 auto;
min-height: 0;
}
.sidebar {
flex: 0 0 14rem;
border-right: 1px solid var(--color-border-subtle);
padding: var(--space-4) var(--space-3);
background: var(--color-surface);
}
.sidebar ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.nav-link {
display: block;
width: 100%;
text-align: left;
font: inherit;
font-size: var(--text-base);
color: var(--color-text-muted);
background: transparent;
border: none;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
cursor: pointer;
}
.nav-link:hover {
background: var(--color-surface-hover);
color: var(--color-text);
}
.nav-link.active {
color: var(--color-accent);
background: var(--color-accent-subtle);
font-weight: var(--weight-medium);
}
.content {
flex: 1 1 auto;
padding: var(--space-5) var(--space-6);
max-width: 48rem;
}
@media (max-width: 640px) {
.body {
flex-direction: column;
}
.sidebar {
flex: 0 0 auto;
border-right: none;
border-bottom: 1px solid var(--color-border-subtle);
padding: var(--space-2) var(--space-3);
}
.sidebar ul {
flex-direction: row;
gap: var(--space-2);
overflow-x: auto;
}
.nav-link {
white-space: nowrap;
padding: var(--space-1) var(--space-3);
}
.content {
padding: var(--space-4);
max-width: none;
}
}
</style>
@@ -0,0 +1,387 @@
<script lang="ts">
// Profile screen: a top-level appScreen (peer of `lobby` and
// `lobby-create`). Loads the caller's account aggregate, lets the
// user edit `display_name`, `preferred_language`, and `time_zone`,
// and posts the changes through `user.profile.update` /
// `user.settings.update`. The form stays on screen after a
// successful save (the shell-level identity strip picks up the
// new value through the shared `account` store) — only `cancel`
// returns to the lobby.
import { onMount } from "svelte";
import { appScreen } from "$lib/app-nav.svelte";
import { createGatewayClient } from "../../api/connect";
import { GalaxyClient } from "../../api/galaxy-client";
import {
AccountError,
updateMyProfile,
updateMySettings,
type Account,
} from "../../api/account";
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
import {
i18n,
SUPPORTED_LOCALES,
type Locale,
type TranslationKey,
} from "$lib/i18n/index.svelte";
import { loadCore } from "../../platform/core/index";
import { session } from "$lib/session-store.svelte";
import { account } from "$lib/account-store.svelte";
import {
browserTimeZone,
supportedTimeZones,
withPreservedValue,
type TimeZoneGroup,
} from "$lib/time-zones";
import LobbyShell from "./lobby-shell.svelte";
let loaded: Account | null = $state(null);
let displayNameInput = $state("");
let preferredLanguageInput = $state("");
let timeZoneInput = $state("");
let loadError: string | null = $state(null);
let configError: string | null = $state(null);
let saveError: string | null = $state(null);
let saving = $state(false);
let savedNotice = $state(false);
let client: GalaxyClient | null = null;
const SUPPORTED_LOCALE_CODES: ReadonlySet<string> = new Set(
SUPPORTED_LOCALES.map((entry) => entry.code),
);
// Built once: the IANA list is static for the page lifetime. The
// stored value is folded in lazily so a zone the runtime no longer
// advertises still renders.
const TIME_ZONE_GROUPS_BASE: readonly TimeZoneGroup[] = supportedTimeZones();
let timeZoneGroups = $derived<readonly TimeZoneGroup[]>(
withPreservedValue(TIME_ZONE_GROUPS_BASE, timeZoneInput),
);
let timeZoneFallbackToText = $derived(TIME_ZONE_GROUPS_BASE.length === 0);
async function sha256(payload: Uint8Array): Promise<Uint8Array> {
const digest = await crypto.subtle.digest("SHA-256", payload as BufferSource);
return new Uint8Array(digest);
}
function describe(err: unknown): string {
if (err instanceof AccountError) {
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 applyAccount(next: Account): void {
loaded = next;
displayNameInput = next.displayName;
preferredLanguageInput = next.preferredLanguage;
// Seed an empty stored zone with the browser's current zone so
// the picker lands on a sensible default rather than the first
// IANA entry. The form treats "no change" as not posting, so
// the seeded value is only persisted on an explicit save.
timeZoneInput = next.timeZone.length > 0 ? next.timeZone : browserTimeZone();
}
function markDirty(): void {
// Any edit invalidates the "Saved" notice.
savedNotice = false;
saveError = null;
}
async function loadAccount(c: GalaxyClient): Promise<void> {
try {
applyAccount(await account.ensure(c));
} catch (err) {
loadError = describe(err);
}
}
async function save(event: SubmitEvent): Promise<void> {
event.preventDefault();
if (client === null || loaded === null || saving) return;
const trimmedDisplay = displayNameInput.trim();
const trimmedLanguage = preferredLanguageInput.trim();
const trimmedZone = timeZoneInput.trim();
if (trimmedLanguage === "") {
saveError = i18n.t("profile.error.language_required");
return;
}
if (trimmedZone === "") {
saveError = i18n.t("profile.error.time_zone_required");
return;
}
saving = true;
saveError = null;
savedNotice = false;
try {
let next: Account = loaded;
if (trimmedDisplay !== loaded.displayName) {
next = await updateMyProfile(client, trimmedDisplay);
}
if (
trimmedLanguage !== loaded.preferredLanguage ||
trimmedZone !== loaded.timeZone
) {
next = await updateMySettings(client, trimmedLanguage, trimmedZone);
}
applyAccount(next);
account.set(next);
// When the user picks a language the UI supports, switch the
// active locale immediately so the rest of the session sees
// the change without a reload. Unsupported BCP 47 codes are
// saved on the account but leave the active locale alone.
if (SUPPORTED_LOCALE_CODES.has(next.preferredLanguage)) {
i18n.setLocale(next.preferredLanguage as Locale);
}
savedNotice = true;
} catch (err) {
saveError = describe(err);
} finally {
saving = false;
}
}
function cancel(): void {
appScreen.go("lobby");
}
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;
try {
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,
});
await loadAccount(client);
} catch (err) {
loadError = describe(err);
}
});
</script>
<LobbyShell activePage="profile">
<h1>{i18n.t("profile.title")}</h1>
{#if configError !== null}
<p role="alert" data-testid="profile-config-error">{configError}</p>
{:else if loaded === null && loadError === null}
<p role="status" data-testid="profile-loading">{i18n.t("profile.loading")}</p>
{:else if loadError !== null}
<p role="alert" data-testid="profile-load-error">{loadError}</p>
{:else if loaded !== null}
<dl class="identity" data-testid="profile-identity">
<dt>{i18n.t("profile.field.user_name")}</dt>
<dd>{loaded.userName}</dd>
<dt>{i18n.t("profile.field.email")}</dt>
<dd>{loaded.email}</dd>
</dl>
<form onsubmit={save} data-testid="profile-form">
<label>
<span>{i18n.t("profile.field.display_name")}</span>
<input
type="text"
bind:value={displayNameInput}
oninput={markDirty}
autocomplete="nickname"
data-testid="profile-display-name"
/>
<small>{i18n.t("profile.hint.display_name")}</small>
</label>
<label>
<span>{i18n.t("profile.field.preferred_language")}</span>
<select
bind:value={preferredLanguageInput}
onchange={markDirty}
data-testid="profile-preferred-language"
>
{#each SUPPORTED_LOCALES as entry (entry.code)}
<option value={entry.code}>{entry.nativeName}</option>
{/each}
{#if !SUPPORTED_LOCALE_CODES.has(preferredLanguageInput) && preferredLanguageInput !== ""}
<!--
Backend stores arbitrary BCP 47 tags, but the UI only
ships translations for the codes in `SUPPORTED_LOCALES`.
Preserve the saved value so saving the form unchanged
does not silently switch it.
-->
<option value={preferredLanguageInput}>{preferredLanguageInput}</option>
{/if}
</select>
</label>
<label>
<span>{i18n.t("profile.field.time_zone")}</span>
{#if timeZoneFallbackToText}
<!--
Browser lacks `Intl.supportedValuesOf("timeZone")` —
fall back to a free-text field so a viable runtime can
still save a zone. The backend remains the validator.
-->
<input
type="text"
bind:value={timeZoneInput}
oninput={markDirty}
placeholder={browserTimeZone()}
autocomplete="off"
data-testid="profile-time-zone"
/>
{:else}
<select
bind:value={timeZoneInput}
onchange={markDirty}
data-testid="profile-time-zone"
>
{#each timeZoneGroups as group (group.label)}
<optgroup label={group.label}>
{#each group.values as zone (zone)}
<option value={zone}>{zone}</option>
{/each}
</optgroup>
{/each}
</select>
{/if}
<small>{i18n.t("profile.hint.time_zone")}</small>
</label>
{#if saveError !== null}
<p role="alert" data-testid="profile-save-error">{saveError}</p>
{:else if savedNotice}
<p role="status" data-testid="profile-saved-notice">
{i18n.t("profile.saved")}
</p>
{/if}
<div class="actions">
<button type="submit" disabled={saving} data-testid="profile-save">
{saving ? i18n.t("profile.saving") : i18n.t("profile.save")}
</button>
<button
type="button"
onclick={cancel}
disabled={saving}
data-testid="profile-cancel"
>
{i18n.t("profile.cancel")}
</button>
</div>
</form>
{/if}
</LobbyShell>
<style>
h1 {
font-size: var(--text-xl);
margin: 0 0 var(--space-4);
}
.identity {
display: grid;
grid-template-columns: max-content 1fr;
gap: var(--space-1) var(--space-4);
margin: 0 0 var(--space-5);
padding: var(--space-3) var(--space-4);
border: 1px solid var(--color-border-subtle);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
}
.identity dt {
color: var(--color-text-muted);
font-size: var(--text-sm);
}
.identity dd {
margin: 0;
font-size: var(--text-base);
}
form {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
label {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
label > span {
font-size: var(--text-sm);
color: var(--color-text-muted);
}
input[type="text"],
select {
font: inherit;
font-size: var(--text-md);
padding: var(--space-1) var(--space-2);
background: var(--color-surface);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
small {
color: var(--color-text-muted);
font-size: var(--text-xs);
}
[data-testid="profile-saved-notice"] {
color: var(--color-text-muted);
font-size: var(--text-sm);
margin: 0;
}
.actions {
display: flex;
gap: var(--space-3);
margin-top: var(--space-2);
}
.actions button {
font: inherit;
font-size: var(--text-md);
padding: var(--space-2) var(--space-4);
background: var(--color-surface-raised);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
}
.actions button[type="submit"] {
background: var(--color-accent);
color: var(--color-accent-contrast);
border-color: var(--color-accent);
}
.actions button:disabled {
cursor: not-allowed;
opacity: 0.6;
}
</style>
@@ -31,6 +31,7 @@ import {
loadDeviceSession,
setDeviceSessionId,
} from "../api/session";
import { account } from "./account-store.svelte";
export type SessionStatus =
| "loading"
@@ -94,6 +95,10 @@ export class SessionStore {
this.keypair = fresh.keypair;
this.deviceSessionId = null;
this.status = "anonymous";
// Drop the cached identity so a different user signing in on the
// same browser does not briefly see the previous display name
// through the post-login shell.
account.clear();
if (reason === "revoked") {
console.info("session store: device session revoked by gateway");
}
+240 -57
View File
@@ -14,7 +14,7 @@ switch (the inspector auto-opens on a planet click) — the calculator is a
long-lived planning tool. `ensureGame` resets it when the game changes.
-->
<script lang="ts">
import { getContext } from "svelte";
import { getContext, tick } from "svelte";
import { appScreen } from "$lib/app-nav.svelte";
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
@@ -204,11 +204,6 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
nameValidation.ok ? "" : i18n.t(nameInvalidKeyMap[nameValidation.reason]),
);
const canCreate = $derived(nameValidation.ok && draft !== undefined);
const canDelete = $derived(
cs.loadedExisting !== null &&
existingNames.includes(cs.loadedExisting) &&
draft !== undefined,
);
// Per-block modernization upgrade cost (current tech → target tech).
const modernCosts = $derived.by(() => {
@@ -237,12 +232,35 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
// Display every computed number rounded up to three decimals via the
// shared `Ceil3` bridge, so a value is never shown lower than it is.
// Always three decimals (`1` → `1.000`) for column-aligned readability,
// and without thousands grouping so the same string also embeds in the
// read-only goal-seek `<input type="number">` cell.
function fmt(value: number | null | undefined): string {
if (value === null || value === undefined) {
return i18n.t("game.calculator.unavailable");
}
const rounded = core !== null ? core.ceil3({ value }) : value;
return rounded.toLocaleString(undefined, { maximumFractionDigits: 3 });
return rounded.toLocaleString(undefined, {
minimumFractionDigits: 3,
maximumFractionDigits: 3,
useGrouping: false,
});
}
// Cap typed precision at three decimal digits. Number inputs use
// `step="any"`, which lets the browser accept arbitrary precision; the
// owner asked us to refuse a fourth decimal as typing happens so the
// calculator never displays a longer-than-three-digit fraction. Pairs
// with `bind:value`: if Svelte's bind handler has already read the
// over-precise number, `apply` overwrites the state with the truncated
// value so the next reactive flush does not undo our truncation.
function capDecimals(event: Event, apply: (next: number) => void): void {
const el = event.currentTarget as HTMLInputElement;
const txt = el.value;
const dot = txt.indexOf(".");
if (dot < 0 || txt.length - dot - 1 <= 3) return;
el.value = txt.slice(0, dot + 4);
apply(el.valueAsNumber);
}
// The goal-seek back-solved block, shown in its read-only cell, is
@@ -273,6 +291,18 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
cs.matValue < 0 ? i18n.t("game.calculator.invalid.negative") : "",
);
// Modernization target tech mirrors the design-area floor: a target
// below the player's current tech on this turn is meaningless (no
// upgrade), so flag it the same way.
function targetTechError(key: TechKey): string {
const value = cs.targetTech[key];
if (value < 0) return i18n.t("game.calculator.invalid.negative");
if (value < playerTech[key]) {
return i18n.t("game.calculator.invalid.tech_below_current");
}
return "";
}
// Locking a speed back-solves the drive block; with a zero drive the
// ship is deliberately immobile, so disallow it.
function lockDisabledReason(output: LockableOutputId): string {
@@ -291,8 +321,12 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
function onResetTech(key: TechKey): void {
cs.techOverridden[key] = false;
}
function onMatInput(): void {
const matInputRef: { el?: HTMLInputElement } = {};
async function activateMatOverride(): Promise<void> {
cs.matOverridden = true;
await tick();
matInputRef.el?.focus();
matInputRef.el?.select();
}
function resetMat(): void {
cs.matOverridden = false;
@@ -307,6 +341,30 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
cs.lock = null;
}
// Generic ±step keyboard handler for the calculator's free-form
// number inputs (MAT, custom-load, lock value, modernization
// target tech). Pairs with `class="no-spin"` so the native spinner
// is hidden everywhere and the column width is stable; ArrowUp /
// ArrowDown is the only step affordance. The smart 0↔1 jump on
// the ship-class blocks lives in `ship-design-area.svelte` —
// these other inputs accept any non-negative number.
function onStepKey(
event: KeyboardEvent,
current: number,
step: number,
min: number,
apply: (next: number) => void,
): void {
const dir = event.key === "ArrowUp" ? 1 : event.key === "ArrowDown" ? -1 : 0;
if (dir === 0) return;
event.preventDefault();
// Snap to the same fractional grid as `step` so 0.001 stays
// at three decimals instead of drifting via float math.
const inv = 1 / step;
const next = Math.round((current + dir * step) * inv) / inv;
apply(next < min ? min : next);
}
function loadExisting(clsName: string): void {
const cls = localShipClass.find((c) => c.name === clsName);
if (cls === undefined) return;
@@ -322,6 +380,78 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
cs.lock = null;
}
// Compare the live blocks to the baseline they were last loaded
// from — or to the empty defaults if no class has been loaded. The
// dropdown selection flow uses this to ask before discarding manual
// edits. Tech overrides are independent of class loading, so they
// don't count as "dirty" here.
function baselineBlocks(): {
drive: number;
armament: number;
weapons: number;
shields: number;
cargo: number;
} {
if (cs.loadedExisting !== null) {
const cls = localShipClass.find((c) => c.name === cs.loadedExisting);
if (cls !== undefined) {
return {
drive: cls.drive,
armament: cls.armament,
weapons: cls.weapons,
shields: cls.shields,
cargo: cls.cargo,
};
}
}
return { drive: 0, armament: 0, weapons: 0, shields: 0, cargo: 0 };
}
function isDesignDirty(): boolean {
const base = baselineBlocks();
return (
cs.blocks.drive !== base.drive ||
cs.blocks.armament !== base.armament ||
cs.blocks.weapons !== base.weapons ||
cs.blocks.shields !== base.shields ||
cs.blocks.cargo !== base.cargo
);
}
function tryLoadByName(name: string): void {
const cls = localShipClass.find((c) => c.name === name);
if (cls === undefined) return;
if (cs.loadedExisting === cls.name) return;
if (isDesignDirty()) {
const ok = window.confirm(
i18n.t("game.calculator.confirm_reset_for_load", {
name: cls.name,
}),
);
if (!ok) {
cs.name = cs.loadedExisting ?? "";
return;
}
}
loadExisting(name);
}
// Catch the datalist option click immediately. Native `change` only
// fires on blur in Firefox, which is what made dropdown selection
// look delayed; `input` fires the moment the value is set. Typed
// keystrokes carry an `inputType` ("insertText", "deleteContent…");
// a datalist selection replaces the value in one shot, so its
// `inputType` is undefined (Firefox) or "insertReplacementText"
// (Chromium / WebKit). We treat that as a selection.
function onNameInput(event: Event): void {
const ev = event as InputEvent;
const isSelection =
ev.inputType === undefined ||
ev.inputType === "insertReplacementText";
if (!isSelection) {
cs.loadedExisting = null;
return;
}
tryLoadByName(cs.name);
}
// React to the ship-classes table / bottom-tabs asking to load a
// class (or start a fresh design) into the calculator. The layout
// flips the sidebar to this tab in parallel.
@@ -354,16 +484,6 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
cs.loadedExisting = created.name;
}
async function deleteClass(): Promise<void> {
if (cs.loadedExisting === null || draft === undefined) return;
await draft.add({
kind: "removeShipClass",
id: crypto.randomUUID(),
name: cs.loadedExisting,
});
cs.loadedExisting = null;
}
const LOCK_LABELS: Record<LockableOutputId, string> = $derived({
emptyMass: i18n.t("game.calculator.out.mass"),
loadedMass: i18n.t("game.calculator.out.mass"),
@@ -378,9 +498,13 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
{#if cs.lock === output}
<span class="cell locked" class:infeasible={!result.lockFeasible}>
<input
class="no-spin"
type="number"
step="0.001"
step="any"
bind:value={cs.lockValue}
onkeydown={(e) =>
onStepKey(e, cs.lockValue, 0.001, 0, (v) => (cs.lockValue = v))}
oninput={(e) => capDecimals(e, (v) => (cs.lockValue = v))}
data-testid={`calculator-locked-${output}`}
title={result.lockFeasible ? "" : i18n.t("game.calculator.lock.infeasible")}
/>
@@ -446,8 +570,8 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
placeholder={i18n.t("game.calculator.name.placeholder")}
maxlength="30"
bind:value={cs.name}
oninput={() => (cs.loadedExisting = null)}
onchange={() => loadExisting(cs.name)}
oninput={onNameInput}
onchange={() => tryLoadByName(cs.name)}
aria-invalid={nameValidation.ok ? "false" : "true"}
data-testid="calculator-name"
/>
@@ -469,23 +593,15 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
</button>
{/if}
</div>
{#if cs.mode === "ship" && canDelete}
<button
type="button"
class="delete"
data-testid="calculator-delete"
onclick={() => void deleteClass()}
>
{i18n.t("game.calculator.action.delete")} {cs.loadedExisting}
</button>
{/if}
<ShipDesignArea
bind:blocks={cs.blocks}
resolved={resolvedCeil}
bind:techs={cs.techValues}
techOverridden={cs.techOverridden}
techFloor={playerTech}
computedInput={result.computedInput}
formatNumber={fmt}
{onTechInput}
{onResetTech}
/>
@@ -509,10 +625,13 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
{#if cs.loadMode === "custom"}
<input
type="number"
step="0.01"
step="any"
min="0"
class="custom-load"
class="custom-load no-spin"
bind:value={cs.customLoad}
onkeydown={(e) =>
onStepKey(e, cs.customLoad, 0.01, 0, (v) => (cs.customLoad = v))}
oninput={(e) => capDecimals(e, (v) => (cs.customLoad = v))}
aria-invalid={customLoadError !== "" ? "true" : "false"}
title={customLoadError}
data-testid="calculator-custom-load"
@@ -560,6 +679,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
<span class="val" data-testid="calculator-out-bombing">
{fmt(result.outputs?.bombing)}
</span>
<span class="lock-slot" aria-hidden="true">🔓</span>
</span>
<span></span>
</div>
@@ -569,6 +689,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
<span class="val" data-testid="calculator-out-cargo-capacity">
{fmt(result.outputs === null ? null : result.cargoCapacity)}
</span>
<span class="lock-slot" aria-hidden="true">🔓</span>
</span>
<span></span>
</div>
@@ -589,17 +710,21 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
<div class="rrow">
<span class="label">{i18n.t("game.calculator.planet.mat")}</span>
<span class="cell">
<input
type="number"
step="0.01"
min="0"
bind:value={cs.matValue}
oninput={onMatInput}
aria-invalid={matError !== "" ? "true" : "false"}
title={matError}
data-testid="calculator-planet-mat"
/>
{#if cs.matOverridden}
<input
bind:this={matInputRef.el}
class="no-spin"
type="number"
step="any"
min="0"
bind:value={cs.matValue}
onkeydown={(e) =>
onStepKey(e, cs.matValue, 0.01, 0, (v) => (cs.matValue = v))}
oninput={(e) => capDecimals(e, (v) => (cs.matValue = v))}
aria-invalid={matError !== "" ? "true" : "false"}
title={matError}
data-testid="calculator-planet-mat"
/>
<button
type="button"
class="lock active"
@@ -610,6 +735,23 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
>
🔒
</button>
{:else}
<span
class="mat-val"
data-testid="calculator-planet-mat-value"
>
{fmt(cs.matValue)}
</span>
<button
type="button"
class="lock"
title={i18n.t("game.calculator.mat.override")}
aria-label={i18n.t("game.calculator.mat.override")}
data-testid="calculator-mat-override"
onclick={() => void activateMatOverride()}
>
🔓
</button>
{/if}
</span>
<span></span>
@@ -638,18 +780,28 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
<span class="col-head">{i18n.t("game.calculator.modern.cost")}</span>
</div>
{#each modernCosts?.perBlock ?? [] as row (row.key)}
{@const targetError = targetTechError(row.key)}
<div class="rrow">
<span class="label">{i18n.t(`game.calculator.field.${row.key}` as TranslationKey)}</span>
<span class="cell">
<input
class="no-spin"
type="number"
step="0.001"
min="0"
step="any"
min={playerTech[row.key]}
bind:value={cs.targetTech[row.key]}
aria-invalid={cs.targetTech[row.key] < 0 ? "true" : "false"}
title={cs.targetTech[row.key] < 0
? i18n.t("game.calculator.invalid.negative")
: ""}
onkeydown={(e) =>
onStepKey(
e,
cs.targetTech[row.key],
0.001,
playerTech[row.key],
(v) => (cs.targetTech[row.key] = v),
)}
oninput={(e) =>
capDecimals(e, (v) => (cs.targetTech[row.key] = v))}
aria-invalid={targetError !== "" ? "true" : "false"}
title={targetError}
data-testid={`calculator-target-${row.key}`}
/>
</span>
@@ -718,8 +870,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
.name[aria-invalid="true"] {
border-color: var(--color-danger);
}
.create,
.delete {
.create {
font: inherit;
font-size: 0.8rem;
padding: 0.25rem 0.55rem;
@@ -737,10 +888,6 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
opacity: 0.5;
cursor: not-allowed;
}
.delete {
color: var(--color-danger);
align-self: flex-start;
}
.load {
display: flex;
align-items: center;
@@ -766,13 +913,15 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
}
.custom-load {
width: 4rem;
font: inherit;
font-family: var(--font-mono);
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
padding: 0.15rem 0.3rem;
background: var(--color-bg);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: 3px;
text-align: right;
}
.results,
.modern {
@@ -802,6 +951,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
justify-content: flex-end;
}
.cell .val {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-size: 0.85rem;
text-align: right;
@@ -809,7 +959,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
.cell input {
width: 100%;
min-width: 0;
font: inherit;
font-family: var(--font-mono);
font-size: 0.8rem;
padding: 0.15rem 0.3rem;
background: var(--color-bg);
@@ -819,6 +969,19 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
font-variant-numeric: tabular-nums;
text-align: right;
}
/* Hide the native spinner on every calculator number input — the
row drives every numeric edit through ArrowUp / ArrowDown so the
column width is stable and the inputs read consistently with the
ship-block row inside the design area. */
input.no-spin::-webkit-inner-spin-button,
input.no-spin::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input.no-spin {
-moz-appearance: textfield;
appearance: textfield;
}
.cell.locked input {
color: var(--color-accent);
border-color: var(--color-accent);
@@ -845,6 +1008,12 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
cursor: not-allowed;
opacity: 0.2;
}
.lock-slot {
flex: none;
font-size: 0.7rem;
line-height: 1;
visibility: hidden;
}
.planet {
border-top: 1px solid var(--color-border-subtle);
padding-top: 0.5rem;
@@ -878,6 +1047,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
}
.planet-stats dd {
margin: 0;
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-size: 0.85rem;
text-align: right;
@@ -895,8 +1065,21 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
cursor: not-allowed;
}
.full-capacity {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
font-size: 0.8rem;
color: var(--color-accent);
}
/* Plain-text view of the planet MAT (mirrors `.tech-val` in the
design area) so the cell width stays the same whether the value is
the inherited planet number or the player's override. */
.mat-val {
flex: 1;
min-width: 0;
font-family: var(--font-mono);
font-size: 0.85rem;
font-variant-numeric: tabular-nums;
text-align: right;
padding: 0.15rem 0.3rem;
}
</style>
+140
View File
@@ -0,0 +1,140 @@
// Time-zone option helpers for the Profile screen's `<select>`.
//
// The browser ships the full IANA list through
// `Intl.supportedValuesOf("timeZone")` (Chrome 99+, Firefox 93+,
// Safari 15.4+ — all within the PWA target). This module reads that
// list, groups the entries by their first slash-delimited segment
// (`Africa`, `America`, …), sorts both groups and entries within each
// group, and yields a shape that maps 1:1 onto `<optgroup>` /
// `<option>`.
//
// Two corner cases:
// * Singletons like `UTC` / `GMT` / `EST` have no slash, so they
// collapse into a single "Other" bucket at the bottom of the
// dropdown.
// * A stored value that is not in the browser-supplied list (an
// older zone the runtime no longer ships, or a name from a
// freshly-imported account) is appended as a one-entry "Other"
// option through `withPreservedValue`. The Profile form calls
// that helper so saving an unchanged form never silently
// downgrades a stored value to the default.
const OTHER_GROUP = "Other";
export interface TimeZoneGroup {
readonly label: string;
readonly values: readonly string[];
}
/**
* supportedTimeZones returns the browser-supplied IANA list, grouped
* by leading segment and sorted alphabetically. Returns an empty
* array when the runtime does not implement
* `Intl.supportedValuesOf("timeZone")` so callers can fall back to a
* text input.
*/
export function supportedTimeZones(): readonly TimeZoneGroup[] {
const zones = listSupportedZones();
if (zones.length === 0) return [];
return groupZones(zones);
}
/**
* withPreservedValue returns `groups` unchanged when the supplied
* `value` is empty or already appears in one of the groups.
* Otherwise it appends a single-entry "Other" group carrying the
* value so the `<select>` can render it without losing the saved
* zone. The original groups are not mutated.
*/
export function withPreservedValue(
groups: readonly TimeZoneGroup[],
value: string,
): readonly TimeZoneGroup[] {
const trimmed = value.trim();
if (trimmed === "") return groups;
for (const group of groups) {
if (group.values.includes(trimmed)) return groups;
}
const extra: TimeZoneGroup = { label: OTHER_GROUP, values: [trimmed] };
// Merge with an existing "Other" group if one is already present,
// otherwise append a fresh one.
const next: TimeZoneGroup[] = [];
let mergedIntoOther = false;
for (const group of groups) {
if (group.label === OTHER_GROUP) {
mergedIntoOther = true;
next.push({
label: OTHER_GROUP,
values: [...group.values, trimmed].sort((a, b) => a.localeCompare(b)),
});
} else {
next.push(group);
}
}
if (!mergedIntoOther) next.push(extra);
return next;
}
/**
* browserTimeZone returns the time zone the runtime believes the
* user is in. An empty string is returned when `Intl.DateTimeFormat`
* is missing or rejects the resolution.
*/
export function browserTimeZone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone ?? "";
} catch {
return "";
}
}
interface IntlWithSupportedValues {
supportedValuesOf?: (key: "timeZone") => string[];
}
function listSupportedZones(): string[] {
const intl = Intl as unknown as IntlWithSupportedValues;
if (typeof intl.supportedValuesOf !== "function") return [];
try {
const zones = intl.supportedValuesOf("timeZone");
return Array.isArray(zones) ? zones.slice() : [];
} catch {
return [];
}
}
function groupZones(zones: readonly string[]): readonly TimeZoneGroup[] {
const buckets = new Map<string, string[]>();
const others: string[] = [];
for (const zone of zones) {
const slash = zone.indexOf("/");
if (slash === -1) {
others.push(zone);
continue;
}
const prefix = zone.slice(0, slash);
const bucket = buckets.get(prefix);
if (bucket === undefined) {
buckets.set(prefix, [zone]);
} else {
bucket.push(zone);
}
}
const groups: TimeZoneGroup[] = [];
const sortedPrefixes = Array.from(buckets.keys()).sort((a, b) =>
a.localeCompare(b),
);
for (const prefix of sortedPrefixes) {
const values = (buckets.get(prefix) ?? []).slice().sort((a, b) =>
a.localeCompare(b),
);
groups.push({ label: prefix, values });
}
if (others.length > 0) {
groups.push({
label: OTHER_GROUP,
values: others.slice().sort((a, b) => a.localeCompare(b)),
});
}
return groups;
}
+5
View File
@@ -19,6 +19,7 @@
import LoginScreen from "$lib/screens/login-screen.svelte";
import LobbyScreen from "$lib/screens/lobby-screen.svelte";
import LobbyCreateScreen from "$lib/screens/lobby-create-screen.svelte";
import ProfileScreen from "$lib/screens/profile-screen.svelte";
import GameShell from "$lib/game/game-shell.svelte";
import { pushState } from "$app/navigation";
import { page } from "$app/state";
@@ -67,6 +68,8 @@
pushState("", { screen: "game", gameId: appScreen.gameId });
} else if (appScreen.screen === "lobby-create") {
pushState("", { screen: "lobby-create" });
} else if (appScreen.screen === "profile") {
pushState("", { screen: "profile" });
}
}
});
@@ -83,6 +86,8 @@
{#if session.status === "authenticated"}
{#if appScreen.screen === "lobby-create"}
<LobbyCreateScreen />
{:else if appScreen.screen === "profile"}
<ProfileScreen />
{:else if appScreen.screen === "game" && appScreen.gameId !== null}
<GameShell />
{:else}
+50
View File
@@ -135,6 +135,56 @@ describe("computeCalculator goal-seek", () => {
expect(result.blocks.drive).toBe(10);
});
test("an attack target that back-solves to a (0, 1) weapons block is infeasible", () => {
// weapons = targetAttack / weaponsTech; weaponsTech=1.5, a 0.5
// target → weapons = 0.333…, which fails the DWSC rule (must be
// 0 or ≥ 1). The lock is flagged infeasible so the UI shows the
// red border, and the claimed block is left at its raw value so
// the design preview keeps reading off the user's own design.
const core = makeFakeCore();
const result = computeCalculator(
input({ lock: { output: "attack", value: 0.5 } }),
core,
);
expect(result.lockFeasible).toBe(false);
expect(result.computedInput).toBeNull();
// The claimed block stays at its raw value.
expect(result.blocks.weapons).toBe(0);
});
test("an empty-mass target that back-solves to a (0, 1) cargo block is infeasible", () => {
// emptyMass = drive + shields + cargo; with drive=10 shields=5,
// rest excluding cargo = 15. Target 15.5 → cargo = 0.5, in the
// invalid gap, so the lock is flagged.
const core = makeFakeCore();
const result = computeCalculator(
input({ lock: { output: "emptyMass", value: 15.5 } }),
core,
);
expect(result.lockFeasible).toBe(false);
expect(result.computedInput).toBeNull();
expect(result.blocks.cargo).toBe(5);
});
test("speed lock is feasible at the ceiling when rest mass is zero", () => {
// Regression for the D=1, W=A=S=C=0 case: every block except
// drive is zero, so speed equals 20*driveTech (the ceiling); the
// solver must accept that exact target instead of flagging it
// as unreachable.
const core = makeFakeCore();
const result = computeCalculator(
input({
blocks: { drive: 1, armament: 0, weapons: 0, shields: 0, cargo: 0 },
driveTech: 1,
lock: { output: "speedEmpty", value: 20 },
}),
core,
);
expect(result.lockFeasible).toBe(true);
expect(result.computedInput).toBe("drive");
expect(result.outputs?.speedEmpty).toBeCloseTo(20, 9);
});
test("calls the matching solver with the right context", () => {
const weaponsForAttack = vi.fn(() => 7);
const core = makeFakeCore({ weaponsForAttack });
+386
View File
@@ -278,4 +278,390 @@ describe("calculator-tab", () => {
"15.273",
);
});
test("tech defaults render as a number with an open-lock affordance", () => {
const ui = mount();
// Default state: no override → number + open lock, no input.
expect(ui.getByTestId("calculator-tech-value-drive")).toHaveTextContent(
"1.2",
);
expect(
ui.getByTestId("calculator-tech-override-drive"),
).toBeInTheDocument();
expect(ui.queryByTestId("calculator-tech-drive")).toBeNull();
});
test("clicking the open tech lock reveals the input + closed lock", async () => {
const ui = mount();
await fireEvent.click(ui.getByTestId("calculator-tech-override-drive"));
// Now an input is rendered and the lock turned closed (reset).
expect(ui.getByTestId("calculator-tech-drive")).toHaveValue(1.2);
expect(ui.getByTestId("calculator-tech-reset-drive")).toBeInTheDocument();
expect(ui.queryByTestId("calculator-tech-value-drive")).toBeNull();
});
test("flags a tech override below the player's current tech", async () => {
const ui = mount();
await fireEvent.click(ui.getByTestId("calculator-tech-override-drive"));
// Player drive is 1.2; setting 0.5 is below the floor.
await fireEvent.input(ui.getByTestId("calculator-tech-drive"), {
target: { value: "0.5" },
});
expect(ui.getByTestId("calculator-tech-drive")).toHaveAttribute(
"aria-invalid",
"true",
);
});
test("smart step jumps from 0 to 1 on ArrowUp for ship blocks", async () => {
const ui = mount();
const drive = ui.getByTestId("calculator-block-drive") as HTMLInputElement;
drive.focus();
await fireEvent.keyDown(drive, { key: "ArrowUp" });
expect(drive).toHaveValue(1);
await fireEvent.keyDown(drive, { key: "ArrowUp" });
expect(drive).toHaveValue(1.1);
await fireEvent.keyDown(drive, { key: "ArrowDown" });
expect(drive).toHaveValue(1);
await fireEvent.keyDown(drive, { key: "ArrowDown" });
expect(drive).toHaveValue(0);
});
test("regression: speed lock works at the ceiling with all-zero non-drive blocks", async () => {
const ui = mount();
await setBlock(ui, "drive", 1);
// Override drive tech to 1 so the ceiling math is plain.
await fireEvent.click(ui.getByTestId("calculator-tech-override-drive"));
await fireEvent.input(ui.getByTestId("calculator-tech-drive"), {
target: { value: "1" },
});
// With D=1, W=A=S=C=0 the only achievable speed is 20*driveTech=20.
expect(ui.getByTestId("calculator-out-speedEmpty")).toHaveTextContent("20");
await fireEvent.click(ui.getByTestId("calculator-lock-speedEmpty"));
const locked = ui.getByTestId("calculator-locked-speedEmpty");
expect(locked).toHaveValue(20);
// The lock is feasible — no infeasible title and no red error class.
expect(locked).not.toHaveAttribute(
"title",
expect.stringMatching(/cannot be reached/i),
);
});
test("planet MAT defaults to a value + open lock and opens an input on click", async () => {
const selection = new SelectionStore();
selection.selectPlanet(17);
const ui = mount({
report: makeReport({ planets: [LOCAL_PLANET] }),
selection,
});
// Initial state: the MAT shows the planet's value via the number cell
// and an open lock; no input until the override is activated.
expect(
ui.getByTestId("calculator-planet-mat-value"),
).toHaveTextContent("100");
expect(
ui.getByTestId("calculator-mat-override"),
).toBeInTheDocument();
expect(ui.queryByTestId("calculator-planet-mat")).toBeNull();
await fireEvent.click(ui.getByTestId("calculator-mat-override"));
expect(ui.getByTestId("calculator-planet-mat")).toHaveValue(100);
expect(ui.getByTestId("calculator-mat-reset")).toBeInTheDocument();
});
test("flags a modernization target below the player's current tech", async () => {
const ui = mount();
await fireEvent.click(ui.getByTestId("calculator-mode-modernization"));
// Player drive is 1.2; the target is seeded with the same value.
await fireEvent.input(ui.getByTestId("calculator-target-drive"), {
target: { value: "0.5" },
});
expect(ui.getByTestId("calculator-target-drive")).toHaveAttribute(
"aria-invalid",
"true",
);
});
test("armament Arrow keys step the integer block by ±1 (clamped at 0)", async () => {
const ui = mount();
const armament = ui.getByTestId(
"calculator-block-armament",
) as HTMLInputElement;
armament.focus();
await fireEvent.keyDown(armament, { key: "ArrowUp" });
expect(armament).toHaveValue(1);
await fireEvent.keyDown(armament, { key: "ArrowUp" });
expect(armament).toHaveValue(2);
await fireEvent.keyDown(armament, { key: "ArrowDown" });
expect(armament).toHaveValue(1);
await fireEvent.keyDown(armament, { key: "ArrowDown" });
expect(armament).toHaveValue(0);
// Clamped at zero — another ArrowDown is a no-op.
await fireEvent.keyDown(armament, { key: "ArrowDown" });
expect(armament).toHaveValue(0);
});
test("renders unoverridden tech as a 3-decimal label (matches the report)", () => {
// Player drive tech 1.2 → "1.200" via the shared ceil3 formatter,
// always padded to three decimals (calculator labels are column-
// aligned with the report).
const ui = mount();
const tech = ui.getByTestId("calculator-tech-value-drive");
expect((tech.textContent ?? "").trim()).toBe("1.200");
});
test("planet MAT label renders through the 3-decimal formatter", () => {
const selection = new SelectionStore();
selection.selectPlanet(17);
const ui = mount({
report: makeReport({ planets: [LOCAL_PLANET] }),
selection,
});
// Planet MAT is 100 → "100.000" through the shared formatter; the
// label is monospaced + right-aligned via the existing `.mat-val`
// rule. Integer MAT pads to three decimals like every other label.
const mat = ui.getByTestId("calculator-planet-mat-value");
expect((mat.textContent ?? "").trim()).toBe("100.000");
});
test("derived results pad to three decimals (integer empty mass)", async () => {
// Integer-valued outputs read with the same trailing zeros as
// fractional ones — column-aligned tabular display.
const ui = mount();
await setBlock(ui, "drive", 10);
await setBlock(ui, "shields", 5);
await setBlock(ui, "cargo", 5);
const mass = ui.getByTestId("calculator-out-emptyMass");
expect((mass.textContent ?? "").trim()).toBe("20.000");
});
test("number inputs refuse a fourth decimal as the user types", async () => {
const selection = new SelectionStore();
selection.selectPlanet(17);
const ui = mount({
report: makeReport({ planets: [LOCAL_PLANET] }),
selection,
});
// MAT input: typed "12.3456" must clamp to "12.345" on input.
await fireEvent.click(ui.getByTestId("calculator-mat-override"));
const mat = ui.getByTestId("calculator-planet-mat") as HTMLInputElement;
await fireEvent.input(mat, { target: { value: "12.3456" } });
expect(mat.value).toBe("12.345");
expect(mat.valueAsNumber).toBeCloseTo(12.345, 9);
// Custom-load input on a ship with a non-zero cargo: typed
// "1.2345" must clamp to "1.234".
await setBlock(ui, "drive", 10);
await setBlock(ui, "shields", 5);
await setBlock(ui, "cargo", 5);
await fireEvent.click(ui.getByTestId("calculator-load-custom"));
const load = ui.getByTestId("calculator-custom-load") as HTMLInputElement;
await fireEvent.input(load, { target: { value: "1.2345" } });
expect(load.value).toBe("1.234");
});
test("tech and target-tech inputs cap at three decimals", async () => {
const ui = mount();
// Tech override input.
await fireEvent.click(ui.getByTestId("calculator-tech-override-drive"));
const tech = ui.getByTestId("calculator-tech-drive") as HTMLInputElement;
await fireEvent.input(tech, { target: { value: "2.9999" } });
expect(tech.value).toBe("2.999");
// Modernization target tech input.
await fireEvent.click(ui.getByTestId("calculator-mode-modernization"));
const target = ui.getByTestId(
"calculator-target-drive",
) as HTMLInputElement;
await fireEvent.input(target, { target: { value: "3.1416" } });
expect(target.value).toBe("3.141");
});
test("lock value input caps at three decimals", async () => {
const ui = mount();
await setBlock(ui, "drive", 10);
await setBlock(ui, "shields", 5);
await setBlock(ui, "cargo", 5);
await fireEvent.click(ui.getByTestId("calculator-lock-attack"));
const lock = ui.getByTestId(
"calculator-locked-attack",
) as HTMLInputElement;
await fireEvent.input(lock, { target: { value: "0.1234" } });
expect(lock.value).toBe("0.123");
});
test("ship-block input caps at three decimals", async () => {
const ui = mount();
const drive = ui.getByTestId("calculator-block-drive") as HTMLInputElement;
await fireEvent.input(drive, { target: { value: "1.2345" } });
expect(drive.value).toBe("1.234");
});
test("lock spinner step is replaced by ArrowUp/ArrowDown (±0.001)", async () => {
const ui = mount();
await setBlock(ui, "drive", 10);
await setBlock(ui, "shields", 5);
await setBlock(ui, "cargo", 5);
await fireEvent.click(ui.getByTestId("calculator-lock-attack"));
const locked = ui.getByTestId(
"calculator-locked-attack",
) as HTMLInputElement;
// Lock value is seeded from outputs.attack (0 with no weapons).
const start = Number(locked.value);
locked.focus();
await fireEvent.keyDown(locked, { key: "ArrowUp" });
expect(Number(locked.value)).toBeCloseTo(start + 0.001, 9);
await fireEvent.keyDown(locked, { key: "ArrowDown" });
expect(Number(locked.value)).toBeCloseTo(start, 9);
});
test("flags the lock as infeasible when the back-solved block falls in (0, 1)", async () => {
// attack lock → weapons = targetAttack / weaponsTech. weaponsTech
// is 1.5; a target of 0.5 would force weapons = 0.333… which
// fails the DWSC rule (must be 0 or ≥ 1).
const ui = mount();
await setBlock(ui, "drive", 10);
await setBlock(ui, "armament", 2);
await setBlock(ui, "weapons", 5);
await setBlock(ui, "shields", 5);
await setBlock(ui, "cargo", 5);
await fireEvent.click(ui.getByTestId("calculator-lock-attack"));
await fireEvent.input(ui.getByTestId("calculator-locked-attack"), {
target: { value: "0.5" },
});
const locked = ui.getByTestId("calculator-locked-attack");
expect(locked).toHaveAttribute(
"title",
expect.stringMatching(/cannot be reached/i),
);
// The claimed block is not back-solved into the invalid (0, 1)
// range — the weapons input keeps the user's typed value (5).
expect(ui.getByTestId("calculator-block-weapons")).toHaveValue(5);
});
test("dropdown selection loads the class immediately (no blur needed)", async () => {
const ui = mount({
report: makeReport({
localShipClass: [
{
name: "Scout",
drive: 3,
armament: 0,
weapons: 0,
shields: 2,
cargo: 1,
},
],
} as unknown as GameReport),
});
// A datalist option click sets the whole value at once — Firefox
// reports no `inputType`, Chromium reports "insertReplacementText".
// Simulate the latter; the calculator should load before any
// `change` event.
await fireEvent.input(ui.getByTestId("calculator-name"), {
target: { value: "Scout" },
inputType: "insertReplacementText",
});
expect(ui.getByTestId("calculator-block-drive")).toHaveValue(3);
expect(ui.getByTestId("calculator-block-shields")).toHaveValue(2);
});
test("dropdown selection asks before discarding manual edits", async () => {
const ui = mount({
report: makeReport({
localShipClass: [
{
name: "Scout",
drive: 3,
armament: 0,
weapons: 0,
shields: 2,
cargo: 1,
},
],
} as unknown as GameReport),
});
// The user has hand-edited the design.
await setBlock(ui, "drive", 7);
const confirm = vi.spyOn(window, "confirm").mockReturnValue(false);
await fireEvent.input(ui.getByTestId("calculator-name"), {
target: { value: "Scout" },
inputType: "insertReplacementText",
});
expect(confirm).toHaveBeenCalledTimes(1);
// The user said no — the manual edits stay.
expect(ui.getByTestId("calculator-block-drive")).toHaveValue(7);
// The name field is reverted to the previously loaded class (or
// empty), so the field does not pretend the load happened.
expect(ui.getByTestId("calculator-name")).toHaveValue("");
confirm.mockReturnValue(true);
await fireEvent.input(ui.getByTestId("calculator-name"), {
target: { value: "Scout" },
inputType: "insertReplacementText",
});
// Confirmed — the class is now loaded.
expect(ui.getByTestId("calculator-block-drive")).toHaveValue(3);
confirm.mockRestore();
});
test("dropdown selection loads silently when the design is clean", async () => {
const ui = mount({
report: makeReport({
localShipClass: [
{
name: "Scout",
drive: 3,
armament: 0,
weapons: 0,
shields: 2,
cargo: 1,
},
],
} as unknown as GameReport),
});
const confirm = vi.spyOn(window, "confirm");
await fireEvent.input(ui.getByTestId("calculator-name"), {
target: { value: "Scout" },
inputType: "insertReplacementText",
});
expect(confirm).not.toHaveBeenCalled();
expect(ui.getByTestId("calculator-block-drive")).toHaveValue(3);
confirm.mockRestore();
});
test("does not render a delete-class button after loading a class", async () => {
const ui = mount({
report: makeReport({
localShipClass: [
{
name: "Scout",
drive: 3,
armament: 0,
weapons: 0,
shields: 2,
cargo: 1,
},
],
} as unknown as GameReport),
});
await fireEvent.input(ui.getByTestId("calculator-name"), {
target: { value: "Scout" },
inputType: "insertReplacementText",
});
// The loaded class state used to render a `delete <name>` button;
// the calculator no longer owns delete-class — issue #53 will.
expect(ui.queryByTestId("calculator-delete")).toBeNull();
});
test("bombing and cargo-capacity rows reserve the lock slot for column alignment", () => {
const ui = mount();
for (const id of ["calculator-out-bombing", "calculator-out-cargo-capacity"]) {
const cell = ui.getByTestId(id).parentElement;
expect(cell).not.toBeNull();
// A hidden placeholder occupies the same width as the lock button
// on the mass/speed/attack/defence rows, so the value column does
// not drift right on the rows without a lock.
expect(cell!.querySelector(".lock-slot")).not.toBeNull();
}
});
});
+16 -11
View File
@@ -159,9 +159,9 @@ async function completeLogin(page: Page): Promise<void> {
await page.getByTestId("login-code-input").click();
await page.getByTestId("login-code-input").fill("123456");
await page.getByTestId("login-code-submit").click();
// Sign-in switches the in-memory screen to the lobby; the device
// session id surfaces only on the lobby screen.
await expect(page.getByTestId("device-session-id")).toBeVisible();
// Sign-in switches the in-memory screen to the lobby; the identity
// strip rendered by `lobby-shell.svelte` is the lobby-loaded signal.
await expect(page.getByTestId("lobby-account-name")).toBeVisible();
}
test.describe("Phase 7 — auth flow", () => {
@@ -174,10 +174,7 @@ test.describe("Phase 7 — auth flow", () => {
}) => {
const mocks = await mockGatewayHappyPath(page, "Pilot");
await completeLogin(page);
await expect(page.getByTestId("device-session-id")).toHaveText(
"dev-test-1",
);
await expect(page.getByTestId("account-greeting")).toContainText("Pilot");
await expect(page.getByTestId("lobby-account-name")).toContainText("Pilot");
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
@@ -187,13 +184,13 @@ test.describe("Phase 7 — auth flow", () => {
}) => {
const mocks = await mockGatewayHappyPath(page, "Pilot");
await completeLogin(page);
await expect(page.getByTestId("account-greeting")).toBeVisible();
await expect(page.getByTestId("lobby-account-name")).toBeVisible();
await page.reload();
// The restored session re-renders the lobby screen directly (no
// `/lobby` route to land on).
await expect(page.getByTestId("device-session-id")).toHaveText(
"dev-test-1",
await expect(page.getByTestId("lobby-account-name")).toContainText(
"Pilot",
);
mocks.pendingSubscribes.forEach((resolve) => resolve());
@@ -204,7 +201,15 @@ test.describe("Phase 7 — auth flow", () => {
}) => {
const mocks = await mockGatewayHappyPath(page, "Pilot");
await completeLogin(page);
await expect(page.getByTestId("account-greeting")).toBeVisible();
// `lobby-account-name` becomes visible on lobby mount with the
// "loading account…" placeholder before the gateway responds.
// Wait for the loaded name to settle so the event-stream effect
// has had a chance to issue its `SubscribeEvents` request — the
// release below targets that pending stream, and an empty
// `pendingSubscribes` list defeats the test.
await expect(page.getByTestId("lobby-account-name")).toContainText(
"Pilot",
);
// Fire all pending SubscribeEvents requests with an empty 200
// response. Connect-Web's server-streaming reader sees no frames
+6 -3
View File
@@ -215,6 +215,9 @@ export interface AccountFixture {
email: string;
userName: string;
displayName: string;
preferredLanguage?: string;
timeZone?: string;
declaredCountry?: string;
}
export function buildAccountResponsePayload(account: AccountFixture): Uint8Array {
@@ -237,9 +240,9 @@ export function buildAccountResponsePayload(account: AccountFixture): Uint8Array
const email = builder.createString(account.email);
const userName = builder.createString(account.userName);
const displayName = builder.createString(account.displayName);
const preferredLanguage = builder.createString("en");
const timeZone = builder.createString("UTC");
const declaredCountry = builder.createString("");
const preferredLanguage = builder.createString(account.preferredLanguage ?? "en");
const timeZone = builder.createString(account.timeZone ?? "UTC");
const declaredCountry = builder.createString(account.declaredCountry ?? "");
AccountView.startAccountView(builder);
AccountView.addUserId(builder, userId);
AccountView.addEmail(builder, email);
@@ -275,6 +275,9 @@ test("calculator draws reach circles for the selected planet", async ({
await calc.getByTestId("calculator-block-drive").fill("10");
await calc.getByTestId("calculator-block-shields").fill("5");
await calc.getByTestId("calculator-block-cargo").fill("5");
// Tech defaults render as a number + open lock; click to reveal the
// input before typing an override (the F8-06 unified lock idiom).
await calc.getByTestId("calculator-tech-override-drive").click();
await calc.getByTestId("calculator-tech-drive").fill("1.2");
await expect.poll(() => countReachCircles(page)).toBeGreaterThan(0);
+1 -1
View File
@@ -251,7 +251,7 @@ async function completeLogin(page: Page): Promise<void> {
await page.getByTestId("login-code-input").fill("123456");
await page.getByTestId("login-code-submit").click();
// Sign-in switches the in-memory screen to the lobby.
await expect(page.getByTestId("device-session-id")).toBeVisible();
await expect(page.getByTestId("lobby-account-name")).toBeVisible();
}
test.describe("Phase 8 — lobby flow", () => {
@@ -0,0 +1,339 @@
// F8-04 profile screen — end-to-end coverage. Mocks the gateway so the
// lobby boots with an account aggregate, then exercises the sidebar
// navigation into the profile, the edit form, the save-stay flow, and
// the time-zone dropdown.
import { fromJson, type JsonValue } from "@bufbuild/protobuf";
import { ByteBuffer } from "flatbuffers";
import { expect, test, type Page } from "@playwright/test";
import { ExecuteCommandRequestSchema } from "../../src/proto/edge/v1/edge_gateway_pb";
import {
UpdateMyProfileRequest,
UpdateMySettingsRequest,
} from "../../src/proto/galaxy/fbs/user";
import { forgeExecuteCommandResponseJson } from "./fixtures/sign-response";
import {
buildAccountResponsePayload,
buildMyApplicationsListPayload,
buildMyGamesListPayload,
buildMyInvitesListPayload,
buildPublicGamesListPayload,
type AccountFixture,
} from "./fixtures/lobby-fbs";
interface ProfileMocks {
pendingSubscribes: Array<() => void>;
account: AccountFixture;
accountGetCount: number;
profileUpdates: Array<{ displayName: string }>;
settingsUpdates: Array<{ preferredLanguage: string; timeZone: string }>;
}
async function mockGateway(
page: Page,
initial: AccountFixture,
): Promise<ProfileMocks> {
const mocks: ProfileMocks = {
pendingSubscribes: [],
account: { ...initial },
accountGetCount: 0,
profileUpdates: [],
settingsUpdates: [],
};
await page.route("**/api/v1/public/auth/send-email-code", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ challenge_id: "ch-test-1" }),
});
});
await page.route("**/api/v1/public/auth/confirm-email-code", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ device_session_id: "dev-test-1" }),
});
});
await page.route("**/edge.v1.Gateway/ExecuteCommand", async (route) => {
const reqText = route.request().postData();
if (reqText === null) {
await route.fulfill({ status: 400 });
return;
}
const req = fromJson(
ExecuteCommandRequestSchema,
JSON.parse(reqText) as JsonValue,
);
let payload: Uint8Array;
switch (req.messageType) {
case "user.account.get":
mocks.accountGetCount += 1;
payload = buildAccountResponsePayload(mocks.account);
break;
case "user.profile.update": {
const decoded = UpdateMyProfileRequest.getRootAsUpdateMyProfileRequest(
new ByteBuffer(req.payloadBytes),
);
const next = decoded.displayName() ?? "";
mocks.profileUpdates.push({ displayName: next });
mocks.account = { ...mocks.account, displayName: next };
payload = buildAccountResponsePayload(mocks.account);
break;
}
case "user.settings.update": {
const decoded = UpdateMySettingsRequest.getRootAsUpdateMySettingsRequest(
new ByteBuffer(req.payloadBytes),
);
const preferredLanguage = decoded.preferredLanguage() ?? "";
const timeZone = decoded.timeZone() ?? "";
mocks.settingsUpdates.push({ preferredLanguage, timeZone });
mocks.account = { ...mocks.account, preferredLanguage, timeZone };
payload = buildAccountResponsePayload(mocks.account);
break;
}
case "lobby.my.games.list":
payload = buildMyGamesListPayload([]);
break;
case "lobby.public.games.list":
payload = buildPublicGamesListPayload([]);
break;
case "lobby.my.invites.list":
payload = buildMyInvitesListPayload([]);
break;
case "lobby.my.applications.list":
payload = buildMyApplicationsListPayload([]);
break;
default:
payload = new Uint8Array();
}
const responseJson = await forgeExecuteCommandResponseJson({
requestId: req.requestId,
timestampMs: BigInt(Date.now()),
resultCode: "ok",
payloadBytes: payload,
});
await route.fulfill({
status: 200,
contentType: "application/json",
body: responseJson,
});
});
await page.route("**/edge.v1.Gateway/SubscribeEvents", async (route) => {
const action = await new Promise<"endOfStream" | "abort">((resolve) => {
mocks.pendingSubscribes.push(() => resolve("endOfStream"));
});
if (action === "abort") {
await route.abort();
return;
}
const body = new TextEncoder().encode("{}");
const frame = new Uint8Array(5 + body.length);
frame[0] = 0x02;
new DataView(frame.buffer).setUint32(1, body.length, false);
frame.set(body, 5);
await route.fulfill({
status: 200,
contentType: "application/connect+json",
body: Buffer.from(frame),
});
});
return mocks;
}
async function completeLogin(page: Page): Promise<void> {
await page.goto("/");
await expect(page.getByTestId("login-email-input")).toBeVisible();
await page.getByTestId("login-email-input").click();
await page.getByTestId("login-email-input").fill("pilot@example.com");
await page.getByTestId("login-email-submit").click();
await expect(page.getByTestId("login-code-input")).toBeVisible();
await page.getByTestId("login-code-input").click();
await page.getByTestId("login-code-input").fill("123456");
await page.getByTestId("login-code-submit").click();
await expect(page.getByTestId("lobby-account-name")).toBeVisible();
}
test.describe("F8-04 — profile screen", () => {
test("clicking the identity strip opens the profile and renders the form", async ({
page,
}) => {
const mocks = await mockGateway(page, {
userId: "user-1",
email: "pilot@example.com",
userName: "player-abc12345",
displayName: "Pilot",
});
await completeLogin(page);
await expect(page.getByTestId("lobby-account-name")).toContainText("Pilot");
await page.getByTestId("lobby-account-name").click();
await expect(page.getByTestId("profile-form")).toBeVisible();
await expect(page.getByTestId("profile-display-name")).toHaveValue("Pilot");
await expect(page.getByTestId("profile-identity")).toContainText(
"player-abc12345",
);
await expect(page.getByTestId("profile-identity")).toContainText(
"pilot@example.com",
);
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
test("saving an edited display name posts user.profile.update, stays on the form, and refreshes the identity strip", async ({
page,
}) => {
const mocks = await mockGateway(page, {
userId: "user-1",
email: "pilot@example.com",
userName: "player-abc12345",
displayName: "Pilot",
});
await completeLogin(page);
await page.getByTestId("lobby-account-name").click();
await expect(page.getByTestId("profile-form")).toBeVisible();
await page.getByTestId("profile-display-name").fill("Captain");
await page.getByTestId("profile-save").click();
// Form stays on screen; the saved notice surfaces and the
// shell-level identity strip picks up the new name without a
// second `user.account.get`.
await expect(page.getByTestId("profile-saved-notice")).toBeVisible();
await expect(page.getByTestId("profile-form")).toBeVisible();
await expect(page.getByTestId("lobby-account-name")).toContainText(
"Captain",
);
expect(mocks.profileUpdates).toEqual([{ displayName: "Captain" }]);
expect(mocks.settingsUpdates).toEqual([]);
// Editing the form again clears the notice so a follow-up save is
// unambiguous.
await page.getByTestId("profile-display-name").fill("Pilot");
await expect(page.getByTestId("profile-saved-notice")).toHaveCount(0);
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
test("changing the language posts user.settings.update, stays on the form, and switches the active locale", async ({
page,
}) => {
const mocks = await mockGateway(page, {
userId: "user-1",
email: "pilot@example.com",
userName: "player-abc12345",
displayName: "Pilot",
});
await completeLogin(page);
await page.getByTestId("lobby-account-name").click();
await page.getByTestId("profile-preferred-language").selectOption("ru");
await page.getByTestId("profile-save").click();
// Profile stays on screen; the Russian dictionary now drives the
// form copy. The save button label is the visible signal.
await expect(page.getByTestId("profile-form")).toBeVisible();
await expect(page.getByTestId("profile-save")).toHaveText("сохранить");
await expect(page.getByTestId("profile-saved-notice")).toBeVisible();
expect(mocks.settingsUpdates).toHaveLength(1);
expect(mocks.settingsUpdates[0]?.preferredLanguage).toBe("ru");
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
test("cancel returns to the lobby without posting anything", async ({
page,
}) => {
const mocks = await mockGateway(page, {
userId: "user-1",
email: "pilot@example.com",
userName: "player-abc12345",
displayName: "Pilot",
});
await completeLogin(page);
await page.getByTestId("lobby-account-name").click();
await expect(page.getByTestId("profile-form")).toBeVisible();
await page.getByTestId("profile-display-name").fill("ignored");
await page.getByTestId("profile-cancel").click();
await expect(page.getByTestId("lobby-my-games-empty")).toBeVisible();
expect(mocks.profileUpdates).toEqual([]);
expect(mocks.settingsUpdates).toEqual([]);
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
test("time zone is a continent-grouped <select>; saving an edited zone posts user.settings.update", async ({
page,
}) => {
const mocks = await mockGateway(page, {
userId: "user-1",
email: "pilot@example.com",
userName: "player-abc12345",
displayName: "Pilot",
preferredLanguage: "en",
timeZone: "Europe/London",
});
await completeLogin(page);
await page.getByTestId("lobby-account-name").click();
await expect(page.getByTestId("profile-form")).toBeVisible();
const select = page.getByTestId("profile-time-zone");
// The field renders as a <select> with at least the Europe and
// America optgroups present and the stored zone selected.
expect(await select.evaluate((el) => el.tagName)).toBe("SELECT");
const optgroupLabels = await select.evaluate((el) =>
Array.from((el as HTMLSelectElement).querySelectorAll("optgroup")).map(
(g) => g.label,
),
);
expect(optgroupLabels).toContain("Europe");
expect(optgroupLabels).toContain("America");
await expect(select).toHaveValue("Europe/London");
await select.selectOption("America/New_York");
await page.getByTestId("profile-save").click();
await expect(page.getByTestId("profile-saved-notice")).toBeVisible();
expect(mocks.settingsUpdates).toEqual([
{ preferredLanguage: "en", timeZone: "America/New_York" },
]);
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
test("the identity strip persists across Overview ⇄ Profile without a second user.account.get", async ({
page,
}) => {
const mocks = await mockGateway(page, {
userId: "user-1",
email: "pilot@example.com",
userName: "player-abc12345",
displayName: "Pilot",
});
await completeLogin(page);
await expect(page.getByTestId("lobby-account-name")).toContainText("Pilot");
const firstCount = mocks.accountGetCount;
expect(firstCount).toBeGreaterThanOrEqual(1);
// Navigate Overview → Profile: identity must NOT flash the
// loading placeholder, and the cache must answer without a
// second gateway call.
await page.getByTestId("lobby-nav-profile").click();
await expect(page.getByTestId("profile-form")).toBeVisible();
await expect(page.getByTestId("lobby-account-name")).toContainText("Pilot");
// Navigate back to Overview.
await page.getByTestId("lobby-nav-overview").click();
await expect(page.getByTestId("lobby-my-games-empty")).toBeVisible();
await expect(page.getByTestId("lobby-account-name")).toContainText("Pilot");
expect(mocks.accountGetCount).toBe(firstCount);
mocks.pendingSubscribes.forEach((resolve) => resolve());
});
});
+5 -2
View File
@@ -94,10 +94,13 @@ export function makeFakeCore(overrides: Partial<Core> = {}): Core {
weaponsForAttack: ({ targetAttack, weaponsTech }) =>
weaponsTech <= 0 || targetAttack < 0 ? null : targetAttack / weaponsTech,
driveForSpeed: ({ targetSpeed, driveTech, restMass }) => {
if (driveTech <= 0 || targetSpeed <= 0) return null;
const ceiling = 20 * driveTech;
if (driveTech <= 0 || targetSpeed <= 0 || targetSpeed >= ceiling) {
return null;
if (restMass <= 0) {
if (targetSpeed !== ceiling) return null;
return 1;
}
if (targetSpeed >= ceiling) return null;
return (targetSpeed * restMass) / (ceiling - targetSpeed);
},
shieldsForDefence: ({ targetDefence, shieldsTech, restMass }) => {