ui: plan 01-27 done #1
@@ -12,7 +12,6 @@ package repo
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"galaxy/model/order"
|
"galaxy/model/order"
|
||||||
@@ -234,15 +233,16 @@ func loadOrder(s Storage, t uint, id uuid.UUID) (*order.UserGamesOrder, bool, er
|
|||||||
if err := s.ReadSafe(path, stored); err != nil {
|
if err := s.ReadSafe(path, stored); err != nil {
|
||||||
return nil, false, NewStorageError(err)
|
return nil, false, NewStorageError(err)
|
||||||
}
|
}
|
||||||
|
// An empty stored batch is a valid state — the player either
|
||||||
|
// cleared their draft or never added a command yet. We round-
|
||||||
|
// trip it as `(*UserGamesOrder, true, nil)` with an empty
|
||||||
|
// `Commands` slice so callers can distinguish "no order yet"
|
||||||
|
// (ok=false) from "order exists but is empty" (ok=true).
|
||||||
result := &order.UserGamesOrder{
|
result := &order.UserGamesOrder{
|
||||||
GameID: stored.GameID,
|
GameID: stored.GameID,
|
||||||
UpdatedAt: stored.UpdatedAt,
|
UpdatedAt: stored.UpdatedAt,
|
||||||
Commands: make([]order.DecodableCommand, len(stored.Commands)),
|
Commands: make([]order.DecodableCommand, len(stored.Commands)),
|
||||||
}
|
}
|
||||||
if len(stored.Commands) == 0 {
|
|
||||||
return nil, false, errors.New("no commands were stored")
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range stored.Commands {
|
for i := range stored.Commands {
|
||||||
command, err := ParseOrder(stored.Commands[i], nil)
|
command, err := ParseOrder(stored.Commands[i], nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -104,6 +104,50 @@ func LoadOrderTest(t *testing.T, s repo.Storage, root string, turn uint, id uuid
|
|||||||
CommandResultTest(t, o)
|
CommandResultTest(t, o)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSaveOrderEmptyRoundTrip(t *testing.T) {
|
||||||
|
// An empty order is a legal player intent (the user removed
|
||||||
|
// every command from the draft). The repo round-trips it as an
|
||||||
|
// `(*UserGamesOrder, true, nil)` triple with `Commands` empty
|
||||||
|
// so the front-end can distinguish "no order yet" (ok=false)
|
||||||
|
// from "order exists but is empty" (ok=true).
|
||||||
|
root := t.ArtifactDir()
|
||||||
|
s, err := fs.NewFileStorage(root)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
id := uuid.New()
|
||||||
|
gameID := uuid.New()
|
||||||
|
now := time.Now().UTC().UnixMilli()
|
||||||
|
o := &order.UserGamesOrder{
|
||||||
|
GameID: gameID,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
var turn uint = 3
|
||||||
|
|
||||||
|
assert.NoError(t, repo.SaveOrder_T(s, turn, id, o))
|
||||||
|
assert.FileExists(t, filepath.Join(root, repo.OrderDir(turn, id)))
|
||||||
|
|
||||||
|
loaded, ok, err := repo.LoadOrder_T(s, turn, id)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.True(t, ok, "empty order must surface as ok=true so callers can tell it apart from a missing one")
|
||||||
|
assert.NotNil(t, loaded)
|
||||||
|
assert.Equal(t, gameID, loaded.GameID)
|
||||||
|
assert.Equal(t, now, loaded.UpdatedAt)
|
||||||
|
assert.Empty(t, loaded.Commands)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadOrderMissing(t *testing.T) {
|
||||||
|
// A turn that has never had a PUT must come back as
|
||||||
|
// `(nil, false, nil)` — the engine's "no stored order" path.
|
||||||
|
root := t.ArtifactDir()
|
||||||
|
s, err := fs.NewFileStorage(root)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
id := uuid.New()
|
||||||
|
|
||||||
|
loaded, ok, err := repo.LoadOrder_T(s, 7, id)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.False(t, ok)
|
||||||
|
assert.Nil(t, loaded)
|
||||||
|
}
|
||||||
|
|
||||||
func CommandResultTest(t *testing.T, o *order.UserGamesOrder) {
|
func CommandResultTest(t *testing.T, o *order.UserGamesOrder) {
|
||||||
assert.NotEmpty(t, o.Commands)
|
assert.NotEmpty(t, o.Commands)
|
||||||
for i := range o.Commands {
|
for i := range o.Commands {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
@@ -33,7 +32,12 @@ func CommandHandler(c *gin.Context, executor CommandExecutor) {
|
|||||||
commands[i] = command
|
commands[i] = command
|
||||||
}
|
}
|
||||||
if len(commands) == 0 {
|
if len(commands) == 0 {
|
||||||
errorResponse(c, errors.New("no commands given"))
|
// `PUT /api/v1/command` is the immediate-execution path —
|
||||||
|
// running an empty batch is a meaningless no-op, so we
|
||||||
|
// reject it with `400` rather than rely on the validator.
|
||||||
|
// `PUT /api/v1/order` keeps an empty list (the player
|
||||||
|
// cleared their draft) — see `OrderHandler`.
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no commands given"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"galaxy/model/order"
|
"galaxy/model/order"
|
||||||
@@ -18,6 +17,10 @@ func PutOrderHandler(c *gin.Context, executor CommandExecutor) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An empty `cmd` array is a valid PUT: the client clears its
|
||||||
|
// local order draft and expects the server to mirror that
|
||||||
|
// state. The engine stores the empty batch so the next GET
|
||||||
|
// returns the same empty list with the new `updatedAt`.
|
||||||
commands := make([]order.DecodableCommand, len(cmd.Commands))
|
commands := make([]order.DecodableCommand, len(cmd.Commands))
|
||||||
for i := range cmd.Commands {
|
for i := range cmd.Commands {
|
||||||
command, err := repo.ParseOrder(cmd.Commands[i], validateCommand)
|
command, err := repo.ParseOrder(cmd.Commands[i], validateCommand)
|
||||||
@@ -26,10 +29,6 @@ func PutOrderHandler(c *gin.Context, executor CommandExecutor) {
|
|||||||
}
|
}
|
||||||
commands[i] = command
|
commands[i] = command
|
||||||
}
|
}
|
||||||
if len(commands) == 0 {
|
|
||||||
errorResponse(c, errors.New("no commands given"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := executor.ValidateOrder(cmd.Actor, commands...)
|
result, err := executor.ValidateOrder(cmd.Actor, commands...)
|
||||||
if errorResponse(c, err) {
|
if errorResponse(c, err) {
|
||||||
|
|||||||
@@ -60,16 +60,25 @@ func TestOrderRaceQuit(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body)
|
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body)
|
||||||
|
|
||||||
// error: no commands
|
// empty cmd[] is a valid PUT — the player cleared their draft;
|
||||||
|
// the engine stores the empty batch and answers with the
|
||||||
|
// canonical `UserGamesOrder` envelope. ValidateOrder receives a
|
||||||
|
// zero-length variadic and the response carries no commands.
|
||||||
payload = &rest.Command{
|
payload = &rest.Command{
|
||||||
Actor: commandDefaultActor,
|
Actor: commandDefaultActor,
|
||||||
}
|
}
|
||||||
|
exec := &dummyExecutor{}
|
||||||
|
emptyRouter := setupRouterExecutor(exec)
|
||||||
|
|
||||||
w = httptest.NewRecorder()
|
w = httptest.NewRecorder()
|
||||||
req, _ = http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
|
req, _ = http.NewRequest(apiCommandMethod, apiOrderPath, asBody(payload))
|
||||||
r.ServeHTTP(w, req)
|
emptyRouter.ServeHTTP(w, req)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, w.Code, w.Body)
|
assert.Equal(t, commandNoErrorsStatus, w.Code, w.Body)
|
||||||
|
assert.Equal(t, 0, exec.CommandsExecuted)
|
||||||
|
var stored order.UserGamesOrder
|
||||||
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &stored))
|
||||||
|
assert.Empty(t, stored.Commands)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestOrderRaceVote(t *testing.T) {
|
func TestOrderRaceVote(t *testing.T) {
|
||||||
|
|||||||
@@ -4,7 +4,14 @@ import "encoding/json"
|
|||||||
|
|
||||||
type Command struct {
|
type Command struct {
|
||||||
Actor string `json:"actor" binding:"notblank"`
|
Actor string `json:"actor" binding:"notblank"`
|
||||||
Commands []json.RawMessage `json:"cmd" binding:"min=1"`
|
// Commands carries the engine-bound payload for either the
|
||||||
|
// command (`PUT /api/v1/command`, immediate) or the order
|
||||||
|
// (`PUT /api/v1/order`, validate-and-store) path. The order
|
||||||
|
// path treats an empty array as "the player has no orders for
|
||||||
|
// this turn" and stores it. The command handler still rejects
|
||||||
|
// an empty array by hand because immediate execution of a
|
||||||
|
// no-op makes no sense.
|
||||||
|
Commands []json.RawMessage `json:"cmd"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o Command) MarshalBinary() (data []byte, err error) {
|
func (o Command) MarshalBinary() (data []byte, err error) {
|
||||||
|
|||||||
@@ -67,6 +67,12 @@ export interface GameReport {
|
|||||||
mapHeight: number;
|
mapHeight: number;
|
||||||
planetCount: number;
|
planetCount: number;
|
||||||
planets: ReportPlanet[];
|
planets: ReportPlanet[];
|
||||||
|
/**
|
||||||
|
* race is the calling player's race name as resolved by the
|
||||||
|
* engine from the runtime player mapping. Empty when the engine
|
||||||
|
* has not produced a report yet (boot state).
|
||||||
|
*/
|
||||||
|
race: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchGameReport(
|
export async function fetchGameReport(
|
||||||
@@ -189,6 +195,7 @@ function decodeReport(report: Report): GameReport {
|
|||||||
mapHeight: report.height(),
|
mapHeight: report.height(),
|
||||||
planetCount: report.planetCount(),
|
planetCount: report.planetCount(),
|
||||||
planets,
|
planets,
|
||||||
|
race: report.race() ?? "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,18 +219,20 @@ export function uuidToHiLo(value: string): [bigint, bigint] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* applyOrderOverlay returns a copy of `report` with every applied or
|
* applyOrderOverlay returns a copy of `report` with every locally-
|
||||||
* still-in-flight (`submitting`) command from `commands` projected on
|
* valid or still-in-flight or applied command from `commands`
|
||||||
* top. Phase 14 understands `planetRename` only — every other variant
|
* projected on top. Phase 14 understands `planetRename` only —
|
||||||
* passes through. The function is pure: callers re-derive the
|
* every other variant passes through. The function is pure:
|
||||||
* overlay whenever the draft or the report change.
|
* callers re-derive the overlay whenever the draft or the report
|
||||||
|
* change.
|
||||||
*
|
*
|
||||||
* `statuses` maps command id → status. Entries with `applied` or
|
* `statuses` maps command id → status. Entries with `valid`,
|
||||||
* `submitting` participate in the overlay; everything else (`draft`,
|
* `submitting`, or `applied` participate in the overlay — together
|
||||||
* `valid`, `invalid`, `rejected`) is treated as "not yet committed
|
* they describe "the player's committed intent for this turn":
|
||||||
* by the player" and skipped. This matches the order-composer model:
|
* locally-valid (auto-sync about to fire), in-flight on the wire,
|
||||||
* the player sees their own committed intent, not their unfinished
|
* or acknowledged by the engine. Entries with `draft`, `invalid`,
|
||||||
* edits.
|
* or `rejected` skip the overlay so the player keeps the server's
|
||||||
|
* (un-renamed) view.
|
||||||
*/
|
*/
|
||||||
export function applyOrderOverlay(
|
export function applyOrderOverlay(
|
||||||
report: GameReport,
|
report: GameReport,
|
||||||
@@ -234,7 +243,13 @@ export function applyOrderOverlay(
|
|||||||
let mutatedPlanets: ReportPlanet[] | null = null;
|
let mutatedPlanets: ReportPlanet[] | null = null;
|
||||||
for (const cmd of commands) {
|
for (const cmd of commands) {
|
||||||
const status = statuses[cmd.id];
|
const status = statuses[cmd.id];
|
||||||
if (status !== "applied" && status !== "submitting") continue;
|
if (
|
||||||
|
status !== "valid" &&
|
||||||
|
status !== "submitting" &&
|
||||||
|
status !== "applied"
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (cmd.kind !== "planetRename") continue;
|
if (cmd.kind !== "planetRename") continue;
|
||||||
const idx = report.planets.findIndex((p) => p.number === cmd.planetNumber);
|
const idx = report.planets.findIndex((p) => p.number === cmd.planetNumber);
|
||||||
if (idx < 0) continue;
|
if (idx < 0) continue;
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ type Status = "idle" | "loading" | "ready" | "error";
|
|||||||
|
|
||||||
export class GameStateStore {
|
export class GameStateStore {
|
||||||
gameId: string = $state("");
|
gameId: string = $state("");
|
||||||
|
/**
|
||||||
|
* gameName mirrors the lobby's `game_name` for the running game.
|
||||||
|
* Lifted from the lobby record on `setGame`; empty during boot
|
||||||
|
* and set once the lobby query resolves. Used by the header to
|
||||||
|
* compose the `<race> @ <game>, turn N` display.
|
||||||
|
*/
|
||||||
|
gameName: string = $state("");
|
||||||
status: Status = $state("idle");
|
status: Status = $state("idle");
|
||||||
report: GameReport | null = $state(null);
|
report: GameReport | null = $state(null);
|
||||||
wrapMode: WrapMode = $state("torus");
|
wrapMode: WrapMode = $state("torus");
|
||||||
@@ -95,6 +102,7 @@ export class GameStateStore {
|
|||||||
this.error = `game ${gameId} is not in your list`;
|
this.error = `game ${gameId} is not in your list`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
this.gameName = summary.gameName;
|
||||||
this.currentTurn = summary.currentTurn;
|
this.currentTurn = summary.currentTurn;
|
||||||
await this.loadTurn(summary.currentTurn);
|
await this.loadTurn(summary.currentTurn);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -1,16 +1,25 @@
|
|||||||
<!--
|
<!--
|
||||||
Top header for the in-game shell. Composes the four artifacts called
|
Top header for the in-game shell. Composes the in-game ID strip
|
||||||
out by `ui/PLAN.md` Phase 10: race name (static placeholder), turn
|
(race name @ game name, turn N), view dropdown / hamburger, and the
|
||||||
counter (static placeholder), view dropdown / hamburger, account
|
account menu. The sidebar-toggle slot to its left appears only on
|
||||||
menu. The sidebar-toggle slot to its left appears only on tablet
|
tablet viewports (768–1024 px) and is wired by `+layout.svelte`.
|
||||||
viewports (768–1024 px) and is wired by `+layout.svelte`.
|
|
||||||
|
The race name is read from the engine's `Report.race`, the game
|
||||||
|
name from the lobby's `GameSummary.gameName`. While either piece
|
||||||
|
is missing (boot, network error) we fall back to the
|
||||||
|
`game.shell.unknown` placeholder so the header chrome keeps its
|
||||||
|
shape.
|
||||||
|
|
||||||
The connection-state indicator from the IA section is intentionally
|
The connection-state indicator from the IA section is intentionally
|
||||||
absent until Phase 24 wires push-event state.
|
absent until Phase 24 wires push-event state.
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { getContext } from "svelte";
|
||||||
import { i18n } from "$lib/i18n/index.svelte";
|
import { i18n } from "$lib/i18n/index.svelte";
|
||||||
import TurnCounter from "./turn-counter.svelte";
|
import {
|
||||||
|
GAME_STATE_CONTEXT_KEY,
|
||||||
|
type GameStateStore,
|
||||||
|
} from "$lib/game-state.svelte";
|
||||||
import ViewMenu from "./view-menu.svelte";
|
import ViewMenu from "./view-menu.svelte";
|
||||||
import AccountMenu from "./account-menu.svelte";
|
import AccountMenu from "./account-menu.svelte";
|
||||||
|
|
||||||
@@ -20,14 +29,42 @@ absent until Phase 24 wires push-event state.
|
|||||||
onToggleSidebar: () => void;
|
onToggleSidebar: () => void;
|
||||||
};
|
};
|
||||||
let { gameId, sidebarOpen, onToggleSidebar }: Props = $props();
|
let { gameId, sidebarOpen, onToggleSidebar }: Props = $props();
|
||||||
|
|
||||||
|
const gameState = getContext<GameStateStore | undefined>(
|
||||||
|
GAME_STATE_CONTEXT_KEY,
|
||||||
|
);
|
||||||
|
|
||||||
|
const raceName = $derived.by(() => {
|
||||||
|
const name = gameState?.report?.race;
|
||||||
|
return name === undefined || name === ""
|
||||||
|
? i18n.t("game.shell.unknown")
|
||||||
|
: name;
|
||||||
|
});
|
||||||
|
const gameName = $derived.by(() => {
|
||||||
|
const name = gameState?.gameName ?? "";
|
||||||
|
return name === "" ? i18n.t("game.shell.unknown") : name;
|
||||||
|
});
|
||||||
|
const turn = $derived.by(() => {
|
||||||
|
const report = gameState?.report;
|
||||||
|
return report === null || report === undefined
|
||||||
|
? i18n.t("game.shell.unknown")
|
||||||
|
: String(report.turn);
|
||||||
|
});
|
||||||
|
|
||||||
|
const headline = $derived(
|
||||||
|
i18n.t("game.shell.headline", {
|
||||||
|
race: raceName,
|
||||||
|
game: gameName,
|
||||||
|
turn,
|
||||||
|
}),
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header class="game-shell-header" data-testid="game-shell-header">
|
<header class="game-shell-header" data-testid="game-shell-header">
|
||||||
<div class="left">
|
<div class="left">
|
||||||
<span class="race" data-testid="race-name">
|
<span class="headline" data-testid="game-shell-headline">
|
||||||
{i18n.t("game.shell.race_placeholder")}
|
{headline}
|
||||||
</span>
|
</span>
|
||||||
<TurnCounter />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="right">
|
<div class="right">
|
||||||
<button
|
<button
|
||||||
@@ -69,7 +106,7 @@ absent until Phase 24 wires push-event state.
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.race {
|
.headline {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
<!--
|
|
||||||
Phase 11 turn counter: reads the live turn number from the per-game
|
|
||||||
`GameStateStore` provided through context by
|
|
||||||
`routes/games/[id]/+layout.svelte`. Renders the static `?` placeholder
|
|
||||||
from `game.shell.turn_unknown` when the store has not yet produced a
|
|
||||||
report (boot, network error, no membership) so the header chrome
|
|
||||||
keeps its width across loading transitions.
|
|
||||||
|
|
||||||
Phase 26 will turn this into a clickable trigger that opens the
|
|
||||||
turn navigator; Phase 24 wires push-event-driven turn-ready toasts
|
|
||||||
that may flash this counter when a new turn is ready.
|
|
||||||
-->
|
|
||||||
<script lang="ts">
|
|
||||||
import { getContext } from "svelte";
|
|
||||||
import { i18n } from "$lib/i18n/index.svelte";
|
|
||||||
import { GAME_STATE_CONTEXT_KEY, type GameStateStore } from "$lib/game-state.svelte";
|
|
||||||
|
|
||||||
const store = getContext<GameStateStore | undefined>(GAME_STATE_CONTEXT_KEY);
|
|
||||||
|
|
||||||
const display = $derived.by(() => {
|
|
||||||
const report = store?.report ?? null;
|
|
||||||
if (report === null) return i18n.t("game.shell.turn_unknown");
|
|
||||||
return String(report.turn);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<span class="turn" data-testid="turn-counter" data-turn={display}>
|
|
||||||
{i18n.t("game.shell.turn_label")} {display}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.turn {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
color: #ddd;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -83,9 +83,8 @@ const en = {
|
|||||||
"lobby.error.internal_error": "internal server error",
|
"lobby.error.internal_error": "internal server error",
|
||||||
"lobby.error.unknown": "{message}",
|
"lobby.error.unknown": "{message}",
|
||||||
|
|
||||||
"game.shell.race_placeholder": "race ?",
|
"game.shell.unknown": "?",
|
||||||
"game.shell.turn_label": "turn",
|
"game.shell.headline": "{race} @ {game}, turn {turn}",
|
||||||
"game.shell.turn_unknown": "?",
|
|
||||||
"game.shell.connection.online": "online",
|
"game.shell.connection.online": "online",
|
||||||
"game.shell.connection.reconnecting": "reconnecting…",
|
"game.shell.connection.reconnecting": "reconnecting…",
|
||||||
"game.shell.connection.offline": "offline",
|
"game.shell.connection.offline": "offline",
|
||||||
@@ -120,8 +119,11 @@ const en = {
|
|||||||
"game.sidebar.empty.inspector": "select an object on the map",
|
"game.sidebar.empty.inspector": "select an object on the map",
|
||||||
"game.sidebar.empty.order": "order is empty",
|
"game.sidebar.empty.order": "order is empty",
|
||||||
"game.sidebar.order.command_delete": "delete",
|
"game.sidebar.order.command_delete": "delete",
|
||||||
"game.sidebar.order.submit": "submit",
|
"game.sidebar.order.sync.idle": "no changes yet",
|
||||||
"game.sidebar.order.submit_in_flight": "submitting…",
|
"game.sidebar.order.sync.in_flight": "syncing…",
|
||||||
|
"game.sidebar.order.sync.synced": "synced with server",
|
||||||
|
"game.sidebar.order.sync.error": "sync failed: {message}",
|
||||||
|
"game.sidebar.order.sync.retry": "retry",
|
||||||
"game.sidebar.order.status.draft": "draft",
|
"game.sidebar.order.status.draft": "draft",
|
||||||
"game.sidebar.order.status.valid": "valid",
|
"game.sidebar.order.status.valid": "valid",
|
||||||
"game.sidebar.order.status.invalid": "invalid",
|
"game.sidebar.order.status.invalid": "invalid",
|
||||||
@@ -130,7 +132,6 @@ const en = {
|
|||||||
"game.sidebar.order.status.rejected": "rejected",
|
"game.sidebar.order.status.rejected": "rejected",
|
||||||
"game.sidebar.order.label.placeholder": "{label}",
|
"game.sidebar.order.label.placeholder": "{label}",
|
||||||
"game.sidebar.order.label.planet_rename": "rename planet {planet} → {name}",
|
"game.sidebar.order.label.planet_rename": "rename planet {planet} → {name}",
|
||||||
"game.sidebar.order.error.batch_failed": "submit failed: {message}",
|
|
||||||
"game.bottom_tabs.map": "map",
|
"game.bottom_tabs.map": "map",
|
||||||
"game.bottom_tabs.calc": "calc",
|
"game.bottom_tabs.calc": "calc",
|
||||||
"game.bottom_tabs.order": "order",
|
"game.bottom_tabs.order": "order",
|
||||||
|
|||||||
@@ -84,9 +84,8 @@ const ru: Record<keyof typeof en, string> = {
|
|||||||
"lobby.error.internal_error": "внутренняя ошибка сервера",
|
"lobby.error.internal_error": "внутренняя ошибка сервера",
|
||||||
"lobby.error.unknown": "{message}",
|
"lobby.error.unknown": "{message}",
|
||||||
|
|
||||||
"game.shell.race_placeholder": "раса ?",
|
"game.shell.unknown": "?",
|
||||||
"game.shell.turn_label": "ход",
|
"game.shell.headline": "{race} @ {game}, ход {turn}",
|
||||||
"game.shell.turn_unknown": "?",
|
|
||||||
"game.shell.connection.online": "онлайн",
|
"game.shell.connection.online": "онлайн",
|
||||||
"game.shell.connection.reconnecting": "переподключение…",
|
"game.shell.connection.reconnecting": "переподключение…",
|
||||||
"game.shell.connection.offline": "офлайн",
|
"game.shell.connection.offline": "офлайн",
|
||||||
@@ -121,8 +120,11 @@ const ru: Record<keyof typeof en, string> = {
|
|||||||
"game.sidebar.empty.inspector": "выберите объект на карте",
|
"game.sidebar.empty.inspector": "выберите объект на карте",
|
||||||
"game.sidebar.empty.order": "приказ пуст",
|
"game.sidebar.empty.order": "приказ пуст",
|
||||||
"game.sidebar.order.command_delete": "удалить",
|
"game.sidebar.order.command_delete": "удалить",
|
||||||
"game.sidebar.order.submit": "отправить",
|
"game.sidebar.order.sync.idle": "нет изменений",
|
||||||
"game.sidebar.order.submit_in_flight": "отправка…",
|
"game.sidebar.order.sync.in_flight": "синхронизация…",
|
||||||
|
"game.sidebar.order.sync.synced": "сохранено на сервере",
|
||||||
|
"game.sidebar.order.sync.error": "ошибка синхронизации: {message}",
|
||||||
|
"game.sidebar.order.sync.retry": "повторить",
|
||||||
"game.sidebar.order.status.draft": "черновик",
|
"game.sidebar.order.status.draft": "черновик",
|
||||||
"game.sidebar.order.status.valid": "готова",
|
"game.sidebar.order.status.valid": "готова",
|
||||||
"game.sidebar.order.status.invalid": "ошибка",
|
"game.sidebar.order.status.invalid": "ошибка",
|
||||||
@@ -131,7 +133,6 @@ const ru: Record<keyof typeof en, string> = {
|
|||||||
"game.sidebar.order.status.rejected": "отклонена",
|
"game.sidebar.order.status.rejected": "отклонена",
|
||||||
"game.sidebar.order.label.placeholder": "{label}",
|
"game.sidebar.order.label.placeholder": "{label}",
|
||||||
"game.sidebar.order.label.planet_rename": "переименовать планету {planet} → {name}",
|
"game.sidebar.order.label.planet_rename": "переименовать планету {planet} → {name}",
|
||||||
"game.sidebar.order.error.batch_failed": "ошибка отправки: {message}",
|
|
||||||
"game.bottom_tabs.map": "карта",
|
"game.bottom_tabs.map": "карта",
|
||||||
"game.bottom_tabs.calc": "калк",
|
"game.bottom_tabs.calc": "калк",
|
||||||
"game.bottom_tabs.order": "приказ",
|
"game.bottom_tabs.order": "приказ",
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
<!--
|
<!--
|
||||||
Order composer tool. Resolves the per-game `OrderDraftStore`,
|
Order composer tool. Resolves the per-game `OrderDraftStore` from
|
||||||
`GameStateStore`, and `GalaxyClient` from context (all set by
|
context (set by `routes/games/[id]/+layout.svelte`) and renders the
|
||||||
`routes/games/[id]/+layout.svelte`) and renders the local draft as
|
local draft as a vertical list with per-row status and a delete
|
||||||
a vertical list with per-row status, a delete button, and a Submit
|
button.
|
||||||
button at the bottom.
|
|
||||||
|
|
||||||
Phase 14 wires the first end-to-end command: clicking Submit calls
|
Phase 14 wires the auto-sync pipeline directly into the draft
|
||||||
`submitOrder` for every entry in `valid` status, flips the in-flight
|
store: every successful `add` / `remove` / `move` triggers a
|
||||||
rows to `submitting`, then merges the per-command verdict back into
|
`submitOrder` call so the server always mirrors the local draft.
|
||||||
the draft once the gateway responds. The optimistic overlay in
|
This view shows the resulting per-command status (`valid`,
|
||||||
`renderedReport` continues to show the player's intent while the
|
`submitting`, `applied`, `rejected`) and a small status bar at the
|
||||||
order is in flight, so the inspector and the map reflect the new
|
bottom that surfaces the latest sync result. The earlier explicit
|
||||||
name even before the server applies it at turn cutoff.
|
Submit button is gone — there is no separate "send" step anymore.
|
||||||
|
|
||||||
Tests exercise the skeleton through `__galaxyDebug.seedOrderDraft`
|
Tests exercise the tab through `__galaxyDebug.seedOrderDraft`
|
||||||
(Playwright) and via direct store / mocked-client construction
|
(Playwright) and via direct store / mocked-client construction
|
||||||
(Vitest).
|
(Vitest).
|
||||||
-->
|
-->
|
||||||
@@ -24,26 +23,11 @@ Tests exercise the skeleton through `__galaxyDebug.seedOrderDraft`
|
|||||||
ORDER_DRAFT_CONTEXT_KEY,
|
ORDER_DRAFT_CONTEXT_KEY,
|
||||||
OrderDraftStore,
|
OrderDraftStore,
|
||||||
} from "../../sync/order-draft.svelte";
|
} from "../../sync/order-draft.svelte";
|
||||||
import {
|
|
||||||
GAME_STATE_CONTEXT_KEY,
|
|
||||||
type GameStateStore,
|
|
||||||
} from "$lib/game-state.svelte";
|
|
||||||
import {
|
|
||||||
GALAXY_CLIENT_CONTEXT_KEY,
|
|
||||||
type GalaxyClientHandle,
|
|
||||||
} from "$lib/galaxy-client-context.svelte";
|
|
||||||
import type { CommandStatus, OrderCommand } from "../../sync/order-types";
|
import type { CommandStatus, OrderCommand } from "../../sync/order-types";
|
||||||
import { submitOrder } from "../../sync/submit";
|
|
||||||
|
|
||||||
const draft = getContext<OrderDraftStore | undefined>(
|
const draft = getContext<OrderDraftStore | undefined>(
|
||||||
ORDER_DRAFT_CONTEXT_KEY,
|
ORDER_DRAFT_CONTEXT_KEY,
|
||||||
);
|
);
|
||||||
const gameState = getContext<GameStateStore | undefined>(
|
|
||||||
GAME_STATE_CONTEXT_KEY,
|
|
||||||
);
|
|
||||||
const galaxyClient = getContext<GalaxyClientHandle | undefined>(
|
|
||||||
GALAXY_CLIENT_CONTEXT_KEY,
|
|
||||||
);
|
|
||||||
|
|
||||||
const statusKeyMap: Record<CommandStatus, TranslationKey> = {
|
const statusKeyMap: Record<CommandStatus, TranslationKey> = {
|
||||||
draft: "game.sidebar.order.status.draft",
|
draft: "game.sidebar.order.status.draft",
|
||||||
@@ -54,30 +38,6 @@ Tests exercise the skeleton through `__galaxyDebug.seedOrderDraft`
|
|||||||
rejected: "game.sidebar.order.status.rejected",
|
rejected: "game.sidebar.order.status.rejected",
|
||||||
};
|
};
|
||||||
|
|
||||||
let submitInFlight = $state(false);
|
|
||||||
let submitError = $state<string | null>(null);
|
|
||||||
|
|
||||||
const submittable = $derived.by(() => {
|
|
||||||
if (draft === undefined) return [] as OrderCommand[];
|
|
||||||
return draft.commands.filter(
|
|
||||||
(cmd) => draft.statuses[cmd.id] === "valid",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasInvalid = $derived.by(() => {
|
|
||||||
if (draft === undefined) return false;
|
|
||||||
return draft.commands.some((cmd) => draft.statuses[cmd.id] === "invalid");
|
|
||||||
});
|
|
||||||
|
|
||||||
const submitDisabled = $derived(
|
|
||||||
draft === undefined ||
|
|
||||||
galaxyClient === undefined ||
|
|
||||||
galaxyClient.client === null ||
|
|
||||||
submitInFlight ||
|
|
||||||
submittable.length === 0 ||
|
|
||||||
hasInvalid,
|
|
||||||
);
|
|
||||||
|
|
||||||
function describe(cmd: OrderCommand): string {
|
function describe(cmd: OrderCommand): string {
|
||||||
switch (cmd.kind) {
|
switch (cmd.kind) {
|
||||||
case "placeholder":
|
case "placeholder":
|
||||||
@@ -95,50 +55,6 @@ Tests exercise the skeleton through `__galaxyDebug.seedOrderDraft`
|
|||||||
function statusOf(cmd: OrderCommand): CommandStatus {
|
function statusOf(cmd: OrderCommand): CommandStatus {
|
||||||
return draft?.statuses[cmd.id] ?? "draft";
|
return draft?.statuses[cmd.id] ?? "draft";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit(): Promise<void> {
|
|
||||||
if (
|
|
||||||
draft === undefined ||
|
|
||||||
galaxyClient === undefined ||
|
|
||||||
galaxyClient.client === null ||
|
|
||||||
gameState === undefined
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
if (submittable.length === 0 || hasInvalid) return;
|
|
||||||
const ids = submittable.map((cmd) => cmd.id);
|
|
||||||
const snapshot = submittable.slice();
|
|
||||||
submitInFlight = true;
|
|
||||||
submitError = null;
|
|
||||||
draft.markSubmitting(ids);
|
|
||||||
try {
|
|
||||||
const result = await submitOrder(
|
|
||||||
galaxyClient.client,
|
|
||||||
gameState.gameId,
|
|
||||||
snapshot,
|
|
||||||
{ updatedAt: draft.updatedAt },
|
|
||||||
);
|
|
||||||
if (result.ok) {
|
|
||||||
draft.applyResults({
|
|
||||||
results: result.results,
|
|
||||||
updatedAt: result.updatedAt,
|
|
||||||
});
|
|
||||||
if (gameState !== undefined) {
|
|
||||||
await gameState.refresh();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
draft.markRejected(ids);
|
|
||||||
submitError = i18n.t("game.sidebar.order.error.batch_failed", {
|
|
||||||
message: result.message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
draft.revertSubmittingToValid();
|
|
||||||
submitError =
|
|
||||||
err instanceof Error ? err.message : "submit failed";
|
|
||||||
} finally {
|
|
||||||
submitInFlight = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="tool" data-testid="sidebar-tool-order">
|
<section class="tool" data-testid="sidebar-tool-order">
|
||||||
@@ -177,20 +93,37 @@ Tests exercise the skeleton through `__galaxyDebug.seedOrderDraft`
|
|||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ol>
|
</ol>
|
||||||
|
{/if}
|
||||||
|
{#if draft !== undefined}
|
||||||
|
<div
|
||||||
|
class="sync sync-{draft.syncStatus}"
|
||||||
|
data-testid="order-sync"
|
||||||
|
data-sync-status={draft.syncStatus}
|
||||||
|
>
|
||||||
|
<span class="sync-label">
|
||||||
|
{#if draft.syncStatus === "syncing"}
|
||||||
|
{i18n.t("game.sidebar.order.sync.in_flight")}
|
||||||
|
{:else if draft.syncStatus === "synced"}
|
||||||
|
{i18n.t("game.sidebar.order.sync.synced")}
|
||||||
|
{:else if draft.syncStatus === "error"}
|
||||||
|
{i18n.t("game.sidebar.order.sync.error", {
|
||||||
|
message: draft.syncError ?? "",
|
||||||
|
})}
|
||||||
|
{:else}
|
||||||
|
{i18n.t("game.sidebar.order.sync.idle")}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if draft.syncStatus === "error"}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="submit"
|
class="sync-retry"
|
||||||
data-testid="order-submit"
|
data-testid="order-sync-retry"
|
||||||
disabled={submitDisabled}
|
onclick={() => draft.forceSync()}
|
||||||
onclick={() => void submit()}
|
|
||||||
>
|
>
|
||||||
{submitInFlight
|
{i18n.t("game.sidebar.order.sync.retry")}
|
||||||
? i18n.t("game.sidebar.order.submit_in_flight")
|
|
||||||
: i18n.t("game.sidebar.order.submit")}
|
|
||||||
</button>
|
</button>
|
||||||
{#if submitError !== null}
|
|
||||||
<p class="error" data-testid="order-submit-error">{submitError}</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -274,26 +207,35 @@ Tests exercise the skeleton through `__galaxyDebug.seedOrderDraft`
|
|||||||
color: #e8eaf6;
|
color: #e8eaf6;
|
||||||
border-color: #6d8cff;
|
border-color: #6d8cff;
|
||||||
}
|
}
|
||||||
.submit {
|
.sync {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #aab;
|
||||||
|
}
|
||||||
|
.sync-error {
|
||||||
|
color: #d97a7a;
|
||||||
|
}
|
||||||
|
.sync-synced {
|
||||||
|
color: #8be9a3;
|
||||||
|
}
|
||||||
|
.sync-syncing {
|
||||||
|
color: #6d8cff;
|
||||||
|
}
|
||||||
|
.sync-retry {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 0.9rem;
|
font-size: 0.8rem;
|
||||||
padding: 0.4rem 1rem;
|
padding: 0.15rem 0.5rem;
|
||||||
background: #1d2440;
|
background: transparent;
|
||||||
color: #e8eaf6;
|
color: #aab;
|
||||||
border: 1px solid #2a3150;
|
border: 1px solid #2a3150;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.submit:not(:disabled):hover {
|
.sync-retry:hover {
|
||||||
|
color: #e8eaf6;
|
||||||
border-color: #6d8cff;
|
border-color: #6d8cff;
|
||||||
}
|
}
|
||||||
.submit:disabled {
|
|
||||||
cursor: not-allowed;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
.error {
|
|
||||||
margin: 0.5rem 0 0;
|
|
||||||
color: #d97a7a;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -164,12 +164,17 @@ fresh.
|
|||||||
orderDraft.init({ cache, gameId }),
|
orderDraft.init({ cache, gameId }),
|
||||||
]);
|
]);
|
||||||
galaxyClient.set(client);
|
galaxyClient.set(client);
|
||||||
if (orderDraft.needsServerHydration) {
|
orderDraft.bindClient(client);
|
||||||
|
// The server is always polled at game boot — its
|
||||||
|
// stored order may be fresher than the local cache
|
||||||
|
// (e.g. user is on a new device), and an offline
|
||||||
|
// edit must catch up at re-sync time. The hydration
|
||||||
|
// is non-fatal: a network error keeps the local
|
||||||
|
// cache and surfaces through `draft.syncStatus`.
|
||||||
await orderDraft.hydrateFromServer({
|
await orderDraft.hydrateFromServer({
|
||||||
client,
|
client,
|
||||||
turn: gameState.currentTurn,
|
turn: gameState.currentTurn,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
gameState.failBootstrap(describeBootstrapError(err));
|
gameState.failBootstrap(describeBootstrapError(err));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,16 @@
|
|||||||
// Draft state is persisted into the platform `Cache` under the
|
// Draft state is persisted into the platform `Cache` under the
|
||||||
// `order-drafts` namespace with a per-game key, so a reload, a
|
// `order-drafts` namespace with a per-game key, so a reload, a
|
||||||
// browser restart, or a navigation through the lobby and back into
|
// browser restart, or a navigation through the lobby and back into
|
||||||
// the same game restores the previously composed list. Phase 14
|
// the same game restores the previously composed list.
|
||||||
// will add the submit pipeline that drains the draft to the server;
|
//
|
||||||
// Phase 26 will hide the order tab in history mode through a flag
|
// Phase 14 wires the auto-sync pipeline: every successful mutation
|
||||||
// passed by the layout (the store itself remains alive across that
|
// (`add` / `remove` / `move`) coalesces a `submitOrder` call so the
|
||||||
// transition so the draft survives history-mode round-trips).
|
// server always mirrors the local draft. The Submit button is gone —
|
||||||
|
// the player's intent is the source of truth and the engine is kept
|
||||||
|
// in lock-step. Phase 26 will hide the order tab in history mode
|
||||||
|
// through a flag passed by the layout (the store itself remains
|
||||||
|
// alive across that transition so the draft survives history-mode
|
||||||
|
// round-trips).
|
||||||
//
|
//
|
||||||
// The store deliberately carries no Svelte component imports so it
|
// The store deliberately carries no Svelte component imports so it
|
||||||
// can be tested directly with a synthetic `Cache` without rendering
|
// can be tested directly with a synthetic `Cache` without rendering
|
||||||
@@ -20,6 +25,7 @@ import type { Cache } from "../platform/store/index";
|
|||||||
import type { GalaxyClient } from "../api/galaxy-client";
|
import type { GalaxyClient } from "../api/galaxy-client";
|
||||||
import { fetchOrder } from "./order-load";
|
import { fetchOrder } from "./order-load";
|
||||||
import type { CommandStatus, OrderCommand } from "./order-types";
|
import type { CommandStatus, OrderCommand } from "./order-types";
|
||||||
|
import { submitOrder } from "./submit";
|
||||||
import { validateEntityName } from "$lib/util/entity-name";
|
import { validateEntityName } from "$lib/util/entity-name";
|
||||||
|
|
||||||
const NAMESPACE = "order-drafts";
|
const NAMESPACE = "order-drafts";
|
||||||
@@ -35,6 +41,8 @@ export const ORDER_DRAFT_CONTEXT_KEY = Symbol("order-draft");
|
|||||||
|
|
||||||
type Status = "idle" | "ready" | "error";
|
type Status = "idle" | "ready" | "error";
|
||||||
|
|
||||||
|
export type SyncStatus = "idle" | "syncing" | "synced" | "error";
|
||||||
|
|
||||||
export class OrderDraftStore {
|
export class OrderDraftStore {
|
||||||
commands: OrderCommand[] = $state([]);
|
commands: OrderCommand[] = $state([]);
|
||||||
statuses: Record<string, CommandStatus> = $state({});
|
statuses: Record<string, CommandStatus> = $state({});
|
||||||
@@ -43,18 +51,26 @@ export class OrderDraftStore {
|
|||||||
error: string | null = $state(null);
|
error: string | null = $state(null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* needsServerHydration is `true` when the cache row for this game
|
* syncStatus reflects the auto-sync pipeline state for the order
|
||||||
* was absent at `init` time. The layout reads it after both
|
* tab status bar:
|
||||||
* `gameState.init` and `orderDraft.init` resolve and, if `true`,
|
* - `idle` — no sync attempted yet (e.g. fresh draft after
|
||||||
* calls `hydrateFromServer` once the current turn is known.
|
* hydration or before the first mutation).
|
||||||
* An explicitly empty cache row sets it to `false` (the user has
|
* - `syncing` — a `submitOrder` call is in flight.
|
||||||
* an empty draft, not a missing one).
|
* - `synced` — the last sync succeeded; statuses match the
|
||||||
|
* server's view.
|
||||||
|
* - `error` — the last sync failed (network or non-`ok`); the
|
||||||
|
* next mutation triggers a retry, or the user can
|
||||||
|
* force a re-sync via `forceSync`.
|
||||||
*/
|
*/
|
||||||
needsServerHydration = $state(false);
|
syncStatus: SyncStatus = $state("idle");
|
||||||
|
syncError: string | null = $state(null);
|
||||||
|
|
||||||
private cache: Cache | null = null;
|
private cache: Cache | null = null;
|
||||||
private gameId = "";
|
private gameId = "";
|
||||||
private destroyed = false;
|
private destroyed = false;
|
||||||
|
private client: GalaxyClient | null = null;
|
||||||
|
private syncing: Promise<void> | null = null;
|
||||||
|
private pending = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* init loads the persisted draft for `opts.gameId` from `opts.cache`
|
* init loads the persisted draft for `opts.gameId` from `opts.cache`
|
||||||
@@ -63,11 +79,11 @@ export class OrderDraftStore {
|
|||||||
* constructs a fresh store per game, so there is no need to support
|
* constructs a fresh store per game, so there is no need to support
|
||||||
* mid-life game switching here.
|
* mid-life game switching here.
|
||||||
*
|
*
|
||||||
* When the cache row is absent, `needsServerHydration` is set to
|
* The cache load is the fast path so the order tab paints
|
||||||
* `true`; the layout fans out a `hydrateFromServer` call once the
|
* immediately on reopening the game; `hydrateFromServer` (called
|
||||||
* current turn is known. An explicitly empty cache row is treated
|
* by the layout once the current turn is known) is the
|
||||||
* as "user has an empty draft" and skipped — local intent always
|
* authoritative read that always overwrites the local cache when
|
||||||
* wins over server snapshot.
|
* the server has a stored order.
|
||||||
*/
|
*/
|
||||||
async init(opts: { cache: Cache; gameId: string }): Promise<void> {
|
async init(opts: { cache: Cache; gameId: string }): Promise<void> {
|
||||||
this.cache = opts.cache;
|
this.cache = opts.cache;
|
||||||
@@ -78,13 +94,7 @@ export class OrderDraftStore {
|
|||||||
draftKey(opts.gameId),
|
draftKey(opts.gameId),
|
||||||
);
|
);
|
||||||
if (this.destroyed) return;
|
if (this.destroyed) return;
|
||||||
if (stored === undefined) {
|
|
||||||
this.commands = [];
|
|
||||||
this.needsServerHydration = true;
|
|
||||||
} else {
|
|
||||||
this.commands = Array.isArray(stored) ? [...stored] : [];
|
this.commands = Array.isArray(stored) ? [...stored] : [];
|
||||||
this.needsServerHydration = false;
|
|
||||||
}
|
|
||||||
this.recomputeStatuses();
|
this.recomputeStatuses();
|
||||||
this.status = "ready";
|
this.status = "ready";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -95,37 +105,64 @@ export class OrderDraftStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* hydrateFromServer fetches the player's stored order from the
|
* bindClient stores the per-game `GalaxyClient` so subsequent
|
||||||
* gateway when the cache row was absent at boot. The result is
|
* mutations can drive the auto-sync pipeline. The layout calls
|
||||||
* merged into `commands` and persisted so subsequent reloads
|
* this after the boot `Promise.all` resolves and before
|
||||||
* prefer the cached version. Failures are non-fatal — the draft
|
* `hydrateFromServer`, so any mutation that lands afterwards goes
|
||||||
* stays empty and the user can keep composing.
|
* through the network.
|
||||||
|
*/
|
||||||
|
bindClient(client: GalaxyClient): void {
|
||||||
|
this.client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* hydrateFromServer issues `user.games.order.get` for the current
|
||||||
|
* turn and overwrites the local cache with the server's stored
|
||||||
|
* order. The server is the source of truth: a player who logged
|
||||||
|
* in from a fresh device must see their existing orders, and a
|
||||||
|
* cache that's out-of-sync (e.g. a stale browser tab) is
|
||||||
|
* superseded by the gateway's view. A `found = false` answer
|
||||||
|
* empties the local draft. Network failures keep the local cache
|
||||||
|
* intact and surface as `syncStatus = "error"`.
|
||||||
*/
|
*/
|
||||||
async hydrateFromServer(opts: {
|
async hydrateFromServer(opts: {
|
||||||
client: GalaxyClient;
|
client: GalaxyClient;
|
||||||
turn: number;
|
turn: number;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
if (this.status !== "ready" || !this.needsServerHydration) return;
|
if (this.status !== "ready") return;
|
||||||
this.needsServerHydration = false;
|
this.client = opts.client;
|
||||||
|
this.syncStatus = "syncing";
|
||||||
|
this.syncError = null;
|
||||||
try {
|
try {
|
||||||
const fetched = await fetchOrder(opts.client, this.gameId, opts.turn);
|
const fetched = await fetchOrder(opts.client, this.gameId, opts.turn);
|
||||||
if (this.destroyed) return;
|
if (this.destroyed) return;
|
||||||
this.commands = fetched.commands;
|
this.commands = fetched.commands;
|
||||||
this.updatedAt = fetched.updatedAt;
|
this.updatedAt = fetched.updatedAt;
|
||||||
this.recomputeStatuses();
|
this.recomputeStatuses();
|
||||||
|
// Server-fetched commands echo cmdApplied=true for entries
|
||||||
|
// that survived previous turns; keep them as `applied` so
|
||||||
|
// the overlay continues to project them on the inspector.
|
||||||
|
const next = { ...this.statuses };
|
||||||
|
for (const cmd of this.commands) {
|
||||||
|
if (next[cmd.id] === "valid") {
|
||||||
|
next[cmd.id] = "applied";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.statuses = next;
|
||||||
await this.persist();
|
await this.persist();
|
||||||
|
this.syncStatus = "synced";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (this.destroyed) return;
|
if (this.destroyed) return;
|
||||||
console.warn(
|
this.syncStatus = "error";
|
||||||
"order-draft: server hydration failed; staying on empty draft",
|
this.syncError = err instanceof Error ? err.message : "fetch failed";
|
||||||
err,
|
console.warn("order-draft: server hydration failed", err);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* add appends a command to the end of the draft, runs local
|
* add appends a command to the end of the draft, runs local
|
||||||
* validation for the new entry, and persists the updated list.
|
* validation for the new entry, persists the updated list, and
|
||||||
|
* triggers an auto-sync to keep the server in lock-step.
|
||||||
* Mutations made before `init` resolves are ignored — the layout
|
* Mutations made before `init` resolves are ignored — the layout
|
||||||
* always awaits `init` before exposing the store.
|
* always awaits `init` before exposing the store.
|
||||||
*/
|
*/
|
||||||
@@ -134,11 +171,15 @@ export class OrderDraftStore {
|
|||||||
this.commands = [...this.commands, command];
|
this.commands = [...this.commands, command];
|
||||||
this.statuses = { ...this.statuses, [command.id]: validateCommand(command) };
|
this.statuses = { ...this.statuses, [command.id]: validateCommand(command) };
|
||||||
await this.persist();
|
await this.persist();
|
||||||
|
this.scheduleSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* remove drops the command with the given id from the draft and
|
* remove drops the command with the given id from the draft,
|
||||||
* persists the result. A miss is a no-op.
|
* persists the result, and triggers an auto-sync. A miss is a
|
||||||
|
* no-op. Even removing the last command sends an explicit empty
|
||||||
|
* order to the server so its stored state matches the local one
|
||||||
|
* (the engine accepts an empty `cmd[]` per the order handler).
|
||||||
*/
|
*/
|
||||||
async remove(id: string): Promise<void> {
|
async remove(id: string): Promise<void> {
|
||||||
if (this.status !== "ready") return;
|
if (this.status !== "ready") return;
|
||||||
@@ -149,13 +190,16 @@ export class OrderDraftStore {
|
|||||||
delete nextStatuses[id];
|
delete nextStatuses[id];
|
||||||
this.statuses = nextStatuses;
|
this.statuses = nextStatuses;
|
||||||
await this.persist();
|
await this.persist();
|
||||||
|
this.scheduleSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* move relocates the command at `fromIndex` to `toIndex`, shifting
|
* move relocates the command at `fromIndex` to `toIndex`, shifting
|
||||||
* the intermediate commands. Out-of-range indices and identical
|
* the intermediate commands. Out-of-range indices and identical
|
||||||
* positions are no-ops; both indices are clamped against the
|
* positions are no-ops; both indices are clamped against the
|
||||||
* current `commands` length.
|
* current `commands` length. Triggers an auto-sync — the server
|
||||||
|
* stores commands in submission order and the engine relies on
|
||||||
|
* that order at turn cutoff.
|
||||||
*/
|
*/
|
||||||
async move(fromIndex: number, toIndex: number): Promise<void> {
|
async move(fromIndex: number, toIndex: number): Promise<void> {
|
||||||
if (this.status !== "ready") return;
|
if (this.status !== "ready") return;
|
||||||
@@ -169,49 +213,137 @@ export class OrderDraftStore {
|
|||||||
next.splice(toIndex, 0, picked);
|
next.splice(toIndex, 0, picked);
|
||||||
this.commands = next;
|
this.commands = next;
|
||||||
await this.persist();
|
await this.persist();
|
||||||
|
this.scheduleSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* markSubmitting flips the status of every entry in `ids` to
|
* forceSync re-runs the auto-sync without requiring a mutation.
|
||||||
* `submitting` so the order tab can disable per-row controls and
|
* Used by the order tab's retry-on-error affordance.
|
||||||
* show a spinner. The state machine runs `valid → submitting →
|
|
||||||
* applied | rejected` (see ui/docs/order-composer.md).
|
|
||||||
*/
|
*/
|
||||||
markSubmitting(ids: string[]): void {
|
forceSync(): void {
|
||||||
|
this.scheduleSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.destroyed = true;
|
||||||
|
this.cache = null;
|
||||||
|
this.client = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleSync(): void {
|
||||||
|
if (this.client === null) return;
|
||||||
|
if (this.syncing !== null) {
|
||||||
|
this.pending = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.syncing = this.runSync().finally(() => {
|
||||||
|
this.syncing = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async runSync(): Promise<void> {
|
||||||
|
while (true) {
|
||||||
|
this.pending = false;
|
||||||
|
const client = this.client;
|
||||||
|
if (client === null || this.destroyed) return;
|
||||||
|
|
||||||
|
// Capture the snapshot up-front: the in-flight request
|
||||||
|
// reflects the draft as it was when the mutation landed,
|
||||||
|
// even if the user adds another command before the
|
||||||
|
// gateway responds.
|
||||||
|
const snapshot: OrderCommand[] = $state.snapshot(
|
||||||
|
this.commands,
|
||||||
|
) as OrderCommand[];
|
||||||
|
// Auto-sync sends every command the player still has in
|
||||||
|
// the draft except the locally-invalid ones (we can't
|
||||||
|
// expect the server to accept a name that fails our own
|
||||||
|
// validator) and the Phase 12 placeholder. `applied` and
|
||||||
|
// `rejected` entries are re-sent so the server's stored
|
||||||
|
// view always mirrors the local one — re-applying an
|
||||||
|
// already-applied command is idempotent at the engine
|
||||||
|
// level (the rename ends at the same name).
|
||||||
|
const submittable = snapshot.filter((cmd) => {
|
||||||
|
const status = this.statuses[cmd.id];
|
||||||
|
return status !== "invalid" && status !== "draft";
|
||||||
|
});
|
||||||
|
const submittingIds = submittable.map((cmd) => cmd.id);
|
||||||
|
|
||||||
|
this.markSubmittingInternal(submittingIds);
|
||||||
|
this.syncStatus = "syncing";
|
||||||
|
this.syncError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await submitOrder(
|
||||||
|
client,
|
||||||
|
this.gameId,
|
||||||
|
submittable,
|
||||||
|
{ updatedAt: this.updatedAt },
|
||||||
|
);
|
||||||
|
if (this.destroyed) return;
|
||||||
|
if (result.ok) {
|
||||||
|
this.applyResultsInternal(result.results, result.updatedAt);
|
||||||
|
// Even with `result.ok === true` an individual
|
||||||
|
// command may have been rejected by the engine
|
||||||
|
// (e.g. validation passed transcoders but failed
|
||||||
|
// the in-game rule). Surface that as an error in
|
||||||
|
// the sync bar so the player notices and can fix
|
||||||
|
// or remove the offending command.
|
||||||
|
const anyRejected = Array.from(result.results.values()).some(
|
||||||
|
(s) => s === "rejected",
|
||||||
|
);
|
||||||
|
this.syncStatus = anyRejected ? "error" : "synced";
|
||||||
|
this.syncError = anyRejected
|
||||||
|
? "engine rejected one or more commands"
|
||||||
|
: null;
|
||||||
|
} else {
|
||||||
|
this.markRejectedInternal(submittingIds);
|
||||||
|
this.syncStatus = "error";
|
||||||
|
this.syncError = result.message;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (this.destroyed) return;
|
||||||
|
this.revertSubmittingToValidInternal();
|
||||||
|
this.syncStatus = "error";
|
||||||
|
this.syncError = err instanceof Error ? err.message : "sync failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.pending) return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private markSubmittingInternal(ids: string[]): void {
|
||||||
const next = { ...this.statuses };
|
const next = { ...this.statuses };
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
|
// `applied` rows stay applied while the wire request is in
|
||||||
|
// flight — re-sending an already-applied command is a
|
||||||
|
// no-op idempotent operation, and flipping the badge back
|
||||||
|
// to `submitting` would flicker the inspector overlay.
|
||||||
|
if (next[id] === "valid" || next[id] === "rejected") {
|
||||||
next[id] = "submitting";
|
next[id] = "submitting";
|
||||||
}
|
}
|
||||||
|
}
|
||||||
this.statuses = next;
|
this.statuses = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private applyResultsInternal(
|
||||||
* applyResults merges the verdict map returned by `submitOrder`
|
results: Map<string, CommandStatus>,
|
||||||
* into the per-command status map. Entries not present in the
|
updatedAt: number,
|
||||||
* map keep their current status — useful when only a subset of
|
): void {
|
||||||
* commands round-tripped to the server. The engine-assigned
|
const liveIds = new Set(this.commands.map((cmd) => cmd.id));
|
||||||
* `updatedAt` is also stashed for the next submit's stale-order
|
|
||||||
* detection (kept as plumbing only in Phase 14).
|
|
||||||
*/
|
|
||||||
applyResults(opts: {
|
|
||||||
results: Map<string, CommandStatus>;
|
|
||||||
updatedAt: number;
|
|
||||||
}): void {
|
|
||||||
const next = { ...this.statuses };
|
const next = { ...this.statuses };
|
||||||
for (const [id, status] of opts.results.entries()) {
|
for (const [id, status] of results.entries()) {
|
||||||
|
// Drop verdicts for commands the user removed while the
|
||||||
|
// request was in flight — they are no longer in the
|
||||||
|
// draft, so re-introducing a stale `applied` row would
|
||||||
|
// confuse the order tab and the overlay.
|
||||||
|
if (!liveIds.has(id)) continue;
|
||||||
next[id] = status;
|
next[id] = status;
|
||||||
}
|
}
|
||||||
this.statuses = next;
|
this.statuses = next;
|
||||||
this.updatedAt = opts.updatedAt;
|
this.updatedAt = updatedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private markRejectedInternal(ids: string[]): void {
|
||||||
* markRejected switches every supplied id to `rejected`. Used by
|
|
||||||
* the order tab when `submitOrder` returns `ok: false` — the
|
|
||||||
* gateway didn't process any command, so the entire batch is
|
|
||||||
* treated as rejected.
|
|
||||||
*/
|
|
||||||
markRejected(ids: string[]): void {
|
|
||||||
const next = { ...this.statuses };
|
const next = { ...this.statuses };
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
next[id] = "rejected";
|
next[id] = "rejected";
|
||||||
@@ -219,13 +351,7 @@ export class OrderDraftStore {
|
|||||||
this.statuses = next;
|
this.statuses = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private revertSubmittingToValidInternal(): void {
|
||||||
* revertSubmittingToValid resets every entry currently in
|
|
||||||
* `submitting` back to its pre-submit status (typically `valid`).
|
|
||||||
* Called when the network layer throws an exception so the
|
|
||||||
* operator can retry without the rows looking stuck mid-flight.
|
|
||||||
*/
|
|
||||||
revertSubmittingToValid(): void {
|
|
||||||
const next = { ...this.statuses };
|
const next = { ...this.statuses };
|
||||||
for (const cmd of this.commands) {
|
for (const cmd of this.commands) {
|
||||||
if (next[cmd.id] === "submitting") {
|
if (next[cmd.id] === "submitting") {
|
||||||
@@ -235,11 +361,6 @@ export class OrderDraftStore {
|
|||||||
this.statuses = next;
|
this.statuses = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose(): void {
|
|
||||||
this.destroyed = true;
|
|
||||||
this.cache = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private recomputeStatuses(): void {
|
private recomputeStatuses(): void {
|
||||||
const next: Record<string, CommandStatus> = {};
|
const next: Record<string, CommandStatus> = {};
|
||||||
for (const cmd of this.commands) {
|
for (const cmd of this.commands) {
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ test("map view renders the reported turn and planet count from a live report", a
|
|||||||
"data-status",
|
"data-status",
|
||||||
"ready",
|
"ready",
|
||||||
);
|
);
|
||||||
await expect(page.getByTestId("turn-counter")).toContainText("turn 4");
|
await expect(page.getByTestId("game-shell-headline")).toContainText("turn 4");
|
||||||
await expect(page.getByTestId("map-canvas-wrap")).toHaveAttribute(
|
await expect(page.getByTestId("map-canvas-wrap")).toHaveAttribute(
|
||||||
"data-planet-count",
|
"data-planet-count",
|
||||||
"4",
|
"4",
|
||||||
@@ -207,7 +207,7 @@ test("zero-planet game renders the empty world without errors", async ({
|
|||||||
"data-status",
|
"data-status",
|
||||||
"ready",
|
"ready",
|
||||||
);
|
);
|
||||||
await expect(page.getByTestId("turn-counter")).toContainText("turn 0");
|
await expect(page.getByTestId("game-shell-headline")).toContainText("turn 0");
|
||||||
await expect(page.getByTestId("map-canvas-wrap")).toHaveAttribute(
|
await expect(page.getByTestId("map-canvas-wrap")).toHaveAttribute(
|
||||||
"data-planet-count",
|
"data-planet-count",
|
||||||
"0",
|
"0",
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ test("shell mounts with header / sidebar / active-view chrome", async ({
|
|||||||
}) => {
|
}) => {
|
||||||
await bootShell(page);
|
await bootShell(page);
|
||||||
await expect(page.getByTestId("game-shell-header")).toBeVisible();
|
await expect(page.getByTestId("game-shell-header")).toBeVisible();
|
||||||
await expect(page.getByTestId("race-name")).toContainText("race ?");
|
await expect(page.getByTestId("game-shell-headline")).toContainText(
|
||||||
await expect(page.getByTestId("turn-counter")).toContainText("turn");
|
"turn",
|
||||||
|
);
|
||||||
await expect(page.getByTestId("view-menu-trigger")).toBeVisible();
|
await expect(page.getByTestId("view-menu-trigger")).toBeVisible();
|
||||||
await expect(page.getByTestId("account-menu-trigger")).toBeVisible();
|
await expect(page.getByTestId("account-menu-trigger")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ async function clickPlanetCentre(page: Page): Promise<void> {
|
|||||||
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
|
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
test("rename a seeded planet, submit, observe overlay + persist after reload", async ({
|
test("rename a seeded planet auto-syncs and the overlay survives reload", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
test.skip(
|
test.skip(
|
||||||
@@ -241,32 +241,30 @@ test("rename a seeded planet, submit, observe overlay + persist after reload", a
|
|||||||
await input.fill("New-Earth");
|
await input.fill("New-Earth");
|
||||||
await sidebar.getByTestId("inspector-planet-rename-confirm").click();
|
await sidebar.getByTestId("inspector-planet-rename-confirm").click();
|
||||||
|
|
||||||
// Open the order tab and assert the row.
|
// Overlay applies immediately on `valid` — no Submit click is
|
||||||
|
// required because the auto-sync pipeline drives the network.
|
||||||
|
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText(
|
||||||
|
"New-Earth",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open the order tab and assert the row plus the synced status bar.
|
||||||
await page.getByTestId("sidebar-tab-order").click();
|
await page.getByTestId("sidebar-tab-order").click();
|
||||||
const orderTool = page.getByTestId("sidebar-tool-order");
|
const orderTool = page.getByTestId("sidebar-tool-order");
|
||||||
await expect(orderTool.getByTestId("order-command-label-0")).toContainText(
|
await expect(orderTool.getByTestId("order-command-label-0")).toContainText(
|
||||||
"New-Earth",
|
"New-Earth",
|
||||||
);
|
);
|
||||||
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
|
|
||||||
"valid",
|
|
||||||
);
|
|
||||||
|
|
||||||
await orderTool.getByTestId("order-submit").click();
|
|
||||||
|
|
||||||
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
|
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
|
||||||
"applied",
|
"applied",
|
||||||
);
|
);
|
||||||
|
await expect(orderTool.getByTestId("order-sync")).toHaveAttribute(
|
||||||
|
"data-sync-status",
|
||||||
|
"synced",
|
||||||
|
);
|
||||||
expect(handle.submittedRenameName).toBe("New-Earth");
|
expect(handle.submittedRenameName).toBe("New-Earth");
|
||||||
|
|
||||||
// Switch back to the inspector — overlay should reflect the new name.
|
// Reload: the layout always polls user.games.order.get on boot,
|
||||||
await page.getByTestId("sidebar-tab-inspector").click();
|
// so the overlay is rebuilt from the server's stored order even
|
||||||
await expect(sidebar.getByTestId("inspector-planet-name")).toHaveText(
|
// when the local cache was wiped.
|
||||||
"New-Earth",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Reload: the order draft is persisted; on cache-miss boots the
|
|
||||||
// hydrate-from-server path takes over. Both round-trips re-apply
|
|
||||||
// the overlay so the player still sees the renamed planet.
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
await expect(page.getByTestId("active-view-map")).toHaveAttribute(
|
||||||
"data-status",
|
"data-status",
|
||||||
@@ -303,11 +301,17 @@ test("rejected submit keeps the old name and surfaces the failure", async ({
|
|||||||
|
|
||||||
await page.getByTestId("sidebar-tab-order").click();
|
await page.getByTestId("sidebar-tab-order").click();
|
||||||
const orderTool = page.getByTestId("sidebar-tool-order");
|
const orderTool = page.getByTestId("sidebar-tool-order");
|
||||||
await orderTool.getByTestId("order-submit").click();
|
|
||||||
|
|
||||||
|
// The auto-sync pipeline reaches the server immediately after
|
||||||
|
// the inline confirm; the rejected verdict surfaces through the
|
||||||
|
// per-row status badge and the sync bar.
|
||||||
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
|
await expect(orderTool.getByTestId("order-command-status-0")).toHaveText(
|
||||||
"rejected",
|
"rejected",
|
||||||
);
|
);
|
||||||
|
await expect(orderTool.getByTestId("order-sync")).toHaveAttribute(
|
||||||
|
"data-sync-status",
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
|
||||||
await page.getByTestId("sidebar-tab-inspector").click();
|
await page.getByTestId("sidebar-tab-inspector").click();
|
||||||
// Overlay does not apply rejected commands — old name persists.
|
// Overlay does not apply rejected commands — old name persists.
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
// Component tests for the Phase 10 in-game shell header. The header
|
// Component tests for the in-game shell header. The header composes
|
||||||
// composes the static `race ?` placeholder, the placeholder
|
// the headline strip (`<race> @ <game>, turn N`, falling back to `?`
|
||||||
// turn-counter (Phase 11 wires the live source), the view-menu, and
|
// while the lobby / report calls are in flight), the view-menu, and
|
||||||
// the account-menu. The tests assert the placeholder copy, that
|
// the account-menu. The tests assert the headline copy, that every
|
||||||
// every view-menu entry dispatches `goto` with the right URL, and
|
// view-menu entry dispatches `goto` with the right URL, and that the
|
||||||
// that the Logout entry of the account-menu calls
|
// Logout entry of the account-menu calls `session.signOut("user")`.
|
||||||
// `session.signOut("user")`.
|
|
||||||
|
|
||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
import { fireEvent, render } from "@testing-library/svelte";
|
import { fireEvent, render } from "@testing-library/svelte";
|
||||||
@@ -20,6 +19,31 @@ import {
|
|||||||
import { i18n } from "../src/lib/i18n/index.svelte";
|
import { i18n } from "../src/lib/i18n/index.svelte";
|
||||||
import { session } from "../src/lib/session-store.svelte";
|
import { session } from "../src/lib/session-store.svelte";
|
||||||
import Header from "../src/lib/header/header.svelte";
|
import Header from "../src/lib/header/header.svelte";
|
||||||
|
import {
|
||||||
|
GAME_STATE_CONTEXT_KEY,
|
||||||
|
GameStateStore,
|
||||||
|
} from "../src/lib/game-state.svelte";
|
||||||
|
|
||||||
|
function withGameState(opts: {
|
||||||
|
gameName?: string;
|
||||||
|
race?: string;
|
||||||
|
turn?: number;
|
||||||
|
} = {}): Map<unknown, unknown> {
|
||||||
|
const store = new GameStateStore();
|
||||||
|
store.gameName = opts.gameName ?? "";
|
||||||
|
if (opts.race !== undefined || opts.turn !== undefined) {
|
||||||
|
store.report = {
|
||||||
|
turn: opts.turn ?? 0,
|
||||||
|
mapWidth: 1000,
|
||||||
|
mapHeight: 1000,
|
||||||
|
planetCount: 0,
|
||||||
|
planets: [],
|
||||||
|
race: opts.race ?? "",
|
||||||
|
};
|
||||||
|
store.status = "ready";
|
||||||
|
}
|
||||||
|
return new Map<unknown, unknown>([[GAME_STATE_CONTEXT_KEY, store]]);
|
||||||
|
}
|
||||||
|
|
||||||
const gotoSpy = vi.fn(async (..._args: unknown[]) => {});
|
const gotoSpy = vi.fn(async (..._args: unknown[]) => {});
|
||||||
vi.mock("$app/navigation", () => ({
|
vi.mock("$app/navigation", () => ({
|
||||||
@@ -37,19 +61,43 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("game-shell header", () => {
|
describe("game-shell header", () => {
|
||||||
test("renders the static race / turn placeholders and toggles", () => {
|
test("renders fall-back placeholders before the lobby / report data lands", () => {
|
||||||
const onToggleSidebar = vi.fn();
|
const onToggleSidebar = vi.fn();
|
||||||
const ui = render(Header, {
|
const ui = render(Header, {
|
||||||
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar },
|
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar },
|
||||||
|
context: withGameState(),
|
||||||
});
|
});
|
||||||
expect(ui.getByTestId("race-name")).toHaveTextContent("race ?");
|
expect(ui.getByTestId("game-shell-headline")).toHaveTextContent(
|
||||||
expect(ui.getByTestId("turn-counter").textContent ?? "").toMatch(
|
"? @ ?, turn ?",
|
||||||
/turn\s+\?/,
|
|
||||||
);
|
);
|
||||||
expect(ui.getByTestId("view-menu-trigger")).toBeInTheDocument();
|
expect(ui.getByTestId("view-menu-trigger")).toBeInTheDocument();
|
||||||
expect(ui.getByTestId("account-menu-trigger")).toBeInTheDocument();
|
expect(ui.getByTestId("account-menu-trigger")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("renders the live race / game / turn from GameStateStore", () => {
|
||||||
|
const ui = render(Header, {
|
||||||
|
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} },
|
||||||
|
context: withGameState({
|
||||||
|
gameName: "Phase 14",
|
||||||
|
race: "Federation",
|
||||||
|
turn: 7,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
expect(ui.getByTestId("game-shell-headline")).toHaveTextContent(
|
||||||
|
"Federation @ Phase 14, turn 7",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("partial data still falls back gracefully (race known, game unknown)", () => {
|
||||||
|
const ui = render(Header, {
|
||||||
|
props: { gameId: "g1", sidebarOpen: false, onToggleSidebar: () => {} },
|
||||||
|
context: withGameState({ race: "Federation", turn: 3 }),
|
||||||
|
});
|
||||||
|
expect(ui.getByTestId("game-shell-headline")).toHaveTextContent(
|
||||||
|
"Federation @ ?, turn 3",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("clicking the sidebar toggle invokes the prop callback", async () => {
|
test("clicking the sidebar toggle invokes the prop callback", async () => {
|
||||||
const onToggleSidebar = vi.fn();
|
const onToggleSidebar = vi.fn();
|
||||||
const ui = render(Header, {
|
const ui = render(Header, {
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ function makeReport(planets: ReportPlanet[]): GameReport {
|
|||||||
mapHeight: 1000,
|
mapHeight: 1000,
|
||||||
planetCount: planets.length,
|
planetCount: planets.length,
|
||||||
planets,
|
planets,
|
||||||
|
race: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
// Test helpers that fabricate `GalaxyClient` stand-ins for the
|
||||||
|
// auto-sync pipeline. Two flavours:
|
||||||
|
//
|
||||||
|
// - `recordingClient` — captures every `submitOrder` call and lets
|
||||||
|
// the test assert on the order of in-flight payloads. The
|
||||||
|
// outcome (`ok` / `rejected`) is settable per call so tests can
|
||||||
|
// simulate retry loops.
|
||||||
|
// - `fakeFetchClient` — wires a synthetic `user.games.order.get`
|
||||||
|
// response so `OrderDraftStore.hydrateFromServer` exercises the
|
||||||
|
// decoder against a populated FBS envelope.
|
||||||
|
//
|
||||||
|
// Both helpers live under `tests/helpers/` so they can be reused
|
||||||
|
// across `order-draft.test.ts`, `inspector-overlay.test.ts`, and
|
||||||
|
// future Phase 14+ specs.
|
||||||
|
|
||||||
|
import { Builder } from "flatbuffers";
|
||||||
|
|
||||||
|
import type { GalaxyClient } from "../../src/api/galaxy-client";
|
||||||
|
import { uuidToHiLo } from "../../src/api/game-state";
|
||||||
|
import { UUID } from "../../src/proto/galaxy/fbs/common";
|
||||||
|
import {
|
||||||
|
CommandItem,
|
||||||
|
CommandPayload,
|
||||||
|
CommandPlanetRename,
|
||||||
|
UserGamesOrder,
|
||||||
|
UserGamesOrderGetResponse,
|
||||||
|
UserGamesOrderResponse,
|
||||||
|
} from "../../src/proto/galaxy/fbs/order";
|
||||||
|
import type { OrderCommand } from "../../src/sync/order-types";
|
||||||
|
|
||||||
|
interface RecordedCall {
|
||||||
|
messageType: string;
|
||||||
|
commandIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecordingHandle {
|
||||||
|
client: GalaxyClient;
|
||||||
|
calls: RecordedCall[];
|
||||||
|
setOutcome(outcome: "ok" | "rejected"): void;
|
||||||
|
waitForCalls(n: number): Promise<void>;
|
||||||
|
waitForIdle(): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* recordingClient returns a fake GalaxyClient whose `executeCommand`
|
||||||
|
* decodes the in-flight UserGamesOrder, records the cmd_ids, and
|
||||||
|
* answers with a synthesised UserGamesOrderResponse where every
|
||||||
|
* cmdApplied is true (when outcome="ok") or false (when outcome=
|
||||||
|
* "rejected"). An optional `delayMs` simulates network latency so
|
||||||
|
* tests can exercise the coalescing path.
|
||||||
|
*/
|
||||||
|
export function recordingClient(
|
||||||
|
gameId: string,
|
||||||
|
initialOutcome: "ok" | "rejected",
|
||||||
|
options: { delayMs?: number } = {},
|
||||||
|
): RecordingHandle {
|
||||||
|
const calls: RecordedCall[] = [];
|
||||||
|
let outcome: "ok" | "rejected" = initialOutcome;
|
||||||
|
let inFlight = 0;
|
||||||
|
const waiters: (() => void)[] = [];
|
||||||
|
|
||||||
|
const client: GalaxyClient = {
|
||||||
|
async executeCommand(messageType: string, payload: Uint8Array) {
|
||||||
|
inFlight += 1;
|
||||||
|
try {
|
||||||
|
if (options.delayMs !== undefined) {
|
||||||
|
await new Promise<void>((resolve) =>
|
||||||
|
setTimeout(resolve, options.delayMs),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (messageType === "user.games.order") {
|
||||||
|
const decoded = UserGamesOrder.getRootAsUserGamesOrder(
|
||||||
|
new (await import("flatbuffers")).ByteBuffer(payload),
|
||||||
|
);
|
||||||
|
const length = decoded.commandsLength();
|
||||||
|
const commandIds: string[] = [];
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
const item = decoded.commands(i);
|
||||||
|
if (item === null) continue;
|
||||||
|
const id = item.cmdId();
|
||||||
|
if (id !== null) commandIds.push(id);
|
||||||
|
}
|
||||||
|
calls.push({ messageType, commandIds });
|
||||||
|
if (outcome === "ok") {
|
||||||
|
return {
|
||||||
|
resultCode: "ok",
|
||||||
|
payloadBytes: encodeApplied(gameId, commandIds, true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
resultCode: "invalid_request",
|
||||||
|
payloadBytes: new TextEncoder().encode(
|
||||||
|
JSON.stringify({
|
||||||
|
code: "validation_failed",
|
||||||
|
message: "rejected by fixture",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected messageType ${messageType}`);
|
||||||
|
} finally {
|
||||||
|
inFlight -= 1;
|
||||||
|
if (inFlight === 0) {
|
||||||
|
while (waiters.length > 0) {
|
||||||
|
const wake = waiters.shift();
|
||||||
|
wake?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
} as unknown as GalaxyClient;
|
||||||
|
|
||||||
|
return {
|
||||||
|
client,
|
||||||
|
calls,
|
||||||
|
setOutcome(next: "ok" | "rejected") {
|
||||||
|
outcome = next;
|
||||||
|
},
|
||||||
|
async waitForCalls(n: number) {
|
||||||
|
while (calls.length < n) {
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 5));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async waitForIdle() {
|
||||||
|
if (inFlight === 0) return;
|
||||||
|
await new Promise<void>((resolve) => waiters.push(resolve));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fakeFetchClient returns a GalaxyClient stand-in whose
|
||||||
|
* `executeCommand` answers a single hard-coded
|
||||||
|
* UserGamesOrderGetResponse — enough for `hydrateFromServer` to
|
||||||
|
* decode a realistic payload without standing up a full mock
|
||||||
|
* gateway.
|
||||||
|
*/
|
||||||
|
export function fakeFetchClient(
|
||||||
|
gameId: string,
|
||||||
|
commands: OrderCommand[],
|
||||||
|
updatedAt: number,
|
||||||
|
found = true,
|
||||||
|
): { client: GalaxyClient } {
|
||||||
|
const client: GalaxyClient = {
|
||||||
|
async executeCommand(messageType: string) {
|
||||||
|
if (messageType !== "user.games.order.get") {
|
||||||
|
throw new Error(`unexpected messageType ${messageType}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
resultCode: "ok",
|
||||||
|
payloadBytes: encodeOrderGet(gameId, commands, updatedAt, found),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
} as unknown as GalaxyClient;
|
||||||
|
return { client };
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeApplied(
|
||||||
|
gameId: string,
|
||||||
|
cmdIds: string[],
|
||||||
|
applied: boolean,
|
||||||
|
): Uint8Array {
|
||||||
|
const builder = new Builder(256);
|
||||||
|
const itemOffsets = cmdIds.map((id) => {
|
||||||
|
const cmdIdOffset = builder.createString(id);
|
||||||
|
const nameOffset = builder.createString("ignored");
|
||||||
|
const inner = CommandPlanetRename.createCommandPlanetRename(
|
||||||
|
builder,
|
||||||
|
BigInt(0),
|
||||||
|
nameOffset,
|
||||||
|
);
|
||||||
|
CommandItem.startCommandItem(builder);
|
||||||
|
CommandItem.addCmdId(builder, cmdIdOffset);
|
||||||
|
CommandItem.addCmdApplied(builder, applied);
|
||||||
|
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename);
|
||||||
|
CommandItem.addPayload(builder, inner);
|
||||||
|
return CommandItem.endCommandItem(builder);
|
||||||
|
});
|
||||||
|
const commandsVec = UserGamesOrderResponse.createCommandsVector(
|
||||||
|
builder,
|
||||||
|
itemOffsets,
|
||||||
|
);
|
||||||
|
const [hi, lo] = uuidToHiLo(gameId);
|
||||||
|
const gameIdOffset = UUID.createUUID(builder, hi, lo);
|
||||||
|
UserGamesOrderResponse.startUserGamesOrderResponse(builder);
|
||||||
|
UserGamesOrderResponse.addGameId(builder, gameIdOffset);
|
||||||
|
UserGamesOrderResponse.addUpdatedAt(builder, BigInt(Date.now()));
|
||||||
|
UserGamesOrderResponse.addCommands(builder, commandsVec);
|
||||||
|
const offset = UserGamesOrderResponse.endUserGamesOrderResponse(builder);
|
||||||
|
builder.finish(offset);
|
||||||
|
return builder.asUint8Array();
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeOrderGet(
|
||||||
|
gameId: string,
|
||||||
|
commands: OrderCommand[],
|
||||||
|
updatedAt: number,
|
||||||
|
found: boolean,
|
||||||
|
): Uint8Array {
|
||||||
|
const builder = new Builder(256);
|
||||||
|
|
||||||
|
let orderOffset = 0;
|
||||||
|
if (found) {
|
||||||
|
const itemOffsets = commands.map((cmd) => {
|
||||||
|
if (cmd.kind !== "planetRename") {
|
||||||
|
throw new Error(`unsupported command kind ${cmd.kind}`);
|
||||||
|
}
|
||||||
|
const cmdIdOffset = builder.createString(cmd.id);
|
||||||
|
const nameOffset = builder.createString(cmd.name);
|
||||||
|
const inner = CommandPlanetRename.createCommandPlanetRename(
|
||||||
|
builder,
|
||||||
|
BigInt(cmd.planetNumber),
|
||||||
|
nameOffset,
|
||||||
|
);
|
||||||
|
CommandItem.startCommandItem(builder);
|
||||||
|
CommandItem.addCmdId(builder, cmdIdOffset);
|
||||||
|
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename);
|
||||||
|
CommandItem.addPayload(builder, inner);
|
||||||
|
return CommandItem.endCommandItem(builder);
|
||||||
|
});
|
||||||
|
const commandsVec = UserGamesOrder.createCommandsVector(builder, itemOffsets);
|
||||||
|
const [hi, lo] = uuidToHiLo(gameId);
|
||||||
|
const gameIdOffset = UUID.createUUID(builder, hi, lo);
|
||||||
|
UserGamesOrder.startUserGamesOrder(builder);
|
||||||
|
UserGamesOrder.addGameId(builder, gameIdOffset);
|
||||||
|
UserGamesOrder.addUpdatedAt(builder, BigInt(updatedAt));
|
||||||
|
UserGamesOrder.addCommands(builder, commandsVec);
|
||||||
|
orderOffset = UserGamesOrder.endUserGamesOrder(builder);
|
||||||
|
}
|
||||||
|
|
||||||
|
UserGamesOrderGetResponse.startUserGamesOrderGetResponse(builder);
|
||||||
|
UserGamesOrderGetResponse.addFound(builder, found);
|
||||||
|
if (orderOffset !== 0) {
|
||||||
|
UserGamesOrderGetResponse.addOrder(builder, orderOffset);
|
||||||
|
}
|
||||||
|
const offset =
|
||||||
|
UserGamesOrderGetResponse.endUserGamesOrderGetResponse(builder);
|
||||||
|
builder.finish(offset);
|
||||||
|
return builder.asUint8Array();
|
||||||
|
}
|
||||||
@@ -7,12 +7,10 @@
|
|||||||
|
|
||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
import "fake-indexeddb/auto";
|
import "fake-indexeddb/auto";
|
||||||
import { fireEvent, render, waitFor } from "@testing-library/svelte";
|
import { render, waitFor } from "@testing-library/svelte";
|
||||||
import { Builder } from "flatbuffers";
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
|
|
||||||
import InspectorTab from "../src/lib/sidebar/inspector-tab.svelte";
|
import InspectorTab from "../src/lib/sidebar/inspector-tab.svelte";
|
||||||
import OrderTab from "../src/lib/sidebar/order-tab.svelte";
|
|
||||||
import {
|
import {
|
||||||
GAME_STATE_CONTEXT_KEY,
|
GAME_STATE_CONTEXT_KEY,
|
||||||
GameStateStore,
|
GameStateStore,
|
||||||
@@ -29,22 +27,10 @@ import {
|
|||||||
RENDERED_REPORT_CONTEXT_KEY,
|
RENDERED_REPORT_CONTEXT_KEY,
|
||||||
createRenderedReportSource,
|
createRenderedReportSource,
|
||||||
} from "../src/lib/rendered-report.svelte";
|
} from "../src/lib/rendered-report.svelte";
|
||||||
import {
|
|
||||||
GALAXY_CLIENT_CONTEXT_KEY,
|
|
||||||
GalaxyClientHolder,
|
|
||||||
} from "../src/lib/galaxy-client-context.svelte";
|
|
||||||
import { i18n } from "../src/lib/i18n/index.svelte";
|
import { i18n } from "../src/lib/i18n/index.svelte";
|
||||||
import { uuidToHiLo, type GameReport, type ReportPlanet } from "../src/api/game-state";
|
import type { GameReport, ReportPlanet } from "../src/api/game-state";
|
||||||
import type { GalaxyClient } from "../src/api/galaxy-client";
|
|
||||||
import { IDBCache } from "../src/platform/store/idb-cache";
|
import { IDBCache } from "../src/platform/store/idb-cache";
|
||||||
import { openGalaxyDB } from "../src/platform/store/idb";
|
import { openGalaxyDB } from "../src/platform/store/idb";
|
||||||
import { UUID } from "../src/proto/galaxy/fbs/common";
|
|
||||||
import {
|
|
||||||
CommandItem,
|
|
||||||
CommandPayload,
|
|
||||||
CommandPlanetRename,
|
|
||||||
UserGamesOrderResponse,
|
|
||||||
} from "../src/proto/galaxy/fbs/order";
|
|
||||||
|
|
||||||
let db: Awaited<ReturnType<typeof openGalaxyDB>>;
|
let db: Awaited<ReturnType<typeof openGalaxyDB>>;
|
||||||
let dbName: string;
|
let dbName: string;
|
||||||
@@ -93,6 +79,7 @@ function makeReport(planets: ReportPlanet[]): GameReport {
|
|||||||
mapHeight: 1000,
|
mapHeight: 1000,
|
||||||
planetCount: planets.length,
|
planetCount: planets.length,
|
||||||
planets,
|
planets,
|
||||||
|
race: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,30 +121,15 @@ describe("inspector overlay reactivity", () => {
|
|||||||
name: "New-Earth",
|
name: "New-Earth",
|
||||||
});
|
});
|
||||||
|
|
||||||
// `valid` does not participate in the overlay — the player
|
// `valid` already participates in the overlay (auto-sync may
|
||||||
// has not submitted yet, the inspector still shows the
|
// not have fired yet, but the player's intent is committed).
|
||||||
// server-side name.
|
|
||||||
expect(draft.statuses[cmdId]).toBe("valid");
|
expect(draft.statuses[cmdId]).toBe("valid");
|
||||||
expect(ui.getByTestId("inspector-planet-name")).toHaveTextContent("Earth");
|
|
||||||
|
|
||||||
draft.markSubmitting([cmdId]);
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(ui.getByTestId("inspector-planet-name")).toHaveTextContent(
|
expect(ui.getByTestId("inspector-planet-name")).toHaveTextContent(
|
||||||
"New-Earth",
|
"New-Earth",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
draft.applyResults({
|
|
||||||
results: new Map([[cmdId, "applied"] as const]),
|
|
||||||
updatedAt: 99,
|
|
||||||
});
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(draft.statuses[cmdId]).toBe("applied");
|
|
||||||
});
|
|
||||||
expect(ui.getByTestId("inspector-planet-name")).toHaveTextContent(
|
|
||||||
"New-Earth",
|
|
||||||
);
|
|
||||||
|
|
||||||
// A simulated server refresh that returns the *un-renamed*
|
// A simulated server refresh that returns the *un-renamed*
|
||||||
// snapshot must not erase the overlay (turn cutoff has not
|
// snapshot must not erase the overlay (turn cutoff has not
|
||||||
// run yet, the engine still reports the old name).
|
// run yet, the engine still reports the old name).
|
||||||
@@ -173,13 +145,39 @@ describe("inspector overlay reactivity", () => {
|
|||||||
draft.dispose();
|
draft.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("submit through the order tab applies the overlay end-to-end", async () => {
|
test("auto-sync after add applies the overlay end-to-end", async () => {
|
||||||
|
const { recordingClient } = await import("./helpers/fake-order-client");
|
||||||
|
const handle = recordingClient(GAME_ID, "ok");
|
||||||
const cache = new IDBCache(db);
|
const cache = new IDBCache(db);
|
||||||
const draft = new OrderDraftStore();
|
const draft = new OrderDraftStore();
|
||||||
await draft.init({
|
await draft.init({ cache, gameId: GAME_ID });
|
||||||
cache,
|
draft.bindClient(handle.client);
|
||||||
gameId: "11111111-2222-3333-4444-555555555555",
|
|
||||||
|
const gameState = new GameStateStore();
|
||||||
|
gameState.gameId = GAME_ID;
|
||||||
|
gameState.report = makeReport([
|
||||||
|
makePlanet({ number: 7, name: "Earth", kind: "local", size: 100 }),
|
||||||
|
]);
|
||||||
|
gameState.status = "ready";
|
||||||
|
|
||||||
|
const selection = new SelectionStore();
|
||||||
|
selection.selectPlanet(7);
|
||||||
|
const renderedReport = createRenderedReportSource(gameState, draft);
|
||||||
|
|
||||||
|
const context = new Map<unknown, unknown>([
|
||||||
|
[GAME_STATE_CONTEXT_KEY, gameState],
|
||||||
|
[SELECTION_CONTEXT_KEY, selection],
|
||||||
|
[ORDER_DRAFT_CONTEXT_KEY, draft],
|
||||||
|
[RENDERED_REPORT_CONTEXT_KEY, renderedReport],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const inspector = render(InspectorTab, { context });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(inspector.getByTestId("inspector-planet-name")).toHaveTextContent(
|
||||||
|
"Earth",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const cmdId = "00000000-0000-0000-0000-000000000abc";
|
const cmdId = "00000000-0000-0000-0000-000000000abc";
|
||||||
await draft.add({
|
await draft.add({
|
||||||
kind: "planetRename",
|
kind: "planetRename",
|
||||||
@@ -187,94 +185,28 @@ describe("inspector overlay reactivity", () => {
|
|||||||
planetNumber: 7,
|
planetNumber: 7,
|
||||||
name: "New-Earth",
|
name: "New-Earth",
|
||||||
});
|
});
|
||||||
|
// Overlay applies on `valid` immediately — auto-sync hasn't
|
||||||
const gameState = new GameStateStore();
|
// landed yet but the player's intent is committed.
|
||||||
gameState.gameId = "11111111-2222-3333-4444-555555555555";
|
|
||||||
gameState.report = makeReport([
|
|
||||||
makePlanet({ number: 7, name: "Earth", kind: "local", size: 100 }),
|
|
||||||
]);
|
|
||||||
gameState.status = "ready";
|
|
||||||
// Stub refresh to return the *un-renamed* server snapshot —
|
|
||||||
// the engine has not applied the rename yet (turn cutoff
|
|
||||||
// pending). The overlay must still show the new name.
|
|
||||||
gameState.refresh = (async () => {
|
|
||||||
gameState.report = makeReport([
|
|
||||||
makePlanet({ number: 7, name: "Earth", kind: "local", size: 100 }),
|
|
||||||
]);
|
|
||||||
}) as unknown as typeof gameState.refresh;
|
|
||||||
|
|
||||||
const selection = new SelectionStore();
|
|
||||||
selection.selectPlanet(7);
|
|
||||||
const renderedReport = createRenderedReportSource(gameState, draft);
|
|
||||||
|
|
||||||
const responsePayload = (() => {
|
|
||||||
const builder = new Builder(256);
|
|
||||||
const cmdIdOffset = builder.createString(cmdId);
|
|
||||||
const nameOffset = builder.createString("New-Earth");
|
|
||||||
const inner = CommandPlanetRename.createCommandPlanetRename(
|
|
||||||
builder,
|
|
||||||
BigInt(7),
|
|
||||||
nameOffset,
|
|
||||||
);
|
|
||||||
CommandItem.startCommandItem(builder);
|
|
||||||
CommandItem.addCmdId(builder, cmdIdOffset);
|
|
||||||
CommandItem.addCmdApplied(builder, true);
|
|
||||||
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename);
|
|
||||||
CommandItem.addPayload(builder, inner);
|
|
||||||
const item = CommandItem.endCommandItem(builder);
|
|
||||||
const commandsVec = UserGamesOrderResponse.createCommandsVector(builder, [
|
|
||||||
item,
|
|
||||||
]);
|
|
||||||
const [hi, lo] = uuidToHiLo("11111111-2222-3333-4444-555555555555");
|
|
||||||
const gameIdOffset = UUID.createUUID(builder, hi, lo);
|
|
||||||
UserGamesOrderResponse.startUserGamesOrderResponse(builder);
|
|
||||||
UserGamesOrderResponse.addGameId(builder, gameIdOffset);
|
|
||||||
UserGamesOrderResponse.addUpdatedAt(builder, BigInt(99));
|
|
||||||
UserGamesOrderResponse.addCommands(builder, commandsVec);
|
|
||||||
const offset = UserGamesOrderResponse.endUserGamesOrderResponse(builder);
|
|
||||||
builder.finish(offset);
|
|
||||||
return builder.asUint8Array();
|
|
||||||
})();
|
|
||||||
const exec = vi.fn(async () => ({
|
|
||||||
resultCode: "ok",
|
|
||||||
payloadBytes: responsePayload,
|
|
||||||
}));
|
|
||||||
const clientHolder = new GalaxyClientHolder();
|
|
||||||
clientHolder.set({ executeCommand: exec } as unknown as GalaxyClient);
|
|
||||||
|
|
||||||
const context = new Map<unknown, unknown>([
|
|
||||||
[GAME_STATE_CONTEXT_KEY, gameState],
|
|
||||||
[SELECTION_CONTEXT_KEY, selection],
|
|
||||||
[ORDER_DRAFT_CONTEXT_KEY, draft],
|
|
||||||
[RENDERED_REPORT_CONTEXT_KEY, renderedReport],
|
|
||||||
[GALAXY_CLIENT_CONTEXT_KEY, clientHolder],
|
|
||||||
]);
|
|
||||||
|
|
||||||
const inspector = render(InspectorTab, { context });
|
|
||||||
const orderTab = render(OrderTab, { context });
|
|
||||||
|
|
||||||
// Pre-submit: the inspector still shows the un-renamed snapshot.
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(inspector.getByTestId("inspector-planet-name")).toHaveTextContent(
|
|
||||||
"Earth",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const submit = orderTab.getByTestId("order-submit");
|
|
||||||
expect(submit).not.toBeDisabled();
|
|
||||||
await fireEvent.click(submit);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(draft.statuses[cmdId]).toBe("applied");
|
|
||||||
});
|
|
||||||
expect(exec).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(inspector.getByTestId("inspector-planet-name")).toHaveTextContent(
|
expect(inspector.getByTestId("inspector-planet-name")).toHaveTextContent(
|
||||||
"New-Earth",
|
"New-Earth",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await handle.waitForCalls(1);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(draft.statuses[cmdId]).toBe("applied");
|
||||||
|
});
|
||||||
|
expect(handle.calls).toHaveLength(1);
|
||||||
|
expect(handle.calls[0]!.commandIds).toEqual([cmdId]);
|
||||||
|
|
||||||
|
// Inspector still shows the new name after auto-sync.
|
||||||
|
expect(inspector.getByTestId("inspector-planet-name")).toHaveTextContent(
|
||||||
|
"New-Earth",
|
||||||
|
);
|
||||||
|
|
||||||
draft.dispose();
|
draft.dispose();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const GAME_ID = "11111111-2222-3333-4444-555555555555";
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
import "fake-indexeddb/auto";
|
import "fake-indexeddb/auto";
|
||||||
|
import { waitFor } from "@testing-library/svelte";
|
||||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
import type { IDBPDatabase } from "idb";
|
import type { IDBPDatabase } from "idb";
|
||||||
|
|
||||||
@@ -176,32 +177,6 @@ describe("OrderDraftStore", () => {
|
|||||||
reload.dispose();
|
reload.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("absent cache row flips needsServerHydration flag", async () => {
|
|
||||||
const store = new OrderDraftStore();
|
|
||||||
await store.init({ cache, gameId: GAME_ID });
|
|
||||||
expect(store.needsServerHydration).toBe(true);
|
|
||||||
store.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("explicitly empty cache row honours the user's empty draft", async () => {
|
|
||||||
const seeded = new OrderDraftStore();
|
|
||||||
await seeded.init({ cache, gameId: GAME_ID });
|
|
||||||
await seeded.add({
|
|
||||||
kind: "planetRename",
|
|
||||||
id: "00000000-0000-0000-0000-000000000001",
|
|
||||||
planetNumber: 7,
|
|
||||||
name: "Earth",
|
|
||||||
});
|
|
||||||
await seeded.remove("00000000-0000-0000-0000-000000000001");
|
|
||||||
seeded.dispose();
|
|
||||||
|
|
||||||
const reload = new OrderDraftStore();
|
|
||||||
await reload.init({ cache, gameId: GAME_ID });
|
|
||||||
expect(reload.needsServerHydration).toBe(false);
|
|
||||||
expect(reload.commands).toEqual([]);
|
|
||||||
reload.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("planetRename validates locally and statuses reflect valid/invalid", async () => {
|
test("planetRename validates locally and statuses reflect valid/invalid", async () => {
|
||||||
const store = new OrderDraftStore();
|
const store = new OrderDraftStore();
|
||||||
await store.init({ cache, gameId: GAME_ID });
|
await store.init({ cache, gameId: GAME_ID });
|
||||||
@@ -222,111 +197,200 @@ describe("OrderDraftStore", () => {
|
|||||||
store.dispose();
|
store.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("markSubmitting / applyResults flip the status map", async () => {
|
test("hydrateFromServer overwrites the local cache with the server snapshot", async () => {
|
||||||
const store = new OrderDraftStore();
|
const { fakeFetchClient } = await import("./helpers/fake-order-client");
|
||||||
await store.init({ cache, gameId: GAME_ID });
|
const { client } = fakeFetchClient(GAME_ID, [
|
||||||
await store.add({
|
{
|
||||||
kind: "planetRename",
|
kind: "planetRename",
|
||||||
id: "id-1",
|
id: "hydr-1",
|
||||||
planetNumber: 1,
|
planetNumber: 7,
|
||||||
name: "Earth",
|
name: "Hydrated",
|
||||||
});
|
|
||||||
store.markSubmitting(["id-1"]);
|
|
||||||
expect(store.statuses["id-1"]).toBe("submitting");
|
|
||||||
store.applyResults({
|
|
||||||
results: new Map([["id-1", "applied"] as const]),
|
|
||||||
updatedAt: 99,
|
|
||||||
});
|
|
||||||
expect(store.statuses["id-1"]).toBe("applied");
|
|
||||||
expect(store.updatedAt).toBe(99);
|
|
||||||
store.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("markRejected switches submitting entries to rejected", async () => {
|
|
||||||
const store = new OrderDraftStore();
|
|
||||||
await store.init({ cache, gameId: GAME_ID });
|
|
||||||
await store.add({
|
|
||||||
kind: "planetRename",
|
|
||||||
id: "id-1",
|
|
||||||
planetNumber: 1,
|
|
||||||
name: "Earth",
|
|
||||||
});
|
|
||||||
store.markSubmitting(["id-1"]);
|
|
||||||
store.markRejected(["id-1"]);
|
|
||||||
expect(store.statuses["id-1"]).toBe("rejected");
|
|
||||||
store.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("revertSubmittingToValid restores status after a thrown submit", async () => {
|
|
||||||
const store = new OrderDraftStore();
|
|
||||||
await store.init({ cache, gameId: GAME_ID });
|
|
||||||
await store.add({
|
|
||||||
kind: "planetRename",
|
|
||||||
id: "id-1",
|
|
||||||
planetNumber: 1,
|
|
||||||
name: "Earth",
|
|
||||||
});
|
|
||||||
store.markSubmitting(["id-1"]);
|
|
||||||
store.revertSubmittingToValid();
|
|
||||||
expect(store.statuses["id-1"]).toBe("valid");
|
|
||||||
store.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("hydrateFromServer seeds the draft on a fresh cache", async () => {
|
|
||||||
const fakeClient = {
|
|
||||||
executeCommand: async () => {
|
|
||||||
const { Builder } = await import("flatbuffers");
|
|
||||||
const { UUID } = await import("../src/proto/galaxy/fbs/common");
|
|
||||||
const order = await import("../src/proto/galaxy/fbs/order");
|
|
||||||
const builder = new Builder(128);
|
|
||||||
const cmdId = builder.createString("hydr-1");
|
|
||||||
const name = builder.createString("Hydrated");
|
|
||||||
const inner = order.CommandPlanetRename.createCommandPlanetRename(
|
|
||||||
builder,
|
|
||||||
BigInt(7),
|
|
||||||
name,
|
|
||||||
);
|
|
||||||
order.CommandItem.startCommandItem(builder);
|
|
||||||
order.CommandItem.addCmdId(builder, cmdId);
|
|
||||||
order.CommandItem.addPayloadType(
|
|
||||||
builder,
|
|
||||||
order.CommandPayload.CommandPlanetRename,
|
|
||||||
);
|
|
||||||
order.CommandItem.addPayload(builder, inner);
|
|
||||||
const item = order.CommandItem.endCommandItem(builder);
|
|
||||||
const cmds = order.UserGamesOrder.createCommandsVector(builder, [item]);
|
|
||||||
const [hi, lo] = (await import("../src/api/game-state")).uuidToHiLo(
|
|
||||||
GAME_ID,
|
|
||||||
);
|
|
||||||
const gameIdOffset = UUID.createUUID(builder, hi, lo);
|
|
||||||
order.UserGamesOrder.startUserGamesOrder(builder);
|
|
||||||
order.UserGamesOrder.addGameId(builder, gameIdOffset);
|
|
||||||
order.UserGamesOrder.addUpdatedAt(builder, BigInt(7));
|
|
||||||
order.UserGamesOrder.addCommands(builder, cmds);
|
|
||||||
const orderOffset = order.UserGamesOrder.endUserGamesOrder(builder);
|
|
||||||
order.UserGamesOrderGetResponse.startUserGamesOrderGetResponse(builder);
|
|
||||||
order.UserGamesOrderGetResponse.addFound(builder, true);
|
|
||||||
order.UserGamesOrderGetResponse.addOrder(builder, orderOffset);
|
|
||||||
const offset =
|
|
||||||
order.UserGamesOrderGetResponse.endUserGamesOrderGetResponse(builder);
|
|
||||||
builder.finish(offset);
|
|
||||||
return {
|
|
||||||
resultCode: "ok",
|
|
||||||
payloadBytes: builder.asUint8Array(),
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
};
|
], 7);
|
||||||
|
|
||||||
const store = new OrderDraftStore();
|
const store = new OrderDraftStore();
|
||||||
await store.init({ cache, gameId: GAME_ID });
|
await store.init({ cache, gameId: GAME_ID });
|
||||||
expect(store.needsServerHydration).toBe(true);
|
await store.hydrateFromServer({ client, turn: 5 });
|
||||||
await store.hydrateFromServer({
|
|
||||||
client: fakeClient as never,
|
|
||||||
turn: 5,
|
|
||||||
});
|
|
||||||
expect(store.commands).toHaveLength(1);
|
expect(store.commands).toHaveLength(1);
|
||||||
expect(store.commands[0]!.id).toBe("hydr-1");
|
expect(store.commands[0]!.id).toBe("hydr-1");
|
||||||
expect(store.updatedAt).toBe(7);
|
expect(store.updatedAt).toBe(7);
|
||||||
expect(store.needsServerHydration).toBe(false);
|
expect(store.statuses["hydr-1"]).toBe("applied");
|
||||||
|
expect(store.syncStatus).toBe("synced");
|
||||||
|
store.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("hydrate empties the local cache when server returns found=false", async () => {
|
||||||
|
// First seed a local draft.
|
||||||
|
const seeded = new OrderDraftStore();
|
||||||
|
await seeded.init({ cache, gameId: GAME_ID });
|
||||||
|
await seeded.add({
|
||||||
|
kind: "planetRename",
|
||||||
|
id: "stale",
|
||||||
|
planetNumber: 1,
|
||||||
|
name: "Stale",
|
||||||
|
});
|
||||||
|
seeded.dispose();
|
||||||
|
|
||||||
|
const { fakeFetchClient } = await import("./helpers/fake-order-client");
|
||||||
|
const { client } = fakeFetchClient(GAME_ID, [], 0, false);
|
||||||
|
|
||||||
|
const reload = new OrderDraftStore();
|
||||||
|
await reload.init({ cache, gameId: GAME_ID });
|
||||||
|
// Local cache shows the stale entry until the server speaks up.
|
||||||
|
expect(reload.commands).toHaveLength(1);
|
||||||
|
await reload.hydrateFromServer({ client, turn: 5 });
|
||||||
|
expect(reload.commands).toEqual([]);
|
||||||
|
expect(reload.syncStatus).toBe("synced");
|
||||||
|
reload.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("OrderDraftStore auto-sync", () => {
|
||||||
|
test("add triggers submitOrder with the full draft", async () => {
|
||||||
|
const { recordingClient } = await import("./helpers/fake-order-client");
|
||||||
|
const handle = recordingClient(GAME_ID, "ok");
|
||||||
|
const store = new OrderDraftStore();
|
||||||
|
await store.init({ cache, gameId: GAME_ID });
|
||||||
|
store.bindClient(handle.client);
|
||||||
|
|
||||||
|
await store.add({
|
||||||
|
kind: "planetRename",
|
||||||
|
id: "id-1",
|
||||||
|
planetNumber: 1,
|
||||||
|
name: "Earth",
|
||||||
|
});
|
||||||
|
|
||||||
|
await handle.waitForCalls(1);
|
||||||
|
expect(handle.calls).toHaveLength(1);
|
||||||
|
expect(handle.calls[0]!.commandIds).toEqual(["id-1"]);
|
||||||
|
expect(store.statuses["id-1"]).toBe("applied");
|
||||||
|
expect(store.syncStatus).toBe("synced");
|
||||||
|
store.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("remove of last command sends an empty cmd[] to the server", async () => {
|
||||||
|
const { recordingClient } = await import("./helpers/fake-order-client");
|
||||||
|
const handle = recordingClient(GAME_ID, "ok");
|
||||||
|
const store = new OrderDraftStore();
|
||||||
|
await store.init({ cache, gameId: GAME_ID });
|
||||||
|
store.bindClient(handle.client);
|
||||||
|
|
||||||
|
await store.add({
|
||||||
|
kind: "planetRename",
|
||||||
|
id: "id-1",
|
||||||
|
planetNumber: 1,
|
||||||
|
name: "Earth",
|
||||||
|
});
|
||||||
|
await handle.waitForCalls(1);
|
||||||
|
|
||||||
|
await store.remove("id-1");
|
||||||
|
await handle.waitForCalls(2);
|
||||||
|
expect(handle.calls[1]!.commandIds).toEqual([]);
|
||||||
|
expect(store.commands).toEqual([]);
|
||||||
|
expect(store.syncStatus).toBe("synced");
|
||||||
|
store.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rapid mutations coalesce into the latest draft", async () => {
|
||||||
|
const { recordingClient } = await import("./helpers/fake-order-client");
|
||||||
|
const handle = recordingClient(GAME_ID, "ok", { delayMs: 10 });
|
||||||
|
const store = new OrderDraftStore();
|
||||||
|
await store.init({ cache, gameId: GAME_ID });
|
||||||
|
store.bindClient(handle.client);
|
||||||
|
|
||||||
|
await store.add({
|
||||||
|
kind: "planetRename",
|
||||||
|
id: "id-1",
|
||||||
|
planetNumber: 1,
|
||||||
|
name: "Earth",
|
||||||
|
});
|
||||||
|
await store.add({
|
||||||
|
kind: "planetRename",
|
||||||
|
id: "id-2",
|
||||||
|
planetNumber: 2,
|
||||||
|
name: "Mars",
|
||||||
|
});
|
||||||
|
await store.remove("id-1");
|
||||||
|
|
||||||
|
// Wait until the store reaches a steady "synced" state. The
|
||||||
|
// in-flight first call carries [id-1], the coalesced retry
|
||||||
|
// reflects the post-remove draft.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(store.syncStatus).toBe("synced");
|
||||||
|
expect(store.statuses["id-2"]).toBe("applied");
|
||||||
|
});
|
||||||
|
expect(handle.calls.length).toBeGreaterThanOrEqual(2);
|
||||||
|
const last = handle.calls[handle.calls.length - 1]!;
|
||||||
|
expect(last.commandIds).toEqual(["id-2"]);
|
||||||
|
expect(store.statuses["id-1"]).toBeUndefined();
|
||||||
|
store.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-ok response marks every in-flight command as rejected", async () => {
|
||||||
|
const { recordingClient } = await import("./helpers/fake-order-client");
|
||||||
|
const handle = recordingClient(GAME_ID, "rejected");
|
||||||
|
const store = new OrderDraftStore();
|
||||||
|
await store.init({ cache, gameId: GAME_ID });
|
||||||
|
store.bindClient(handle.client);
|
||||||
|
|
||||||
|
await store.add({
|
||||||
|
kind: "planetRename",
|
||||||
|
id: "id-1",
|
||||||
|
planetNumber: 1,
|
||||||
|
name: "Earth",
|
||||||
|
});
|
||||||
|
await handle.waitForCalls(1);
|
||||||
|
|
||||||
|
expect(store.statuses["id-1"]).toBe("rejected");
|
||||||
|
expect(store.syncStatus).toBe("error");
|
||||||
|
store.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("forceSync re-runs the pipeline after a previous failure", async () => {
|
||||||
|
const { recordingClient } = await import("./helpers/fake-order-client");
|
||||||
|
const handle = recordingClient(GAME_ID, "rejected");
|
||||||
|
const store = new OrderDraftStore();
|
||||||
|
await store.init({ cache, gameId: GAME_ID });
|
||||||
|
store.bindClient(handle.client);
|
||||||
|
|
||||||
|
await store.add({
|
||||||
|
kind: "planetRename",
|
||||||
|
id: "id-1",
|
||||||
|
planetNumber: 1,
|
||||||
|
name: "Earth",
|
||||||
|
});
|
||||||
|
await handle.waitForCalls(1);
|
||||||
|
expect(store.syncStatus).toBe("error");
|
||||||
|
|
||||||
|
handle.setOutcome("ok");
|
||||||
|
store.forceSync();
|
||||||
|
await handle.waitForCalls(2);
|
||||||
|
expect(store.statuses["id-1"]).toBe("applied");
|
||||||
|
expect(store.syncStatus).toBe("synced");
|
||||||
|
store.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mutations made before bindClient still sync once client is bound", async () => {
|
||||||
|
const { recordingClient } = await import("./helpers/fake-order-client");
|
||||||
|
const handle = recordingClient(GAME_ID, "ok");
|
||||||
|
const store = new OrderDraftStore();
|
||||||
|
await store.init({ cache, gameId: GAME_ID });
|
||||||
|
|
||||||
|
// Mutation lands before the client is wired — the layout
|
||||||
|
// can't always sequence init → bindClient → mutate, e.g.
|
||||||
|
// when bind happens after a slow `Promise.all`.
|
||||||
|
await store.add({
|
||||||
|
kind: "planetRename",
|
||||||
|
id: "id-1",
|
||||||
|
planetNumber: 1,
|
||||||
|
name: "Earth",
|
||||||
|
});
|
||||||
|
expect(handle.calls).toHaveLength(0);
|
||||||
|
|
||||||
|
store.bindClient(handle.client);
|
||||||
|
store.forceSync();
|
||||||
|
await handle.waitForCalls(1);
|
||||||
|
expect(store.statuses["id-1"]).toBe("applied");
|
||||||
store.dispose();
|
store.dispose();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ function makeReport(planets: ReportPlanet[]): GameReport {
|
|||||||
mapHeight: 4000,
|
mapHeight: 4000,
|
||||||
planetCount: planets.length,
|
planetCount: planets.length,
|
||||||
planets,
|
planets,
|
||||||
|
race: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +84,7 @@ describe("applyOrderOverlay", () => {
|
|||||||
expect(out.planets[0]!.name).toBe("Pending");
|
expect(out.planets[0]!.name).toBe("Pending");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("skips unsubmitted statuses (draft/valid/invalid/rejected)", () => {
|
test("skips draft / invalid / rejected statuses", () => {
|
||||||
const report = makeReport([makePlanet({ number: 1, name: "Earth" })]);
|
const report = makeReport([makePlanet({ number: 1, name: "Earth" })]);
|
||||||
const cmd: OrderCommand = {
|
const cmd: OrderCommand = {
|
||||||
kind: "planetRename",
|
kind: "planetRename",
|
||||||
@@ -91,12 +92,24 @@ describe("applyOrderOverlay", () => {
|
|||||||
planetNumber: 1,
|
planetNumber: 1,
|
||||||
name: "Tentative",
|
name: "Tentative",
|
||||||
};
|
};
|
||||||
for (const status of ["draft", "valid", "invalid", "rejected"] as const) {
|
for (const status of ["draft", "invalid", "rejected"] as const) {
|
||||||
const out = applyOrderOverlay(report, [cmd], { "cmd-1": status });
|
const out = applyOrderOverlay(report, [cmd], { "cmd-1": status });
|
||||||
expect(out.planets[0]!.name).toBe("Earth");
|
expect(out.planets[0]!.name).toBe("Earth");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("applies on `valid` so the player sees their committed intent immediately", () => {
|
||||||
|
const report = makeReport([makePlanet({ number: 1, name: "Earth" })]);
|
||||||
|
const cmd: OrderCommand = {
|
||||||
|
kind: "planetRename",
|
||||||
|
id: "cmd-1",
|
||||||
|
planetNumber: 1,
|
||||||
|
name: "Pending-Sync",
|
||||||
|
};
|
||||||
|
const out = applyOrderOverlay(report, [cmd], { "cmd-1": "valid" });
|
||||||
|
expect(out.planets[0]!.name).toBe("Pending-Sync");
|
||||||
|
});
|
||||||
|
|
||||||
test("ignores rename for missing planet (visibility lost)", () => {
|
test("ignores rename for missing planet (visibility lost)", () => {
|
||||||
const report = makeReport([makePlanet({ number: 1, name: "Earth" })]);
|
const report = makeReport([makePlanet({ number: 1, name: "Earth" })]);
|
||||||
const cmd: OrderCommand = {
|
const cmd: OrderCommand = {
|
||||||
|
|||||||
+103
-147
@@ -1,53 +1,33 @@
|
|||||||
// Component coverage for the Phase 14 order-tab submit flow. Drives
|
// Component coverage for the Phase 14 order tab. The Submit button
|
||||||
// the tab against an in-memory `OrderDraftStore`, a synthetic
|
// has been retired — every successful `add` / `remove` triggers
|
||||||
// `GalaxyClient`, and a stubbed `GameStateStore.refresh`. Every
|
// `OrderDraftStore.scheduleSync`, so the tab is mostly a status
|
||||||
// case asserts both the rendered DOM (status badges, button state)
|
// surface. Tests assert the per-row status badge transitions and
|
||||||
// and the side effect on the draft store (per-command status flips).
|
// the bottom-bar sync state.
|
||||||
|
|
||||||
import "@testing-library/jest-dom/vitest";
|
import "@testing-library/jest-dom/vitest";
|
||||||
import "fake-indexeddb/auto";
|
import "fake-indexeddb/auto";
|
||||||
import { fireEvent, render, waitFor } from "@testing-library/svelte";
|
import { fireEvent, render, waitFor } from "@testing-library/svelte";
|
||||||
import { Builder } from "flatbuffers";
|
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
||||||
|
|
||||||
import OrderTab from "../src/lib/sidebar/order-tab.svelte";
|
import OrderTab from "../src/lib/sidebar/order-tab.svelte";
|
||||||
import {
|
import {
|
||||||
ORDER_DRAFT_CONTEXT_KEY,
|
ORDER_DRAFT_CONTEXT_KEY,
|
||||||
OrderDraftStore,
|
OrderDraftStore,
|
||||||
} from "../src/sync/order-draft.svelte";
|
} from "../src/sync/order-draft.svelte";
|
||||||
import {
|
|
||||||
GAME_STATE_CONTEXT_KEY,
|
|
||||||
GameStateStore,
|
|
||||||
} from "../src/lib/game-state.svelte";
|
|
||||||
import {
|
|
||||||
GALAXY_CLIENT_CONTEXT_KEY,
|
|
||||||
GalaxyClientHolder,
|
|
||||||
} from "../src/lib/galaxy-client-context.svelte";
|
|
||||||
import { i18n } from "../src/lib/i18n/index.svelte";
|
import { i18n } from "../src/lib/i18n/index.svelte";
|
||||||
import { uuidToHiLo } from "../src/api/game-state";
|
|
||||||
import type { GalaxyClient } from "../src/api/galaxy-client";
|
|
||||||
import type { OrderCommand } from "../src/sync/order-types";
|
import type { OrderCommand } from "../src/sync/order-types";
|
||||||
import { IDBCache } from "../src/platform/store/idb-cache";
|
import { IDBCache } from "../src/platform/store/idb-cache";
|
||||||
import { openGalaxyDB, type GalaxyDB } from "../src/platform/store/idb";
|
import { openGalaxyDB } from "../src/platform/store/idb";
|
||||||
import type { Cache } from "../src/platform/store/index";
|
import { recordingClient } from "./helpers/fake-order-client";
|
||||||
import { UUID } from "../src/proto/galaxy/fbs/common";
|
|
||||||
import {
|
|
||||||
CommandItem,
|
|
||||||
CommandPayload,
|
|
||||||
CommandPlanetRename,
|
|
||||||
UserGamesOrderResponse,
|
|
||||||
} from "../src/proto/galaxy/fbs/order";
|
|
||||||
|
|
||||||
const GAME_ID = "11111111-2222-3333-4444-555555555555";
|
const GAME_ID = "11111111-2222-3333-4444-555555555555";
|
||||||
|
|
||||||
let db: Awaited<ReturnType<typeof openGalaxyDB>>;
|
let db: Awaited<ReturnType<typeof openGalaxyDB>>;
|
||||||
let dbName: string;
|
let dbName: string;
|
||||||
let cache: Cache;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
dbName = `galaxy-order-tab-${crypto.randomUUID()}`;
|
dbName = `galaxy-order-tab-${crypto.randomUUID()}`;
|
||||||
db = await openGalaxyDB(dbName);
|
db = await openGalaxyDB(dbName);
|
||||||
cache = new IDBCache(db);
|
|
||||||
i18n.resetForTests("en");
|
i18n.resetForTests("en");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,162 +41,138 @@ afterEach(async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
interface Setup {
|
async function makeDraft(
|
||||||
context: Map<unknown, unknown>;
|
commands: OrderCommand[],
|
||||||
draft: OrderDraftStore;
|
): Promise<{ draft: OrderDraftStore; context: Map<unknown, unknown> }> {
|
||||||
gameState: GameStateStore;
|
const cache = new IDBCache(db);
|
||||||
clientHolder: GalaxyClientHolder;
|
|
||||||
exec: ReturnType<typeof vi.fn>;
|
|
||||||
refresh: ReturnType<typeof vi.fn>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildResponse(
|
|
||||||
commands: { id: string; applied: boolean | null; errorCode: number | null }[],
|
|
||||||
updatedAt: number,
|
|
||||||
): Uint8Array {
|
|
||||||
const builder = new Builder(256);
|
|
||||||
const itemOffsets = commands.map((c) => {
|
|
||||||
const cmdIdOffset = builder.createString(c.id);
|
|
||||||
const nameOffset = builder.createString("ignored");
|
|
||||||
const inner = CommandPlanetRename.createCommandPlanetRename(
|
|
||||||
builder,
|
|
||||||
BigInt(0),
|
|
||||||
nameOffset,
|
|
||||||
);
|
|
||||||
CommandItem.startCommandItem(builder);
|
|
||||||
CommandItem.addCmdId(builder, cmdIdOffset);
|
|
||||||
if (c.applied !== null) CommandItem.addCmdApplied(builder, c.applied);
|
|
||||||
if (c.errorCode !== null) {
|
|
||||||
CommandItem.addCmdErrorCode(builder, BigInt(c.errorCode));
|
|
||||||
}
|
|
||||||
CommandItem.addPayloadType(builder, CommandPayload.CommandPlanetRename);
|
|
||||||
CommandItem.addPayload(builder, inner);
|
|
||||||
return CommandItem.endCommandItem(builder);
|
|
||||||
});
|
|
||||||
const commandsVec = UserGamesOrderResponse.createCommandsVector(
|
|
||||||
builder,
|
|
||||||
itemOffsets,
|
|
||||||
);
|
|
||||||
const [hi, lo] = uuidToHiLo(GAME_ID);
|
|
||||||
const gameIdOffset = UUID.createUUID(builder, hi, lo);
|
|
||||||
UserGamesOrderResponse.startUserGamesOrderResponse(builder);
|
|
||||||
UserGamesOrderResponse.addGameId(builder, gameIdOffset);
|
|
||||||
UserGamesOrderResponse.addUpdatedAt(builder, BigInt(updatedAt));
|
|
||||||
UserGamesOrderResponse.addCommands(builder, commandsVec);
|
|
||||||
const offset = UserGamesOrderResponse.endUserGamesOrderResponse(builder);
|
|
||||||
builder.finish(offset);
|
|
||||||
return builder.asUint8Array();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function makeSetup(commands: OrderCommand[]): Promise<Setup> {
|
|
||||||
const draft = new OrderDraftStore();
|
const draft = new OrderDraftStore();
|
||||||
await draft.init({ cache, gameId: GAME_ID });
|
await draft.init({ cache, gameId: GAME_ID });
|
||||||
for (const cmd of commands) {
|
for (const cmd of commands) {
|
||||||
await draft.add(cmd);
|
await draft.add(cmd);
|
||||||
}
|
}
|
||||||
const gameState = new GameStateStore();
|
|
||||||
gameState.gameId = GAME_ID;
|
|
||||||
gameState.status = "ready";
|
|
||||||
const refresh = vi.fn(async () => {});
|
|
||||||
gameState.refresh = refresh as unknown as typeof gameState.refresh;
|
|
||||||
const clientHolder = new GalaxyClientHolder();
|
|
||||||
const exec = vi.fn(async (_messageType: string, _payload: Uint8Array) => ({
|
|
||||||
resultCode: "ok",
|
|
||||||
payloadBytes: buildResponse(
|
|
||||||
commands.map((cmd) => ({
|
|
||||||
id: cmd.id,
|
|
||||||
applied: true,
|
|
||||||
errorCode: null,
|
|
||||||
})),
|
|
||||||
17,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
clientHolder.set({ executeCommand: exec } as unknown as GalaxyClient);
|
|
||||||
const context = new Map<unknown, unknown>([
|
const context = new Map<unknown, unknown>([
|
||||||
[ORDER_DRAFT_CONTEXT_KEY, draft],
|
[ORDER_DRAFT_CONTEXT_KEY, draft],
|
||||||
[GAME_STATE_CONTEXT_KEY, gameState],
|
|
||||||
[GALAXY_CLIENT_CONTEXT_KEY, clientHolder],
|
|
||||||
]);
|
]);
|
||||||
return { context, draft, gameState, clientHolder, exec, refresh };
|
return { draft, context };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("order-tab", () => {
|
describe("order-tab", () => {
|
||||||
test("renders the empty state when the draft has no commands", async () => {
|
test("renders the empty state when the draft has no commands", async () => {
|
||||||
const { context } = await makeSetup([]);
|
const { draft, context } = await makeDraft([]);
|
||||||
const ui = render(OrderTab, { context });
|
const ui = render(OrderTab, { context });
|
||||||
expect(ui.getByTestId("order-empty")).toBeVisible();
|
expect(ui.getByTestId("order-empty")).toBeVisible();
|
||||||
expect(ui.queryByTestId("order-submit")).toBeNull();
|
expect(ui.queryByTestId("order-list")).toBeNull();
|
||||||
|
// The sync bar still renders so the user can see the
|
||||||
|
// idle / synced / error transitions.
|
||||||
|
expect(ui.getByTestId("order-sync")).toBeVisible();
|
||||||
|
draft.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Submit is disabled when every entry is invalid", async () => {
|
test("invalid command shows the invalid status badge", async () => {
|
||||||
const { context } = await makeSetup([
|
const { draft, context } = await makeDraft([
|
||||||
{ kind: "planetRename", id: "id-1", planetNumber: 1, name: "" },
|
{ kind: "planetRename", id: "id-1", planetNumber: 1, name: "" },
|
||||||
]);
|
]);
|
||||||
const ui = render(OrderTab, { context });
|
const ui = render(OrderTab, { context });
|
||||||
const submit = ui.getByTestId("order-submit");
|
|
||||||
expect(submit).toBeDisabled();
|
|
||||||
expect(ui.getByTestId("order-command-status-0")).toHaveTextContent(
|
expect(ui.getByTestId("order-command-status-0")).toHaveTextContent(
|
||||||
"invalid",
|
"invalid",
|
||||||
);
|
);
|
||||||
|
draft.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Submit posts every valid command and applies returned statuses", async () => {
|
test("auto-sync flips the row to applied and the sync bar to synced", async () => {
|
||||||
const { context, draft, exec, refresh } = await makeSetup([
|
const handle = recordingClient(GAME_ID, "ok");
|
||||||
{ kind: "planetRename", id: "id-1", planetNumber: 1, name: "Earth" },
|
const { draft, context } = await makeDraft([]);
|
||||||
]);
|
draft.bindClient(handle.client);
|
||||||
|
|
||||||
const ui = render(OrderTab, { context });
|
const ui = render(OrderTab, { context });
|
||||||
const submit = ui.getByTestId("order-submit");
|
|
||||||
expect(submit).not.toBeDisabled();
|
|
||||||
expect(ui.getByTestId("order-command-status-0")).toHaveTextContent("valid");
|
|
||||||
|
|
||||||
await fireEvent.click(submit);
|
await draft.add({
|
||||||
|
kind: "planetRename",
|
||||||
await waitFor(() => {
|
id: "id-1",
|
||||||
expect(draft.statuses["id-1"]).toBe("applied");
|
planetNumber: 1,
|
||||||
|
name: "Earth",
|
||||||
});
|
});
|
||||||
expect(exec).toHaveBeenCalledTimes(1);
|
|
||||||
expect(refresh).toHaveBeenCalledTimes(1);
|
await handle.waitForCalls(1);
|
||||||
|
await waitFor(() => {
|
||||||
expect(ui.getByTestId("order-command-status-0")).toHaveTextContent(
|
expect(ui.getByTestId("order-command-status-0")).toHaveTextContent(
|
||||||
"applied",
|
"applied",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Non-ok response marks every submitting entry as rejected", async () => {
|
|
||||||
const { context, draft, refresh } = await makeSetup([
|
|
||||||
{ kind: "planetRename", id: "id-1", planetNumber: 1, name: "Earth" },
|
|
||||||
]);
|
|
||||||
const exec = vi.fn(async () => ({
|
|
||||||
resultCode: "invalid_request",
|
|
||||||
payloadBytes: new TextEncoder().encode(
|
|
||||||
JSON.stringify({ code: "boom", message: "down" }),
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
const holder = context.get(GALAXY_CLIENT_CONTEXT_KEY) as GalaxyClientHolder;
|
|
||||||
holder.set({ executeCommand: exec } as unknown as GalaxyClient);
|
|
||||||
|
|
||||||
const ui = render(OrderTab, { context });
|
|
||||||
await fireEvent.click(ui.getByTestId("order-submit"));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(draft.statuses["id-1"]).toBe("rejected");
|
expect(ui.getByTestId("order-sync")).toHaveAttribute(
|
||||||
|
"data-sync-status",
|
||||||
|
"synced",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
expect(refresh).not.toHaveBeenCalled();
|
expect(handle.calls).toHaveLength(1);
|
||||||
expect(ui.getByTestId("order-submit-error")).toHaveTextContent("down");
|
expect(handle.calls[0]!.commandIds).toEqual(["id-1"]);
|
||||||
|
draft.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Already-applied entries do not get re-submitted", async () => {
|
test("removing the last command sends an empty cmd[] PUT", async () => {
|
||||||
const { context, draft, exec } = await makeSetup([
|
const handle = recordingClient(GAME_ID, "ok");
|
||||||
{ kind: "planetRename", id: "id-1", planetNumber: 1, name: "Earth" },
|
const { draft, context } = await makeDraft([]);
|
||||||
]);
|
draft.bindClient(handle.client);
|
||||||
draft.markSubmitting(["id-1"]);
|
|
||||||
draft.applyResults({
|
await draft.add({
|
||||||
results: new Map([["id-1", "applied"] as const]),
|
kind: "planetRename",
|
||||||
updatedAt: 1,
|
id: "id-1",
|
||||||
|
planetNumber: 1,
|
||||||
|
name: "Earth",
|
||||||
});
|
});
|
||||||
|
await handle.waitForCalls(1);
|
||||||
|
|
||||||
const ui = render(OrderTab, { context });
|
const ui = render(OrderTab, { context });
|
||||||
const submit = ui.getByTestId("order-submit");
|
await fireEvent.click(ui.getByTestId("order-command-delete-0"));
|
||||||
expect(submit).toBeDisabled();
|
|
||||||
expect(exec).not.toHaveBeenCalled();
|
await handle.waitForCalls(2);
|
||||||
|
expect(handle.calls[1]!.commandIds).toEqual([]);
|
||||||
|
expect(draft.commands).toEqual([]);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(ui.getByTestId("order-empty")).toBeVisible();
|
||||||
|
});
|
||||||
|
draft.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("non-ok response surfaces the sync error and a retry button", async () => {
|
||||||
|
const handle = recordingClient(GAME_ID, "rejected");
|
||||||
|
const { draft, context } = await makeDraft([]);
|
||||||
|
draft.bindClient(handle.client);
|
||||||
|
|
||||||
|
const ui = render(OrderTab, { context });
|
||||||
|
await draft.add({
|
||||||
|
kind: "planetRename",
|
||||||
|
id: "id-1",
|
||||||
|
planetNumber: 1,
|
||||||
|
name: "Earth",
|
||||||
|
});
|
||||||
|
|
||||||
|
await handle.waitForCalls(1);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(ui.getByTestId("order-sync")).toHaveAttribute(
|
||||||
|
"data-sync-status",
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(ui.getByTestId("order-sync-retry")).toBeVisible();
|
||||||
|
expect(ui.getByTestId("order-command-status-0")).toHaveTextContent(
|
||||||
|
"rejected",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Retry the call now that the fixture answers ok.
|
||||||
|
handle.setOutcome("ok");
|
||||||
|
await fireEvent.click(ui.getByTestId("order-sync-retry"));
|
||||||
|
await handle.waitForCalls(2);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(draft.statuses["id-1"]).toBe("applied");
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(ui.getByTestId("order-sync")).toHaveAttribute(
|
||||||
|
"data-sync-status",
|
||||||
|
"synced",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
draft.dispose();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ function makeReport(overrides: Partial<GameReport> = {}): GameReport {
|
|||||||
mapHeight: 4000,
|
mapHeight: 4000,
|
||||||
planetCount: 0,
|
planetCount: 0,
|
||||||
planets: [],
|
planets: [],
|
||||||
|
race: "",
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user