Compare commits
7 Commits
5271f2b1ec
...
a679d9cdcb
| Author | SHA1 | Date | |
|---|---|---|---|
| a679d9cdcb | |||
| 2ecdecad1e | |||
| b03993fcb1 | |||
| b01a60e42b | |||
| cc4727a32e | |||
| cbf7f65916 | |||
| e9b904332e |
+17
-4
@@ -22,12 +22,25 @@ func WeaponsForAttack(targetAttack, weaponsTech float64) (float64, bool) {
|
|||||||
// DriveForSpeed returns the drive block that yields targetSpeed for a
|
// DriveForSpeed returns the drive block that yields targetSpeed for a
|
||||||
// ship whose mass excluding the drive block is restMass, at drive tech
|
// ship whose mass excluding the drive block is restMass, at drive tech
|
||||||
// level driveTech, inverting [Speed] composed with [DriveEffective].
|
// level driveTech, inverting [Speed] composed with [DriveEffective].
|
||||||
// Speed approaches but never reaches the stripped-hull ceiling
|
// With a positive restMass the speed approaches but never reaches the
|
||||||
// 20*driveTech, so a target at or above the ceiling (or a non-positive
|
// stripped-hull ceiling 20*driveTech, so a target at or above the
|
||||||
// target or tech level) is infeasible.
|
// 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) {
|
func DriveForSpeed(targetSpeed, driveTech, restMass float64) (float64, bool) {
|
||||||
|
if driveTech <= 0 || targetSpeed <= 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
ceiling := 20 * driveTech
|
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 0, false
|
||||||
}
|
}
|
||||||
return targetSpeed * restMass / (ceiling - targetSpeed), true
|
return targetSpeed * restMass / (ceiling - targetSpeed), true
|
||||||
|
|||||||
+19
-1
@@ -24,12 +24,30 @@ func TestDriveForSpeed(t *testing.T) {
|
|||||||
if !ok || math.Abs(got-drive) > 1e-9 {
|
if !ok || math.Abs(got-drive) > 1e-9 {
|
||||||
t.Errorf("DriveForSpeed round-trip = %v (ok=%v), want %v", got, ok, drive)
|
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 {
|
if _, ok := calc.DriveForSpeed(20*driveTech, driveTech, restMass); ok {
|
||||||
t.Error("DriveForSpeed at the speed ceiling should be infeasible")
|
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) {
|
func TestShieldsForDefence(t *testing.T) {
|
||||||
const shields, shieldsTech, restMass = 5.75, 1.0, 40.0
|
const shields, shieldsTech, restMass = 5.75, 1.0, 40.0
|
||||||
defence := calc.EffectiveDefence(shields, shieldsTech, shields+restMass)
|
defence := calc.EffectiveDefence(shields, shieldsTech, shields+restMass)
|
||||||
|
|||||||
+86
-29
@@ -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,
|
1. **Ship Class design area** — five blocks (drive, armament, weapons,
|
||||||
shields, cargo) and four tech levels (drive, weapons, shields,
|
shields, cargo) and four tech levels (drive, weapons, shields,
|
||||||
cargo). Tech defaults to the player's current tech and shows a lock
|
cargo). Tech defaults to the player's current tech: the cell renders
|
||||||
icon once overridden; clicking it resets to the default.
|
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/
|
2. **Calculator area** — derived results: empty/loaded mass, empty/
|
||||||
loaded speed, attack, defence, bombing (per ship), cargo capacity.
|
loaded speed, attack, defence, bombing (per ship), cargo capacity.
|
||||||
A load toggle (empty / full / custom) sets the cargo load (in cargo
|
A load toggle (empty / full / custom) sets the cargo load (in cargo
|
||||||
units) that the loaded-column results use. At **full** the toggle
|
units) that the loaded-column results use. At **full** the toggle
|
||||||
shows the ship's cargo capacity; a **custom** load over that capacity
|
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
|
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
|
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,
|
its MAT (overridable) and the single-turn build rate (ships per turn,
|
||||||
turns per ship). The realistic multi-turn forecast with CAP/COL
|
turns per ship). The MAT follows the same lock idiom as the tech
|
||||||
supply is planned (see ../ROADMAP.md).
|
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
|
## Locks and goal-seek
|
||||||
|
|
||||||
Two distinct lock semantics share one icon (a closed padlock; it only
|
Two distinct lock semantics share one padlock affordance. Both follow
|
||||||
appears once a value is pinned, click to release):
|
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
|
- **Override locks** on inputs that have a default — the four techs and
|
||||||
the planet MAT. Editing one overrides the default; the lock resets it.
|
the planet MAT. By default the cell shows the inherited number plus
|
||||||
Any number may be overridden at once.
|
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
|
- **Goal-seek locks** on derived results. Pinning a result back-solves
|
||||||
the single input it claims, which then renders read-only (computed):
|
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
|
Only **one** result may be locked at a time (the others' lock
|
||||||
affordances disable with a tooltip). An unreachable target — e.g. a
|
affordances disable with a tooltip). An unreachable target — e.g. a
|
||||||
speed at or above the stripped-hull ceiling `20 × driveTech`, or a
|
speed above the stripped-hull ceiling `20 × driveTech`, or a solved
|
||||||
solved block that fails the value rules — leaves the locked cell in a
|
block that fails the value rules (a DWSC value in the `(0, 1)` gap)
|
||||||
red error state and does not apply. Inverse solving lives in
|
— leaves the locked cell in a red error state and does not apply.
|
||||||
`pkg/calc/solve.go`; the bisection for defence → shields is the only
|
When that happens the claimed block is **not** back-solved into the
|
||||||
non-analytic case. Locking a speed is disabled when the drive block is
|
invalid range; the design preview keeps reading the user's typed
|
||||||
zero (a deliberately immobile ship has no speed to back-solve).
|
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
|
## Validation and display
|
||||||
|
|
||||||
@@ -75,25 +114,43 @@ negative, the five blocks follow the engine value rules
|
|||||||
(`pkg/calc/validator.go`, surfaced per-field by
|
(`pkg/calc/validator.go`, surfaced per-field by
|
||||||
`shipClassFieldErrors`), and a custom load may not exceed cargo capacity.
|
`shipClassFieldErrors`), and a custom load may not exceed cargo capacity.
|
||||||
|
|
||||||
Every displayed number — the derived results and the goal-seek
|
Every displayed number — the derived results, the inherited tech /
|
||||||
back-solved input — is rounded **up** to three decimals through the
|
planet MAT labels, and the goal-seek back-solved input — is rounded
|
||||||
shared `pkg/calc/number.go.Ceil3` (bridged as `core.ceil3`), so a value
|
**up** to three decimals through the shared `pkg/calc/number.go.Ceil3`
|
||||||
is never shown lower than it is (a speed of 5.0003 reads 5.001). The
|
(bridged as `core.ceil3`) and always padded to three decimals so the
|
||||||
engine keeps its own round-to-nearest `util.Fixed*`; `Ceil3` is a
|
column reads the same on integers and fractions alike (a speed of 20
|
||||||
display-only helper that lives in `pkg/calc` so the UI and Go share one
|
shows as `20.000`, of 5.0003 as `5.001`). Labels and inputs use the
|
||||||
implementation.
|
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
|
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
|
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
|
new one); Create is disabled while the name is invalid or duplicate
|
||||||
(reusing `lib/util/ship-class-validation.ts`). When a saved class is
|
(reusing `lib/util/ship-class-validation.ts`). Create reuses the existing
|
||||||
loaded, a Delete affordance appears. Create / Delete reuse the existing
|
`createShipClass` order-draft flow, so the optimistic overlay reflects
|
||||||
`createShipClass` / `removeShipClass` order-draft flow, so the optimistic
|
the change immediately. Ship classes are immutable after creation (per
|
||||||
overlay reflects the change immediately. Ship classes are immutable after
|
`game/rules.txt`), so there is no edit — only Create-new. Delete-class
|
||||||
creation (per `game/rules.txt`), so there is no edit — only Create-new
|
lives in the ship-classes table (`lib/active-view/table-ship-classes.svelte`),
|
||||||
and Delete.
|
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
|
## Reach circles
|
||||||
|
|
||||||
|
|||||||
+71
-11
@@ -2,16 +2,48 @@
|
|||||||
|
|
||||||
The lobby is the first authenticated view; the user lands here after
|
The lobby is the first authenticated view; the user lands here after
|
||||||
the email-code login completes (see
|
the email-code login completes (see
|
||||||
[`docs/auth-flow.md`](auth-flow.md)). This doc captures the
|
[`docs/auth-flow.md`](auth-flow.md)). This doc captures the shared
|
||||||
sections, the application / invite lifecycle the user sees, and
|
shell, the Overview sections, the profile sub-screen, and the
|
||||||
the defaults baked into the create-game form.
|
defaults baked into the create-game form.
|
||||||
|
|
||||||
## Sections
|
## Shell
|
||||||
|
|
||||||
The lobby renders one column of sections, top to bottom, with the
|
Lobby and profile share a single chrome implemented in
|
||||||
common content max-width capped at `32rem` (same convention as the
|
`lib/screens/lobby-shell.svelte`. The chrome mirrors the project
|
||||||
login page). Cards inside each section take the full available
|
site's VitePress layout: a left page-list sidebar (Overview /
|
||||||
width.
|
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 |
|
| Section | Empty state | Source | Action |
|
||||||
| -------------------- | --------------------- | -------------------------- | --------------------------------------------------------- |
|
| -------------------- | --------------------- | -------------------------- | --------------------------------------------------------- |
|
||||||
@@ -21,9 +53,37 @@ width.
|
|||||||
| `my applications` | `no applications` | `lobby.my.applications.list` | Status badge (`pending` / `approved` / `rejected`) |
|
| `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`) |
|
| `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
|
## Profile sub-screen
|
||||||
a debug affordance) plus a greeting if the gateway returns a
|
|
||||||
`display_name` for the caller.
|
`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
|
`GameSummary` carries a `current_turn` field that the lobby UI does
|
||||||
not display directly — the in-game shell reads it from the same
|
not display directly — the in-game shell reads it from the same
|
||||||
|
|||||||
+11
-7
@@ -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`:
|
rune singletons in `src/lib/app-nav.svelte.ts`:
|
||||||
|
|
||||||
- **`appScreen`** — the top-level screen
|
- **`appScreen`** — the top-level screen
|
||||||
(`login` / `lobby` / `lobby-create` / `game`) plus the active
|
(`login` / `lobby` / `lobby-create` / `profile` / `game`) plus the
|
||||||
`gameId`. It replaces the old `goto`-based redirects and the `[id]`
|
active `gameId`. It replaces the old `goto`-based redirects and the
|
||||||
route param.
|
`[id]` route param.
|
||||||
- **`activeView`** — the in-game view (`map` / `table` / `report` /
|
- **`activeView`** — the in-game view (`map` / `table` / `report` /
|
||||||
`battle` / `mail` / `designer-science`) plus the sub-parameters the
|
`battle` / `mail` / `designer-science`) plus the sub-parameters the
|
||||||
old route segments carried (`tableEntity`, `battleId`, `turn`,
|
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
|
→ the `appScreen.screen`), and for the authenticated tree mounts the
|
||||||
matching screen component from `src/lib/screens/`
|
matching screen component from `src/lib/screens/`
|
||||||
(`login-screen.svelte`, `lobby-screen.svelte`,
|
(`login-screen.svelte`, `lobby-screen.svelte`,
|
||||||
`lobby-create-screen.svelte`) or, for `screen === "game"`, the in-game
|
`lobby-create-screen.svelte`, `profile-screen.svelte`) or, for
|
||||||
shell `src/lib/game/game-shell.svelte`. The game shell in turn renders
|
`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
|
the active view from `activeView` (see below). Navigation is
|
||||||
`appScreen.go(screen, { gameId })` and `activeView.select(view,
|
`appScreen.go(screen, { gameId })` and `activeView.select(view,
|
||||||
params)` — never `goto`.
|
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
|
so without ever changing the URL. The shell layers screen history on
|
||||||
top of `appScreen` via SvelteKit shallow routing: `appScreen.go(...)`
|
top of `appScreen` via SvelteKit shallow routing: `appScreen.go(...)`
|
||||||
calls `pushState("", { screen, gameId })` for the overlay screens
|
calls `pushState("", { screen, gameId })` for the overlay screens
|
||||||
(`game`, `lobby-create`) and `replaceState(...)` for `lobby` / `login`,
|
(`game`, `lobby-create`, `profile`) and `replaceState(...)` for
|
||||||
so browser **Back from a game returns to the lobby** beneath it. On
|
`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
|
the first authenticated render the dispatcher stamps the restored
|
||||||
overlay on top of the load entry, then mirrors `page.state` back into
|
overlay on top of the load entry, then mirrors `page.state` back into
|
||||||
the store on every popstate through `appScreen.syncFromHistory(...)`.
|
the store on every popstate through `appScreen.syncFromHistory(...)`.
|
||||||
|
|||||||
@@ -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() ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ declare global {
|
|||||||
// (and active game) live in `page.state` so browser Back/Forward
|
// (and active game) live in `page.state` so browser Back/Forward
|
||||||
// move between screens while the address bar stays at /game/.
|
// move between screens while the address bar stays at /game/.
|
||||||
interface PageState {
|
interface PageState {
|
||||||
screen?: "login" | "lobby" | "lobby-create" | "game";
|
screen?: "login" | "lobby" | "lobby-create" | "profile" | "game";
|
||||||
gameId?: string | null;
|
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();
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
import { pushState, replaceState } from "$app/navigation";
|
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 =
|
export type GameView =
|
||||||
| "map"
|
| "map"
|
||||||
@@ -51,6 +51,7 @@ const APP_SCREENS: readonly AppScreen[] = [
|
|||||||
"login",
|
"login",
|
||||||
"lobby",
|
"lobby",
|
||||||
"lobby-create",
|
"lobby-create",
|
||||||
|
"profile",
|
||||||
"game",
|
"game",
|
||||||
];
|
];
|
||||||
const GAME_VIEWS: readonly GameView[] = [
|
const GAME_VIEWS: readonly GameView[] = [
|
||||||
@@ -183,7 +184,11 @@ class AppScreenStore {
|
|||||||
#syncHistory(): void {
|
#syncHistory(): void {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
const state: App.PageState = { screen: this.#screen, gameId: this.#gameId };
|
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);
|
pushState("", state);
|
||||||
} else {
|
} else {
|
||||||
replaceState("", state);
|
replaceState("", state);
|
||||||
|
|||||||
@@ -92,6 +92,18 @@ export interface CalculatorResult {
|
|||||||
outputs: CalculatorOutputs | null;
|
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(
|
function resolveLoad(
|
||||||
mode: LoadMode,
|
mode: LoadMode,
|
||||||
customLoad: number,
|
customLoad: number,
|
||||||
@@ -224,12 +236,22 @@ export function computeCalculator(
|
|||||||
);
|
);
|
||||||
if (solved === null) {
|
if (solved === null) {
|
||||||
lockFeasible = false;
|
lockFeasible = false;
|
||||||
|
} else {
|
||||||
|
// 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 {
|
} else {
|
||||||
blocks[claimed] = solved;
|
blocks[claimed] = solved;
|
||||||
computedInput = claimed;
|
computedInput = claimed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let load: number;
|
let load: number;
|
||||||
if (input.lock !== null && CLAIM_MAP[input.lock.output] === "load") {
|
if (input.lock !== null && CLAIM_MAP[input.lock.output] === "load") {
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
<!--
|
<!--
|
||||||
Reusable "Ship Class design area": the five design blocks (drive,
|
Reusable "Ship Class design area": the five design blocks (drive,
|
||||||
armament, weapons, shields, cargo) plus the four tech levels they are
|
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
|
built with. Tech and MAT locks follow the same idiom as goal-seek
|
||||||
lock icon once overridden; clicking the lock resets it. A block claimed
|
locks below the design area — by default the value renders as plain
|
||||||
by an active goal-seek lock renders read-only with its own lock marker.
|
text with an open padlock; clicking it overrides (input + closed
|
||||||
The component is presentational — the parent owns the state and the
|
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.
|
calculator math — so the ship-group upgrade flow can reuse it later.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { tick } from "svelte";
|
||||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||||
import {
|
import {
|
||||||
shipClassFieldErrors,
|
shipClassFieldErrors,
|
||||||
@@ -37,8 +41,17 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
|||||||
resolved: DesignBlocksState;
|
resolved: DesignBlocksState;
|
||||||
techs: TechState;
|
techs: TechState;
|
||||||
techOverridden: Record<TechKey, boolean>;
|
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;
|
computedInput?: ClaimedInput | null;
|
||||||
blocksReadonly?: boolean;
|
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;
|
onTechInput: (key: TechKey) => void;
|
||||||
onResetTech: (key: TechKey) => void;
|
onResetTech: (key: TechKey) => void;
|
||||||
};
|
};
|
||||||
@@ -47,8 +60,10 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
|||||||
resolved,
|
resolved,
|
||||||
techs = $bindable(),
|
techs = $bindable(),
|
||||||
techOverridden,
|
techOverridden,
|
||||||
|
techFloor,
|
||||||
computedInput = null,
|
computedInput = null,
|
||||||
blocksReadonly = false,
|
blocksReadonly = false,
|
||||||
|
formatNumber,
|
||||||
onTechInput,
|
onTechInput,
|
||||||
onResetTech,
|
onResetTech,
|
||||||
}: Props = $props();
|
}: 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]);
|
return reason === undefined ? "" : i18n.t(VALUE_REASON_KEY[reason]);
|
||||||
}
|
}
|
||||||
function techError(key: TechKey): string {
|
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: {
|
const BLOCK_ROWS: {
|
||||||
key: keyof DesignBlocksState;
|
key: keyof DesignBlocksState;
|
||||||
label: () => string;
|
label: () => string;
|
||||||
step: string;
|
|
||||||
tech: TechKey | null;
|
tech: TechKey | null;
|
||||||
|
smartStep: boolean;
|
||||||
}[] = [
|
}[] = [
|
||||||
{ key: "drive", label: () => i18n.t("game.calculator.field.drive"), step: "0.01", tech: "drive" },
|
{ key: "drive", label: () => i18n.t("game.calculator.field.drive"), tech: "drive", smartStep: true },
|
||||||
{ key: "armament", label: () => i18n.t("game.calculator.field.armament"), step: "1", tech: null },
|
{ key: "armament", label: () => i18n.t("game.calculator.field.armament"), tech: null, smartStep: false },
|
||||||
{ key: "weapons", label: () => i18n.t("game.calculator.field.weapons"), step: "0.01", tech: "weapons" },
|
{ key: "weapons", label: () => i18n.t("game.calculator.field.weapons"), tech: "weapons", smartStep: true },
|
||||||
{ key: "shields", label: () => i18n.t("game.calculator.field.shields"), step: "0.01", tech: "shields" },
|
{ key: "shields", label: () => i18n.t("game.calculator.field.shields"), tech: "shields", smartStep: true },
|
||||||
{ key: "cargo", label: () => i18n.t("game.calculator.field.cargo"), step: "0.01", tech: "cargo" },
|
{ 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>
|
</script>
|
||||||
|
|
||||||
<div class="design" data-testid="calculator-design-area">
|
<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>
|
<span class="label">{row.label()}</span>
|
||||||
{#if isComputed}
|
{#if isComputed}
|
||||||
<input
|
<input
|
||||||
class="ship"
|
class="ship no-spin"
|
||||||
type="number"
|
type="number"
|
||||||
step={row.step}
|
step="any"
|
||||||
readonly
|
readonly
|
||||||
value={resolved[row.key]}
|
value={formatNumber(resolved[row.key])}
|
||||||
data-computed="true"
|
data-computed="true"
|
||||||
data-testid={`calculator-block-${row.key}`}
|
data-testid={`calculator-block-${row.key}`}
|
||||||
title={i18n.t("game.calculator.lock.reset")}
|
title={i18n.t("game.calculator.lock.reset")}
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<input
|
<input
|
||||||
class="ship"
|
class="ship no-spin"
|
||||||
type="number"
|
type="number"
|
||||||
step={row.step}
|
step="any"
|
||||||
min="0"
|
min="0"
|
||||||
bind:value={blocks[row.key]}
|
bind:value={blocks[row.key]}
|
||||||
readonly={blocksReadonly}
|
readonly={blocksReadonly}
|
||||||
aria-invalid={blockError(row.key) !== "" ? "true" : "false"}
|
aria-invalid={blockError(row.key) !== "" ? "true" : "false"}
|
||||||
title={blockError(row.key)}
|
title={blockError(row.key)}
|
||||||
data-testid={`calculator-block-${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}
|
||||||
{#if row.tech !== null}
|
{#if row.tech !== null}
|
||||||
{@const techKey = row.tech}
|
{@const techKey = row.tech}
|
||||||
<span class="tech-cell">
|
<span class="tech-cell">
|
||||||
|
{#if techOverridden[techKey]}
|
||||||
<input
|
<input
|
||||||
class="tech"
|
bind:this={techInputEls[techKey]}
|
||||||
|
class="tech no-spin"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.001"
|
step="any"
|
||||||
min="0"
|
min={techFloor[techKey]}
|
||||||
bind:value={techs[techKey]}
|
bind:value={techs[techKey]}
|
||||||
oninput={() => onTechInput(techKey)}
|
|
||||||
aria-invalid={techError(techKey) !== "" ? "true" : "false"}
|
aria-invalid={techError(techKey) !== "" ? "true" : "false"}
|
||||||
title={techError(techKey)}
|
title={techError(techKey)}
|
||||||
data-testid={`calculator-tech-${techKey}`}
|
data-testid={`calculator-tech-${techKey}`}
|
||||||
|
onkeydown={(e) => bumpTech(e, techKey)}
|
||||||
|
oninput={(e) => capDecimals(e, (v) => (techs[techKey] = v))}
|
||||||
/>
|
/>
|
||||||
{#if techOverridden[techKey]}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="lock"
|
class="lock active"
|
||||||
title={i18n.t("game.calculator.tech.reset")}
|
title={i18n.t("game.calculator.tech.reset")}
|
||||||
aria-label={i18n.t("game.calculator.tech.reset")}
|
aria-label={i18n.t("game.calculator.tech.reset")}
|
||||||
data-testid={`calculator-tech-reset-${techKey}`}
|
data-testid={`calculator-tech-reset-${techKey}`}
|
||||||
@@ -149,6 +240,23 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
|||||||
>
|
>
|
||||||
🔒
|
🔒
|
||||||
</button>
|
</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}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -182,7 +290,7 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
input {
|
input {
|
||||||
font: inherit;
|
font-family: var(--font-mono);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
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: 1px solid var(--color-border);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-variant-numeric: tabular-nums;
|
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[data-computed="true"],
|
||||||
input[readonly] {
|
input[readonly] {
|
||||||
@@ -206,6 +327,15 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.2rem;
|
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 {
|
.lock {
|
||||||
flex: none;
|
flex: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -214,5 +344,10 @@ calculator math — so the ship-group upgrade flow can reuse it later.
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.lock.active,
|
||||||
|
.lock:hover {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -50,11 +50,11 @@ const en = {
|
|||||||
"login.device_key_not_ready":
|
"login.device_key_not_ready":
|
||||||
"device key is not ready, please reload the page",
|
"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.account_loading": "loading account…",
|
||||||
"lobby.logout": "logout",
|
"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.my_games": "my games",
|
||||||
"lobby.section.invitations": "pending invitations",
|
"lobby.section.invitations": "pending invitations",
|
||||||
"lobby.section.applications": "my applications",
|
"lobby.section.applications": "my applications",
|
||||||
@@ -103,6 +103,22 @@ const en = {
|
|||||||
"lobby.error.internal_error": "internal server error",
|
"lobby.error.internal_error": "internal server error",
|
||||||
"lobby.error.unknown": "{message}",
|
"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.unknown": "?",
|
||||||
"game.shell.connection.online": "online",
|
"game.shell.connection.online": "online",
|
||||||
"game.shell.connection.reconnecting": "reconnecting…",
|
"game.shell.connection.reconnecting": "reconnecting…",
|
||||||
@@ -364,7 +380,6 @@ const en = {
|
|||||||
"game.calculator.name.placeholder": "new class name",
|
"game.calculator.name.placeholder": "new class name",
|
||||||
"game.calculator.name.existing": "your ship classes",
|
"game.calculator.name.existing": "your ship classes",
|
||||||
"game.calculator.action.create": "create",
|
"game.calculator.action.create": "create",
|
||||||
"game.calculator.action.delete": "delete",
|
|
||||||
"game.calculator.col.ship": "ship",
|
"game.calculator.col.ship": "ship",
|
||||||
"game.calculator.col.tech": "tech",
|
"game.calculator.col.tech": "tech",
|
||||||
"game.calculator.field.drive": "drive",
|
"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.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.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.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.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.mat.reset": "overridden — click to reset to the planet value",
|
||||||
"game.calculator.modern.current": "current",
|
"game.calculator.modern.current": "current",
|
||||||
"game.calculator.modern.target": "target",
|
"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.all_zero": "at least one value must be nonzero",
|
||||||
"game.calculator.invalid.negative": "value cannot be negative",
|
"game.calculator.invalid.negative": "value cannot be negative",
|
||||||
"game.calculator.invalid.tech_value": "tech level 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.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.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.title": "sciences",
|
||||||
"game.table.sciences.column.name": "name",
|
"game.table.sciences.column.name": "name",
|
||||||
|
|||||||
@@ -51,11 +51,11 @@ const ru: Record<keyof typeof en, string> = {
|
|||||||
"login.device_key_not_ready":
|
"login.device_key_not_ready":
|
||||||
"ключ устройства ещё не готов, перезагрузите страницу",
|
"ключ устройства ещё не готов, перезагрузите страницу",
|
||||||
|
|
||||||
"lobby.title": "вы вошли в систему",
|
|
||||||
"lobby.device_session_id_label": "идентификатор сессии устройства",
|
|
||||||
"lobby.greeting": "здравствуйте, {name}!",
|
|
||||||
"lobby.account_loading": "загрузка профиля…",
|
"lobby.account_loading": "загрузка профиля…",
|
||||||
"lobby.logout": "выйти",
|
"lobby.logout": "выйти",
|
||||||
|
"lobby.nav.aria_label": "разделы лобби",
|
||||||
|
"lobby.nav.overview": "Обзор",
|
||||||
|
"lobby.nav.profile": "Профиль",
|
||||||
"lobby.section.my_games": "мои игры",
|
"lobby.section.my_games": "мои игры",
|
||||||
"lobby.section.invitations": "ожидающие приглашения",
|
"lobby.section.invitations": "ожидающие приглашения",
|
||||||
"lobby.section.applications": "мои заявки",
|
"lobby.section.applications": "мои заявки",
|
||||||
@@ -104,6 +104,22 @@ const ru: Record<keyof typeof en, string> = {
|
|||||||
"lobby.error.internal_error": "внутренняя ошибка сервера",
|
"lobby.error.internal_error": "внутренняя ошибка сервера",
|
||||||
"lobby.error.unknown": "{message}",
|
"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.unknown": "?",
|
||||||
"game.shell.connection.online": "онлайн",
|
"game.shell.connection.online": "онлайн",
|
||||||
"game.shell.connection.reconnecting": "переподключение…",
|
"game.shell.connection.reconnecting": "переподключение…",
|
||||||
@@ -365,7 +381,6 @@ const ru: Record<keyof typeof en, string> = {
|
|||||||
"game.calculator.name.placeholder": "имя нового класса",
|
"game.calculator.name.placeholder": "имя нового класса",
|
||||||
"game.calculator.name.existing": "ваши классы кораблей",
|
"game.calculator.name.existing": "ваши классы кораблей",
|
||||||
"game.calculator.action.create": "создать",
|
"game.calculator.action.create": "создать",
|
||||||
"game.calculator.action.delete": "удалить",
|
|
||||||
"game.calculator.col.ship": "корабль",
|
"game.calculator.col.ship": "корабль",
|
||||||
"game.calculator.col.tech": "технологии",
|
"game.calculator.col.tech": "технологии",
|
||||||
"game.calculator.field.drive": "двигатель",
|
"game.calculator.field.drive": "двигатель",
|
||||||
@@ -394,7 +409,9 @@ const ru: Record<keyof typeof en, string> = {
|
|||||||
"game.calculator.lock.reset": "зафиксировано — нажмите, чтобы вернуть вычисляемое значение",
|
"game.calculator.lock.reset": "зафиксировано — нажмите, чтобы вернуть вычисляемое значение",
|
||||||
"game.calculator.lock.infeasible": "эта цель недостижима при текущих параметрах",
|
"game.calculator.lock.infeasible": "эта цель недостижима при текущих параметрах",
|
||||||
"game.calculator.lock.max": "сначала снимите фиксацию с другого результата — по одному за раз",
|
"game.calculator.lock.max": "сначала снимите фиксацию с другого результата — по одному за раз",
|
||||||
|
"game.calculator.tech.override": "нажмите, чтобы задать свой технологический уровень",
|
||||||
"game.calculator.tech.reset": "переопределено — нажмите, чтобы вернуть ваши текущие технологии",
|
"game.calculator.tech.reset": "переопределено — нажмите, чтобы вернуть ваши текущие технологии",
|
||||||
|
"game.calculator.mat.override": "нажмите, чтобы задать своё значение MAT",
|
||||||
"game.calculator.mat.reset": "переопределено — нажмите, чтобы вернуть значение планеты",
|
"game.calculator.mat.reset": "переопределено — нажмите, чтобы вернуть значение планеты",
|
||||||
"game.calculator.modern.current": "текущий",
|
"game.calculator.modern.current": "текущий",
|
||||||
"game.calculator.modern.target": "целевой",
|
"game.calculator.modern.target": "целевой",
|
||||||
@@ -419,8 +436,10 @@ const ru: Record<keyof typeof en, string> = {
|
|||||||
"game.calculator.invalid.all_zero": "хотя бы одно значение должно быть ненулевым",
|
"game.calculator.invalid.all_zero": "хотя бы одно значение должно быть ненулевым",
|
||||||
"game.calculator.invalid.negative": "значение не может быть отрицательным",
|
"game.calculator.invalid.negative": "значение не может быть отрицательным",
|
||||||
"game.calculator.invalid.tech_value": "технологический уровень не может быть отрицательным",
|
"game.calculator.invalid.tech_value": "технологический уровень не может быть отрицательным",
|
||||||
|
"game.calculator.invalid.tech_below_current": "технологический уровень не может быть ниже ваших текущих технологий на этом ходу",
|
||||||
"game.calculator.invalid.load_over_capacity": "загрузка превышает грузоподъёмность корабля",
|
"game.calculator.invalid.load_over_capacity": "загрузка превышает грузоподъёмность корабля",
|
||||||
"game.calculator.lock.no_drive": "задайте ненулевой двигатель, прежде чем фиксировать скорость",
|
"game.calculator.lock.no_drive": "задайте ненулевой двигатель, прежде чем фиксировать скорость",
|
||||||
|
"game.calculator.confirm_reset_for_load": "Сбросить несохранённые изменения и загрузить класс «{name}»?",
|
||||||
|
|
||||||
"game.table.sciences.title": "науки",
|
"game.table.sciences.title": "науки",
|
||||||
"game.table.sciences.column.name": "название",
|
"game.table.sciences.column.name": "название",
|
||||||
|
|||||||
@@ -17,8 +17,7 @@
|
|||||||
type GameSummary,
|
type GameSummary,
|
||||||
type InviteSummary,
|
type InviteSummary,
|
||||||
} from "../../api/lobby";
|
} from "../../api/lobby";
|
||||||
import { ByteBuffer } from "flatbuffers";
|
import { account } from "$lib/account-store.svelte";
|
||||||
import { AccountResponse } from "../../proto/galaxy/fbs/user";
|
|
||||||
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
|
import { gatewayRpcBaseUrl, GATEWAY_RESPONSE_PUBLIC_KEY } from "$lib/env";
|
||||||
import {
|
import {
|
||||||
SyntheticReportError,
|
SyntheticReportError,
|
||||||
@@ -27,10 +26,8 @@
|
|||||||
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
import { i18n, type TranslationKey } from "$lib/i18n/index.svelte";
|
||||||
import { loadCore } from "../../platform/core/index";
|
import { loadCore } from "../../platform/core/index";
|
||||||
import { session } from "$lib/session-store.svelte";
|
import { session } from "$lib/session-store.svelte";
|
||||||
import { Builder } from "flatbuffers";
|
import LobbyShell from "./lobby-shell.svelte";
|
||||||
import { GetMyAccountRequest } from "../../proto/galaxy/fbs/user";
|
|
||||||
|
|
||||||
let displayName: string | null = $state(null);
|
|
||||||
let configError: string | null = $state(null);
|
let configError: string | null = $state(null);
|
||||||
let listsLoading = $state(true);
|
let listsLoading = $state(true);
|
||||||
let lobbyError: string | null = $state(null);
|
let lobbyError: string | null = $state(null);
|
||||||
@@ -51,10 +48,6 @@
|
|||||||
|
|
||||||
let client: GalaxyClient | null = null;
|
let client: GalaxyClient | null = null;
|
||||||
|
|
||||||
async function logout(): Promise<void> {
|
|
||||||
await session.signOut("user");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sha256(payload: Uint8Array): Promise<Uint8Array> {
|
async function sha256(payload: Uint8Array): Promise<Uint8Array> {
|
||||||
const digest = await crypto.subtle.digest("SHA-256", payload as BufferSource);
|
const digest = await crypto.subtle.digest("SHA-256", payload as BufferSource);
|
||||||
return new Uint8Array(digest);
|
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 {
|
function gotoCreate(): void {
|
||||||
appScreen.go("lobby-create");
|
appScreen.go("lobby-create");
|
||||||
}
|
}
|
||||||
@@ -260,7 +233,11 @@
|
|||||||
deviceSessionId: session.deviceSessionId,
|
deviceSessionId: session.deviceSessionId,
|
||||||
gatewayResponsePublicKey: GATEWAY_RESPONSE_PUBLIC_KEY,
|
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();
|
await refreshAll();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
lobbyError = describeLobbyError(err);
|
lobbyError = describeLobbyError(err);
|
||||||
@@ -269,24 +246,7 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<a class="skip-link" href="#main-content">{i18n.t("common.skip_to_content")}</a>
|
<LobbyShell activePage="overview">
|
||||||
<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>
|
|
||||||
|
|
||||||
{#if configError !== null}
|
{#if configError !== null}
|
||||||
<p role="alert" data-testid="account-error">{configError}</p>
|
<p role="alert" data-testid="account-error">{configError}</p>
|
||||||
{:else if lobbyError !== null}
|
{:else if lobbyError !== null}
|
||||||
@@ -483,38 +443,16 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</LobbyShell>
|
||||||
|
|
||||||
<style>
|
<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 {
|
section {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: var(--space-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
section h2 {
|
section h2 {
|
||||||
font-size: 1.1rem;
|
font-size: var(--text-lg);
|
||||||
margin: 0 0 0.75rem;
|
margin: 0 0 var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-list {
|
.card-list {
|
||||||
@@ -523,16 +461,16 @@
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.25rem;
|
gap: var(--space-1);
|
||||||
padding: 0.75rem;
|
padding: var(--space-3);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 0.4rem;
|
border-radius: var(--radius-md);
|
||||||
background: var(--color-surface-raised);
|
background: var(--color-surface-raised);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
@@ -552,54 +490,55 @@
|
|||||||
|
|
||||||
.meta {
|
.meta {
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: 0.9rem;
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status {
|
.status {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
padding: 0.1rem 0.5rem;
|
padding: 0.1rem var(--space-2);
|
||||||
border-radius: 999px;
|
border-radius: var(--radius-pill);
|
||||||
background: var(--color-surface-raised);
|
background: var(--color-surface-raised);
|
||||||
font-size: 0.8rem;
|
font-size: var(--text-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: var(--space-2);
|
||||||
margin-top: 0.25rem;
|
margin-top: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
form {
|
form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.5rem;
|
gap: var(--space-2);
|
||||||
margin-top: 0.5rem;
|
margin-top: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.25rem;
|
gap: var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
input[type="text"] {
|
input[type="text"] {
|
||||||
font-size: 1rem;
|
font: inherit;
|
||||||
padding: 0.4rem 0.5rem;
|
font-size: var(--text-md);
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.synthetic-loader {
|
.synthetic-loader {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: var(--space-2);
|
||||||
padding: 0.4rem 0.75rem;
|
padding: var(--space-1) var(--space-3);
|
||||||
border: 1px dashed var(--color-text-muted);
|
border: 1px dashed var(--color-text-muted);
|
||||||
border-radius: 0.4rem;
|
border-radius: var(--radius-md);
|
||||||
background: var(--color-surface-raised);
|
background: var(--color-surface-raised);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
.synthetic-loader input[type="file"] {
|
.synthetic-loader input[type="file"] {
|
||||||
font-size: 0.9rem;
|
font-size: var(--text-sm);
|
||||||
}
|
}
|
||||||
</style>
|
</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,
|
loadDeviceSession,
|
||||||
setDeviceSessionId,
|
setDeviceSessionId,
|
||||||
} from "../api/session";
|
} from "../api/session";
|
||||||
|
import { account } from "./account-store.svelte";
|
||||||
|
|
||||||
export type SessionStatus =
|
export type SessionStatus =
|
||||||
| "loading"
|
| "loading"
|
||||||
@@ -94,6 +95,10 @@ export class SessionStore {
|
|||||||
this.keypair = fresh.keypair;
|
this.keypair = fresh.keypair;
|
||||||
this.deviceSessionId = null;
|
this.deviceSessionId = null;
|
||||||
this.status = "anonymous";
|
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") {
|
if (reason === "revoked") {
|
||||||
console.info("session store: device session revoked by gateway");
|
console.info("session store: device session revoked by gateway");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
long-lived planning tool. `ensureGame` resets it when the game changes.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { getContext } from "svelte";
|
import { getContext, tick } from "svelte";
|
||||||
import { appScreen } from "$lib/app-nav.svelte";
|
import { appScreen } from "$lib/app-nav.svelte";
|
||||||
|
|
||||||
import { i18n, type TranslationKey } from "$lib/i18n/index.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]),
|
nameValidation.ok ? "" : i18n.t(nameInvalidKeyMap[nameValidation.reason]),
|
||||||
);
|
);
|
||||||
const canCreate = $derived(nameValidation.ok && draft !== undefined);
|
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).
|
// Per-block modernization upgrade cost (current tech → target tech).
|
||||||
const modernCosts = $derived.by(() => {
|
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
|
// Display every computed number rounded up to three decimals via the
|
||||||
// shared `Ceil3` bridge, so a value is never shown lower than it is.
|
// 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 {
|
function fmt(value: number | null | undefined): string {
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
return i18n.t("game.calculator.unavailable");
|
return i18n.t("game.calculator.unavailable");
|
||||||
}
|
}
|
||||||
const rounded = core !== null ? core.ceil3({ value }) : value;
|
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
|
// 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") : "",
|
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
|
// Locking a speed back-solves the drive block; with a zero drive the
|
||||||
// ship is deliberately immobile, so disallow it.
|
// ship is deliberately immobile, so disallow it.
|
||||||
function lockDisabledReason(output: LockableOutputId): string {
|
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 {
|
function onResetTech(key: TechKey): void {
|
||||||
cs.techOverridden[key] = false;
|
cs.techOverridden[key] = false;
|
||||||
}
|
}
|
||||||
function onMatInput(): void {
|
const matInputRef: { el?: HTMLInputElement } = {};
|
||||||
|
async function activateMatOverride(): Promise<void> {
|
||||||
cs.matOverridden = true;
|
cs.matOverridden = true;
|
||||||
|
await tick();
|
||||||
|
matInputRef.el?.focus();
|
||||||
|
matInputRef.el?.select();
|
||||||
}
|
}
|
||||||
function resetMat(): void {
|
function resetMat(): void {
|
||||||
cs.matOverridden = false;
|
cs.matOverridden = false;
|
||||||
@@ -307,6 +341,30 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
cs.lock = null;
|
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 {
|
function loadExisting(clsName: string): void {
|
||||||
const cls = localShipClass.find((c) => c.name === clsName);
|
const cls = localShipClass.find((c) => c.name === clsName);
|
||||||
if (cls === undefined) return;
|
if (cls === undefined) return;
|
||||||
@@ -322,6 +380,78 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
cs.lock = null;
|
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
|
// React to the ship-classes table / bottom-tabs asking to load a
|
||||||
// class (or start a fresh design) into the calculator. The layout
|
// class (or start a fresh design) into the calculator. The layout
|
||||||
// flips the sidebar to this tab in parallel.
|
// 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;
|
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({
|
const LOCK_LABELS: Record<LockableOutputId, string> = $derived({
|
||||||
emptyMass: i18n.t("game.calculator.out.mass"),
|
emptyMass: i18n.t("game.calculator.out.mass"),
|
||||||
loadedMass: 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}
|
{#if cs.lock === output}
|
||||||
<span class="cell locked" class:infeasible={!result.lockFeasible}>
|
<span class="cell locked" class:infeasible={!result.lockFeasible}>
|
||||||
<input
|
<input
|
||||||
|
class="no-spin"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.001"
|
step="any"
|
||||||
bind:value={cs.lockValue}
|
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}`}
|
data-testid={`calculator-locked-${output}`}
|
||||||
title={result.lockFeasible ? "" : i18n.t("game.calculator.lock.infeasible")}
|
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")}
|
placeholder={i18n.t("game.calculator.name.placeholder")}
|
||||||
maxlength="30"
|
maxlength="30"
|
||||||
bind:value={cs.name}
|
bind:value={cs.name}
|
||||||
oninput={() => (cs.loadedExisting = null)}
|
oninput={onNameInput}
|
||||||
onchange={() => loadExisting(cs.name)}
|
onchange={() => tryLoadByName(cs.name)}
|
||||||
aria-invalid={nameValidation.ok ? "false" : "true"}
|
aria-invalid={nameValidation.ok ? "false" : "true"}
|
||||||
data-testid="calculator-name"
|
data-testid="calculator-name"
|
||||||
/>
|
/>
|
||||||
@@ -469,23 +593,15 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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
|
<ShipDesignArea
|
||||||
bind:blocks={cs.blocks}
|
bind:blocks={cs.blocks}
|
||||||
resolved={resolvedCeil}
|
resolved={resolvedCeil}
|
||||||
bind:techs={cs.techValues}
|
bind:techs={cs.techValues}
|
||||||
techOverridden={cs.techOverridden}
|
techOverridden={cs.techOverridden}
|
||||||
|
techFloor={playerTech}
|
||||||
computedInput={result.computedInput}
|
computedInput={result.computedInput}
|
||||||
|
formatNumber={fmt}
|
||||||
{onTechInput}
|
{onTechInput}
|
||||||
{onResetTech}
|
{onResetTech}
|
||||||
/>
|
/>
|
||||||
@@ -509,10 +625,13 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
{#if cs.loadMode === "custom"}
|
{#if cs.loadMode === "custom"}
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="any"
|
||||||
min="0"
|
min="0"
|
||||||
class="custom-load"
|
class="custom-load no-spin"
|
||||||
bind:value={cs.customLoad}
|
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"}
|
aria-invalid={customLoadError !== "" ? "true" : "false"}
|
||||||
title={customLoadError}
|
title={customLoadError}
|
||||||
data-testid="calculator-custom-load"
|
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">
|
<span class="val" data-testid="calculator-out-bombing">
|
||||||
{fmt(result.outputs?.bombing)}
|
{fmt(result.outputs?.bombing)}
|
||||||
</span>
|
</span>
|
||||||
|
<span class="lock-slot" aria-hidden="true">🔓</span>
|
||||||
</span>
|
</span>
|
||||||
<span></span>
|
<span></span>
|
||||||
</div>
|
</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">
|
<span class="val" data-testid="calculator-out-cargo-capacity">
|
||||||
{fmt(result.outputs === null ? null : result.cargoCapacity)}
|
{fmt(result.outputs === null ? null : result.cargoCapacity)}
|
||||||
</span>
|
</span>
|
||||||
|
<span class="lock-slot" aria-hidden="true">🔓</span>
|
||||||
</span>
|
</span>
|
||||||
<span></span>
|
<span></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -589,17 +710,21 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
<div class="rrow">
|
<div class="rrow">
|
||||||
<span class="label">{i18n.t("game.calculator.planet.mat")}</span>
|
<span class="label">{i18n.t("game.calculator.planet.mat")}</span>
|
||||||
<span class="cell">
|
<span class="cell">
|
||||||
|
{#if cs.matOverridden}
|
||||||
<input
|
<input
|
||||||
|
bind:this={matInputRef.el}
|
||||||
|
class="no-spin"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="any"
|
||||||
min="0"
|
min="0"
|
||||||
bind:value={cs.matValue}
|
bind:value={cs.matValue}
|
||||||
oninput={onMatInput}
|
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"}
|
aria-invalid={matError !== "" ? "true" : "false"}
|
||||||
title={matError}
|
title={matError}
|
||||||
data-testid="calculator-planet-mat"
|
data-testid="calculator-planet-mat"
|
||||||
/>
|
/>
|
||||||
{#if cs.matOverridden}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="lock active"
|
class="lock active"
|
||||||
@@ -610,6 +735,23 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
>
|
>
|
||||||
🔒
|
🔒
|
||||||
</button>
|
</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}
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
<span></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>
|
<span class="col-head">{i18n.t("game.calculator.modern.cost")}</span>
|
||||||
</div>
|
</div>
|
||||||
{#each modernCosts?.perBlock ?? [] as row (row.key)}
|
{#each modernCosts?.perBlock ?? [] as row (row.key)}
|
||||||
|
{@const targetError = targetTechError(row.key)}
|
||||||
<div class="rrow">
|
<div class="rrow">
|
||||||
<span class="label">{i18n.t(`game.calculator.field.${row.key}` as TranslationKey)}</span>
|
<span class="label">{i18n.t(`game.calculator.field.${row.key}` as TranslationKey)}</span>
|
||||||
<span class="cell">
|
<span class="cell">
|
||||||
<input
|
<input
|
||||||
|
class="no-spin"
|
||||||
type="number"
|
type="number"
|
||||||
step="0.001"
|
step="any"
|
||||||
min="0"
|
min={playerTech[row.key]}
|
||||||
bind:value={cs.targetTech[row.key]}
|
bind:value={cs.targetTech[row.key]}
|
||||||
aria-invalid={cs.targetTech[row.key] < 0 ? "true" : "false"}
|
onkeydown={(e) =>
|
||||||
title={cs.targetTech[row.key] < 0
|
onStepKey(
|
||||||
? i18n.t("game.calculator.invalid.negative")
|
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}`}
|
data-testid={`calculator-target-${row.key}`}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
@@ -718,8 +870,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
.name[aria-invalid="true"] {
|
.name[aria-invalid="true"] {
|
||||||
border-color: var(--color-danger);
|
border-color: var(--color-danger);
|
||||||
}
|
}
|
||||||
.create,
|
.create {
|
||||||
.delete {
|
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
padding: 0.25rem 0.55rem;
|
padding: 0.25rem 0.55rem;
|
||||||
@@ -737,10 +888,6 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
.delete {
|
|
||||||
color: var(--color-danger);
|
|
||||||
align-self: flex-start;
|
|
||||||
}
|
|
||||||
.load {
|
.load {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -766,13 +913,15 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
}
|
}
|
||||||
.custom-load {
|
.custom-load {
|
||||||
width: 4rem;
|
width: 4rem;
|
||||||
font: inherit;
|
font-family: var(--font-mono);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
padding: 0.15rem 0.3rem;
|
padding: 0.15rem 0.3rem;
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
.results,
|
.results,
|
||||||
.modern {
|
.modern {
|
||||||
@@ -802,6 +951,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
.cell .val {
|
.cell .val {
|
||||||
|
font-family: var(--font-mono);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
@@ -809,7 +959,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
.cell input {
|
.cell input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
font: inherit;
|
font-family: var(--font-mono);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
padding: 0.15rem 0.3rem;
|
padding: 0.15rem 0.3rem;
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
@@ -819,6 +969,19 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
text-align: right;
|
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 {
|
.cell.locked input {
|
||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
border-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;
|
cursor: not-allowed;
|
||||||
opacity: 0.2;
|
opacity: 0.2;
|
||||||
}
|
}
|
||||||
|
.lock-slot {
|
||||||
|
flex: none;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
.planet {
|
.planet {
|
||||||
border-top: 1px solid var(--color-border-subtle);
|
border-top: 1px solid var(--color-border-subtle);
|
||||||
padding-top: 0.5rem;
|
padding-top: 0.5rem;
|
||||||
@@ -878,6 +1047,7 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
}
|
}
|
||||||
.planet-stats dd {
|
.planet-stats dd {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
font-family: var(--font-mono);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
@@ -895,8 +1065,21 @@ long-lived planning tool. `ensureGame` resets it when the game changes.
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
.full-capacity {
|
.full-capacity {
|
||||||
|
font-family: var(--font-mono);
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--color-accent);
|
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>
|
</style>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
import LoginScreen from "$lib/screens/login-screen.svelte";
|
import LoginScreen from "$lib/screens/login-screen.svelte";
|
||||||
import LobbyScreen from "$lib/screens/lobby-screen.svelte";
|
import LobbyScreen from "$lib/screens/lobby-screen.svelte";
|
||||||
import LobbyCreateScreen from "$lib/screens/lobby-create-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 GameShell from "$lib/game/game-shell.svelte";
|
||||||
import { pushState } from "$app/navigation";
|
import { pushState } from "$app/navigation";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
@@ -67,6 +68,8 @@
|
|||||||
pushState("", { screen: "game", gameId: appScreen.gameId });
|
pushState("", { screen: "game", gameId: appScreen.gameId });
|
||||||
} else if (appScreen.screen === "lobby-create") {
|
} else if (appScreen.screen === "lobby-create") {
|
||||||
pushState("", { screen: "lobby-create" });
|
pushState("", { screen: "lobby-create" });
|
||||||
|
} else if (appScreen.screen === "profile") {
|
||||||
|
pushState("", { screen: "profile" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -83,6 +86,8 @@
|
|||||||
{#if session.status === "authenticated"}
|
{#if session.status === "authenticated"}
|
||||||
{#if appScreen.screen === "lobby-create"}
|
{#if appScreen.screen === "lobby-create"}
|
||||||
<LobbyCreateScreen />
|
<LobbyCreateScreen />
|
||||||
|
{:else if appScreen.screen === "profile"}
|
||||||
|
<ProfileScreen />
|
||||||
{:else if appScreen.screen === "game" && appScreen.gameId !== null}
|
{:else if appScreen.screen === "game" && appScreen.gameId !== null}
|
||||||
<GameShell />
|
<GameShell />
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -135,6 +135,56 @@ describe("computeCalculator goal-seek", () => {
|
|||||||
expect(result.blocks.drive).toBe(10);
|
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", () => {
|
test("calls the matching solver with the right context", () => {
|
||||||
const weaponsForAttack = vi.fn(() => 7);
|
const weaponsForAttack = vi.fn(() => 7);
|
||||||
const core = makeFakeCore({ weaponsForAttack });
|
const core = makeFakeCore({ weaponsForAttack });
|
||||||
|
|||||||
@@ -278,4 +278,390 @@ describe("calculator-tab", () => {
|
|||||||
"15.273",
|
"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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -159,9 +159,9 @@ async function completeLogin(page: Page): Promise<void> {
|
|||||||
await page.getByTestId("login-code-input").click();
|
await page.getByTestId("login-code-input").click();
|
||||||
await page.getByTestId("login-code-input").fill("123456");
|
await page.getByTestId("login-code-input").fill("123456");
|
||||||
await page.getByTestId("login-code-submit").click();
|
await page.getByTestId("login-code-submit").click();
|
||||||
// Sign-in switches the in-memory screen to the lobby; the device
|
// Sign-in switches the in-memory screen to the lobby; the identity
|
||||||
// session id surfaces only on the lobby screen.
|
// strip rendered by `lobby-shell.svelte` is the lobby-loaded signal.
|
||||||
await expect(page.getByTestId("device-session-id")).toBeVisible();
|
await expect(page.getByTestId("lobby-account-name")).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe("Phase 7 — auth flow", () => {
|
test.describe("Phase 7 — auth flow", () => {
|
||||||
@@ -174,10 +174,7 @@ test.describe("Phase 7 — auth flow", () => {
|
|||||||
}) => {
|
}) => {
|
||||||
const mocks = await mockGatewayHappyPath(page, "Pilot");
|
const mocks = await mockGatewayHappyPath(page, "Pilot");
|
||||||
await completeLogin(page);
|
await completeLogin(page);
|
||||||
await expect(page.getByTestId("device-session-id")).toHaveText(
|
await expect(page.getByTestId("lobby-account-name")).toContainText("Pilot");
|
||||||
"dev-test-1",
|
|
||||||
);
|
|
||||||
await expect(page.getByTestId("account-greeting")).toContainText("Pilot");
|
|
||||||
|
|
||||||
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
||||||
});
|
});
|
||||||
@@ -187,13 +184,13 @@ test.describe("Phase 7 — auth flow", () => {
|
|||||||
}) => {
|
}) => {
|
||||||
const mocks = await mockGatewayHappyPath(page, "Pilot");
|
const mocks = await mockGatewayHappyPath(page, "Pilot");
|
||||||
await completeLogin(page);
|
await completeLogin(page);
|
||||||
await expect(page.getByTestId("account-greeting")).toBeVisible();
|
await expect(page.getByTestId("lobby-account-name")).toBeVisible();
|
||||||
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
// The restored session re-renders the lobby screen directly (no
|
// The restored session re-renders the lobby screen directly (no
|
||||||
// `/lobby` route to land on).
|
// `/lobby` route to land on).
|
||||||
await expect(page.getByTestId("device-session-id")).toHaveText(
|
await expect(page.getByTestId("lobby-account-name")).toContainText(
|
||||||
"dev-test-1",
|
"Pilot",
|
||||||
);
|
);
|
||||||
|
|
||||||
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
mocks.pendingSubscribes.forEach((resolve) => resolve());
|
||||||
@@ -204,7 +201,15 @@ test.describe("Phase 7 — auth flow", () => {
|
|||||||
}) => {
|
}) => {
|
||||||
const mocks = await mockGatewayHappyPath(page, "Pilot");
|
const mocks = await mockGatewayHappyPath(page, "Pilot");
|
||||||
await completeLogin(page);
|
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
|
// Fire all pending SubscribeEvents requests with an empty 200
|
||||||
// response. Connect-Web's server-streaming reader sees no frames
|
// response. Connect-Web's server-streaming reader sees no frames
|
||||||
|
|||||||
@@ -215,6 +215,9 @@ export interface AccountFixture {
|
|||||||
email: string;
|
email: string;
|
||||||
userName: string;
|
userName: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
preferredLanguage?: string;
|
||||||
|
timeZone?: string;
|
||||||
|
declaredCountry?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildAccountResponsePayload(account: AccountFixture): Uint8Array {
|
export function buildAccountResponsePayload(account: AccountFixture): Uint8Array {
|
||||||
@@ -237,9 +240,9 @@ export function buildAccountResponsePayload(account: AccountFixture): Uint8Array
|
|||||||
const email = builder.createString(account.email);
|
const email = builder.createString(account.email);
|
||||||
const userName = builder.createString(account.userName);
|
const userName = builder.createString(account.userName);
|
||||||
const displayName = builder.createString(account.displayName);
|
const displayName = builder.createString(account.displayName);
|
||||||
const preferredLanguage = builder.createString("en");
|
const preferredLanguage = builder.createString(account.preferredLanguage ?? "en");
|
||||||
const timeZone = builder.createString("UTC");
|
const timeZone = builder.createString(account.timeZone ?? "UTC");
|
||||||
const declaredCountry = builder.createString("");
|
const declaredCountry = builder.createString(account.declaredCountry ?? "");
|
||||||
AccountView.startAccountView(builder);
|
AccountView.startAccountView(builder);
|
||||||
AccountView.addUserId(builder, userId);
|
AccountView.addUserId(builder, userId);
|
||||||
AccountView.addEmail(builder, email);
|
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-drive").fill("10");
|
||||||
await calc.getByTestId("calculator-block-shields").fill("5");
|
await calc.getByTestId("calculator-block-shields").fill("5");
|
||||||
await calc.getByTestId("calculator-block-cargo").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 calc.getByTestId("calculator-tech-drive").fill("1.2");
|
||||||
|
|
||||||
await expect.poll(() => countReachCircles(page)).toBeGreaterThan(0);
|
await expect.poll(() => countReachCircles(page)).toBeGreaterThan(0);
|
||||||
|
|||||||
@@ -251,7 +251,7 @@ async function completeLogin(page: Page): Promise<void> {
|
|||||||
await page.getByTestId("login-code-input").fill("123456");
|
await page.getByTestId("login-code-input").fill("123456");
|
||||||
await page.getByTestId("login-code-submit").click();
|
await page.getByTestId("login-code-submit").click();
|
||||||
// Sign-in switches the in-memory screen to the lobby.
|
// 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", () => {
|
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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -94,10 +94,13 @@ export function makeFakeCore(overrides: Partial<Core> = {}): Core {
|
|||||||
weaponsForAttack: ({ targetAttack, weaponsTech }) =>
|
weaponsForAttack: ({ targetAttack, weaponsTech }) =>
|
||||||
weaponsTech <= 0 || targetAttack < 0 ? null : targetAttack / weaponsTech,
|
weaponsTech <= 0 || targetAttack < 0 ? null : targetAttack / weaponsTech,
|
||||||
driveForSpeed: ({ targetSpeed, driveTech, restMass }) => {
|
driveForSpeed: ({ targetSpeed, driveTech, restMass }) => {
|
||||||
|
if (driveTech <= 0 || targetSpeed <= 0) return null;
|
||||||
const ceiling = 20 * driveTech;
|
const ceiling = 20 * driveTech;
|
||||||
if (driveTech <= 0 || targetSpeed <= 0 || targetSpeed >= ceiling) {
|
if (restMass <= 0) {
|
||||||
return null;
|
if (targetSpeed !== ceiling) return null;
|
||||||
|
return 1;
|
||||||
}
|
}
|
||||||
|
if (targetSpeed >= ceiling) return null;
|
||||||
return (targetSpeed * restMass) / (ceiling - targetSpeed);
|
return (targetSpeed * restMass) / (ceiling - targetSpeed);
|
||||||
},
|
},
|
||||||
shieldsForDefence: ({ targetDefence, shieldsTech, restMass }) => {
|
shieldsForDefence: ({ targetDefence, shieldsTech, restMass }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user